Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395<script lang="ts"> const iconOnlyNarrow = "@max-[42rem]/toolbar:w-[calc(1.75rem-var(--seg-inset,0px))] @max-[42rem]/toolbar:px-0"; 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 { prefersReducedMotion } from "svelte/motion"; import { MediaQuery } from "svelte/reactivity"; import { fade } from "svelte/transition"; import { CROSSFADE_DURATION, RAIL_DURATION, crossfadeEasing, railEasing } from "#lib/motion/spring.ts"; import { revealWidth } from "#lib/motion/reveal.ts"; import Button, { buttonIcon } from "#lib/components/ui/Button.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte"; import Segment from "#lib/components/ui/Segment.svelte"; import SegmentedControl from "#lib/components/ui/SegmentedControl.svelte"; import Card from "#lib/components/ui/Card.svelte"; import Modal from "#lib/components/ui/Modal.svelte"; import Toggle from "#lib/components/ui/Toggle.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 { SelectedLineRange } from "@pierre/diffs"; import type { CodeViewFile } from "#lib/components/repo/DiffCodeView.svelte"; import type { DiffStyle } from "#lib/components/repo/pierre.ts"; import type { PatchStream } from "#lib/api/patchStream.ts"; import type { RepoDiff, RepoDiffDeps } from "#lib/api/repoDiff.ts"; 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 { plural } from "#lib/format.ts"; 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; treeCollapsed?: boolean; linkLines?: boolean; onQuoteLines?: (path: string, range: SelectedLineRange) => void; }
let { deps, diff, error, loading = false, blobUrl, diffStyle: routeStyle = "unified", styleHref, wrap = $bindable(false), downloadUrls, patchUrl, patch, treeCollapsed = false, linkLines = false, onQuoteLines }: Props = $props();
let treeOpen = $state(untrack(() => !treeCollapsed)); let treeSheetOpen = $state(false); const wide = new MediaQuery("width >= 40rem", true); 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 measureDock = () => { if (!shell) return; dock = window.scrollY + shell.getBoundingClientRect().top; }; $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>(untrack(() => 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 selectFromSheet = (path: string) => { treeSheetOpen = false; void tick().then(() => jumpToFile(path)); };
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>
{#snippet download(size: "small" | "regular")} {#if downloadUrls} <Dropdown variant="button" buttonVariant="ghost" {size} align="right" label="Download commit" title="Download commit" triggerClass={iconOnlyNarrow} > {#snippet trigger()} <FileDown class={buttonIcon({ size })} 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}{/snippet}
{#snippet styleSwitch(size: "small" | "regular", narrow: string)} <SegmentedControl value={diffStyle} href={styleHref ? (style) => styleHref(style) as "/" : undefined} label="Diff style" > {#each ["unified", "split"] as const as style (style)} <Segment value={style} icon={style === "split" ? SquareSplitHorizontal : SquareSplitVertical} title="{style === 'split' ? 'Split' : 'Unified'} diff" data-sveltekit-replacestate data-sveltekit-reset="false" onclick={styleHref ? undefined : () => (localStyle = style)} {size} class={narrow} > <span class={[narrow && "hidden @min-[42rem]/toolbar:inline"]} >{style === "split" ? "Split" : "Unified"}</span > </Segment> {/each} </SegmentedControl>{/snippet}
{#if loading && !diff} <Bones capture={treeCollapsed ? repoDiffviewSkeletonFixtureCollapsed : repoDiffviewSkeletonFixture} />{:else} <div bind:this={shell} class:select-none={resizing}> <Card padding="none" class="sticky top-0 flex max-h-[calc(100svh-var(--dock-bottom,0px))] px-xs py-px" > <!-- file tree --> {#if treeOpen && wide.current} <div class="hidden min-h-0 shrink-0 justify-end sm:flex" transition:revealWidth={{ duration: prefersReducedMotion.current ? 0 : RAIL_DURATION, easing: railEasing }} > <div class="flex min-h-0 shrink-0 gap-xs pr-xs"> <aside class="flex min-h-0 w-(--tree-width) shrink-0 flex-col" style:--tree-width="{treeWidth}px" > <div class="flex min-h-12 items-center bg-background-default py-xs"> <Button icon={PanelLeftClose} title="Collapse file tree" onclick={() => (treeOpen = 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={jumpToFile} /> {/if} </div> </aside> <ResizeHandle bind:width={treeWidth} bind:resizing min={TREE_MIN_WIDTH} max={TREE_MAX_WIDTH} resetTo={TREE_WIDTH} side="before" /> </div> </div> {/if} <!-- diffs: a sized flex column, so the scroller takes exactly the room the toolbar leaves --> <div class="flex min-h-0 min-w-0 flex-1 flex-col"> <div class="relative z-10 flex flex-col gap-sm bg-background-default py-xs sm:hidden" > <div class="flex items-center justify-between gap-xs"> <div class="flex items-center gap-xs"> {#if diff} <DiffStatPill stat={diff.stat} /> <span data-testid="changedFilesCount" data-count={diff.files.length} class="text-foreground-muted" > {diff.files.length} changed {plural( diff.files.length, "file", "files" )} </span> {/if} </div> <div class="flex items-center gap-xs"> {@render download("regular")} <Button icon={ListTree} disabled={!diff} onclick={() => (treeSheetOpen = true)} > Tree </Button> </div> </div> <div class="flex items-center gap-xs"> {@render styleSwitch("regular", "")} <Button icon={allOpen ? FoldVertical : UnfoldVertical} title={allOpen ? "Collapse all" : "Expand all"} onclick={() => setAllOpen(!allOpen)} /> <Toggle bind:checked={wrap} class="ml-auto pr-xs">Wrap</Toggle> </div> </div> <!-- 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-xs bg-background-default py-xs max-sm:hidden" > <div class={["flex items-center gap-sm", treeOpen && "pl-2xs"]}> {#if !treeOpen} <Button title="Expand file tree" icon={ListTree} onclick={() => (treeOpen = true)} variant="ghost" size="small" /> {/if} {#if diff} <DiffStatPill stat={diff.stat} size="small" /> <span data-testid="changedFilesCount" data-count={diff.files.length} class="typography-paragraph-small text-foreground-muted" > {diff.files.length} changed {plural( diff.files.length, "file", "files" )} </span> {/if} </div> <div class="flex items-center gap-xs"> <Button icon={allOpen ? FoldVertical : UnfoldVertical} title={allOpen ? "Collapse lines" : "Expand lines"} onclick={() => setAllOpen(!allOpen)} variant="ghost" size="small" class={iconOnlyNarrow} > <span class="hidden @min-[42rem]/toolbar:inline" >{allOpen ? "Collapse" : "Expand"}</span > </Button> {@render download("small")} <Toggle bind:checked={wrap} size="small" class="pr-xs pl-2xs">Wrap</Toggle> {@render styleSwitch("small", iconOnlyNarrow)} </div> </div> {#if error} <div class="mb-xs flex min-h-32 items-center justify-center rounded-md border border-border-danger bg-background-danger-subtle p-md 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} <!-- a held diff gives way to the next one with a fade, not a skeleton --> <div class="flex min-h-0 shrink grow basis-auto flex-col" in:fade={{ duration: prefersReducedMotion.current ? 0 : CROSSFADE_DURATION, easing: crossfadeEasing }} > <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} {linkLines} {onQuoteLines} /> </div> {/key} {:else if diff} <EmptyState class="mb-xs min-h-32" message="No change between two revisions." /> {:else} <div class="mb-xs 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 diff} <Modal drawer bind:open={treeSheetOpen}> <DiffFileTree files={diff.files} onSelect={selectFromSheet} /> </Modal> {/if}{/if}