From 79d1c35f36c12e38b6723170b7fcfb305c51bbc5 Mon Sep 17 00:00:00 2001 From: dawn Date: Sat, 25 Jul 2026 18:16:02 +0300 Subject: [PATCH] web: add tree pages for refs and folders Signed-off-by: dawn --- web/src/lib/api/repo.ts | 18 ++ web/src/lib/api/repoIndex.ts | 179 ++++++++++++++++++ web/src/lib/components/repo/FileTree.svelte | 43 ++++- .../components/repo/LastCommitPanel.svelte | 40 ++++ .../lib/components/repo/RepoIndexView.svelte | 123 ++++++++++++ .../lib/components/repo/RepoToolbar.svelte | 80 ++++++++ web/src/lib/components/repo/TreeHeader.svelte | 76 ++++++++ web/src/routes/[handle]/[repo]/+page.svelte | 160 +--------------- web/src/routes/[handle]/[repo]/+page.ts | 105 +--------- .../[handle]/[repo]/tree/[ref]/+page.svelte | 7 + .../[handle]/[repo]/tree/[ref]/+page.ts | 8 + .../[repo]/tree/[ref]/[...path]/+page.svelte | 40 ++++ .../[repo]/tree/[ref]/[...path]/+page.ts | 8 + 13 files changed, 617 insertions(+), 270 deletions(-) create mode 100644 web/src/lib/api/repoIndex.ts create mode 100644 web/src/lib/components/repo/LastCommitPanel.svelte create mode 100644 web/src/lib/components/repo/RepoIndexView.svelte create mode 100644 web/src/lib/components/repo/RepoToolbar.svelte create mode 100644 web/src/lib/components/repo/TreeHeader.svelte create mode 100644 web/src/routes/[handle]/[repo]/tree/[ref]/+page.svelte create mode 100644 web/src/routes/[handle]/[repo]/tree/[ref]/+page.ts create mode 100644 web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.svelte create mode 100644 web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.ts diff --git a/web/src/lib/api/repo.ts b/web/src/lib/api/repo.ts index 15b3cab4..4a5a375b 100644 --- a/web/src/lib/api/repo.ts +++ b/web/src/lib/api/repo.ts @@ -107,6 +107,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 ?? ""; @@ -122,6 +125,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/api/repoIndex.ts b/web/src/lib/api/repoIndex.ts new file mode 100644 index 00000000..93e861f8 --- /dev/null +++ b/web/src/lib/api/repoIndex.ts @@ -0,0 +1,179 @@ +import { error } from "@sveltejs/kit"; +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, + toTreeCommitSummary, + toTreeEntrySummary +} from "$lib/api/repo"; +import { renderDocument } from "$lib/markup"; +import type { LanguageSlice, RepoInfo } from "$lib/components/repo/types"; + +// `/tree/{ref}` is this same page at another ref, so they share a load + +const COMMIT_LIMIT = 10; +const BRANCH_LIMIT = 5; +const TAG_LIMIT = 5; +// a knot only ever lists 100 refs, so any total we get is really a minimum +const REF_LIMIT = 100; + +export interface RepoParent { + publicConfig: { bobbinUrl: string; camoEnabled: boolean }; + repo: RepoInfo; +} + +export interface RepoLoadEvent { + fetch: typeof globalThis.fetch; + url: URL; +} + +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); +}; + +const refNames = (branches: { name: string }[], tags: { name: string }[]) => ({ + branches: branches.map((branch) => branch.name), + tags: tags.map((tag) => tag.name), + capped: branches.length >= REF_LIMIT || tags.length >= REF_LIMIT +}); + +const renderReadme = ( + readme: { filename: string; contents: string } | null, + parent: RepoParent, + event: RepoLoadEvent, + ref: string, + dir?: string +) => + readme + ? renderDocument(readme.filename, readme.contents, { + repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, + ref, + dir, + host: event.url.host, + camo: parent.publicConfig.camoEnabled + }) + : Promise.resolve(null); + +// the knot sends a readme with empty fields when a directory has none +const readmeOf = (tree: { readme?: { filename: string; contents: string } } | null) => { + const readme = tree?.readme; + return readme?.filename ? readme : null; +}; + +export const loadRepoIndex = async ( + event: RepoLoadEvent, + parent: RepoParent, + ref: string, + // a ref from the url has to resolve or a typo looks like a repo with no + // files. the default branch renders whatever the knot managed to answer + { requireRef = false }: { requireRef?: boolean } = {} +) => { + const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); + const repo = parent.repo.uri; + + // each list falls back on its own, so half a 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 = readmeOf(results.tree); + const readmeHtml = await renderReadme(readme, parent, event, ref); + + const knotUnreachable = + results.tree === null && results.log === null && results.branches === null; + const isEmpty = !knotUnreachable && files.length === 0 && branches.length === 0; + + // there are refs but not this one, so it is not a real ref. an empty repo has + // no refs at all and still gets a page + if (requireRef && results.tree === null && branches.length > 0) { + error(404, `${ref} does not exist in this repository`); + } + + 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: refNames(branches, tags), + languages + }; +}; + +export const loadRepoTree = async ( + event: RepoLoadEvent, + parent: RepoParent, + ref: string, + path: string +) => { + const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); + const repo = parent.repo.uri; + + // the tree is the whole page here, so a miss is just a 404 + const tree = await orNull(knotTree(ctx, { repo, ref, path })); + const files = sortTreeEntries((tree?.files ?? []).map(toTreeEntrySummary)); + // git cannot store an empty directory. so nothing here means the path is a + // file, or was never there + if (tree === null || files.length === 0) { + error(404, `${path} does not exist at ${ref}`); + } + + const readme = readmeOf(tree); + const readmeHtml = await renderReadme(readme, parent, event, ref, path); + + return { + ref, + path, + files, + readme, + readmeHtml, + lastCommit: tree.lastCommit ? toTreeCommitSummary(tree.lastCommit) : null + }; +}; 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 { resolve } from "$app/paths"; + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; + import type { CommitSummary } from "./types"; + + interface Props { + ownerHandle: string; + repoName: string; + commit: CommitSummary; + } + + let { ownerHandle, repoName, commit }: Props = $props(); + + const href = $derived(resolve(`/${ownerHandle}/${repoName}/commit/${commit.hash}` as "/")); + + + + diff --git a/web/src/lib/components/repo/RepoIndexView.svelte b/web/src/lib/components/repo/RepoIndexView.svelte new file mode 100644 index 00000000..e9674388 --- /dev/null +++ b/web/src/lib/components/repo/RepoIndexView.svelte @@ -0,0 +1,123 @@ + + +
+ {#if data.knotUnreachable} +
+ + +
+ {:else if data.isEmpty} + + {:else} + {#if data.languages.length > 0} + + {/if} + + + +
+
+ +
+ + +
+ {/if} +
+ +{#if data.readme} + +{/if} 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.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); +}; -- 2.51.2