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"}
+
+{:else if row.type === "hunk-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 @@
-
-