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 25, 2026, 9:13 PM +0300) commit 79d1c35f parent e9987c69 change-id stupkrzk
+617 -270
+18
web/src/lib/api/repo.ts
··· 107 107 return [message.slice(0, separator).trim(), message.slice(separator + 2).trim()]; 108 108 }; 109 109 110 + /** the subject is the first paragraph, not the first line */ 111 + export const subjectOf = (message: string): string => splitMessage(message)[0]; 112 + 110 113 export const toCommitSummary = (commit: LogCommit): CommitSummary => { 111 114 const [subject, body] = splitMessage(commit.message ?? ""); 112 115 const hash = commit.this ?? ""; ··· 119 122 authorEmail: commit.author?.Email ?? "", 120 123 when: commit.committer?.When ?? commit.author?.When ?? "", 121 124 changeId: commit.change_id 125 + }; 126 + }; 127 + 128 + // the tree endpoint uses lexicon casing where the log ones pass through go field 129 + // names, so this cannot share `toCommitSummary` 130 + export const toTreeCommitSummary = (commit: Tree.LastCommit): CommitSummary => { 131 + const [subject, body] = splitMessage(commit.message ?? ""); 132 + return { 133 + hash: commit.hash, 134 + shortHash: commit.hash.slice(0, 8), 135 + subject, 136 + body, 137 + authorName: commit.author?.name ?? "", 138 + authorEmail: commit.author?.email ?? "", 139 + when: commit.when ?? commit.author?.when ?? "" 122 140 }; 123 141 }; 124 142
+179
web/src/lib/api/repoIndex.ts
··· 1 + import { error } from "@sveltejs/kit"; 2 + import { createBobbinClient } from "$lib/api/client"; 3 + import { languages as knotLanguages, tree as knotTree } from "$lib/api/knot"; 4 + import { parallel } from "$lib/api/load"; 5 + import { 6 + branchesFor, 7 + logFor, 8 + sortTreeEntries, 9 + tagsByCommitHash, 10 + tagsFor, 11 + toBranchSummary, 12 + toCommitSummary, 13 + toTagSummary, 14 + toTreeCommitSummary, 15 + toTreeEntrySummary 16 + } from "$lib/api/repo"; 17 + import { renderDocument } from "$lib/markup"; 18 + import type { LanguageSlice, RepoInfo } from "$lib/components/repo/types"; 19 + 20 + // `/tree/{ref}` is this same page at another ref, so they share a load 21 + 22 + const COMMIT_LIMIT = 10; 23 + const BRANCH_LIMIT = 5; 24 + const TAG_LIMIT = 5; 25 + // a knot only ever lists 100 refs, so any total we get is really a minimum 26 + const REF_LIMIT = 100; 27 + 28 + export interface RepoParent { 29 + publicConfig: { bobbinUrl: string; camoEnabled: boolean }; 30 + repo: RepoInfo; 31 + } 32 + 33 + export interface RepoLoadEvent { 34 + fetch: typeof globalThis.fetch; 35 + url: URL; 36 + } 37 + 38 + const orNull = <T>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null); 39 + 40 + const toLanguageSlices = (languages: { name: string; size: number }[]): LanguageSlice[] => { 41 + const sized = languages.filter((language) => language.size > 0); 42 + const total = sized.reduce((sum, language) => sum + language.size, 0); 43 + if (total === 0) return []; 44 + 45 + const slices = sized.map((language) => { 46 + const share = (language.size / total) * 100; 47 + return { name: language.name, share, percentage: Math.floor(share) }; 48 + }); 49 + 50 + const short = 100 - slices.reduce((sum, slice) => sum + slice.percentage, 0); 51 + [...slices] 52 + .sort((a, b) => (b.share % 1) - (a.share % 1) || b.share - a.share) 53 + .slice(0, Math.max(0, short)) 54 + .forEach((slice) => { 55 + slice.percentage += 1; 56 + }); 57 + 58 + return slices.sort((a, b) => b.share - a.share); 59 + }; 60 + 61 + const refNames = (branches: { name: string }[], tags: { name: string }[]) => ({ 62 + branches: branches.map((branch) => branch.name), 63 + tags: tags.map((tag) => tag.name), 64 + capped: branches.length >= REF_LIMIT || tags.length >= REF_LIMIT 65 + }); 66 + 67 + const renderReadme = ( 68 + readme: { filename: string; contents: string } | null, 69 + parent: RepoParent, 70 + event: RepoLoadEvent, 71 + ref: string, 72 + dir?: string 73 + ) => 74 + readme 75 + ? renderDocument(readme.filename, readme.contents, { 76 + repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, 77 + ref, 78 + dir, 79 + host: event.url.host, 80 + camo: parent.publicConfig.camoEnabled 81 + }) 82 + : Promise.resolve(null); 83 + 84 + // the knot sends a readme with empty fields when a directory has none 85 + const readmeOf = (tree: { readme?: { filename: string; contents: string } } | null) => { 86 + const readme = tree?.readme; 87 + return readme?.filename ? readme : null; 88 + }; 89 + 90 + export const loadRepoIndex = async ( 91 + event: RepoLoadEvent, 92 + parent: RepoParent, 93 + ref: string, 94 + // a ref from the url has to resolve or a typo looks like a repo with no 95 + // files. the default branch renders whatever the knot managed to answer 96 + { requireRef = false }: { requireRef?: boolean } = {} 97 + ) => { 98 + const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 99 + const repo = parent.repo.uri; 100 + 101 + // each list falls back on its own, so half a page still renders 102 + const results = await parallel({ 103 + tree: orNull(knotTree(ctx, { repo, ref })), 104 + log: orNull(logFor(ctx, repo, ref, COMMIT_LIMIT)), 105 + branches: orNull(branchesFor(ctx, repo, REF_LIMIT)), 106 + tags: orNull(tagsFor(ctx, repo, REF_LIMIT)), 107 + languages: orNull(knotLanguages(ctx, { repo, ref })) 108 + }); 109 + 110 + const branches = (results.branches?.branches ?? []).map(toBranchSummary); 111 + const tags = (results.tags?.tags ?? []).map(toTagSummary); 112 + const commits = (results.log?.commits ?? []).map(toCommitSummary); 113 + const files = sortTreeEntries((results.tree?.files ?? []).map(toTreeEntrySummary)); 114 + 115 + const languages = toLanguageSlices(results.languages?.languages ?? []); 116 + 117 + const readme = readmeOf(results.tree); 118 + const readmeHtml = await renderReadme(readme, parent, event, ref); 119 + 120 + const knotUnreachable = 121 + results.tree === null && results.log === null && results.branches === null; 122 + const isEmpty = !knotUnreachable && files.length === 0 && branches.length === 0; 123 + 124 + // there are refs but not this one, so it is not a real ref. an empty repo has 125 + // no refs at all and still gets a page 126 + if (requireRef && results.tree === null && branches.length > 0) { 127 + error(404, `${ref} does not exist in this repository`); 128 + } 129 + 130 + return { 131 + ref, 132 + isEmpty, 133 + knotUnreachable, 134 + files, 135 + readme, 136 + readmeHtml, 137 + commits, 138 + tagsByCommit: tagsByCommitHash(commits, tags), 139 + totalCommits: results.log?.total ?? commits.length, 140 + branches: branches.slice(0, BRANCH_LIMIT), 141 + totalBranches: branches.length, 142 + tags: tags.slice(0, TAG_LIMIT), 143 + totalTags: tags.length, 144 + // the switcher needs every ref, not just the visible slice 145 + refs: refNames(branches, tags), 146 + languages 147 + }; 148 + }; 149 + 150 + export const loadRepoTree = async ( 151 + event: RepoLoadEvent, 152 + parent: RepoParent, 153 + ref: string, 154 + path: string 155 + ) => { 156 + const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 157 + const repo = parent.repo.uri; 158 + 159 + // the tree is the whole page here, so a miss is just a 404 160 + const tree = await orNull(knotTree(ctx, { repo, ref, path })); 161 + const files = sortTreeEntries((tree?.files ?? []).map(toTreeEntrySummary)); 162 + // git cannot store an empty directory. so nothing here means the path is a 163 + // file, or was never there 164 + if (tree === null || files.length === 0) { 165 + error(404, `${path} does not exist at ${ref}`); 166 + } 167 + 168 + const readme = readmeOf(tree); 169 + const readmeHtml = await renderReadme(readme, parent, event, ref, path); 170 + 171 + return { 172 + ref, 173 + path, 174 + files, 175 + readme, 176 + readmeHtml, 177 + lastCommit: tree.lastCommit ? toTreeCommitSummary(tree.lastCommit) : null 178 + }; 179 + };
+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 "/")}
+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>
+123
web/src/lib/components/repo/RepoIndexView.svelte
··· 1 + <script lang="ts"> 2 + import GitBranch from "$icon/git-branch"; 3 + import Logs from "$icon/logs"; 4 + import Tags from "$icon/tags"; 5 + import TriangleAlert from "$icon/triangle-alert"; 6 + import BranchList from "./BranchList.svelte"; 7 + import CommitList from "./CommitList.svelte"; 8 + import EmptyRepo from "./EmptyRepo.svelte"; 9 + import FileTree from "./FileTree.svelte"; 10 + import LanguageBar from "./LanguageBar.svelte"; 11 + import PanelHeader from "./PanelHeader.svelte"; 12 + import Readme from "./Readme.svelte"; 13 + import RepoToolbar from "./RepoToolbar.svelte"; 14 + import TagList from "./TagList.svelte"; 15 + import type { loadRepoIndex } from "$lib/api/repoIndex"; 16 + import type { RepoInfo } from "./types"; 17 + 18 + interface Props { 19 + repo: RepoInfo; 20 + data: Awaited<ReturnType<typeof loadRepoIndex>>; 21 + bobbinUrl: string; 22 + } 23 + 24 + let { repo, data, bobbinUrl }: Props = $props(); 25 + 26 + const base = $derived(`/${repo.ownerHandle}/${repo.name}`); 27 + const encodedRef = $derived(encodeURIComponent(data.ref)); 28 + const refsCapped = $derived(data.refs.capped); 29 + </script> 30 + 31 + <section 32 + class="relative mx-auto w-full rounded bg-background-default px-6 py-4 text-foreground-default" 33 + > 34 + {#if data.knotUnreachable} 35 + <div class="flex h-96 items-center justify-center text-center text-foreground-danger"> 36 + <span class="flex items-center gap-2"> 37 + <TriangleAlert class="size-5 shrink-0" aria-hidden="true" /> 38 + The knot hosting this repository is unreachable. 39 + </span> 40 + </div> 41 + {:else if data.isEmpty} 42 + <EmptyRepo {repo} /> 43 + {:else} 44 + {#if data.languages.length > 0} 45 + <LanguageBar languages={data.languages} /> 46 + {/if} 47 + 48 + <RepoToolbar 49 + {repo} 50 + ref={data.ref} 51 + refs={data.refs} 52 + totalCommits={data.totalCommits} 53 + totalBranches={data.totalBranches} 54 + totalTags={data.totalTags} 55 + {bobbinUrl} 56 + /> 57 + 58 + <div class="grid grid-cols-1 gap-2 md:grid-cols-2"> 59 + <div class="min-w-0 md:border-r md:border-border-default md:pr-2"> 60 + <FileTree 61 + ownerHandle={repo.ownerHandle} 62 + repoName={repo.name} 63 + ref={data.ref} 64 + entries={data.files} 65 + /> 66 + </div> 67 + 68 + <div class="hidden md:block"> 69 + {#if data.commits.length > 0} 70 + <div class="px-2 pb-4"> 71 + <PanelHeader 72 + title="Commits" 73 + icon={Logs} 74 + href={`${base}/commits/${encodedRef}`} 75 + count={data.totalCommits} 76 + /> 77 + <CommitList 78 + ownerHandle={repo.ownerHandle} 79 + repoName={repo.name} 80 + commits={data.commits} 81 + tagsByCommit={data.tagsByCommit} 82 + /> 83 + </div> 84 + {/if} 85 + 86 + {#if data.branches.length > 0} 87 + <div class="border-t border-border-default px-2 py-4"> 88 + <PanelHeader 89 + title="Branches" 90 + icon={GitBranch} 91 + href={`${base}/branches`} 92 + count={data.totalBranches} 93 + approximate={refsCapped} 94 + /> 95 + <BranchList 96 + ownerHandle={repo.ownerHandle} 97 + repoName={repo.name} 98 + currentRef={data.ref} 99 + branches={data.branches} 100 + /> 101 + </div> 102 + {/if} 103 + 104 + {#if data.tags.length > 0} 105 + <div class="border-t border-border-default px-2 py-4"> 106 + <PanelHeader 107 + title="Tags" 108 + icon={Tags} 109 + href={`${base}/tags`} 110 + count={data.totalTags} 111 + approximate={refsCapped} 112 + /> 113 + <TagList ownerHandle={repo.ownerHandle} repoName={repo.name} tags={data.tags} /> 114 + </div> 115 + {/if} 116 + </div> 117 + </div> 118 + {/if} 119 + </section> 120 + 121 + {#if data.readme} 122 + <Readme filename={data.readme.filename} contents={data.readme.contents} html={data.readmeHtml} /> 123 + {/if}
+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>
+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 + };