Skip to content

Commit 4cc1651

Browse files
committed
refactor(git): display performance improvement with virtualisation
- add @tanstack/react-virtual to the desktop app for Git diff row virtualization - virtualize large text diffs while preserving the existing two-pane side-by-side layout - keep binary files in staged and unstaged lists with placeholder-only rendering - cache rendered line HTML to reduce repeated Prism highlighting work during rerenders - raise auto-expand and syntax-highlighting thresholds for larger code and JSON diffs - fix virtualization to bind to the real scroll container instead of an inner grid - restore correct diff pane sizing, long-line background extension, and wider line-number gutters - preserve single-side text selection and full-copy behavior by falling back to full render during selection - keep SQL diffs syntax-highlighted instead of forcing plain-text rendering
1 parent 91b326b commit 4cc1651

6 files changed

Lines changed: 213 additions & 68 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"dependencies": {
1313
"@codelegate/shared": "workspace:*",
14+
"@tanstack/react-virtual": "^3.13.23",
1415
"@tauri-apps/api": "^2.0.0",
1516
"@tauri-apps/plugin-dialog": "^2.0.0",
1617
"@xterm/addon-fit": "^0.11.0",

apps/desktop/src/components/MainPane/tabs/git/GitDiff.module.css

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -435,10 +435,30 @@
435435
display: grid;
436436
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
437437
gap: 0;
438+
}
439+
440+
.diffBody {
441+
padding: 0;
442+
}
443+
444+
.diffGrid,
445+
.diffRowsSurface {
438446
font-family: "JetBrains Mono", "SF Mono", "Fira Code", monospace;
439447
font-size: 0.82rem;
440448
}
441449

450+
.diffRowsSurface {
451+
min-width: 0;
452+
overflow: auto;
453+
max-height: min(62vh, 640px);
454+
overscroll-behavior: contain;
455+
scrollbar-gutter: stable both-edges;
456+
}
457+
458+
.diffRowsSurface:focus {
459+
outline: none;
460+
}
461+
442462
.diffColumn {
443463
min-width: 0;
444464
overflow-x: auto;
@@ -456,13 +476,24 @@
456476

457477
.diffColumnRow {
458478
display: grid;
459-
grid-template-columns: 48px auto;
479+
grid-template-columns: 72px auto;
460480
width: 100%;
461481
}
462482

483+
.diffRowsList {
484+
min-width: 0;
485+
}
486+
487+
.diffVirtualInner {
488+
position: relative;
489+
min-width: 100%;
490+
}
491+
463492
.diffCell {
464493
padding: 4px 8px;
465494
background: var(--surface);
495+
min-height: 30px;
496+
box-sizing: border-box;
466497
}
467498

468499
.diffGutter,
@@ -478,9 +509,11 @@
478509
background: var(--surface-2);
479510
font-variant-numeric: tabular-nums;
480511
line-height: 1.5;
512+
min-height: 30px;
513+
box-sizing: border-box;
481514
user-select: none;
482515
-webkit-user-select: none;
483-
pointer-events: none;
516+
pointer-events: auto;
484517
}
485518

486519
.diffGutterAdd {
@@ -503,6 +536,11 @@
503536
border-bottom: none;
504537
}
505538

539+
.diffRowsList > :last-child > *,
540+
.diffVirtualInner > :last-child > * {
541+
border-bottom: none;
542+
}
543+
506544
.diffCellAdd {
507545
background: rgba(34, 197, 94, 0.16);
508546
}
@@ -529,8 +567,8 @@
529567
user-select: text;
530568
}
531569

