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

Merged opened this

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

Merged

scout/forge:scout/7990-the-terminal-s-board-screen-removes-the → main 5 commits fork 1 thread · 1 unresolved

  • Merged. scout/7990-the-terminal-s-board-screen-removes-the was merged into main as 16fae75.

Review

Sign in to review this change.
4 files changed +220 −7
docs/STATUS.md +1 −1
@@ -60,7 +60,7 @@
6060 `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit
6161 by the admin who made it, and a file that does not validate is not shown. Make, rename,
6262 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.
6464 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):
6565 - the page (Pin to home / Unpin on `/boards/:slug`, Up / Down / Unpin on home);
6666 - 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";
1414 import { panelData, type PanelData } from "../../boards/panel_data.ts";
1515 import type { Board } from "../../boards/store.ts";
1616 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";
1818 import type { Ctx, Screen } from "../app/app.ts";
1919 import { binding } from "../app/keys.ts";
2020 import type { Session } from "../session.ts";
@@ -79,11 +79,16 @@ export function boardLines(board: Board, at = -1, pinned: readonly string[] = []
7979 return lines;
8080 }
8181
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;
8384 const scroll = new Scroll();
8485 let at = board.panels.length > 0 ? 0 : -1;
8586 let pinned: string[] = [];
8687 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";
8792 let lines = boardLines(board, at, pinned);
8893 const read = async (): Promise<void> => {
8994 const listed = await listPinned(f.app, f.user);
@@ -107,21 +112,69 @@ function boardScreen(ctx: Ctx, f: Session, slug: string, board: Board): Screen {
107112 await read();
108113 });
109114 };
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;
outdated

The prompt names a title but changeBoard removes by index. If another door reorders or removes panels while the screen holds the old board, y deletes whichever panel now sits at index. The new refusal test only covers the out-of-range case. Fix: re-read the board with showBoard before the change and refuse with the page words if panels[index]?.title no longer equals the title asked about. Remember the asked title in asking and pass it to drop. Then add a test where another write swaps the panel order, press x then y, and assert nothing is removed. Also tell the user the board changed, and reload the screen.

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 });
110140 return {
111141 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 },
116150 load: read,
117151 render(area, s) {
118152 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 }
119157 const spans = lines.map((text, i) => [{ text, style: style(i === 0 ? c.fg : c.mutedFg, c.bg) }]);
120158 drawLines(s, area, c, scroll, spans);
121159 hitScroll(ctx, area, scroll, lines.length);
122160 },
123161 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 }
124171 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 }
125178 if (e.key === "[" || e.key === "]") {
126179 if (board.panels.length > 0) at = (at + (e.key === "]" ? 1 : board.panels.length - 1)) % board.panels.length;
127180 lines = boardLines(board, at, pinned, data);
test/egress-admin.test.ts +3
@@ -93,6 +93,9 @@ describe("egress grants in the terminal", () => {
9393 await settleUntil(h.tui, "granted by ada");
9494 expect(grants()).toEqual([ARM]);
9595 await h.press("x");
96 await settleUntil(h.tui, "Revoke");
97 expect(grants()).toEqual([ARM]);
98 await h.press("y");
9699 await settleUntil(h.tui, "asked for, not granted");
97100 expect(grants()).toEqual([]);
98101 } 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs