Skip to content

Commit 5b2440c

Browse files
committed
Merge branch 'todo/viewport-row-windowing-prototype'
# Conflicts: # package.json # src/ui/components/panes/DiffPane.tsx
2 parents d9f4458 + 419de1f commit 5b2440c

5 files changed

Lines changed: 339 additions & 25 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"test": "bun test",
1818
"test:tty-smoke": "bun test test/tty-render-smoke.test.ts",
1919
"bench:bootstrap-load": "bun run test/bootstrap-load-benchmark.ts",
20-
"bench:highlight-prefetch": "bun run test/adjacent-highlight-prefetch-benchmark.ts"
20+
"bench:highlight-prefetch": "bun run test/adjacent-highlight-prefetch-benchmark.ts",
21+
"bench:large-stream": "bun run test/large-stream-windowing-benchmark.ts"
2122
},
2223
"devDependencies": {
2324
"@types/bun": "latest",

src/ui/components/panes/DiffPane.tsx

Lines changed: 110 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import type { ScrollBoxRenderable } from "@opentui/core";
22
import { useCallback, useEffect, useMemo, useState, type RefObject } from "react";
33
import type { AgentAnnotation, DiffFile, LayoutMode } from "../../../core/types";
44
import type { VisibleAgentNote } from "../../lib/agentAnnotations";
5+
import { estimateDiffBodyRows } from "../../lib/sectionHeights";
56
import type { AppTheme } from "../../themes";
67
import { DiffSection } from "./DiffSection";
8+
import { DiffSectionPlaceholder } from "./DiffSectionPlaceholder";
79

810
const EMPTY_VISIBLE_AGENT_NOTES: VisibleAgentNote[] = [];
911

@@ -117,6 +119,74 @@ export function DiffPane({
117119
return next;
118120
}, [activeAnnotations, dismissedAgentNoteIds, selectedFileId, selectedHunkIndex, showAgentNotes]);
119121

122+
// Keep exact row rendering for wrapped lines and visible notes; otherwise reserve
123+
// offscreen section height and only materialize rows near the viewport.
124+
const windowingEnabled = !wrapLines && visibleAgentNotesByFile.size === 0;
125+
const [scrollViewport, setScrollViewport] = useState({ top: 0, height: 0 });
126+
127+
useEffect(() => {
128+
if (!windowingEnabled) {
129+
setScrollViewport({ top: 0, height: 0 });
130+
return;
131+
}
132+
133+
const updateViewport = () => {
134+
const nextTop = scrollRef.current?.scrollTop ?? 0;
135+
const nextHeight = scrollRef.current?.viewport.height ?? 0;
136+
137+
setScrollViewport((current) =>
138+
current.top === nextTop && current.height === nextHeight ? current : { top: nextTop, height: nextHeight },
139+
);
140+
};
141+
142+
updateViewport();
143+
const interval = setInterval(updateViewport, 50);
144+
return () => clearInterval(interval);
145+
}, [scrollRef, windowingEnabled]);
146+
147+
const estimatedBodyHeights = useMemo(
148+
() => files.map((file) => estimateDiffBodyRows(file, layout, showHunkHeaders)),
149+
[files, layout, showHunkHeaders],
150+
);
151+
152+
const visibleWindowedFileIds = useMemo(() => {
153+
if (!windowingEnabled) {
154+
return null;
155+
}
156+
157+
const overscanRows = 40;
158+
const minVisibleY = Math.max(0, scrollViewport.top - overscanRows);
159+
const maxVisibleY = scrollViewport.top + scrollViewport.height + overscanRows;
160+
let offsetY = 0;
161+
const next = new Set<string>();
162+
163+
files.forEach((file, index) => {
164+
const sectionHeight = (index > 0 ? 1 : 0) + 1 + (estimatedBodyHeights[index] ?? 0);
165+
const sectionStart = offsetY;
166+
const sectionEnd = sectionStart + sectionHeight;
167+
168+
if (
169+
file.id === selectedFileId ||
170+
adjacentPrefetchFileIds.has(file.id) ||
171+
(sectionEnd >= minVisibleY && sectionStart <= maxVisibleY)
172+
) {
173+
next.add(file.id);
174+
}
175+
176+
offsetY = sectionEnd;
177+
});
178+
179+
return next;
180+
}, [
181+
adjacentPrefetchFileIds,
182+
estimatedBodyHeights,
183+
files,
184+
scrollViewport.height,
185+
scrollViewport.top,
186+
selectedFileId,
187+
windowingEnabled,
188+
]);
189+
120190
return (
121191
<box
122192
style={{
@@ -144,30 +214,46 @@ export function DiffPane({
144214
horizontalScrollbarOptions={{ visible: false }}
145215
>
146216
<box style={{ width: "100%", flexDirection: "column" }}>
147-
{files.map((file, index) => (
148-
<DiffSection
149-
key={file.id}
150-
file={file}
151-
headerLabelWidth={headerLabelWidth}
152-
headerStatsWidth={headerStatsWidth}
153-
layout={layout}
154-
selected={file.id === selectedFileId}
155-
selectedHunkIndex={file.id === selectedFileId ? selectedHunkIndex : -1}
156-
shouldLoadHighlight={file.id === selectedFileId || adjacentPrefetchFileIds.has(file.id)}
157-
onHighlightReady={file.id === selectedFileId ? handleSelectedHighlightReady : undefined}
158-
separatorWidth={separatorWidth}
159-
showSeparator={index > 0}
160-
showLineNumbers={showLineNumbers}
161-
showHunkHeaders={showHunkHeaders}
162-
wrapLines={wrapLines}
163-
theme={theme}
164-
viewWidth={diffContentWidth}
165-
visibleAgentNotes={visibleAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES}
166-
onDismissAgentNote={onDismissAgentNote}
167-
onOpenAgentNotesAtHunk={(hunkIndex) => onOpenAgentNotesAtHunk(file.id, hunkIndex)}
168-
onSelect={() => onSelectFile(file.id)}
169-
/>
170-
))}
217+
{files.map((file, index) => {
218+
const shouldRenderSection = visibleWindowedFileIds?.has(file.id) ?? true;
219+
220+
return shouldRenderSection ? (
221+
<DiffSection
222+
key={file.id}
223+
file={file}
224+
headerLabelWidth={headerLabelWidth}
225+
headerStatsWidth={headerStatsWidth}
226+
layout={layout}
227+
selected={file.id === selectedFileId}
228+
selectedHunkIndex={file.id === selectedFileId ? selectedHunkIndex : -1}
229+
shouldLoadHighlight={file.id === selectedFileId || adjacentPrefetchFileIds.has(file.id)}
230+
onHighlightReady={file.id === selectedFileId ? handleSelectedHighlightReady : undefined}
231+
separatorWidth={separatorWidth}
232+
showSeparator={index > 0}
233+
showLineNumbers={showLineNumbers}
234+
showHunkHeaders={showHunkHeaders}
235+
wrapLines={wrapLines}
236+
theme={theme}
237+
viewWidth={diffContentWidth}
238+
visibleAgentNotes={visibleAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES}
239+
onDismissAgentNote={onDismissAgentNote}
240+
onOpenAgentNotesAtHunk={(hunkIndex) => onOpenAgentNotesAtHunk(file.id, hunkIndex)}
241+
onSelect={() => onSelectFile(file.id)}
242+
/>
243+
) : (
244+
<DiffSectionPlaceholder
245+
key={file.id}
246+
bodyHeight={estimatedBodyHeights[index] ?? 0}
247+
file={file}
248+
headerLabelWidth={headerLabelWidth}
249+
headerStatsWidth={headerStatsWidth}
250+
separatorWidth={separatorWidth}
251+
showSeparator={index > 0}
252+
theme={theme}
253+
onSelect={() => onSelectFile(file.id)}
254+
/>
255+
);
256+
})}
171257
</box>
172258
</scrollbox>
173259
) : (
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { DiffFile } from "../../../core/types";
2+
import { diffSectionId } from "../../lib/ids";
3+
import { fileLabel } from "../../lib/files";
4+
import { fitText } from "../../lib/text";
5+
import type { AppTheme } from "../../themes";
6+
7+
interface DiffSectionPlaceholderProps {
8+
bodyHeight: number;
9+
file: DiffFile;
10+
headerLabelWidth: number;
11+
headerStatsWidth: number;
12+
separatorWidth: number;
13+
showSeparator: boolean;
14+
theme: AppTheme;
15+
onSelect: () => void;
16+
}
17+
18+
/** Reserve offscreen section height without mounting its full diff rows. */
19+
export function DiffSectionPlaceholder({
20+
bodyHeight,
21+
file,
22+
headerLabelWidth,
23+
headerStatsWidth,
24+
separatorWidth,
25+
showSeparator,
26+
theme,
27+
onSelect,
28+
}: DiffSectionPlaceholderProps) {
29+
const additionsText = `+${file.stats.additions}`;
30+
const deletionsText = `-${file.stats.deletions}`;
31+
32+
return (
33+
<box
34+
id={diffSectionId(file.id)}
35+
style={{
36+
width: "100%",
37+
flexDirection: "column",
38+
backgroundColor: theme.panel,
39+
}}
40+
>
41+
{showSeparator ? (
42+
<box
43+
style={{
44+
width: "100%",
45+
height: 1,
46+
paddingLeft: 1,
47+
paddingRight: 1,
48+
backgroundColor: theme.panel,
49+
}}
50+
>
51+
<text fg={theme.border}>{fitText("─".repeat(separatorWidth), separatorWidth)}</text>
52+
</box>
53+
) : null}
54+
55+
<box
56+
style={{
57+
width: "100%",
58+
height: 1,
59+
flexDirection: "row",
60+
justifyContent: "space-between",
61+
paddingLeft: 1,
62+
paddingRight: 1,
63+
backgroundColor: theme.panel,
64+
}}
65+
onMouseUp={onSelect}
66+
>
67+
<text fg={theme.text}>{fitText(fileLabel(file), headerLabelWidth)}</text>
68+
<box style={{ width: headerStatsWidth, height: 1, flexDirection: "row", justifyContent: "flex-end" }}>
69+
<text fg={theme.badgeAdded}>{additionsText}</text>
70+
<text fg={theme.muted}> </text>
71+
<text fg={theme.badgeRemoved}>{deletionsText}</text>
72+
</box>
73+
</box>
74+
75+
<box style={{ width: "100%", height: bodyHeight, backgroundColor: theme.panel }} />
76+
</box>
77+
);
78+
}

src/ui/lib/sectionHeights.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { FileDiffMetadata } from "@pierre/diffs";
2+
import type { DiffFile, LayoutMode } from "../../core/types";
3+
4+
/** Count hidden unchanged lines after the final visible hunk when Pierre omits them. */
5+
function trailingCollapsedLines(metadata: FileDiffMetadata) {
6+
const lastHunk = metadata.hunks.at(-1);
7+
if (!lastHunk || metadata.isPartial) {
8+
return 0;
9+
}
10+
11+
const additionRemaining = metadata.additionLines.length - (lastHunk.additionLineIndex + lastHunk.additionCount);
12+
const deletionRemaining = metadata.deletionLines.length - (lastHunk.deletionLineIndex + lastHunk.deletionCount);
13+
14+
if (additionRemaining !== deletionRemaining) {
15+
return 0;
16+
}
17+
18+
return Math.max(additionRemaining, 0);
19+
}
20+
21+
/** Estimate the number of diff-body rows for one file when wrapping and note cards are off. */
22+
export function estimateDiffBodyRows(
23+
file: DiffFile,
24+
layout: Exclude<LayoutMode, "auto">,
25+
showHunkHeaders: boolean,
26+
) {
27+
if (file.metadata.hunks.length === 0) {
28+
return 1;
29+
}
30+
31+
let rows = 0;
32+
33+
for (const hunk of file.metadata.hunks) {
34+
if (hunk.collapsedBefore > 0) {
35+
rows += 1;
36+
}
37+
38+
if (showHunkHeaders) {
39+
rows += 1;
40+
}
41+
42+
for (const content of hunk.hunkContent) {
43+
if (content.type === "context") {
44+
rows += content.lines;
45+
continue;
46+
}
47+
48+
rows += layout === "split" ? Math.max(content.deletions, content.additions) : content.deletions + content.additions;
49+
}
50+
}
51+
52+
if (trailingCollapsedLines(file.metadata) > 0) {
53+
rows += 1;
54+
}
55+
56+
return rows;
57+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Measure first-frame cost for a very large multi-file review stream.
2+
import { performance } from "perf_hooks";
3+
import React from "react";
4+
import { testRender } from "@opentui/react/test-utils";
5+
import { parseDiffFromFile } from "@pierre/diffs";
6+
import { act } from "react";
7+
import { App } from "../src/ui/App";
8+
import type { AppBootstrap, DiffFile } from "../src/core/types";
9+
10+
const FILE_COUNT = 180;
11+
const LINES_PER_FILE = 120;
12+
13+
function createDiffFile(index: number): DiffFile {
14+
const path = `src/stream${index}.ts`;
15+
const before = Array.from({ length: LINES_PER_FILE }, (_, lineIndex) => {
16+
const line = lineIndex + 1;
17+
return `export function stream${index}_${line}(value: number) { return value + ${line}; }\n`;
18+
}).join("");
19+
20+
const after = Array.from({ length: LINES_PER_FILE }, (_, lineIndex) => {
21+
const line = lineIndex + 1;
22+
if (lineIndex >= 36 && lineIndex < 84) {
23+
return `export function stream${index}_${line}(value: number) { return value * ${line} + ${index}; }\n`;
24+
}
25+
26+
return `export function stream${index}_${line}(value: number) { return value + ${line}; }\n`;
27+
}).join("");
28+
29+
const metadata = parseDiffFromFile(
30+
{
31+
name: path,
32+
contents: before,
33+
cacheKey: `stream:${index}:before`,
34+
},
35+
{
36+
name: path,
37+
contents: after,
38+
cacheKey: `stream:${index}:after`,
39+
},
40+
{ context: 3 },
41+
true,
42+
);
43+
44+
return {
45+
id: `stream:${index}`,
46+
path,
47+
patch: "",
48+
language: "typescript",
49+
stats: { additions: 48, deletions: 48 },
50+
metadata,
51+
agent: null,
52+
};
53+
}
54+
55+
function createBootstrap(): AppBootstrap {
56+
return {
57+
input: {
58+
kind: "git",
59+
staged: false,
60+
options: {
61+
mode: "auto",
62+
},
63+
},
64+
changeset: {
65+
id: "changeset:large-stream-windowing",
66+
sourceLabel: "repo",
67+
title: "repo working tree",
68+
files: Array.from({ length: FILE_COUNT }, (_, index) => createDiffFile(index + 1)),
69+
},
70+
initialMode: "split",
71+
initialTheme: "midnight",
72+
};
73+
}
74+
75+
const start = performance.now();
76+
const setup = await testRender(React.createElement(App, { bootstrap: createBootstrap() }), { width: 240, height: 28 });
77+
78+
try {
79+
await act(async () => {
80+
await setup.renderOnce();
81+
await Bun.sleep(0);
82+
});
83+
84+
const firstFrameMs = performance.now() - start;
85+
console.log(`METRIC first_frame_ms=${firstFrameMs.toFixed(2)}`);
86+
console.log(`METRIC files=${FILE_COUNT}`);
87+
console.log(`METRIC lines_per_file=${LINES_PER_FILE}`);
88+
} finally {
89+
await act(async () => {
90+
setup.renderer.destroy();
91+
});
92+
}

0 commit comments

Comments
 (0)