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/session | |
| parent | c07077f96c0019b2e18e0e8e1e0383deda08b3e6 (diff) | |
| download | opencode-a4bc883595df9ea0f752079519081bc602408553.tar.gz opencode-a4bc883595df9ea0f752079519081bc602408553.zip | |
chore: refactoring and tests (#12468)
Diffstat (limited to 'packages/app/src/pages/session')
| -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 |
4 files changed, 500 insertions, 0 deletions
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, + } +} |
