From ecb274273a04920c215625b4bf93845d166411e2 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 07:25:58 -0600 Subject: wip(ui): diff virtualization (#12693) --- packages/ui/src/components/code.tsx | 15 +++++- packages/ui/src/components/diff-ssr.tsx | 86 ++++++++++++++++++++++----------- packages/ui/src/components/diff.tsx | 62 ++++++++++++++++-------- packages/ui/src/context/marked.tsx | 2 +- packages/ui/src/pierre/index.ts | 70 +++++++++------------------ packages/ui/src/pierre/virtualizer.ts | 76 +++++++++++++++++++++++++++++ packages/ui/src/pierre/worker.ts | 1 + 7 files changed, 212 insertions(+), 100 deletions(-) create mode 100644 packages/ui/src/pierre/virtualizer.ts (limited to 'packages/ui') diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index 4e7c82d78..2fe0e0352 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -318,7 +318,7 @@ export function Code(props: CodeProps) { const needle = query.toLowerCase() const out: Range[] = [] - const cols = Array.from(root.querySelectorAll("[data-column-content]")).filter( + const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter( (node): node is HTMLElement => node instanceof HTMLElement, ) @@ -537,17 +537,28 @@ export function Code(props: CodeProps) { node.removeAttribute("data-comment-selected") } + const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + for (const range of ranges) { const start = Math.max(1, Math.min(range.start, range.end)) const end = Math.max(range.start, range.end) for (let line = start; line <= end; line++) { - const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"]`)) + const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`)) for (const node of nodes) { if (!(node instanceof HTMLElement)) continue node.setAttribute("data-comment-selected", "") } } + + for (const annotation of annotations) { + const line = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10) + if (Number.isNaN(line)) continue + if (line < start || line > end) continue + annotation.setAttribute("data-comment-selected", "") + } } } diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx index 602e59a2f..e739afc16 100644 --- a/packages/ui/src/components/diff-ssr.tsx +++ b/packages/ui/src/components/diff-ssr.tsx @@ -1,8 +1,9 @@ -import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange } from "@pierre/diffs" +import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" import { Dynamic, isServer } from "solid-js/web" import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre" +import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" import { useWorkerPool } from "../context/worker-pool" export type SSRDiffProps = DiffProps & { @@ -24,10 +25,21 @@ export function Diff(props: SSRDiffProps) { const workerPool = useWorkerPool(props.diffStyle) let fileDiffInstance: FileDiff | undefined + let sharedVirtualizer: NonNullable> | undefined const cleanupFunctions: Array<() => void> = [] const getRoot = () => fileDiffRef?.shadowRoot ?? undefined + const getVirtualizer = () => { + if (sharedVirtualizer) return sharedVirtualizer.virtualizer + + const result = acquireVirtualizer(container) + if (!result) return + + sharedVirtualizer = result + return result.virtualizer + } + const applyScheme = () => { const scheme = document.documentElement.dataset.colorScheme if (scheme === "dark" || scheme === "light") { @@ -70,10 +82,10 @@ export function Diff(props: SSRDiffProps) { const root = getRoot() if (!root) return - const diffs = root.querySelector("[data-diffs]") + const diffs = root.querySelector("[data-diff]") if (!(diffs instanceof HTMLElement)) return - const split = diffs.dataset.type === "split" + const split = diffs.dataset.diffType === "split" const start = rowIndex(root, split, range.start, range.side) const end = rowIndex(root, split, range.end, range.endSide ?? range.side) @@ -132,15 +144,19 @@ export function Diff(props: SSRDiffProps) { node.removeAttribute("data-comment-selected") } - const diffs = root.querySelector("[data-diffs]") + const diffs = root.querySelector("[data-diff]") if (!(diffs instanceof HTMLElement)) return - const split = diffs.dataset.type === "split" + const split = diffs.dataset.diffType === "split" + + const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + if (rows.length === 0) return - const code = Array.from(diffs.querySelectorAll("[data-code]")).filter( + const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter( (node): node is HTMLElement => node instanceof HTMLElement, ) - if (code.length === 0) return const lineIndex = (element: HTMLElement) => { const raw = element.dataset.lineIndex @@ -183,19 +199,18 @@ export function Diff(props: SSRDiffProps) { const first = Math.min(start, end) const last = Math.max(start, end) - for (const block of code) { - for (const element of Array.from(block.children)) { - if (!(element instanceof HTMLElement)) continue - const idx = lineIndex(element) - if (idx === undefined) continue - if (idx > last) break - if (idx < first) continue - element.setAttribute("data-comment-selected", "") - const next = element.nextSibling - if (next instanceof HTMLElement && next.hasAttribute("data-line-annotation")) { - next.setAttribute("data-comment-selected", "") - } - } + for (const row of rows) { + const idx = lineIndex(row) + if (idx === undefined) continue + if (idx < first || idx > last) continue + row.setAttribute("data-comment-selected", "") + } + + for (const annotation of annotations) { + const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10) + if (Number.isNaN(idx)) continue + if (idx < first || idx > last) continue + annotation.setAttribute("data-comment-selected", "") } } } @@ -212,14 +227,27 @@ export function Diff(props: SSRDiffProps) { onCleanup(() => monitor.disconnect()) } - fileDiffInstance = new FileDiff( - { - ...createDefaultOptions(props.diffStyle), - ...others, - ...props.preloadedDiff, - }, - workerPool, - ) + const virtualizer = getVirtualizer() + + fileDiffInstance = virtualizer + ? new VirtualizedFileDiff( + { + ...createDefaultOptions(props.diffStyle), + ...others, + ...props.preloadedDiff, + }, + virtualizer, + virtualMetrics, + workerPool, + ) + : new FileDiff( + { + ...createDefaultOptions(props.diffStyle), + ...others, + ...props.preloadedDiff, + }, + workerPool, + ) // @ts-expect-error - fileContainer is private but needed for SSR hydration fileDiffInstance.fileContainer = fileDiffRef fileDiffInstance.hydrate({ @@ -273,6 +301,8 @@ export function Diff(props: SSRDiffProps) { // Clean up FileDiff event handlers and dispose SolidJS components fileDiffInstance?.cleanUp() cleanupFunctions.forEach((dispose) => dispose()) + sharedVirtualizer?.release() + sharedVirtualizer = undefined }) return ( diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index 21dada535..0966db75e 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -1,8 +1,9 @@ import { checksum } from "@opencode-ai/util/encode" -import { FileDiff, type SelectedLineRange } from "@pierre/diffs" +import { FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" import { createMediaQuery } from "@solid-primitives/media" import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js" import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre" +import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" import { getWorkerPool } from "../pierre/worker" type SelectionSide = "additions" | "deletions" @@ -52,6 +53,7 @@ function findSide(node: Node | null): SelectionSide | undefined { export function Diff(props: DiffProps) { let container!: HTMLDivElement let observer: MutationObserver | undefined + let sharedVirtualizer: NonNullable> | undefined let renderToken = 0 let selectionFrame: number | undefined let dragFrame: number | undefined @@ -92,6 +94,16 @@ export function Diff(props: DiffProps) { const [current, setCurrent] = createSignal | undefined>(undefined) const [rendered, setRendered] = createSignal(0) + const getVirtualizer = () => { + if (sharedVirtualizer) return sharedVirtualizer.virtualizer + + const result = acquireVirtualizer(container) + if (!result) return + + sharedVirtualizer = result + return result.virtualizer + } + const getRoot = () => { const host = container.querySelector("diffs-container") if (!(host instanceof HTMLElement)) return @@ -147,10 +159,10 @@ export function Diff(props: DiffProps) { const root = getRoot() if (!root) return - const diffs = root.querySelector("[data-diffs]") + const diffs = root.querySelector("[data-diff]") if (!(diffs instanceof HTMLElement)) return - const split = diffs.dataset.type === "split" + const split = diffs.dataset.diffType === "split" const start = rowIndex(root, split, range.start, range.side) const end = rowIndex(root, split, range.end, range.endSide ?? range.side) @@ -261,15 +273,19 @@ export function Diff(props: DiffProps) { node.removeAttribute("data-comment-selected") } - const diffs = root.querySelector("[data-diffs]") + const diffs = root.querySelector("[data-diff]") if (!(diffs instanceof HTMLElement)) return - const split = diffs.dataset.type === "split" + const split = diffs.dataset.diffType === "split" + + const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + if (rows.length === 0) return - const code = Array.from(diffs.querySelectorAll("[data-code]")).filter( + const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter( (node): node is HTMLElement => node instanceof HTMLElement, ) - if (code.length === 0) return for (const range of ranges) { const start = rowIndex(root, split, range.start, range.side) @@ -285,19 +301,18 @@ export function Diff(props: DiffProps) { const first = Math.min(start, end) const last = Math.max(start, end) - for (const block of code) { - for (const element of Array.from(block.children)) { - if (!(element instanceof HTMLElement)) continue - const idx = lineIndex(split, element) - if (idx === undefined) continue - if (idx > last) break - if (idx < first) continue - element.setAttribute("data-comment-selected", "") - const next = element.nextSibling - if (next instanceof HTMLElement && next.hasAttribute("data-line-annotation")) { - next.setAttribute("data-comment-selected", "") - } - } + for (const row of rows) { + const idx = lineIndex(split, row) + if (idx === undefined) continue + if (idx < first || idx > last) continue + row.setAttribute("data-comment-selected", "") + } + + for (const annotation of annotations) { + const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10) + if (Number.isNaN(idx)) continue + if (idx < first || idx > last) continue + annotation.setAttribute("data-comment-selected", "") } } } @@ -514,12 +529,15 @@ export function Diff(props: DiffProps) { createEffect(() => { const opts = options() const workerPool = getWorkerPool(props.diffStyle) + const virtualizer = getVirtualizer() const annotations = local.annotations const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : "" const afterContents = typeof local.after?.contents === "string" ? local.after.contents : "" instance?.cleanUp() - instance = new FileDiff(opts, workerPool) + instance = virtualizer + ? new VirtualizedFileDiff(opts, virtualizer, virtualMetrics, workerPool) + : new FileDiff(opts, workerPool) setCurrent(instance) container.innerHTML = "" @@ -606,6 +624,8 @@ export function Diff(props: DiffProps) { instance?.cleanUp() setCurrent(undefined) + sharedVirtualizer?.release() + sharedVirtualizer = undefined }) return
diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 0c6d58b93..c5ff3c767 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -10,7 +10,7 @@ registerCustomTheme("OpenCode", () => { return Promise.resolve({ name: "OpenCode", colors: { - "editor.background": "transparent", + "editor.background": "var(--color-background-stronger)", "editor.foreground": "var(--text-base)", "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)", "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)", diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index f6446f3cc..dc9d857bf 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -13,7 +13,7 @@ export type DiffProps = FileDiffOptions & { } const unsafeCSS = ` -[data-diffs] { +[data-diff] { --diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg)); --diffs-bg-buffer: var(--diffs-bg-buffer-override, light-dark( color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)))); --diffs-bg-hover: var(--diffs-bg-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer)))); @@ -44,7 +44,7 @@ const unsafeCSS = ` --diffs-bg-selection-text: rgb(from var(--surface-warning-strong) r g b / 0.2); } -:host([data-color-scheme='dark']) [data-diffs] { +:host([data-color-scheme='dark']) [data-diff] { --diffs-selection-number-fg: #fdfbfb; --diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--solaris-dark-6) r g b / 0.65)); --diffs-bg-selection-number: var( @@ -53,7 +53,7 @@ const unsafeCSS = ` ); } -[data-diffs] ::selection { +[data-diff] ::selection { background-color: var(--diffs-bg-selection-text); } @@ -65,61 +65,48 @@ const unsafeCSS = ` background-color: rgb(from var(--surface-warning-strong) r g b / 0.55); } -[data-diffs] [data-comment-selected]:not([data-selected-line]) [data-column-content] { +[data-diff] [data-line][data-comment-selected]:not([data-selected-line]) { box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection); } -[data-diffs] [data-comment-selected]:not([data-selected-line]) [data-column-number] { +[data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) { box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number); color: var(--diffs-selection-number-fg); } -[data-diffs] [data-selected-line] { +[data-diff] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] { + box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection); +} + +[data-diff] [data-line][data-selected-line] { background-color: var(--diffs-bg-selection); box-shadow: inset 2px 0 0 var(--diffs-selection-border); } -[data-diffs] [data-selected-line] [data-column-number] { +[data-diff] [data-column-number][data-selected-line] { background-color: var(--diffs-bg-selection-number); color: var(--diffs-selection-number-fg); } -[data-diffs] [data-line-type='context'][data-selected-line] [data-column-number], -[data-diffs] [data-line-type='context-expanded'][data-selected-line] [data-column-number], -[data-diffs] [data-line-type='change-addition'][data-selected-line] [data-column-number], -[data-diffs] [data-line-type='change-deletion'][data-selected-line] [data-column-number] { +[data-diff] [data-column-number][data-line-type='context'][data-selected-line], +[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line], +[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line], +[data-diff] [data-column-number][data-line-type='change-deletion'][data-selected-line] { color: var(--diffs-selection-number-fg); } /* The deletion word-diff emphasis is stronger than additions; soften it while selected so the selection highlight reads consistently. */ -[data-diffs] [data-line-type='change-deletion'][data-selected-line] { +[data-diff] [data-line][data-line-type='change-deletion'][data-selected-line] { --diffs-bg-deletion-emphasis: light-dark( rgb(from var(--diffs-deletion-base) r g b / 0.07), rgb(from var(--diffs-deletion-base) r g b / 0.1) ); } -[data-diffs-header], -[data-diffs] { - [data-separator-wrapper] { - margin: 0 !important; - border-radius: 0 !important; - } - [data-expand-button] { - width: 6.5ch !important; - height: 24px !important; - justify-content: end !important; - padding-left: 3ch !important; - padding-inline: 1ch !important; - } - [data-separator-multi-button] { - grid-template-rows: 10px 10px !important; - [data-expand-button] { - height: 12px !important; - } - } - [data-separator-content] { - height: 24px !important; +[data-diff-header], +[data-diff] { + [data-separator] { + height: 24px; } [data-column-number] { background-color: var(--background-stronger); @@ -146,28 +133,15 @@ export function createDefaultOptions(style: FileDiffOptions["diffStyle"]) overflow: "wrap", diffStyle: style ?? "unified", diffIndicators: "bars", + lineHoverHighlight: "both", disableBackground: false, expansionLineCount: 20, + hunkSeparators: "line-info-basic", lineDiffType: style === "split" ? "word-alt" : "none", maxLineDiffLength: 1000, maxLineLengthForHighlighting: 1000, disableFileHeader: true, unsafeCSS, - // hunkSeparators(hunkData: HunkData) { - // const fragment = document.createDocumentFragment() - // const numCol = document.createElement("div") - // numCol.innerHTML = ` ` - // numCol.dataset["slot"] = "diff-hunk-separator-line-number" - // fragment.appendChild(numCol) - // const contentCol = document.createElement("div") - // contentCol.dataset["slot"] = "diff-hunk-separator-content" - // const span = document.createElement("span") - // span.dataset["slot"] = "diff-hunk-separator-content-span" - // span.textContent = `${hunkData.lines} unmodified lines` - // contentCol.appendChild(span) - // fragment.appendChild(contentCol) - // return fragment - // }, } as const } diff --git a/packages/ui/src/pierre/virtualizer.ts b/packages/ui/src/pierre/virtualizer.ts new file mode 100644 index 000000000..4957afc12 --- /dev/null +++ b/packages/ui/src/pierre/virtualizer.ts @@ -0,0 +1,76 @@ +import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs" + +type Target = { + key: Document | HTMLElement + root: Document | HTMLElement + content: HTMLElement | undefined +} + +type Entry = { + virtualizer: Virtualizer + refs: number +} + +const cache = new WeakMap() + +export const virtualMetrics: Partial = { + lineHeight: 24, + hunkSeparatorHeight: 24, + fileGap: 0, +} + +function target(container: HTMLElement): Target | undefined { + if (typeof document === "undefined") return + + const root = container.closest("[data-component='session-review']") + if (root instanceof HTMLElement) { + const content = root.querySelector("[data-slot='session-review-container']") + return { + key: root, + root, + content: content instanceof HTMLElement ? content : undefined, + } + } + + return { + key: document, + root: document, + content: undefined, + } +} + +export function acquireVirtualizer(container: HTMLElement) { + const resolved = target(container) + if (!resolved) return + + let entry = cache.get(resolved.key) + if (!entry) { + const virtualizer = new Virtualizer() + virtualizer.setup(resolved.root, resolved.content) + entry = { + virtualizer, + refs: 0, + } + cache.set(resolved.key, entry) + } + + entry.refs += 1 + let done = false + + return { + virtualizer: entry.virtualizer, + release() { + if (done) return + done = true + + const current = cache.get(resolved.key) + if (!current) return + + current.refs -= 1 + if (current.refs > 0) return + + current.virtualizer.cleanUp() + cache.delete(resolved.key) + }, + } +} diff --git a/packages/ui/src/pierre/worker.ts b/packages/ui/src/pierre/worker.ts index 0d117c368..1993ad7aa 100644 --- a/packages/ui/src/pierre/worker.ts +++ b/packages/ui/src/pierre/worker.ts @@ -21,6 +21,7 @@ function createPool(lineDiffType: "none" | "word-alt") { { theme: "OpenCode", lineDiffType, + preferredHighlighter: "shiki-wasm", }, ) -- cgit v1.2.3