summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages/session
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 09:37:49 -0600
committerGitHub <[email protected]>2026-02-06 09:37:49 -0600
commita4bc883595df9ea0f752079519081bc602408553 (patch)
tree583f21642f431899abe1dfb1f6bd9b2c01dc0206 /packages/app/src/pages/session
parentc07077f96c0019b2e18e0e8e1e0383deda08b3e6 (diff)
downloadopencode-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.ts61
-rw-r--r--packages/app/src/pages/session/helpers.ts38
-rw-r--r--packages/app/src/pages/session/scroll-spy.test.ts127
-rw-r--r--packages/app/src/pages/session/scroll-spy.ts274
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,
+ }
+}