Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531<script lang="ts"> import { mount, unmount, untrack } from "svelte"; import { CodeView, type CodeViewDiffItem, type CodeViewLineSelection, type FileDiffMetadata, type SelectedLineRange, type SelectionSide, type VirtualizedFileDiff } from "@pierre/diffs"; import { diffLineSpan } from "#lib/components/repo/lineAnchor.ts"; import { retainPierreWorkerPool, pierreCodeViewOptions, PIERRE_ITEM_METRICS, PIERRE_VALIDATE_ITEM_HEIGHTS, PIERRE_LINE_HEIGHT, type DiffStyle } from "#lib/components/repo/pierre.ts"; import DiffFileHeader from "#lib/components/repo/DiffFileHeader.svelte"; import LineHandles from "#lib/components/repo/LineHandles.svelte"; import { SvelteMap } from "svelte/reactivity"; import type { Snippet } from "svelte"; import { hiddenLine, newSideLine, reservedRows, type DiffEntry } from "#lib/components/repo/fileDiff.ts";
export interface CodeViewApi { scrollToFile: (key: string, range?: SelectedLineRange | null) => 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; onLineSelected?: (key: string, range: SelectedLineRange | null) => void; onWantWhole?: (key: string) => boolean; onQuoteLines?: (key: string, range: SelectedLineRange) => void; }
let { files, entry, blobUrl, headerActions, diffStyle = "unified", wrap = false, isOpen, onSetOpen, onWant, onFocus, dockTop = () => 0, onScrollExtent, onLineSelected, onWantWhole, onQuoteLines }: Props = $props();
const HEADER_HEIGHT = 38; const DIFF_BOTTOM_BORDER = 1; const LOOKAHEAD_ITEMS = 8;
let root: HTMLElement | undefined = $state(); let frame: 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, paddingBottom: DIFF_BOTTOM_BORDER }, stickyHeaders: true, enableLineSelection: onLineSelected !== undefined, onLineSelectionStart: trackSelection, onLineSelectionChange: trackSelection, onLineSelected: (range: SelectedLineRange | null, context: ItemContext) => { if (quoting) { quoting = false; restoreSelection(); return; } trackSelection(range, context); committed = selected; onLineSelected?.(context.item.id, range); }, enableGutterUtility: onQuoteLines !== undefined, onGutterUtilityClick: onQuoteLines && ((range: SelectedLineRange, context: ItemContext) => { quoting = true; onQuoteLines(context.item.id, range); }), __devOnlyValidateItemHeights: PIERRE_VALIDATE_ITEM_HEIGHTS, layout: { paddingTop: 0, paddingBottom: 8, gap: 8 } });
let selected: CodeViewLineSelection | null = null; let committed: CodeViewLineSelection | null = null; let quoting = false;
type ItemContext = { item: { id: string } };
const highlightSide = (range: SelectedLineRange): SelectionSide | undefined => { const { side, endSide } = diffLineSpan(range); return side === endSide ? side : undefined; };
const paintSelection = () => { const current = selected; if (!view || !current) return; for (const rendered of view.getRenderedItems()) { if (rendered.id !== current.id || rendered.type !== "diff") continue; rendered.instance.setSelectedLines(current.range, { notify: false, activeLineSide: highlightSide(current.range) }); } };
const restoreSelection = () => { selected = committed; view?.setSelectedLines(committed, { notify: false }); paintSelection(); };
const trackSelection = (range: SelectedLineRange | null, context: ItemContext): void => { selected = range ? { id: context.item.id, range } : null; paintSelection(); };
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: [], deletionCharacterCount: 0, additionCharacterCount: 0 } 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; const { pool: poolPromise, release } = retainPierreWorkerPool();
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 poolPromise; 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)); } paintSelection(); 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; release(); }; });
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) }); } paintSelection(); 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)); } paintSelection(); 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)); } paintSelection(); syncScroll(); });
const revealRange = ( key: string, instance: VirtualizedFileDiff<undefined, undefined>, range: SelectedLineRange ): boolean => { const parsed = entry(key); if (!parsed || !("hunks" in parsed)) return true; const { start, end, side, endSide } = diffLineSpan(range); const from = newSideLine(parsed.hunks, start, side); const to = newSideLine(parsed.hunks, end, endSide); const ends = [ hiddenLine(parsed.hunks, start, side), hiddenLine(parsed.hunks, end, endSide) ]; for (let line = Math.min(from, to); line <= Math.max(from, to); line++) { if (hiddenLine(parsed.hunks, line, "additions") !== undefined) ends.push(line); } const hidden = ends.filter((line) => line !== undefined); if (!hidden.length) return false; if (parsed.isPartial) return onWantWhole?.(key) ?? false; return hidden.some((line) => instance.revealLine(line)); };
const scrollToOffset = (top: number) => window.scrollTo({ top: dockTop() + top, behavior: "instant" });
export const scrollToFile = ( key: string, range?: SelectedLineRange | null ): number | undefined => { const instance = view; const top = instance?.getTopForItem(key); if (!instance || top == null) return undefined; if (!range) { scrollToOffset(top); return top; } selected = { id: key, range }; committed = selected; instance.setSelectedLines(selected, { notify: false }); paintSelection(); if (isOpen && !isOpen(key)) { onSetOpen?.(key, true); scrollToOffset(top); return undefined; } const rendered = instance.getRenderedItems().find((item) => item.id === key); if (rendered?.type !== "diff") { scrollToOffset(top); return undefined; } const pending = revealRange(key, rendered.instance, range); const first = rendered.instance.getLinePosition(range.start, range.side); const last = rendered.instance.getLinePosition(range.end, range.endSide ?? range.side); if (!first || !last) { scrollToOffset(top); return pending ? undefined : top; } const lineTop = Math.min(first.top, last.top); const lineBottom = Math.max(first.top + first.height, last.top + last.height); const room = window.innerHeight - 2 * HEADER_HEIGHT; const span = lineBottom - lineTop; const margin = span < room ? (room - span) / 2 : PIERRE_LINE_HEIGHT * 2; const at = Math.max(0, Math.round(top + lineTop - HEADER_HEIGHT - margin)); scrollToOffset(at); return pending ? undefined : at; };
export const resync = () => syncScroll();</script>
{#if initError} <div class="flex min-h-32 items-center justify-center p-md"> <span class="typography-paragraph-small text-foreground-danger">{initError}</span> </div>{:else} <div bind:this={frame} class="relative isolate 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> {#if onLineSelected} <LineHandles container={frame} /> {/if} </div>{/if}