This repository has no description
0

Configure Feed

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

web: add the branches and tags pages

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

author
dawn
date (Aug 1, 2026, 2:21 AM +0300) commit e78b3d5f parent 0fa0d4fa change-id zpzkpzpx
+472 -13
+8 -3
web/src/lib/api/repo.test.ts
··· 181 181 it("reads the nested reference and the go-git commit fields", () => { 182 182 const branch: BranchEntry = { 183 183 reference: { name: "master", hash: "ff3a3678" }, 184 - commit: { Committer: { Name: "Ada", Email: "a@b.c", When: "2026-07-02T10:00:00Z" } }, 184 + commit: { 185 + Committer: { Name: "Ada", Email: "a@b.c", When: "2026-07-02T10:00:00Z" }, 186 + Message: "the tip commit" 187 + }, 185 188 is_default: true 186 189 }; 187 190 expect(toBranchSummary(branch)).toEqual({ 188 191 name: "master", 189 192 hash: "ff3a3678", 190 193 when: "2026-07-02T10:00:00Z", 191 - isDefault: true 194 + isDefault: true, 195 + message: "the tip commit" 192 196 }); 193 197 }); 194 198 ··· 214 218 hash: "63fa1d4b", 215 219 commitHash: "4b4efe25", 216 220 when: "2026-07-01T10:00:00Z", 217 - message: "release" 221 + message: "release", 222 + taggerName: "Ada" 218 223 }); 219 224 }); 220 225
+8 -2
web/src/lib/api/repo.ts
··· 224 224 hash: string; 225 225 when?: string; 226 226 isDefault: boolean; 227 + // full commit message 228 + message?: string; 227 229 } 228 230 229 231 export const toBranchSummary = (branch: BranchEntry): BranchSummary => ({ 230 232 name: branch.reference.name, 231 233 hash: branch.reference.hash, 232 234 when: branch.commit?.Committer?.When ?? branch.commit?.Author?.When, 233 - isDefault: branch.is_default === true 235 + isDefault: branch.is_default === true, 236 + message: branch.commit?.Message 234 237 }); 235 238 236 239 export interface TagSummary { ··· 240 243 commitHash: string; 241 244 when?: string; 242 245 message?: string; 246 + // annotated tags carry their tagger 247 + taggerName?: string; 243 248 } 244 249 245 250 export const hexFromBytes = (bytes: number[]): string => ··· 252 257 hash: tag.hash, 253 258 commitHash: target?.length ? hexFromBytes(target) : tag.hash, 254 259 when: tag.tag?.Tagger?.When, 255 - message: tag.message ?? tag.tag?.Message 260 + message: tag.message ?? tag.tag?.Message, 261 + taggerName: tag.tag?.Tagger?.Name 256 262 }; 257 263 }; 258 264
+53
web/src/lib/components/repo/BranchTable.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 + import { expect } from "storybook/test"; 4 + import BranchTable from "./BranchTable.svelte"; 5 + import type { BranchSummary } from "./types"; 6 + 7 + const branches: BranchSummary[] = [ 8 + { 9 + name: "main", 10 + hash: "0123456789abcdef0123456789abcdef01234567", 11 + when: "2026-07-28T09:00:00Z", 12 + isDefault: true, 13 + message: "merge pull request #42\n\na longer body nobody reads" 14 + }, 15 + { 16 + name: "feature/storybook", 17 + hash: "abcdef0123456789abcdef0123456789abcdef01", 18 + when: "2026-07-27T09:00:00Z", 19 + isDefault: false, 20 + message: "add stories for everything" 21 + } 22 + ]; 23 + 24 + type PlayContext = Pick<StoryContext<Record<string, unknown>>, "canvas">; 25 + 26 + const rowsLinkOut = async ({ canvas }: PlayContext) => { 27 + const treeLinks = canvas.getAllByRole("link", { name: "main" }); 28 + await expect(treeLinks[0]).toHaveAttribute("href", "/dawn/tangled/tree/main"); 29 + const hashLinks = canvas.getAllByRole("link", { name: "01234567" }); 30 + await expect(hashLinks[0]).toHaveAttribute("href", "/dawn/tangled/commits/main"); 31 + await expect(canvas.getByText("merge pull request #42")).toBeInTheDocument(); 32 + await expect(canvas.queryByText(/a longer body/)).toBeNull(); 33 + await expect(canvas.getAllByText("Default")).toHaveLength(2); 34 + }; 35 + 36 + const empty = async ({ canvas }: PlayContext) => { 37 + await expect(canvas.getByText("This repository has no branches.")).toBeVisible(); 38 + }; 39 + 40 + const { Story } = defineMeta({ 41 + title: "Repo/BranchTable", 42 + component: BranchTable, 43 + tags: ["autodocs"], 44 + args: { 45 + ownerHandle: "dawn", 46 + repoName: "tangled", 47 + branches 48 + } 49 + }); 50 + </script> 51 + 52 + <Story name="Branches" play={rowsLinkOut} /> 53 + <Story name="Empty" args={{ branches: [] }} play={empty} />
+107
web/src/lib/components/repo/BranchTable.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import Tag from "$lib/components/ui/Tag.svelte"; 4 + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 5 + import type { BranchSummary } from "./types"; 6 + 7 + interface Props { 8 + ownerHandle: string; 9 + repoName: string; 10 + branches: BranchSummary[]; 11 + } 12 + 13 + let { ownerHandle, repoName, branches }: Props = $props(); 14 + 15 + const base = $derived(`/${ownerHandle}/${repoName}`); 16 + const treeHref = (name: string) => resolve(`${base}/tree/${encodeURIComponent(name)}` as "/"); 17 + const logHref = (name: string) => resolve(`${base}/commits/${encodeURIComponent(name)}` as "/"); 18 + const subject = (message?: string) => message?.split("\n\n")[0] ?? ""; 19 + </script> 20 + 21 + <section id="branches-table" class="overflow-x-auto rounded bg-background-default px-6 py-4"> 22 + <h2 class="mb-4 typography-paragraph-regular font-bold">Branches</h2> 23 + 24 + {#if branches.length === 0} 25 + <p class="py-6 text-center text-foreground-subtle">This repository has no branches.</p> 26 + {:else} 27 + <div class="hidden divide-y divide-border-default md:flex md:flex-col"> 28 + <div class="grid grid-cols-14 gap-4"> 29 + <div 30 + class="col-span-4 py-2 text-left typography-paragraph-regular font-bold text-foreground-muted" 31 + > 32 + Name 33 + </div> 34 + <div 35 + class="col-span-2 py-2 text-left typography-paragraph-regular font-bold text-foreground-muted" 36 + > 37 + Commit 38 + </div> 39 + <div 40 + class="col-span-6 py-2 text-left typography-paragraph-regular font-bold text-foreground-muted" 41 + > 42 + Message 43 + </div> 44 + <div 45 + class="col-span-2 justify-self-end py-2 text-left typography-paragraph-regular font-bold text-foreground-muted" 46 + > 47 + Date 48 + </div> 49 + </div> 50 + {#each branches as branch (branch.name)} 51 + <div class="grid grid-cols-14 gap-4 py-3"> 52 + <div class="col-span-4 flex items-center gap-2 align-top"> 53 + <a 54 + href={treeHref(branch.name)} 55 + class="truncate text-foreground-default no-underline hover:underline" 56 + > 57 + {branch.name} 58 + </a> 59 + {#if branch.isDefault} 60 + <Tag color="gray" class="font-mono">Default</Tag> 61 + {/if} 62 + </div> 63 + <div class="col-span-2 align-top font-mono"> 64 + <a href={logHref(branch.name)} class="no-underline hover:underline"> 65 + {branch.hash.slice(0, 8)} 66 + </a> 67 + </div> 68 + <div class="col-span-6 align-top break-words">{subject(branch.message)}</div> 69 + <div class="col-span-2 justify-self-end align-top text-foreground-muted"> 70 + {#if branch.when} 71 + <TimeAgo value={branch.when} /> 72 + {/if} 73 + </div> 74 + </div> 75 + {/each} 76 + </div> 77 + 78 + <div class="md:hidden"> 79 + {#each branches as branch, index (branch.name)} 80 + <div class="p-2 {index < branches.length - 1 ? 'border-b border-border-default' : ''}"> 81 + <div class="flex items-center gap-2"> 82 + <a 83 + href={treeHref(branch.name)} 84 + class="truncate font-medium text-foreground-default no-underline hover:underline" 85 + > 86 + {branch.name} 87 + </a> 88 + {#if branch.isDefault} 89 + <Tag color="gray" class="font-mono">Default</Tag> 90 + {/if} 91 + </div> 92 + <div 93 + class="mt-1 flex items-center gap-1 typography-paragraph-small text-foreground-muted" 94 + > 95 + <a href={logHref(branch.name)} class="font-mono no-underline hover:underline"> 96 + {branch.hash.slice(0, 8)} 97 + </a> 98 + {#if branch.when} 99 + <span aria-hidden="true">&middot;</span> 100 + <TimeAgo value={branch.when} /> 101 + {/if} 102 + </div> 103 + </div> 104 + {/each} 105 + </div> 106 + {/if} 107 + </section>
+53
web/src/lib/components/repo/TagCard.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; 3 + import { expect } from "storybook/test"; 4 + import TagCard from "./TagCard.svelte"; 5 + import type { TagSummary } from "./types"; 6 + 7 + const annotated: TagSummary = { 8 + name: "v1.2.0", 9 + hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 10 + commitHash: "0123456789abcdef0123456789abcdef01234567", 11 + when: "2026-07-28T09:00:00Z", 12 + taggerName: "dawn", 13 + message: "release v1.2.0\n\nships the mobile fixes" 14 + }; 15 + 16 + const lightweight: TagSummary = { 17 + name: "nightly", 18 + hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 19 + commitHash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" 20 + }; 21 + 22 + type PlayContext = Pick<StoryContext<Record<string, unknown>>, "canvas">; 23 + 24 + const annotatedLayout = async ({ canvas }: PlayContext) => { 25 + const nameLinks = canvas.getAllByRole("link", { name: /v1\.2\.0/ }); 26 + await expect(nameLinks[0]).toHaveAttribute("href", "/dawn/tangled/tags/v1.2.0"); 27 + const shaLinks = canvas.getAllByRole("link", { name: /01234567/ }); 28 + await expect(shaLinks[0]).toHaveAttribute( 29 + "href", 30 + "/dawn/tangled/commit/0123456789abcdef0123456789abcdef01234567" 31 + ); 32 + await expect(canvas.getByText("release v1.2.0")).toBeInTheDocument(); 33 + await expect(canvas.getByText("ships the mobile fixes")).toBeInTheDocument(); 34 + }; 35 + 36 + const lightweightLayout = async ({ canvas }: PlayContext) => { 37 + await expect(canvas.getByText("No message")).toBeInTheDocument(); 38 + }; 39 + 40 + const { Story } = defineMeta({ 41 + title: "Repo/TagCard", 42 + component: TagCard, 43 + tags: ["autodocs"], 44 + args: { 45 + ownerHandle: "dawn", 46 + repoName: "tangled", 47 + tag: annotated 48 + } 49 + }); 50 + </script> 51 + 52 + <Story name="Annotated" play={annotatedLayout} /> 53 + <Story name="Lightweight" args={{ tag: lightweight }} play={lightweightLayout} />
+81
web/src/lib/components/repo/TagCard.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import GitCommitHorizontal from "$icon/git-commit-horizontal"; 4 + import TagIcon from "$icon/tag"; 5 + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 6 + import type { TagSummary } from "./types"; 7 + 8 + interface Props { 9 + ownerHandle: string; 10 + repoName: string; 11 + tag: TagSummary; 12 + } 13 + 14 + let { ownerHandle, repoName, tag }: Props = $props(); 15 + 16 + const base = $derived(`/${ownerHandle}/${repoName}`); 17 + const tagHref = $derived(resolve(`${base}/tags/${encodeURIComponent(tag.name)}` as "/")); 18 + const commitHref = $derived(resolve(`${base}/commit/${tag.commitHash}` as "/")); 19 + const shortHash = $derived(tag.commitHash.slice(0, 8)); 20 + const messageParts = $derived(tag.message?.split("\n\n") ?? []); 21 + </script> 22 + 23 + <div class="flex flex-col md:grid md:grid-cols-12 md:items-start"> 24 + <div 25 + class="w-full border-b border-border-default md:col-span-2 md:h-full md:border-r md:border-b-0" 26 + > 27 + <div class="flex flex-col px-2 py-2 text-xl md:hidden"> 28 + <a 29 + href={tagHref} 30 + class="flex items-center gap-2 font-bold text-foreground-default no-underline hover:underline" 31 + > 32 + <TagIcon class="size-4" aria-hidden="true" /> 33 + {tag.name} 34 + </a> 35 + <div class="flex items-center gap-1 typography-paragraph-regular text-foreground-muted"> 36 + <a href={commitHref} class="font-mono no-underline hover:underline">{shortHash}</a> 37 + {#if tag.taggerName} 38 + <span aria-hidden="true">&middot;</span> 39 + <span>{tag.taggerName}</span> 40 + {/if} 41 + {#if tag.when} 42 + <span aria-hidden="true">&middot;</span> 43 + <TimeAgo value={tag.when} /> 44 + {/if} 45 + </div> 46 + </div> 47 + 48 + <div class="hidden px-2 pb-6 text-left md:block"> 49 + <a 50 + href={tagHref} 51 + class="flex items-center gap-2 font-bold text-foreground-default no-underline hover:underline" 52 + > 53 + <TagIcon class="size-4" aria-hidden="true" /> 54 + {tag.name} 55 + </a> 56 + <div class="flex flex-col typography-paragraph-regular text-foreground-muted"> 57 + <a href={commitHref} class="flex items-center gap-2 font-mono no-underline hover:underline"> 58 + <GitCommitHorizontal class="size-4" aria-hidden="true" /> 59 + {shortHash} 60 + </a> 61 + {#if tag.taggerName} 62 + <span>{tag.taggerName}</span> 63 + {/if} 64 + {#if tag.when} 65 + <TimeAgo value={tag.when} /> 66 + {/if} 67 + </div> 68 + </div> 69 + </div> 70 + 71 + <div class="px-2 py-3 md:col-span-10 md:py-0 md:pb-6"> 72 + {#if messageParts.length > 0} 73 + <p class="text-lg font-bold">{messageParts[0]}</p> 74 + {#if messageParts.length > 1} 75 + <p class="cursor-text py-2 whitespace-pre-wrap">{messageParts[1]}</p> 76 + {/if} 77 + {:else} 78 + <p class="text-foreground-muted italic">No message</p> 79 + {/if} 80 + </div> 81 + </div>
+10 -8
web/src/lib/components/repo/TagList.svelte
··· 26 26 {tag.name} 27 27 </a> 28 28 </div> 29 - <div class="flex items-center gap-2"> 30 - {#if tag.when} 31 - <TimeAgo value={tag.when} class="typography-paragraph-regular text-foreground-muted" /> 32 - {/if} 33 - {#if index === 0} 34 - <Tag color="gray" class="typography-monospace-regular">Latest</Tag> 35 - {/if} 36 - </div> 29 + {#if tag.when || index === 0} 30 + <div class="flex items-center gap-2"> 31 + {#if tag.when} 32 + <TimeAgo value={tag.when} class="typography-paragraph-regular text-foreground-muted" /> 33 + {/if} 34 + {#if index === 0} 35 + <Tag color="gray" class="typography-monospace-regular">Latest</Tag> 36 + {/if} 37 + </div> 38 + {/if} 37 39 </div> 38 40 {/each} 39 41 </div>
+33
web/src/routes/[handle]/[repo]/branches/+page.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import ChevronLeft from "$icon/chevron-left"; 4 + import ChevronRight from "$icon/chevron-right"; 5 + import BranchTable from "$lib/components/repo/BranchTable.svelte"; 6 + import Button from "$lib/components/ui/Button.svelte"; 7 + 8 + let { data } = $props(); 9 + 10 + const hasPrev = $derived(data.page > 1); 11 + const hasNext = $derived(data.page < data.pageCount); 12 + const pageHref = (next: number) => 13 + resolve( 14 + `/${data.repo.ownerHandle}/${data.repo.name}/branches${next > 1 ? `?page=${next}` : ""}` as "/" 15 + ); 16 + </script> 17 + 18 + <BranchTable 19 + ownerHandle={data.repo.ownerHandle} 20 + repoName={data.repo.name} 21 + branches={data.branches} 22 + /> 23 + 24 + {#if hasPrev || hasNext} 25 + <div class="mt-4 flex justify-end gap-2"> 26 + {#if hasPrev} 27 + <Button href={pageHref(data.page - 1)} icon={ChevronLeft} size="sm">Previous</Button> 28 + {/if} 29 + {#if hasNext} 30 + <Button href={pageHref(data.page + 1)} icon={ChevronRight} iconSide="right" size="sm">Next</Button> 31 + {/if} 32 + </div> 33 + {/if}
+23
web/src/routes/[handle]/[repo]/branches/+page.ts
··· 1 + import { branches, gitTarget } from "$lib/api/gitclient"; 2 + import { REF_LIMIT } from "$lib/api/repoIndex"; 3 + import { toBranchSummary } from "$lib/api/repo"; 4 + import type { PageLoad } from "./$types"; 5 + 6 + export const load: PageLoad = async (event) => { 7 + const parent = await event.parent(); 8 + const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); 9 + const rawPage = event.url.searchParams.get("page") ?? ""; 10 + const parsed = /^\d+$/.test(rawPage) ? Number(rawPage) : 1; 11 + const page = parsed >= 1 ? parsed : 1; 12 + const cursor = page > 1 ? String((page - 1) * REF_LIMIT) : undefined; 13 + const results = await branches(git, REF_LIMIT, cursor); 14 + const list = (results.branches ?? []).map(toBranchSummary); 15 + const total = results.total ?? 0; 16 + const pageCount = 17 + total > 0 ? Math.ceil(total / REF_LIMIT) : page + (list.length === REF_LIMIT ? 1 : 0); 18 + return { 19 + branches: list, 20 + page, 21 + pageCount 22 + }; 23 + };
+41
web/src/routes/[handle]/[repo]/tags/+page.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import ChevronLeft from "$icon/chevron-left"; 4 + import ChevronRight from "$icon/chevron-right"; 5 + import TagCard from "$lib/components/repo/TagCard.svelte"; 6 + import Button from "$lib/components/ui/Button.svelte"; 7 + import TabPanel from "$lib/components/ui/TabPanel.svelte"; 8 + 9 + let { data } = $props(); 10 + 11 + const hasPrev = $derived(data.page > 1); 12 + const hasNext = $derived(data.page < data.pageCount); 13 + const pageHref = (next: number) => 14 + resolve( 15 + `/${data.repo.ownerHandle}/${data.repo.name}/tags${next > 1 ? `?page=${next}` : ""}` as "/" 16 + ); 17 + </script> 18 + 19 + <TabPanel> 20 + <h2 class="mb-4 typography-paragraph-regular font-bold">Tags</h2> 21 + <div class="flex flex-col gap-12 py-2 md:gap-0"> 22 + {#each data.tags as tag (tag.name)} 23 + <TagCard ownerHandle={data.repo.ownerHandle} repoName={data.repo.name} {tag} /> 24 + {:else} 25 + <p class="p-4 text-center text-foreground-subtle"> 26 + This repository does not contain any tags. 27 + </p> 28 + {/each} 29 + </div> 30 + </TabPanel> 31 + 32 + {#if hasPrev || hasNext} 33 + <div class="mt-4 flex justify-end gap-2"> 34 + {#if hasPrev} 35 + <Button href={pageHref(data.page - 1)} icon={ChevronLeft} size="sm">Previous</Button> 36 + {/if} 37 + {#if hasNext} 38 + <Button href={pageHref(data.page + 1)} icon={ChevronRight} iconSide="right" size="sm">Next</Button> 39 + {/if} 40 + </div> 41 + {/if}
+23
web/src/routes/[handle]/[repo]/tags/+page.ts
··· 1 + import { gitTarget, tags } from "$lib/api/gitclient"; 2 + import { REF_LIMIT } from "$lib/api/repoIndex"; 3 + import { toTagSummary } from "$lib/api/repo"; 4 + import type { PageLoad } from "./$types"; 5 + 6 + export const load: PageLoad = async (event) => { 7 + const parent = await event.parent(); 8 + const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); 9 + const rawPage = event.url.searchParams.get("page") ?? ""; 10 + const parsed = /^\d+$/.test(rawPage) ? Number(rawPage) : 1; 11 + const page = parsed >= 1 ? parsed : 1; 12 + const cursor = page > 1 ? String((page - 1) * REF_LIMIT) : undefined; 13 + const results = await tags(git, REF_LIMIT, cursor); 14 + const list = (results.tags ?? []).map(toTagSummary); 15 + const total = results.total ?? 0; 16 + const pageCount = 17 + total > 0 ? Math.ceil(total / REF_LIMIT) : page + (list.length === REF_LIMIT ? 1 : 0); 18 + return { 19 + tags: list, 20 + page, 21 + pageCount 22 + }; 23 + };
+11
web/src/routes/[handle]/[repo]/tags/[tag]/+page.svelte
··· 1 + <script lang="ts"> 2 + import TagCard from "$lib/components/repo/TagCard.svelte"; 3 + 4 + let { data } = $props(); 5 + </script> 6 + 7 + <section class="rounded bg-background-default px-6 py-4"> 8 + <div class="flex flex-col py-2"> 9 + <TagCard ownerHandle={data.repo.ownerHandle} repoName={data.repo.name} tag={data.tag} /> 10 + </div> 11 + </section>
+21
web/src/routes/[handle]/[repo]/tags/[tag]/+page.ts
··· 1 + import { error } from "@sveltejs/kit"; 2 + import { gitTarget, tag, tags } from "$lib/api/gitclient"; 3 + import { toTagSummary } from "$lib/api/repo"; 4 + import type { PageLoad } from "./$types"; 5 + 6 + export const load: PageLoad = async (event) => { 7 + const parent = await event.parent(); 8 + const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); 9 + // the params arrive decoded, the wire names are raw 10 + const name = event.params.tag; 11 + let entry = await tag(git, name) 12 + .then((result) => result.tag) 13 + .catch(() => undefined); 14 + if (!entry && name === "latest") { 15 + entry = await tags(git, 1) 16 + .then((results) => results.tags?.[0]) 17 + .catch(() => undefined); 18 + } 19 + if (!entry) error(404, `no tag named ${name}`); 20 + return { tag: toTagSummary(entry) }; 21 + };