532-
.diffGridSelectingLeft .diffColumnRight .diffCode,
533-
.diffGridSelectingRight .diffColumnLeft .diffCode {
570+
.diffGridSelectingLeft .diffCodeRight,
571+
.diffGridSelectingRight .diffCodeLeft {
534572
user-select: none;
535573
}
536574

apps/desktop/src/components/MainPane/tabs/git/GitDiff.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const nonTextInputTypes = new Set([
3232

3333
const EMPTY_SUMMARY: GitChangeSummaryPayload = { staged: [], unstaged: [] };
3434
const AUTO_OPEN_LIMIT = 10;
35-
const LARGE_DIFF_THRESHOLD = 100;
35+
const LARGE_DIFF_THRESHOLD = 250;
3636

3737
function isTextInputElement(target: EventTarget | null) {
3838
if (!(target instanceof HTMLElement)) {

apps/desktop/src/components/MainPane/tabs/git/GitFileCard.tsx

Lines changed: 146 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { useCallback, useEffect, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
2+
import { useVirtualizer } from "@tanstack/react-virtual";
3+
import { flushSync } from "react-dom";
24
import Prism from "prismjs";
35
import "prismjs/components/prism-bash";
46
import "prismjs/components/prism-clike";
@@ -41,6 +43,9 @@ interface GitFileCardProps {
4143
}
4244

4345
const emptyCell = { __html: " " };
46+
const DIFF_ROW_HEIGHT = 30;
47+
const DIFF_ROW_OVERSCAN = 12;
48+
const DIFF_VIRTUALIZE_THRESHOLD = 120;
4449

4550
function escapeHtml(value: string) {
4651
return value
@@ -121,6 +126,9 @@ function buildFileDiff(detail?: GitFileDiffPayload): FileDiff | null {
121126

122127
export default function GitFileCard({ summary, fileKey, isOpen, detailState, onToggle }: GitFileCardProps) {
123128
const [selectionColumn, setSelectionColumn] = useState<"left" | "right" | null>(null);
129+
const [selectionFullRender, setSelectionFullRender] = useState(false);
130+
const virtualViewportRef = useRef<HTMLDivElement | null>(null);
131+
const lineHtmlCacheRef = useRef<Map<string, { __html: string }>>(new Map());
124132

125133
const clearSelectionColumn = useCallback(() => {
126134
setSelectionColumn(null);
@@ -138,30 +146,92 @@ export default function GitFileCard({ summary, fileKey, isOpen, detailState, onT
138146
};
139147
}, [clearSelectionColumn, selectionColumn]);
140148

149+
useEffect(() => {
150+
lineHtmlCacheRef.current.clear();
151+
}, [detailState?.data?.path, detailState?.data?.rows, summary.path, summary.changedLineCount]);
152+
153+
useEffect(() => {
154+
if (!isOpen) {
155+
setSelectionFullRender(false);
156+
}
157+
}, [isOpen, detailState?.data?.path, detailState?.data?.rows]);
158+
141159
const handleColumnPointerDown = useCallback((column: "left" | "right") => {
142160
setSelectionColumn(column);
143161
}, []);
144162

145-
const diffGridClass = [
146-
styles.diffGrid,
147-
selectionColumn === "left" ? styles.diffGridSelectingLeft : "",
148-
selectionColumn === "right" ? styles.diffGridSelectingRight : "",
149-
]
150-
.filter(Boolean)
151-
.join(" ");
163+
const enableSelectionFullRender = useCallback(() => {
164+
flushSync(() => {
165+
setSelectionFullRender(true);
166+
});
167+
}, []);
168+
169+
const selectionClass =
170+
selectionColumn === "left"
171+
? styles.diffGridSelectingLeft
172+
: selectionColumn === "right"
173+
? styles.diffGridSelectingRight
174+
: "";
152175

153176
const file = useMemo(() => buildFileDiff(detailState?.data), [detailState?.data]);
154177
const shouldHighlight = useMemo(
155178
() => shouldHighlightDiff(summary.path, summary.changedLineCount),
156179
[summary.changedLineCount, summary.path]
157180
);
181+
const shouldVirtualize = Boolean(
182+
file && !file.isBinary && file.rows.length > DIFF_VIRTUALIZE_THRESHOLD && !selectionFullRender
183+
);
184+
const rowVirtualizer = useVirtualizer({
185+
count: file?.rows.length ?? 0,
186+
getScrollElement: () => virtualViewportRef.current,
187+
estimateSize: () => DIFF_ROW_HEIGHT,
188+
overscan: DIFF_ROW_OVERSCAN,
189+
});
190+
const virtualRows = shouldVirtualize ? rowVirtualizer.getVirtualItems() : [];
158191
const displayFile = file ?? {
159192
path: summary.path,
160193
oldPath: summary.oldPath,
161194
newPath: summary.newPath,
162195
status: summary.status,
163196
};
164197

198+
const renderColumnRow = useCallback(
199+
(
200+
row: FileDiff["rows"][number],
201+
index: number,
202+
side: "left" | "right",
203+
style?: CSSProperties
204+
) => {
205+
const isLeft = side === "left";
206+
const cell = isLeft ? row.left : row.right;
207+
const lineNumber = isLeft ? row.leftLine : row.rightLine;
208+
const usePlainText = row.left.type === "meta" || row.right.type === "meta" || !shouldHighlight;
209+
const cellClass = `${styles.diffCell} ${getCellClass(cell.type)}`;
210+
const gutterClass = `${styles.diffGutter} ${getGutterClass(cell.type)}`;
211+
const cacheKey = `${file?.language ?? "text"}:${usePlainText ? "plain" : "highlight"}:${cell.text}`;
212+
let lineHtml = lineHtmlCacheRef.current.get(cacheKey);
213+
if (!lineHtml) {
214+
lineHtml = getLineHtml(cell.text, file?.language ?? "text", usePlainText);
215+
lineHtmlCacheRef.current.set(cacheKey, lineHtml);
216+
}
217+
218+
return (
219+
<div key={`${fileKey}-${side}-${index}`} className={styles.diffColumnRow} style={style}>
220+
<div className={gutterClass} onPointerDownCapture={() => handleColumnPointerDown(side)}>
221+
{lineNumber !== null ? lineNumber : ""}
222+
</div>
223+
<div className={cellClass} onPointerDownCapture={() => handleColumnPointerDown(side)}>
224+
<code
225+
className={`${styles.diffCode} ${isLeft ? styles.diffCodeLeft : styles.diffCodeRight}`}
226+
dangerouslySetInnerHTML={lineHtml}
227+
/>
228+
</div>
229+
</div>
230+
);
231+
},
232+
[file?.language, fileKey, handleColumnPointerDown, shouldHighlight]
233+
);
234+
165235
return (
166236
<div className={styles.diffFile}>
167237
<CollapsibleSection
@@ -188,14 +258,14 @@ export default function GitFileCard({ summary, fileKey, isOpen, detailState, onT
188258
toggleClassName={styles.diffFileToggle}
189259
titleClassName={styles.diffFileTitle}
190260
chevronClassName={styles.diffFileIcon}
191-
bodyClassName={diffGridClass}
261+
bodyClassName={styles.diffBody}
192262
>
193263
{detailState?.status === "loading" ? (
194264
<div className={styles.state}>Loading diff…</div>
195265
) : detailState?.status === "error" ? (
196266
<div className={`${styles.state} ${styles.stateError}`}>{detailState.error ?? "Unable to load diff."}</div>
197267
) : file?.isBinary ? (
198-
<>
268+
<div className={[styles.diffGrid, selectionClass].filter(Boolean).join(" ")}>
199269
<div
200270
className={`${styles.diffColumn} ${styles.diffColumnLeft}`}
201271
onPointerDownCapture={() => handleColumnPointerDown("left")}
@@ -222,9 +292,9 @@ export default function GitFileCard({ summary, fileKey, isOpen, detailState, onT
222292
</div>
223293
</div>
224294
</div>
225-
</>
295+
</div>
226296
) : file && file.rows.length === 0 ? (
227-
<>
297+
<div className={[styles.diffGrid, selectionClass].filter(Boolean).join(" ")}>
228298
<div
229299
className={`${styles.diffColumn} ${styles.diffColumnLeft}`}
230300
onPointerDownCapture={() => handleColumnPointerDown("left")}
@@ -251,58 +321,73 @@ export default function GitFileCard({ summary, fileKey, isOpen, detailState, onT
251321
</div>
252322
</div>
253323
</div>
254-
</>
324+
</div>
255325
) : file ? (
256-
<>
257-
<div
258-
className={`${styles.diffColumn} ${styles.diffColumnLeft}`}
259-
onPointerDownCapture={() => handleColumnPointerDown("left")}
260-
>
261-
<div className={styles.diffColumnBody}>
262-
{file.rows.map((row, index) => {
263-
const cellClass = `${styles.diffCell} ${getCellClass(row.left.type)}`;
264-
const gutterClass = `${styles.diffGutter} ${getGutterClass(row.left.type)}`;
265-
const usePlainText =
266-
row.left.type === "meta" || row.right.type === "meta" || !shouldHighlight;
267-
return (
268-
<div key={`${fileKey}-left-${index}`} className={styles.diffColumnRow}>
269-
<div className={gutterClass}>{row.leftLine !== null ? row.leftLine : ""}</div>
270-
<div className={cellClass}>
271-
<code
272-
className={styles.diffCode}
273-
dangerouslySetInnerHTML={getLineHtml(row.left.text, file.language, usePlainText)}
274-
/>
275-
</div>
276-
</div>
277-
);
278-
})}
326+
<div
327+
ref={shouldVirtualize ? virtualViewportRef : undefined}
328+
tabIndex={0}
329+
className={[styles.diffRowsSurface, selectionClass].filter(Boolean).join(" ")}
330+
onPointerDownCapture={() => {
331+
if (shouldVirtualize) {
332+
enableSelectionFullRender();
333+
}
334+
}}
335+
onKeyDownCapture={(event) => {
336+
if (shouldVirtualize && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") {
337+
enableSelectionFullRender();
338+
}
339+
}}
340+
>
341+
{shouldVirtualize ? (
342+
<div className={styles.diffGrid}>
343+
<div className={`${styles.diffColumn} ${styles.diffColumnLeft}`}>
344+
<div
345+
className={styles.diffVirtualInner}
346+
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
347+
>
348+
{virtualRows.map((virtualRow) =>
349+
renderColumnRow(file.rows[virtualRow.index], virtualRow.index, "left", {
350+
position: "absolute",
351+
top: 0,
352+
left: 0,
353+
width: "100%",
354+
transform: `translateY(${virtualRow.start}px)`,
355+
})
356+
)}
357+
</div>
358+
</div>
359+
<div className={`${styles.diffColumn} ${styles.diffColumnRight}`}>
360+
<div
361+
className={styles.diffVirtualInner}
362+
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
363+
>
364+
{virtualRows.map((virtualRow) =>
365+
renderColumnRow(file.rows[virtualRow.index], virtualRow.index, "right", {
366+
position: "absolute",
367+
top: 0,
368+
left: 0,
369+
width: "100%",
370+
transform: `translateY(${virtualRow.start}px)`,
371+
})
372+
)}
373+
</div>
374+
</div>
279375
</div>
280-
</div>
281-
<div
282-
className={`${styles.diffColumn} ${styles.diffColumnRight}`}
283-
onPointerDownCapture={() => handleColumnPointerDown("right")}
284-
>
285-
<div className={styles.diffColumnBody}>
286-
{file.rows.map((row, index) => {
287-
const cellClass = `${styles.diffCell} ${getCellClass(row.right.type)}`;
288-
const gutterClass = `${styles.diffGutter} ${getGutterClass(row.right.type)}`;
289-
const usePlainText =
290-
row.left.type === "meta" || row.right.type === "meta" || !shouldHighlight;
291-
return (
292-
<div key={`${fileKey}-right-${index}`} className={styles.diffColumnRow}>
293-
<div className={gutterClass}>{row.rightLine !== null ? row.rightLine : ""}</div>
294-
<div className={cellClass}>
295-
<code
296-
className={styles.diffCode}
297-
dangerouslySetInnerHTML={getLineHtml(row.right.text, file.language, usePlainText)}
298-
/>
299-
</div>
300-
</div>
301-
);
302-
})}
376+
) : (
377+
<div className={styles.diffGrid}>
378+
<div className={`${styles.diffColumn} ${styles.diffColumnLeft}`}>
379+
<div className={styles.diffColumnBody}>
380+
{file.rows.map((row, index) => renderColumnRow(row, index, "left"))}
381+
</div>
382+
</div>
383+
<div className={`${styles.diffColumn} ${styles.diffColumnRight}`}>
384+
<div className={styles.diffColumnBody}>
385+
{file.rows.map((row, index) => renderColumnRow(row, index, "right"))}
386+
</div>
387+
</div>
303388
</div>
304-
</div>
305-
</>
389+
)}
390+
</div>
306391
) : (
307392
<div className={styles.state}>Diff will load when expanded.</div>
308393
)}

0 commit comments

Comments
 (0)