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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7350-the-terminal-s-changelog-narrows-release → main 3 commits fork 2 threads · 2 unresolved
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 | ||
| 431 | 431 | |
| 432 | 432 | 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"). |
| 433 | 433 | |
| 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`). | |
| 435 | 435 | |
| 436 | 436 | 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 @@ | ||
| 2 | 2 | |
| 3 | 3 | `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. |
| 4 | 4 | |
| 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 | ||
| 5 | 7 | 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The changelog, the terminal's /changelog: the same `forgeChangelog` read the |
| 3 | 3 | * 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 | |
| 5 | * pull title; Enter lists a release's pulls, and Enter on a pull opens it. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import { forgeChangelog, type ReleaseEntry } from "../../release/changelog.ts"; |
| @@ -9,9 +10,17 @@ import { sourceFor } from "../../view/source.ts"; | ||
| 9 | 10 | import type { Ctx, Screen } from "../app/app.ts"; |
| 10 | 11 | import { binding } from "../app/keys.ts"; |
| 11 | 12 | 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"; | |
| 12 | 16 | import { emptyState } from "../ui/widgets.ts"; |
| 13 | 17 | 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 | } | |
| 15 | 24 | |
| 16 | 25 | export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] { |
| 17 | 26 | return entries.map((e) => ({ |
| @@ -24,14 +33,49 @@ export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] { | ||
| 24 | 33 | })); |
| 25 | 34 | } |
| 26 | 35 | |
| 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 | ||
| 27 | 64 | export function changelogScreen(f: Session, ctx: Ctx): Screen { |
| 28 | 65 | const state = new ListState(); |
| 66 | const filter = new Filter(); | |
| 29 | 67 | let entries: ReleaseEntry[] = []; |
| 30 | 68 | 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)); | |
| 32 | 71 | return { |
| 33 | 72 | 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 | ], | |
| 35 | 79 | async load() { |
| 36 | 80 | const read = await forgeChangelog(f.app); |
| 37 | 81 | entries = read?.entries ?? []; |
| @@ -49,15 +93,30 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen { | ||
| 49 | 93 | }); |
| 50 | 94 | return; |
| 51 | 95 | } |
| 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)); | |
| 53 | 106 | }, |
| 54 | 107 | 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(); | |
| 56 | 116 | 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); | |
| 61 | 120 | return true; |
| 62 | 121 | }, |
| 63 | 122 | }; |
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 | ||
| 71 | 71 | const text = tui.screen.toText(); |
| 72 | 72 | terminal.feed("\r"); |
| 73 | 73 | await settleApp(tui); |
| 74 | terminal.feed("\r"); | |
| 75 | await settleApp(tui); | |
| 74 | 76 | const opened = tui.screen.toText(); |
| 75 | 77 | 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. | |
| 77 | 79 | expect(opened).not.toBe(text); |
| 78 | expect(opened).toContain("Changelog › #2"); | |
| 80 | expect(opened).toContain("Changelog › 0.2.0 › #2"); | |
| 79 | 81 | expect(opened).not.toContain("0.1.0"); |
| 80 | 82 | expect(text.indexOf("0.2.0")).toBeGreaterThan(-1); |
| 81 | 83 | expect(text.indexOf("0.2.0")).toBeLessThan(text.indexOf("0.1.0")); |
new above
offline
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.