<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 Search from "$icon/search";
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 LineSearch from "#lib/components/lines/LineSearch.svelte";
import { Finder } from "#lib/components/lines/finder.svelte.ts";
import type { DiffMatch } from "#lib/components/lines/diff.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;
search: (query: string) => DiffMatch[];
}>();
const finder = new Finder<DiffMatch>((query) => files?.search(query) ?? []);
const searchable = $derived((diff?.files.length ?? 0) > 0 && !error);
const onkeydown = (event: KeyboardEvent) => {
if (searchable) finder.onkeydown(event);
};
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}
<svelte:window {onkeydown} />
{#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)}
/>
{#if searchable}
<Button
icon={Search}
title="Find in diff"
onclick={() => finder.show()}
/>
{/if}
<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 finder.open && searchable}
<LineSearch
label="Find in diff"
bind:query={finder.query}
bind:input={finder.input}
total={finder.matches.length}
current={finder.current}
onstep={(delta) => finder.step(delta)}
onclose={() => finder.close()}
class="relative z-10 pb-xs"
/>
{/if}
{#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}
matches={finder.matches}
current={finder.current}
/>
</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}