Public: anyone can read this; the crew can change it.
Terminal waves: a row opens its wave file, and a live browse says it cannot read waves #2102
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2076-terminal-waves-a-row-opens-its-wave-file → main 2 commits fork
3 files
changed +81
−9
README.md +3 −1
| @@ -972,7 +972,9 @@ work here. | ||
| 972 | 972 | earlier wave does not parse (naming the file) or nothing new has merged. |
| 973 | 973 | `/:owner/:name/waves` lists the waves on the default branch, newest version |
| 974 | 974 | first, each linking to its file (linked from that settings card); in the |
| 975 | terminal, `V` in a repository opens the same list. | |
| 975 | terminal, `V` in a repository opens the same list, and Enter on a row opens | |
| 976 | its `waves/<file>` in the file viewer (a live browse of another forge says | |
| 977 | waves are only read on this forge). | |
| 976 | 978 | |
| 977 | 979 | - **Accounts & access** — register/sign in, personal access tokens with coarse |
| 978 | 980 | scopes (ro/rw/admin), public and private repositories. |
src/tui/screens/waves.ts +29 −5
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * A repository's waves: the same rows, in the same order, as the web page, |
| 3 | 3 | * read through `story/waves.ts`. Local repositories only — a live browse of |
| 4 | * another forge has no `waves/` we can read here, and says so. | |
| 4 | * 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 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import { canRead } from "../../core/repos.ts"; |
| @@ -14,7 +15,8 @@ import { truncate } from "../term/width.ts"; | ||
| 14 | 15 | import { fill, len, split } from "../ui/layout.ts"; |
| 15 | 16 | import { emptyState } from "../ui/widgets.ts"; |
| 16 | 17 | import type { Session } from "../session.ts"; |
| 17 | import { ListState, drawList, plural, runAsync } from "./common.ts"; | |
| 18 | import { ListState, drawList, hitList, plural, runAsync } from "./common.ts"; | |
| 19 | import { blobScreen } from "./workspace.ts"; | |
| 18 | 20 | import { localRepo, sourceName, whereRows, whereStrip } from "./where.ts"; |
| 19 | 21 | |
| 20 | 22 | /** One line per wave, as the screen draws it. */ |
| @@ -36,6 +38,14 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { | ||
| 36 | 38 | let loaded = false; |
| 37 | 39 | const state = new ListState(); |
| 38 | 40 | |
| 41 | const openAt = (i: number): void => { | |
| 42 | const entry = entries[i]; | |
| 43 | if (entry === undefined || repo === undefined) return; | |
| 44 | ctx.push( | |
| 45 | blobScreen(f, ctx, src, repo.defaultBranch, `waves/${entry.file}`), | |
| 46 | ); | |
| 47 | }; | |
| 48 | ||
| 39 | 49 | const reload = async (): Promise<void> => { |
| 40 | 50 | // The web's gate, not a weaker one: whoever reaches this screen, a |
| 41 | 51 | // repository they cannot read has no waves to show. |
| @@ -49,7 +59,11 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { | ||
| 49 | 59 | |
| 50 | 60 | return { |
| 51 | 61 | title: "Waves", |
| 52 | keys: [binding(["j", "k"], "move"), binding(["r"], "refresh")], | |
| 62 | keys: [ | |
| 63 | binding(["j", "k"], "move"), | |
| 64 | binding(["enter"], "open"), | |
| 65 | binding(["r"], "refresh"), | |
| 66 | ], | |
| 53 | 67 | load: reload, |
| 54 | 68 | render(area, s) { |
| 55 | 69 | const c = ctx.theme.c; |
| @@ -72,14 +86,24 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { | ||
| 72 | 86 | if (entries.length === 0) { |
| 73 | 87 | emptyState(s, body!, c, { |
| 74 | 88 | title: loaded ? "No waves yet" : "Reading…", |
| 75 | body: loaded ? "This repository has no waves/ directory." : "", | |
| 89 | body: !loaded | |
| 90 | ? "" | |
| 91 | : repo === undefined | |
| 92 | ? "Waves are only read on this forge." | |
| 93 | : "This repository has no waves/ directory.", | |
| 76 | 94 | }); |
| 77 | 95 | return; |
| 78 | 96 | } |
| 79 | drawList(s, body!, c, state, waveRows(entries)); | |
| 97 | const rows = waveRows(entries); | |
| 98 | drawList(s, body!, c, state, rows); | |
| 99 | hitList(ctx, body!, state, rows.length, { open: openAt }); | |
| 80 | 100 | }, |
| 81 | 101 | key(e) { |
| 82 | 102 | if (state.key(e, entries.length)) return true; |
| 103 | if (e.key === "enter") { | |
| 104 | openAt(state.index); | |
| 105 | return true; | |
| 106 | } | |
| 83 | 107 | if (e.key === "r") { |
| 84 | 108 | runAsync(ctx, reload); |
| 85 | 109 | return true; |
test/tui-waves.test.ts +49 −3
| @@ -10,6 +10,7 @@ import type { User } from "../src/core/users.ts"; | ||
| 10 | 10 | import { writeWave, type Wave } from "../src/story/wave.ts"; |
| 11 | 11 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 12 | 12 | import { wavesScreen } from "../src/tui/screens/waves.ts"; |
| 13 | import type { RepoSource } from "../src/view/source.ts"; | |
| 13 | 14 | import { local } from "../src/tui/screens/where.ts"; |
| 14 | 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 15 | 16 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -24,6 +25,29 @@ const wave = (version: string, date: string, headline: string): Wave => ({ | ||
| 24 | 25 | highlights: [1, 2, 3].map((n) => ({ title: `Thing ${n}`, why: "because" })), |
| 25 | 26 | pulls: [{ title: "#1 A pull", url: "https://forge.example/o/r/pulls/1" }], |
| 26 | 27 | }); |
| 28 | /** The same source, read live from another forge. */ | |
| 29 | const remoteOf = (src: RepoSource): RepoSource => ({ | |
| 30 | ...src, | |
| 31 | where: { | |
| 32 | kind: "origin", | |
| 33 | origin: { | |
| 34 | id: "o1", | |
| 35 | userId: "u1", | |
| 36 | kind: "github", | |
| 37 | apiUrl: "https://api.github.test", | |
| 38 | webUrl: "https://github.test", | |
| 39 | login: "someone", | |
| 40 | createdAt: "2026-01-01T00:00:00Z", | |
| 41 | lastSyncAt: null, | |
| 42 | lastError: "", | |
| 43 | authKind: "pat", | |
| 44 | expiresAt: null, | |
| 45 | needsReauth: false, | |
| 46 | }, | |
| 47 | fullName: "someone/else", | |
| 48 | htmlUrl: "https://github.test/someone/else", | |
| 49 | }, | |
| 50 | }); | |
| 27 | 51 | const text = (w: Wave): string => { |
| 28 | 52 | const r = writeWave(w); |
| 29 | 53 | if (!r.ok) throw r.error; |
| @@ -68,7 +92,11 @@ describe("waves in the terminal", () => { | ||
| 68 | 92 | await rm(dataDir, { recursive: true, force: true }); |
| 69 | 93 | }); |
| 70 | 94 | |
| 71 | const open = async (user: User, repo: Repo): Promise<{ text: string; stop(): void }> => { | |
| 95 | const open = async ( | |
| 96 | user: User, | |
| 97 | repo: Repo, | |
| 98 | remote = false, | |
| 99 | ): Promise<{ text: string; stop(): void; app: ReturnType<typeof createForgeApp>["app"] }> => { | |
| 72 | 100 | const terminal = headlessTerminal(120, 40); |
| 73 | 101 | const { app, session } = createForgeApp( |
| 74 | 102 | forge, |
| @@ -77,9 +105,11 @@ describe("waves in the terminal", () => { | ||
| 77 | 105 | ); |
| 78 | 106 | app.start(); |
| 79 | 107 | await settleApp(app); |
| 80 | app.push(wavesScreen(session, app.ctx, local(session, repo))); | |
| 108 | const here = local(session, repo); | |
| 109 | const src = remote ? remoteOf(here) : here; | |
| 110 | app.push(wavesScreen(session, app.ctx, src)); | |
| 81 | 111 | await settleApp(app); |
| 82 | return { text: app.screen.toText(), stop: () => app.stop() }; | |
| 112 | return { text: app.screen.toText(), stop: () => app.stop(), app }; | |
| 83 | 113 | }; |
| 84 | 114 | |
| 85 | 115 | test("lists the web page's rows in the same order, bad files last", async () => { |
| @@ -98,4 +128,20 @@ describe("waves in the terminal", () => { | ||
| 98 | 128 | expect(h.text).not.toContain("Secret launch"); |
| 99 | 129 | expect(h.text).toContain("No waves yet"); |
| 100 | 130 | }); |
| 131 | ||
| 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 | h.stop(); | |
| 138 | expect(after).toContain("0.10.0.md"); | |
| 139 | }); | |
| 140 | ||
| 141 | test("a live browse says waves are only read on this forge", async () => { | |
| 142 | const h = await open(alice, pub, true); | |
| 143 | h.stop(); | |
| 144 | expect(h.text).toContain("only read on this forge"); | |
| 145 | expect(h.text).not.toContain("no waves/ directory"); | |
| 146 | }); | |
| 101 | 147 | }); |
new above
offline