<script module lang="ts">
import { tv } from "tailwind-variants";
const treeRow = tv({
slots: {
root: "diff-file-tree pt-2xs pr-md pb-2xl pl-1.5",
row: "tree-entry group my-3xs inline-flex w-full items-center gap-xs rounded-md px-1.5 py-0.75 text-foreground-default hover:bg-background-subtle",
icon: "size-3.5 shrink-0 text-foreground-placeholder group-hover:text-foreground-default",
name: "tree-name truncate select-none",
children: "tree-children ml-[12.5px] border-l border-border-subtle pl-3xs"
},
variants: {
kind: {
dir: {
row: "tree-directory cursor-pointer text-left",
icon: "tree-toggle transition-[rotate] duration-(--fold-duration) ease-(--fold-easing) group-aria-expanded:rotate-90",
name: "text-foreground-muted group-hover:text-inherit"
},
file: { row: "tree-file flex items-center gap-xs", icon: "tree-icon" }
}
}
});
interface TreeDir {
type: "dir";
name: string;
path: string;
children: TreeNode[];
}
interface TreeFile {
type: "file";
name: string;
path: string;
}
type TreeNode = TreeDir | TreeFile;
const buildTree = (paths: string[]): TreeNode[] => {
const roots: TreeNode[] = [];
const dirs: Record<string, TreeDir> = Object.create(null);
for (const path of paths) {
const parts = path.split("/");
let siblings = roots;
let prefix = "";
for (const dir of parts.slice(0, -1)) {
prefix = prefix ? `${prefix}/${dir}` : dir;
let node = dirs[prefix];
if (!node) {
node = { type: "dir", name: dir, path: prefix, children: [] };
dirs[prefix] = node;
siblings.push(node);
}
siblings = node.children;
}
siblings.push({ type: "file", name: parts.at(-1) ?? path, path });
}
return roots;
};
</script>
<script lang="ts">
import File from "$icon/file";
import { fileAnchor } from "#lib/components/repo/fileAnchor.svelte.ts";
import ChevronRight from "$icon/chevron-right";
import { prefersReducedMotion } from "svelte/motion";
import { CROSSFADE_DURATION, CROSSFADE_EASING, crossfadeEasing } from "#lib/motion/spring.ts";
import { revealHeight } from "#lib/motion/reveal.ts";
interface Props {
/** full slash-separated paths, linked as the diff's own file anchors */
files: { name: string }[];
/** when the diff owns its own scroller, jumping is a call, not an anchor */
onSelect?: (path: string) => void;
}
let { files, onSelect }: Props = $props();
const tree = $derived(buildTree(files.map((file) => file.name)));
let closed = $state<Record<string, boolean>>({});
const uid = $props.id();
const duration = $derived(prefersReducedMotion.current ? 0 : CROSSFADE_DURATION);
const dir = treeRow({ kind: "dir" });
const file = treeRow({ kind: "file" });
</script>
{#snippet nodes(list: TreeNode[])}
{#each list as node (`${node.type}:${node.path}`)}
{#if node.type === "dir"}
{@const open = !closed[node.path]}
{@const id = `${uid}-${node.path}`}
<button
type="button"
class={dir.row()}
data-kind="directory"
aria-expanded={open}
aria-controls={open ? id : undefined}
onclick={() => (closed[node.path] = open)}
>
<ChevronRight class={dir.icon()} aria-hidden="true" />
<span class={dir.name()}>{node.name}</span>
</button>
{#if open}
<div
{id}
class={dir.children()}
transition:revealHeight={{ duration, easing: crossfadeEasing }}
>
{@render nodes(node.children)}
</div>
{/if}
{:else}
<!-- an in-page anchor the scroller honours, so there is no route to resolve -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={fileAnchor(node.path)}
class={file.row()}
data-kind="file"
onclick={onSelect
? (event) => {
event.preventDefault();
onSelect(node.path);
}
: undefined}
>
<File class={file.icon()} aria-hidden="true" />
<span class={file.name()}>{node.name}</span>
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/if}
{/each}
{/snippet}
<div class={dir.root()} style:--fold-duration="{duration}ms" style:--fold-easing={CROSSFADE_EASING}>
{@render nodes(tree)}
</div>