~james/core

core/web/src/lib/components/repo/DiffFiles.svelte -rw-r--r-- 7.3 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
<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>