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

My pinned graphs are back on home #8728

Merged opened this in answer to

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

Merged

scout/forge:scout/8726-my-pinned-graphs-are-back-on-home → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/8726-my-pinned-graphs-are-back-on-home was merged into main as 4ce0a25.

Review

Sign in to review this change.
14 files changed +347 −9
docs/design/areas/home.md +1
@@ -3,3 +3,4 @@
33 - `.moving` — the living home's two lists (Being built, Just shipped), drawn by `movingRegion` in `src/web/studio/living.ts`: a one-column grid on a phone, two equal columns from 700px. Each list always holds three rows, so a quiet list is as tall as a busy one and nothing below it moves.
44 - `.tell-box` — home's one-line box that tells the crew: a `bar` whose text input takes the free width (`flex: 1 1 14rem`) and wraps the app picker and Tell under it on a phone.
55 - `.kpi-row` — a modifier on `.stats` for the home's figure tiles, not a second layout: it only changes the phone case. `.stats` alone (`minmax(160px, 1fr)`) falls to one column at 360px (two 160px tiles and the gap need 332px of 328px), stacking nine 104px tiles to ~1,100px; under 480px `.kpi-row` is two equal `minmax(0, 1fr)` columns instead. Tile height stays fixed (`.stat`), so nothing shifts.
6 - `.home-panels-slot` — home's place for the pinned panels, right after the KPIs (`pinnedSlot` in `src/web/studio/pinned.ts`). Only while it still waits for `/live/home/panels` (`data-refresh`) it holds 240px, about one panel row, with the 16px below it that `.bgrid` keeps (the system has no spacing tokens, only the 4px scale in §4, and both sit on it), so the charts arriving after first paint shift nothing; the refresh script drops the attribute when the fragment lands, is empty, or fails, and the slot collapses to its content.
docs/features/living-home.md +3 −1
@@ -10,7 +10,9 @@ Signed in, `/` is five things and nothing else:
1010
1111 The terminal's home carries the same lines (`movingLines` in `src/tui/screens/moving.ts`): Now for a site admin, then being built and just shipped, read for the viewer; its tell is the box.
1212
13 The feed — the composer, the timeline, what is on you, since you were here, pinned panels — is `/feed`, linked from home.
13 6. **Pinned panels** — an admin's own pins, in their order, right after the KPIs. With no pin nothing is drawn. With one, home holds an empty `#home-panels` slot (one panel row tall, so nothing shifts) that the refresh script fills from `/live/home/panels` after first paint, keeping Up, Down and Unpin; with no script a link opens the same fragment. The panels sit in a live region (`#home-pins`) that redraws on `board.pins`, the event every pin, unpin and move makes for its owner alone, so a change from a board, the terminal, the API or the MCP shows at once. In the terminal the same list is the palette's Pinned panels (`src/tui/screens/home_pins.ts`): `<`/`>` move, `u` unpins, `enter` opens the board. `/feed` still draws them too.
14
15 The feed — the composer, the timeline, what is on you, since you were here — is `/feed`, linked from home.
1416
1517 Left out on purpose: the waiting-on-you line, the clog, vitals and keeper lines (the boards and the Flow dialog still show them), and "slowest right now" (it needs a timing read the home does not have). The box posts to `/tell` today; it becomes the `/talk` entry (#7652) when that lands.
1618
src/actions/board-pins.ts +7
@@ -35,6 +35,10 @@ async function resolved(app: App, who: User): Promise<{ live: Pinned[]; gone: Pi
3535 return { live, gone };
3636 }
3737
38 /** Every door showing these pins (home's slot, the terminal's screen) redraws: an event for its owner alone. */
39 export const PINS_EVENT = "board.pins";
40 const moved = (app: App, who: User): void => void app.bus.append({ kind: "board.pins", actorId: who.id });
41
3842 export async function listPinned(app: App, actor: User | undefined): Promise<Result<Pinned[], "not_found">> {
3943 const who = person(actor);
4044 return who ? ok((await resolved(app, who)).live) : err("not_found");
@@ -52,6 +56,7 @@ export async function pinPanel(app: App, actor: User | undefined, slug: unknown,
5256 if (live.length >= MAX_PINS && !live.some((p) => p.slug === which.slug && p.title === which.title)) return err("too_many");
5357 pin(app.db, who.id, which);
5458 forgetRegion(app.db, who.id, "panels");
59 moved(app, who);
5560 return ok(which);
5661 }
5762
@@ -62,6 +67,7 @@ export function unpinPanel(app: App, actor: User | undefined, slug: unknown, tit
6267 if (!which) return err("invalid");
6368 unpin(app.db, who.id, which);
6469 forgetRegion(app.db, who.id, "panels");
70 moved(app, who);
6571 return ok(which);
6672 }
6773
@@ -73,5 +79,6 @@ export function movePinned(app: App, actor: User | undefined, slug: unknown, tit
7379 if (!listPins(app.db, who.id).some((x) => x.slug === which.slug && x.title === which.title)) return err("not_found");
7480 movePin(app.db, who.id, which, by);
7581 forgetRegion(app.db, who.id, "panels");
82 moved(app, who);
7683 return ok(which);
7784 }
src/core/event_audience.ts +2
@@ -200,6 +200,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
200200 if (
201201 kind === "repo.pinned" ||
202202 kind === "repo.unpinned" ||
203 // A person's pinned panels changed (`actions/board-pins.ts`): theirs alone.
204 kind === "board.pins" ||
203205 kind.startsWith("fleet.") ||
204206 kind.startsWith("thought.")
205207 )
src/design/activity.ts +1
@@ -150,6 +150,7 @@ const WORDS: Record<string, string> = {
150150 "repo.imported": "imported issues and pulls",
151151 "repo.pinned": "pinned a repository",
152152 "repo.unpinned": "unpinned a repository",
153 "board.pins": "changed their pinned panels",
153154 "branch.created": "created a branch",
154155 "branch.deleted": "deleted a branch",
155156 "label.created": "added a label",
src/tui/screens/entries.ts +2
@@ -22,6 +22,7 @@ import { entry as domains } from "./domains.ts";
2222 import { entry as feed } from "./feed.ts";
2323 import { entry as front } from "./front.ts";
2424 import { entry as forges } from "./forges.ts";
25 import { entry as homePins } from "./home_pins.ts";
2526 import { entry as invites } from "./invites.ts";
2627 import { entry as limits } from "./limits.ts";
2728 import { modelCeilingEntry, modelGrantEntry } from "./model.ts";
@@ -60,6 +61,7 @@ export interface PaletteEntry {
6061 export const ENTRIES: readonly PaletteEntry[] = [
6162 sentinel,
6263 boards,
64 homePins,
6365 system,
6466 telemetry,
6567 resilience,
src/tui/screens/home_pins.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * The panels pinned to home, in the terminal (#8726): the same list home
3 * draws after its KPIs, in its owner's order, read and changed through
4 * `actions/board-pins.ts` as the page's Up, Down and Unpin are, so the gate
5 * is the action's (a forge admin who is a person, their own pins only) and
6 * the palette offers the door only to that person. `<`/`>` move the marked
7 * pin, `u` unpins it, `enter` opens its board. A change from any door (home,
8 * a board, the API, the MCP) redraws this list, and one made here redraws home.
9 */
10
11 import { listPinned, movePinned, PINS_EVENT, unpinPanel, type Pinned } from "../../actions/board-pins.ts";
12 import { showBoard } from "../../actions/boards.ts";
13 import type { Ctx, Screen } from "../app/app.ts";
14 import { binding } from "../app/keys.ts";
15 import type { Session } from "../session.ts";
16 import { fill, len, split } from "../ui/layout.ts";
17 import { emptyState } from "../ui/widgets.ts";
18 import { style } from "../term/style.ts";
19 import { truncate } from "../term/width.ts";
20 import { boardScreen, plain } from "./boards.ts";
21 import { ListState, drawList, hitList, live, plural, runAsync, type ListRow } from "./common.ts";
22 import type { PaletteEntry } from "./entries.ts";
23
24 const mayPin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent";
25
26 /** One pin as a list row: the panel's title, then its board and chart. */
27 export const pinRow = (p: Pinned): ListRow => ({ title: plain(p.panel.title), secondary: `${plain(p.board.title)} · ${p.panel.chart}` });
28
29 export function homePinsScreen(f: Session, ctx: Ctx): Screen {
30 const state = new ListState();
31 let pins: Pinned[] = [];
32 const read = async (): Promise<void> => {
33 const listed = await listPinned(f.app, f.user);
34 pins = listed.ok ? listed.value : [];
35 state.clamp(pins.length);
36 ctx.invalidate();
37 };
38 const act = (by?: -1 | 1): void => {
39 const p = pins[state.index];
40 if (p === undefined) return;
41 runAsync(ctx, async () => {
42 const done = by === undefined ? unpinPanel(f.app, f.user, p.slug, p.title) : movePinned(f.app, f.user, p.slug, p.title, by);
43 if (done.ok && by !== undefined) state.index = Math.max(0, Math.min(pins.length - 1, state.index + by));
44 ctx.toast(done.ok ? (by === undefined ? "unpinned" : "moved on home") : "could not change that pin", done.ok ? "success" : "danger");
45 await read();
46 });
47 };
48 const open = (index: number): void => {
49 const p = pins[index];
50 if (p === undefined) return;
51 runAsync(ctx, async () => {
52 const shown = await showBoard(f.app, f.user, p.slug);
53 if (!shown.ok) ctx.toast("That board is gone", "default");
54 else ctx.push(boardScreen(ctx, f, p.slug, shown.value));
55 });
56 };
57 return {
58 title: "Pinned panels",
59 keys: [binding(["j", "k"], "move"), binding(["<", ">"], "move pin"), binding(["u"], "unpin"), binding(["enter"], "open board")],
60 async load(signal) {
61 await read();
62 live(f, signal, { kinds: [PINS_EVENT], where: (e) => e.actorId === f.user?.id }, read);
63 },
64 render(area, s) {
65 const c = ctx.theme.c;
66 const [head, body] = split(area, "v", [len(1), fill(1)]);
67 s.text(head!.x, head!.y, truncate(`${plural(pins.length, "panel")} pinned to home, in its order`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
68 if (pins.length === 0) {
69 emptyState(s, body!, c, { title: "Nothing pinned", body: "Open Boards, mark a panel with [ or ], and press p." });
70 return;
71 }
72 drawList(s, body!, c, state, pins.map(pinRow));
73 hitList(ctx, body!, state, pins.length, { open });
74 },
75 key(e) {
76 if (!mayPin(f)) return false;
77 if (state.key(e, pins.length)) return true;
78 if (e.key === "enter") return open(state.index), true;
79 if (e.key === "u") return act(), true;
80 if (e.key === "<" || e.key === ">") return act(e.key === ">" ? 1 : -1), true;
81 return false;
82 },
83 };
84 }
85
86 /** Home's pinned panels, as the page gates them: a forge admin who is a person. */
87 export const entry: PaletteEntry = {
88 id: "home-pins",
89 title: "Pinned panels",
90 hint: "the panels pinned to home: reorder and unpin",
91 after: ["boards"],
92 offered: mayPin,
93 run: (session, ctx) => ctx.push(homePinsScreen(session, ctx)),
94 };
src/web/studio/home.ts +2 −1
@@ -64,7 +64,7 @@ import { accessFingerprint } from "../../core/access_fingerprint.ts";
6464 import { homeFresh, homeRegion, homeVariant, refreshJs, type HomeRegion } from "./home_last.ts";
6565 import type { KpiTimer } from "../../flow/kpis.ts";
6666 import { archivedAppCount } from "../pages/apps.ts";
67 import { pinnedPanels } from "./pinned.ts";
67 import { pinnedPanels, pinnedSlot } from "./pinned.ts";
6868 import { isNavigation, lastVisit, noteVisit } from "../../core/home_visit.ts";
6969 import { sinceYouWere } from "./since.ts";
7070 import { homeTabs, unshippedAsks } from "./home_tabs.ts";
@@ -328,6 +328,7 @@ export async function studioHome(
328328 ${tellBox(ctx, user)}
329329 ${ask} ${address}
330330 <section class="stack" aria-label="The forge">${kpis}</section>
331 ${pinnedSlot(ctx.app.db, user)}
331332 ${timePhase(ctx.request, "moving", () => movingRegion(ctx.app.db, user))}
332333 ${appChips(ctx, user)}
333334 ${when(
src/web/studio/home_last.ts +2 −2
@@ -121,7 +121,7 @@ export const refreshJs = /* js */ `
121121 var holder = document.createElement('template');
122122 holder.innerHTML = text;
123123 var root = holder.content.firstElementChild;
124 if (!root) return;
124 if (!root) { el.removeAttribute('data-refresh'); return; }
125125 // A region that owns its element (the feed, bound once by its script) is swapped inside it.
126126 var inner = root.children.length === 1 ? root.firstElementChild : null;
127127 var mine = inner && inner.id ? el.querySelector('#' + inner.id) : null;
@@ -129,7 +129,7 @@ export const refreshJs = /* js */ `
129129 el.removeAttribute('data-refresh');
130130 el.dispatchEvent(new CustomEvent('forge:swapped', { bubbles: true }));
131131 })
132 .catch(function () {});
132 .catch(function () { el.removeAttribute('data-refresh'); });
133133 });
134134 })();
135135 `;
src/web/studio/pinned.ts +25 −3
@@ -4,10 +4,12 @@
44 * viewer's own pins are read. A pin whose board or panel is gone is skipped.
55 */
66 import type { App } from "../../app.ts";
7 import { listPinned } from "../../actions/board-pins.ts";
7 import { listPinned, PINS_EVENT } from "../../actions/board-pins.ts";
88 import type { Board, Panel } from "../../boards/store.ts";
99 import type { Pin } from "../../boards/pins.ts";
1010 import type { User } from "../../core/users.ts";
11 import type { Database } from "bun:sqlite";
12 import { listPins } from "../../boards/pins.ts";
1113 import { html, type Html } from "../html.ts";
1214 import { btn } from "../ui.ts";
1315 import { cachedPanel } from "./home_cache.ts";
@@ -31,6 +33,26 @@ const cached = (p: Pin & { board: Board; panel: Panel }): typeof runFor => (db,
3133
3234 export async function pinnedPanels(app: App, user: User): Promise<Html | null> {
3335 const listed = await listPinned(app, user);
34 if (!listed.ok || listed.value.length === 0) return null;
35 return html`<section class="bgrid" aria-label="Pinned panels">${listed.value.map((p) => drawPanel(app, p.board, p.panel, { slug: p.slug, index: p.board.panels.indexOf(p.panel) }, controls(p), cached(p)))}</section>`;
36 if (!listed.ok) return null;
37 const shown = listed.value.length === 0 ? null : html`<section class="bgrid" aria-label="Pinned panels">${listed.value.map((p) => drawPanel(app, p.board, p.panel, { slug: p.slug, index: p.board.panels.indexOf(p.panel) }, controls(p), cached(p)))}</section>`;
38 return pinsRegion(shown);
39 }
40
41 /** The live region the pins sit in: a pin, unpin or move from any door (the terminal too) redraws it from `/live/home/panels`. */
42 const pinsRegion = (inner: Html | null): Html =>
43 html`<div id="home-pins" data-live="${PINS_EVENT}" data-live-src="/live/home/panels">${inner}</div>`;
44
45 /**
46 * Home's place for the pins, right after the KPIs. Without a pin it is an
47 * empty live region and takes no space (a pin made elsewhere fills it); with
48 * one, an empty slot that the page's script fills from
outdated

The empty div has no min-height, so the late-arriving charts shift everything below them. Reserve the space (token or class) and collapse it when the fragment is empty.

outdated

The pins have no terminal screen. Add one in src/tui/ with its entry where AGENTS.md says, gated the same as this (admin, own pins), and cover it with a test.

49 * `/live/home/panels` after first paint, so the charts never cost the paint.
50 * The slot holds one panel row's height until the fragment lands (or
51 * fails, or comes back empty), so nothing below it shifts. Without script
52 * the noscript link opens the same fragment.
53 */
54 export function pinnedSlot(db: Database, user: User): Html | null {
55 if (!user.isAdmin || user.kind === "agent") return null;
56 if (listPins(db, user.id).length === 0) return html`<div id="home-panels" class="home-panels-slot">${pinsRegion(null)}</div>`;
57 return html`<div id="home-panels" class="home-panels-slot" data-refresh="/live/home/panels">${pinsRegion(null)}<noscript><p class="small muted"><a href="/live/home/panels">Open your pinned panels</a>.</p></noscript></div>`;
3658 }
src/web/style/components.ts +3
@@ -864,6 +864,9 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); }
864864 .bgrid h2 { font-size: 14px; }
865865 @media (max-width: 720px) { .bgrid { grid-template-columns: minmax(0, 1fr); } .bp-w2, .bp-w3 { grid-column: auto; } }
866866
867 /* Home's slot for pinned panels holds one panel row until the fragment lands, then the panels size it. */
868 .home-panels-slot[data-refresh] { min-height: 240px; margin-bottom: 16px; }
outdated

15rem and 16px are literals. Use the spacing/size tokens docs/DESIGN.md names, or say in home.md why none fits.

869
867870 /* ---- settings shell ---- */
868871 .settings { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; margin-top: 16px; }
869872 .settings-nav {
test/board-pins.test.ts +76 −2
@@ -1,4 +1,6 @@
11 /** Pinned panels (#2741): pin shows on home under the KPIs in the owner's order, unpin removes, nobody else's pins show. */
2 import { stylesheetAsset } from "../src/web/asset.ts";
3 import { refreshJs } from "../src/web/studio/home_last.ts";
24 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
35 import { mkdtemp, rm } from "node:fs/promises";
46 import { tmpdir } from "node:os";
@@ -25,10 +27,50 @@ const post = (path: string, fields: Record<string, string>, who = "alice", heade
2527 method: "POST", redirect: "manual", body: new URLSearchParams(fields),
2628 headers: { authorization: `Bearer ${tok[who]}`, ...headers },
2729 });
28 const home = async (who: string) =>
29 (await fetch(servers.url + "/feed", { headers: { authorization: `Bearer ${tok[who]}` } })).text();
30 const get = async (path: string, who: string) =>
31 (await fetch(servers.url + path, { headers: { authorization: `Bearer ${tok[who]}` } })).text();
32 /** Home as the page's script leaves it: the slot after first paint, then the fragment it fetches. */
33 const home = async (who: string) => {
34 const first = await get("/?all=1", who);
35 expect(titles(first)).toEqual([]); // nothing of the charts in the first paint
36 return first.includes('data-refresh="/live/home/panels"') ? get("/live/home/panels", who) : "";
37 };
3038 const titles = (h: string) => [...h.matchAll(/<h2>(Pin [ABC])<\/h2>/g)].map((m) => m[1]);
3139
40 type Answer = () => Promise<{ ok: boolean; text: () => Promise<string> }>;
41 /**
42 * `refreshJs` run against one slot in a stand-in document: whether the slot still
43 * waits (holds its reserve) afterwards, and what its live region holds. The
44 * stand-in parses only what the fragment is: a <div> around #home-pins.
45 */
46 async function refreshSlot(answer: Answer): Promise<{ waiting: boolean; pins: string }> {
47 const attrs = new Map([["data-refresh", "/live/home/panels"]]);
48 const pins = { id: "home-pins", innerHTML: "" };
49 const slot = {
50 getAttribute: (n: string) => attrs.get(n) ?? null,
51 removeAttribute: (n: string) => void attrs.delete(n),
52 querySelector: (q: string) => (q === "#home-pins" ? pins : null),
53 dispatchEvent: () => true,
54 innerHTML: "",
55 };
56 const template = () => {
57 const content: { firstElementChild: unknown } = { firstElementChild: null };
58 return {
59 content,
60 set innerHTML(text: string) {
61 const inner = { id: "home-pins", innerHTML: "PANELS" };
62 content.firstElementChild = text === "" ? null : { children: [inner], firstElementChild: inner, innerHTML: text };
63 },
64 };
65 };
66 const document = { querySelectorAll: () => [slot], createElement: template };
67 const window = { fetch: answer, HTMLTemplateElement: function () {} };
68 class CustomEvent { constructor(readonly type: string) {} }
69 new Function("window", "document", "fetch", "CustomEvent", refreshJs)(window, document, answer, CustomEvent);
70 for (let i = 0; i < 10; i++) await Promise.resolve();
71 return { waiting: attrs.has("data-refresh"), pins: pins.innerHTML };
72 }
73
3274 beforeAll(async () => {
3375 dir = await mkdtemp(join(tmpdir(), "forge-board-pins-"));
3476 app = createApp({ dataDir: dir, port: 0 });
@@ -73,6 +115,38 @@ describe("pinned panels", () => {
73115 expect(titles(await home("alice"))).toEqual(["Pin A", "Pin B"]);
74116 });
75117
118 test("home's first paint holds a slot after the KPIs only when something is pinned", async () => {
119 const first = await get("/?all=1", "alice");
120 expect(first.indexOf('aria-label="The forge"')).toBeLessThan(first.indexOf('id="home-panels"'));
121 expect(first.indexOf('id="home-panels"')).toBeLessThan(first.indexOf("live-moving"));
122 expect(first).not.toContain("Pinned panels");
123 expect(first).toContain('class="home-panels-slot"');
124 expect(first).toContain("Open your pinned panels"); // no-script door
125 expect(first).toMatch(/id="home-pins"[^>]*data-live="board\.pins"/); // a pin from any door redraws it live
126 // The reserve is held only while the slot waits: once the script drops data-refresh, nothing holds it.
127 const css = await (await fetch(servers.url + stylesheetAsset().path)).text();
128 const rules = [...css.matchAll(/([^{}]*\.home-panels-slot[^{}]*)\{([^}]*)\}/g)].map((m) => [m[1]!.trim(), m[2]!] as const);
129 expect(rules.some(([sel, body]) => sel.includes("[data-refresh]") && /min-height/.test(body))).toBe(true);
130 expect(rules.some(([sel, body]) => !sel.includes("[data-refresh]") && /min-height/.test(body))).toBe(false);
131 const slot = first.match(/<div id="home-panels"[\s\S]*?<\/noscript><\/div>/)?.[0] ?? "";
132 expect(slot.length).toBeLessThan(400); // the pins cost home's first paint almost no bytes
133 expect(await get("/?all=1", "bob")).not.toContain("home-panels");
134 // An admin with no pins: an empty live region to fill, no reserve, no fetch, no link.
135 const none = await get("/?all=1", "carol");
136 expect(none).toContain('id="home-pins"');
137 expect(none).not.toContain('data-refresh="/live/home/panels"');
138 expect(none).not.toContain("Open your pinned panels");
139 });
140
141 test("the slot is filled when the fragment lands, and collapses when it is empty or fails", async () => {
142 const filled = await refreshSlot(async () => ({ ok: true, text: async () => "<div>pins</div>" }));
143 expect(filled.pins).toBe("PANELS");
144 expect(filled.waiting).toBe(false);
145 expect((await refreshSlot(async () => ({ ok: true, text: async () => "" }))).waiting).toBe(false);
146 expect((await refreshSlot(async () => ({ ok: false, text: async () => "" }))).waiting).toBe(false);
147 expect((await refreshSlot(async () => { throw new Error("offline"); })).waiting).toBe(false);
148 });
149
76150 test("the pin button is on the board page and flips to Unpin", async () => {
77151 const page = await (await fetch(servers.url + "/boards/mine", { headers: { authorization: `Bearer ${tok.alice}` } })).text();
78152 expect(page).toContain("Unpin: Pin A");
test/palette-roster.test.ts +1
@@ -72,6 +72,7 @@ test("a site-admin person is offered the whole roster", () => {
7272 ["signins", "Signed in", "your browsers and tokens; d signs a browser out"],
7373 ["sentinel", "Sentinel", "the forge's open faults"],
7474 ["boards", "Boards", "the forge's boards and their panels"],
75 ["home-pins", "Pinned panels", "the panels pinned to home: reorder and unpin"],
7576 ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"],
7677 ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"],
7778 ["flow", "Flow", "the pipeline's bottleneck and throughput"],
test/tui-home-pins.test.ts added +128
@@ -0,0 +1,128 @@
1 /**
2 * Home's pinned panels in the terminal (#8726): a forge admin who is a person
3 * lists their own pins in home's order, moves and unpins them, and each change
4 * is an event for them alone, which home's live region redraws on. Nobody
5 * else has the door, and nobody else's pins show.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { PINS_EVENT, pinPanel } from "../src/actions/board-pins.ts";
12 import { writeBoard } from "../src/actions/boards.ts";
13 import { createAgent } from "../src/actions/crew.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { listPins } from "../src/boards/pins.ts";
16 import { eventAudience } from "../src/core/event_audience.ts";
17 import type { ForgeEvent } from "../src/core/events.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { createForgeApp, forgeCommands } from "../src/tui/forge.ts";
20 import { homePinsScreen } from "../src/tui/screens/home_pins.ts";
21 import { headlessTerminal } from "../src/tui/term/terminal.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23 import { settleApp } from "./helpers/tui.ts";
24
25 quietLogs();
26
27 let dir: string;
28 let app: App;
29 let carol: User;
30 let dana: User;
31 let bob: User;
32 let bot: User;
33
34 const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user });
35
36 async function screenText(user: User | undefined, keys = ""): Promise<string> {
37 const terminal = headlessTerminal(120, 30);
38 const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false });
39 tui.start();
40 tui.ctx.push(homePinsScreen(session, tui.ctx));
41 await settleApp(tui);
42 for (const key of keys) {
43 terminal.feed(key);
44 await settleApp(tui);
45 }
46 const text = tui.screen.toText();
47 tui.stop?.();
48 return text;
49 }
50
51 const doors = (user?: User): string[] => {
52 const { app: tui, session } = createForgeApp(app, identity(user), { terminal: headlessTerminal(80, 24), exitHooks: false });
53 const ids = forgeCommands(session)(tui.ctx).map((c) => c.id);
54 tui.stop?.();
55 return ids;
56 };
57
58 const order = (u: User) => listPins(app.db, u.id).map((p) => p.title);
59 const panel = (title: string) => ({ title, chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 });
60
61 beforeAll(async () => {
62 dir = await mkdtemp(join(tmpdir(), "forge-tui-home-pins-"));
63 app = createApp({ dataDir: dir, port: 0 });
64 const mk = async (username: string, first = false) => {
65 const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
66 if (!r.ok) throw new Error(r.error);
67 return r.value;
68 };
69 carol = await mk("carol", true);
70 dana = await mk("dana");
71 app.db.run("UPDATE user SET is_admin = 1 WHERE id = ?", [dana.id]);
72 dana = { ...dana, isAdmin: true };
73 bob = await mk("bob");
74 const made = await createAgent(app, carol, { username: "pinbot", brief: "x" });
75 if (!made.ok) throw new Error(String(made.error));
76 app.db.run("UPDATE user SET is_admin = 1 WHERE id = ?", [made.value.user.id]);
77 bot = { ...made.value.user, isAdmin: true };
78 const w = await writeBoard(app, carol, "mine", { title: "Mine", range: "all", refresh: "off", panels: [panel("Pin A"), panel("Pin B"), panel("Pin C")] });
79 if (!w.ok) throw new Error(JSON.stringify(w.error));
80 for (const t of ["Pin A", "Pin B", "Pin C"]) expect((await pinPanel(app, carol, "mine", t)).ok).toBe(true);
81 expect((await pinPanel(app, dana, "mine", "Pin C")).ok).toBe(true);
82 }, 30_000);
83
84 afterAll(async () => {
85 app.close();
86 await rm(dir, { recursive: true, force: true });
87 });
88
89 describe("tui home pins", () => {
90 test("the palette offers the door to an admin person only", () => {
91 expect(doors(carol)).toContain("home-pins");
92 expect(doors(bob)).not.toContain("home-pins");
93 expect(doors(bot)).not.toContain("home-pins");
94 expect(doors(undefined)).not.toContain("home-pins");
95 });
96
97 test("an admin person reads their own pins in home's order, nobody else's", async () => {
98 const text = await screenText(carol);
99 expect(text).toContain("3 panels pinned to home");
100 expect(text.indexOf("Pin A")).toBeLessThan(text.indexOf("Pin B"));
101 expect(text.indexOf("Pin B")).toBeLessThan(text.indexOf("Pin C"));
102 const hers = await screenText(dana);
103 expect(hers).toContain("1 panel pinned to home");
104 expect(hers).not.toContain("Pin A");
105 });
106
107 test("a member and an agent see no pins and change none", async () => {
108 for (const who of [bob, bot]) {
109 const text = await screenText(who, "u>");
110 expect(text).toContain("Nothing pinned");
111 }
112 expect(order(carol)).toEqual(["Pin A", "Pin B", "Pin C"]);
113 });
114
115 test("> moves the marked pin, u unpins it, and each change is an event for its owner alone", async () => {
116 const seen: ForgeEvent[] = [];
117 const off = app.bus.subscribe((e) => void (e.kind === PINS_EVENT && seen.push(e)));
118 await screenText(carol, ">");
119 expect(order(carol)).toEqual(["Pin B", "Pin A", "Pin C"]);
120 await screenText(carol, "ju"); // the second row, now Pin A
121 expect(order(carol)).toEqual(["Pin B", "Pin C"]);
122 expect(order(dana)).toEqual(["Pin C"]);
123 off();
124 expect(seen.length).toBe(2);
125 expect(seen.every((e) => e.actorId === carol.id)).toBe(true);
126 expect(eventAudience(PINS_EVENT)).toBe("actor");
127 });
128 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs