Public: anyone can read this; the crew can change it.
The terminal's board screen removes the marked panel with `x` after `remove <title>? y/n`, as the web's Remove does #8039
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7990-the-terminal-s-board-screen-removes-the → main 5 commits fork 1 thread · 1 unresolved
4 files
changed +220
−7
docs/STATUS.md +1 −1
| @@ -60,7 +60,7 @@ | ||
| 60 | 60 | `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit |
| 61 | 61 | by the admin who made it, and a file that does not validate is not shown. Make, rename, |
| 62 | 62 | duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`. |
| 63 | The terminal's Boards screen shapes the list too, through the same actions: `n` asks for a title, makes the board and opens it, `c` copies the marked row, `D` asks `delete <title>? y/n` (`y` deletes; `n` or `esc` cancels). A refusal is shown in the page's words (`boardRefusal`) and nothing changes. | |
| 63 | The terminal's Boards screen shapes the list too, through the same actions: `n` asks for a title, makes the board and opens it, `c` copies the marked row, `D` asks `delete <title>? y/n` (`y` deletes; `n` or `esc` cancels). On a board's screen `x` asks `remove <panel title>? y/n` for the marked panel: `y` reads the board again and removes it through `changeBoard`, as the page's Remove does, unless another door moved or removed panels meanwhile (then nothing is removed and the screen shows the board as it is), `n` or `esc` cancels, and the key is offered only to a forge admin who is a person; a pin on the removed panel is left as the page leaves it. A refusal is shown in the page's words (`boardRefusal`) and nothing changes. | |
| 64 | 64 | Any panel has **Pin to home** / **Unpin** on its board page (a same-origin POST); pins are per admin, kept in `board_pin` (slug + panel title + position, `boards/pins.ts`), and home draws them under the KPI row in that person's order with Up, Down and Unpin beside each (`web/studio/pinned.ts`). A pin whose panel is gone is skipped, does not count toward the limit of 24, and is dropped at that person's next pin; nobody sees another's pins. All doors go through `actions/board-pins.ts` (an admin who is a person; anyone else gets not-found): |
| 65 | 65 | - the page (Pin to home / Unpin on `/boards/:slug`, Up / Down / Unpin on home); |
| 66 | 66 | - the terminal: on a board's screen `[` `]` mark a panel, `p` pins or unpins it, `<` `>` move a pinned one; the dashboard lists the pinned panels by title; |
src/tui/screens/boards.ts +59 −6
| @@ -14,7 +14,7 @@ import { formatValue } from "../../boards/chart.ts"; | ||
| 14 | 14 | import { panelData, type PanelData } from "../../boards/panel_data.ts"; |
| 15 | 15 | import type { Board } from "../../boards/store.ts"; |
| 16 | 16 | import { listPinned, movePinned, pinPanel, unpinPanel } from "../../actions/board-pins.ts"; |
| 17 | import { boardRefusal, deleteBoard, duplicateBoard, listAllBoards, makeBoard, showBoard, type BoardError } from "../../actions/boards.ts"; | |
| 17 | import { boardRefusal, changeBoard, deleteBoard, duplicateBoard, listAllBoards, makeBoard, showBoard, type BoardError } from "../../actions/boards.ts"; | |
| 18 | 18 | import type { Ctx, Screen } from "../app/app.ts"; |
| 19 | 19 | import { binding } from "../app/keys.ts"; |
| 20 | 20 | import type { Session } from "../session.ts"; |
| @@ -79,11 +79,16 @@ export function boardLines(board: Board, at = -1, pinned: readonly string[] = [] | ||
| 79 | 79 | return lines; |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | function boardScreen(ctx: Ctx, f: Session, slug: string, board: Board): Screen { | |
| 82 | export function boardScreen(ctx: Ctx, f: Session, slug: string, first: Board): Screen { | |
| 83 | let board = first; | |
| 83 | 84 | const scroll = new Scroll(); |
| 84 | 85 | let at = board.panels.length > 0 ? 0 : -1; |
| 85 | 86 | let pinned: string[] = []; |
| 86 | 87 | let data: PanelData[] = []; |
| 88 | /** The panel `x` asked about (where it sat and its title), until `y` or `n`. */ | |
| 89 | let asking: { readonly index: number; readonly title: string } | undefined; | |
| 90 | // The gate is `changeBoard`'s, read once as the list's is. | |
| 91 | const mayEdit = f.user?.isAdmin === true && f.user.kind !== "agent"; | |
| 87 | 92 | let lines = boardLines(board, at, pinned); |
| 88 | 93 | const read = async (): Promise<void> => { |
| 89 | 94 | const listed = await listPinned(f.app, f.user); |
| @@ -107,21 +112,69 @@ function boardScreen(ctx: Ctx, f: Session, slug: string, board: Board): Screen { | ||
| 107 | 112 | await read(); |
| 108 | 113 | }); |
| 109 | 114 | }; |
| 115 | const reload = async (index: number): Promise<void> => { | |
| 116 | const shown = await showBoard(f.app, f.user, slug); | |
| 117 | if (shown.ok) { | |
| 118 | board = shown.value; | |
| 119 | at = Math.min(index, board.panels.length - 1); | |
| 120 | } | |
| 121 | await read(); | |
| 122 | }; | |
| 123 | /** | |
| 124 | * Remove the panel asked about as the web's Remove does; pins are left as `changeBoard` leaves them. | |
| 125 | * `changeBoard` removes by index, so the board is read again first: when another door moved or removed | |
| 126 | * panels and `index` no longer holds `title`, nothing is removed and the screen shows the board as it is. | |
| 127 | */ | |
| 128 | const drop = (index: number, title: string): void => | |
| 129 | runAsync(ctx, async () => { | |
| 130 | const now = await showBoard(f.app, f.user, slug); | |
| 131 | if (!now.ok) return void ctx.toast(refusal(now.error), "danger"); | |
| 132 | if (now.value.panels[index]?.title !== title) { | |
| 133 | await reload(index); | |
| 134 | return void ctx.toast("The board changed; nothing was removed.", "danger"); | |
| 135 | } | |
| 136 | const done = await changeBoard(f.app, f.user, slug, { kind: "remove", index }); | |
| 137 | await reload(index); | |
| 138 | ctx.toast(done.ok ? "panel removed" : refusal(done.error), done.ok ? "success" : "danger"); | |
| 139 | }); | |
| 110 | 140 | return { |
| 111 | 141 | title: plain(board.title), |
| 112 | keys: [ | |
| 113 | binding(["j", "k"], "scroll"), binding(["[", "]"], "panel"), binding(["p"], "pin / unpin"), | |
| 114 | binding(["<", ">"], "move pin"), binding(["esc", "q"], "back"), | |
| 115 | ], | |
| 142 | get keys() { | |
| 143 | return [ | |
| 144 | binding(["j", "k"], "scroll"), binding(["[", "]"], "panel"), binding(["p"], "pin / unpin"), | |
| 145 | binding(["<", ">"], "move pin"), | |
| 146 | ...(mayEdit && board.panels.length > 0 ? [binding(["x"], "remove panel")] : []), | |
| 147 | binding(["esc", "q"], "back"), | |
| 148 | ]; | |
| 149 | }, | |
| 116 | 150 | load: read, |
| 117 | 151 | render(area, s) { |
| 118 | 152 | const c = ctx.theme.c; |
| 153 | if (asking !== undefined) { | |
| 154 | s.text(area.x, area.y, truncate(`remove ${plain(asking.title)}? y/n`, area.w), style(c.fg, c.bg), area.x + area.w); | |
| 155 | area = { ...area, y: area.y + 1, h: Math.max(0, area.h - 1) }; | |
| 156 | } | |
| 119 | 157 | const spans = lines.map((text, i) => [{ text, style: style(i === 0 ? c.fg : c.mutedFg, c.bg) }]); |
| 120 | 158 | drawLines(s, area, c, scroll, spans); |
| 121 | 159 | hitScroll(ctx, area, scroll, lines.length); |
| 122 | 160 | }, |
| 123 | 161 | key(e) { |
| 162 | if (asking !== undefined) { | |
| 163 | const asked = asking; | |
| 164 | if (e.key === "y" || e.key === "n" || e.key === "esc") { | |
| 165 | asking = undefined; | |
| 166 | if (e.key === "y") drop(asked.index, asked.title); | |
| 167 | ctx.invalidate(); | |
| 168 | } | |
| 169 | return true; | |
| 170 | } | |
| 124 | 171 | if (scroll.key(e, lines.length)) return true; |
| 172 | if (e.key === "x") { | |
| 173 | const panel = board.panels[at]; | |
| 174 | if (mayEdit && panel !== undefined) asking = { index: at, title: panel.title }; | |
| 175 | ctx.invalidate(); | |
| 176 | return true; | |
| 177 | } | |
| 125 | 178 | if (e.key === "[" || e.key === "]") { |
| 126 | 179 | if (board.panels.length > 0) at = (at + (e.key === "]" ? 1 : board.panels.length - 1)) % board.panels.length; |
| 127 | 180 | lines = boardLines(board, at, pinned, data); |
test/egress-admin.test.ts +3
| @@ -93,6 +93,9 @@ describe("egress grants in the terminal", () => { | ||
| 93 | 93 | await settleUntil(h.tui, "granted by ada"); |
| 94 | 94 | expect(grants()).toEqual([ARM]); |
| 95 | 95 | await h.press("x"); |
| 96 | await settleUntil(h.tui, "Revoke"); | |
| 97 | expect(grants()).toEqual([ARM]); | |
| 98 | await h.press("y"); | |
| 96 | 99 | await settleUntil(h.tui, "asked for, not granted"); |
| 97 | 100 | expect(grants()).toEqual([]); |
| 98 | 101 | } finally { |
test/tui-board-panel-remove.test.ts added +157
| @@ -0,0 +1,157 @@ | ||
| 1 | /** | |
| 2 | * `x` on the terminal's board screen removes the marked panel after `y/n`, as | |
| 3 | * the web's Remove does through `changeBoard`; only a forge admin is offered it. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { listPinned } from "../src/actions/board-pins.ts"; | |
| 10 | import { deleteBoard, showBoard, writeBoard } from "../src/actions/boards.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createUser, type User } from "../src/core/users.ts"; | |
| 13 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 14 | import { boardScreen } from "../src/tui/screens/boards.ts"; | |
| 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | let dir: string; | |
| 22 | let app: App; | |
| 23 | let carol: User; | |
| 24 | let bob: User; | |
| 25 | ||
| 26 | const panel = (title: string) => ({ title, chart: "number" as const, query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }); | |
| 27 | const seed = async () => { | |
| 28 | const wrote = await writeBoard(app, carol, "runs", { | |
| 29 | title: "Runs this week", range: "7d", refresh: "off", panels: [panel("Alpha"), panel("Beta"), panel("Gamma")], | |
| 30 | }); | |
| 31 | if (!wrote.ok) throw new Error(JSON.stringify(wrote.error)); | |
| 32 | }; | |
| 33 | const titles = async (): Promise<string[]> => { | |
| 34 | const shown = await showBoard(app, carol, "runs"); | |
| 35 | return shown.ok ? shown.value.panels.map((p) => p.title) : []; | |
| 36 | }; | |
| 37 | ||
| 38 | async function drive(user: User, keys: string[]): Promise<string[]> { | |
| 39 | const shown = await showBoard(app, carol, "runs"); | |
| 40 | if (!shown.ok) throw new Error("no board"); | |
| 41 | const terminal = headlessTerminal(120, 30); | |
| 42 | const { app: tui, session } = createForgeApp(app, { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, { terminal, exitHooks: false }); | |
| 43 | tui.start(); | |
| 44 | tui.ctx.push(boardScreen(tui.ctx, session, "runs", shown.value)); | |
| 45 | await settleApp(tui); | |
| 46 | const seen: string[] = []; | |
| 47 | for (const key of keys) { | |
| 48 | terminal.feed(key); | |
| 49 | await settleApp(tui); | |
| 50 | seen.push(tui.screen.toText()); | |
| 51 | } | |
| 52 | tui.stop?.(); | |
| 53 | return seen; | |
| 54 | } | |
| 55 | ||
| 56 | beforeAll(async () => { | |
| 57 | dir = await mkdtemp(join(tmpdir(), "forge-tui-panel-remove-")); | |
| 58 | app = createApp({ dataDir: dir, port: 0 }); | |
| 59 | const mk = async (username: string, first = false) => { | |
| 60 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 61 | if (!r.ok) throw new Error(r.error); | |
| 62 | return r.value; | |
| 63 | }; | |
| 64 | carol = await mk("carol", true); | |
| 65 | bob = await mk("bob"); | |
| 66 | }, 30_000); | |
| 67 | ||
| 68 | afterAll(async () => { | |
| 69 | app.close(); | |
| 70 | await rm(dir, { recursive: true, force: true }); | |
| 71 | }); | |
| 72 | ||
| 73 | describe("tui board panel remove", () => { | |
| 74 | test("x then y removes the marked panel and nothing else", async () => { | |
| 75 | await seed(); | |
| 76 | const seen = await drive(carol, ["]", "x", "y"]); | |
| 77 | expect(seen[1]).toContain("remove Beta? y/n"); | |
| 78 | expect(await titles()).toEqual(["Alpha", "Gamma"]); | |
| 79 | expect(seen[2]).not.toContain("Beta"); | |
| 80 | expect(seen[2]).toContain("panel removed"); | |
| 81 | }); | |
| 82 | ||
| 83 | test("x then n, or esc, changes nothing", async () => { | |
| 84 | await seed(); | |
| 85 | const seen = await drive(carol, ["x", "n", "x", "esc"]); | |
| 86 | expect(seen[0]).toContain("remove Alpha? y/n"); | |
| 87 | expect(seen[1]).not.toContain("remove Alpha"); | |
| 88 | expect(await titles()).toEqual(["Alpha", "Beta", "Gamma"]); | |
| 89 | }); | |
| 90 | ||
| 91 | test("the key line offers x to an admin and not to anyone else", async () => { | |
| 92 | await seed(); | |
| 93 | expect((await drive(carol, ["j"]))[0]).toContain("x remove panel"); | |
| 94 | const other = await drive(bob, ["j", "x"]); | |
| 95 | expect(other[0]).not.toContain("remove panel"); | |
| 96 | expect(other[1]).not.toContain("remove Alpha"); | |
| 97 | expect(await titles()).toEqual(["Alpha", "Beta", "Gamma"]); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a mark on the last panel lands on the new last one", async () => { | |
| 101 | await seed(); | |
| 102 | const seen = await drive(carol, ["]", "]", "x", "y", "x"]); | |
| 103 | expect(await titles()).toEqual(["Alpha", "Beta"]); | |
| 104 | expect(seen[4]).toContain("remove Beta? y/n"); | |
| 105 | }); | |
| 106 | ||
| 107 | /** Open the screen as carol, press `before`, let another door `meanwhile` change the board, then press `after`. */ | |
| 108 | async function race(before: string[], meanwhile: () => Promise<unknown>, after: string[]): Promise<string> { | |
| 109 | const shown = await showBoard(app, carol, "runs"); | |
| 110 | if (!shown.ok) throw new Error("no board"); | |
| 111 | const terminal = headlessTerminal(120, 30); | |
| 112 | const { app: tui, session } = createForgeApp(app, { sshUsername: "carol", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: carol }, { terminal, exitHooks: false }); | |
| 113 | tui.start(); | |
| 114 | tui.ctx.push(boardScreen(tui.ctx, session, "runs", shown.value)); | |
| 115 | await settleApp(tui); | |
| 116 | for (const key of before) { terminal.feed(key); await settleApp(tui); } | |
| 117 | await meanwhile(); | |
| 118 | for (const key of after) { terminal.feed(key); await settleApp(tui); } | |
| 119 | const text = tui.screen.toText(); | |
| 120 | tui.stop?.(); | |
| 121 | return text; | |
| 122 | } | |
| 123 | const rewrite = (titles: string[]) => () => | |
| 124 | writeBoard(app, carol, "runs", { title: "Runs this week", range: "7d", refresh: "off", panels: titles.map(panel) }); | |
| 125 | ||
| 126 | test("a board shortened elsewhere removes nothing, says so and shows the board as it is", async () => { | |
| 127 | await seed(); | |
| 128 | const text = await race(["]", "]", "x"], rewrite(["Alpha"]), ["y"]); | |
| 129 | expect(text).toContain("The board changed; nothing was removed."); | |
| 130 | expect(text).toContain("1 panel"); | |
| 131 | expect(text).not.toContain("Gamma"); | |
| 132 | expect(await titles()).toEqual(["Alpha"]); | |
| 133 | }); | |
| 134 | ||
| 135 | test("panels reordered elsewhere after the prompt: y removes nothing rather than the wrong panel", async () => { | |
| 136 | await seed(); | |
| 137 | const text = await race(["]", "x"], rewrite(["Gamma", "Alpha", "Beta"]), ["y"]); | |
| 138 | expect(text).toContain("The board changed; nothing was removed."); | |
| 139 | expect(await titles()).toEqual(["Gamma", "Alpha", "Beta"]); | |
| 140 | }); | |
| 141 | ||
| 142 | test("a board deleted elsewhere is refused in the page's words", async () => { | |
| 143 | await seed(); | |
| 144 | const text = await race(["x"], () => deleteBoard(app, carol, "runs"), ["y"]); | |
| 145 | expect(text).toContain("That board is gone."); | |
| 146 | expect(text).toContain("Alpha"); | |
| 147 | }); | |
| 148 | ||
| 149 | test("a pinned panel that is removed leaves the screen working, and p pins its neighbour", async () => { | |
| 150 | await seed(); | |
| 151 | const seen = await drive(carol, ["p", "x", "y", "p"]); | |
| 152 | expect(await titles()).toEqual(["Beta", "Gamma"]); | |
| 153 | expect(seen[2]).toContain("Beta"); | |
| 154 | const pins = await listPinned(app, carol); | |
| 155 | expect(pins.ok && pins.value.map((p) => p.title)).toContain("Beta"); | |
| 156 | }); | |
| 157 | }); |
new above
offline
The prompt names a title but
changeBoardremoves by index. If another door reorders or removes panels while the screen holds the old board,ydeletes whichever panel now sits atindex. The new refusal test only covers the out-of-range case. Fix: re-read the board withshowBoardbefore the change and refuse with the page words ifpanels[index]?.titleno longer equals the title asked about. Remember the asked title inaskingand pass it todrop. Then add a test where another write swaps the panel order, pressxtheny, and assert nothing is removed. Also tell the user the board changed, and reload the screen.