diff options
| author | Adam <[email protected]> | 2026-01-04 15:40:25 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-22 22:12:12 -0600 |
| commit | 640d1f1ecc7a2b46fb2bafed760c7348c70579a8 (patch) | |
| tree | 090f22b0e98053e7089133f164b17cff0367daa6 | |
| parent | 2e53697da01d1417845567296774166350e786f1 (diff) | |
| download | opencode-640d1f1ecc7a2b46fb2bafed760c7348c70579a8.tar.gz opencode-640d1f1ecc7a2b46fb2bafed760c7348c70579a8.zip | |
wip(app): line selection
| -rw-r--r-- | packages/app/src/components/prompt-input.tsx | 43 | ||||
| -rw-r--r-- | packages/app/src/components/session/session-context-tab.tsx | 12 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 598 | ||||
| -rw-r--r-- | packages/ui/src/components/code.tsx | 272 | ||||
| -rw-r--r-- | packages/ui/src/components/diff.tsx | 97 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.tsx | 2 | ||||
| -rw-r--r-- | packages/ui/src/pierre/index.ts | 21 |
7 files changed, 786 insertions, 259 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 44a1db253..0d6a7641a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -15,7 +15,7 @@ import { import { createStore, produce } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { useFile, type FileSelection } from "@/context/file" +import { selectionFromLines, useFile, type FileSelection } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, @@ -163,6 +163,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (!tab) return return files.pathFromTab(tab) }) + + const activeFileSelection = createMemo(() => { + const path = activeFile() + if (!path) return + const range = files.selectedLines(path) + if (!range) return + return selectionFromLines(range) + }) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const status = createMemo( () => @@ -1256,7 +1264,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const activePath = activeFile() if (activePath && prompt.context.activeTab()) { - addContextFile(activePath) + addContextFile(activePath, activeFileSelection()) } for (const item of prompt.context.items()) { @@ -1476,22 +1484,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </div> </div> </Show> - <Show when={false && (prompt.context.items().length > 0 || !!activeFile())}> - <div class="flex flex-wrap items-center gap-2 px-3 pt-3"> + <Show when={prompt.context.items().length > 0 || !!activeFile()}> + <div class="flex flex-wrap items-center gap-1.5 px-3 pt-3"> <Show when={prompt.context.activeTab() ? activeFile() : undefined}> {(path) => ( - <div class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base max-w-full"> - <FileIcon node={{ path: path(), type: "file" }} class="shrink-0 size-4" /> - <div class="flex items-center text-12-regular min-w-0"> + <div class="flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base max-w-full"> + <FileIcon node={{ path: path(), type: "file" }} class="shrink-0 size-3.5" /> + <div class="flex items-center text-11-regular min-w-0"> <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(path())}</span> <span class="text-text-strong whitespace-nowrap">{getFilename(path())}</span> + <Show when={activeFileSelection()}> + {(sel) => ( + <span class="text-text-weak whitespace-nowrap ml-1"> + {sel().startLine === sel().endLine + ? `:${sel().startLine}` + : `:${sel().startLine}-${sel().endLine}`} + </span> + )} + </Show> <span class="text-text-weak whitespace-nowrap ml-1">{language.t("prompt.context.active")}</span> </div> <IconButton type="button" icon="close" variant="ghost" - class="h-6 w-6" + class="h-5 w-5" onClick={() => prompt.context.removeActive()} aria-label={language.t("prompt.context.removeActiveFile")} /> @@ -1501,7 +1518,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <Show when={!prompt.context.activeTab() && !!activeFile()}> <button type="button" - class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-weak hover:bg-surface-raised-base-hover" + class="flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base text-11-regular text-text-weak hover:bg-surface-raised-base-hover" onClick={() => prompt.context.addActive()} > <Icon name="plus-small" size="small" /> @@ -1510,9 +1527,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </Show> <For each={prompt.context.items()}> {(item) => ( - <div class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base max-w-full"> - <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" /> - <div class="flex items-center text-12-regular min-w-0"> + <div class="flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base max-w-full"> + <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" /> + <div class="flex items-center text-11-regular min-w-0"> <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span> <span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span> <Show when={item.selection}> @@ -1529,7 +1546,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { type="button" icon="close" variant="ghost" - class="h-6 w-6" + class="h-5 w-5" onClick={() => prompt.context.remove(item.key)} aria-label={language.t("prompt.context.removeFile")} /> diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index b41578910..57648c380 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -282,7 +282,9 @@ export function SessionContextTab(props: SessionContextTabProps) { } }) - return <Code file={file()} overflow="wrap" class="select-text" /> + return ( + <Code file={file()} overflow="wrap" class="select-text" onRendered={() => requestAnimationFrame(restoreScroll)} /> + ) } function RawMessage(msgProps: { message: Message }) { @@ -314,19 +316,13 @@ export function SessionContextTab(props: SessionContextTabProps) { let frame: number | undefined let pending: { x: number; y: number } | undefined - const restoreScroll = (retries = 0) => { + const restoreScroll = () => { const el = scroll if (!el) return const s = props.view()?.scroll("context") if (!s) return - // Wait for content to be scrollable - content may not have rendered yet - if (el.scrollHeight <= el.clientHeight && retries < 10) { - requestAnimationFrame(() => restoreScroll(retries + 1)) - return - } - if (el.scrollTop !== s.y) el.scrollTop = s.y if (el.scrollLeft !== s.x) el.scrollLeft = s.x } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 719e13f00..ad6d360dc 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -15,7 +15,7 @@ import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" import { useLocal } from "@/context/local" -import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file" +import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" import { SessionContextUsage } from "@/components/session-context-usage" @@ -102,19 +102,13 @@ function SessionReviewTab(props: SessionReviewTabProps) { .catch(() => undefined) } - const restoreScroll = (retries = 0) => { + const restoreScroll = () => { const el = scroll if (!el) return const s = props.view().scroll("review") if (!s) return - // Wait for content to be scrollable - content may not have rendered yet - if (el.scrollHeight <= el.clientHeight && retries < 10) { - requestAnimationFrame(() => restoreScroll(retries + 1)) - return - } - if (el.scrollTop !== s.y) el.scrollTop = s.y if (el.scrollLeft !== s.x) el.scrollLeft = s.x } @@ -159,6 +153,7 @@ function SessionReviewTab(props: SessionReviewTabProps) { restoreScroll() }} onScroll={handleScroll} + onDiffRendered={() => requestAnimationFrame(restoreScroll)} open={props.view().review.open()} onOpenChange={props.view().review.setOpen} classes={{ @@ -192,7 +187,6 @@ export default function Page() { const prompt = usePrompt() const permission = usePermission() const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined) - const [pendingHash, setPendingHash] = createSignal<string | undefined>(undefined) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) const view = createMemo(() => layout.view(sessionKey())) @@ -494,38 +488,73 @@ export default function Page() { setStore("expanded", id, status().type !== "idle") }) + const addSelectionToContext = (path: string, selection: FileSelection) => { + prompt.context.add({ type: "file", path, selection }) + } + command.register(() => [ { id: "session.new", - title: language.t("command.session.new"), - category: language.t("command.category.session"), + title: "New session", + category: "Session", keybind: "mod+shift+s", slash: "new", onSelect: () => navigate(`/${params.dir}/session`), }, { id: "file.open", - title: language.t("command.file.open"), - description: language.t("command.file.open.description"), - category: language.t("command.category.file"), + title: "Open file", + description: "Search files and commands", + category: "File", keybind: "mod+p", slash: "open", onSelect: () => dialog.show(() => <DialogSelectFile />), }, { + id: "context.addSelection", + title: "Add selection to context", + description: "Add selected lines from the current file", + category: "Context", + keybind: "mod+shift+l", + disabled: (() => { + const active = tabs().active() + if (!active) return true + const path = file.pathFromTab(active) + if (!path) return true + return file.selectedLines(path) == null + })(), + onSelect: () => { + const active = tabs().active() + if (!active) return + const path = file.pathFromTab(active) + if (!path) return + + const range = file.selectedLines(path) + if (!range) { + showToast({ + title: "No line selection", + description: "Select a line range in a file tab first.", + }) + return + } + + addSelectionToContext(path, selectionFromLines(range)) + }, + }, + { id: "terminal.toggle", - title: language.t("command.terminal.toggle"), + title: "Toggle terminal", description: "", - category: language.t("command.category.view"), + category: "View", keybind: "ctrl+`", slash: "terminal", onSelect: () => view().terminal.toggle(), }, { id: "review.toggle", - title: language.t("command.review.toggle"), + title: "Toggle review", description: "", - category: language.t("command.category.view"), + category: "View", keybind: "mod+shift+r", onSelect: () => view().reviewPanel.toggle(), }, @@ -542,9 +571,9 @@ export default function Page() { }, { id: "steps.toggle", - title: language.t("command.steps.toggle"), - description: language.t("command.steps.toggle.description"), - category: language.t("command.category.view"), + title: "Toggle steps", + description: "Show or hide steps for the current message", + category: "View", keybind: "mod+e", slash: "steps", disabled: !params.id, @@ -556,62 +585,62 @@ export default function Page() { }, { id: "message.previous", - title: language.t("command.message.previous"), - description: language.t("command.message.previous.description"), - category: language.t("command.category.session"), + title: "Previous message", + description: "Go to the previous user message", + category: "Session", keybind: "mod+arrowup", disabled: !params.id, onSelect: () => navigateMessageByOffset(-1), }, { id: "message.next", - title: language.t("command.message.next"), - description: language.t("command.message.next.description"), - category: language.t("command.category.session"), + title: "Next message", + description: "Go to the next user message", + category: "Session", keybind: "mod+arrowdown", disabled: !params.id, onSelect: () => navigateMessageByOffset(1), }, { id: "model.choose", - title: language.t("command.model.choose"), - description: language.t("command.model.choose.description"), - category: language.t("command.category.model"), + title: "Choose model", + description: "Select a different model", + category: "Model", keybind: "mod+'", slash: "model", onSelect: () => dialog.show(() => <DialogSelectModel />), }, { id: "mcp.toggle", - title: language.t("command.mcp.toggle"), - description: language.t("command.mcp.toggle.description"), - category: language.t("command.category.mcp"), + title: "Toggle MCPs", + description: "Toggle MCPs", + category: "MCP", keybind: "mod+;", slash: "mcp", onSelect: () => dialog.show(() => <DialogSelectMcp />), }, { id: "agent.cycle", - title: language.t("command.agent.cycle"), - description: language.t("command.agent.cycle.description"), - category: language.t("command.category.agent"), + title: "Cycle agent", + description: "Switch to the next agent", + category: "Agent", keybind: "mod+.", slash: "agent", onSelect: () => local.agent.move(1), }, { id: "agent.cycle.reverse", - title: language.t("command.agent.cycle.reverse"), - description: language.t("command.agent.cycle.reverse.description"), - category: language.t("command.category.agent"), + title: "Cycle agent backwards", + description: "Switch to the previous agent", + category: "Agent", keybind: "shift+mod+.", onSelect: () => local.agent.move(-1), }, { id: "model.variant.cycle", - title: language.t("command.model.variant.cycle"), - description: language.t("command.model.variant.cycle.description"), - category: language.t("command.category.model"), + title: "Cycle thinking effort", + description: "Switch to the next effort level", + category: "Model", keybind: "shift+mod+d", onSelect: () => { local.model.variant.cycle() @@ -621,31 +650,30 @@ export default function Page() { id: "permissions.autoaccept", title: params.id && permission.isAutoAccepting(params.id, sdk.directory) - ? language.t("command.permissions.autoaccept.disable") - : language.t("command.permissions.autoaccept.enable"), - category: language.t("command.category.permissions"), + ? "Stop auto-accepting edits" + : "Auto-accept edits", + category: "Permissions", keybind: "mod+shift+a", disabled: !params.id || !permission.permissionsEnabled(), onSelect: () => { const sessionID = params.id if (!sessionID) return permission.toggleAutoAccept(sessionID, sdk.directory) - const enabled = permission.isAutoAccepting(sessionID, sdk.directory) showToast({ - title: enabled - ? language.t("toast.permissions.autoaccept.on.title") - : language.t("toast.permissions.autoaccept.off.title"), - description: enabled - ? language.t("toast.permissions.autoaccept.on.description") - : language.t("toast.permissions.autoaccept.off.description"), + title: permission.isAutoAccepting(sessionID, sdk.directory) + ? "Auto-accepting edits" + : "Stopped auto-accepting edits", + description: permission.isAutoAccepting(sessionID, sdk.directory) + ? "Edit and write permissions will be automatically approved" + : "Edit and write permissions will require approval", }) }, }, { id: "session.undo", - title: language.t("command.session.undo"), - description: language.t("command.session.undo.description"), - category: language.t("command.category.session"), + title: "Undo", + description: "Undo the last message", + category: "Session", slash: "undo", disabled: !params.id || visibleUserMessages().length === 0, onSelect: async () => { @@ -662,10 +690,7 @@ export default function Page() { // Restore the prompt from the reverted message const parts = sync.data.part[message.id] if (parts) { - const restored = extractPromptFromParts(parts, { - directory: sdk.directory, - attachmentName: language.t("common.attachment"), - }) + const restored = extractPromptFromParts(parts, { directory: sdk.directory }) prompt.set(restored) } // Navigate to the message before the reverted one (which will be the new last visible message) @@ -675,9 +700,9 @@ export default function Page() { }, { id: "session.redo", - title: language.t("command.session.redo"), - description: language.t("command.session.redo.description"), - category: language.t("command.category.session"), + title: "Redo", + description: "Redo the last undone message", + category: "Session", slash: "redo", disabled: !params.id || !info()?.revert?.messageID, onSelect: async () => { @@ -704,9 +729,9 @@ export default function Page() { }, { id: "session.compact", - title: language.t("command.session.compact"), - description: language.t("command.session.compact.description"), - category: language.t("command.category.session"), + title: "Compact session", + description: "Summarize the session to reduce context size", + category: "Session", slash: "compact", disabled: !params.id || visibleUserMessages().length === 0, onSelect: async () => { @@ -715,8 +740,8 @@ export default function Page() { const model = local.model.current() if (!model) { showToast({ - title: language.t("toast.model.none.title"), - description: language.t("toast.model.none.description"), + title: "No model selected", + description: "Connect a provider to summarize this session", }) return } @@ -729,9 +754,9 @@ export default function Page() { }, { id: "session.fork", - title: language.t("command.session.fork"), - description: language.t("command.session.fork.description"), - category: language.t("command.category.session"), + title: "Fork from message", + description: "Create a new session from a previous message", + category: "Session", slash: "fork", disabled: !params.id || visibleUserMessages().length === 0, onSelect: () => dialog.show(() => <DialogFork />), @@ -740,9 +765,9 @@ export default function Page() { ? [ { id: "session.share", - title: language.t("command.session.share"), - description: language.t("command.session.share.description"), - category: language.t("command.category.session"), + title: "Share session", + description: "Share this session and copy the URL to clipboard", + category: "Session", slash: "share", disabled: !params.id || !!info()?.share?.url, onSelect: async () => { @@ -752,22 +777,22 @@ export default function Page() { .then((res) => { navigator.clipboard.writeText(res.data!.share!.url).catch(() => showToast({ - title: language.t("toast.session.share.copyFailed.title"), + title: "Failed to copy URL to clipboard", variant: "error", }), ) }) .then(() => showToast({ - title: language.t("toast.session.share.success.title"), - description: language.t("toast.session.share.success.description"), + title: "Session shared", + description: "Share URL copied to clipboard!", variant: "success", }), ) .catch(() => showToast({ - title: language.t("toast.session.share.failed.title"), - description: language.t("toast.session.share.failed.description"), + title: "Failed to share session", + description: "An error occurred while sharing the session", variant: "error", }), ) @@ -775,9 +800,9 @@ export default function Page() { }, { id: "session.unshare", - title: language.t("command.session.unshare"), - description: language.t("command.session.unshare.description"), - category: language.t("command.category.session"), + title: "Unshare session", + description: "Stop sharing this session", + category: "Session", slash: "unshare", disabled: !params.id || !info()?.share?.url, onSelect: async () => { @@ -786,15 +811,15 @@ export default function Page() { .unshare({ sessionID: params.id }) .then(() => showToast({ - title: language.t("toast.session.unshare.success.title"), - description: language.t("toast.session.unshare.success.description"), + title: "Session unshared", + description: "Session unshared successfully!", variant: "success", }), ) .catch(() => showToast({ - title: language.t("toast.session.unshare.failed.title"), - description: language.t("toast.session.unshare.failed.description"), + title: "Failed to unshare session", + description: "An error occurred while unsharing the session", variant: "error", }), ) @@ -1093,39 +1118,63 @@ export default function Page() { const a = el.getBoundingClientRect() const b = root.getBoundingClientRect() - const offset = (info()?.title ? 40 : 0) + 12 - const top = a.top - b.top + root.scrollTop - offset - root.scrollTo({ top: top > 0 ? top : 0, behavior }) + const top = a.top - b.top + root.scrollTop + root.scrollTo({ top, behavior }) return true } const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { - // Navigating to a specific message should always pause auto-follow. - autoScroll.pause() setActiveMessage(message) - updateHash(message.id) const msgs = visibleUserMessages() const index = msgs.findIndex((m) => m.id === message.id) if (index !== -1 && index < store.turnStart) { setStore("turnStart", index) scheduleTurnBackfill() + + requestAnimationFrame(() => { + const el = document.getElementById(anchor(message.id)) + if (!el) { + requestAnimationFrame(() => { + const next = document.getElementById(anchor(message.id)) + if (!next) return + scrollToElement(next, behavior) + }) + return + } + scrollToElement(el, behavior) + }) + + updateHash(message.id) + return } - const id = anchor(message.id) - const attempt = (tries: number) => { - const el = document.getElementById(id) - if (el && scrollToElement(el, behavior)) return - if (tries >= 8) return - requestAnimationFrame(() => attempt(tries + 1)) + const el = document.getElementById(anchor(message.id)) + if (!el) { + updateHash(message.id) + requestAnimationFrame(() => { + const next = document.getElementById(anchor(message.id)) + if (!next) return + if (!scrollToElement(next, behavior)) return + }) + return + } + if (scrollToElement(el, behavior)) { + updateHash(message.id) + return } - attempt(0) + + requestAnimationFrame(() => { + const next = document.getElementById(anchor(message.id)) + if (!next) return + if (!scrollToElement(next, behavior)) return + }) + updateHash(message.id) } const applyHash = (behavior: ScrollBehavior) => { const hash = window.location.hash.slice(1) if (!hash) { - setPendingHash(undefined) autoScroll.forceScrollToBottom() return } @@ -1134,25 +1183,21 @@ export default function Page() { if (match) { const msg = visibleUserMessages().find((m) => m.id === match[1]) if (msg) { - setPendingHash(undefined) scrollToMessage(msg, behavior) return } // If we have a message hash but the message isn't loaded/rendered yet, // don't fall back to "bottom". We'll retry once messages arrive. - setPendingHash(match[1]) return } const target = document.getElementById(hash) if (target) { - setPendingHash(undefined) scrollToElement(target, behavior) return } - setPendingHash(undefined) autoScroll.forceScrollToBottom() } @@ -1210,14 +1255,20 @@ export default function Page() { visibleUserMessages().length store.turnStart - const targetId = pendingMessage() ?? pendingHash() + const targetId = + pendingMessage() ?? + (() => { + const hash = window.location.hash.slice(1) + const match = hash.match(/^message-(.+)$/) + if (!match) return undefined + return match[1] + })() if (!targetId) return if (store.messageId === targetId) return const msg = visibleUserMessages().find((m) => m.id === targetId) if (!msg) return if (pendingMessage() === targetId) setPendingMessage(undefined) - if (pendingHash() === targetId) setPendingHash(undefined) requestAnimationFrame(() => scrollToMessage(msg, "auto")) }) @@ -1305,7 +1356,7 @@ export default function Page() { classes={{ button: "w-full" }} onClick={() => setStore("mobileTab", "session")} > - {language.t("session.tab.session")} + Session </Tabs.Trigger> <Tabs.Trigger value="review" @@ -1314,10 +1365,8 @@ export default function Page() { onClick={() => setStore("mobileTab", "review")} > <Switch> - <Match when={hasReview()}> - {language.t("session.review.filesChanged", { count: reviewCount() })} - </Match> - <Match when={true}>{language.t("session.tab.review")}</Match> + <Match when={hasReview()}>{reviewCount()} Files Changed</Match> + <Match when={true}>Review</Match> </Switch> </Tabs.Trigger> </Tabs.List> @@ -1347,11 +1396,7 @@ export default function Page() { <Match when={hasReview()}> <Show when={diffsReady()} - fallback={ - <div class="px-4 py-4 text-text-weak"> - {language.t("session.review.loadingChanges")} - </div> - } + fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>} > <SessionReviewTab diffs={diffs} @@ -1373,9 +1418,7 @@ export default function Page() { <Match when={true}> <div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6"> <Mark class="w-14 opacity-10" /> - <div class="text-14-regular text-text-weak max-w-56"> - {language.t("session.review.empty")} - </div> + <div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div> </div> </Match> </Switch> @@ -1413,8 +1456,9 @@ export default function Page() { if (!hasScrollGesture()) return markScrollGesture(e.target) autoScroll.handleScroll() - if (isDesktop() && autoScroll.userScrolled()) scheduleScrollSpy(e.currentTarget) + if (isDesktop()) scheduleScrollSpy(e.currentTarget) }} + onClick={autoScroll.handleInteraction} class="relative min-w-0 w-full h-full overflow-y-auto session-scroller" style={{ "--session-title-height": info()?.title ? "40px" : "0px" }} > @@ -1452,7 +1496,7 @@ export default function Page() { class="text-12-medium opacity-50" onClick={() => setStore("turnStart", 0)} > - {language.t("session.messages.renderEarlier")} + Render earlier messages </Button> </div> </Show> @@ -1470,9 +1514,7 @@ export default function Page() { sync.session.history.loadMore(id) }} > - {historyLoading() - ? language.t("session.messages.loadingEarlier") - : language.t("session.messages.loadEarlier")} + {historyLoading() ? "Loading earlier messages..." : "Load earlier messages"} </Button> </div> </Show> @@ -1556,7 +1598,7 @@ export default function Page() { when={prompt.ready()} fallback={ <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"> - {handoff.prompt || language.t("prompt.loading")} + {handoff.prompt || "Loading prompt..."} </div> } > @@ -1608,7 +1650,7 @@ export default function Page() { <DiffChanges changes={diffs()} variant="bars" /> </Show> <div class="flex items-center gap-1.5"> - <div>{language.t("session.tab.review")}</div> + <div>Review</div> <Show when={info()?.summary?.files}> <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base"> {info()?.summary?.files ?? 0} @@ -1636,7 +1678,7 @@ export default function Page() { > <div class="flex items-center gap-2"> <SessionContextUsage variant="indicator" /> - <div>{language.t("session.tab.context")}</div> + <div>Context</div> </div> </Tabs.Trigger> </Show> @@ -1645,7 +1687,7 @@ export default function Page() { </SortableProvider> <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3"> <TooltipKeybind - title={language.t("command.file.open")} + title="Open file" keybind={command.keybind("file.open")} class="flex items-center" > @@ -1668,11 +1710,7 @@ export default function Page() { <Match when={hasReview()}> <Show when={diffsReady()} - fallback={ - <div class="px-6 py-4 text-text-weak"> - {language.t("session.review.loadingChanges")} - </div> - } + fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>} > <SessionReviewTab diffs={diffs} @@ -1690,9 +1728,7 @@ export default function Page() { <Match when={true}> <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6"> <Mark class="w-14 opacity-10" /> - <div class="text-14-regular text-text-weak max-w-56"> - {language.t("session.review.empty")} - </div> + <div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div> </div> </Match> </Switch> @@ -1719,6 +1755,9 @@ export default function Page() { let scroll: HTMLDivElement | undefined let scrollFrame: number | undefined let pending: { x: number; y: number } | undefined + let codeScroll: HTMLElement[] = [] + + const [selectionPopoverTop, setSelectionPopoverTop] = createSignal<number | undefined>() const path = createMemo(() => file.pathFromTab(tab)) const state = createMemo(() => { @@ -1775,28 +1814,78 @@ export default function Page() { return `L${sel.startLine}-${sel.endLine}` }) - const restoreScroll = (retries = 0) => { + const updateSelectionPopover = () => { const el = scroll - if (!el) return + if (!el) { + setSelectionPopoverTop(undefined) + return + } - const s = view()?.scroll(tab) - if (!s) return + const sel = selection() + if (!sel) { + setSelectionPopoverTop(undefined) + return + } - // Wait for content to be scrollable - content may not have rendered yet - if (el.scrollHeight <= el.clientHeight && retries < 10) { - requestAnimationFrame(() => restoreScroll(retries + 1)) + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) { + setSelectionPopoverTop(undefined) return } - if (el.scrollTop !== s.y) el.scrollTop = s.y - if (el.scrollLeft !== s.x) el.scrollLeft = s.x - } + const root = host.shadowRoot + if (!root) { + setSelectionPopoverTop(undefined) + return + } - const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - pending = { - x: event.currentTarget.scrollLeft, - y: event.currentTarget.scrollTop, + const marker = + (root.querySelector( + '[data-selected-line="last"], [data-selected-line="single"]', + ) as HTMLElement | null) ?? (root.querySelector("[data-selected-line]") as HTMLElement | null) + + if (!marker) { + setSelectionPopoverTop(undefined) + return } + + const containerRect = el.getBoundingClientRect() + const markerRect = marker.getBoundingClientRect() + setSelectionPopoverTop(markerRect.bottom - containerRect.top + el.scrollTop + 8) + } + + createEffect( + on( + selection, + (sel) => { + if (!sel) { + setSelectionPopoverTop(undefined) + return + } + + requestAnimationFrame(updateSelectionPopover) + }, + { defer: true }, + ), + ) + + const getCodeScroll = () => { + const el = scroll + if (!el) return [] + + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return [] + + const root = host.shadowRoot + if (!root) return [] + + return Array.from(root.querySelectorAll("[data-code]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0, + ) + } + + const queueScrollUpdate = (next: { x: number; y: number }) => { + pending = next if (scrollFrame !== undefined) return scrollFrame = requestAnimationFrame(() => { @@ -1810,6 +1899,65 @@ export default function Page() { }) } + const handleCodeScroll = (event: Event) => { + const el = scroll + if (!el) return + + const target = event.currentTarget + if (!(target instanceof HTMLElement)) return + + queueScrollUpdate({ + x: target.scrollLeft, + y: el.scrollTop, + }) + } + + const syncCodeScroll = () => { + const next = getCodeScroll() + if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return + + for (const item of codeScroll) { + item.removeEventListener("scroll", handleCodeScroll) + } + + codeScroll = next + + for (const item of codeScroll) { + item.addEventListener("scroll", handleCodeScroll) + } + } + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = view()?.scroll(tab) + if (!s) return + + syncCodeScroll() + + if (codeScroll.length > 0) { + for (const item of codeScroll) { + if (item.scrollLeft !== s.x) item.scrollLeft = s.x + } + } + + if (el.scrollTop !== s.y) el.scrollTop = s.y + + if (codeScroll.length > 0) return + + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + if (codeScroll.length === 0) syncCodeScroll() + + queueScrollUpdate({ + x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + }) + } + createEffect( on( () => state()?.loaded, @@ -1844,6 +1992,10 @@ export default function Page() { ) onCleanup(() => { + for (const item of codeScroll) { + item.removeEventListener("scroll", handleCodeScroll) + } + if (scrollFrame === undefined) return cancelAnimationFrame(scrollFrame) }) @@ -1851,93 +2003,115 @@ export default function Page() { return ( <Tabs.Content value={tab} - class="mt-3" + class="mt-3 relative" ref={(el: HTMLDivElement) => { scroll = el restoreScroll() + updateSelectionPopover() }} onScroll={handleScroll} > <Show when={activeTab() === tab}> - <Show when={selection()}> + <Show when={selectionPopoverTop() !== undefined && selection()}> {(sel) => ( - <div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base"> - <button - type="button" - class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover" - onClick={() => { - const p = path() - if (!p) return - prompt.context.add({ type: "file", path: p, selection: sel() }) - }} + <div class="absolute z-20 right-6" style={{ top: `${selectionPopoverTop() ?? 0}px` }}> + <TooltipKeybind + placement="bottom" + title="Add selection to context" + keybind={command.keybind("context.addSelection")} > - <Icon name="plus-small" size="small" /> - <span> - {language.t("session.context.addToContext", { selection: selectionLabel() ?? "" })} - </span> - </button> + <button + type="button" + class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-raised-stronger-non-alpha border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover" + onClick={() => { + const p = path() + if (!p) return + addSelectionToContext(p, sel()) + }} + > + <Icon name="plus-small" size="small" /> + <span> + {language.t("session.context.addToContext", { + selection: selectionLabel() ?? "", + })} + </span> + </button> + </TooltipKeybind> </div> )} </Show> - <Switch> - <Match when={state()?.loaded && isImage()}> - <div class="px-6 py-4 pb-40"> - <img src={imageDataUrl()} alt={path()} class="max-w-full" /> - </div> - </Match> - <Match when={state()?.loaded && isSvg()}> - <div class="flex flex-col gap-4 px-6 py-4"> - <Dynamic - component={codeComponent} - file={{ - name: path() ?? "", - contents: svgContent() ?? "", - cacheKey: cacheKey(), - }} - enableLineSelection - selectedLines={selectedLines()} - onLineSelected={(range: SelectedLineRange | null) => { - const p = path() - if (!p) return - file.setSelectedLines(p, range) - }} - overflow="scroll" - class="select-text" - /> - <Show when={svgPreviewUrl()}> - <div class="flex justify-center pb-40"> - <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" /> - </div> - </Show> - </div> - </Match> - <Match when={state()?.loaded}> + </Show> + <Switch> + <Match when={state()?.loaded && isImage()}> + <div class="px-6 py-4 pb-40"> + <img + src={imageDataUrl()} + alt={path()} + class="max-w-full" + onLoad={() => requestAnimationFrame(restoreScroll)} + /> + </div> + </Match> + <Match when={state()?.loaded && isSvg()}> + <div class="flex flex-col gap-4 px-6 py-4"> <Dynamic component={codeComponent} file={{ name: path() ?? "", - contents: contents(), + contents: svgContent() ?? "", cacheKey: cacheKey(), }} enableLineSelection selectedLines={selectedLines()} + onRendered={() => { + requestAnimationFrame(restoreScroll) + requestAnimationFrame(updateSelectionPopover) + }} onLineSelected={(range: SelectedLineRange | null) => { const p = path() if (!p) return file.setSelectedLines(p, range) }} overflow="scroll" - class="select-text pb-40" + class="select-text" /> - </Match> - <Match when={state()?.loading}> - <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div> - </Match> - <Match when={state()?.error}> - {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>} - </Match> - </Switch> - </Show> + <Show when={svgPreviewUrl()}> + <div class="flex justify-center pb-40"> + <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" /> + </div> + </Show> + </div> + </Match> + <Match when={state()?.loaded}> + <Dynamic + component={codeComponent} + file={{ + name: path() ?? "", + contents: contents(), + cacheKey: cacheKey(), + }} + enableLineSelection + selectedLines={selectedLines()} + onRendered={() => { + requestAnimationFrame(restoreScroll) + requestAnimationFrame(updateSelectionPopover) + }} + onLineSelected={(range: SelectedLineRange | null) => { + const p = path() + if (!p) return + file.setSelectedLines(p, range) + }} + overflow="scroll" + class="select-text pb-40" + /> + </Match> + <Match when={state()?.loading}> + <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div> + </Match> + <Match when={state()?.error}> + {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>} + </Match> + </Switch> </Tabs.Content> ) }} @@ -1990,11 +2164,9 @@ export default function Page() { )} </For> <div class="flex-1" /> - <div class="text-text-weak pr-2">{language.t("common.loading")}...</div> - </div> - <div class="flex-1 flex items-center justify-center text-text-weak"> - {language.t("terminal.loading")} + <div class="text-text-weak pr-2">Loading...</div> </div> + <div class="flex-1 flex items-center justify-center text-text-weak">Loading terminal...</div> </div> } > diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index ed7db368c..c6f702fb5 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -9,6 +9,7 @@ export type CodeProps<T = {}> = FileOptions<T> & { file: FileContents annotations?: LineAnnotation<T>[] selectedLines?: SelectedLineRange | null + onRendered?: () => void class?: string classList?: ComponentProps<"div">["classList"] } @@ -45,8 +46,32 @@ function findSide(node: Node | null): SelectionSide | undefined { export function Code<T>(props: CodeProps<T>) { let container!: HTMLDivElement + let observer: MutationObserver | undefined + let renderToken = 0 + let selectionFrame: number | undefined + let dragFrame: number | undefined + let dragStart: number | undefined + let dragEnd: number | undefined + let dragMoved = false + + const [local, others] = splitProps(props, [ + "file", + "class", + "classList", + "annotations", + "selectedLines", + "onRendered", + ]) + + const handleLineClick: FileOptions<T>["onLineClick"] = (info) => { + props.onLineClick?.(info) - const [local, others] = splitProps(props, ["file", "class", "classList", "annotations", "selectedLines"]) + if (props.enableLineSelection !== true) return + if (info.numberColumn) return + if (!local.selectedLines) return + + file().setSelectedLines(null) + } const file = createMemo( () => @@ -54,6 +79,7 @@ export function Code<T>(props: CodeProps<T>) { { ...createDefaultOptions<T>("unified"), ...others, + onLineClick: props.enableLineSelection === true || props.onLineClick ? handleLineClick : undefined, }, getWorkerPool("unified"), ), @@ -69,37 +95,218 @@ export function Code<T>(props: CodeProps<T>) { return root } - const handleMouseUp = () => { - if (props.enableLineSelection !== true) return + const notifyRendered = () => { + if (!local.onRendered) return + + observer?.disconnect() + observer = undefined + renderToken++ + + const token = renderToken + + const lines = (() => { + const text = local.file.contents + const total = text.split("\n").length - (text.endsWith("\n") ? 1 : 0) + return Math.max(1, total) + })() + + const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines + + const notify = () => { + if (token !== renderToken) return + + observer?.disconnect() + observer = undefined + requestAnimationFrame(() => { + if (token !== renderToken) return + local.onRendered?.() + }) + } + + const root = getRoot() + if (root && isReady(root)) { + notify() + return + } + + if (typeof MutationObserver === "undefined") return + + const observeRoot = (root: ShadowRoot) => { + if (isReady(root)) { + notify() + return + } + + observer?.disconnect() + observer = new MutationObserver(() => { + if (token !== renderToken) return + if (!isReady(root)) return + + notify() + }) + + observer.observe(root, { childList: true, subtree: true }) + } + + if (root) { + observeRoot(root) + return + } + + observer = new MutationObserver(() => { + if (token !== renderToken) return + + const root = getRoot() + if (!root) return + observeRoot(root) + }) + + observer.observe(container, { childList: true, subtree: true }) + } + + const updateSelection = () => { const root = getRoot() if (!root) return - const selection = window.getSelection() + const selection = + (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection() if (!selection || selection.isCollapsed) return - const anchor = selection.anchorNode - const focus = selection.focusNode - if (!anchor || !focus) return - if (!root.contains(anchor) || !root.contains(focus)) return + const domRange = + ( + selection as unknown as { + getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[] + } + ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ?? + (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined) + + const startNode = domRange?.startContainer ?? selection.anchorNode + const endNode = domRange?.endContainer ?? selection.focusNode + if (!startNode || !endNode) return - const start = findLineNumber(anchor) - const end = findLineNumber(focus) + if (!root.contains(startNode) || !root.contains(endNode)) return + + const start = findLineNumber(startNode) + const end = findLineNumber(endNode) if (start === undefined || end === undefined) return - const startSide = findSide(anchor) - const endSide = findSide(focus) + const startSide = findSide(startNode) + const endSide = findSide(endNode) const side = startSide ?? endSide - const range: SelectedLineRange = { + const selected: SelectedLineRange = { start, end, } - if (side) range.side = side - if (endSide && side && endSide !== side) range.endSide = endSide + if (side) selected.side = side + if (endSide && side && endSide !== side) selected.endSide = endSide + + file().setSelectedLines(selected) + } + + const scheduleSelectionUpdate = () => { + if (selectionFrame !== undefined) return + + selectionFrame = requestAnimationFrame(() => { + selectionFrame = undefined + updateSelection() + }) + } + + const updateDragSelection = () => { + if (dragStart === undefined || dragEnd === undefined) return + + const start = Math.min(dragStart, dragEnd) + const end = Math.max(dragStart, dragEnd) + + file().setSelectedLines({ start, end }) + } + + const scheduleDragUpdate = () => { + if (dragFrame !== undefined) return + + dragFrame = requestAnimationFrame(() => { + dragFrame = undefined + updateDragSelection() + }) + } + + const lineFromMouseEvent = (event: MouseEvent) => { + const path = event.composedPath() + + let numberColumn = false + let line: number | undefined + + for (const item of path) { + if (!(item instanceof HTMLElement)) continue + + numberColumn = numberColumn || item.dataset.columnNumber != null + + if (line === undefined && item.dataset.line) { + const parsed = parseInt(item.dataset.line, 10) + if (!Number.isNaN(parsed)) line = parsed + } + + if (numberColumn && line !== undefined) break + } + + return { line, numberColumn } + } + + const handleMouseDown = (event: MouseEvent) => { + if (props.enableLineSelection !== true) return + if (event.button !== 0) return + + const { line, numberColumn } = lineFromMouseEvent(event) + if (numberColumn) return + if (line === undefined) return + + dragStart = line + dragEnd = line + dragMoved = false + } + + const handleMouseMove = (event: MouseEvent) => { + if (props.enableLineSelection !== true) return + if (dragStart === undefined) return + + if ((event.buttons & 1) === 0) { + dragStart = undefined + dragEnd = undefined + dragMoved = false + return + } + + const { line } = lineFromMouseEvent(event) + if (line === undefined) return + + dragEnd = line + dragMoved = true + scheduleDragUpdate() + } + + const handleMouseUp = () => { + if (props.enableLineSelection !== true) return + + if (dragStart !== undefined) { + if (dragMoved) scheduleDragUpdate() + dragStart = undefined + dragEnd = undefined + dragMoved = false + } + + scheduleSelectionUpdate() + } + + const handleSelectionChange = () => { + if (props.enableLineSelection !== true) return + + const selection = window.getSelection() + if (!selection || selection.isCollapsed) return - file().setSelectedLines(range) + scheduleSelectionUpdate() } createEffect(() => { @@ -111,12 +318,17 @@ export function Code<T>(props: CodeProps<T>) { }) createEffect(() => { + observer?.disconnect() + observer = undefined + container.innerHTML = "" file().render({ file: local.file, lineAnnotations: local.annotations, containerWrapper: container, }) + + notifyRendered() }) createEffect(() => { @@ -126,13 +338,37 @@ export function Code<T>(props: CodeProps<T>) { createEffect(() => { if (props.enableLineSelection !== true) return - container.addEventListener("mouseup", handleMouseUp) + container.addEventListener("mousedown", handleMouseDown) + container.addEventListener("mousemove", handleMouseMove) + window.addEventListener("mouseup", handleMouseUp) + document.addEventListener("selectionchange", handleSelectionChange) onCleanup(() => { - container.removeEventListener("mouseup", handleMouseUp) + container.removeEventListener("mousedown", handleMouseDown) + container.removeEventListener("mousemove", handleMouseMove) + window.removeEventListener("mouseup", handleMouseUp) + document.removeEventListener("selectionchange", handleSelectionChange) }) }) + onCleanup(() => { + observer?.disconnect() + + if (selectionFrame !== undefined) { + cancelAnimationFrame(selectionFrame) + selectionFrame = undefined + } + + if (dragFrame !== undefined) { + cancelAnimationFrame(dragFrame) + dragFrame = undefined + } + + dragStart = undefined + dragEnd = undefined + dragMoved = false + }) + return ( <div data-component="code" diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index 33925592c..46b6709b6 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -7,7 +7,10 @@ import { getWorkerPool } from "../pierre/worker" export function Diff<T>(props: DiffProps<T>) { let container!: HTMLDivElement - const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"]) + let observer: MutationObserver | undefined + let renderToken = 0 + + const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations", "onRendered"]) const mobile = createMediaQuery("(max-width: 640px)") @@ -25,6 +28,95 @@ export function Diff<T>(props: DiffProps<T>) { let instance: FileDiff<T> | undefined + const getRoot = () => { + const host = container.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return + + const root = host.shadowRoot + if (!root) return + + return root + } + + const notifyRendered = () => { + if (!local.onRendered) return + + observer?.disconnect() + observer = undefined + renderToken++ + + const token = renderToken + let settle = 0 + + const isReady = (root: ShadowRoot) => root.querySelector("[data-line]") != null + + const notify = () => { + if (token !== renderToken) return + + observer?.disconnect() + observer = undefined + requestAnimationFrame(() => { + if (token !== renderToken) return + local.onRendered?.() + }) + } + + const schedule = () => { + settle++ + const current = settle + + requestAnimationFrame(() => { + if (token !== renderToken) return + if (current !== settle) return + + requestAnimationFrame(() => { + if (token !== renderToken) return + if (current !== settle) return + + notify() + }) + }) + } + + const observeRoot = (root: ShadowRoot) => { + observer?.disconnect() + observer = new MutationObserver(() => { + if (token !== renderToken) return + if (!isReady(root)) return + + schedule() + }) + + observer.observe(root, { childList: true, subtree: true }) + + if (!isReady(root)) return + schedule() + } + + const root = getRoot() + if (typeof MutationObserver === "undefined") { + if (!root || !isReady(root)) return + local.onRendered() + return + } + + if (root) { + observeRoot(root) + return + } + + observer = new MutationObserver(() => { + if (token !== renderToken) return + + const root = getRoot() + if (!root) return + + observeRoot(root) + }) + + observer.observe(container, { childList: true, subtree: true }) + } + createEffect(() => { const opts = options() const workerPool = getWorkerPool(props.diffStyle) @@ -50,9 +142,12 @@ export function Diff<T>(props: DiffProps<T>) { lineAnnotations: annotations, containerWrapper: container, }) + + notifyRendered() }) onCleanup(() => { + observer?.disconnect() instance?.cleanUp() }) diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 2c8de9aa4..c47d11d08 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -22,6 +22,7 @@ export interface SessionReviewProps { split?: boolean diffStyle?: SessionReviewDiffStyle onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void + onDiffRendered?: () => void open?: string[] onOpenChange?: (open: string[]) => void scrollRef?: (el: HTMLDivElement) => void @@ -346,6 +347,7 @@ export const SessionReview = (props: SessionReviewProps) => { component={diffComponent} preloadedDiff={diff.preloaded} diffStyle={diffStyle()} + onRendered={props.onDiffRendered} before={{ name: diff.file!, contents: beforeText(), diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index 824d96b11..38bf6c854 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -5,6 +5,7 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & { before: FileContents after: FileContents annotations?: DiffLineAnnotation<T>[] + onRendered?: () => void class?: string classList?: ComponentProps<"div">["classList"] } @@ -18,9 +19,9 @@ const unsafeCSS = ` --diffs-bg-separator: var(--diffs-bg-separator-override, light-dark( color-mix(in lab, var(--diffs-bg) 96%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-mixer)))); --diffs-fg: light-dark(var(--diffs-light), var(--diffs-dark)); --diffs-fg-number: var(--diffs-fg-number-override, light-dark(color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg)), color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg)))); - --diffs-deletion-base: var(--diffs-deletion-color-override, light-dark(var(--diffs-light-deletion-color, var(--diffs-deletion-color, rgb(255, 0, 0))), var(--diffs-dark-deletion-color, var(--diffs-deletion-color, rgb(255, 0, 0))))); - --diffs-addition-base: var(--diffs-addition-color-override, light-dark(var(--diffs-light-addition-color, var(--diffs-addition-color, rgb(0, 255, 0))), var(--diffs-dark-addition-color, var(--diffs-addition-color, rgb(0, 255, 0))))); - --diffs-modified-base: var(--diffs-modified-color-override, light-dark(var(--diffs-light-modified-color, var(--diffs-modified-color, rgb(0, 0, 255))), var(--diffs-dark-modified-color, var(--diffs-modified-color, rgb(0, 0, 255))))); + --diffs-deletion-base: var(--syntax-diff-delete); + --diffs-addition-base: var(--syntax-diff-add); + --diffs-modified-base: var(--syntax-diff-unknown); --diffs-bg-deletion: var(--diffs-bg-deletion-override, light-dark( color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-deletion-base)))); --diffs-bg-deletion-number: var(--diffs-bg-deletion-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-deletion-base)))); --diffs-bg-deletion-hover: var(--diffs-bg-deletion-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-deletion-base)))); @@ -29,10 +30,15 @@ const unsafeCSS = ` --diffs-bg-addition-number: var(--diffs-bg-addition-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-addition-base)))); --diffs-bg-addition-hover: var(--diffs-bg-addition-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 70%, var(--diffs-addition-base)))); --diffs-bg-addition-emphasis: var(--diffs-bg-addition-emphasis-override, light-dark(rgb(from var(--diffs-addition-base) r g b / 0.07), rgb(from var(--diffs-addition-base) r g b / 0.1))); - --diffs-selection-base: var(--diffs-modified-base); + --diffs-selection-base: var(--text-interactive-base); --diffs-selection-number-fg: light-dark( color-mix(in lab, var(--diffs-selection-base) 65%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-selection-base) 75%, var(--diffs-mixer))); - --diffs-bg-selection: var(--diffs-bg-selection-override, light-dark( color-mix(in lab, var(--diffs-bg) 82%, var(--diffs-selection-base)), color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-selection-base)))); - --diffs-bg-selection-number: var(--diffs-bg-selection-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-selection-base)), color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-selection-base)))); + --diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--diffs-selection-base) r g b / 0.18)); + --diffs-bg-selection-number: var(--diffs-bg-selection-number-override, rgb(from var(--diffs-selection-base) r g b / 0.22)); + --diffs-bg-selection-text: rgb(from var(--diffs-selection-base) r g b / 0.12); +} + +[data-diffs] ::selection { + background-color: var(--diffs-bg-selection-text); } [data-diffs-header], @@ -57,6 +63,9 @@ const unsafeCSS = ` [data-separator-content] { height: 24px !important; } + [data-column-number] { + background-color: var(--background-stronger); + } [data-code] { overflow-x: auto !important; } |
