diff options
| author | Adam <[email protected]> | 2026-02-06 09:37:49 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-06 09:37:49 -0600 |
| commit | a4bc883595df9ea0f752079519081bc602408553 (patch) | |
| tree | 583f21642f431899abe1dfb1f6bd9b2c01dc0206 /packages/app/src/pages | |
| parent | c07077f96c0019b2e18e0e8e1e0383deda08b3e6 (diff) | |
| download | opencode-a4bc883595df9ea0f752079519081bc602408553.tar.gz opencode-a4bc883595df9ea0f752079519081bc602408553.zip | |
chore: refactoring and tests (#12468)
Diffstat (limited to 'packages/app/src/pages')
| -rw-r--r-- | packages/app/src/pages/session.tsx | 503 | ||||
| -rw-r--r-- | packages/app/src/pages/session/helpers.test.ts | 61 | ||||
| -rw-r--r-- | packages/app/src/pages/session/helpers.ts | 38 | ||||
| -rw-r--r-- | packages/app/src/pages/session/scroll-spy.test.ts | 127 | ||||
| -rw-r--r-- | packages/app/src/pages/session/scroll-spy.ts | 274 |
5 files changed, 713 insertions, 290 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 433e47925..67606e860 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -75,6 +75,8 @@ import { } from "@/components/session" import { navMark, navParams } from "@/utils/perf" import { same } from "@/utils/same" +import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers" +import { createScrollSpy } from "@/pages/session/scroll-spy" type DiffStyle = "unified" | "split" @@ -872,19 +874,7 @@ export default function Page() { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() } - const wrapper = document.getElementById(`terminal-wrapper-${activeId}`) - const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement - if (!element) return - - // Find and focus the ghostty textarea (the actual input element) - const textarea = element.querySelector("textarea") as HTMLTextAreaElement - if (textarea) { - textarea.focus() - return - } - // Fallback: focus container and dispatch pointer event - element.focus() - element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true })) + focusTerminalById(activeId) }, ), ) @@ -973,7 +963,7 @@ export default function Page() { }) } - command.register(() => [ + const sessionCommands = createMemo(() => [ { id: "session.new", title: language.t("command.session.new"), @@ -982,6 +972,9 @@ export default function Page() { slash: "new", onSelect: () => navigate(`/${params.dir}/session`), }, + ]) + + const fileCommands = createMemo(() => [ { id: "file.open", title: language.t("command.file.open"), @@ -989,7 +982,7 @@ export default function Page() { category: language.t("command.category.file"), keybind: "mod+p", slash: "open", - onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={() => showAllFiles()} />), + onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />), }, { id: "tab.close", @@ -1003,6 +996,9 @@ export default function Page() { tabs().close(active) }, }, + ]) + + const contextCommands = createMemo(() => [ { id: "context.addSelection", title: language.t("command.context.addSelection"), @@ -1034,6 +1030,9 @@ export default function Page() { addSelectionToContext(path, selectionFromLines(range)) }, }, + ]) + + const viewCommands = createMemo(() => [ { id: "terminal.toggle", title: language.t("command.terminal.toggle"), @@ -1087,6 +1086,9 @@ export default function Page() { setStore("expanded", msg.id, (open: boolean | undefined) => !open) }, }, + ]) + + const messageCommands = createMemo(() => [ { id: "message.previous", title: language.t("command.message.previous"), @@ -1105,6 +1107,9 @@ export default function Page() { disabled: !params.id, onSelect: () => navigateMessageByOffset(1), }, + ]) + + const agentCommands = createMemo(() => [ { id: "model.choose", title: language.t("command.model.choose"), @@ -1150,6 +1155,9 @@ export default function Page() { local.model.variant.cycle() }, }, + ]) + + const permissionCommands = createMemo(() => [ { id: "permissions.autoaccept", title: @@ -1173,6 +1181,9 @@ export default function Page() { }) }, }, + ]) + + const sessionActionCommands = createMemo(() => [ { id: "session.undo", title: language.t("command.session.undo"), @@ -1187,17 +1198,14 @@ export default function Page() { await sdk.client.session.abort({ sessionID }).catch(() => {}) } const revert = info()?.revert?.messageID - // Find the last user message that's not already reverted const message = findLast(userMessages(), (x) => !revert || x.id < revert) if (!message) return await sdk.client.session.revert({ sessionID, messageID: message.id }) - // Restore the prompt from the reverted message const parts = sync.data.part[message.id] if (parts) { 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) const priorMessage = findLast(userMessages(), (x) => x.id < message.id) setActiveMessage(priorMessage) }, @@ -1216,17 +1224,13 @@ export default function Page() { if (!revertMessageID) return const nextMessage = userMessages().find((x) => x.id > revertMessageID) if (!nextMessage) { - // Full unrevert - restore all messages and navigate to last await sdk.client.session.unrevert({ sessionID }) prompt.reset() - // Navigate to the last message (the one that was at the revert point) const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) setActiveMessage(lastMsg) return } - // Partial redo - move forward to next message await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) - // Navigate to the message before the new revert point const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) setActiveMessage(priorMsg) }, @@ -1265,74 +1269,90 @@ export default function Page() { disabled: !params.id || visibleUserMessages().length === 0, onSelect: () => dialog.show(() => <DialogFork />), }, - ...(sync.data.config.share !== "disabled" - ? [ - { - id: "session.share", - title: language.t("command.session.share"), - description: language.t("command.session.share.description"), - category: language.t("command.category.session"), - slash: "share", - disabled: !params.id || !!info()?.share?.url, - onSelect: async () => { - if (!params.id) return - await sdk.client.session - .share({ sessionID: params.id }) - .then((res) => { - navigator.clipboard.writeText(res.data!.share!.url).catch(() => - showToast({ - title: language.t("toast.session.share.copyFailed.title"), - variant: "error", - }), - ) - }) - .then(() => - showToast({ - title: language.t("toast.session.share.success.title"), - description: language.t("toast.session.share.success.description"), - variant: "success", - }), - ) - .catch(() => - showToast({ - title: language.t("toast.session.share.failed.title"), - description: language.t("toast.session.share.failed.description"), - variant: "error", - }), - ) - }, - }, - { - id: "session.unshare", - title: language.t("command.session.unshare"), - description: language.t("command.session.unshare.description"), - category: language.t("command.category.session"), - slash: "unshare", - disabled: !params.id || !info()?.share?.url, - onSelect: async () => { - if (!params.id) return - await sdk.client.session - .unshare({ sessionID: params.id }) - .then(() => - showToast({ - title: language.t("toast.session.unshare.success.title"), - description: language.t("toast.session.unshare.success.description"), - variant: "success", - }), - ) - .catch(() => - showToast({ - title: language.t("toast.session.unshare.failed.title"), - description: language.t("toast.session.unshare.failed.description"), - variant: "error", - }), - ) - }, - }, - ] - : []), ]) + const shareCommands = createMemo(() => { + if (sync.data.config.share === "disabled") return [] + return [ + { + id: "session.share", + title: language.t("command.session.share"), + description: language.t("command.session.share.description"), + category: language.t("command.category.session"), + slash: "share", + disabled: !params.id || !!info()?.share?.url, + onSelect: async () => { + if (!params.id) return + await sdk.client.session + .share({ sessionID: params.id }) + .then((res) => { + navigator.clipboard.writeText(res.data!.share!.url).catch(() => + showToast({ + title: language.t("toast.session.share.copyFailed.title"), + variant: "error", + }), + ) + }) + .then(() => + showToast({ + title: language.t("toast.session.share.success.title"), + description: language.t("toast.session.share.success.description"), + variant: "success", + }), + ) + .catch(() => + showToast({ + title: language.t("toast.session.share.failed.title"), + description: language.t("toast.session.share.failed.description"), + variant: "error", + }), + ) + }, + }, + { + id: "session.unshare", + title: language.t("command.session.unshare"), + description: language.t("command.session.unshare.description"), + category: language.t("command.category.session"), + slash: "unshare", + disabled: !params.id || !info()?.share?.url, + onSelect: async () => { + if (!params.id) return + await sdk.client.session + .unshare({ sessionID: params.id }) + .then(() => + showToast({ + title: language.t("toast.session.unshare.success.title"), + description: language.t("toast.session.unshare.success.description"), + variant: "success", + }), + ) + .catch(() => + showToast({ + title: language.t("toast.session.unshare.failed.title"), + description: language.t("toast.session.unshare.failed.description"), + variant: "error", + }), + ) + }, + }, + ] + }) + + command.register("session", () => + combineCommandSections([ + sessionCommands(), + fileCommands(), + contextCommands(), + viewCommands(), + messageCommands(), + agentCommands(), + permissionCommands(), + sessionActionCommands(), + shareCommands(), + ]), + ) + const handleKeyDown = (event: KeyboardEvent) => { const activeElement = document.activeElement as HTMLElement | undefined if (activeElement) { @@ -1407,19 +1427,7 @@ export default function Page() { const activeId = terminal.active() if (!activeId) return setTimeout(() => { - const wrapper = document.getElementById(`terminal-wrapper-${activeId}`) - const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement - if (!element) return - - // Find and focus the ghostty textarea (the actual input element) - const textarea = element.querySelector("textarea") as HTMLTextAreaElement - if (textarea) { - textarea.focus() - return - } - // Fallback: focus container and dispatch pointer event - element.focus() - element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true })) + focusTerminalById(activeId) }, 0) } @@ -1457,6 +1465,13 @@ export default function Page() { setFileTreeTab("all") } + const openReviewFile = createOpenReviewFile({ + showAllFiles, + tabForPath: file.tab, + openTab: tabs().open, + loadFile: file.load, + }) + const changesOptions = ["session", "turn"] as const const changesOptionsList = [...changesOptions] @@ -1481,65 +1496,72 @@ export default function Page() { </div> ) + const reviewContent = (input: { + diffStyle: DiffStyle + onDiffStyleChange?: (style: DiffStyle) => void + classes?: SessionReviewTabProps["classes"] + loadingClass: string + emptyClass: string + }) => ( + <Switch> + <Match when={store.changes === "turn" && !!params.id}> + <SessionReviewTab + title={changesTitle()} + empty={emptyTurn()} + diffs={reviewDiffs} + view={view} + diffStyle={input.diffStyle} + onDiffStyleChange={input.onDiffStyleChange} + onScrollRef={(el) => setTree("reviewScroll", el)} + focusedFile={tree.activeDiff} + onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} + onViewFile={openReviewFile} + classes={input.classes} + /> + </Match> + <Match when={hasReview()}> + <Show + when={diffsReady()} + fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>} + > + <SessionReviewTab + title={changesTitle()} + diffs={reviewDiffs} + view={view} + diffStyle={input.diffStyle} + onDiffStyleChange={input.onDiffStyleChange} + onScrollRef={(el) => setTree("reviewScroll", el)} + focusedFile={tree.activeDiff} + onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} + onViewFile={openReviewFile} + classes={input.classes} + /> + </Show> + </Match> + <Match when={true}> + <div class={input.emptyClass}> + <Mark class="w-14 opacity-10" /> + <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div> + </div> + </Match> + </Switch> + ) + const reviewPanel = () => ( <div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> - <Switch> - <Match when={store.changes === "turn" && !!params.id}> - <SessionReviewTab - title={changesTitle()} - empty={emptyTurn()} - diffs={reviewDiffs} - view={view} - diffStyle={layout.review.diffStyle()} - onDiffStyleChange={layout.review.setDiffStyle} - onScrollRef={(el) => setTree("reviewScroll", el)} - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={(path) => { - showAllFiles() - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - /> - </Match> - <Match when={hasReview()}> - <Show - when={diffsReady()} - fallback={<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>} - > - <SessionReviewTab - title={changesTitle()} - diffs={reviewDiffs} - view={view} - diffStyle={layout.review.diffStyle()} - onDiffStyleChange={layout.review.setDiffStyle} - onScrollRef={(el) => setTree("reviewScroll", el)} - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={(path) => { - showAllFiles() - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - /> - </Show> - </Match> - <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> - </Match> - </Switch> + {reviewContent({ + diffStyle: layout.review.diffStyle(), + onDiffStyleChange: layout.review.setDiffStyle, + loadingClass: "px-6 py-4 text-text-weak", + emptyClass: "h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6", + })} </div> </div> ) @@ -1656,6 +1678,12 @@ export default function Page() { return "empty" }) + const activeFileTab = createMemo(() => { + const active = activeTab() + if (!openedTabs().includes(active)) return + return active + }) + createEffect(() => { if (!layout.ready()) return if (tabs().active()) return @@ -1760,6 +1788,12 @@ export default function Page() { let scrollStateFrame: number | undefined let scrollStateTarget: HTMLDivElement | undefined + const scrollSpy = createScrollSpy({ + onActive: (id) => { + if (id === store.messageId) return + setStore("messageId", id) + }, + }) const updateScrollState = (el: HTMLDivElement) => { const max = el.scrollHeight - el.clientHeight @@ -1807,16 +1841,11 @@ export default function Page() { ), ) - let scrollSpyFrame: number | undefined - let scrollSpyTarget: HTMLDivElement | undefined - createEffect( on( sessionKey, () => { - if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) - scrollSpyFrame = undefined - scrollSpyTarget = undefined + scrollSpy.clear() }, { defer: true }, ), @@ -1827,6 +1856,7 @@ export default function Page() { const setScrollRef = (el: HTMLDivElement | undefined) => { scroller = el autoScroll.scrollRef(el) + scrollSpy.setContainer(el) if (el) scheduleScrollState(el) } @@ -1835,6 +1865,7 @@ export default function Page() { () => { const el = scroller if (el) scheduleScrollState(el) + scrollSpy.markDirty() }, ) @@ -1940,6 +1971,7 @@ export default function Page() { } if (el) scheduleScrollState(el) + scrollSpy.markDirty() }, ) @@ -2053,61 +2085,6 @@ export default function Page() { if (el) scheduleScrollState(el) } - const closestMessage = (node: Element | null): HTMLElement | null => { - if (!node) return null - const match = node.closest?.("[data-message-id]") as HTMLElement | null - if (match) return match - const root = node.getRootNode?.() - if (root instanceof ShadowRoot) return closestMessage(root.host) - return null - } - - const getActiveMessageId = (container: HTMLDivElement) => { - const rect = container.getBoundingClientRect() - if (!rect.width || !rect.height) return - - const x = Math.min(window.innerWidth - 1, Math.max(0, rect.left + rect.width / 2)) - const y = Math.min(window.innerHeight - 1, Math.max(0, rect.top + 100)) - - const hit = document.elementFromPoint(x, y) - const host = closestMessage(hit) - const id = host?.dataset.messageId - if (id) return id - - // Fallback: DOM query (handles edge hit-testing cases) - const cutoff = container.scrollTop + 100 - const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]") - let last: string | undefined - - for (const node of nodes) { - const next = node.dataset.messageId - if (!next) continue - if (node.offsetTop > cutoff) break - last = next - } - - return last - } - - const scheduleScrollSpy = (container: HTMLDivElement) => { - scrollSpyTarget = container - if (scrollSpyFrame !== undefined) return - - scrollSpyFrame = requestAnimationFrame(() => { - scrollSpyFrame = undefined - - const target = scrollSpyTarget - scrollSpyTarget = undefined - if (!target) return - - const id = getActiveMessageId(target) - if (!id) return - if (id === store.messageId) return - - setStore("messageId", id) - }) - } - createEffect(() => { const sessionID = params.id const ready = messagesReady() @@ -2215,7 +2192,7 @@ export default function Page() { onCleanup(() => { cancelTurnBackfill() document.removeEventListener("keydown", handleKeyDown) - if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) + scrollSpy.destroy() if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) }) @@ -2272,74 +2249,16 @@ export default function Page() { when={!mobileChanges()} fallback={ <div class="relative h-full overflow-hidden"> - <Switch> - <Match when={store.changes === "turn" && !!params.id}> - <SessionReviewTab - title={changesTitle()} - empty={emptyTurn()} - diffs={reviewDiffs} - view={view} - diffStyle="unified" - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={(path) => { - showAllFiles() - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - classes={{ - root: "pb-[calc(var(--prompt-height,8rem)+32px)]", - header: "px-4", - container: "px-4", - }} - /> - </Match> - <Match when={hasReview()}> - <Show - when={diffsReady()} - fallback={ - <div class="px-4 py-4 text-text-weak"> - {language.t("session.review.loadingChanges")} - </div> - } - > - <SessionReviewTab - title={changesTitle()} - diffs={reviewDiffs} - view={view} - diffStyle="unified" - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={(path) => { - showAllFiles() - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - classes={{ - root: "pb-[calc(var(--prompt-height,8rem)+32px)]", - header: "px-4", - container: "px-4", - }} - /> - </Show> - </Match> - <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> - </Match> - </Switch> + {reviewContent({ + diffStyle: "unified", + classes: { + root: "pb-[calc(var(--prompt-height,8rem)+32px)]", + header: "px-4", + container: "px-4", + }, + loadingClass: "px-4 py-4 text-text-weak", + emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6", + })} </div> } > @@ -2451,7 +2370,7 @@ export default function Page() { if (!hasScrollGesture()) return autoScroll.handleScroll() markScrollGesture(e.currentTarget) - if (isDesktop()) scheduleScrollSpy(e.currentTarget) + if (isDesktop()) scrollSpy.onScroll() }} onClick={autoScroll.handleInteraction} class="relative min-w-0 w-full h-full overflow-y-auto session-scroller" @@ -2636,6 +2555,10 @@ export default function Page() { <div id={anchor(message.id)} data-message-id={message.id} + ref={(el) => { + scrollSpy.register(el, message.id) + onCleanup(() => scrollSpy.unregister(message.id)) + }} classList={{ "min-w-0 w-full max-w-full": true, "md:max-w-200 3xl:max-w-[1200px]": centered(), @@ -2979,7 +2902,7 @@ export default function Page() { </Tabs.Content> </Show> - <For each={openedTabs()}> + <Show when={activeFileTab()} keyed> {(tab) => { let scroll: HTMLDivElement | undefined let scrollFrame: number | undefined @@ -3483,7 +3406,7 @@ export default function Page() { </Tabs.Content> ) }} - </For> + </Show> </Tabs> <DragOverlay> <Show when={store.activeDraggable}> diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts new file mode 100644 index 000000000..0afc7eb6a --- /dev/null +++ b/packages/app/src/pages/session/helpers.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "./helpers" + +describe("createOpenReviewFile", () => { + test("opens and loads selected review file", () => { + const calls: string[] = [] + const openReviewFile = createOpenReviewFile({ + showAllFiles: () => calls.push("show"), + tabForPath: (path) => { + calls.push(`tab:${path}`) + return `file://${path}` + }, + openTab: (tab) => calls.push(`open:${tab}`), + loadFile: (path) => calls.push(`load:${path}`), + }) + + openReviewFile("src/a.ts") + + expect(calls).toEqual(["show", "tab:src/a.ts", "open:file://src/a.ts", "load:src/a.ts"]) + }) +}) + +describe("focusTerminalById", () => { + test("focuses textarea when present", () => { + document.body.innerHTML = `<div id="terminal-wrapper-one"><div data-component="terminal"><textarea></textarea></div></div>` + + const focused = focusTerminalById("one") + + expect(focused).toBe(true) + expect(document.activeElement?.tagName).toBe("TEXTAREA") + }) + + test("falls back to terminal element focus", () => { + document.body.innerHTML = `<div id="terminal-wrapper-two"><div data-component="terminal" tabindex="0"></div></div>` + const terminal = document.querySelector('[data-component="terminal"]') as HTMLElement + let pointerDown = false + terminal.addEventListener("pointerdown", () => { + pointerDown = true + }) + + const focused = focusTerminalById("two") + + expect(focused).toBe(true) + expect(document.activeElement).toBe(terminal) + expect(pointerDown).toBe(true) + }) +}) + +describe("combineCommandSections", () => { + test("keeps section order stable", () => { + const result = combineCommandSections([ + [{ id: "a", title: "A" }], + [ + { id: "b", title: "B" }, + { id: "c", title: "C" }, + ], + ]) + + expect(result.map((item) => item.id)).toEqual(["a", "b", "c"]) + }) +}) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts new file mode 100644 index 000000000..d9ce90793 --- /dev/null +++ b/packages/app/src/pages/session/helpers.ts @@ -0,0 +1,38 @@ +import type { CommandOption } from "@/context/command" + +export const focusTerminalById = (id: string) => { + const wrapper = document.getElementById(`terminal-wrapper-${id}`) + const terminal = wrapper?.querySelector('[data-component="terminal"]') + if (!(terminal instanceof HTMLElement)) return false + + const textarea = terminal.querySelector("textarea") + if (textarea instanceof HTMLTextAreaElement) { + textarea.focus() + return true + } + + terminal.focus() + terminal.dispatchEvent( + typeof PointerEvent === "function" + ? new PointerEvent("pointerdown", { bubbles: true, cancelable: true }) + : new MouseEvent("pointerdown", { bubbles: true, cancelable: true }), + ) + return true +} + +export const createOpenReviewFile = (input: { + showAllFiles: () => void + tabForPath: (path: string) => string + openTab: (tab: string) => void + loadFile: (path: string) => void +}) => { + return (path: string) => { + input.showAllFiles() + input.openTab(input.tabForPath(path)) + input.loadFile(path) + } +} + +export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => { + return sections.flatMap((section) => section) +} diff --git a/packages/app/src/pages/session/scroll-spy.test.ts b/packages/app/src/pages/session/scroll-spy.test.ts new file mode 100644 index 000000000..f3e6775cb --- /dev/null +++ b/packages/app/src/pages/session/scroll-spy.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test" +import { createScrollSpy, pickOffsetId, pickVisibleId } from "./scroll-spy" + +const rect = (top: number, height = 80): DOMRect => + ({ + x: 0, + y: top, + top, + left: 0, + right: 800, + bottom: top + height, + width: 800, + height, + toJSON: () => ({}), + }) as DOMRect + +const setRect = (el: Element, top: number, height = 80) => { + Object.defineProperty(el, "getBoundingClientRect", { + configurable: true, + value: () => rect(top, height), + }) +} + +describe("pickVisibleId", () => { + test("prefers higher intersection ratio", () => { + const id = pickVisibleId( + [ + { id: "a", ratio: 0.2, top: 100 }, + { id: "b", ratio: 0.8, top: 300 }, + ], + 120, + ) + + expect(id).toBe("b") + }) + + test("breaks ratio ties by nearest line", () => { + const id = pickVisibleId( + [ + { id: "a", ratio: 0.5, top: 90 }, + { id: "b", ratio: 0.5, top: 140 }, + ], + 130, + ) + + expect(id).toBe("b") + }) +}) + +describe("pickOffsetId", () => { + test("uses binary search cutoff", () => { + const id = pickOffsetId( + [ + { id: "a", top: 0 }, + { id: "b", top: 200 }, + { id: "c", top: 400 }, + ], + 350, + ) + + expect(id).toBe("b") + }) +}) + +describe("createScrollSpy fallback", () => { + test("tracks active id from offsets and dirty refresh", () => { + const active: string[] = [] + const root = document.createElement("div") as HTMLDivElement + const one = document.createElement("div") + const two = document.createElement("div") + const three = document.createElement("div") + + root.append(one, two, three) + document.body.append(root) + + Object.defineProperty(root, "scrollTop", { configurable: true, writable: true, value: 250 }) + setRect(root, 0, 800) + setRect(one, -250) + setRect(two, -50) + setRect(three, 150) + + const queue: FrameRequestCallback[] = [] + const flush = () => { + const run = [...queue] + queue.length = 0 + for (const cb of run) cb(0) + } + + const spy = createScrollSpy({ + onActive: (id) => active.push(id), + raf: (cb) => (queue.push(cb), queue.length), + caf: () => {}, + IntersectionObserver: undefined, + ResizeObserver: undefined, + MutationObserver: undefined, + }) + + spy.setContainer(root) + spy.register(one, "a") + spy.register(two, "b") + spy.register(three, "c") + spy.onScroll() + flush() + + expect(spy.getActiveId()).toBe("b") + expect(active.at(-1)).toBe("b") + + root.scrollTop = 450 + setRect(one, -450) + setRect(two, -250) + setRect(three, -50) + spy.onScroll() + flush() + expect(spy.getActiveId()).toBe("c") + + root.scrollTop = 250 + setRect(one, -250) + setRect(two, 250) + setRect(three, 150) + spy.markDirty() + spy.onScroll() + flush() + expect(spy.getActiveId()).toBe("a") + + spy.destroy() + }) +}) diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts new file mode 100644 index 000000000..8c52d77dc --- /dev/null +++ b/packages/app/src/pages/session/scroll-spy.ts @@ -0,0 +1,274 @@ +type Visible = { + id: string + ratio: number + top: number +} + +type Offset = { + id: string + top: number +} + +type Input = { + onActive: (id: string) => void + raf?: (cb: FrameRequestCallback) => number + caf?: (id: number) => void + IntersectionObserver?: typeof globalThis.IntersectionObserver + ResizeObserver?: typeof globalThis.ResizeObserver + MutationObserver?: typeof globalThis.MutationObserver +} + +export const pickVisibleId = (list: Visible[], line: number) => { + if (list.length === 0) return + + const sorted = [...list].sort((a, b) => { + if (b.ratio !== a.ratio) return b.ratio - a.ratio + + const da = Math.abs(a.top - line) + const db = Math.abs(b.top - line) + if (da !== db) return da - db + + return a.top - b.top + }) + + return sorted[0]?.id +} + +export const pickOffsetId = (list: Offset[], cutoff: number) => { + if (list.length === 0) return + + let lo = 0 + let hi = list.length - 1 + let out = 0 + + while (lo <= hi) { + const mid = (lo + hi) >> 1 + const top = list[mid]?.top + if (top === undefined) break + + if (top <= cutoff) { + out = mid + lo = mid + 1 + continue + } + + hi = mid - 1 + } + + return list[out]?.id +} + +export const createScrollSpy = (input: Input) => { + const raf = input.raf ?? requestAnimationFrame + const caf = input.caf ?? cancelAnimationFrame + const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver + const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver + const CtorMO = input.MutationObserver ?? globalThis.MutationObserver + + let root: HTMLDivElement | undefined + let io: IntersectionObserver | undefined + let ro: ResizeObserver | undefined + let mo: MutationObserver | undefined + let frame: number | undefined + let active: string | undefined + let dirty = true + + const node = new Map<string, HTMLElement>() + const id = new WeakMap<HTMLElement, string>() + const visible = new Map<string, { ratio: number; top: number }>() + let offset: Offset[] = [] + + const schedule = () => { + if (frame !== undefined) return + frame = raf(() => { + frame = undefined + update() + }) + } + + const refreshOffset = () => { + const el = root + if (!el) { + offset = [] + dirty = false + return + } + + const base = el.getBoundingClientRect().top + offset = [...node].map(([next, item]) => ({ + id: next, + top: item.getBoundingClientRect().top - base + el.scrollTop, + })) + offset.sort((a, b) => a.top - b.top) + dirty = false + } + + const update = () => { + const el = root + if (!el) return + + const line = el.getBoundingClientRect().top + 100 + const next = + pickVisibleId( + [...visible].map(([k, v]) => ({ + id: k, + ratio: v.ratio, + top: v.top, + })), + line, + ) ?? + (() => { + if (dirty) refreshOffset() + return pickOffsetId(offset, el.scrollTop + 100) + })() + + if (!next || next === active) return + active = next + input.onActive(next) + } + + const observe = () => { + const el = root + if (!el) return + + io?.disconnect() + io = undefined + if (CtorIO) { + try { + io = new CtorIO( + (entries) => { + for (const entry of entries) { + const item = entry.target + if (!(item instanceof HTMLElement)) continue + const key = id.get(item) + if (!key) continue + + if (!entry.isIntersecting || entry.intersectionRatio <= 0) { + visible.delete(key) + continue + } + + visible.set(key, { + ratio: entry.intersectionRatio, + top: entry.boundingClientRect.top, + }) + } + + schedule() + }, + { + root: el, + threshold: [0, 0.25, 0.5, 0.75, 1], + }, + ) + } catch { + io = undefined + } + } + + if (io) { + for (const item of node.values()) io.observe(item) + } + + ro?.disconnect() + ro = undefined + if (CtorRO) { + ro = new CtorRO(() => { + dirty = true + schedule() + }) + ro.observe(el) + for (const item of node.values()) ro.observe(item) + } + + mo?.disconnect() + mo = undefined + if (CtorMO) { + mo = new CtorMO(() => { + dirty = true + schedule() + }) + mo.observe(el, { subtree: true, childList: true, characterData: true }) + } + + dirty = true + schedule() + } + + const setContainer = (el?: HTMLDivElement) => { + if (root === el) return + + root = el + visible.clear() + active = undefined + observe() + } + + const register = (el: HTMLElement, key: string) => { + const prev = node.get(key) + if (prev && prev !== el) { + io?.unobserve(prev) + ro?.unobserve(prev) + } + + node.set(key, el) + id.set(el, key) + if (io) io.observe(el) + if (ro) ro.observe(el) + dirty = true + schedule() + } + + const unregister = (key: string) => { + const item = node.get(key) + if (!item) return + + io?.unobserve(item) + ro?.unobserve(item) + node.delete(key) + visible.delete(key) + dirty = true + } + + const markDirty = () => { + dirty = true + schedule() + } + + const clear = () => { + for (const item of node.values()) { + io?.unobserve(item) + ro?.unobserve(item) + } + + node.clear() + visible.clear() + offset = [] + active = undefined + dirty = true + } + + const destroy = () => { + if (frame !== undefined) caf(frame) + frame = undefined + clear() + io?.disconnect() + ro?.disconnect() + mo?.disconnect() + io = undefined + ro = undefined + mo = undefined + root = undefined + } + + return { + setContainer, + register, + unregister, + onScroll: schedule, + markDirty, + clear, + destroy, + getActiveId: () => active, + } +} |
