summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-12 07:25:58 -0600
committerGitHub <[email protected]>2026-02-12 07:25:58 -0600
commitecb274273a04920c215625b4bf93845d166411e2 (patch)
treeb92d056f5cbacf7430edef41294b452f431c6b52
parent5f421883a8aa92338bee1399532f359c5e986f41 (diff)
downloadopencode-ecb274273a04920c215625b4bf93845d166411e2.tar.gz
opencode-ecb274273a04920c215625b4bf93845d166411e2.zip
wip(ui): diff virtualization (#12693)
-rw-r--r--bun.lock30
-rw-r--r--package.json2
-rw-r--r--packages/app/src/pages/session/review-tab.tsx2
-rw-r--r--packages/ui/src/components/code.tsx15
-rw-r--r--packages/ui/src/components/diff-ssr.tsx86
-rw-r--r--packages/ui/src/components/diff.tsx62
-rw-r--r--packages/ui/src/context/marked.tsx2
-rw-r--r--packages/ui/src/pierre/index.ts70
-rw-r--r--packages/ui/src/pierre/virtualizer.ts76
-rw-r--r--packages/ui/src/pierre/worker.ts1
10 files changed, 220 insertions, 126 deletions
diff --git a/bun.lock b/bun.lock
index 6f399805b..0131a8e0c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -513,7 +513,7 @@
"@kobalte/core": "0.13.11",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
- "@pierre/diffs": "1.0.2",
+ "@pierre/diffs": "1.1.0-beta.13",
"@playwright/test": "1.51.0",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4",
@@ -1409,7 +1409,7 @@
"@petamoriken/float16": ["@petamoriken/[email protected]", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
- "@pierre/diffs": ["@pierre/[email protected]", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="],
+ "@pierre/diffs": ["@pierre/[email protected]", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="],
"@pinojs/redact": ["@pinojs/[email protected]", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
@@ -4387,13 +4387,9 @@
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/[email protected]", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
- "@pierre/diffs/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
+ "@pierre/diffs/@shikijs/transformers": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
- "@pierre/diffs/@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ=="],
-
- "@pierre/diffs/@shikijs/transformers": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/types": "3.19.0" } }, "sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw=="],
-
- "@pierre/diffs/shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/engine-oniguruma": "3.19.0", "@shikijs/langs": "3.19.0", "@shikijs/themes": "3.19.0", "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-77VJr3OR/VUZzPiStyRhADmO2jApMM0V2b1qf0RpfWya8Zr1PeZev5AEpPGAAKWdiYUtcZGBE4F5QvJml1PvWA=="],
+ "@pierre/diffs/diff": ["[email protected]", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@poppinss/dumper/supports-color": ["[email protected]", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
@@ -4973,23 +4969,9 @@
"@opentui/solid/@babel/core/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
- "@pierre/diffs/@shikijs/core/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
-
- "@pierre/diffs/@shikijs/engine-javascript/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
-
- "@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="],
-
- "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
-
- "@pierre/diffs/shiki/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="],
-
- "@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1hRxtYIJfJSZeM5ivbUXv9hcJP3PWRo5prG/V2sWwiubUKTa+7P62d2qxCW8jiVFX4pgRHhnHNp+qeR7Xl+6kg=="],
-
- "@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg=="],
-
- "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A=="],
+ "@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
- "@pierre/diffs/shiki/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
+ "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@slack/web-api/form-data/mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
diff --git a/package.json b/package.json
index ae790e0a5..c396905d4 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
- "@pierre/diffs": "1.0.2",
+ "@pierre/diffs": "1.1.0-beta.13",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx
index a4232dd74..72518c68e 100644
--- a/packages/app/src/pages/session/review-tab.tsx
+++ b/packages/app/src/pages/session/review-tab.tsx
@@ -139,7 +139,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
open={props.view().review.open()}
onOpenChange={props.view().review.setOpen}
classes={{
- root: props.classes?.root ?? "pb-40",
+ root: props.classes?.root ?? "pb-6",
header: props.classes?.header ?? "px-6",
container: props.classes?.container ?? "px-6",
}}
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<T>(props: CodeProps<T>) {
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<T>(props: CodeProps<T>) {
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<T = {}> = DiffProps<T> & {
@@ -24,10 +25,21 @@ export function Diff<T>(props: SSRDiffProps<T>) {
const workerPool = useWorkerPool(props.diffStyle)
let fileDiffInstance: FileDiff<T> | undefined
+ let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | 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<T>(props: SSRDiffProps<T>) {
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<T>(props: SSRDiffProps<T>) {
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<T>(props: SSRDiffProps<T>) {
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<T>(props: SSRDiffProps<T>) {
onCleanup(() => monitor.disconnect())
}
- fileDiffInstance = new FileDiff<T>(
- {
- ...createDefaultOptions(props.diffStyle),
- ...others,
- ...props.preloadedDiff,
- },
- workerPool,
- )
+ const virtualizer = getVirtualizer()
+
+ fileDiffInstance = virtualizer
+ ? new VirtualizedFileDiff<T>(
+ {
+ ...createDefaultOptions(props.diffStyle),
+ ...others,
+ ...props.preloadedDiff,
+ },
+ virtualizer,
+ virtualMetrics,
+ workerPool,
+ )
+ : new FileDiff<T>(
+ {
+ ...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<T>(props: SSRDiffProps<T>) {
// 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<T>(props: DiffProps<T>) {
let container!: HTMLDivElement
let observer: MutationObserver | undefined
+ let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
let renderToken = 0
let selectionFrame: number | undefined
let dragFrame: number | undefined
@@ -92,6 +94,16 @@ export function Diff<T>(props: DiffProps<T>) {
const [current, setCurrent] = createSignal<FileDiff<T> | 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<T>(props: DiffProps<T>) {
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<T>(props: DiffProps<T>) {
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<T>(props: DiffProps<T>) {
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<T>(props: DiffProps<T>) {
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<T>(opts, workerPool)
+ instance = virtualizer
+ ? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
+ : new FileDiff<T>(opts, workerPool)
setCurrent(instance)
container.innerHTML = ""
@@ -606,6 +624,8 @@ export function Diff<T>(props: DiffProps<T>) {
instance?.cleanUp()
setCurrent(undefined)
+ sharedVirtualizer?.release()
+ sharedVirtualizer = undefined
})
return <div data-component="diff" style={styleVariables} ref={container} />
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<T = {}> = FileDiffOptions<T> & {
}
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<T>(style: FileDiffOptions<T>["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 = `<svg data-slot="diff-hunk-separator-line-number-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8.97978 14.0204L8.62623 13.6668L9.33334 12.9597L9.68689 13.3133L9.33333 13.6668L8.97978 14.0204ZM12 16.3335L12.3535 16.6871L12 17.0406L11.6464 16.687L12 16.3335ZM14.3131 13.3133L14.6667 12.9597L15.3738 13.6668L15.0202 14.0204L14.6667 13.6668L14.3131 13.3133ZM12.5 16.0002V16.5002H11.5V16.0002H12H12.5ZM9.33333 13.6668L9.68689 13.3133L12.3535 15.9799L12 16.3335L11.6464 16.687L8.97978 14.0204L9.33333 13.6668ZM12 16.3335L11.6464 15.9799L14.3131 13.3133L14.6667 13.6668L15.0202 14.0204L12.3535 16.6871L12 16.3335ZM6.5 8.00016V7.50016H8.5V8.00016V8.50016H6.5V8.00016ZM9.5 8.00016V7.50016H11.5V8.00016V8.50016H9.5V8.00016ZM12.5 8.00016V7.50016H14.5V8.00016V8.50016H12.5V8.00016ZM15.5 8.00016V7.50016H17.5V8.00016V8.50016H15.5V8.00016ZM12 10.5002H12.5V16.0002H12H11.5V10.5002H12Z" fill="currentColor"/></svg> `
- // 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<Document | HTMLElement, Entry>()
+
+export const virtualMetrics: Partial<VirtualFileMetrics> = {
+ 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",
},
)