Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378<script lang="ts"> import { mount, unmount, untrack } from "svelte"; import { CodeView, type CodeViewDiffItem, type FileDiffMetadata } from "@pierre/diffs"; import { getPierreWorkerPool, pierreCodeViewOptions, PIERRE_ITEM_METRICS, PIERRE_VALIDATE_ITEM_HEIGHTS, type DiffStyle } from "$lib/components/repo/pierre"; import DiffFileHeader from "$lib/components/repo/DiffFileHeader.svelte"; import { SvelteMap } from "svelte/reactivity"; import type { Snippet } from "svelte"; import { reservedRows, type DiffEntry } from "$lib/components/repo/fileDiff";
export interface CodeViewApi { scrollToFile: (key: string) => number | undefined; resync: () => void; }
export interface CodeViewFile { key: string; name: string; oldName?: string; kind: "new" | "deleted" | "renamed" | "change"; stat: { insertions: number; deletions: number }; note?: string; lines?: number; }
interface Props { files: CodeViewFile[]; entry: (key: string) => DiffEntry | undefined; blobUrl?: (file: CodeViewFile) => string | undefined; headerActions?: Snippet<[string]>; diffStyle?: DiffStyle; wrap?: boolean; isOpen?: (key: string) => boolean; onSetOpen?: (key: string, open: boolean) => void; onWant?: (key: string) => void; onFocus?: (key: string) => void; dockTop?: () => number; onScrollExtent?: (max: number) => void; }
let { files, entry, blobUrl, headerActions, diffStyle = "unified", wrap = false, isOpen, onSetOpen, onWant, onFocus, dockTop = () => 0, onScrollExtent }: Props = $props();
const HEADER_HEIGHT = 43; const LOOKAHEAD_ITEMS = 8;
let root: HTMLElement | undefined = $state(); let scroller: HTMLElement | undefined; let contentHeight = $state(0); let view: CodeView | undefined = $state.raw(); let initError = $state<string | undefined>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- internal version tracking const versions = new Map<string, number>(); const openState = new SvelteMap<string, boolean>(); const noteState = new SvelteMap<string, string>();
type HeaderEntry = { div: HTMLDivElement; app: Record<string, unknown> }; // eslint-disable-next-line svelte/prefer-svelte-reactivity -- dom bookkeeping const headers = new Map<string, HeaderEntry>();
const mountHeader = (rendered: { id: string; element: HTMLElement }) => { const file = files.find((candidate) => candidate.key === rendered.id); if (!file || headers.has(rendered.id)) return; const div = document.createElement("div"); div.slot = "header-custom"; const app = mount(DiffFileHeader, { target: div, props: { file, openState, noteState, note: noteOf(file), blobUrl: blobUrl?.(file), headerActions, onToggle: () => onSetOpen?.(file.key, !(openState.get(file.key) ?? true)) } }); rendered.element.appendChild(div); headers.set(rendered.id, { div, app }); };
const dropHeader = (id: string) => { const held = headers.get(id); if (!held) return; void unmount(held.app); held.div.remove(); headers.delete(id); };
const codeViewOptions = (style: DiffStyle, forWrap: boolean) => ({ ...pierreCodeViewOptions(style, forWrap), disableFileHeader: false, // Presence of renderCustomHeader activates custom-header slot in Pierre's renderer. renderCustomHeader: () => undefined, itemMetrics: { ...PIERRE_ITEM_METRICS, diffHeaderHeight: HEADER_HEIGHT }, stickyHeaders: true, __devOnlyValidateItemHeights: PIERRE_VALIDATE_ITEM_HEIGHTS, layout: { paddingTop: 0, paddingBottom: 8, gap: 8 } });
const placeholder = (file: CodeViewFile): FileDiffMetadata => { const split = reservedRows(file, true); const lines = reservedRows(file, false); return { name: file.name, prevName: file.oldName, type: file.kind === "new" ? "new" : file.kind === "deleted" ? "deleted" : file.kind === "renamed" ? "rename-changed" : "change", hunks: [], splitLineCount: split, unifiedLineCount: lines, isPartial: true, deletionLines: [], additionLines: [] } as FileDiffMetadata; };
const metadataOf = (file: CodeViewFile): FileDiffMetadata => { const parsed = entry(file.key); if (parsed && "hunks" in parsed) return parsed; return placeholder(file); };
const noteOf = (file: CodeViewFile): string | undefined => { const parsed = entry(file.key); return file.note ?? (parsed && "note" in parsed ? parsed.note : undefined); };
const bumpVersion = (key: string): number => { const version = (versions.get(key) ?? 0) + 1; versions.set(key, version); return version; };
const itemOf = (file: CodeViewFile): CodeViewDiffItem => { const version = bumpVersion(file.key); return { id: file.key, type: "diff", fileDiff: metadataOf(file), collapsed: isOpen ? !isOpen(file.key) : false, version }; };
$effect(() => { const element = root; if (!element) return; let disposed = false;
void (async () => { try { await start(element); } catch (error) { if (!disposed) initError = "The diff viewer could not start in this browser."; console.error("Failed to start the diff viewer:", error); } })();
async function start(element: HTMLElement) { const pool = await getPierreWorkerPool(); if (disposed || !root) return;
const instance = new CodeView(codeViewOptions(diffStyle, wrap), pool, true);
let filled = false; instance.setSlotCoordinator({ hasHeaderRenderers: true, hasAnnotationRenderer: false, hasGutterRenderer: false, onSnapshotChange: (snapshot) => { if (!snapshot) { for (const id of [...headers.keys()]) dropHeader(id); return; } const items = snapshot.items ?? []; const live = new Set(items.map((rendered) => rendered.id)); for (const id of [...headers.keys()]) if (!live.has(id)) dropHeader(id); for (const rendered of items) { if (!headers.has(rendered.id)) mountHeader(rendered); rendered.element.toggleAttribute("data-pending", !entry(rendered.id)); } if (!filled) { filled = true; requestContents(); } } });
instance.setup(element); scroller = element; instance.setItems(files.map(itemOf)); appliedStyle = untrack(() => diffStyle); appliedWrap = untrack(() => wrap); appliedFiles = files; instance.render(true); view = instance; requestContents(); }
return () => { disposed = true; for (const id of [...headers.keys()]) dropHeader(id); view?.cleanUp(); view = undefined; scroller = undefined; }; });
const requestContents = () => { if (!view || !onWant) return; const rendered = view.getRenderedItems(); if (!rendered.length) return; const first = files.findIndex((file) => file.key === rendered[0]?.id); if (first < 0) return; if (rendered[0]) onFocus?.(rendered[0].id); const last = first + rendered.length; for (const file of files.slice( Math.max(0, first - LOOKAHEAD_ITEMS), last + LOOKAHEAD_ITEMS )) { if (!entry(file.key)) onWant(file.key); } };
const syncScroll = () => { const element = scroller; if (!element) return; const container = element.firstElementChild; if (container) { const style = getComputedStyle(container); contentHeight = container.getBoundingClientRect().height + parseFloat(style.marginTop) + parseFloat(style.marginBottom); } const max = Math.max(0, element.scrollHeight - element.clientHeight); onScrollExtent?.(max); const position = Math.min(Math.max(0, window.scrollY - dockTop()), max); if (Math.abs(element.scrollTop - position) < 0.5) return; element.scrollTop = position; };
$effect(() => { const instance = view; const element = scroller; if (!instance || !element) return; syncScroll(); const observer = new ResizeObserver(syncScroll); observer.observe(element); if (element.firstElementChild) observer.observe(element.firstElementChild); window.addEventListener("scroll", syncScroll, { passive: true }); window.addEventListener("resize", syncScroll); return () => { observer.disconnect(); window.removeEventListener("scroll", syncScroll); window.removeEventListener("resize", syncScroll); }; });
$effect(() => { const instance = view; if (!instance) return; return instance.subscribeToScroll(() => { requestContents(); syncScroll(); }); });
$effect(() => { const instance = view; if (!instance) return; let pinTo: number | undefined; for (const file of files) { const open = isOpen ? isOpen(file.key) : true; if (openState.get(file.key) !== open) openState.set(file.key, open); const item = instance.getItem(file.key); if (item?.type !== "diff" || item.collapsed === !open) continue; if (!open) { const top = instance.getTopForItem(file.key); if (top != null && window.scrollY > dockTop() + top) pinTo = top; } instance.updateItem({ ...item, collapsed: !open, version: bumpVersion(file.key) }); } syncScroll(); if (pinTo != null) { window.scrollTo({ top: dockTop() + pinTo, behavior: "instant" }); syncScroll(); } });
$effect(() => { const instance = view; if (!instance) return; for (const file of files) { const parsed = entry(file.key); if (parsed && "note" in parsed && !file.note) { if (untrack(() => noteState.get(file.key)) !== parsed.note) { noteState.set(file.key, parsed.note); } continue; } if (!parsed || !("hunks" in parsed)) continue; const current = untrack(() => instance.getItem(file.key)); if (current?.type === "diff" && current.fileDiff === parsed) continue; instance.updateItem(itemOf(file)); } syncScroll(); });
let appliedStyle: DiffStyle | undefined; let appliedWrap: boolean | undefined; let appliedFiles: CodeViewFile[] | undefined; $effect(() => { const instance = view; if (!instance) return; if (appliedStyle !== diffStyle || appliedWrap !== wrap) { appliedStyle = diffStyle; appliedWrap = wrap; appliedFiles = files; instance.setOptions(codeViewOptions(diffStyle, wrap)); instance.setItems(files.map(itemOf)); } else if (appliedFiles !== files) { appliedFiles = files; instance.setItems(files.map(itemOf)); } syncScroll(); });
export const scrollToFile = (key: string): number | undefined => { const instance = view; const top = instance?.getTopForItem(key); if (!instance || top == null) return undefined; window.scrollTo({ top: dockTop() + top, behavior: "instant" }); return top; };
export const resync = () => syncScroll();</script>
{#if initError} <div class="flex min-h-32 items-center justify-center p-4"> <span class="typography-paragraph-small text-foreground-danger">{initError}</span> </div>{:else} <div class="relative flex min-h-0 shrink grow basis-auto flex-col"> <div bind:this={root} class="codeview relative min-h-0 w-full shrink grow basis-auto overflow-x-clip overflow-y-hidden will-change-scroll contain-strict" style:height="{contentHeight}px" ></div> </div>{/if}