Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321<script lang="ts"> import FileDiff from "$icon/file-diff"; import FileCode from "$icon/file-code"; import FileDown from "$icon/file-down"; import PanelLeftClose from "$icon/panel-left-close"; import ListTree from "$icon/list-tree"; import LoaderCircle from "$icon/loader-circle"; import SquareSplitHorizontal from "$icon/square-split-horizontal"; import SquareSplitVertical from "$icon/square-split-vertical"; import FoldVertical from "$icon/fold-vertical"; import UnfoldVertical from "$icon/unfold-vertical"; import Button from "$lib/components/ui/Button.svelte"; import EmptyState from "$lib/components/ui/EmptyState.svelte"; import ButtonGroup, { segmentProps } from "$lib/components/ui/ButtonGroup.svelte"; import Card from "$lib/components/ui/Card.svelte"; import Checkbox from "$lib/components/ui/Checkbox.svelte"; import DiffFileTree from "$lib/components/repo/DiffFileTree.svelte"; import DiffStatPill from "$lib/components/repo/DiffStatPill.svelte"; import DiffFiles from "$lib/components/repo/DiffFiles.svelte"; import type { CodeViewFile } from "$lib/components/repo/DiffCodeView.svelte"; import type { DiffStyle } from "$lib/components/repo/pierre"; import type { PatchStream } from "$lib/api/patchStream"; import type { RepoDiff, RepoDiffDeps } from "$lib/api/repoDiff"; import Dropdown from "$lib/components/ui/Dropdown.svelte"; import DropdownItem from "$lib/components/ui/DropdownItem.svelte"; import repoDiffviewSkeletonFixture from "$lib/bones/repo-diffview--skeleton-fixture.bones.json"; import repoDiffviewSkeletonFixtureCollapsed from "$lib/bones/repo-diffview--skeleton-fixture-collapsed.bones.json"; import Bones from "$lib/components/ui/Bones.svelte"; import ResizeHandle from "$lib/components/ui/ResizeHandle.svelte"; import { browser } from "$app/environment"; import { tick, untrack } from "svelte";
interface Props { deps: RepoDiffDeps; // absent while loading and on an error diff?: RepoDiff; error?: string; loading?: boolean; blobUrl?: (file: CodeViewFile) => string | undefined; /** the style to open with, and the live one when the route owns it */ diffStyle?: DiffStyle; /** set when the route persists the style in its url: the buttons become links and this component stops keeping a second copy of the setting */ styleHref?: (style: DiffStyle) => string; wrap?: boolean; downloadUrls?: { patch: string; diff: string }; /** where the whole diff can be fetched in one go, preferred over the download url */ patchUrl?: string; /** a patch already streaming in parallel with the file list */ patch?: PatchStream; stickyOffset?: number; treeCollapsed?: boolean; }
let { deps, diff, error, loading = false, blobUrl, diffStyle: routeStyle = "unified", styleHref, wrap = $bindable(false), downloadUrls, patchUrl, patch, stickyOffset = 0, treeCollapsed = false }: Props = $props();
const SM = 640; let treeToggled = $state<boolean | null>(untrack(() => (treeCollapsed ? false : null))); const TREE_WIDTH = 250; const TREE_MIN_WIDTH = 200; const TREE_MAX_WIDTH = 560; let treeWidth = $state(TREE_WIDTH); let resizing = $state(false); let shell = $state<HTMLDivElement>(); let dock = $state(0); let diffScroll = $state(0); const HEADER_GAP = 8; const stickyTop = $derived(stickyOffset ? stickyOffset + HEADER_GAP : 0); const maxHeight = $derived(`calc(100svh - ${stickyTop}px)`); const dockOffset = $derived(stickyTop); const measureDock = () => { if (!shell) return; dock = window.scrollY + shell.getBoundingClientRect().top - dockOffset; }; $effect(() => { const element = shell; if (!element) return; const measure = measureDock; measure(); const observer = new ResizeObserver(measure); observer.observe(element); window.addEventListener("scroll", measure, { passive: true }); window.addEventListener("resize", measure); return () => { observer.disconnect(); window.removeEventListener("scroll", measure); window.removeEventListener("resize", measure); }; }); const dockTop = () => dock; // only pages without a url for it keep the style here, so the toolbar and the // address bar can never disagree let localStyle = $state<DiffStyle>(routeStyle); const diffStyle = $derived(styleHref ? routeStyle : localStyle);
// the scroller lives two levels down; the page's conflict list and this // component's file tree both reach it through here let files = $state<{ jumpToFile: (path: string) => number | undefined; resync: () => void; }>(); export const jumpToFile = (path: string): number | undefined => files?.jumpToFile(path);
const selectFromTree = (path: string): number | undefined => { if (!browser || window.innerWidth >= SM) return jumpToFile(path); treeToggled = false; void (async () => { await tick(); files?.resync(); await tick(); measureDock(); jumpToFile(path); })(); return undefined; };
let openStates = $state<Record<string, boolean>>({}); const isOpen = (key: string) => openStates[key] ?? true; const allOpen = $derived((diff?.files ?? []).every((file) => isOpen(file.key))); const setAllOpen = (open: boolean) => { openStates = Object.fromEntries((diff?.files ?? []).map((file) => [file.key, open])); };</script>
{#if loading && !diff} <Bones capture={treeCollapsed ? repoDiffviewSkeletonFixtureCollapsed : repoDiffviewSkeletonFixture} />{:else} <div bind:this={shell} class:select-none={resizing}> <Card padding="none" class="sticky flex gap-2 px-2" style="top: {stickyTop}px; max-height: {maxHeight}" > <!-- file tree --> {#if treeToggled !== false} <aside class="{treeToggled === null ? 'hidden sm:flex' : 'flex'} min-h-0 w-full shrink-0 flex-col sm:w-(--tree-width)" style:--tree-width="{treeWidth}px" > <div class="flex min-h-12 items-center bg-background-default py-2"> <Button icon={PanelLeftClose} title="Collapse file tree" onclick={() => (treeToggled = false)} variant="ghost" size="small" /> </div> <div class="min-h-0 shrink grow basis-auto scrollbar-gutter-stable overflow-y-auto" > {#if diff} <DiffFileTree files={diff.files} onSelect={selectFromTree} /> {/if} </div> </aside> <ResizeHandle class={treeToggled === null ? "hidden sm:flex" : "flex"} bind:width={treeWidth} bind:resizing min={TREE_MIN_WIDTH} max={TREE_MAX_WIDTH} resetTo={TREE_WIDTH} side="before" /> {/if} <!-- diffs: a sized flex column, so the scroller takes exactly the room the toolbar leaves --> <div class="{treeToggled === true ? 'hidden sm:flex' : 'flex'} min-h-0 min-w-0 flex-1 flex-col" > <!-- nowrap: a wrapped toolbar grows past the space the file headers dock under. the button labels drop out below the width where the full toolbar still fits on that one line --> <div class="@container/toolbar relative z-10 flex min-h-12 flex-nowrap items-center justify-between gap-2 bg-background-default py-2" > <div class="{treeToggled === false ? '' : 'sm:pl-1'} flex items-center gap-3"> {#if treeToggled !== true} <Button title="Expand file tree" icon={ListTree} onclick={() => (treeToggled = true)} variant="ghost" size="small" class={treeToggled === false ? undefined : "sm:hidden"} /> {/if} {#if diff} <DiffStatPill stat={diff.stat} size="small" /> <span class="typography-paragraph-small text-foreground-muted"> {diff.files.length} changed {diff.files.length === 1 ? "file" : "files"} </span> {/if} </div> <div class="flex items-center gap-2"> <Button icon={allOpen ? FoldVertical : UnfoldVertical} title={allOpen ? "Collapse lines" : "Expand lines"} onclick={() => setAllOpen(!allOpen)} variant="ghost" size="small" > <span class="hidden @min-[42rem]/toolbar:inline" >{allOpen ? "Collapse" : "Expand"}</span > </Button> {#if downloadUrls} <Dropdown variant="button" buttonVariant="ghost" size="small" align="right" label="Download commit" title="Download commit" > {#snippet trigger()} <FileDown class="size-4" aria-hidden="true" /> <span class="hidden @min-[42rem]/toolbar:inline" >Download commit</span > {/snippet} <DropdownItem href={downloadUrls.patch as "/"} data-sveltekit-reload icon={FileCode}>As a patch file</DropdownItem > <DropdownItem href={downloadUrls.diff as "/"} data-sveltekit-reload icon={FileDiff}>As a diff file</DropdownItem > </Dropdown> {/if} <Checkbox bind:checked={wrap} class="px-2 text-foreground-muted"> Wrap </Checkbox> <ButtonGroup> {#each ["unified", "split"] as const as style (style)} <Button icon={style === "split" ? SquareSplitHorizontal : SquareSplitVertical} title="{style === 'split' ? 'Split' : 'Unified'} diff" href={styleHref ? (styleHref(style) as "/") : undefined} data-sveltekit-replacestate data-sveltekit-noscroll onclick={styleHref ? undefined : () => (localStyle = style)} size="small" {...segmentProps(diffStyle === style)} > <span class="hidden @min-[42rem]/toolbar:inline" >{style === "split" ? "Split" : "Unified"}</span > </Button> {/each} </ButtonGroup> </div> </div> {#if error} <div class="mb-2 flex min-h-32 items-center justify-center rounded-sm border border-border-danger bg-background-danger-subtle p-4 typography-paragraph-regular text-foreground-danger" > <span>{error}</span> </div> {:else if diff && diff.files.length > 0} <!-- a new diff gets a fresh fill pool by teardown, so a version switch cannot show the previous version's text under a repeated file path --> {#key diff} <DiffFiles {deps} {diff} {blobUrl} {diffStyle} {wrap} {patch} {dockTop} onScrollExtent={(max) => (diffScroll = max)} bind:this={files} {isOpen} onSetOpen={(key, open) => (openStates[key] = open)} patchUrl={patchUrl ?? downloadUrls?.diff} /> {/key} {:else if diff} <EmptyState class="mb-2 min-h-32" message="No change between two revisions." /> {:else} <div class="mb-2 flex min-h-32 items-center justify-center text-foreground-subtle" > <LoaderCircle class="inline-flex size-4 animate-spin" aria-hidden="true" /> </div> {/if} </div> </Card> <div aria-hidden="true" style:height="{diffScroll}px"></div> </div>{/if}