Public: anyone can read this; the crew can change it.
Each row of a repository's waves page shows the wave's headline and how many pulls it carries #5891
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5878-each-row-of-a-repository-s-waves-page-sh → main 3 commits fork 4 threads · 4 unresolved
2 files
changed +39
−4
src/web/pages/waves.ts +9 −3
| @@ -12,7 +12,7 @@ import { holds, stamp, xml } from "../feeds.ts"; | ||
| 12 | 12 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 13 | 13 | import { repoDir } from "../../git/repos.ts"; |
| 14 | 14 | import type { Ctx } from "../../http/context.ts"; |
| 15 | import { VERSION } from "../../story/wave.ts"; | |
| 15 | import { VERSION, type Wave } from "../../story/wave.ts"; | |
| 16 | 16 | import { carriedPulls, pullPath } from "../../story/wave_pulls.ts"; |
| 17 | 17 | import { pullRefs, readWave, readWaves } from "../../story/waves.ts"; |
| 18 | 18 | import { NO_WAVES } from "../../story/words.ts"; |
| @@ -22,6 +22,11 @@ import { page } from "../layout.ts"; | ||
| 22 | 22 | import { empty, listCard, row } from "../ui.ts"; |
| 23 | 23 | import { localScope } from "../scope.ts"; |
| 24 | 24 | |
| 25 | /** A wave's row title: `<version> · <headline> · N pulls`, N being the pulls this viewer can see. */ | |
| 26 | export function waveLine(w: Wave, visible: number): string { | |
| 27 | return `${w.version} · ${w.headline} · ${visible} ${visible === 1 ? "pull" : "pulls"}`; | |
| 28 | } | |
outdated
Dead branch: the wave format rejects an empty headline. Remove it. |
||
| 29 | ||
| 25 | 30 | // GET /:owner/:name/waves |
| 26 | 31 | export async function wavesPage(ctx: Ctx): Promise<Response> { |
| 27 | 32 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -42,8 +47,9 @@ export async function wavesPage(ctx: Ctx): Promise<Response> { | ||
| 42 | 47 | e.ok |
| 43 | 48 | ? row({ |
| 44 | 49 | href: fileHref(e.file), |
| 45 | title: e.wave.headline, | |
| 46 | meta: html`${e.wave.version} · ${e.wave.date}`, | |
| 50 | // The wave's own page lists these same pulls, so the two agree. | |
| 51 | title: waveLine(e.wave, carriedPulls(ctx.app.db, pullRefs(e.wave), ctx.auth.user).length), | |
| 52 | meta: html`${e.wave.date}`, | |
Per-row |
||
| 47 | 53 | }) |
| 48 | 54 | : row({ |
| 49 | 55 | href: fileHref(e.file), |
test/waves-page.test.ts +30 −1
| @@ -8,6 +8,7 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 8 | 8 | import { createSession } from "../src/core/sessions.ts"; |
| 9 | 9 | import { createUser, type User } from "../src/core/users.ts"; |
| 10 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | import { mergedPull, waveNaming } from "./helpers/wave-pulls.ts"; | |
| 11 | 12 | import { writeWave, type Wave } from "../src/story/wave.ts"; |
| 12 | 13 | |
| 13 | 14 | const PASSWORD = "a-good-passphrase"; |
| @@ -77,6 +78,17 @@ describe("waves page", () => { | ||
| 77 | 78 | await put(priv, [ |
| 78 | 79 | { path: "waves/1.0.0.md", content: text(wave("1.0.0", "2026-03-01", "Secret launch")) }, |
| 79 | 80 | ]); |
| 81 | const shown = await mergedPull(app, alice, pub, "Shown work"); | |
| 82 | const secret = await mergedPull(app, alice, priv, "Secret work"); | |
| 83 | await put(pub, [ | |
| 84 | { | |
| 85 | path: "waves/0.1.0.md", | |
| 86 | content: waveNaming("0.1.0", [ | |
| 87 | { owner: "alice", name: "pub", number: shown }, | |
| 88 | { owner: "alice", name: "priv", number: secret }, | |
| 89 | ]), | |
| 90 | }, | |
| 91 | ]); | |
| 80 | 92 | }, 60_000); |
| 81 | 93 | |
| 82 | 94 | afterAll(async () => { |
| @@ -187,12 +199,29 @@ describe("waves page", () => { | ||
| 187 | 199 | expect(res.status).toBe(200); |
| 188 | 200 | expect(res.headers.get("content-type")).toContain("application/atom+xml"); |
| 189 | 201 | const body = await res.text(); |
| 190 | expect(body.match(/<entry>/g)?.length).toBe(4); | |
| 202 | expect(body.match(/<entry>/g)?.length).toBe(5); | |
| 191 | 203 | expect(body.indexOf("Tenth")).toBeLessThan(body.indexOf("Ninth")); |
| 192 | 204 | expect(body).toContain('href="http://'); |
| 193 | 205 | expect(body).toContain("/alice/pub/waves/0.10.0"); |
| 194 | 206 | }); |
| 195 | 207 | |
| 208 | test("a row counts the pulls this viewer can see, as the wave's page lists them", async () => { | |
| 209 | const body = await (await get("/alice/pub/waves")).text(); | |
| 210 | expect(body).toContain("0.1.0 · Mixed · 2 pulls"); | |
| 211 | const guest = await (await get("/alice/pub/waves", false)).text(); | |
| 212 | expect(guest).toContain("0.1.0 · Mixed · 1 pull<"); | |
| 213 | expect(guest).not.toContain("2 pulls"); | |
| 214 | expect(guest).not.toContain("Secret work"); | |
| 215 | const page = await (await get("/alice/pub/waves/0.1.0", false)).text(); | |
| 216 | expect(page).toContain("Shown work"); | |
| 217 | expect(page).not.toContain("Secret work"); | |
| 218 | }); | |
| 219 | ||
| 220 | test("waves.atom titles an entry with the headline", async () => { | |
| 221 | const body = await (await get("/alice/pub/waves.atom")).text(); | |
| 222 | expect(body).toContain("<title>Tenth</title>"); | |
| 223 | }); | |
| 224 | ||
| 196 | 225 | test("waves.atom escapes markup in headlines and notes", async () => { |
| 197 | 226 | const body = await (await get("/alice/pub/waves.atom")).text(); |
| 198 | 227 | expect(body).not.toContain("<script>"); |
new above
offline
Counts raw refs, not what this viewer may see. This leaks private pulls and can disagree with the wave's page. Use the visible count from carriedPulls.