diff --git a/web/src/lib/api/repo.ts b/web/src/lib/api/repo.ts index 930e09f9..83048848 100644 --- a/web/src/lib/api/repo.ts +++ b/web/src/lib/api/repo.ts @@ -108,6 +108,9 @@ const splitMessage = (message: string): [string, string] => { return [message.slice(0, separator).trim(), message.slice(separator + 2).trim()]; }; +/** the subject is the first paragraph, not the first line */ +export const subjectOf = (message: string): string => splitMessage(message)[0]; + export const toCommitSummary = (commit: LogCommit): CommitSummary => { const [subject, body] = splitMessage(commit.message ?? ""); const hash = commit.this ?? ""; @@ -123,6 +126,21 @@ export const toCommitSummary = (commit: LogCommit): CommitSummary => { }; }; +// the tree endpoint uses lexicon casing where the log ones pass through go field +// names, so this cannot share `toCommitSummary` +export const toTreeCommitSummary = (commit: Tree.LastCommit): CommitSummary => { + const [subject, body] = splitMessage(commit.message ?? ""); + return { + hash: commit.hash, + shortHash: commit.hash.slice(0, 8), + subject, + body, + authorName: commit.author?.name ?? "", + authorEmail: commit.author?.email ?? "", + when: commit.when ?? commit.author?.when ?? "" + }; +}; + export interface BranchSummary { name: string; hash: string; diff --git a/web/src/lib/components/repo/FileTree.stories.svelte b/web/src/lib/components/repo/FileTree.stories.svelte new file mode 100644 index 00000000..67026d88 --- /dev/null +++ b/web/src/lib/components/repo/FileTree.stories.svelte @@ -0,0 +1,44 @@ + + + + + + diff --git a/web/src/lib/components/repo/FileTree.svelte b/web/src/lib/components/repo/FileTree.svelte index 4ad36ad4..bb03d79d 100644 --- a/web/src/lib/components/repo/FileTree.svelte +++ b/web/src/lib/components/repo/FileTree.svelte @@ -4,6 +4,7 @@ import FileSymlink from "$icon/file-symlink"; import Folder from "$icon/folder"; import FolderInput from "$icon/folder-input"; + import { subjectOf } from "$lib/api/repo"; import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; import type { TreeEntrySummary } from "./types"; @@ -13,9 +14,10 @@ ref: string; entries: TreeEntrySummary[]; path?: string; + withMessage?: boolean; } - let { ownerHandle, repoName, ref, entries, path = "" }: Props = $props(); + let { ownerHandle, repoName, ref, entries, path = "", withMessage = false }: Props = $props(); const base = $derived(`/${ownerHandle}/${repoName}`); const encodedRef = $derived(encodeURIComponent(ref)); @@ -26,6 +28,11 @@ return `${base}/${kind}/${encodedRef}/${target}`; }; + // lucide puts fill="none" on the path itself, which beats anything inherited + // from the svg, so the class has to reach the path + const iconClassFor = (entry: TreeEntrySummary) => + entry.kind === "directory" ? "size-4 shrink-0 [&_path]:fill-current" : "size-4 shrink-0"; + const iconFor = (entry: TreeEntrySummary) => { switch (entry.kind) { case "directory": @@ -40,21 +47,39 @@ }; -
+ +
{#each entries as entry (entry.name)} {@const Glyph = iconFor(entry)} -
+
- -
+ + {#if withMessage} + + {/if} + +
{#if entry.lastCommitHash && entry.lastCommitWhen} + import { defineMeta } from "@storybook/addon-svelte-csf"; + import LastCommitPanel from "./LastCommitPanel.svelte"; + + const commit = { + hash: "0123456789abcdef0123456789abcdef01234567", + shortHash: "01234567", + subject: "add the repository index", + body: "", + authorName: "dawn", + authorEmail: "dawn@example.test", + when: "2026-07-28T09:00:00Z" + }; + + const { Story } = defineMeta({ + title: "Repo/LastCommitPanel", + component: LastCommitPanel, + tags: ["autodocs"], + args: { ownerHandle: "dawn", repoName: "tangled", commit } + }); + + + + diff --git a/web/src/lib/components/repo/LastCommitPanel.svelte b/web/src/lib/components/repo/LastCommitPanel.svelte new file mode 100644 index 00000000..f6eafc15 --- /dev/null +++ b/web/src/lib/components/repo/LastCommitPanel.svelte @@ -0,0 +1,40 @@ + + + + diff --git a/web/src/lib/components/repo/RepoToolbar.stories.svelte b/web/src/lib/components/repo/RepoToolbar.stories.svelte new file mode 100644 index 00000000..d8e46d2f --- /dev/null +++ b/web/src/lib/components/repo/RepoToolbar.stories.svelte @@ -0,0 +1,38 @@ + + + + + diff --git a/web/src/lib/components/repo/RepoToolbar.svelte b/web/src/lib/components/repo/RepoToolbar.svelte new file mode 100644 index 00000000..2669dbd6 --- /dev/null +++ b/web/src/lib/components/repo/RepoToolbar.svelte @@ -0,0 +1,80 @@ + + + diff --git a/web/src/lib/components/repo/TreeHeader.stories.svelte b/web/src/lib/components/repo/TreeHeader.stories.svelte new file mode 100644 index 00000000..2bfd6042 --- /dev/null +++ b/web/src/lib/components/repo/TreeHeader.stories.svelte @@ -0,0 +1,27 @@ + + + + + diff --git a/web/src/lib/components/repo/TreeHeader.svelte b/web/src/lib/components/repo/TreeHeader.svelte new file mode 100644 index 00000000..8e7df3ee --- /dev/null +++ b/web/src/lib/components/repo/TreeHeader.svelte @@ -0,0 +1,76 @@ + + +
+
+
+ {#each crumbs as crumb (crumb.href)} + {#if crumb.last} + {crumb.name} + {:else} + {crumb.name} + + {/if} + {/each} +
+ +
+ {#if folders > 0} + {plural(folders, "folder")} + {/if} + {#if folders > 0 && files > 0} + + {/if} + {#if files > 0} + {plural(files, "file")} + {/if} +
+
+ + +
diff --git a/web/src/routes/[handle]/[repo]/+page.svelte b/web/src/routes/[handle]/[repo]/+page.svelte index 5af7401c..3415ce39 100644 --- a/web/src/routes/[handle]/[repo]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/+page.svelte @@ -1,163 +1,7 @@ -
- {#if data.knotUnreachable} -
- - -
- {:else if data.isEmpty} - - {:else} - {#if data.languages.length > 0} - - {/if} - - - -
- - - -
- {/if} -
- -{#if data.readme} - -{/if} + diff --git a/web/src/routes/[handle]/[repo]/+page.ts b/web/src/routes/[handle]/[repo]/+page.ts index 4a9fafd3..0be20980 100644 --- a/web/src/routes/[handle]/[repo]/+page.ts +++ b/web/src/routes/[handle]/[repo]/+page.ts @@ -1,108 +1,7 @@ -import { createBobbinClient } from "$lib/api/client"; -import { languages as knotLanguages, tree as knotTree } from "$lib/api/knot"; -import { parallel } from "$lib/api/load"; -import { - branchesFor, - logFor, - sortTreeEntries, - tagsByCommitHash, - tagsFor, - toBranchSummary, - toCommitSummary, - toTagSummary, - toTreeEntrySummary -} from "$lib/api/repo"; -import { renderDocument } from "$lib/markup"; -import type { LanguageSlice } from "$lib/components/repo/types"; +import { loadRepoIndex } from "$lib/api/repoIndex"; import type { PageLoad } from "./$types"; -const COMMIT_LIMIT = 10; -const BRANCH_LIMIT = 5; -const TAG_LIMIT = 5; -// knots cap ref listings at 100, so a total is really an "at least" -const REF_LIMIT = 100; - -const orNull = (promise: Promise): Promise => promise.catch(() => null); - -const toLanguageSlices = (languages: { name: string; size: number }[]): LanguageSlice[] => { - const sized = languages.filter((language) => language.size > 0); - const total = sized.reduce((sum, language) => sum + language.size, 0); - if (total === 0) return []; - - const slices = sized.map((language) => { - const share = (language.size / total) * 100; - return { name: language.name, share, percentage: Math.floor(share) }; - }); - - const short = 100 - slices.reduce((sum, slice) => sum + slice.percentage, 0); - [...slices] - .sort((a, b) => (b.share % 1) - (a.share % 1) || b.share - a.share) - .slice(0, Math.max(0, short)) - .forEach((slice) => { - slice.percentage += 1; - }); - - return slices.sort((a, b) => b.share - a.share); -}; - export const load: PageLoad = async (event) => { const parent = await event.parent(); - const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); - const repo = parent.repo.uri; - const ref = parent.repo.defaultBranch; - - // every list falls back on its own so a partial page still renders - const results = await parallel({ - tree: orNull(knotTree(ctx, { repo, ref })), - log: orNull(logFor(ctx, repo, ref, COMMIT_LIMIT)), - branches: orNull(branchesFor(ctx, repo, REF_LIMIT)), - tags: orNull(tagsFor(ctx, repo, REF_LIMIT)), - languages: orNull(knotLanguages(ctx, { repo, ref })) - }); - - const branches = (results.branches?.branches ?? []).map(toBranchSummary); - const tags = (results.tags?.tags ?? []).map(toTagSummary); - const commits = (results.log?.commits ?? []).map(toCommitSummary); - const files = sortTreeEntries((results.tree?.files ?? []).map(toTreeEntrySummary)); - - const languages = toLanguageSlices(results.languages?.languages ?? []); - - const readme = results.tree?.readme ?? null; - const readmeHtml = readme - ? await renderDocument(readme.filename, readme.contents, { - repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, - ref, - host: event.url.host, - camo: parent.publicConfig.camoEnabled - }) - : null; - - // nothing answered, so the knot is down or doesn't know this repo - const knotUnreachable = - results.tree === null && results.log === null && results.branches === null; - // the knot answered but there is nothing there, so it was never pushed to - const isEmpty = !knotUnreachable && files.length === 0 && branches.length === 0; - - return { - ref, - isEmpty, - knotUnreachable, - files, - readme, - readmeHtml, - commits, - tagsByCommit: tagsByCommitHash(commits, tags), - totalCommits: results.log?.total ?? commits.length, - branches: branches.slice(0, BRANCH_LIMIT), - totalBranches: branches.length, - tags: tags.slice(0, TAG_LIMIT), - totalTags: tags.length, - // the switcher needs every ref, not just the visible slice - refs: { - branches: branches.map((branch) => branch.name), - tags: tags.map((tag) => tag.name), - capped: branches.length >= REF_LIMIT || tags.length >= REF_LIMIT - }, - languages - }; + return loadRepoIndex(event, parent, parent.repo.defaultBranch); }; diff --git a/web/src/routes/[handle]/[repo]/tree/[ref]/+page.svelte b/web/src/routes/[handle]/[repo]/tree/[ref]/+page.svelte new file mode 100644 index 00000000..3415ce39 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/tree/[ref]/+page.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web/src/routes/[handle]/[repo]/tree/[ref]/+page.ts b/web/src/routes/[handle]/[repo]/tree/[ref]/+page.ts new file mode 100644 index 00000000..69fe8d4d --- /dev/null +++ b/web/src/routes/[handle]/[repo]/tree/[ref]/+page.ts @@ -0,0 +1,8 @@ +import { loadRepoIndex } from "$lib/api/repoIndex"; +import type { PageLoad } from "./$types"; + +// the ref is a single encoded segment, so `feature/x` arrives here intact +export const load: PageLoad = async (event) => { + const parent = await event.parent(); + return loadRepoIndex(event, parent, event.params.ref, { requireRef: true }); +}; diff --git a/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.svelte b/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.svelte new file mode 100644 index 00000000..89362def --- /dev/null +++ b/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.svelte @@ -0,0 +1,40 @@ + + +
+ + + {#if data.lastCommit} + + {/if} + + + +
+ +{#if data.readme} + +{/if} diff --git a/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.ts b/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.ts new file mode 100644 index 00000000..659f43f2 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.ts @@ -0,0 +1,8 @@ +import { loadRepoTree } from "$lib/api/repoIndex"; +import type { PageLoad } from "./$types"; + +// the rest param arrives decoded and joined, so it is the path as git knows it +export const load: PageLoad = async (event) => { + const parent = await event.parent(); + return loadRepoTree(event, parent, event.params.ref, event.params.path); +};