Public: anyone can read this; the crew can change it.

/:owner/:name/waves/<version> renders one wave as a note: headline, highlights with their why, merged pulls as links #2091

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1856-owner-name-waves-version-renders-one-wav → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/1856-owner-name-waves-version-renders-one-wav was merged into main as c6a385e.

Review

Sign in to review this change.
8 files changed +219 −26
README.md +4 −3
@@ -980,9 +980,10 @@ work here.
980980 placeholders; no model is called. It refuses, writing nothing, when an
981981 earlier wave does not parse (naming the file) or nothing new has merged.
982982 `/:owner/:name/waves` lists the waves on the default branch, newest version
983 first, each linking to its file (linked from that settings card); in the
984 terminal, `V` in a repository opens the same list, and Enter on a row opens
985 its `waves/<file>` in the file viewer (a live browse of another forge says
983 first, each line opening `/:owner/:name/waves/<version>` (headline, date,
984 highlights with why, pulls as links; linked from that settings card); in the
985 terminal, `V` in a repository opens the same list and Enter (or a click) on
986 a row opens the same wave as plain text (a live browse of another forge says
986987 waves are only read on this forge).
987988
988989 - **Accounts & access** — register/sign in, personal access tokens with coarse
src/http/server.ts +2 −1
@@ -169,7 +169,7 @@ import {
169169 branchDelete,
170170 branchesPage,
171171 } from "../web/pages/branches.ts";
172 import { wavesPage } from "../web/pages/waves.ts";
172 import { wavePage, wavesPage } from "../web/pages/waves.ts";
173173 import {
174174 contributeSubmit,
175175 deleteFilePage,
@@ -1152,6 +1152,7 @@ export function buildRouter(): Router<Handler> {
11521152 router.add("GET", "/:owner/:name/activity.atom", activityAtom);
11531153 router.add("GET", "/:owner/:name/branches", branchesPage);
11541154 router.add("GET", "/:owner/:name/waves", wavesPage);
1155 router.add("GET", "/:owner/:name/waves/:version", wavePage);
11551156 router.add("POST", "/:owner/:name/branches", branchCreate);
11561157 router.add("POST", "/:owner/:name/branches/:branch/delete", branchDelete);
11571158
src/story/wave.ts +2 −1
@@ -40,7 +40,8 @@ export type WaveResult<T> =
4040 export const MIN_HIGHLIGHTS = 3;
4141 export const MAX_HIGHLIGHTS = 5;
4242
43 const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
43 /** The shape of a wave's version, and of its file name before `.md`. */
44 export const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
4445 const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
4546 const SEP = " — ";
4647
src/story/waves.ts +23 −1
@@ -6,7 +6,7 @@
66 */
77
88 import { readerFor, listTree } from "../git/read.ts";
9 import { parseWave, type Wave } from "./wave.ts";
9 import { parseWave, VERSION as WAVE_VERSION, type Wave } from "./wave.ts";
1010
1111 /** A page, not an archive: more files than this are not read. */
1212 const MAX_FILES = 300;
@@ -62,6 +62,28 @@ export function byVersionDesc(a: string, b: string): number {
6262 return y.pre.length - x.pre.length;
6363 }
6464
65 /** One wave file by version, or null when the version is malformed or absent. */
66 export async function readWave(
67 dir: string,
68 branch: string,
69 version: string,
70 ): Promise<WaveEntry | null> {
71 if (!WAVE_VERSION.test(version)) return null;
72 const file = `${version}.md`;
73 const path = `waves/${file}`;
74 const tree = await listTree(dir, branch, "waves");
75 if (!tree.ok) return null;
76 const f = tree.value.find((e) => e.type === "blob" && e.name === file);
77 if (!f) return null;
78 if (f.size > MAX_BYTES) return { ok: false, file, reason: `${path}: file is too large` };
79 const obj = await readerFor(dir).object(f.oid);
80 if (!obj.ok) return { ok: false, file, reason: `${path}: unreadable` };
81 const parsed = parseWave(path, new TextDecoder().decode(obj.value.body));
82 return parsed.ok
83 ? { ok: true, file, wave: parsed.value }
84 : { ok: false, file, reason: parsed.error.message };
85 }
86
6587 /** Every wave file on `branch`, parsed; good ones newest first, then the bad. */
6688 export async function readWaves(
6789 dir: string,
src/tui/screens/waves.ts +54 −7
@@ -2,11 +2,12 @@
22 * A repository's waves: the same rows, in the same order, as the web page,
33 * read through `story/waves.ts`. Local repositories only — a live browse of
44 * another forge has no `waves/` we can read here, and says so. Enter on a row
5 * opens its file in the workspace viewer on the default branch.
5 * opens that wave as plain text.
66 */
77
88 import { canRead } from "../../core/repos.ts";
9 import { readWaves, type WaveEntry } from "../../story/waves.ts";
9 import { VERSION } from "../../story/wave.ts";
10 import { readWave, readWaves, type WaveEntry } from "../../story/waves.ts";
1011 import type { RepoSource } from "../../view/source.ts";
1112 import type { Ctx, Screen } from "../app/app.ts";
1213 import { binding } from "../app/keys.ts";
@@ -15,8 +16,7 @@ import { truncate } from "../term/width.ts";
1516 import { fill, len, split } from "../ui/layout.ts";
1617 import { emptyState } from "../ui/widgets.ts";
1718 import type { Session } from "../session.ts";
18 import { ListState, drawList, hitList, plural, runAsync } from "./common.ts";
19 import { blobScreen } from "./workspace.ts";
19 import { ListState, Scroll, drawList, drawLines, hitList, plural, runAsync } from "./common.ts";
2020 import { localRepo, sourceName, whereRows, whereStrip } from "./where.ts";
2121
2222 /** One line per wave, as the screen draws it. */
@@ -32,6 +32,47 @@ export function waveRows(entries: WaveEntry[]) {
3232 );
3333 }
3434
35 /** Wave text is someone's prose: no escape, control or newline survives into the terminal. */
36 const plain = (s: string): string =>
outdated

Nit: consider adding the bidi controls (\u202a-\u202e, \u2066-\u2069) to this class so prose cannot reorder what is shown on the line. Not blocking.

37 // eslint-disable-next-line no-control-regex
38 s.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]+/g, " ");
39
40 /** One wave as lines of plain text: headline, version and date, highlights with why, pulls as title and URL. */
41 export function waveLines(entry: WaveEntry): string[] {
42 if (!entry.ok) return [plain(entry.file), "", `Not a wave: ${plain(entry.reason)}`];
43 const w = entry.wave;
44 return [
45 plain(w.headline),
46 `${plain(w.version)} ${plain(w.date)}`,
47 "",
48 "Highlights",
49 ...w.highlights.map((h) => ` ${plain(h.title)} - ${plain(h.why)}`),
50 "",
51 "Pulls",
52 ...w.pulls.flatMap((p) => [` ${plain(p.title)}`, ` ${plain(p.url)}`]),
53 ];
54 }
55
56 function waveScreen(ctx: Ctx, entry: WaveEntry): Screen {
57 const lines = waveLines(entry);
58 const scroll = new Scroll();
59 return {
60 title: entry.ok ? entry.wave.version : entry.file,
61 keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")],
62 render(area, s) {
63 const c = ctx.theme.c;
64 drawLines(
65 s,
66 area,
67 c,
68 scroll,
69 lines.map((text) => [{ text, style: style(c.fg, c.bg) }]),
70 );
71 },
72 key: (e) => scroll.key(e, lines.length),
73 };
74 }
75
3576 export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen {
3677 const repo = localRepo(src);
3778 let entries: WaveEntry[] = [];
@@ -41,9 +82,15 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen {
4182 const openAt = (i: number): void => {
4283 const entry = entries[i];
4384 if (entry === undefined || repo === undefined) return;
44 ctx.push(
45 blobScreen(f, ctx, src, repo.defaultBranch, `waves/${entry.file}`),
46 );
85 runAsync(ctx, async () => {
86 // Same gate as the list, re-checked: the screen outlives a change of access.
87 if (!canRead(f.db, repo, f.user)) return;
88 const version = entry.file.slice(0, -3);
89 const one = VERSION.test(version)
90 ? await readWave(f.dir(repo), repo.defaultBranch, version)
91 : entry;
92 if (one) ctx.push(waveScreen(ctx, one));
93 });
4794 };
4895
4996 const reload = async (): Promise<void> => {
src/web/pages/waves.ts +53 −2
@@ -8,7 +8,8 @@
88
99 import { repoDir } from "../../git/repos.ts";
1010 import type { Ctx } from "../../http/context.ts";
11 import { readWaves } from "../../story/waves.ts";
11 import { VERSION } from "../../story/wave.ts";
12 import { readWave, readWaves } from "../../story/waves.ts";
1213 import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts";
1314 import { html } from "../html.ts";
1415 import { page } from "../layout.ts";
@@ -26,7 +27,9 @@ export async function wavesPage(ctx: Ctx): Promise<Response> {
2627 branch,
2728 );
2829 const fileHref = (file: string): string =>
29 `${repoBase(repo)}/blob/${encodeURIComponent(branch)}/waves/${encodeURIComponent(file)}`;
30 VERSION.test(file.slice(0, -3))
31 ? `${repoBase(repo)}/waves/${encodeURIComponent(file.slice(0, -3))}`
32 : `${repoBase(repo)}/blob/${encodeURIComponent(branch)}/waves/${encodeURIComponent(file)}`;
3033
3134 const rows = entries.map((e) =>
3235 e.ok
@@ -57,3 +60,51 @@ export async function wavesPage(ctx: Ctx): Promise<Response> {
5760 body,
5861 });
5962 }
63
64 /** Only http(s) becomes a link; anything else is shown as plain text. */
65 export const webUrl = (s: string): boolean => /^https?:\/\//i.test(s);
66
67 // GET /:owner/:name/waves/:version

The terminal needs the same view: src/tui/screens/waves.ts lists these rows but has no way to open one. Both doors should do what the page does. Reuse readWave behind canRead there.

68 export async function wavePage(ctx: Ctx): Promise<Response> {
69 const repo = loadRepo(ctx);
70 if (!repo) return notFoundPage(ctx);
71 const entry = await readWave(
72 repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
73 repo.defaultBranch,
74 ctx.params.version ?? "",
75 );
76 if (!entry) return notFoundPage(ctx);
77 const scope = localScope(ctx, repo);
78 const back = html`<a href="${repoBase(repo)}/waves">All waves</a>`;
79
80 const content = entry.ok
81 ? html`<h1>${entry.wave.headline}</h1>
82 <p class="muted">${entry.wave.version} · ${entry.wave.date}</p>
83 <h2>Highlights</h2>
84 <ul>
85 ${entry.wave.highlights.map(
86 (h) => html`<li><strong>${h.title}</strong> — ${h.why}</li>`,
87 )}
88 </ul>
89 <h2>Pulls</h2>
90 <ul>
91 ${entry.wave.pulls.map((p) =>
92 webUrl(p.url)
93 ? html`<li><a href="${p.url}" rel="noopener noreferrer">${p.title}</a></li>`

Good: only http(s) becomes an href. Everything else falls back to the title as text.

94 : html`<li>${p.title}</li>`,
95 )}
96 </ul>`
97 : html`<h1>${entry.file}</h1>
98 <p class="muted">Not a wave: ${entry.reason}</p>`;
99
100 const body = html`${scopeHeader(ctx, scope, "code", { compact: true })}
101 <div class="mt">${content}<p>${back}</p></div>`;
102
103 return page({
104 ctx,
105 title: `${entry.ok ? entry.wave.version : entry.file} · Waves · ${scope.facts.name}`,
106 crumbs: scope.crumbs,
107 palette: scope.palette,
108 body,
109 });
110 }
test/tui-waves.test.ts +38 −10
@@ -9,7 +9,7 @@ import type { Repo } from "../src/core/repos.ts";
99 import type { User } from "../src/core/users.ts";
1010 import { writeWave, type Wave } from "../src/story/wave.ts";
1111 import { createForgeApp } from "../src/tui/forge.ts";
12 import { wavesScreen } from "../src/tui/screens/waves.ts";
12 import { waveLines, wavesScreen } from "../src/tui/screens/waves.ts";
1313 import type { RepoSource } from "../src/view/source.ts";
1414 import { local } from "../src/tui/screens/where.ts";
1515 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -95,8 +95,9 @@ describe("waves in the terminal", () => {
9595 const open = async (
9696 user: User,
9797 repo: Repo,
98 keys: string[] = [],
9899 remote = false,
99 ): Promise<{ text: string; stop(): void; app: ReturnType<typeof createForgeApp>["app"] }> => {
100 ): Promise<{ text: string; stop(): void }> => {
100101 const terminal = headlessTerminal(120, 40);
101102 const { app, session } = createForgeApp(
102103 forge,
@@ -109,7 +110,11 @@ describe("waves in the terminal", () => {
109110 const src = remote ? remoteOf(here) : here;
110111 app.push(wavesScreen(session, app.ctx, src));
111112 await settleApp(app);
112 return { text: app.screen.toText(), stop: () => app.stop(), app };
113 for (const k of keys) {
114 terminal.feed(k === "enter" ? "\r" : k);
115 await settleApp(app);
116 }
117 return { text: app.screen.toText(), stop: () => app.stop() };
113118 };
114119
115120 test("lists the web page's rows in the same order, bad files last", async () => {
@@ -129,17 +134,40 @@ describe("waves in the terminal", () => {
129134 expect(h.text).toContain("No waves yet");
130135 });
131136
132 test("Enter on a wave row opens its file in the viewer", async () => {
133 const h = await open(alice, pub);
134 h.app.dispatch({ type: "key", key: "enter", ctrl: false, alt: false, shift: false });
135 await settleApp(h.app);
136 const after = h.app.screen.toText();
137 test("enter opens one wave: headline, version, date, highlights with why, pulls", async () => {
138 const h = await open(alice, pub, ["enter"]);
137139 h.stop();
138 expect(after).toContain("0.10.0.md");
140 expect(h.text).toContain("Tenth");
141 expect(h.text).toContain("0.10.0 2026-02-01");
142 expect(h.text).toContain("Thing 1 - because");
143 expect(h.text).toContain("#1 A pull");
144 expect(h.text).toContain("https://forge.example/o/r/pulls/1");
145 });
146
147 test("enter on an unparsable row shows its reason", async () => {
148 const h = await open(alice, pub, ["j", "j", "enter"]);
149 h.stop();
150 expect(h.text).toContain("broken.md");
151 expect(h.text).toContain("Not a wave:");
152 });
153
154 test("wave text carries no escape or control characters", () => {
155 const lines = waveLines({
156 ok: true,
157 file: "1.0.0.md",
158 wave: {
159 version: "1.0.0",
160 date: "2026-01-01",
161 headline: "Hi\u001b[31m red\u0007\u202e",
162 highlights: [{ title: "T\u001b]0;x\u0007", why: "w\nz" }],
163 pulls: [{ title: "P", url: "https://x.example/\u001b[2J" }],
164 },
165 });
166 expect(lines.join("\n")).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/);
139167 });
140168
141169 test("a live browse says waves are only read on this forge", async () => {
142 const h = await open(alice, pub, true);
170 const h = await open(alice, pub, [], true);
143171 h.stop();
144172 expect(h.text).toContain("only read on this forge");
145173 expect(h.text).not.toContain("no waves/ directory");
test/waves-page.test.ts +43 −1
@@ -8,6 +8,7 @@ import type { Repo } from "../src/core/repos.ts";
88 import { createSession } from "../src/core/sessions.ts";
99 import { createUser, type User } from "../src/core/users.ts";
1010 import { serve, type Servers } from "../src/http/server.ts";
11 import { webUrl } from "../src/web/pages/waves.ts";
1112 import { writeWave, type Wave } from "../src/story/wave.ts";
1213
1314 const PASSWORD = "a-good-passphrase";
@@ -68,6 +69,9 @@ describe("waves page", () => {
6869 { path: "waves/0.9.0.md", content: text(wave("0.9.0", "2026-01-01", "Ninth")) },
6970 { path: "waves/0.10.0.md", content: text(wave("0.10.0", "2026-02-01", "Tenth")) },
7071 { path: "waves/0.2.0.md", content: text(wave("0.2.0", "2025-12-01", "<script>alert(1)</script> & Zeta")) },
72 { path: "waves/0.3.0.md", content: text({ ...wave("0.3.0", "2025-12-15", "Evil <b>links</b>"), highlights: [{ title: "T<i>1</i>", why: "why <u>one</u>" }, { title: "Two", why: "w" }, { title: "Three", why: "w" }] }) },
73 { path: "waves/0.5.0.md", content: text(wave("0.5.0", "2025-12-16", "Js")).replace("https://forge.example/o/r/pulls/1", "javascript:alert(1)") },
74 { path: "waves/0.4.0.md", content: "no front matter here\n" },
7175 { path: "waves/broken.md", content: "no front matter here\n" },
7276 ]);
7377 await put(priv, [
@@ -91,7 +95,7 @@ describe("waves page", () => {
9195 expect(at("Ninth")).toBeLessThan(at("Zeta"));
9296 expect(body).toContain("0.10.0");
9397 expect(body).toContain("2026-02-01");
94 expect(body).toContain("/alice/pub/blob/main/waves/0.10.0.md");
98 expect(body).toContain('href="/alice/pub/waves/0.10.0"');
9599 });
96100
97101 test("a headline with markup comes out inert", async () => {
@@ -127,4 +131,42 @@ describe("waves page", () => {
127131 const owner = await get("/alice/priv/waves");
128132 expect(await owner.text()).toContain("Secret launch");
129133 });
134
135 test("one wave renders headline, date, highlights with why, pulls as links", async () => {
136 const res = await get("/alice/pub/waves/0.10.0");
137 expect(res.status).toBe(200);
138 const body = await res.text();
139 expect(body).toContain("Tenth");
140 expect(body).toContain("2026-02-01");
141 expect(body).toContain("Thing 1");
142 expect(body).toContain("because");
143 expect(body).toContain('href="https://forge.example/o/r/pulls/1"');
144 });
145
146 test("values are escaped and a javascript: pull is not a link", async () => {
147 const body = await (await get("/alice/pub/waves/0.3.0")).text();
148 expect(body).toContain("Evil &lt;b&gt;links&lt;/b&gt;");
149 expect(body).toContain("why &lt;u&gt;one&lt;/u&gt;");
150 expect(body).not.toContain("<b>links");
151 const js = await (await get("/alice/pub/waves/0.5.0")).text();
152 expect(js).not.toContain('href="javascript:');
153 expect(webUrl("javascript:alert(1)")).toBe(false);
154 expect(webUrl("https://x.example/p/1")).toBe(true);
155 });
156
157 test("an unknown or malformed version is a 404; an unparsable file shows its reason", async () => {
158 expect((await get("/alice/pub/waves/9.9.9")).status).toBe(404);
159 expect((await get("/alice/pub/waves/nope")).status).toBe(404);
160 expect((await get("/alice/pub/waves/..%2Fx")).status).toBe(404);
161 const bad = await get("/alice/pub/waves/0.4.0");
162 expect(bad.status).toBe(200);
163 expect(await bad.text()).toContain("missing front matter");
164 });
165
166 test("a private repository's wave is refused to an outsider", async () => {
167 const out = await get("/alice/priv/waves/1.0.0", false);
168 expect(out.status).toBe(404);
169 expect(await out.text()).not.toContain("Secret launch");
170 expect(await (await get("/alice/priv/waves/1.0.0")).text()).toContain("Secret launch");
171 });
130172 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs