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

The story opens wave to changes to pulls to conversation, one tap each, and a test walks it #10706

Merged opened this

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

Merged

mason/forge:mason/10702-the-story-opens-wave-to-changes-to-pulls → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/10702-the-story-opens-wave-to-changes-to-pulls was merged into main as 2e5bf44.

Review

Sign in to review this change.
8 files changed +243 −15
docs/features/wave-walk.md added +20
@@ -0,0 +1,20 @@
1 # Walking a wave
2
3 A wave names the pulls it carries. You can go from the wave to a pull, to its conversation, and back, on the page and in the terminal.
4
5 ## On the page
6
7 1. Open `/<owner>/<repo>/waves` and pick a wave, or go to `/<owner>/<repo>/waves/<version>`.
8 2. Tap a pull in its list. The link carries `?from=<owner>/<repo>@<version>`, so the pull page knows where you came from.
9 3. The pull page opens on its verdict and the conversation. Above the verdict a breadcrumb reads `Wave <version> / This pull`.
10 4. Tap `Wave <version>` to return to the wave.
11
12 The `from` value is checked against the shape of a wave address before it is used; anything else shows no breadcrumb.
13
14 ## In the terminal
15
16 1. Open a repository's Waves screen and press Enter on a wave.
17 2. `n` (or Tab) and `p` move the `>` mark among the wave's pulls; Enter opens the marked pull.
18 3. Esc (or `q`) on the pull returns to the wave, and again to the list.
19
20 Only pulls the viewer may read are listed, and Enter re-checks that before opening one. The walk is tested in `test/story-walk.test.ts` and `test/tui-waves.test.ts`.
src/story/waves.ts +20
@@ -188,3 +188,23 @@ export async function latestWave(dir: string, branch: string): Promise<Wave | nu
188188 }
189189 return null;
190190 }
191
192 /** A wave's page on this forge. */
193 export const wavePath = (owner: string, name: string, version: string): string =>
194 `/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/waves/${encodeURIComponent(version)}`;
195
196 /** The query a pull link carries so the pull can lead back to the wave it was opened from. */
197 export const fromWave = (owner: string, name: string, version: string): string =>
198 `?from=${encodeURIComponent(`${owner}/${name}@${version}`)}`;
199
200 const FROM = /^([A-Za-z0-9._-]{1,100})\/([A-Za-z0-9._-]{1,100})@(.{1,64})$/;
201
202 /**
203 * The wave a `?from=` names, as its page path, or null when the value is not
204 * a well-formed wave address. Pure text: nothing is read, nothing echoed raw.
205 */
206 export function backToWave(from: string | null): { href: string; version: string } | null {
207 const m = from === null ? null : FROM.exec(from);
208 if (!m || !WAVE_VERSION.test(m[3]!)) return null;

Both doors: the terminal wave screen needs the same wave -> pull -> back walk, built on this helper, with a test.

209 return { href: wavePath(m[1]!, m[2]!, m[3]!), version: m[3]! };
210 }
src/tui/screens/waves.ts +56 −9
@@ -6,7 +6,7 @@
66 */
77
88 import { cutWaveFor, planCutFor } from "../../actions/cut_wave.ts";
9 import { canRead } from "../../core/repos.ts";
9 import { canRead, getRepo } from "../../core/repos.ts";
1010 import { VERSION } from "../../story/wave.ts";
1111 import { movedForViewer, type MovedMilestone } from "../../story/wave_moved.ts";
1212 import { milestonePath, planNow } from "../../story/wave_plan.ts";
@@ -24,7 +24,7 @@ import { emptyState } from "../ui/widgets.ts";
2424 import type { Session } from "../session.ts";
2525 import { ListState, Scroll, confirmScreen, drawList, drawLines, hitList, plural, runAsync, yesNo } from "./common.ts";
2626 import { pullScreen } from "./pull.ts";
27 import { localRepo, sourceName, whereRows, whereStrip } from "./where.ts";
27 import { local, localRepo, sourceName, whereRows, whereStrip } from "./where.ts";
2828
2929 /** One line per wave, as the screen draws it. */
3030 export function waveRows(entries: WaveEntry[]) {
@@ -52,6 +52,8 @@ export function waveLines(
5252 entry: WaveEntry,
5353 pulls: CarriedPull[],
5454 moved: MovedLine[] = [],
55 /** Which pull the cursor is on, marked with `>`; none when null. */
56 picked: number | null = null,
5557 ): string[] {
5658 if (!entry.ok) return [plain(entry.file), "", `Not a wave: ${plain(entry.reason)}`];
5759 const w = entry.wave;
@@ -75,21 +77,48 @@ export function waveLines(
7577 "Pulls",
7678 ...(pulls.length === 0
7779 ? [" No pulls to show here."]
78 : pulls.flatMap((p) => [
79 ` #${p.number} ${plain(p.title)} ${p.mergedAt === null ? p.state : `merged ${p.mergedAt.slice(0, 10)}`}`,
80 : pulls.flatMap((p, i) => [
81 `${picked === i ? "> " : " "}#${p.number} ${plain(p.title)} ${p.mergedAt === null ? p.state : `merged ${p.mergedAt.slice(0, 10)}`}`,
8082 ` ${plain(pullPath(p))}`,
8183 ])),
8284 ];
8385 }
8486
85 function waveScreen(ctx: Ctx, entry: WaveEntry, pulls: CarriedPull[], moved: MovedLine[]): Screen {
86 const lines = waveLines(entry, pulls, moved);
87 function waveScreen(
88 f: Session,
89 ctx: Ctx,
90 entry: WaveEntry,
91 pulls: CarriedPull[],
92 moved: MovedLine[],
93 src: RepoSource,
94 ): Screen {
8795 const scroll = new Scroll();
96 let picked = 0;
97 /** Where the wave's pulls start in the text, so the pick is scrolled into view. */
98 const linesFor = (): string[] => waveLines(entry, pulls, moved, pulls.length === 0 ? null : picked);
99 const open = (): void => {
100 const p = pulls[picked];

Non-blocking: this comment says the pick is scrolled into view, but linesFor only builds the lines. Make it true or reword it.

101 if (p === undefined) return;
102 const here = localRepo(src);
103 // The pull's own repository, read again through the gate: a wave may name pulls of other repositories.
104 const repo =
105 here !== undefined && here.ownerName === p.owner && here.name === p.name
106 ? here
107 : getRepo(f.db, p.owner, p.name);
108 if (repo === undefined || !canRead(f.db, repo, f.user)) return;
109 // Esc on the pull pops back to this screen: the wave is where the walk began.
110 ctx.push(pullScreen(f, ctx, local(f, repo), p.number));
111 };
88112 return {
89113 title: entry.ok ? entry.wave.version : entry.file,
90 keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")],
114 keys: [
115 binding(["j", "k"], "scroll"),
116 ...(pulls.length > 0 ? [binding(["n", "p"], "pick a pull"), binding(["enter"], "open pull")] : []),
117 binding(["esc"], "back"),
118 ],
91119 render(area, s) {
92120 const c = ctx.theme.c;
121 const lines = linesFor();
93122 drawLines(
94123 s,
95124 area,
@@ -98,7 +127,25 @@ function waveScreen(ctx: Ctx, entry: WaveEntry, pulls: CarriedPull[], moved: Mov
98127 lines.map((text) => [{ text, style: style(c.fg, c.bg) }]),
99128 );
100129 },
101 key: (e) => scroll.key(e, lines.length),
130 key(e) {
131 if (pulls.length > 0 && !e.ctrl && !e.alt) {
132 if (e.key === "n" || e.key === "tab") {
133 picked = (picked + 1) % pulls.length;
134 ctx.invalidate();
135 return true;
136 }
137 if (e.key === "p") {
138 picked = (picked + pulls.length - 1) % pulls.length;
139 ctx.invalidate();
140 return true;
141 }
142 if (e.key === "enter") {
143 open();
144 return true;
145 }
146 }
147 return scroll.key(e, linesFor().length);
148 },
102149 };
103150 }
104151
@@ -131,7 +178,7 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen {
131178 plan: () => planNow(f.db, repo, new Date()),
132179 }).map((m) => ({ ...m, path: milestonePath(repoBase(repo), m.number) }))
133180 : [];
134 ctx.push(waveScreen(ctx, one, pulls, moved));
181 ctx.push(waveScreen(f, ctx, one, pulls, moved, src));
135182 });
136183 };
137184
src/web/pages/waves.ts +2 −2
@@ -16,7 +16,7 @@ import type { Ctx } from "../../http/context.ts";
1616 import { VERSION, type Wave } from "../../story/wave.ts";
1717 import { movedForViewer } from "../../story/wave_moved.ts";
1818 import { carriedPulls, pullPath } from "../../story/wave_pulls.ts";
19 import { pullRefs, readWave, readWaves } from "../../story/waves.ts";
19 import { fromWave, pullRefs, readWave, readWaves } from "../../story/waves.ts";
2020 import { NO_WAVES } from "../../story/words.ts";
2121 import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts";
2222 import { escapeHtml, html } from "../html.ts";
@@ -244,7 +244,7 @@ export async function wavePage(ctx: Ctx): Promise<Response> {
244244 : listCard(
245245 carried.map((p) =>
246246 row({
247 href: pullPath(p),
247 href: `${pullPath(p)}${fromWave(repo.ownerName, repo.name, entry.wave.version)}`,
248248 title: html`#${p.number} ${p.title}`,
249249 meta: html`${p.mergedAt === null ? p.state : `merged ${p.mergedAt.slice(0, 10)}`}`,
250250 state: p.mergedAt === null ? p.state : ("merged" as const),
src/web/studio/change.ts +4
@@ -51,6 +51,7 @@ import { liveClientJs, liveMount } from "../live.ts";
5151 import {
5252 btn,
5353 card,
54 crumbs,
5455 confirmAction,
5556 badge,
5657 icon,
@@ -92,6 +93,7 @@ import { STEP_MEANS, STEPS, stepOfPull, stepWord, type PathStep } from "../../fl
9293 import { changeHref } from "./nouns.ts";
9394 import { pullWord, servingHistory, stateDot, wordDot, type WordCtx } from "./words.ts";
9495 import { getHold } from "../../core/holds.ts";
96 import { backToWave } from "../../story/waves.ts";
9597 import { pullRoster } from "./pull_roster.ts";
9698
9799 /** The pull and its repo, or the response that refuses to say why not. */
@@ -737,7 +739,9 @@ async function changeConversation(
737739 const totals =
738740 status === undefined ? null : (await lap(spent, "diff", () => keptChange(wordCtx.dir, status))).diff;
739741 const verdict = verdictOf(verdictFacts(ctx, repo, pull, status, { mayMerge: writer }), thread, files);
742 const wave = backToWave(ctx.url.searchParams.get("from"));
740743 const body = html`${pageHead.top}
outdated

Principle 3/7: hand-rolled <p class="small"> line; use the design system's named back link/breadcrumb component rather than ad-hoc markup.

744 ${when(wave, (w) => crumbs([{ label: `Wave ${w.version}`, href: w.href }, { label: "This pull" }]))}
741745 ${verdictLine(verdict, { action: false })}
742746 ${changeSummary(pull.body, totals, files)}
743747 ${when(siblings.length > 0, () =>
test/story-walk.test.ts added +121
@@ -0,0 +1,121 @@
1 /** The story opens wave → pull → conversation, one tap each, and back to the same wave (#10702). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createSession } from "../src/core/sessions.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { backToWave } from "../src/story/waves.ts";
12 import { writeWave } from "../src/story/wave.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14 import { mergedPull } from "./helpers/wave-pulls.ts";
15
16 quietLogs();
17
18 let root = "";
19 let app: App;
20 let servers: Servers;
21 let cookie = "";
22
23 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
24 if (!r.ok) throw new Error(JSON.stringify(r.error));
25 return r.value;
26 };
27
28 beforeAll(async () => {
29 root = mkdtempSync(join(tmpdir(), "forge-story-walk-"));
30 app = createApp({ dataDir: root, port: 0 });
31 servers = await serve(app);
32 const u = must(
33 await createUser(app.db, {
34 origin: "seed",
35 trusted: true,
36 username: "keeper",
37 email: "[email protected]",
38 password: "a-good-passphrase",
39 adminIfFirst: true,
40 }),
41 );
42 cookie = `forge_session=${createSession(app.db, u.id).token}; forge_mode=dev`;
43 const repo = must(await createRepo(app, u, { name: "alpha", visibility: "public" }));
44 must(await commitToBranch(app, u, repo, { message: "init", files: [{ path: "README.md", content: "hi\n" }] }));
45 const n = await mergedPull(app, u, repo, "Add the thing");
46 const wave = (version: string, pulls: { title: string; url: string }[]) => {
47 const w = writeWave({
48 version,
49 date: "2026-03-05",
50 headline: `Wave ${version}`,
51 highlights: [1, 2, 3].map((k) => ({ title: `Thing ${k}`, why: "because" })),
52 pulls,
53 });
54 if (!w.ok) throw w.error;
55 return { path: `waves/${version}.md`, content: w.value };
56 };
57 must(
58 await commitToBranch(app, u, repo, {
59 message: "waves",
60 files: [
61 wave("0.1.0", []),
62 wave("0.2.0", [{ title: `#${n} Add the thing`, url: `https://forge.example/keeper/alpha/pulls/${n}` }]),
63 ],
64 }),
65 );
66 }, 30_000);
67 afterAll(() => {
68 servers.stop();
69 app.close();
70 rmSync(root, { recursive: true, force: true });
71 });
72
73 const get = async (path: string) => {
74 const res = await fetch(`${servers.url}${path}`, { headers: { cookie } });
75 return { res, body: await res.text() };
76 };
77 const href = (body: string, re: RegExp): string => {
78 const m = re.exec(body);
79 if (!m) throw new Error(`no link matching ${re}`);
80 return m[1]!.replaceAll("&amp;", "&");
81 };
82 const fast = (res: Response) => {
83 const app = /app;dur=([\d.]+)/.exec(res.headers.get("server-timing") ?? "");
84 expect(app).not.toBeNull();
85 expect(Number(app![1])).toBeLessThan(500); // loose: a slow machine must not flake it
86 };
87
88 test("waves → newest wave → its pull → the conversation, and back to the same wave", async () => {
89 const list = await get("/keeper/alpha/waves");
90 expect(list.res.status).toBe(200);
91 const waveHref = href(list.body, /href="(\/keeper\/alpha\/waves\/0\.2\.0)"/);
92
93 const wave = await get(waveHref);
94 expect(wave.res.status).toBe(200);
95 await get(waveHref); // warm
96 fast((await get(waveHref)).res);
97 const pullHref = href(wave.body, /href="(\/keeper\/alpha\/pulls\/\d+\?from=[^"]+)"/);
98
99 const pull = await get(pullHref);
100 expect(pull.res.status).toBe(200);
101 expect(pull.body).toContain("Add the thing");
102 expect(pull.body).toContain('id="pull-check"');
103 fast((await get(pullHref)).res);
104
105 expect(href(pull.body, /href="([^"]*)">Wave 0\.2\.0/)).toBe(waveHref);
106 expect((await get(waveHref)).res.status).toBe(200);
107 });
108
109 test("a level with nothing below it shows no link", async () => {
110 const empty = await get("/keeper/alpha/waves/0.1.0");
111 expect(empty.res.status).toBe(200);
112 expect(empty.body).toContain("No pulls to show here.");
113 expect(empty.body).not.toMatch(/\/pulls\/\d+/);
114 });
115
116 test("a pull without a wave, or with a malformed one, shows no back link", () => {
117 expect(backToWave(null)).toBeNull();
118 expect(backToWave("../../[email protected]")).toBeNull();
119 expect(backToWave("a/b@<script>")).toBeNull();
120 expect(backToWave("a/[email protected]")).toEqual({ href: "/a/b/waves/1.0.0", version: "1.0.0" });
121 });
test/tui-waves.test.ts +19 −3
@@ -127,7 +127,7 @@ describe("waves in the terminal", () => {
127127 repo: Repo,
128128 keys: string[] = [],
129129 remote = false,
130 ): Promise<{ text: string; stop(): void }> => {
130 ): Promise<{ text: string; after: string[]; stop(): void }> => {
131131 const terminal = headlessTerminal(120, 40);
132132 const { app, session } = createForgeApp(
133133 forge,
@@ -140,11 +140,13 @@ describe("waves in the terminal", () => {
140140 const src = remote ? remoteOf(here) : here;
141141 app.push(wavesScreen(session, app.ctx, src));
142142 await settleApp(app);
143 const after: string[] = [];
143144 for (const k of keys) {
144 terminal.feed(k === "enter" ? "\r" : k);
145 terminal.feed(k === "enter" ? "\r" : k === "esc" ? "\x1b" : k);
145146 await settleApp(app);
147 after.push(app.screen.toText());
146148 }
147 return { text: app.screen.toText(), stop: () => app.stop() };
149 return { text: app.screen.toText(), after, stop: () => app.stop() };
148150 };
149151
150152 test("lists the web page's rows in the same order, bad files last", async () => {
@@ -186,6 +188,20 @@ describe("waves in the terminal", () => {
186188 expect(outsider.text).not.toContain("/alice/priv/pulls/");
187189 });
188190
191 test("the wave leads to its pulls and back: pick, open the pull, back returns to the wave", async () => {
192 const h = await open(alice, mix, ["enter", "n", "enter", "q"]);
193 h.stop();
194 const [wave, picked, pull, back] = h.after;
195 expect(wave).toContain("> #");
196 expect(picked).toContain("> #");
197 // The second pull is the private one, picked by `n`; the pull screen names it.
198 expect(picked).not.toEqual(wave);
199 expect(pull).toContain("Secret work");
200 expect(pull).not.toContain("Highlights");
201 expect(back).toContain("Highlights");
202 expect(back).toContain("Shown work");
203 });
204
189205 test("the wave screen names the milestones it moved, as the page does, and not one the viewer may not see", async () => {
190206 const r = await createRepo(forge, alice, { name: "ms", visibility: "public" });
191207 if (!r.ok) throw new Error("repo failed");
test/wave-pulls.test.ts +1 −1
@@ -103,7 +103,7 @@ test("rows give #N title · merged date, link to the pull, and leave out the uns
103103 expect(body).not.toContain("<b>thing");
104104 expect(body).toContain("Fix the other");
105105 expect(body).toContain("merged 2026-03-04");
106 expect(body).toMatch(/href="\/alice\/pub\/pulls\/\d+"[^>]*class="row"|class="row"[^>]*href="\/alice\/pub\/pulls\/\d+"/);
106 expect(body).toMatch(/href="\/alice\/pub\/pulls\/\d+(?:\?from=[^"]*)?"[^>]*class="row"|class="row"[^>]*href="\/alice\/pub\/pulls\/\d+(?:\?from=[^"]*)?"/);
107107 expect(body).not.toContain("Secret work");
108108 expect(body).not.toContain("/alice/priv/pulls/");
109109 expect(body).not.toContain("#999");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs