<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>