<script lang="ts">
import type { FileDiffMetadata, SelectedLineRange } from "@pierre/diffs";
import { untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { SvelteMap } from "svelte/reactivity";
import DiffCodeView, {
type CodeViewApi,
type CodeViewFile
} from "#lib/components/repo/DiffCodeView.svelte";
import { fileAnchor, jumpToAnchoredFile } from "#lib/components/repo/fileAnchor.svelte.ts";
import type { PatchStream } from "#lib/api/patchStream.ts";
import type { RepoDiff, RepoDiffDeps } from "#lib/api/repoDiff.ts";
import type { DiffStyle } from "#lib/components/repo/pierre.ts";
import type { DiffEntry } from "#lib/components/repo/fileDiff.ts";
import { createDiffQueue } from "#lib/components/repo/diffQueue.ts";
import { diffRows, findDiffMatches, type DiffMatch } from "#lib/components/lines/diff.ts";
import { MATCH_LIMIT } from "#lib/components/lines/search.ts";
interface Props {
deps: RepoDiffDeps;
diff: RepoDiff;
blobUrl?: (file: CodeViewFile) => string | undefined;
diffStyle?: DiffStyle;
wrap?: boolean;
isOpen?: (key: string) => boolean;
onSetOpen?: (key: string, open: boolean) => void;
/** the whole diff in one request, when the route knows where to get it */
patchUrl?: string;
/** a patch already streaming in parallel with this diff's file list */
patch?: PatchStream;
dockTop?: () => number;
onScrollExtent?: (max: number) => void;
linkLines?: boolean;
onQuoteLines?: (path: string, range: SelectedLineRange) => void;
matches?: DiffMatch[];
current?: number;
}
let {
deps,
diff,
blobUrl,
diffStyle = "unified",
wrap = false,
isOpen = () => true,
onSetOpen,
patchUrl,
patch,
dockTop,
onScrollExtent,
linkLines = false,
onQuoteLines,
matches,
current
}: Props = $props();
// parsed metadata only: raw text is dropped after parsing. a map, because
// rebuilding a record per filled file is quadratic over a diff with thousands
// of them. values stay unproxied, because pierre posts them to its highlight
// worker and structured-cloning a reactive proxy throws.
// nothing is evicted: the view measures every item from its metadata, so
// dropping hunks would shrink the scrollbar instead of freeing anything that
// matters - it virtualizes the rendering, not the data
const entries = new SvelteMap<string, DiffEntry>();
// while a patch is on its way, the queue leaves the files to it: one request
// for the whole diff beats two blob fetches per file, and racing them fills
// the page one card at a time. flipped the moment the patch settles - what it
// did not carry (an interdiff's rebased side, a root commit, a mirror that
// refused) is fetched per file, which is what this queue is for
let patchExpected = $state(untrack(() => Boolean(patch ?? patchUrl)));
const queue = createDiffQueue({
deps: () => deps,
diff: () => diff,
entry: (key) => entries.get(key),
put: (parsed) => {
for (const [key, entry] of Object.entries(parsed)) entries.set(key, entry);
},
patchExpected: () => patchExpected
});
// files the patch has already parsed, matched to this diff's entries. the patch
// may have started before this list existed, so it is drained on mount and then
// followed
const fileByName = $derived(
new Map(
diff.files.flatMap((f) => [
[f.name, f],
...(f.oldName ? [[f.oldName, f] as const] : [])
])
)
);
const adoptBatch = (batch: FileDiffMetadata[]) => {
const landed: Record<string, DiffEntry> = {};
for (const parsed of batch) {
const file =
fileByName.get(parsed.name) ??
(parsed.prevName ? fileByName.get(parsed.prevName) : undefined);
if (file && !entries.has(file.key)) {
landed[file.key] = parsed;
}
}
if (Object.keys(landed).length) queue.adopt(landed);
};
$effect(() => {
const stream = patch;
const url = patchUrl;
const controller = new AbortController();
patchExpected = Boolean(stream ?? url);
const settle = () => {
patchExpected = false;
queue.drain();
};
const fetchHere = () => {
if (!url || controller.signal.aborted) {
settle();
return;
}
queue
.applyPatch(url, controller.signal)
.catch(() => undefined)
.finally(settle);
};
if (stream) {
adoptBatch([...stream.byName.values()]);
const stop = stream.subscribe(adoptBatch);
void stream.done.then((ok) => {
if (ok) settle();
else fetchHere();
});
return () => {
stop();
controller.abort();
queue.destroy();
};
}
fetchHere();
return () => {
controller.abort();
queue.destroy();
};
});
// the mirror spells an edit "changed" where the scroller (and pierre) say
// "change": one mapping here beats a second spelling in the shared shape
const viewFiles = $derived<CodeViewFile[]>(
diff.files.map((file) => ({
...file,
kind: file.kind === "changed" ? "change" : file.kind
}))
);
let view: CodeViewApi | undefined = $state();
/** reports the offset the file landed on, so a deep link can wait for the
diff to settle instead of missing it. exported for the file tree and the
page's conflict list */
export const jumpToFile = (
path: string,
range?: SelectedLineRange | null
): number | undefined => {
const file = diff.files.find((candidate) => candidate.name === path);
return file ? view?.scrollToFile(file.key, range) : undefined;
};
const fileByKey = (key: string) => diff.files.find((candidate) => candidate.key === key);
const selectLines = (key: string, range: SelectedLineRange | null): void => {
const file = fileByKey(key);
if (!file) return;
const hash = range ? fileAnchor(file.name, range) : "";
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(`${location.pathname}${location.search}${hash}`, {
shallow: true,
replace: true,
state: page.state
});
};
const quoteLines = (key: string, range: SelectedLineRange): void => {
const file = fileByKey(key);
if (file) onQuoteLines?.(file.name, range);
};
export const search = (query: string): DiffMatch[] => {
const found: DiffMatch[] = [];
if (query === "") return found;
for (const file of diff.files) {
const parsed = entries.get(file.key);
if (!isOpen(file.key) || !parsed || !("hunks" in parsed)) continue;
found.push(
...findDiffMatches(file.key, diffRows(parsed), query, MATCH_LIMIT - found.length)
);
if (found.length >= MATCH_LIMIT) break;
}
return found;
};
export const resync = () => view?.resync();
jumpToAnchoredFile(jumpToFile);
</script>
<!-- one scroller for the whole diff: the page itself never grows with the file
count, which is the only way past the browser's layout coordinate ceiling on
a diff of this size -->
<!-- fills whatever the sticky toolbar leaves of the column, so the scroller
always reaches the bottom of the card -->
<div class="flex min-h-0 shrink grow basis-auto flex-col">
<DiffCodeView
bind:this={view}
files={viewFiles}
entry={(key) => entries.get(key)}
{blobUrl}
{diffStyle}
{wrap}
{isOpen}
onSetOpen={(key, open) => onSetOpen?.(key, open)}
{dockTop}
{onScrollExtent}
onWant={(key) => queue.want(key)}
onFocus={(key) => queue.focus(key)}
onLineSelected={linkLines ? selectLines : undefined}
onWantWhole={(key) => queue.whole(key)}
onQuoteLines={onQuoteLines && quoteLines}
{matches}
{current}
/>
</div>