<script module lang="ts">
import { tv } from "tailwind-variants";
export const fileRow = tv({
slots: {
root: "my-3xs grid items-center gap-md rounded-md px-1.5 py-0.75",
link: "hit-area-y-1 flex min-w-0 cursor-pointer items-center gap-xs text-foreground-default no-underline hover:underline",
icon: "text-foreground-placeholder",
message: "hidden min-w-0 md:col-span-6 md:block",
time: "text-right text-foreground-subtle"
},
variants: {
withMessage: {
true: {
root: "grid-cols-12",
link: "col-span-8 md:col-span-4",
time: "col-span-4 md:col-span-2"
},
false: {
root: "grid-cols-3",
link: "col-span-2",
time: "col-span-1"
}
}
},
defaultVariants: {
withMessage: false
}
});
</script>
<script lang="ts">
import { resolve } from "$app/paths";
import { MediaQuery } from "svelte/reactivity";
import ChevronDown from "$icon/chevron-down";
import File from "$icon/file";
import FileSymlink from "$icon/file-symlink";
import Folder from "$icon/folder";
import FolderInput from "$icon/folder-input";
import { splitMessage } from "#lib/api/repo.ts";
import { button } from "#lib/components/ui/Button.svelte";
import TimeAgo from "#lib/components/ui/TimeAgo.svelte";
import type { TreeEntrySummary } from "#lib/components/repo/types.ts";
import { encodePathSegments } from "#lib/components/repo/urls.ts";
interface Props {
ownerHandle: string;
repoName: string;
ref: string;
entries: TreeEntrySummary[];
path?: string;
withMessage?: boolean;
clamp?: boolean;
}
let {
ownerHandle,
repoName,
ref,
entries,
path = "",
withMessage = false,
clamp = false
}: Props = $props();
const wide = new MediaQuery("min-width: 48rem");
let root = $state<HTMLDivElement>();
let fit = $state<number>();
const cut = $derived(
clamp && wide.current && fit !== undefined && fit < entries.length ? fit : undefined
);
$effect(() => {
if (!clamp || !wide.current || !root) return;
const el = root;
const measure = () => {
if (el.querySelector("details[open]")) return;
const rows = el.querySelectorAll<HTMLElement>(".tree-entry");
if (rows.length === 0) return;
const step =
rows.length > 1 ? rows[1].offsetTop - rows[0].offsetTop : rows[0].offsetHeight;
fit = Math.max(Math.floor(el.clientHeight / step), 1);
};
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
});
const base = $derived(`/${ownerHandle}/${repoName}`);
const encodedRef = $derived(encodeURIComponent(ref));
const row = $derived(fileRow({ withMessage }));
const linkFor = (entry: TreeEntrySummary) => {
const target = path ? `${path}/${entry.name}` : entry.name;
const kind = entry.kind === "directory" ? "tree" : "blob";
return `${base}/${kind}/${encodedRef}/${encodePathSegments(target)}`;
};
// lucide puts fill="none" on the path itself, which beats anything inherited
// from the svg, so the class has to reach the path, and only the last svg so
// icon extensions that copy its classes onto an icon put before it keep their fills
const iconWrapClassFor = (entry: TreeEntrySummary) =>
entry.kind === "directory"
? [row.icon(), "[&>svg:last-child_path]:fill-current"]
: row.icon();
const iconFor = (entry: TreeEntrySummary) => {
switch (entry.kind) {
case "directory":
return Folder;
case "submodule":
return FolderInput;
case "symlink":
return FileSymlink;
default:
return File;
}
};
</script>
{#snippet entryRow(entry: TreeEntrySummary)}
{@const Glyph = iconFor(entry)}
{@const href = resolve(linkFor(entry) as "/")}
<div class={["tree-entry", `tree-${entry.kind}`, row.root()]} data-kind={entry.kind}>
<a {href} class={["tree-link", row.link()]}>
<span class={iconWrapClassFor(entry)}>
<Glyph class="tree-icon size-3.5 shrink-0" aria-hidden="true" />
</span>
<span class="tree-name truncate">{entry.name}</span>
</a>
{#if withMessage}
<div class={row.message()}>
{#if entry.lastCommitHash && entry.lastCommitMessage}
<a
href={resolve(`${base}/commit/${entry.lastCommitHash}` as "/")}
class="block truncate text-foreground-muted no-underline hover:text-foreground-default"
>
{splitMessage(entry.lastCommitMessage)[0]}
</a>
{/if}
</div>
{/if}
<div class={row.time()}>
{#if entry.lastCommitHash && entry.lastCommitWhen}
<a
href={resolve(`${base}/commit/${entry.lastCommitHash}` as "/")}
class="text-foreground-muted no-underline hover:text-foreground-default"
title={entry.lastCommitMessage}
>
<TimeAgo value={entry.lastCommitWhen} />
</a>
{/if}
</div>
</div>
{/snippet}
<!-- the divider belongs to whatever column the tree is sat in -->
<div
bind:this={root}
class={["file-tree min-w-0", clamp && "md:relative md:h-full md:overflow-hidden"]}
>
{#if cut === undefined}
{#each entries as entry (entry.name)}
{@render entryRow(entry)}
{/each}
{:else}
<div
class="[&:has(+details:not([open]))]:mask-[linear-gradient(to_bottom,black_calc(100%-4rem),transparent)]"
>
{#each entries.slice(0, cut) as entry (entry.name)}
{@render entryRow(entry)}
{/each}
</div>
<details class="group">
<summary
class={button({
variant: "ghost",
size: "small",
class: "absolute inset-x-0 bottom-0 mx-auto flex w-fit list-none group-open:hidden [&::-webkit-details-marker]:hidden"
})}
>
<ChevronDown class="size-3.5" aria-hidden="true" />
Show all
</summary>
{#each entries.slice(cut) as entry (entry.name)}
{@render entryRow(entry)}
{/each}
</details>
{/if}
</div>