Virtualize the commit diff view

Render only visible diff rows (via virtua) so commit diffs stay smooth
at any size.
This commit is contained in:
Rūdolfs Ošiņš
2026-07-03 11:28:12 +02:00
parent 32b1d0e272
commit fb35fe369e
5 changed files with 584 additions and 81 deletions
+144 -9
View File
@@ -1,17 +1,105 @@
<script lang="ts">
import type { CodeComments } from "@app/components/Diff.svelte";
import type { Diff } from "@bindings/diff/Diff";
import type { Snippet } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import type { DiffRow } from "@app/lib/diffRows";
import { diffRowKey, flattenDiff } from "@app/lib/diffRows";
import DiffRowView from "@app/components/DiffRow.svelte";
import FileDiffComponent from "@app/components/FileDiff.svelte";
import VirtualList from "@app/components/VirtualList.svelte";
interface Props {
codeComments?: CodeComments;
diff: Diff;
expanded?: boolean;
head: string;
// Rendered as the first virtualized row (e.g. commit/patch metadata), so
// the virtualizer owns the whole scroll content and starts at offset 0.
header?: Snippet;
}
const { codeComments, diff, expanded = true, head }: Props = $props();
const { codeComments, diff, expanded = true, head, header }: Props = $props();
// Files the user collapsed (virtualized read-only path only).
const collapsed = new SvelteSet<number>();
function isFileExpanded(fileIndex: number): boolean {
return expanded && !collapsed.has(fileIndex);
}
function toggleFile(fileIndex: number) {
if (collapsed.has(fileIndex)) {
collapsed.delete(fileIndex);
} else {
collapsed.add(fileIndex);
}
}
type Row = { type: "header" } | DiffRow;
const rows = $derived<Row[]>(
header
? [{ type: "header" }, ...flattenDiff(diff, isFileExpanded)]
: flattenDiff(diff, isFileExpanded),
);
function rowKey(row: Row): string {
return row.type === "header" ? "header" : diffRowKey(row);
}
let topIndex = $state(0);
let scrollOffset = $state(0);
let itemOffset = $state<(index: number) => number>(() => 0);
let stickyBarHeight = $state(0);
function onScrollState(s: {
topIndex: number;
scrollOffset: number;
itemOffset: (index: number) => number;
}) {
topIndex = s.topIndex;
scrollOffset = s.scrollOffset;
itemOffset = s.itemOffset;
}
// The file whose header is pinned: the topmost row's file, or the file above a
// gap row (so the bar persists while transitioning between files). Pinned from
// the moment any of a file's rows reaches the top, including its own header.
const stickyFileIndex = $derived.by(() => {
const r = rows[topIndex];
if (!r) return undefined;
if (
r.type === "file-header" ||
r.type === "hunk-header" ||
r.type === "line"
) {
return r.fileIndex;
}
if (r.type === "file-gap") {
return r.fileIndex - 1;
}
return undefined;
});
// The next file's header row, used to push the current sticky bar up and out
// as the next header rises into it (VSCode-style sticky scroll).
const nextHeaderIndex = $derived.by(() => {
for (let i = topIndex + 1; i < rows.length; i++) {
if (rows[i].type === "file-header") {
return i;
}
}
return undefined;
});
const pushOffset = $derived.by(() => {
if (nextHeaderIndex === undefined || stickyBarHeight === 0) {
return 0;
}
const nextTop = itemOffset(nextHeaderIndex) - scrollOffset;
return nextTop < stickyBarHeight ? stickyBarHeight - nextTop : 0;
});
</script>
<style>
@@ -19,16 +107,63 @@
display: flex;
flex-direction: column;
}
.diff:not(:last-of-type) {
margin-bottom: 1rem;
}
/* Zero-height sticky anchor pinned to the viewport top; the bar overflows
from it (absolute) so it overlays the rows without taking flow space. */
.sticky-anchor {
position: sticky;
top: 0;
height: 0;
z-index: 2;
}
.sticky-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
}
</style>
<div class="diff-list">
{#each diff.files as file}
<div class="diff">
<FileDiffComponent {codeComments} {expanded} {file} {head} />
</div>
{/each}
</div>
{#if codeComments}
<!-- Patch review keeps the comment-capable path until it is virtualized. -->
<div class="diff-list">
{#each diff.files as file}
<div class="diff">
<FileDiffComponent {codeComments} {expanded} {file} {head} />
</div>
{/each}
</div>
{:else}
<div class="sticky-anchor">
{#if stickyFileIndex !== undefined}
<div
class="sticky-bar"
bind:clientHeight={stickyBarHeight}
style:transform="translateY({-pushOffset}px)">
<DiffRowView
row={{
type: "file-header",
fileIndex: stickyFileIndex,
file: diff.files[stickyFileIndex],
standalone: false,
}}
expanded={isFileExpanded(stickyFileIndex)}
onToggleFile={toggleFile} />
</div>
{/if}
</div>
<VirtualList items={rows} getKey={rowKey} {onScrollState}>
{#snippet row(r)}
{#if r.type === "header"}
{@render header?.()}
{:else}
<DiffRowView
row={r}
expanded={isFileExpanded(r.fileIndex)}
onToggleFile={toggleFile} />
{/if}
{/snippet}
</VirtualList>
{/if}
+239
View File
@@ -0,0 +1,239 @@
<script lang="ts">
import type { Modification } from "@bindings/diff/Modification";
import escape from "lodash/escape";
import type { DiffRow } from "@app/lib/diffRows";
import Icon from "@app/components/Icon.svelte";
import Path from "@app/components/Path.svelte";
interface Props {
row: DiffRow;
// Whether the row's file is expanded (only meaningful for file-header rows).
expanded?: boolean;
onToggleFile?: (fileIndex: number) => void;
}
const { row, expanded = true, onToggleFile }: Props = $props();
function lineNumber(line: Modification, side: "left" | "right") {
if (side === "left") {
if (line.type === "context") return line.lineNoOld;
if (line.type === "deletion") return line.lineNo;
} else {
if (line.type === "context") return line.lineNoNew;
if (line.type === "addition") return line.lineNo;
}
}
</script>
<style>
.file-gap {
height: 1rem;
}
.file-header {
display: flex;
align-items: center;
gap: 0.5rem;
height: 2.5rem;
margin: 0 1rem;
padding: 0 0.5rem;
background-color: var(--color-surface-canvas);
/* Bottom border present in both states: it's the separator above the lines
when expanded, and the card bottom when standalone. Keeping it always
avoids a 1px reflow when toggling collapse. */
border: 1px solid var(--color-border-subtle);
border-top-left-radius: var(--border-radius-md);
border-top-right-radius: var(--border-radius-md);
font: var(--txt-body-m-regular);
}
.file-header.standalone {
border-bottom-left-radius: var(--border-radius-md);
border-bottom-right-radius: var(--border-radius-md);
}
.collapse {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--color-text-secondary);
display: flex;
align-items: center;
}
.collapse:hover {
color: var(--color-text-primary);
}
.arrow {
padding: 0 0.25rem;
color: var(--color-text-secondary);
}
.status {
padding: 0 0.375rem;
border-radius: var(--border-radius-sm);
}
.status.added {
color: var(--color-feedback-success-text);
background-color: var(--color-feedback-success-bg);
}
.status.deleted {
color: var(--color-feedback-error-text);
background-color: var(--color-feedback-error-bg);
}
.status.moved,
.status.copied {
color: var(--color-text-secondary);
background: var(--color-surface-subtle);
}
.stats {
margin-left: auto;
font: var(--txt-code-regular);
white-space: nowrap;
}
.add {
color: var(--color-feedback-success-text);
}
.del {
color: var(--color-feedback-error-text);
}
.line {
display: flex;
position: relative;
white-space: pre-wrap;
margin: 0 1rem;
font: var(--txt-code-regular);
border-left: 1px solid var(--color-border-subtle);
border-right: 1px solid var(--color-border-subtle);
background-color: var(--color-surface-canvas);
}
.line.last {
border-bottom: 1px solid var(--color-border-subtle);
border-bottom-left-radius: var(--border-radius-md);
border-bottom-right-radius: var(--border-radius-md);
}
.hunk-header {
color: var(--color-text-secondary);
}
.file-note {
justify-content: center;
align-items: center;
gap: 0.5rem;
padding: 1rem 0;
font: var(--txt-body-m-regular);
}
.addition {
background-color: var(--color-feedback-success-bg);
}
.deletion {
background-color: var(--color-feedback-error-bg);
}
.addition > .num,
.addition > .sign {
color: var(--color-feedback-success-text);
}
.deletion > .num,
.deletion > .sign {
color: var(--color-feedback-error-text);
}
.context > .num,
.context > .sign {
color: var(--color-text-disabled);
}
.num {
min-width: 3rem;
text-align: center;
flex-shrink: 0;
}
.sign {
min-width: 1.5rem;
flex-shrink: 0;
}
.code {
-webkit-user-select: text;
user-select: text;
width: 100%;
word-break: break-word;
padding-right: 1rem;
}
</style>
{#if row.type === "file-gap"}
<div class="file-gap"></div>
{:else if row.type === "file-header"}
<div class="file-header" class:standalone={row.standalone}>
<button
class="collapse"
onclick={() => onToggleFile?.(row.fileIndex)}
title={expanded ? "Collapse file" : "Expand file"}>
<Icon name={expanded ? "chevron-down" : "chevron-right"} />
</button>
{#if row.file.status === "moved" || row.file.status === "copied"}
<Path fullPath={row.file.oldPath} />
<span class="arrow">→</span>
<Path fullPath={row.file.newPath} />
{:else}
<Path fullPath={row.file.path} />
{/if}
{#if row.file.status === "added"}
<span class="status added">Added</span>
{:else if row.file.status === "deleted"}
<span class="status deleted">Deleted</span>
{:else if row.file.status === "moved"}
<span class="status moved">Moved</span>
{:else if row.file.status === "copied"}
<span class="status copied">Copied</span>
{/if}
<span class="stats">
{#if row.file.diff.type === "plain" && row.file.diff.hunks.length > 0}
<span class="add">+{row.file.diff.stats.additions}</span>
<span class="del">-{row.file.diff.stats.deletions}</span>
{/if}
</span>
</div>
{:else if row.type === "hunk-header"}
<div class="line hunk-header">
<div class="num"></div>
<div class="num"></div>
<div class="sign"></div>
<div class="code">{row.header}</div>
</div>
{:else if row.type === "file-note"}
<div class="line last file-note">
{#if row.note === "binary"}
<Icon name="binary" />
Binary file
{:else}
Empty file
{/if}
</div>
{:else}
{@const line = row.modification}
<div
class="line"
class:addition={line.type === "addition"}
class:deletion={line.type === "deletion"}
class:context={line.type === "context"}
class:last={row.last}>
<div class="num">{lineNumber(line, "left") ?? ""}</div>
<div class="num">{lineNumber(line, "right") ?? ""}</div>
<div class="sign">
{#if line.type === "addition"}+{:else if line.type === "deletion"}-{/if}
</div>
{#if line.highlight && line.highlight.items.length > 0}
<div class="code">
{@html line.highlight.items
.map(
paint =>
`<span class="global-syntax ${paint.style}">${escape(paint.item)}</span>`,
)
.join("")}
</div>
{:else if line.line !== ""}
<div class="code">{line.line}</div>
{:else}
<div class="code"><br /></div>
{/if}
</div>
{/if}
+29 -2
View File
@@ -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);
});
</script>
@@ -105,7 +132,7 @@
{bufferSize}
{startMargin}
cache={initialCache}
onscroll={maybeLoadMore}
onscroll={onScroll}
onscrollend={reportState}>
{#snippet children(item: T, index: number)}
{@render row(item, index)}
+108
View File
@@ -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}`;
}
}
+64 -70
View File
@@ -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 @@
</div>
</Topbar>
<ScrollArea style="height: 100%; min-width: 0;">
<div class="content">
<section class="meta">
<div class="meta-header">
<div class="meta-title">
<div class="summary txt-selectable">
{#if !commit.summary}
<span class="txt-missing">No commit message</span>
{:else}
{commit.summary}
{/if}
<Changeset {diff} head={commit.id}>
{#snippet header()}
<section class="meta">
<div class="meta-header">
<div class="meta-title">
<div class="summary txt-selectable">
{#if !commit.summary}
<span class="txt-missing">No commit message</span>
{:else}
{commit.summary}
{/if}
</div>
<div class="summary-meta">
<span class="summary-author">
<img
class="summary-avatar"
alt=""
src={gravatarURL(commit.author.email)} />
<span class="txt-selectable">{commit.author.name}</span>
</span>
committed
<Id id={commit.id} clipboard={commit.id} />
<span
class="summary-timestamp"
title={absoluteTimestamp(commit.committer.time * 1000)}>
{formatTimestamp(commit.committer.time * 1000)}
</span>
<JobCob rid={repo.rid} commit={commit.id} />
</div>
<div class="summary-parents">
<span class="summary-parents-label">
{commit.parents.length === 1 ? "parent" : "parents"}
</span>
{#if commit.parents.length === 0}
<span>Initial commit</span>
{:else}
{#each commit.parents as parent}
<button
class="parent-link txt-id"
onclick={() => {
void router.push({
resource: "repo.commit",
rid: repo.rid,
commit: parent,
});
}}>
{formatOid(parent)}
</button>
{/each}
{/if}
</div>
<pre class="summary-message txt-selectable">{commit.message
.replace(commit.summary, "")
.trim()}</pre>
</div>
<div class="summary-meta">
<span class="summary-author">
<img
class="summary-avatar"
alt=""
src={gravatarURL(commit.author.email)} />
<span class="txt-selectable">{commit.author.name}</span>
</span>
committed
<Id id={commit.id} clipboard={commit.id} />
<span
class="summary-timestamp"
title={absoluteTimestamp(commit.committer.time * 1000)}>
{formatTimestamp(commit.committer.time * 1000)}
</span>
<JobCob rid={repo.rid} commit={commit.id} />
<div class="chips">
<div class="files-chip">
{diff.stats.filesChanged}
{pluralize("file", diff.stats.filesChanged)} changed
</div>
<DiffStatBadge stats={diff.stats} />
</div>
<div class="summary-parents">
<span class="summary-parents-label">
{commit.parents.length === 1 ? "parent" : "parents"}
</span>
{#if commit.parents.length === 0}
<span>Initial commit</span>
{:else}
{#each commit.parents as parent}
<button
class="parent-link txt-id"
onclick={() => {
void router.push({
resource: "repo.commit",
rid: repo.rid,
commit: parent,
});
}}>
{formatOid(parent)}
</button>
{/each}
{/if}
</div>
<pre class="summary-message txt-selectable">{commit.message
.replace(commit.summary, "")
.trim()}</pre>
</div>
<div class="chips">
<div class="files-chip">
{diff.stats.filesChanged}
{pluralize("file", diff.stats.filesChanged)} changed
</div>
<DiffStatBadge stats={diff.stats} />
</div>
</div>
</section>
<Changeset {diff} head={commit.id} />
</div>
</section>
{/snippet}
</Changeset>
</ScrollArea>
</div>
</Layout>