~james/core

core/web/src/lib/components/repo/PierreFile.svelte -rw-r--r-- 5.7 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
<script lang="ts">
	import { untrack } from "svelte";
	import { File, type FileOptions, type SelectedLineRange } from "@pierre/diffs";
	import {
		clearPierreRender,
		PIERRE_SELECTION_CSS,
		pierreFileOptions,
		prerenderFile
	} from "#lib/components/repo/pierre.ts";
	import BlameOverlay from "#lib/components/repo/blame/BlameOverlay.svelte";
	import LineHandles from "#lib/components/repo/LineHandles.svelte";
	import { BlameHover } from "#lib/components/repo/blame/hover.svelte.ts";
	import { getSsrBudget } from "#lib/ssr/render.ts";
	import type { Blame } from "#lib/components/repo/blame/types.ts";
	import fileShadowCSS from "#lib/components/repo/pierreFile.css?raw";
	import {
		clearHighlights,
		paintHighlights,
		textRange
	} from "#lib/components/lines/highlight.ts";
	import type { LineMatch } from "#lib/components/lines/search.ts";

	interface Props {
		// used for the header and to infer the highlighting language
		name: string;
		// null keeps the server markup up, unhydrated, until the text arrives
		contents: string | null;
		// pierre's native overflow: wrap keeps highlighting instead of scrolling
		wrap?: boolean;
		// forwarded to pierre, fires after every (async-safe) render pass
		onPostRender?: FileOptions<undefined, undefined>["onPostRender"];
		// one run per stretch of lines sharing a commit, absent when blame is off
		blame?: Blame;
		selectedLines?: SelectedLineRange | null;
		onLineSelected?: (range: SelectedLineRange | null) => void;
		matches?: LineMatch[];
		current?: number;
	}

	let {
		name,
		contents,
		wrap = false,
		onPostRender,
		blame,
		selectedLines = null,
		onLineSelected,
		matches = [],
		current = -1
	}: Props = $props();

	let element: HTMLElement | undefined = $state();
	let wrapper: HTMLElement | undefined = $state();
	let rendered = $state(0);

	const hover = new BlameHover(() => wrapper);
	const budget = getSsrBudget();

	const options = (forWrap: boolean): FileOptions<undefined, undefined> => ({
		...pierreFileOptions(forWrap),
		unsafeCSS: PIERRE_SELECTION_CSS + fileShadowCSS,
		onPostRender: (node, file, phase) => {
			rendered = untrack(() => rendered) + 1;
			return onPostRender?.(node, file, phase);
		},
		enableLineSelection: onLineSelected !== undefined,
		onLineSelected: (range) => onLineSelected?.(range),
		onLineEnter: ({ lineNumber, lineElement, event }) => {
			if (!blame || event.pointerType === "touch") return;
			hover.enter(lineNumber, lineElement);
		},
		onLineLeave: () => hover.leave()
	});

	$effect(() => {
		if (!blame) hover.hide();
	});

	let instance: File | undefined;
	// the async re-render reads this and it has to match what's on screen
	let shown: { name: string; contents: string } | undefined;
	const file = $derived(contents === null ? undefined : { name, contents });
	const ready = $derived(file !== undefined);

	// same manual updates as PierreDiff, this effect owns the instance,
	// a contents swap re-renders in place instead of a shadow dom teardown
	$effect(() => {
		const host = element;
		const current = ready ? untrack(() => file) : undefined;
		if (!host || !current) return;
		// scroll and wrap lay out differently enough in grid to re-hydrate on
		// toggle, and the toggle is rare
		const dehydrated = host.shadowRoot?.querySelector("[data-dehydrated]") != null;
		if (!dehydrated) clearPierreRender(host);
		shown = current;
		// pierre hands its re-renders (a finished highlight) to the owner
		const created: File = new File(options(wrap), undefined, () =>
			created.render({ file: shown ?? current, forceRender: true })
		);
		// untracked, the render effect below owns updates
		untrack(() => {
			created.hydrate({ file: current, fileContainer: host });
			created.setSelectedLines(selectedLines, { notify: false });
		});
		instance = created;

		return () => {
			created.cleanUp();
			instance = undefined;
		};
	});

	// hydrate paints the initial file, this pass covers later name/contents
	// changes
	$effect(() => {
		const next = file;
		if (!next || (next.name === shown?.name && next.contents === shown.contents)) return;
		shown = next;
		untrack(() => instance?.render({ file: next, forceRender: true }));
	});

	$effect(() => {
		const range = selectedLines;
		instance?.setSelectedLines(range, { notify: false });
	});

	const lineElement = (line: number): HTMLElement | null =>
		element?.shadowRoot?.querySelector<HTMLElement>(`[data-line="${line}"]`) ?? null;

	let revealed: LineMatch | undefined;

	$effect(() => {
		void rendered;
		if (matches.length === 0) {
			clearHighlights();
			return;
		}
		const ranges = matches.map((match) => {
			const row = lineElement(match.line);
			return row ? textRange(row, match.start, match.end) : null;
		});
		const active = ranges[current] ?? null;
		paintHighlights(
			ranges.filter((range) => range !== null),
			active
		);
		const match = matches[current];
		if (match && active && match !== revealed) {
			revealed = match;
			active.startContainer.parentElement?.scrollIntoView({
				block: "center",
				inline: "nearest"
			});
		}
		return clearHighlights;
	});
</script>

<div bind:this={wrapper} class="relative isolate">
	<diffs-container
		data-testid="fileContents"
		data-filename={name}
		bind:this={element}
		class="diffs-container [--diffs-gap-style:none] [--diffs-selection-base:var(--color-border-focus)]"
	>
		{#if budget}
			{@const prerenderedHTML = await prerenderFile(budget, name, contents)}
			{#if prerenderedHTML}
				<!-- eslint-disable-next-line svelte/no-at-html-tags -- pierre's own renderer output, token text is escaped by its hast builder -->
				<template shadowrootmode="open">{@html prerenderedHTML}</template>
			{/if}
		{/if}
	</diffs-container>
	{#if blame}
		<BlameOverlay {blame} {hover} />
	{/if}
	{#if onLineSelected}
		<LineHandles container={wrapper} />
	{/if}
</div>