~james/core

core/web/src/lib/components/repo/DiffFileTree.svelte -rw-r--r-- 4.1 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
<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>