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

Any /boards panel can be pinned to home, and pinned panels show under the KPIs in the owner's order #2913

Merged opened this

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

Merged

scout/forge:scout/2741-any-boards-panel-can-be-pinned-to-home-a → main 6 commits fork 5 threads · 5 unresolved

  • Merged. scout/2741-any-boards-panel-can-be-pinned-to-home-a was merged into main as f1f6d29.

Review

Sign in to review this change.
17 files changed +592 −20
README.md +5
@@ -1574,6 +1574,11 @@ work here.
15741574 `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit
15751575 by the admin who made it, and a file that does not validate is not shown. Make, rename,
15761576 duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`.
1577 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):
1578 - the page (Pin to home / Unpin on `/boards/:slug`, Up / Down / Unpin on home);
1579 - 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;
1580 - `GET /api/v1/boards/pins` (the list, in order) and `POST /api/v1/boards/pins` with `{"op":"pin|unpin|move","slug":"…","title":"…","by":-1|1}` (`by` for move only);
1581 - the `board_pins` MCP tool (no `op` lists; `op` pin, unpin or move changes). `pins` is therefore not a board slug.
15771582 The first boot also writes seven starting boards, **Usage**, **Crew**, **Releases**, **Apps**, **Security**, **Throughput** and **Forges**. Apps' per-app panels show repo ids until a source exposes a safe name; Security (sign-ins, failed sign-ins, tokens by scope, admin actions, from the admin-only `signins`, `tokens` and `adminActions` sources and `usage`) shows no token text, hash, payload or IP; Throughput is items entering each stage from issue to live, per hour, from the `stages` source. Each is written once: `.offered` in that repo records them, so an edit stays and a deletion sticks.
15781583 A live board (any `refresh` but `off`; the form offers off or on, and `10s` and `1m` are still accepted and mean the same) redraws its grid when a run, pull, check, issue or usage event arrives, through the live-region swap
15791584 (`/boards/:slug/live`, etag'd, one fetch per burst). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug`
src/actions/board-pins.ts added +73
@@ -0,0 +1,73 @@
1 /**
2 * Pinned panels, for a forge admin who is a person (anyone else is told
3 * `not_found`, as for `showBoard`): pin a board's panel to home, unpin it,
4 * move it one step in the owner's order, list what is pinned. The web
5 * forms, the terminal, `/api/v1/boards/pins` and the `board_pins` MCP tool
6 * all come through here. A pin counts toward the limit only while its panel
7 * still exists: a pin whose board was deleted or panel renamed is dropped
8 * the next time its owner pins.
9 */
10 import type { App } from "../app.ts";
11 import { MAX_PINS, listPins, movePin, pin, pinOf, unpin, type Pin } from "../boards/pins.ts";
12 import { readBoard, type Board, type Panel } from "../boards/store.ts";
13 import type { User } from "../core/users.ts";
14 import { err, ok, type Result } from "../kernel/result.ts";
15
16 export type PinError = "not_found" | "invalid" | "too_many";
17 export type Pinned = Pin & { board: Board; panel: Panel };
18
19 const person = (actor: User | undefined): User | undefined =>
20 actor?.isAdmin === true && actor.kind !== "agent" ? actor : undefined;
21
22 /** The actor's pins that still name a panel, in their order, with the board and panel. */
23 async function resolved(app: App, who: User): Promise<{ live: Pinned[]; gone: Pin[] }> {
24 const boards = new Map<string, Board | undefined>();
25 const live: Pinned[] = [];
26 const gone: Pin[] = [];
27 for (const p of listPins(app.db, who.id)) {
28 if (!boards.has(p.slug)) boards.set(p.slug, await readBoard(app.reposRoot, p.slug));
29 const board = boards.get(p.slug);
30 const panel = board?.panels.find((x) => x.title === p.title);
31 if (board && panel) live.push({ ...p, board, panel });
32 else gone.push(p);
33 }
34 return { live, gone };
35 }
36
37 export async function listPinned(app: App, actor: User | undefined): Promise<Result<Pinned[], "not_found">> {
38 const who = person(actor);
39 return who ? ok((await resolved(app, who)).live) : err("not_found");
40 }
41
42 export async function pinPanel(app: App, actor: User | undefined, slug: unknown, title: unknown): Promise<Result<Pin, PinError>> {
43 const who = person(actor);
44 if (!who) return err("not_found");
45 const which = pinOf(slug, title);
46 if (!which) return err("invalid");
47 const board = await readBoard(app.reposRoot, which.slug);
48 if (!board?.panels.some((p) => p.title === which.title)) return err("not_found");
49 const { live, gone } = await resolved(app, who);
50 for (const g of gone) unpin(app.db, who.id, g);
51 if (live.length >= MAX_PINS && !live.some((p) => p.slug === which.slug && p.title === which.title)) return err("too_many");
52 pin(app.db, who.id, which);
53 return ok(which);
54 }
55
56 export function unpinPanel(app: App, actor: User | undefined, slug: unknown, title: unknown): Result<Pin, PinError> {
57 const who = person(actor);
58 if (!who) return err("not_found");
59 const which = pinOf(slug, title);
60 if (!which) return err("invalid");
61 unpin(app.db, who.id, which);
62 return ok(which);
63 }
64
65 export function movePinned(app: App, actor: User | undefined, slug: unknown, title: unknown, by: unknown): Result<Pin, PinError> {
66 const who = person(actor);
67 if (!who) return err("not_found");
68 const which = pinOf(slug, title);
69 if (!which || (by !== 1 && by !== -1)) return err("invalid");
70 if (!listPins(app.db, who.id).some((x) => x.slug === which.slug && x.title === which.title)) return err("not_found");
outdated

This returns ok(which) even when the panel is not pinned, because movePin does nothing in that case. The page then says "Moved.", the API answers 200 and the terminal says "moved on home". Return err("not_found") when the pin is not in listPins(app.db, who.id).

71 movePin(app.db, who.id, which, by);
72 return ok(which);
73 }
src/boards/pins.ts added +59
@@ -0,0 +1,59 @@
1 /**
2 * Pinned panels: a person's own list of panels from /boards that home draws
3 * under the KPI row. A pin names a board's slug and a panel's title and sits
4 * at a position in its owner's order; it is only ever read or changed with
5 * its owner's id, so one person's pins are never another's.
6 */
7 import type { Database } from "bun:sqlite";
8 import { isSlug, MAX_TITLE } from "./store.ts";
9
10 export type Pin = { slug: string; title: string };
11 export const MAX_PINS = 24;
12
13 /** A pin as a form says it, judged: the plain value or undefined. */
14 export function pinOf(slug: unknown, title: unknown): Pin | undefined {
15 if (!isSlug(slug) || typeof title !== "string") return undefined;
16 if (title.trim() === "" || title.length > MAX_TITLE || /[\u0000-\u001f\u007f]/.test(title)) return undefined;
17 return { slug, title: title.trim() };
18 }
19
20 /** The owner's pins in their order. */
21 export function listPins(db: Database, userId: string): Pin[] {
22 return db
23 .query<Pin, [string]>("SELECT slug, title FROM board_pin WHERE user_id = ? ORDER BY pos, rowid")
24 .all(userId);
25 }
26
27 export const isPinned = (pins: readonly Pin[], slug: string, title: string) =>
28 pins.some((p) => p.slug === slug && p.title === title);
29
30 /** Pin at the end of the owner's order; false when they already hold the most. Pinning twice changes nothing. */
31 export function pin(db: Database, userId: string, p: Pin): boolean {
32 const have = listPins(db, userId);
33 if (isPinned(have, p.slug, p.title)) return true;
34 if (have.length >= MAX_PINS) return false;

have includes pins whose board or panel is gone. Home skips those, so they can't be unpinned from anywhere, yet they still count toward MAX_PINS. Count only pins that still resolve, or remove pin rows when the board is deleted or the panel is renamed or removed.

35 db.run("INSERT INTO board_pin (user_id, slug, title, pos) VALUES (?, ?, ?, ?)", [userId, p.slug, p.title, have.length]);
36 return true;
37 }
38
39 export function unpin(db: Database, userId: string, p: Pin): void {
40 db.run("DELETE FROM board_pin WHERE user_id = ? AND slug = ? AND title = ?", [userId, p.slug, p.title]);
41 renumber(db, userId, listPins(db, userId));
42 }
43
44 /** Move one step earlier (-1) or later (1); the ends stay put. */
45 export function movePin(db: Database, userId: string, p: Pin, by: -1 | 1): void {
46 const list = listPins(db, userId);
47 const i = list.findIndex((x) => x.slug === p.slug && x.title === p.title);
48 const j = i + by;
49 if (i < 0 || j < 0 || j >= list.length) return;
50 [list[i], list[j]] = [list[j]!, list[i]!];
51 renumber(db, userId, list);
52 }
53
54 function renumber(db: Database, userId: string, list: readonly Pin[]): void {
55 db.transaction(() => {
56 list.forEach((x, pos) =>
57 db.run("UPDATE board_pin SET pos = ? WHERE user_id = ? AND slug = ? AND title = ?", [pos, userId, x.slug, x.title]));
58 })();
59 }
src/boards/store.ts +1 −1
@@ -49,7 +49,7 @@ const span = (v: unknown, max: number): v is number =>
4949 typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= max;
5050
5151 /** Names that belong to a page or an API door of their own, never a board's. */
52 const TAKEN = new Set(["data", "sources", "query"]);
52 const TAKEN = new Set(["data", "sources", "query", "pins"]);
5353
5454 /** A slug is a file name: lowercase, short, and never one of `TAKEN`. */
5555 export const isSlug = (s: unknown): s is string =>
src/core/migrations/20261019180000-board-pins.ts added +14
@@ -0,0 +1,14 @@
1 // A panel on /boards can be pinned to its owner's home (#2741). A pin is the
2 // owner, the board's slug and the panel's title; `pos` is the owner's order.
3 // Deleting the owner deletes their pins. A pin whose board or panel has since
4 // gone is kept and simply not drawn.
5 export default `
6 CREATE TABLE board_pin (
7 user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
8 slug TEXT NOT NULL,
9 title TEXT NOT NULL,
10 pos INTEGER NOT NULL,
11 PRIMARY KEY (user_id, slug, title)
12 );
13 CREATE INDEX board_pin_order ON board_pin (user_id, pos);
14 `;
src/http/api.ts +35
@@ -16,6 +16,7 @@ import { storageLimitMessage } from "../core/storage.ts";
1616 import { voteVariant } from "../actions/variants.ts";
1717 import { parentForgeFor } from "../core/parent_releases.ts";
1818 import { readFlow } from "../flow/read.ts";
19 import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel, type PinError } from "../actions/board-pins.ts";
1920 import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../actions/boards.ts";
2021 import { runQuery } from "../boards/query.ts";
2122 import { recordDrill } from "../core/domain_drills.ts";
@@ -1494,6 +1495,40 @@ export function apiRoutes(router: Router<Handler>): void {
14941495 : notFound();
14951496 });
14961497
1498 // The signed-in admin's pins for home: GET lists them in order, POST
1499 // {op: pin|unpin|move, slug, title, by?} changes them. Above `:slug`.
1500 router.add("GET", "/api/v1/boards/pins", async (ctx) => {
1501 const denied = boardsAdmin(ctx);
1502 if (denied) return denied;
1503 const listed = await listPinnedPanels(ctx.app, ctx.auth.user);
1504 return listed.ok ? json({ pins: listed.value.map((p) => ({ slug: p.slug, title: p.title, panel: p.panel })) }) : notFound();
1505 });
1506
1507 router.add("POST", "/api/v1/boards/pins", async (ctx) => {
1508 const denied = boardsAdmin(ctx) ?? requireAccount(ctx, { adminToken: true });
1509 if (denied) return denied;
1510 const bytes = await readBounded(ctx.request, bodyCapFor(ctx.request));
1511 if (bytes === "too_large") return apiError("too_large", 413, "that is too large to read here");
1512 let input: Record<string, unknown>;
1513 try {
1514 const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
1515 if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
1516 input = parsed as Record<string, unknown>;
1517 } catch {
1518 return apiError("bad_json", 400, "the body must be a JSON object");
1519 }
1520 const { op, slug, title, by } = input;
1521 const done =
1522 op === "pin" ? await pinPanel(ctx.app, ctx.auth.user, slug, title)
1523 : op === "unpin" ? unpinPanel(ctx.app, ctx.auth.user, slug, title)
1524 : op === "move" ? movePinned(ctx.app, ctx.auth.user, slug, title, by)
1525 : undefined;
1526 if (!done) return apiError("invalid", 422, "op must be pin, unpin or move");
1527 if (done.ok) return json({ ...done.value });
1528 const e: PinError = done.error;
1529 return e === "not_found" ? notFound() : apiError(e, 422, e === "too_many" ? "too many pins" : "that is not a panel");
1530 });
1531
14971532 router.add("GET", "/api/v1/boards/:slug", async (ctx) => {
14981533 const denied = boardsAdmin(ctx);
14991534 if (denied) return denied;
src/http/mcp.ts +32
@@ -186,6 +186,7 @@ import type { Router } from "./router.ts";
186186 import { mayReadRuns, runForReader } from "../actions/runs.ts";
187187 import { manifestFor } from "../actions/here.ts";
188188 import { clientBucket } from "./peer.ts";
189 import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel } from "../actions/board-pins.ts";
189190 import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../actions/boards.ts";
190191 import { fileReport } from "../actions/reports.ts";
191192 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
@@ -2974,6 +2975,37 @@ const TOOLS: readonly Tool[] = [
29742975 return done.ok ? good({ slug: str(args["slug"]), ...done.value }) : boardFailed(done.error);
29752976 },
29762977 },
2978 {
2979 name: "board_pins",

No test calls this tool; the MCP test only checks that the name is listed. Add a call test covering list, pin, move and unpin as an admin who is a person, plus an agent being refused.

2980 description:
2981 "The panels pinned to the caller's own home, for a forge admin who is a person (an agent is refused). With no `op`, lists them in order. With `op` pin, unpin or move, changes them: `slug` and `title` name a panel of a board; `by` is -1 or 1 for move. The same as GET and POST /api/v1/boards/pins.",
2982 inputSchema: {
2983 type: "object",
2984 properties: {
2985 op: { type: "string", enum: ["pin", "unpin", "move"] },
2986 slug: { type: "string", maxLength: 40 },
2987 title: { type: "string", maxLength: 80 },
2988 by: { type: "integer", enum: [-1, 1] },
2989 },
2990 },
2991 writes: true,
2992 async run(ctx, user, args) {
2993 if (!mayBoards(user)) return boardRefused();
2994 const op = args["op"];
2995 if (op === undefined) {
2996 const listed = await listPinnedPanels(ctx.app, user);
2997 return listed.ok ? good({ pins: listed.value.map((p) => ({ slug: p.slug, title: p.title })) }) : boardRefused();
2998 }
2999 const [slug, title] = [args["slug"], args["title"]];
3000 const done =
3001 op === "pin" ? await pinPanel(ctx.app, user, slug, title)
3002 : op === "unpin" ? unpinPanel(ctx.app, user, slug, title)
3003 : op === "move" ? movePinned(ctx.app, user, slug, title, args["by"])
3004 : undefined;
3005 if (!done) return bad("invalid", "op must be pin, unpin or move");
3006 return done.ok ? good({ ...done.value }) : bad(done.error, "that could not be pinned");
3007 },
3008 },
29773009 {
29783010 name: "board_delete",
29793011 description:
src/http/server.ts +2 −1
@@ -25,7 +25,7 @@ import { resiliencePage } from "../web/pages/resilience.ts";
2525 import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
2626 import { measureSubmit } from "./measure.ts";
2727 import { boardsPage } from "../web/pages/boards.ts";
28 import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts";
28 import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit.ts";
2929 import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts";
3030 import {
3131 closeStreamsOf,
@@ -830,6 +830,7 @@ export function buildRouter(): Router<Handler> {
830830 router.add("GET", "/boards/data", boardsPage);
831831 router.add("POST", "/boards/new", boardNew);
832832 router.add("POST", "/boards/pin", boardPin);
833 for (const op of ["pin-panel", "unpin-panel", "move-pin"]) router.add("POST", `/boards/${op}`, pinPost);
833834 router.add("GET", "/boards/:slug", boardShow);
834835 router.add("GET", "/boards/:slug/live", boardLive);
835836 router.add("POST", "/boards/:slug/:op", boardPost);
src/tui/screens/boards.ts +46 −11
@@ -3,11 +3,13 @@
33 * keeps, then one board's panels as text. It reads through the same
44 * `listAllBoards` and `showBoard` the page and the API do, so the gate is the
55 * action's (an agent or anyone else is told nothing); the palette offers the
6 * door only to that person. Read-only: a chart is not drawn, a panel is said
7 * as its title, chart, size and the query that feeds it.
6 * door only to that person. Reading only, but for pins: a chart is not drawn, a panel is said
7 * as its title, chart, size and the query that feeds it. `[`/`]` mark a panel,
8 * `p` pins or unpins it to home and `<`/`>` move a pinned one (`actions/board-pins.ts`).
89 */
910
1011 import type { Board } from "../../boards/store.ts";
12 import { listPinned, movePinned, pinPanel, unpinPanel } from "../../actions/board-pins.ts";
1113 import { listAllBoards, showBoard } from "../../actions/boards.ts";
1214 import type { Ctx, Screen } from "../app/app.ts";
1315 import { binding } from "../app/keys.ts";
@@ -19,7 +21,7 @@ import { truncate } from "../term/width.ts";
1921 import { ListState, Scroll, drawList, drawLines, hitList, hitScroll, metaOf, plural, runAsync, type ListRow } from "./common.ts";
2022
2123 /** One line of plain text: a title or a query value may carry escapes the terminal must not obey. */
22 const plain = (text: string): string =>
24 export const plain = (text: string): string =>
2325 // eslint-disable-next-line no-control-regex
2426 text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
2527
@@ -39,27 +41,53 @@ export async function boardRows(session: Session): Promise<BoardRow[]> {
3941 }));
4042 }
4143
42 /** A board, panel by panel, as lines of text. */
43 export function boardLines(board: Board): string[] {
44 /** A board, panel by panel, as lines of text; the panel at `at` is marked, and a pinned one says so. */
45 export function boardLines(board: Board, at = -1, pinned: readonly string[] = []): string[] {
4446 const lines = [
4547 plain(board.title),
4648 `range ${board.range} · refresh ${board.refresh} · ${plural(board.panels.length, "panel")}`,
4749 ];
48 for (const p of board.panels) {
50 for (const [i, p] of board.panels.entries()) {
4951 const query = Object.entries(p.query)
5052 .map(([k, v]) => `${plain(k)}=${plain(typeof v === "string" ? v : JSON.stringify(v))}`)
5153 .join(" ");
52 lines.push("", plain(p.title), ` ${p.chart} · ${p.w}×${p.h}`, ` ${query === "" ? "no query" : query}`);
54 lines.push("", `${i === at ? "▸ " : ""}${plain(p.title)}${pinned.includes(p.title) ? " [pinned to home]" : ""}`, ` ${p.chart} · ${p.w}×${p.h}`, ` ${query === "" ? "no query" : query}`);
5355 }
5456 return lines;
5557 }
5658
57 function boardScreen(ctx: Ctx, board: Board): Screen {
59 function boardScreen(ctx: Ctx, f: Session, slug: string, board: Board): Screen {
5860 const scroll = new Scroll();
59 const lines = boardLines(board);
61 let at = board.panels.length > 0 ? 0 : -1;
62 let pinned: string[] = [];
63 let lines = boardLines(board, at, pinned);
64 const read = async (): Promise<void> => {
65 const listed = await listPinned(f.app, f.user);
66 pinned = listed.ok ? listed.value.filter((p) => p.slug === slug).map((p) => p.title) : [];
67 lines = boardLines(board, at, pinned);
68 ctx.invalidate();
69 };
70 /** Pin the marked panel, or unpin it when it is; `by` moves a pinned one in the home order. */
71 const act = (by?: -1 | 1): void => {
72 const panel = board.panels[at];
73 if (panel === undefined) return;
74 runAsync(ctx, async () => {
75 const there = pinned.includes(panel.title);
76 const done =
77 by !== undefined ? movePinned(f.app, f.user, slug, panel.title, by)

< / > on an unpinned panel toasts "moved on home" (see movePinned). This path also has no test; add one beside the p test.

78 : there ? unpinPanel(f.app, f.user, slug, panel.title)
79 : await pinPanel(f.app, f.user, slug, panel.title);
80 ctx.toast(done.ok ? (by !== undefined ? "moved on home" : there ? "unpinned" : "pinned to home") : done.error === "too_many" ? "too many pins" : done.error === "not_found" && by !== undefined ? "that panel is not pinned" : "could not pin that", done.ok ? "success" : "danger");
81 await read();
82 });
83 };
6084 return {
6185 title: plain(board.title),
62 keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")],
86 keys: [
87 binding(["j", "k"], "scroll"), binding(["[", "]"], "panel"), binding(["p"], "pin / unpin"),
88 binding(["<", ">"], "move pin"), binding(["esc", "q"], "back"),
89 ],
90 load: read,
6391 render(area, s) {
6492 const c = ctx.theme.c;
6593 const spans = lines.map((text, i) => [{ text, style: style(i === 0 ? c.fg : c.mutedFg, c.bg) }]);
@@ -68,6 +96,13 @@ function boardScreen(ctx: Ctx, board: Board): Screen {
6896 },
6997 key(e) {
7098 if (scroll.key(e, lines.length)) return true;
99 if (e.key === "[" || e.key === "]") {
100 if (board.panels.length > 0) at = (at + (e.key === "]" ? 1 : board.panels.length - 1)) % board.panels.length;
101 lines = boardLines(board, at, pinned);
102 return true;
103 }
104 if (e.key === "p") return act(), true;
105 if (e.key === "<" || e.key === ">") return act(e.key === ">" ? 1 : -1), true;
71106 if (e.key === "esc" || e.key === "q") {
72107 ctx.pop();
73108 return true;
@@ -90,7 +125,7 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen {
90125 runAsync(ctx, async () => {
91126 const shown = await showBoard(f.app, f.user, row.slug);
92127 if (!shown.ok) ctx.toast("That board is gone", "default");
93 else ctx.push(boardScreen(ctx, shown.value));
128 else ctx.push(boardScreen(ctx, f, row.slug, shown.value));
94129 });
95130 };
96131 return {
src/tui/screens/dashboard.ts +17 −1
@@ -24,6 +24,7 @@ import { nextStepsFor } from "../../core/next-steps.ts";
2424 import { agentsLine } from "../../web/studio/client_home.ts";
2525 import { mayView } from "../../core/sealed.ts";
2626 import { applyFleetIds, previewFleetIds, undoFleetOp } from "../../actions/fleet.ts";
27 import { plain } from "./boards.ts";
2728 import { pinRepo, unpinRepo } from "../../actions/origins.ts";
2829 import { getProposal } from "../../core/proposals.ts";
2930 import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts";
@@ -90,6 +91,7 @@ import { BOOTED } from "../../release/gate.ts";
9091 import { running } from "../../kernel/version.ts";
9192 import { deciderLine, deciderScreen } from "./decider.ts";
9293 import { issueScreen } from "./issue.ts";
94 import { listPinned } from "../../actions/board-pins.ts";
9395 import { newRepoScreen } from "./newrepo.ts";
9496 import { pullScreen } from "./pull.ts";
9597 import { repoScreen } from "./repo.ts";
@@ -162,6 +164,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
162164 let decider: string | null = null;
163165 /** Home's three figures, as the web's row says them; empty unless an admin looks (they count every repository). */
164166 let figures: string[] = [];
167 /** The panels this admin pinned to the web home, by title and board, in their order. */
168 let panelLines: string[] = [];
165169 const filter = new Filter();
166170 /** One cursor per pane: moving in one must never move another. */
167171 const cursors: Record<PaneId, ListState> = {
@@ -186,6 +190,13 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
186190 ctx.invalidate();
187191 };
188192
193 const loadPanelLines = async (): Promise<void> => {
194 const listed = await listPinned(f.app, f.user);
195 const titles = listed.ok ? listed.value.map((p) => `${plain(p.panel.title)} (${plain(p.board.title)})`) : [];
196 panelLines = titles.length === 0 ? [] : [`Pinned: ${titles.join(" · ")}`];
197 ctx.invalidate();
198 };
199
189200 const reload = (): void => {
190201 // The server remembers the *choice* and nothing about the laptop, so
191202 // this is a list of decisions — never a status that would be stale.
@@ -761,7 +772,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
761772 // functions of the outbox, so one subscription refreshes all of them.
762773 live(f, signal, {}, reload);
763774 reload();
764 return loadClientLines();
775 return Promise.all([loadClientLines(), loadPanelLines()]).then(() => undefined);
765776 },
766777 render(area, s) {
767778 const c = ctx.theme.c;
@@ -792,6 +803,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
792803 );
793804 body = rest!;
794805 }
806 if (panelLines.length > 0) {
807 const [strip, rest] = split(body, "v", [len(1), fill(1)]);
808 s.text(strip!.x, strip!.y, truncate(panelLines[0]!, strip!.w), style(c.mutedFg, c.bg), strip!.x + strip!.w);
809 body = rest!;
810 }
795811 if (decider !== null) {
796812 const [strip, rest] = split(body, "v", [len(1), fill(1)]);
797813 s.text(
src/web/pages/boards-edit.ts +25 −1
@@ -9,10 +9,11 @@ import {
99 changeBoard, deleteBoard, duplicateBoard, makeBoard, type BoardError,
1010 } from "../../actions/boards.ts";
1111 import type { Edit } from "../../boards/edit.ts";
12 import { movePinned, pinPanel, unpinPanel } from "../../actions/board-pins.ts";
1213 import { isSlug } from "../../boards/store.ts";
1314 import type { Ctx } from "../../http/context.ts";
1415 import { notFoundPage } from "../chrome.ts";
15 import { field, readForm, type Form } from "../form.ts";
16 import { backTo, field, readForm, type Form } from "../form.ts";
1617 import { flashTo } from "../layout.ts";
1718 import { PANEL_FIELDS, readParams, toQuery } from "./boards.ts";
1819
@@ -99,3 +100,26 @@ export async function boardPost(ctx: Ctx): Promise<Response> {
99100 const changed = await changeBoard(ctx.app, ctx.auth.user, slug, edit);
100101 return changed.ok ? done(ctx, back, "Saved.") : failed(ctx, back, changed.error);
101102 }
103
104 /**
105 * POST /boards/pin-panel, /boards/unpin-panel, /boards/move-pin — the
106 * signed-in admin's own pins for home, through `actions/board-pins.ts`.
107 */
108 export async function pinPost(ctx: Ctx): Promise<Response> {
109 const form = await readForm(ctx, "admin");
outdated

This is the only door to pins. Move this logic into an action. Then call it from here, from the terminal (tui/screens/boards.ts), from /api/v1/boards/pins and from an MCP tool, so the terminal, the API and MCP can do what the page does.

110 if (form instanceof Response) return form;
111 const op = ctx.url.pathname.slice("/boards/".length);
112 const [slug, title] = [field(form, "slug"), field(form, "title")];
113 const back = backTo(form, "/");
114 const by = num(field(form, "by"));
115 const run = {
116 "pin-panel": () => pinPanel(ctx.app, ctx.auth.user, slug, title),
117 "unpin-panel": () => unpinPanel(ctx.app, ctx.auth.user, slug, title),
118 "move-pin": () => movePinned(ctx.app, ctx.auth.user, slug, title, by),
119 }[op];
120 if (!run) return notFoundPage(ctx);
121 const done = await run();
122 if (done.ok) return flashTo(ctx, back, { message: { "pin-panel": "Pinned to home.", "unpin-panel": "Unpinned.", "move-pin": "Moved." }[op]! });
123 if (done.error === "not_found") return notFoundPage(ctx);
124 return flashTo(ctx, back, { tone: "danger", message: done.error === "too_many" ? "That is too many pins." : "That is not a panel." });
125 }
src/web/pages/boards-view.ts +16 −3
@@ -19,6 +19,7 @@ import { html, raw, when, type Html } from "../html.ts";
1919 import { fragment, page } from "../layout.ts";
2020 import { btn, card, empty } from "../ui.ts";
2121 import { flowPanel, flowPanelCss } from "./flow-panel.ts";
22 import { isPinned, listPins } from "../../boards/pins.ts";
2223 import { cellsOf, chartBox, paramsOf, queryFields, readParams, runFor } from "./boards.ts";
2324
2425 const RANGE_MS = { "1h": 3_600_000, "24h": 86_400_000, "7d": 604_800_000, "30d": 2_592_000_000 } as const;
@@ -34,7 +35,7 @@ function ranged(query: Record<string, unknown>, range: Board["range"]): Record<s
3435 return { ...query, from: new Date(Date.now() - RANGE_MS[range]).toISOString() };
3536 }
3637
37 function drawPanel(app: App, board: Board, panel: Panel): Html {
38 export function drawPanel(app: App, board: Board, panel: Panel, aside: Html | null = null): Html {
3839 const answer = runFor(app.db, panel.chart, ranged(panel.query, board.range));
3940 const q = paramsOf(panel.query, panel.chart);
4041 const body = !answer.ok
@@ -45,6 +46,7 @@ function drawPanel(app: App, board: Board, panel: Panel): Html {
4546 <div class="card-head"><h2>${panel.title}</h2>
4647 <a class="muted small" href="/boards/data?${explore.toString()}">explore</a></div>
4748 <div class="card-body">${body}</div>
49 ${aside}
4850 </div>`;
4951 }
5052
@@ -115,12 +117,23 @@ function panelForm(slug: string, op: string, p: Panel | undefined, index: number
115117 </form>`;
116118 }
117119
118 function manage(slug: string, board: Board, adding: Html): Html {
120 /** Pin to home / Unpin: one same-origin POST, back to this board. */
121 function pinForm(slug: string, title: string, pinned: boolean): Html {
122 const verb = pinned ? "Unpin" : "Pin to home";
123 return html`<form method="post" action="/boards/${pinned ? "unpin-panel" : "pin-panel"}" class="row-actions">
124 <input type="hidden" name="slug" value="${slug}" /><input type="hidden" name="title" value="${title}" />
125 <input type="hidden" name="back" value="/boards/${slug}" />
126 ${btn(verb, { size: "sm", label: `${verb}: ${title}` })}
127 </form>`;
128 }
129
130 function manage(slug: string, board: Board, adding: Html, pins: ReturnType<typeof listPins>): Html {
119131 const one = (p: Panel, i: number) => html`<li class="row bmanage">
120132 <details>
121133 <summary><strong>${p.title}</strong> <span class="muted small">${CHART_NAMES[p.chart]} · ${String(p.w)}×${String(p.h)}</span></summary>
122134 ${panelForm(slug, "edit", p, i, queryFields(paramsOf(p.query, p.chart)))}
123135 </details>
136 ${pinForm(slug, p.title, isPinned(pins, slug, p.title))}
124137 <form method="post" action="/boards/${slug}/move" class="row-actions">
125138 <input type="hidden" name="index" value="${String(i)}" />
126139 ${btn("Up", { size: "sm", name: "by", value: "-1", label: `Move ${p.title} up` })}
@@ -167,7 +180,7 @@ export async function boardShow(ctx: Ctx): Promise<Response> {
167180 body: html`<div class="page-head"><h1>${board.title}</h1>
168181 ${btn("All boards", { href: "/boards", variant: "ghost" })}</div>
169182 ${grid(ctx.app, slug, board)}
170 ${manage(slug, board, queryFields(readParams(ctx.url.searchParams)))}`,
183 ${manage(slug, board, queryFields(readParams(ctx.url.searchParams)), listPins(ctx.app.db, ctx.auth.user?.id ?? ""))}`,
171184 scriptJs: liveRegionsJs,
172185 });
173186 }
src/web/studio/home.ts +2
@@ -49,6 +49,7 @@ import { needsYouSection } from "./asks_page.ts";
4949 import { flowCards } from "./flow_card.ts";
5050 import { composer, feedJs, feedNote, feedRegion } from "./feed.ts";
5151 import { kpiRow } from "./kpi_row.ts";
52 import { pinnedPanels } from "./pinned.ts";
5253 import { homeTabs } from "./home_tabs.ts";
5354 import { crewKeyNote } from "./crew.ts";
5455 import { everydayCard } from "../pages/everyday.ts";
@@ -249,6 +250,7 @@ export async function studioHome(
249250 const kpiOpts = ceilingUsd === undefined ? {} : { ceilingUsd };
250251 const body = html`<h1 class="sr-only">Home</h1>
251252 ${ask} ${address} ${figures ? kpiRow(ctx.app.db, kpiOpts) : liveLine(ctx)}
253 ${figures ? await pinnedPanels(ctx.app, user) : null}
252254 ${when(
253255 steps,
254256 () => html`<p class="small muted" id="lean-home">
src/web/studio/pinned.ts added +29
@@ -0,0 +1,29 @@
1 /**
2 * The panels a person pinned from /boards, drawn under home's KPI row in
3 * their order (#2741), each with Up, Down and Unpin beside it. Only the
4 * viewer's own pins are read. A pin whose board or panel is gone is skipped.
5 */
6 import type { App } from "../../app.ts";
7 import { listPinned } from "../../actions/board-pins.ts";
8 import type { Pin } from "../../boards/pins.ts";
9 import type { User } from "../../core/users.ts";
10 import { html, type Html } from "../html.ts";
11 import { btn } from "../ui.ts";
12 import { drawPanel } from "../pages/boards-view.ts";
13
14 function controls(p: Pin): Html {
15 const hidden = html`<input type="hidden" name="slug" value="${p.slug}" /><input type="hidden" name="title" value="${p.title}" /><input type="hidden" name="back" value="/" />`;
16 return html`<div class="row-actions">
17 <form method="post" action="/boards/move-pin" class="row-actions">${hidden}
18 ${btn("Up", { size: "sm", name: "by", value: "-1", label: `Move ${p.title} up` })}
19 ${btn("Down", { size: "sm", name: "by", value: "1", label: `Move ${p.title} down` })}
20 </form>
21 <form method="post" action="/boards/unpin-panel" class="row-actions">${hidden}${btn("Unpin", { size: "sm", label: `Unpin ${p.title}` })}</form>
22 </div>`;
23 }
24
25 export async function pinnedPanels(app: App, user: User): Promise<Html | null> {
26 const listed = await listPinned(app, user);
27 if (!listed.ok || listed.value.length === 0) return null;
28 return html`<section class="bgrid" aria-label="Pinned panels">${listed.value.map((p) => drawPanel(app, p.board, p.panel, controls(p)))}</section>`;
29 }
test/board-pins.test.ts added +140
@@ -0,0 +1,140 @@
1 /** Pinned panels (#2741): pin shows on home under the KPIs in the owner's order, unpin removes, nobody else's pins show. */
2 import { afterAll, beforeAll, describe, 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 { changeBoard, deleteBoard, writeBoard } from "../src/actions/boards.ts";
7 import { pinPanel } from "../src/actions/board-pins.ts";
8 import { MAX_PINS } from "../src/boards/pins.ts";
9 import { createApp } from "../src/app.ts";
10 import { listPins, pinOf } from "../src/boards/pins.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import { serve } from "../src/http/server.ts";
14
15 let dir: string;
16 let app: ReturnType<typeof createApp>;
17 let servers: Awaited<ReturnType<typeof serve>>;
18 let alice: User;
19 let carol: User;
20 const tok: Record<string, string> = {};
21
22 const panel = (title: string) => ({ title, chart: "line", query: { source: "runs", aggregate: "count", groupBy: "kind" }, w: 1, h: 1 });
23 const post = (path: string, fields: Record<string, string>, who = "alice", headers: Record<string, string> = {}) =>
24 fetch(servers.url + path, {
25 method: "POST", redirect: "manual", body: new URLSearchParams(fields),
26 headers: { authorization: `Bearer ${tok[who]}`, ...headers },
27 });
28 const home = async (who: string) =>
29 (await fetch(servers.url + "/", { headers: { authorization: `Bearer ${tok[who]}` } })).text();
30 const titles = (h: string) => [...h.matchAll(/<h2>(Pin [ABC])<\/h2>/g)].map((m) => m[1]);
31
32 beforeAll(async () => {
33 dir = await mkdtemp(join(tmpdir(), "forge-board-pins-"));
34 app = createApp({ dataDir: dir, port: 0 });
35 servers = await serve(app);
36 const mk = async (username: string, adminIfFirst?: boolean) => {
37 const r = await createUser(app.db, {
38 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
39 password: "a-good-passphrase", ...(adminIfFirst ? { adminIfFirst } : {}),
40 });
41 if (!r.ok) throw new Error(r.error);
42 return r.value;
43 };
44 alice = await mk("alice", true);
45 carol = await mk("carol");
46 app.db.run("UPDATE user SET is_admin = 1 WHERE id = ?", [carol.id]);
47 const bob = await mk("bob");
48 tok.alice = createToken(app.db, alice.id, "t", "admin").token;
49 tok.carol = createToken(app.db, carol.id, "t", "admin").token;
50 tok.bob = createToken(app.db, bob.id, "t", "admin").token;
51 const w = await writeBoard(app, alice, "mine", { title: "Mine", range: "all", refresh: "off", panels: [panel("Pin A"), panel("Pin B"), panel("Pin C")] });
52 if (!w.ok) throw new Error(JSON.stringify(w.error));
53 });
54
55 afterAll(async () => {
56 servers.stop();
57 app.close();
58 await rm(dir, { recursive: true, force: true });
59 });
60
61 const pinIt = (t: string, who = "alice") => post("/boards/pin-panel", { slug: "mine", title: t, back: "/" }, who);
62
63 describe("pinned panels", () => {
64 test("pinning shows on home in the owner's order; moving reorders; unpinning removes", async () => {
65 expect(titles(await home("alice"))).toEqual([]);
66 for (const t of ["Pin A", "Pin B", "Pin C"]) expect((await pinIt(t)).status).toBe(303);
67 await pinIt("Pin A"); // twice changes nothing
68 expect(titles(await home("alice"))).toEqual(["Pin A", "Pin B", "Pin C"]);
69 await post("/boards/move-pin", { slug: "mine", title: "Pin C", by: "-1" });
70 await post("/boards/move-pin", { slug: "mine", title: "Pin A", by: "-1" }); // at the top: stays
71 expect(titles(await home("alice"))).toEqual(["Pin A", "Pin C", "Pin B"]);
72 await post("/boards/unpin-panel", { slug: "mine", title: "Pin C" });
73 expect(titles(await home("alice"))).toEqual(["Pin A", "Pin B"]);
74 });
75
76 test("the pin button is on the board page and flips to Unpin", async () => {
77 const page = await (await fetch(servers.url + "/boards/mine", { headers: { authorization: `Bearer ${tok.alice}` } })).text();
78 expect(page).toContain("Unpin: Pin A");
79 expect(page).toContain("Pin to home: Pin C");
80 });
81
82 test("another person's pins never show", async () => {
83 expect(titles(await home("carol"))).toEqual([]);
84 await pinIt("Pin C", "carol");
85 expect(titles(await home("carol"))).toEqual(["Pin C"]);
86 expect(titles(await home("alice"))).toEqual(["Pin A", "Pin B"]);
87 await post("/boards/unpin-panel", { slug: "mine", title: "Pin A" }, "carol"); // not carol's to unpin
88 expect(listPins(app.db, alice.id).map((p) => p.title)).toEqual(["Pin A", "Pin B"]);
89 });
90
91 test("refusals: cross-origin, a stranger, an unknown panel, a bad slug", async () => {
92 expect((await post("/boards/pin-panel", { slug: "mine", title: "Pin C" }, "alice", { origin: "https://evil.example" })).status).toBe(403);
93 expect((await pinIt("Pin C", "bob")).status).toBe(404);
94 expect((await pinIt("No such panel")).status).toBe(404);
95 expect((await post("/boards/pin-panel", { slug: "../x", title: "Pin A" })).status).toBe(303); // flashed back, nothing stored
96 expect(listPins(app.db, alice.id)).toHaveLength(2);
97 expect(pinOf("mine", "a\u0000b")).toBeUndefined();
98 });
99
100 test("a pin whose panel has gone is skipped, not an error", async () => {
101 app.db.run("INSERT INTO board_pin (user_id, slug, title, pos) VALUES (?, 'gone', 'x', 9)", [alice.id]);
102 expect(titles(await home("alice"))).toEqual(["Pin A", "Pin B"]);
103 });
104
105 test("pins you can't see do not count toward the limit", async () => {
106 const many = Array.from({ length: MAX_PINS - 2 }, (_, i) => panel(`Old ${i}`));
107 const w = await writeBoard(app, alice, "old", { title: "Old", range: "all", refresh: "off", panels: many });
108 expect(w.ok).toBe(true);
109 for (const p of many) expect((await pinPanel(app, alice, "old", p.title)).ok).toBe(true);
110 expect((await pinPanel(app, alice, "mine", "Pin C")).ok).toBe(false); // full: refused
111 await deleteBoard(app, alice, "old"); // every one of those pins now names nothing
112 const again = await pinPanel(app, alice, "mine", "Pin C");
113 expect(again.ok).toBe(true);
114 expect(listPins(app.db, alice.id).map((p) => p.title)).toEqual(["Pin A", "Pin B", "Pin C"]);
115 const renamed = await changeBoard(app, alice, "mine", { kind: "edit", index: 2, panel: panel("Pin C2") });
116 expect(renamed.ok).toBe(true);
117 expect((await pinPanel(app, alice, "mine", "Pin C2")).ok).toBe(true);
118 expect(listPins(app.db, alice.id).map((p) => p.title)).toEqual(["Pin A", "Pin B", "Pin C2"]);
119 });
120
121 test("the JSON API lists, pins, moves and unpins; others are refused", async () => {
122 const api = (who: string, method: string, body?: unknown) =>
123 fetch(servers.url + "/api/v1/boards/pins", {
124 method, headers: { authorization: `Bearer ${tok[who]}`, "content-type": "application/json" },
125 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
126 });
127 const titlesOf = async (who: string) => ((await (await api(who, "GET")).json()) as { pins: { title: string }[] }).pins.map((p) => p.title);
128 expect(await titlesOf("alice")).toEqual(["Pin A", "Pin B", "Pin C2"]);
129 expect((await api("alice", "POST", { op: "move", slug: "mine", title: "Pin C2", by: -1 })).status).toBe(200);
130 expect(await titlesOf("alice")).toEqual(["Pin A", "Pin C2", "Pin B"]);
131 expect((await api("alice", "POST", { op: "unpin", slug: "mine", title: "Pin A" })).status).toBe(200);
132 expect((await api("alice", "POST", { op: "pin", slug: "mine", title: "Pin A" })).status).toBe(200);
133 expect(await titlesOf("alice")).toEqual(["Pin C2", "Pin B", "Pin A"]);
134 expect((await api("alice", "POST", { op: "pin", slug: "mine", title: "nope" })).status).toBe(404);
135 expect((await api("alice", "POST", { op: "dance" })).status).toBe(422);
136 expect((await api("bob", "GET")).status).toBe(403);
137 expect((await api("bob", "POST", { op: "pin", slug: "mine", title: "Pin A" })).status).toBe(403);
138 expect(await titlesOf("carol")).toEqual([]); // her "Pin C" was renamed away: not shown
139 });
140 });
test/mcp.test.ts +33
@@ -239,6 +239,7 @@ describe("the forge over MCP", () => {
239239 "signup",
240240 "boards",
241241 "board_write",
242 "board_pins",
242243 "board_delete",
243244 "report",
244245 ]);
@@ -820,6 +821,38 @@ describe("the forge over MCP", () => {
820821 expect(limitChanges(app.db)[0]).toMatchObject({ key });
821822 });
822823
824 test("board_pins: list, pin, move, unpin for an admin person; an agent and a non-admin are refused", async () => {
825 const board = { title: "Pinnable", range: "24h", refresh: "off", panels: [
826 { title: "One", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 },
827 { title: "Two", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 },
828 ] };
829 expect((await tool(rw, "board_write", { slug: "pinnable", board })).value).toMatchObject({ slug: "pinnable" });
830 const titles = async () => (await tool(rw, "board_pins")).value.pins.map((p: any) => p.title);
831 expect(await titles()).toEqual([]);
832 expect((await tool(rw, "board_pins", { op: "pin", slug: "pinnable", title: "One" })).value).toMatchObject({ slug: "pinnable", title: "One" });
833 await tool(rw, "board_pins", { op: "pin", slug: "pinnable", title: "Two" });
834 expect(await titles()).toEqual(["One", "Two"]);
835 await tool(rw, "board_pins", { op: "move", slug: "pinnable", title: "Two", by: -1 });
836 expect(await titles()).toEqual(["Two", "One"]);
837 expect((await tool(rw, "board_pins", { op: "move", slug: "pinnable", title: "Nope", by: 1 })).error).toContain("not_found");
838 expect((await tool(rw, "board_pins", { op: "pin", slug: "pinnable", title: "Nope" })).error).toContain("not_found");
839 await tool(rw, "board_pins", { op: "unpin", slug: "pinnable", title: "One" });
840 expect(await titles()).toEqual(["Two"]);
841 await tool(rw, "board_pins", { op: "unpin", slug: "pinnable", title: "Two" });
842 expect(await titles()).toEqual([]);
843
844 const made = await createAgent(app, alice, { username: "pinbot", brief: "x" });
845 if (!made.ok) throw new Error(String(made.error));
846 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id);
847 const botToken = createToken(app.db, made.value.user.id, "bot", "rw").token;
848 for (const who of [bobRw, botToken]) {
849 expect((await tool(who, "board_pins")).error).toContain("forbidden");
850 expect((await tool(who, "board_pins", { op: "pin", slug: "pinnable", title: "One" })).error).toContain("forbidden");
851 }
852 expect(await titles()).toEqual([]);
853 await tool(rw, "board_delete", { slug: "pinnable" });
854 });
855
823856 test("boards: list, read, write and delete for an admin person; an agent and a non-admin are refused", async () => {
824857 const board = { title: "Runs", range: "24h", refresh: "off", panels: [{ title: "Failed", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }] };
825858 const made = await createAgent(app, alice, { username: "boardbot", brief: "x" });
test/tui-boards.test.ts +63 −2
@@ -7,11 +7,13 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
77 import { mkdtemp, rm } from "node:fs/promises";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
10 import { listPinned, pinPanel, unpinPanel } from "../src/actions/board-pins.ts";
1011 import { writeBoard } from "../src/actions/boards.ts";
1112 import { createAgent } from "../src/actions/crew.ts";
1213 import { createApp, type App } from "../src/app.ts";
1314 import { createUser, type User } from "../src/core/users.ts";
1415 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
16 import { dashboardScreen } from "../src/tui/screens/dashboard.ts";
1517 import { boardRows, boardsScreen } from "../src/tui/screens/boards.ts";
1618 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1719 import { quietLogs } from "./helpers/globals.ts";
@@ -38,8 +40,9 @@ async function screenText(user?: User, keys = ""): Promise<string> {
3840 tui.start();
3941 tui.ctx.push(boardsScreen(session, tui.ctx));
4042 await settleApp(tui);
41 if (keys !== "") {
42 terminal.feed(keys);
43 // One key at a time: a key that opens a screen must land before the next is read.
44 for (const key of keys) {
45 terminal.feed(key);
4346 await settleApp(tui);
4447 }
4548 const text = tui.screen.toText();
@@ -106,4 +109,62 @@ describe("tui boards", () => {
106109 }
107110 expect(doors(carol)).toContain("boards");
108111 });
112
113 test("p pins the marked panel, the dashboard lists it by title, p again unpins", async () => {
114 const pinned = async () => {
115 const listed = await listPinned(app, carol);
116 return listed.ok ? listed.value.map((p) => p.title) : [];
117 };
118 expect(await pinned()).toEqual([]);
119 const text = await screenText(carol, "\rp");
120 expect(text).toContain("[pinned to home]");
121 expect(await pinned()).toEqual(["Failed runs"]);
122
123 const terminal = headlessTerminal(120, 30);
124 const { app: tui, session } = createForgeApp(app, identity(carol), { terminal, exitHooks: false });
125 tui.start();
126 tui.ctx.push(dashboardScreen(session, tui.ctx));
127 await settleApp(tui);
128 expect(tui.screen.toText()).toContain("Pinned: Failed runs (Runs this week)");
129 tui.stop?.();
130
131 await screenText(carol, "\rp");
132 expect(await pinned()).toEqual([]);
133 // Nobody else can pin from the terminal.
134 await screenText(bob, "\rp");
135 const other = await listPinned(app, bob);
136 expect(other.ok).toBe(false);
137 });
138
139 test("> and < move the marked pinned panel in the home order; an unpinned one does not move", async () => {
140 const wrote = await writeBoard(app, carol, "runs", {
141 title: "Runs this week",
142 range: "7d",
143 refresh: "off",
144 panels: [
145 { title: "Failed runs", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 },
146 { title: "Slow runs", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 },
147 ],
148 });
149 expect(wrote.ok).toBe(true);
150 const order = async () => {
151 const listed = await listPinned(app, carol);
152 return listed.ok ? listed.value.map((p) => p.title) : [];
153 };
154 expect(await order()).toEqual([]);
155 // Not pinned: nothing moves and nothing is claimed.
156 expect(await screenText(carol, "\r>")).not.toContain("moved on home");
157 await pinPanel(app, carol, "runs", "Failed runs");
158 await pinPanel(app, carol, "runs", "Slow runs");
159 expect(await order()).toEqual(["Failed runs", "Slow runs"]);
160 await screenText(carol, "\r>");
161 expect(await order()).toEqual(["Slow runs", "Failed runs"]);
162 await screenText(carol, "\r<");
163 expect(await order()).toEqual(["Failed runs", "Slow runs"]);
164 // At the top already: it stays.
165 await screenText(carol, "\r<");
166 expect(await order()).toEqual(["Failed runs", "Slow runs"]);
167 unpinPanel(app, carol, "runs", "Failed runs");
168 unpinPanel(app, carol, "runs", "Slow runs");
169 });
109170 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs