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

The terminal's changelog narrows releases by a word with `/`, and Enter on a release lists every pull it carried #7364

Merged opened this

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

Merged

scout/forge:scout/7350-the-terminal-s-changelog-narrows-release → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/7350-the-terminal-s-changelog-narrows-release was merged into main as f8a398c.

Review

Sign in to review this change.
5 files changed +199 −13
README.md +1 −1
@@ -431,6 +431,6 @@ This page is the front door. Everything else is a file of its own under
431431
432432 The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do (`docs/CREW.md`, "Where you are signed in").
433433
434 `/changelog` lists the last 30 releases of `FORGE_REPO` (public only) with their pulls, for anyone; the terminal's palette has `Changelog` for the same list (`docs/features/changelog.md`).
434 `/changelog` lists the last 30 releases of `FORGE_REPO` (public only) with their pulls, for anyone; the terminal's palette has `Changelog` for the same list. In the terminal, `/` takes a word and keeps the releases whose version or pull titles contain it (`esc` clears it); Enter on a release lists its pulls, and Enter on a pull opens it (`docs/features/changelog.md`).
435435
436436 A repository admin cuts a wave from two doors, both through `cutWaveFor` (`src/actions/cut_wave.ts`): Settings → *Cut a wave*, and `c` on the terminal's waves screen, which says how many pulls would go in, asks `cut wave N? y/n` (`y` and `n` choose outright) and shows the draft pull's number (Enter opens it). Each opens a draft pull listing the pulls merged since the last wave; nothing merged, an earlier wave that does not parse, or a viewer who is not an admin is refused in the action's words, writing nothing.
docs/features/changelog.md +2
@@ -2,4 +2,6 @@
22
33 `GET /changelog` is public. It lists the last 30 `Release <version>: …` commits on the default branch of the forge's own repository (`FORGE_REPO`), newest first. Each has a heading anchored `#<version>`, its date, and one link per pull named in the subject (`title (#N);title (#N)`). When `FORGE_REPO` is unset or its repository is private, the page answers 200 with "No releases yet". The footer links to it.
44
5 The terminal's palette has `Changelog`, the same read for anyone, a guest included. `/` takes a word and keeps only releases whose version or carried pull titles contain it, ignoring case; the header says how many match, and `esc` clears it. Enter on a release lists its pulls (`#N title`); a release whose subject names none shows "No pulls". Enter on a pull opens it, as Enter does on any pull.
6
57 One bounded `git log` read, cached per head oid; Server-Timing names `releases` and `render`.
src/tui/screens/changelog.ts +69 −10
@@ -1,7 +1,8 @@
11 /**
22 * The changelog, the terminal's /changelog: the same `forgeChangelog` read the
33 * page makes, for anyone, a guest included. Each release is a row: version,
4 * date and the pulls it carried; Enter opens its first pull.
4 * date and the pulls it carried. `/` narrows them by a word of the version or a

The header comment now says what the screen does, but README.md:419 and docs/features/changelog.md do not mention / or the pull list. Update both.

5 * pull title; Enter lists a release's pulls, and Enter on a pull opens it.
56 */
67
78 import { forgeChangelog, type ReleaseEntry } from "../../release/changelog.ts";
@@ -9,9 +10,17 @@ import { sourceFor } from "../../view/source.ts";
910 import type { Ctx, Screen } from "../app/app.ts";
1011 import { binding } from "../app/keys.ts";
1112 import type { Session } from "../session.ts";
13 import { fill, len, split } from "../ui/layout.ts";
14 import { style } from "../term/style.ts";
15 import { truncate } from "../term/width.ts";
1216 import { emptyState } from "../ui/widgets.ts";
1317 import { pullScreen } from "./pull.ts";
14 import { ListState, ago, drawList, metaOf, type ListRow } from "./common.ts";
18 import { Filter, ListState, ago, drawList, metaOf, type ListRow } from "./common.ts";
19
20 /** Whether a release's version or any pull it carried names the word. */
21 export function releaseMatches(filter: Filter, e: ReleaseEntry): boolean {
22 return filter.matches([e.version, ...e.pulls.map((p) => p.title)].join("\n"));
23 }
1524
1625 export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] {
1726 return entries.map((e) => ({
@@ -24,14 +33,49 @@ export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] {
2433 }));
2534 }
2635
36 type Repo = { owner: string; name: string };
37
38 /** One release's pulls, `#N title`; Enter opens the pull as the changelog's Enter once did. */
39 function releasePulls(f: Session, ctx: Ctx, repoRef: Repo, entry: ReleaseEntry): Screen {
40 const state = new ListState();
41 return {
42 title: entry.version,
43 keys: [binding(["j", "k"], "move"), binding(["enter"], "open pull"), binding(["esc", "q"], "back")],
44 render(area, s) {
45 const rows = entry.pulls.map((p) => ({ title: `#${p.number} ${p.title}`.trim() }));
46 if (rows.length === 0) {
47 emptyState(s, area, ctx.theme.c, { title: "No pulls", body: "This release carried none." });

The 'No pulls' empty state is untested. Add a test for it or remove the branch.

48 return;
49 }
50 drawList(s, area, ctx.theme.c, state, rows);
51 },
52 key(e) {
53 if (e.key === "esc" || e.key === "q") ctx.pop();
54 else if (e.key === "enter") {
55 const pull = entry.pulls[state.index];
56 const opened = pull === undefined ? undefined : sourceFor(f.app, f.user, repoRef);
57 if (pull !== undefined && opened?.ok) ctx.push(pullScreen(f, ctx, opened.value, pull.number));
58 } else state.key(e, entry.pulls.length);
59 return true;
60 },
61 };
62 }
63
2764 export function changelogScreen(f: Session, ctx: Ctx): Screen {
2865 const state = new ListState();
66 const filter = new Filter();
2967 let entries: ReleaseEntry[] = [];
3068 let loaded = false;
31 let repoRef: { owner: string; name: string } | undefined;
69 let repoRef: Repo | undefined;
70 const shown = (): ReleaseEntry[] => entries.filter((e) => releaseMatches(filter, e));
3271 return {
3372 title: "Changelog",
34 keys: [binding(["j", "k"], "move"), binding(["enter"], "open first pull"), binding(["esc", "q"], "close")],
73 keys: [
74 binding(["j", "k"], "move"),
75 binding(["/"], "find"),
76 binding(["enter"], "list pulls"),
77 binding(["esc", "q"], "close"),
78 ],
3579 async load() {
3680 const read = await forgeChangelog(f.app);
3781 entries = read?.entries ?? [];
@@ -49,15 +93,30 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen {
4993 });
5094 return;
5195 }
52 drawList(s, area, c, state, changelogRows(entries));
96 const list = shown();
97 const [head, box, body] = split(area, "v", [len(1), len(filter.on ? 1 : 0), fill(1)]);
98 const count = filter.query === "" ? `${entries.length} releases` : `${list.length} of ${entries.length} releases match`;
99 s.text(head!.x, head!.y, truncate(count, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
100 ctx.setCursor(filter.on ? filter.render(s, box!, c, "find a version or pull") : null);
101 if (list.length === 0) {
102 emptyState(s, body!, c, { title: "No release matches", body: "Esc clears the word." });
103 return;
104 }
105 drawList(s, body!, c, state, changelogRows(list));
53106 },
54107 key(e) {
55 if (e.key === "esc" || e.key === "q") ctx.pop();
108 if (filter.key(e)) {
109 state.clamp(shown().length);
110 return true;
111 }
112 if (e.key === "esc" && filter.query !== "") {
113 filter.query = "";
114 state.clamp(shown().length);
115 } else if (e.key === "esc" || e.key === "q") ctx.pop();
56116 else if (e.key === "enter") {
57 const first = entries[state.index]?.pulls[0];
58 const opened = first === undefined || repoRef === undefined ? undefined : sourceFor(f.app, f.user, repoRef);
59 if (first !== undefined && opened?.ok) ctx.push(pullScreen(f, ctx, opened.value, first.number));
60 } else state.key(e, entries.length);
117 const entry = shown()[state.index];
118 if (entry !== undefined && repoRef !== undefined) ctx.push(releasePulls(f, ctx, repoRef, entry));
119 } else state.key(e, shown().length);
61120 return true;
62121 },
63122 };
test/tui-changelog-find.test.ts added +123
@@ -0,0 +1,123 @@
1 /** `/` narrows the changelog by a word; Enter on a release lists its pulls and Enter on a pull opens it. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { openPull } from "../src/actions/pulls.ts";
7 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { createForgeApp } from "../src/tui/forge.ts";
11 import { changelogScreen } from "../src/tui/screens/changelog.ts";
12 import { headlessTerminal } from "../src/tui/term/terminal.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14 import { settleApp } from "./helpers/tui.ts";
15
16 quietLogs();
17
18 let dir: string;
19 let app: App;
20 let owner: Awaited<ReturnType<typeof createUser>> & { ok: true };
21 let repoRow: Awaited<ReturnType<typeof createRepo>> & { ok: true };
22 const guest = { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test" };
23
24 beforeAll(async () => {
25 dir = await mkdtemp(join(tmpdir(), "forge-tui-changelog-find-"));
26 app = createApp({ dataDir: dir, port: 0 });
27 const made = await createUser(app.db, {
28 origin: "seed",
29 trusted: true,
30 username: "alice",
31 email: "[email protected]",
32 password: "a-good-passphrase",
33 adminIfFirst: true,
34 });
35 if (!made.ok) throw new Error(made.error);
36 const repo = await createRepo(app, made.value, { name: "forge", visibility: "public" });
37 if (!repo.ok) throw new Error(JSON.stringify(repo.error));
38 owner = made;
39 repoRow = repo;
40 app.config.forgeRepo = "alice/forge";
41 for (const [n, message] of ["Release 0.1.0: Older (#1)", "Release 0.2.0: Newer thing (#2)"].entries()) {
42 const done = await commitToBranch(app, made.value, repo.value, {
43 message,
44 files: [{ path: `f${n}.txt`, content: `${n}\n` }],
45 });
46 if (!done.ok) throw new Error(JSON.stringify(done.error));
47 }
48 for (const [n, title] of ["Older", "Newer thing"].entries()) {
49 const branch = `p${n + 1}`;
50 const done = await commitToBranch(app, made.value, repo.value, {
51 message: title,
52 branch,
53 files: [{ path: `p${n}.txt`, content: `${n}\n` }],
54 });
55 if (!done.ok) throw new Error(JSON.stringify(done.error));
56 const pull = await openPull(app, made.value, repo.value, { title, baseBranch: repo.value.defaultBranch, headBranch: branch });
57 if (!pull.ok) throw new Error(JSON.stringify(pull.error));
58 }
59 }, 60_000);
60
61 afterAll(async () => {
62 app.close();
63 await rm(dir, { recursive: true, force: true });
64 });
65
66 test("a word of a pull's title narrows to its release, esc clears it, Enter lists and opens the pull", async () => {
67 const terminal = headlessTerminal(120, 30);
68 const { app: tui, session } = createForgeApp(app, guest, { terminal, exitHooks: false });
69 tui.start();
70 tui.ctx.push(changelogScreen(session, tui.ctx));
71 await settleApp(tui);
72 const all = tui.screen.toText();
73 expect(all).toContain("2 releases");
74 expect(all).toContain("0.1.0");
75
76 terminal.feed("/NEWER");
77 await settleApp(tui);
78 const narrowed = tui.screen.toText();
79 expect(narrowed).toContain("1 of 2 releases match");
80 expect(narrowed).toContain("0.2.0");
81 expect(narrowed).not.toContain("0.1.0");
82
83 terminal.feed("\x1b");
84 await new Promise((r) => setTimeout(r, 60));
85 await settleApp(tui);
86 expect(tui.screen.toText()).toContain("0.1.0");
87
88 terminal.feed("/older\r");
89 await settleApp(tui);
90 terminal.feed("\r");
91 await settleApp(tui);
92 const pulls = tui.screen.toText();
93 expect(pulls).toContain("#1 Older");
94 expect(pulls).not.toContain("#2");
95
96 terminal.feed("\r");
97 await settleApp(tui);
98 const opened = tui.screen.toText();
99 tui.stop?.();
100 expect(opened).toContain("#1");
101 expect(opened).toContain("0.1.0 › #1");
102 });
103
104 test("a release naming no pull lists none and survives j", async () => {
105 const done = await commitToBranch(app, owner.value, repoRow.value, {
106 message: "Release 0.3.0: Quiet",
107 files: [{ path: "quiet.txt", content: "q\n" }],
108 });
109 if (!done.ok) throw new Error(JSON.stringify(done.error));
110 const terminal = headlessTerminal(120, 30);
111 const { app: tui, session } = createForgeApp(app, guest, { terminal, exitHooks: false });
112 tui.start();
113 tui.ctx.push(changelogScreen(session, tui.ctx));
114 await settleApp(tui);
115 terminal.feed("\r");
116 await settleApp(tui);
117 const none = tui.screen.toText();
118 expect(none).toContain("No pulls");
119 terminal.feed("j");
120 await settleApp(tui);
121 expect(tui.screen.toText()).toContain("No pulls");
122 tui.stop?.();
123 });
test/tui-changelog.test.ts +4 −2
@@ -71,11 +71,13 @@ test("a guest is offered the changelog and sees the releases newest first", asyn
7171 const text = tui.screen.toText();
7272 terminal.feed("\r");
7373 await settleApp(tui);
74 terminal.feed("\r");
75 await settleApp(tui);
7476 const opened = tui.screen.toText();
7577 tui.stop?.();
76 // Enter opens the first pull of the newest release: pull #2's own screen.
78 // Enter lists the newest release's pulls, Enter again opens pull #2's own screen.
7779 expect(opened).not.toBe(text);
78 expect(opened).toContain("Changelog › #2");
80 expect(opened).toContain("Changelog › 0.2.0 › #2");
7981 expect(opened).not.toContain("0.1.0");
8082 expect(text.indexOf("0.2.0")).toBeGreaterThan(-1);
8183 expect(text.indexOf("0.2.0")).toBeLessThan(text.indexOf("0.1.0"));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs