Public: anyone can read this; the crew can change it.
A repository's home shows its latest wave's headline in one line, linking to the wave #5923
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5905-a-repository-s-home-shows-its-latest-wav → main 1 commit fork 2 threads · 2 unresolved
4 files
changed +51
−2
src/story/waves.ts +25
| @@ -124,3 +124,28 @@ export async function readWaves( | ||
| 124 | 124 | return [...good, ...bad]; |
| 125 | 125 | } |
| 126 | 126 | |
| 127 | ||
| 128 | /** Unreadable files tried before giving up on finding the newest good wave. */ | |
| 129 | const LATEST_TRIES = 5; | |
| 130 | ||
| 131 | /** | |
| 132 | * The newest wave that parses, or null. Reads the tree once and only as many | |
| 133 | * files as it takes, so a home page can ask without paying for the archive. | |
| 134 | */ | |
| 135 | export async function latestWave(dir: string, branch: string): Promise<Wave | null> { | |
| 136 | const tree = await listTree(dir, branch, "waves"); | |
| 137 | if (!tree.ok) return null; | |
| 138 | const files = tree.value | |
| 139 | .filter((e) => e.type === "blob" && e.name.endsWith(".md") && WAVE_VERSION.test(e.name.slice(0, -3))) | |
| 140 | .sort((x, y) => byVersionDesc(x.name.slice(0, -3), y.name.slice(0, -3))) | |
| 141 | .slice(0, LATEST_TRIES); | |
| 142 | const reader = readerFor(dir); | |
| 143 | for (const f of files) { | |
| 144 | if (f.size > MAX_BYTES) continue; | |
| 145 | const obj = await reader.object(f.oid); | |
| 146 | if (!obj.ok) continue; | |
| 147 | const parsed = parseWave(`waves/${f.name}`, new TextDecoder().decode(obj.value.body)); | |
| 148 | if (parsed.ok) return parsed.value; | |
| 149 | } | |
| 150 | return null; | |
| 151 | } |
src/web/pages/repo.ts +9
| @@ -47,6 +47,7 @@ import { | ||
| 47 | 47 | } from "../chrome.ts"; |
| 48 | 48 | import { codeJs } from "../client.ts"; |
| 49 | 49 | import { highlightBlob } from "../code.ts"; |
| 50 | import { latestWave } from "../../story/waves.ts"; | |
| 50 | 51 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 51 | 52 | import { liveClientJs, liveMount } from "../live.ts"; |
| 52 | 53 | import { appStrip } from "./apps.ts"; |
| @@ -1033,10 +1034,18 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1033 | 1034 | readmeFor(scope.source, ref, tree, ""), |
| 1034 | 1035 | ); |
| 1035 | 1036 | const following = await timePhaseAsync(ctx.request, "following", () => followingOf(ctx, repo)); |
| 1037 | const latest = await timePhaseAsync(ctx.request, "wave", () => | |
| 1038 | latestWave(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), repo.defaultBranch), | |
| 1039 | ); | |
| 1036 | 1040 | const rendering = performance.now(); |
| 1037 | 1041 | const body = html`${scopeHeader(ctx, scope, "code", { |
| 1038 | 1042 | branchCount: refs.branches.length, |
| 1039 | 1043 | })}${archivedLine(ctx, repo)}${noApp} |
| 1044 | ${latest === null | |
| 1045 | ? "" | |
| 1046 | : html`<p class="muted latest-wave">Latest: | |
Nice-to-have: put the middle dot and date in a small named component, and wrap the version/headline so it truncates on a 360px phone instead of wrapping to several lines. |
||
| 1047 | <a href="${repoBase(repo)}/waves/${encodeURIComponent(latest.version)}">${latest.version}</a> | |
| 1048 | · ${latest.headline} · ${latest.date}</p>`} | |
| 1040 | 1049 | ${frozenBlock(ctx, repo)} ${appStrip(ctx, repo)} |
| 1041 | 1050 | ${following === undefined |
| 1042 | 1051 | ? forkBar(ctx, repo) |
test/repo-timing.test.ts +2 −2
| @@ -51,10 +51,10 @@ afterAll(async () => { | ||
| 51 | 51 | await rm(dataDir, { recursive: true, force: true }); |
| 52 | 52 | }); |
| 53 | 53 | |
| 54 | test("a repository home names session, appof, refs, tree, readme, following and render in order", async () => { | |
| 54 | test("a repository home names session, appof, refs, tree, readme, following, wave and render in order", async () => { | |
| 55 | 55 | const res = await fetch(`${servers.url}/alice/r`, { headers: { cookie } }); |
| 56 | 56 | expect(res.status).toBe(200); |
| 57 | 57 | await res.arrayBuffer(); |
| 58 | 58 | const names = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!); |
| 59 | expect(names).toEqual(["app", "session", "appof", "refs", "tree", "readme", "following", "render"]); | |
| 59 | expect(names).toEqual(["app", "session", "appof", "refs", "tree", "readme", "following", "wave", "render"]); | |
| 60 | 60 | }); |
test/waves-page.test.ts +15
| @@ -117,6 +117,21 @@ describe("waves page", () => { | ||
| 117 | 117 | expect(body).toContain('href="/alice/pub/waves"'); |
| 118 | 118 | }); |
| 119 | 119 | |
| 120 | test("the repository home draws the newest wave in one line, linked", async () => { | |
| 121 | const res = await get("/alice/pub"); | |
| 122 | const body = await res.text(); | |
| 123 | expect(body).toContain("latest-wave"); | |
| 124 | expect(body).toContain('href="/alice/pub/waves/0.10.0"'); | |
| 125 | expect(body).toContain("Tenth · 2026-02-01"); | |
| 126 | expect(body).not.toContain("Ninth"); | |
| 127 | expect(res.headers.get("server-timing")).toContain("wave"); | |
| 128 | }); | |
| 129 | ||
| 130 | test("the repository home without waves draws no wave line", async () => { | |
| 131 | const body = await (await get("/alice/bare")).text(); | |
| 132 | expect(body).not.toContain("latest-wave"); | |
| 133 | }); | |
| 134 | ||
| 120 | 135 | test("no waves/ gets the empty state", async () => { |
| 121 | 136 | const res = await get("/alice/bare/waves"); |
| 122 | 137 | expect(res.status).toBe(200); |
new above
offline
Doubled blank line before the doc comment; trim to one.