This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web: add tree pages for refs and folders

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Jul 28, 2026, 9:31 PM +0300) commit c01000a3 parent f4ae616c change-id zltzoonq
+451 -270
+18
web/src/lib/api/repo.ts
··· 106 106 return [message.slice(0, separator).trim(), message.slice(separator + 2).trim()]; 107 107 }; 108 108 109 + /** the subject is the first paragraph, not the first line */ 110 + export const subjectOf = (message: string): string => splitMessage(message)[0]; 111 + 109 112 export const toCommitSummary = (commit: LogCommit): CommitSummary => { 110 113 const [subject, body] = splitMessage(commit.message ?? ""); 111 114 const hash = commit.this ?? ""; ··· 118 121 authorEmail: commit.author?.Email ?? "", 119 122 when: commit.committer?.When ?? commit.author?.When ?? "", 120 123 changeId: commit.change_id 124 + }; 125 + }; 126 + 127 + // the tree endpoint uses lexicon casing where the log ones pass through go field 128 + // names, so this cannot share `toCommitSummary` 129 + export const toTreeCommitSummary = (commit: Tree.LastCommit): CommitSummary => { 130 + const [subject, body] = splitMessage(commit.message ?? ""); 131 + return { 132 + hash: commit.hash, 133 + shortHash: commit.hash.slice(0, 8), 134 + subject, 135 + body, 136 + authorName: commit.author?.name ?? "", 137 + authorEmail: commit.author?.email ?? "", 138 + when: commit.when ?? commit.author?.when ?? "" 121 139 }; 122 140 }; 123 141
+44
web/src/lib/components/repo/FileTree.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import FileTree from "./FileTree.svelte"; 4 + 5 + const entries = [ 6 + { 7 + name: "src", 8 + kind: "directory" as const, 9 + size: 0, 10 + lastCommitHash: "0123456789abcdef", 11 + lastCommitWhen: "2026-07-28T09:00:00Z", 12 + lastCommitMessage: "refactor: move the components" 13 + }, 14 + { 15 + name: "README.md", 16 + kind: "file" as const, 17 + size: 512, 18 + lastCommitHash: "abcdef0123456789", 19 + lastCommitWhen: "2026-07-27T09:00:00Z", 20 + lastCommitMessage: "docs: explain the project" 21 + }, 22 + { name: "LICENSE", kind: "file" as const, size: 1024 }, 23 + { name: "docs", kind: "directory" as const, size: 0 }, 24 + { name: "linked-lib", kind: "symlink" as const, size: 0 }, 25 + { name: "dependency", kind: "submodule" as const, size: 0 } 26 + ]; 27 + 28 + const { Story } = defineMeta({ 29 + title: "Repo/FileTree", 30 + component: FileTree, 31 + tags: ["autodocs"], 32 + args: { 33 + ownerHandle: "dawn", 34 + repoName: "tangled", 35 + ref: "main", 36 + entries 37 + } 38 + }); 39 + </script> 40 + 41 + <Story name="File kinds and timestamps" /> 42 + <Story name="Commit message column" args={{ withMessage: true }} /> 43 + <Story name="Nested path links" args={{ path: "src/lib" }} /> 44 + <Story name="No entries" args={{ entries: [] }} />
+34 -9
web/src/lib/components/repo/FileTree.svelte
··· 4 4 import FileSymlink from "$icon/file-symlink"; 5 5 import Folder from "$icon/folder"; 6 6 import FolderInput from "$icon/folder-input"; 7 + import { subjectOf } from "$lib/api/repo"; 7 8 import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 8 9 import type { TreeEntrySummary } from "./types"; 9 10 ··· 13 14 ref: string; 14 15 entries: TreeEntrySummary[]; 15 16 path?: string; 17 + withMessage?: boolean; 16 18 } 17 19 18 - let { ownerHandle, repoName, ref, entries, path = "" }: Props = $props(); 20 + let { ownerHandle, repoName, ref, entries, path = "", withMessage = false }: Props = $props(); 19 21 20 22 const base = $derived(`/${ownerHandle}/${repoName}`); 21 23 const encodedRef = $derived(encodeURIComponent(ref)); ··· 26 28 return `${base}/${kind}/${encodedRef}/${target}`; 27 29 }; 28 30 31 + // lucide puts fill="none" on the path itself, which beats anything inherited 32 + // from the svg, so the class has to reach the path 33 + const iconClassFor = (entry: TreeEntrySummary) => 34 + entry.kind === "directory" ? "size-4 shrink-0 [&_path]:fill-current" : "size-4 shrink-0"; 35 + 29 36 const iconFor = (entry: TreeEntrySummary) => { 30 37 switch (entry.kind) { 31 38 case "directory": ··· 40 47 }; 41 48 </script> 42 49 43 - <div class="min-w-0 md:border-r md:border-border-default md:pr-2"> 50 + <!-- the divider belongs to whatever column the tree is sat in --> 51 + <div class="min-w-0"> 44 52 {#each entries as entry (entry.name)} 45 53 {@const Glyph = iconFor(entry)} 46 - <div class="grid grid-cols-3 items-center gap-4 py-1"> 54 + <div class={`grid items-center gap-4 py-1 ${withMessage ? "grid-cols-12" : "grid-cols-3"}`}> 47 55 <a 48 56 href={resolve(linkFor(entry) as "/")} 49 - class="col-span-2 flex min-w-0 items-center gap-2 text-foreground-default no-underline hover:underline" 57 + class={`flex min-w-0 items-center gap-2 text-foreground-default no-underline hover:underline ${ 58 + withMessage ? "col-span-8 md:col-span-4" : "col-span-2" 59 + }`} 50 60 > 51 - <Glyph 52 - class={`size-4 shrink-0 ${entry.kind === "directory" ? "fill-current" : ""}`} 53 - aria-hidden="true" 54 - /> 61 + <Glyph class={iconClassFor(entry)} aria-hidden="true" /> 55 62 <span class="truncate">{entry.name}</span> 56 63 </a> 57 - <div class="col-span-1 text-right text-sm text-foreground-subtle"> 64 + 65 + {#if withMessage} 66 + <div class="hidden min-w-0 md:col-span-6 md:block"> 67 + {#if entry.lastCommitHash && entry.lastCommitMessage} 68 + <a 69 + href={resolve(`${base}/commit/${entry.lastCommitHash}` as "/")} 70 + class="block truncate text-foreground-subtle no-underline hover:underline" 71 + > 72 + {subjectOf(entry.lastCommitMessage)} 73 + </a> 74 + {/if} 75 + </div> 76 + {/if} 77 + 78 + <div 79 + class={`text-right text-sm text-foreground-subtle ${ 80 + withMessage ? "col-span-4 md:col-span-2" : "col-span-1" 81 + }`} 82 + > 58 83 {#if entry.lastCommitHash && entry.lastCommitWhen} 59 84 <a 60 85 href={resolve(`${base}/commit/${entry.lastCommitHash}` as "/")}
+27
web/src/lib/components/repo/LastCommitPanel.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import LastCommitPanel from "./LastCommitPanel.svelte"; 4 + 5 + const commit = { 6 + hash: "0123456789abcdef0123456789abcdef01234567", 7 + shortHash: "01234567", 8 + subject: "add the repository index", 9 + body: "", 10 + authorName: "dawn", 11 + authorEmail: "dawn@example.test", 12 + when: "2026-07-28T09:00:00Z" 13 + }; 14 + 15 + const { Story } = defineMeta({ 16 + title: "Repo/LastCommitPanel", 17 + component: LastCommitPanel, 18 + tags: ["autodocs"], 19 + args: { ownerHandle: "dawn", repoName: "tangled", commit } 20 + }); 21 + </script> 22 + 23 + <Story name="Author, subject, time, and hash" /> 24 + <Story 25 + name="Minimal commit metadata" 26 + args={{ commit: { ...commit, authorName: "", when: "", subject: "update files" } }} 27 + />
+40
web/src/lib/components/repo/LastCommitPanel.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 4 + import type { CommitSummary } from "./types"; 5 + 6 + interface Props { 7 + ownerHandle: string; 8 + repoName: string; 9 + commit: CommitSummary; 10 + } 11 + 12 + let { ownerHandle, repoName, commit }: Props = $props(); 13 + 14 + const href = $derived(resolve(`/${ownerHandle}/${repoName}/commit/${commit.hash}` as "/")); 15 + </script> 16 + 17 + <!-- narrow screens get the per-entry times instead, there is no room for both --> 18 + <div 19 + class="mb-3 hidden flex-wrap items-center justify-between gap-2 border-b border-border-default pb-2 text-sm md:flex" 20 + > 21 + <div class="flex min-w-0 flex-wrap items-center gap-2"> 22 + {#if commit.authorName} 23 + <span class="truncate text-foreground-muted">{commit.authorName}</span> 24 + <span class="text-foreground-subtle" aria-hidden="true">&middot;</span> 25 + {/if} 26 + <a {href} class="min-w-0 truncate text-foreground-default no-underline hover:underline"> 27 + {commit.subject} 28 + </a> 29 + {#if commit.when} 30 + <span class="text-foreground-subtle" aria-hidden="true">&middot;</span> 31 + <span class="text-foreground-subtle"><TimeAgo value={commit.when} /></span> 32 + {/if} 33 + </div> 34 + <a 35 + {href} 36 + class="w-fit rounded bg-background-inset px-2 py-1 font-mono text-xs text-foreground-muted no-underline hover:underline" 37 + > 38 + {commit.shortHash} 39 + </a> 40 + </div>
+38
web/src/lib/components/repo/RepoToolbar.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import RepoToolbar from "./RepoToolbar.svelte"; 4 + import type { RepoInfo } from "./types"; 5 + 6 + const repo: RepoInfo = { 7 + uri: "at://did:plc:repo/sh.tangled.repo.repo/3jzrepo", 8 + rkey: "3jzrepo", 9 + name: "tangled", 10 + ownerDid: "did:plc:owner", 11 + ownerHandle: "dawn", 12 + repoDid: "did:plc:repo", 13 + knot: "knot1.tangled.sh", 14 + defaultBranch: "main" 15 + }; 16 + 17 + const { Story } = defineMeta({ 18 + title: "Repo/RepoToolbar", 19 + component: RepoToolbar, 20 + tags: ["autodocs"], 21 + args: { 22 + repo, 23 + ref: "main", 24 + refs: { branches: ["main", "feature/storybook"], tags: ["v1.0.0"] }, 25 + totalCommits: 42, 26 + totalBranches: 2, 27 + totalTags: 1, 28 + bobbinUrl: "https://bobbin.example.test" 29 + } 30 + }); 31 + </script> 32 + 33 + <Story name="Branch and tag refs" /> 34 + <Story name="Detached commit ref" args={{ ref: "0123456789abcdef", totalCommits: 1 }} /> 35 + <Story 36 + name="Repository has no tags" 37 + args={{ refs: { branches: ["main"], tags: [] }, totalTags: 0 }} 38 + />
+80
web/src/lib/components/repo/RepoToolbar.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import GitBranch from "$icon/git-branch"; 4 + import GitCommitHorizontal from "$icon/git-commit-horizontal"; 5 + import SearchCode from "$icon/search-code"; 6 + import Tags from "$icon/tags"; 7 + import CloneDropdown from "./CloneDropdown.svelte"; 8 + import RefSelector from "./RefSelector.svelte"; 9 + import type { RepoInfo } from "./types"; 10 + 11 + interface Props { 12 + repo: RepoInfo; 13 + ref: string; 14 + refs: { branches: string[]; tags: string[] }; 15 + totalCommits: number; 16 + totalBranches: number; 17 + totalTags: number; 18 + bobbinUrl: string; 19 + } 20 + 21 + let { repo, ref, refs, totalCommits, totalBranches, totalTags, bobbinUrl }: Props = $props(); 22 + 23 + const base = $derived(`/${repo.ownerHandle}/${repo.name}`); 24 + const encodedRef = $derived(encodeURIComponent(ref)); 25 + </script> 26 + 27 + <div class="flex flex-wrap items-center justify-between gap-3 pb-5"> 28 + <RefSelector 29 + ownerHandle={repo.ownerHandle} 30 + repoName={repo.name} 31 + current={ref} 32 + branches={refs.branches} 33 + tags={refs.tags} 34 + /> 35 + 36 + <form 37 + class="order-last flex h-8 w-full items-center md:order-none md:w-64" 38 + method="GET" 39 + action={resolve(`${base}/search` as "/")} 40 + > 41 + <div class="relative flex h-full w-full items-center"> 42 + <SearchCode 43 + class="pointer-events-none absolute left-2 size-4 text-foreground-placeholder" 44 + aria-hidden="true" 45 + /> 46 + <input 47 + class="h-full w-full rounded border border-border-default bg-background-default py-1 pr-2 pl-8 text-sm outline-none focus:border-border-strong" 48 + type="text" 49 + name="q" 50 + placeholder="Find files or code..." 51 + aria-label="Search this repository" 52 + /> 53 + </div> 54 + </form> 55 + 56 + <div class="flex items-center gap-3"> 57 + <a 58 + href={resolve(`${base}/commits/${encodedRef}` as "/")} 59 + class="inline-flex items-center gap-1 text-sm font-medium text-foreground-default no-underline hover:underline md:hidden" 60 + > 61 + <GitCommitHorizontal class="size-4" aria-hidden="true" /> 62 + {totalCommits} 63 + </a> 64 + <a 65 + href={resolve(`${base}/branches` as "/")} 66 + class="inline-flex items-center gap-1 text-sm font-medium text-foreground-default no-underline hover:underline md:hidden" 67 + > 68 + <GitBranch class="size-4" aria-hidden="true" /> 69 + {totalBranches} 70 + </a> 71 + <a 72 + href={resolve(`${base}/tags` as "/")} 73 + class="inline-flex items-center gap-1 text-sm font-medium text-foreground-default no-underline hover:underline md:hidden" 74 + > 75 + <Tags class="size-4" aria-hidden="true" /> 76 + {totalTags} 77 + </a> 78 + <CloneDropdown {repo} {ref} {bobbinUrl} /> 79 + </div> 80 + </div>
+27
web/src/lib/components/repo/TreeHeader.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import TreeHeader from "./TreeHeader.svelte"; 4 + 5 + const entries = [ 6 + { name: "components", kind: "directory" as const, size: 0 }, 7 + { name: "index.ts", kind: "file" as const, size: 128 }, 8 + { name: "package.json", kind: "file" as const, size: 512 } 9 + ]; 10 + 11 + const { Story } = defineMeta({ 12 + title: "Repo/TreeHeader", 13 + component: TreeHeader, 14 + tags: ["autodocs"], 15 + args: { 16 + ownerHandle: "dawn", 17 + repoName: "tangled", 18 + ref: "main", 19 + path: "src/lib", 20 + entries 21 + } 22 + }); 23 + </script> 24 + 25 + <Story name="Nested path with folder and file counts" /> 26 + <Story name="Repository root" args={{ path: "", entries: entries.slice(0, 2) }} /> 27 + <Story name="Files only" args={{ entries: entries.slice(1) }} />
+76
web/src/lib/components/repo/TreeHeader.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import type { TreeEntrySummary } from "./types"; 4 + 5 + interface Props { 6 + ownerHandle: string; 7 + repoName: string; 8 + ref: string; 9 + path: string; 10 + entries: TreeEntrySummary[]; 11 + } 12 + 13 + let { ownerHandle, repoName, ref, path, entries }: Props = $props(); 14 + 15 + const base = $derived(`/${ownerHandle}/${repoName}`); 16 + const encodedRef = $derived(encodeURIComponent(ref)); 17 + 18 + const crumbs = $derived.by(() => { 19 + const parts = path.split("/").filter(Boolean); 20 + return [ 21 + { name: repoName, href: `${base}/tree/${encodedRef}`, last: parts.length === 0 }, 22 + ...parts.map((name, index) => ({ 23 + name, 24 + href: `${base}/tree/${encodedRef}/${parts.slice(0, index + 1).join("/")}`, 25 + last: index === parts.length - 1 26 + })) 27 + ]; 28 + }); 29 + 30 + const folders = $derived( 31 + entries.filter((entry) => entry.kind === "directory" || entry.kind === "submodule").length 32 + ); 33 + const files = $derived(entries.length - folders); 34 + const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}`; 35 + </script> 36 + 37 + <div class="mb-3 flex items-center justify-between gap-2 text-sm"> 38 + <div class="flex min-w-0 items-center gap-3"> 39 + <div 40 + class="flex w-fit items-center gap-1 overflow-x-auto rounded border border-border-default bg-background-inset p-2 whitespace-nowrap" 41 + > 42 + {#each crumbs as crumb (crumb.href)} 43 + {#if crumb.last} 44 + <span class="text-foreground-default" aria-current="page">{crumb.name}</span> 45 + {:else} 46 + <a 47 + href={resolve(crumb.href as "/")} 48 + class="text-foreground-muted no-underline hover:underline">{crumb.name}</a 49 + > 50 + <span class="text-foreground-muted" aria-hidden="true">/</span> 51 + {/if} 52 + {/each} 53 + </div> 54 + 55 + <div class="flex items-center gap-2 text-foreground-muted"> 56 + {#if folders > 0} 57 + <span>{plural(folders, "folder")}</span> 58 + {/if} 59 + {#if folders > 0 && files > 0} 60 + <span aria-hidden="true">&middot;</span> 61 + {/if} 62 + {#if files > 0} 63 + <span>{plural(files, "file")}</span> 64 + {/if} 65 + </div> 66 + </div> 67 + 68 + <div 69 + class="hidden w-fit items-center gap-1 rounded border border-border-default bg-background-inset p-2 whitespace-nowrap text-foreground-muted md:flex" 70 + > 71 + at <a 72 + href={resolve(`${base}/tree/${encodedRef}` as "/")} 73 + class="text-foreground-default no-underline hover:underline">{ref}</a 74 + > 75 + </div> 76 + </div>
+2 -158
web/src/routes/[handle]/[repo]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from "$app/paths"; 3 - import GitBranch from "$icon/git-branch"; 4 - import GitCommitHorizontal from "$icon/git-commit-horizontal"; 5 - import Logs from "$icon/logs"; 6 - import SearchCode from "$icon/search-code"; 7 - import Tags from "$icon/tags"; 8 - import TriangleAlert from "$icon/triangle-alert"; 9 - import BranchList from "$lib/components/repo/BranchList.svelte"; 10 - import CloneDropdown from "$lib/components/repo/CloneDropdown.svelte"; 11 - import CommitList from "$lib/components/repo/CommitList.svelte"; 12 - import EmptyRepo from "$lib/components/repo/EmptyRepo.svelte"; 13 - import FileTree from "$lib/components/repo/FileTree.svelte"; 14 - import LanguageBar from "$lib/components/repo/LanguageBar.svelte"; 15 - import PanelHeader from "$lib/components/repo/PanelHeader.svelte"; 16 - import Readme from "$lib/components/repo/Readme.svelte"; 17 - import RefSelector from "$lib/components/repo/RefSelector.svelte"; 18 - import TagList from "$lib/components/repo/TagList.svelte"; 2 + import RepoIndexView from "$lib/components/repo/RepoIndexView.svelte"; 19 3 20 4 let { data } = $props(); 21 - 22 - const repo = $derived(data.repo); 23 - const base = $derived(`/${repo.ownerHandle}/${repo.name}`); 24 - const encodedRef = $derived(encodeURIComponent(data.ref)); 25 - const refsCapped = $derived(data.refs.capped); 26 5 </script> 27 6 28 - <section 29 - class="relative mx-auto w-full rounded bg-background-default px-6 py-4 text-foreground-default" 30 - > 31 - {#if data.knotUnreachable} 32 - <div class="flex h-96 items-center justify-center text-center text-foreground-danger"> 33 - <span class="flex items-center gap-2"> 34 - <TriangleAlert class="size-5 shrink-0" aria-hidden="true" /> 35 - The knot hosting this repository is unreachable. 36 - </span> 37 - </div> 38 - {:else if data.isEmpty} 39 - <EmptyRepo {repo} /> 40 - {:else} 41 - {#if data.languages.length > 0} 42 - <LanguageBar languages={data.languages} /> 43 - {/if} 44 - 45 - <div class="flex flex-wrap items-center justify-between gap-3 pb-5"> 46 - <RefSelector 47 - ownerHandle={repo.ownerHandle} 48 - repoName={repo.name} 49 - current={data.ref} 50 - branches={data.refs.branches} 51 - tags={data.refs.tags} 52 - /> 53 - 54 - <form 55 - class="order-last flex h-8 w-full items-center md:order-none md:w-64" 56 - method="GET" 57 - action={resolve(`${base}/search` as "/")} 58 - > 59 - <div class="relative flex h-full w-full items-center"> 60 - <SearchCode 61 - class="pointer-events-none absolute left-2 size-4 text-foreground-placeholder" 62 - aria-hidden="true" 63 - /> 64 - <input 65 - class="h-full w-full rounded border border-border-default bg-background-default py-1 pr-2 pl-8 text-sm outline-none focus:border-border-strong" 66 - type="text" 67 - name="q" 68 - placeholder="Find files or code..." 69 - aria-label="Search this repository" 70 - /> 71 - </div> 72 - </form> 73 - 74 - <div class="flex items-center gap-3"> 75 - <a 76 - href={resolve(`${base}/commits/${encodedRef}` as "/")} 77 - class="inline-flex items-center gap-1 text-sm font-medium text-foreground-default no-underline hover:underline md:hidden" 78 - > 79 - <GitCommitHorizontal class="size-4" aria-hidden="true" /> 80 - {data.totalCommits} 81 - </a> 82 - <a 83 - href={resolve(`${base}/branches` as "/")} 84 - class="inline-flex items-center gap-1 text-sm font-medium text-foreground-default no-underline hover:underline md:hidden" 85 - > 86 - <GitBranch class="size-4" aria-hidden="true" /> 87 - {data.totalBranches} 88 - </a> 89 - <a 90 - href={resolve(`${base}/tags` as "/")} 91 - class="inline-flex items-center gap-1 text-sm font-medium text-foreground-default no-underline hover:underline md:hidden" 92 - > 93 - <Tags class="size-4" aria-hidden="true" /> 94 - {data.totalTags} 95 - </a> 96 - <CloneDropdown {repo} ref={data.ref} bobbinUrl={data.publicConfig.bobbinUrl} /> 97 - </div> 98 - </div> 99 - 100 - <div class="grid grid-cols-1 gap-2 md:grid-cols-2"> 101 - <FileTree 102 - ownerHandle={repo.ownerHandle} 103 - repoName={repo.name} 104 - ref={data.ref} 105 - entries={data.files} 106 - /> 107 - 108 - <div class="hidden md:block"> 109 - {#if data.commits.length > 0} 110 - <div class="px-2 pb-4"> 111 - <PanelHeader 112 - title="Commits" 113 - icon={Logs} 114 - href={`${base}/commits/${encodedRef}`} 115 - count={data.totalCommits} 116 - /> 117 - <CommitList 118 - ownerHandle={repo.ownerHandle} 119 - repoName={repo.name} 120 - commits={data.commits} 121 - tagsByCommit={data.tagsByCommit} 122 - /> 123 - </div> 124 - {/if} 125 - 126 - {#if data.branches.length > 0} 127 - <div class="border-t border-border-default px-2 py-4"> 128 - <PanelHeader 129 - title="Branches" 130 - icon={GitBranch} 131 - href={`${base}/branches`} 132 - count={data.totalBranches} 133 - approximate={refsCapped} 134 - /> 135 - <BranchList 136 - ownerHandle={repo.ownerHandle} 137 - repoName={repo.name} 138 - currentRef={data.ref} 139 - branches={data.branches} 140 - /> 141 - </div> 142 - {/if} 143 - 144 - {#if data.tags.length > 0} 145 - <div class="border-t border-border-default px-2 py-4"> 146 - <PanelHeader 147 - title="Tags" 148 - icon={Tags} 149 - href={`${base}/tags`} 150 - count={data.totalTags} 151 - approximate={refsCapped} 152 - /> 153 - <TagList ownerHandle={repo.ownerHandle} repoName={repo.name} tags={data.tags} /> 154 - </div> 155 - {/if} 156 - </div> 157 - </div> 158 - {/if} 159 - </section> 160 - 161 - {#if data.readme} 162 - <Readme filename={data.readme.filename} contents={data.readme.contents} html={data.readmeHtml} /> 163 - {/if} 7 + <RepoIndexView repo={data.repo} {data} bobbinUrl={data.publicConfig.bobbinUrl} />
+2 -103
web/src/routes/[handle]/[repo]/+page.ts
··· 1 - import { createBobbinClient } from "$lib/api/client"; 2 - import { languages as knotLanguages, tree as knotTree } from "$lib/api/knot"; 3 - import { parallel } from "$lib/api/load"; 4 - import { 5 - branchesFor, 6 - logFor, 7 - sortTreeEntries, 8 - tagsByCommitHash, 9 - tagsFor, 10 - toBranchSummary, 11 - toCommitSummary, 12 - toTagSummary, 13 - toTreeEntrySummary 14 - } from "$lib/api/repo"; 15 - import { renderDocument } from "$lib/markup"; 16 - import type { LanguageSlice } from "$lib/components/repo/types"; 1 + import { loadRepoIndex } from "$lib/api/repoIndex"; 17 2 import type { PageLoad } from "./$types"; 18 3 19 - const COMMIT_LIMIT = 10; 20 - const BRANCH_LIMIT = 5; 21 - const TAG_LIMIT = 5; 22 - // knots cap ref listings at 100, so a total is really an "at least" 23 - const REF_LIMIT = 100; 24 - 25 - const orNull = <T>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null); 26 - 27 - const toLanguageSlices = (languages: { name: string; size: number }[]): LanguageSlice[] => { 28 - const sized = languages.filter((language) => language.size > 0); 29 - const total = sized.reduce((sum, language) => sum + language.size, 0); 30 - if (total === 0) return []; 31 - 32 - const slices = sized.map((language) => { 33 - const share = (language.size / total) * 100; 34 - return { name: language.name, share, percentage: Math.floor(share) }; 35 - }); 36 - 37 - const short = 100 - slices.reduce((sum, slice) => sum + slice.percentage, 0); 38 - [...slices] 39 - .sort((a, b) => (b.share % 1) - (a.share % 1) || b.share - a.share) 40 - .slice(0, Math.max(0, short)) 41 - .forEach((slice) => { 42 - slice.percentage += 1; 43 - }); 44 - 45 - return slices.sort((a, b) => b.share - a.share); 46 - }; 47 - 48 4 export const load: PageLoad = async (event) => { 49 5 const parent = await event.parent(); 50 - const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 51 - const repo = parent.repo.uri; 52 - const ref = parent.repo.defaultBranch; 53 - 54 - // every list falls back on its own so a partial page still renders 55 - const results = await parallel({ 56 - tree: orNull(knotTree(ctx, { repo, ref })), 57 - log: orNull(logFor(ctx, repo, ref, COMMIT_LIMIT)), 58 - branches: orNull(branchesFor(ctx, repo, REF_LIMIT)), 59 - tags: orNull(tagsFor(ctx, repo, REF_LIMIT)), 60 - languages: orNull(knotLanguages(ctx, { repo, ref })) 61 - }); 62 - 63 - const branches = (results.branches?.branches ?? []).map(toBranchSummary); 64 - const tags = (results.tags?.tags ?? []).map(toTagSummary); 65 - const commits = (results.log?.commits ?? []).map(toCommitSummary); 66 - const files = sortTreeEntries((results.tree?.files ?? []).map(toTreeEntrySummary)); 67 - 68 - const languages = toLanguageSlices(results.languages?.languages ?? []); 69 - 70 - const readme = results.tree?.readme ?? null; 71 - const readmeHtml = readme 72 - ? await renderDocument(readme.filename, readme.contents, { 73 - repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, 74 - ref, 75 - host: event.url.host, 76 - camo: parent.publicConfig.camoEnabled 77 - }) 78 - : null; 79 - 80 - // nothing answered, so the knot is down or doesn't know this repo 81 - const knotUnreachable = 82 - results.tree === null && results.log === null && results.branches === null; 83 - // the knot answered but there is nothing there, so it was never pushed to 84 - const isEmpty = !knotUnreachable && files.length === 0 && branches.length === 0; 85 - 86 - return { 87 - ref, 88 - isEmpty, 89 - knotUnreachable, 90 - files, 91 - readme, 92 - readmeHtml, 93 - commits, 94 - tagsByCommit: tagsByCommitHash(commits, tags), 95 - totalCommits: results.log?.total ?? commits.length, 96 - branches: branches.slice(0, BRANCH_LIMIT), 97 - totalBranches: branches.length, 98 - tags: tags.slice(0, TAG_LIMIT), 99 - totalTags: tags.length, 100 - // the switcher needs every ref, not just the visible slice 101 - refs: { 102 - branches: branches.map((branch) => branch.name), 103 - tags: tags.map((tag) => tag.name), 104 - capped: branches.length >= REF_LIMIT || tags.length >= REF_LIMIT 105 - }, 106 - languages 107 - }; 6 + return loadRepoIndex(event, parent, parent.repo.defaultBranch); 108 7 };
+7
web/src/routes/[handle]/[repo]/tree/[ref]/+page.svelte
··· 1 + <script lang="ts"> 2 + import RepoIndexView from "$lib/components/repo/RepoIndexView.svelte"; 3 + 4 + let { data } = $props(); 5 + </script> 6 + 7 + <RepoIndexView repo={data.repo} {data} bobbinUrl={data.publicConfig.bobbinUrl} />
+8
web/src/routes/[handle]/[repo]/tree/[ref]/+page.ts
··· 1 + import { loadRepoIndex } from "$lib/api/repoIndex"; 2 + import type { PageLoad } from "./$types"; 3 + 4 + // the ref is a single encoded segment, so `feature/x` arrives here intact 5 + export const load: PageLoad = async (event) => { 6 + const parent = await event.parent(); 7 + return loadRepoIndex(event, parent, event.params.ref, { requireRef: true }); 8 + };
+40
web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.svelte
··· 1 + <script lang="ts"> 2 + import FileTree from "$lib/components/repo/FileTree.svelte"; 3 + import LastCommitPanel from "$lib/components/repo/LastCommitPanel.svelte"; 4 + import Readme from "$lib/components/repo/Readme.svelte"; 5 + import TreeHeader from "$lib/components/repo/TreeHeader.svelte"; 6 + 7 + let { data } = $props(); 8 + 9 + const repo = $derived(data.repo); 10 + </script> 11 + 12 + <section 13 + class="relative mx-auto w-full rounded bg-background-default px-6 py-4 text-foreground-default" 14 + > 15 + <TreeHeader 16 + ownerHandle={repo.ownerHandle} 17 + repoName={repo.name} 18 + ref={data.ref} 19 + path={data.path} 20 + entries={data.files} 21 + /> 22 + 23 + {#if data.lastCommit} 24 + <LastCommitPanel ownerHandle={repo.ownerHandle} repoName={repo.name} commit={data.lastCommit} /> 25 + {/if} 26 + 27 + <!-- one column, the commit and ref panels describe the repo not a folder --> 28 + <FileTree 29 + ownerHandle={repo.ownerHandle} 30 + repoName={repo.name} 31 + ref={data.ref} 32 + entries={data.files} 33 + path={data.path} 34 + withMessage 35 + /> 36 + </section> 37 + 38 + {#if data.readme} 39 + <Readme filename={data.readme.filename} contents={data.readme.contents} html={data.readmeHtml} /> 40 + {/if}
+8
web/src/routes/[handle]/[repo]/tree/[ref]/[...path]/+page.ts
··· 1 + import { loadRepoTree } from "$lib/api/repoIndex"; 2 + import type { PageLoad } from "./$types"; 3 + 4 + // the rest param arrives decoded and joined, so it is the path as git knows it 5 + export const load: PageLoad = async (event) => { 6 + const parent = await event.parent(); 7 + return loadRepoTree(event, parent, event.params.ref, event.params.path); 8 + };