Compare commits

...
Author SHA1 Message Date
vimtor ef9a4baf9e fix(tui): virtualize large added-file diffs 2026-09-24 13:36:07 +00:00
5 changed files with 321 additions and 27 deletions
+150 -27
View File
@@ -1,31 +1,53 @@
/** @jsxImportSource @opentui/solid */
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
import {
BoxRenderable,
DiffRenderable,
LineNumberRenderable,
type ColorInput,
type ScrollBoxRenderable,
} from "@opentui/core"
import type { JSX } from "@opentui/solid"
import { createMemo, For, Show, splitProps } from "solid-js"
import { splitPatchHunks } from "../util/diff"
import { useRenderer } from "@opentui/solid"
import { createEffect, createMemo, createSignal, For, onCleanup, Show, splitProps } from "solid-js"
import { splitAddedPatch, splitPatchHunks, type AddedPatchChunk } from "../util/diff"
import { stringWidth } from "../util/string-width"
export interface PatchDiffRef {
readonly hunks: () => readonly DiffRenderable[]
readonly hunks: () => readonly (DiffRenderable | BoxRenderable)[]
}
const VIRTUAL_CHUNK_LINES = 128
type Props = Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg" | "ref"> & {
diff: string
hunkFg: ColorInput
lineNumberBg: ColorInput
ref?: (value: PatchDiffRef) => void
virtualScroll?: () => ScrollBoxRenderable | undefined
viewportWidth?: number
}
export function PatchDiff(props: Props) {
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref"])
const [local, diffProps] = splitProps(props, [
"diff",
"hunkFg",
"lineNumberBg",
"ref",
"virtualScroll",
"viewportWidth",
])
const hunks = createMemo(() => splitPatchHunks(local.diff))
const chunks = createMemo(() => local.virtualScroll && splitAddedPatch(local.diff, VIRTUAL_CHUNK_LINES))
const nodes = new Map<number, DiffRenderable>()
let virtualRoot: BoxRenderable | undefined
local.ref?.({
hunks: () =>
[...nodes.entries()]
hunks: () => {
if (chunks()) return virtualRoot && !virtualRoot.isDestroyed ? [virtualRoot] : []
return [...nodes.entries()]
.sort(([left], [right]) => left - right)
.map(([, node]) => node)
.filter((node) => !node.isDestroyed),
.filter((node) => !node.isDestroyed)
},
})
const syncGutters = (attempt = 0) => {
requestAnimationFrame(() => {
@@ -55,29 +77,130 @@ export function PatchDiff(props: Props) {
}
const register = (index: number, node: DiffRenderable) => {
nodes.set(index, node)
onCleanup(() => nodes.delete(index))
syncGutters()
}
return (
<For each={hunks()}>
{(hunk, index) => (
<>
<Show when={index() > 0}>
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
<text fg={local.hunkFg} bg={local.lineNumberBg}>
{` ${hunk.header ?? ""}`}
</text>
</box>
</Show>
<diff
{...diffProps}
ref={(node: DiffRenderable) => register(index(), node)}
diff={hunk.patch}
minHeight={hunk.rows}
lineNumberBg={local.lineNumberBg}
/>
</>
<Show
when={chunks()}
fallback={
<For each={hunks()}>
{(hunk, index) => (
<>
<Show when={index() > 0}>
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
<text fg={local.hunkFg} bg={local.lineNumberBg}>
{` ${hunk.header ?? ""}`}
</text>
</box>
</Show>
<diff
{...diffProps}
ref={(node: DiffRenderable) => register(index(), node)}
diff={hunk.patch}
minHeight={hunk.rows}
lineNumberBg={local.lineNumberBg}
/>
</>
)}
</For>
}
>
{(items) => (
<VirtualAddedPatch
chunks={items()}
width={local.viewportWidth ?? 80}
scroll={local.virtualScroll!}
diffProps={diffProps}
lineNumberBg={local.lineNumberBg}
register={register}
registerRoot={(root) => (virtualRoot = root)}
/>
)}
</For>
</Show>
)
}
function VirtualAddedPatch(props: {
chunks: readonly AddedPatchChunk[]
width: number
scroll: () => ScrollBoxRenderable | undefined
diffProps: Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg" | "ref">
lineNumberBg: ColorInput
register: (index: number, node: DiffRenderable) => void
registerRoot: (root: BoxRenderable) => void
}) {
const renderer = useRenderer()
const [visible, setVisible] = createSignal(0)
const [measured, setMeasured] = createSignal<ReadonlyMap<number, number>>(new Map())
createEffect(() => {
props.width
props.chunks
setMeasured(new Map())
})
// Offscreen chunks need heights for scroll jumps before OpenTUI has measured them.
// Replace those estimates with actual rendered heights as chunks enter the viewport.
const estimates = createMemo(() => {
const codeWidth = Math.max(
1,
props.width - String(props.chunks.reduce((count, chunk) => count + chunk.rows, 0)).length - 5,
)
return props.chunks.map((chunk) =>
chunk.lines.reduce((height, line) => height + Math.max(1, Math.ceil(stringWidth(line.slice(1)) / codeWidth)), 0),
)
})
const heights = createMemo(() => estimates().map((estimate, index) => measured().get(index) ?? estimate))
return (
<box
width="100%"
ref={(root: BoxRenderable) => {
props.registerRoot(root)
root.onLifecyclePass = () => {
const scroll = props.scroll()
if (!scroll) return
// ScrollBox's scroll position is not a Solid signal; observe it during the render pass.
const offset = root.y - scroll.content.y
const top = scroll.scrollTop - offset
const sizes = heights()
if (top + scroll.viewport.height < 0 || top > sizes.reduce((sum, height) => sum + height, 0)) {
setVisible(-1)
return
}
let position = 0
const index = sizes.findIndex((height) => (position += height) > top)
setVisible(index < 0 ? sizes.length - 1 : index)
}
renderer.registerLifecyclePass(root)
onCleanup(() => renderer.unregisterLifecyclePass(root))
}}
>
<For each={props.chunks}>
{(chunk, index) => (
<Show
when={visible() >= 0 && Math.abs(index() - visible()) <= 2}
fallback={<box height={heights()[index()]} />}
>
<diff
{...props.diffProps}
ref={(node: DiffRenderable) => {
props.register(index(), node)
node.onSizeChange = () => {
if (node.height <= 0 || measured().get(index()) === node.height) return
const scroll = props.scroll()
const atEnd = scroll && scroll.scrollTop >= scroll.scrollHeight - scroll.viewport.height - 1
setMeasured((known) => new Map(known).set(index(), node.height))
// Keep G pinned to the end when a newly mounted chunk changes total height.
if (atEnd) requestAnimationFrame(() => scroll.scrollTo(Infinity))
}
}}
diff={chunk.patch}
lineNumberBg={props.lineNumberBg}
/>
</Show>
)}
</For>
</box>
)
}
@@ -1024,6 +1024,12 @@ export function DiffViewerContent(props: {
onCleanup(() => patchDiffByFileIndex.delete(entry.fileIndex))
}}
diff={patch()}
virtualScroll={
entry.file.status === "added" && entry.file.additions > 1000
? () => scroll
: undefined
}
viewportWidth={patchPaneWidth()}
hunkFg={theme.diff.text.hunkHeader}
view={entry.file.status === "modified" ? view() : "unified"}
filetype={filetype(entry.file.file)}
+29
View File
@@ -4,6 +4,35 @@ export interface PatchHunk {
readonly rows?: number
}
export interface AddedPatchChunk {
readonly patch: string
readonly lines: readonly string[]
readonly rows: number
}
/** Only a complete, single-hunk new-file patch can be split without changing diff semantics. */
export function splitAddedPatch(patch: string, size: number): AddedPatchChunk[] | undefined {
const header = /^@@ -0,0 \+1,(\d+) @@[^\n]*\n/m.exec(patch)
if (!header) return
const count = Number(header[1])
const lines = patch
.slice(header.index + header[0].length)
.replace(/\n$/, "")
.split("\n")
const marker = lines.at(-1)?.startsWith("\\ No newline at end of file") ? lines.pop() : undefined
if (lines.length !== count || lines.some((line) => !line.startsWith("+"))) return
const prefix = patch.slice(0, header.index)
return Array.from({ length: Math.ceil(count / size) }, (_, index) => {
const start = index * size
const slice = lines.slice(start, start + size)
return {
patch: `${prefix}@@ -0,0 +${start + 1},${slice.length} @@\n${slice.join("\n")}${marker && start + size >= count ? `\n${marker}` : ""}`,
lines: slice,
rows: slice.length,
}
})
}
export function splitPatchHunks(patch: string): PatchHunk[] {
const starts = [...patch.matchAll(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@.*$/gm)].map((match) => match.index)
if (starts.length <= 1) return [{ patch }]
@@ -2058,6 +2058,98 @@ const manyDiffs = Array.from({ length: 40 }, (_, index) => ({
file: `file${String(index).padStart(2, "0")}.txt`,
}))
test.each([80, 160])("virtualizes a large added file at %i columns without losing its end", async (width) => {
const lines = [
"+{",
...Array.from(
{ length: 2500 },
(_, index) =>
`+ "row-${String(index).padStart(4, "0")}": "${"value".repeat(index === 777 ? 2000 : index % 7 === 0 ? 24 : 1)}"${index === 2499 ? "" : ","}`,
),
"+}",
]
const viewer = await renderDiffViewer(
[
{
file: "snapshot.json",
status: "added",
additions: lines.length,
deletions: 0,
patch: `diff --git a/snapshot.json b/snapshot.json\nnew file mode 100644\n--- /dev/null\n+++ b/snapshot.json\n@@ -0,0 +1,${lines.length} @@\n${lines.join("\n")}`,
},
],
{ width, height: 24 },
)
try {
expect(viewer.app.captureCharFrame()).toContain("row-0000")
expect(
findDiffs(viewer.app.renderer.root).reduce((total, node) => total + node.diff.split("\n").length, 0),
).toBeLessThan(900)
viewer.commands.get("diff.last")!.run()
await viewer.app.flush()
if (!viewer.app.captureCharFrame().includes("row-2499")) {
await viewer.app.waitForFrame((frame) => frame.includes("row-2499"))
}
expect(viewer.app.captureCharFrame()).toContain("row-2499")
expect(
findDiffs(viewer.app.renderer.root).reduce((total, node) => total + node.diff.split("\n").length, 0),
).toBeLessThan(900)
viewer.commands.get("diff.first")!.run()
await viewer.app.flush()
expect(viewer.app.captureCharFrame()).toContain("row-0000")
viewer.app.resize(width === 80 ? 160 : 80, 20)
await viewer.app.flush()
viewer.commands.get("diff.last")!.run()
await viewer.app.flush()
expect(viewer.app.captureCharFrame()).toContain("row-2499")
} finally {
viewer.app.renderer.destroy()
}
})
test("file navigation and review still work after a virtualized patch", async () => {
const additions = Array.from({ length: 2200 }, (_, index) => `+added line ${index}`)
const viewer = await renderDiffViewer(
[
{
file: "a-large.txt",
status: "added",
additions: additions.length,
deletions: 0,
patch: `--- /dev/null\n+++ b/a-large.txt\n@@ -0,0 +1,${additions.length} @@\n${additions.join("\n")}`,
},
{ ...hunkDiff[0], file: "b-small.txt" },
],
{ width: 160, height: 24 },
)
try {
const scroll = findScrollBox(viewer.app.renderer.root)!
scroll.scrollTo(900)
await viewer.app.flush()
viewer.commands.get("diff.previous_hunk")!.run()
await viewer.app.flush()
expect(viewer.app.captureCharFrame()).toContain("added line 0")
viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.flush()
expect(viewer.app.captureCharFrame()).toContain("b-small.txt")
viewer.commands.get("diff.next_file")!.run()
await viewer.app.flush()
expect(viewer.app.captureCharFrame()).toContain("b-small.txt")
expect(viewer.app.captureCharFrame()).toContain("const first")
viewer.commands.get("diff.previous_file")!.run()
await viewer.app.flush()
expect(viewer.app.captureCharFrame()).toContain("a-large.txt")
viewer.commands.get("diff.mark_reviewed")!.run()
await viewer.app.flush()
expect(viewer.app.captureCharFrame()).not.toContain("added line 0")
viewer.commands.get("diff.mark_reviewed")!.run()
await viewer.app.flush()
expect(viewer.app.captureCharFrame()).toContain("added line 0")
} finally {
viewer.app.renderer.destroy()
}
})
function findScrollBox(root: Renderable, patches = true): ScrollBoxRenderable | undefined {
const node = root.findDescendantById(patches ? "diff-patches" : "diff-files")
return node instanceof ScrollBoxRenderable ? node : undefined
@@ -0,0 +1,44 @@
import { expect, test } from "bun:test"
import { splitAddedPatch } from "../../src/util/diff"
test("splits a complete new-file patch into independently numbered chunks", () => {
const patch = `diff --git a/new.txt b/new.txt
new file mode 100644
--- /dev/null
+++ b/new.txt
@@ -0,0 +1,5 @@
+one
+++value beginning with plus signs
+three
+four
+five`
const chunks = splitAddedPatch(patch, 2)!
expect(chunks.map((chunk) => chunk.rows)).toEqual([2, 2, 1])
expect(chunks.map((chunk) => chunk.patch.match(/@@ -0,0 \+(\d+),(\d+) @@/)?.slice(1))).toEqual([
["1", "2"],
["3", "2"],
["5", "1"],
])
expect(chunks.flatMap((chunk) => chunk.lines)).toEqual([
"+one",
"+++value beginning with plus signs",
"+three",
"+four",
"+five",
])
expect(chunks.every((chunk) => chunk.patch.startsWith("diff --git a/new.txt b/new.txt"))).toBe(true)
})
test("retains a missing-final-newline marker only on the last chunk", () => {
const patch = `--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1,3 @@\n+one\n+two\n+three\n\\ No newline at end of file\n`
const chunks = splitAddedPatch(patch, 2)!
expect(chunks).toHaveLength(2)
expect(chunks[0].patch).not.toContain("No newline")
expect(chunks[1].patch).toContain("+three\n\\ No newline at end of file")
})
test("does not split partial or mixed patches", () => {
expect(splitAddedPatch("@@ -1 +1 @@\n-before\n+after", 2)).toBeUndefined()
expect(splitAddedPatch("@@ -0,0 +1,3 @@\n+one\n+two", 2)).toBeUndefined()
expect(splitAddedPatch("@@ -0,0 +1,2 @@\n+one\n two", 2)).toBeUndefined()
})