From fb35fe369eef170fdf4d15c4ffd71f7f8dd38e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C5=ABdolfs=20O=C5=A1i=C5=86=C5=A1?= Date: Wed, 1 Jul 2026 18:03:30 +0200 Subject: [PATCH] Virtualize the commit diff view Render only visible diff rows (via virtua) so commit diffs stay smooth at any size. --- src/components/Changeset.svelte | 153 +++++++++++++++++-- src/components/DiffRow.svelte | 239 ++++++++++++++++++++++++++++++ src/components/VirtualList.svelte | 31 +++- src/lib/diffRows.ts | 108 ++++++++++++++ src/views/repo/RepoCommit.svelte | 134 ++++++++--------- 5 files changed, 584 insertions(+), 81 deletions(-) create mode 100644 src/components/DiffRow.svelte create mode 100644 src/lib/diffRows.ts diff --git a/src/components/Changeset.svelte b/src/components/Changeset.svelte index 3feb60f51..2c93a48b5 100644 --- a/src/components/Changeset.svelte +++ b/src/components/Changeset.svelte @@ -1,17 +1,105 @@ -
- {#each diff.files as file} -
- -
- {/each} -
+{#if codeComments} + +
+ {#each diff.files as file} +
+ +
+ {/each} +
+{:else} +
+ {#if stickyFileIndex !== undefined} + + {/if} +
+ + {#snippet row(r)} + {#if r.type === "header"} + {@render header?.()} + {:else} + + {/if} + {/snippet} + +{/if} diff --git a/src/components/DiffRow.svelte b/src/components/DiffRow.svelte new file mode 100644 index 000000000..b2869f063 --- /dev/null +++ b/src/components/DiffRow.svelte @@ -0,0 +1,239 @@ + + + + +{#if row.type === "file-gap"} +
+{:else if row.type === "file-header"} +
+ + {#if row.file.status === "moved" || row.file.status === "copied"} + + → + + {:else} + + {/if} + {#if row.file.status === "added"} + Added + {:else if row.file.status === "deleted"} + Deleted + {:else if row.file.status === "moved"} + Moved + {:else if row.file.status === "copied"} + Copied + {/if} + + + {#if row.file.diff.type === "plain" && row.file.diff.hunks.length > 0} + +{row.file.diff.stats.additions} + -{row.file.diff.stats.deletions} + {/if} + +
+{:else if row.type === "hunk-header"} +
+
+
+
+
{row.header}
+
+{:else if row.type === "file-note"} +
+ {#if row.note === "binary"} + + Binary file + {:else} + Empty file + {/if} +
+{:else} + {@const line = row.modification} +
+
{lineNumber(line, "left") ?? ""}
+
{lineNumber(line, "right") ?? ""}
+
+ {#if line.type === "addition"}+{:else if line.type === "deletion"}-{/if} +
+ {#if line.highlight && line.highlight.items.length > 0} +
+ {@html line.highlight.items + .map( + paint => + `${escape(paint.item)}`, + ) + .join("")} +
+ {:else if line.line !== ""} +
{line.line}
+ {:else} +

+ {/if} +
+{/if} diff --git a/src/components/VirtualList.svelte b/src/components/VirtualList.svelte index adea1090d..34965fb15 100644 --- a/src/components/VirtualList.svelte +++ b/src/components/VirtualList.svelte @@ -31,6 +31,13 @@ scrollOffset: number; cache: ListCacheSnapshot; }) => void; + // Reports scroll state for sticky headers: the topmost visible item index, + // the current scroll offset, and a way to get any item's offset from start. + onScrollState?: (state: { + topIndex: number; + scrollOffset: number; + itemOffset: (index: number) => number; + }) => void; } const { @@ -47,6 +54,7 @@ initialCache = undefined, initialScrollOffset = undefined, onState = undefined, + onScrollState = undefined, }: Props = $props(); const getViewport = getScrollViewport(); @@ -75,6 +83,22 @@ }); } + function reportScrollState() { + const h = handle; + if (!h || !onScrollState) return; + const scrollOffset = h.getScrollOffset(); + onScrollState({ + topIndex: h.findItemIndex(scrollOffset), + scrollOffset, + itemOffset: index => h.getItemOffset(index), + }); + } + + function onScroll() { + maybeLoadMore(); + reportScrollState(); + } + function maybeLoadMore() { if (!onLoadMore || !hasMore || loadingMore) return; const el = viewport; @@ -90,7 +114,10 @@ // frame, once Virtualizer has sized the scroll container. // eslint-disable-next-line @typescript-eslint/no-unused-expressions (items.length, hasMore, loadingMore, viewport); - const id = requestAnimationFrame(() => maybeLoadMore()); + const id = requestAnimationFrame(() => { + maybeLoadMore(); + reportScrollState(); + }); return () => cancelAnimationFrame(id); }); @@ -105,7 +132,7 @@ {bufferSize} {startMargin} cache={initialCache} - onscroll={maybeLoadMore} + onscroll={onScroll} onscrollend={reportState}> {#snippet children(item: T, index: number)} {@render row(item, index)} diff --git a/src/lib/diffRows.ts b/src/lib/diffRows.ts new file mode 100644 index 000000000..7ace28512 --- /dev/null +++ b/src/lib/diffRows.ts @@ -0,0 +1,108 @@ +import type { Diff } from "@bindings/diff/Diff"; +import type { FileDiff } from "@bindings/diff/FileDiff"; +import type { Modification } from "@bindings/diff/Modification"; + +// A changeset flattened into a single stream of rows so it can be rendered +// through one virtualizer. Collapsed files contribute only their header row. +export type DiffRow = + // Spacing between file cards; its own row so file-header cards start flush at + // the row top and line up exactly with the sticky header bar. + | { type: "file-gap"; fileIndex: number } + | { + type: "file-header"; + fileIndex: number; + file: FileDiff; + // No body rows follow (collapsed), so the header is a self-contained + // card and needs a full border. + standalone: boolean; + } + | { + type: "hunk-header"; + fileIndex: number; + hunkIndex: number; + header: string; + } + // The file's only body row when there are no hunks to render, telling the + // user why (binary content vs. an empty file) instead of a bare header. + | { + type: "file-note"; + fileIndex: number; + note: "binary" | "empty"; + } + | { + type: "line"; + fileIndex: number; + hunkIndex: number; + lineIndex: number; + file: FileDiff; + modification: Modification; + // Last line of the file, so it closes the card (bottom border + radius). + last: boolean; + }; + +export function flattenDiff( + diff: Diff, + isExpanded: (fileIndex: number) => boolean, +): DiffRow[] { + const rows: DiffRow[] = []; + diff.files.forEach((file, fileIndex) => { + if (fileIndex > 0) { + rows.push({ type: "file-gap", fileIndex }); + } + const expanded = isExpanded(fileIndex); + rows.push({ type: "file-header", fileIndex, file, standalone: !expanded }); + if (!expanded) { + return; + } + if (file.diff.type !== "plain" || file.diff.hunks.length === 0) { + rows.push({ + type: "file-note", + fileIndex, + note: file.diff.type === "binary" ? "binary" : "empty", + }); + return; + } + + const lineRows: DiffRow[] = []; + file.diff.hunks.forEach((hunk, hunkIndex) => { + lineRows.push({ + type: "hunk-header", + fileIndex, + hunkIndex, + header: hunk.header, + }); + hunk.lines.forEach((modification, lineIndex) => { + lineRows.push({ + type: "line", + fileIndex, + hunkIndex, + lineIndex, + file, + modification, + last: false, + }); + }); + }); + const lastLine = lineRows.findLast(r => r.type === "line"); + if (lastLine && lastLine.type === "line") { + lastLine.last = true; + } + rows.push(...lineRows); + }); + return rows; +} + +export function diffRowKey(row: DiffRow): string { + switch (row.type) { + case "file-gap": + return `g:${row.fileIndex}`; + case "file-header": + return `f:${row.fileIndex}`; + case "hunk-header": + return `h:${row.fileIndex}:${row.hunkIndex}`; + case "file-note": + return `n:${row.fileIndex}`; + case "line": + return `l:${row.fileIndex}:${row.hunkIndex}:${row.lineIndex}`; + } +} diff --git a/src/views/repo/RepoCommit.svelte b/src/views/repo/RepoCommit.svelte index 464791aee..a09afbd4e 100644 --- a/src/views/repo/RepoCommit.svelte +++ b/src/views/repo/RepoCommit.svelte @@ -56,14 +56,8 @@ .breadcrumb-link:hover { color: var(--color-text-primary); } - .content { - padding: 1rem; - display: flex; - flex-direction: column; - gap: 1rem; - } .meta { - padding: 0.25rem 0 0.5rem; + padding: 1rem 1rem 0.5rem; } .meta-header { display: flex; @@ -172,72 +166,72 @@ -
-
-
-
-
- {#if !commit.summary} - No commit message - {:else} - {commit.summary} - {/if} + + {#snippet header()} +
+
+
+
+ {#if !commit.summary} + No commit message + {:else} + {commit.summary} + {/if} +
+
+ + + {commit.author.name} + + committed + + + {formatTimestamp(commit.committer.time * 1000)} + + +
+
+ + {commit.parents.length === 1 ? "parent" : "parents"} + + {#if commit.parents.length === 0} + Initial commit + {:else} + {#each commit.parents as parent} + + {/each} + {/if} +
+
{commit.message
+                    .replace(commit.summary, "")
+                    .trim()}
-
- - - {commit.author.name} - - committed - - - {formatTimestamp(commit.committer.time * 1000)} - - +
+
+ {diff.stats.filesChanged} + {pluralize("file", diff.stats.filesChanged)} changed +
+
-
- - {commit.parents.length === 1 ? "parent" : "parents"} - - {#if commit.parents.length === 0} - Initial commit - {:else} - {#each commit.parents as parent} - - {/each} - {/if} -
-
{commit.message
-                  .replace(commit.summary, "")
-                  .trim()}
-
-
- {diff.stats.filesChanged} - {pluralize("file", diff.stats.filesChanged)} changed -
- -
-
-
- - -
+
+ {/snippet} +