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

The terminal's palette offers "Forges you sent" to an admin: each forge with its claim state and the same apps-running note as /forges #2214

Merged opened this

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

Merged

scout/forge:scout/2211-the-terminal-s-palette-offers-forges-you → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/2211-the-terminal-s-palette-offers-forges-you was merged into main as 5ee713d.

Review

Sign in to review this change.
6 files changed +212 −20
README.md +2
@@ -21,6 +21,8 @@ any URL — and it is the rest of this page.
2121 A forge this one spawned (hosted) runs the apps it was given too: its `bakery`
2222 answers at `bakery--<child>--<this forge's host>`, in this forge's lanes, on the
2323 child's own data, and `/forges` says how many are running and why any is not.
24 In the terminal an admin's palette offers "Forges you sent": each forge with its
25 claim state and the same apps-running note, never a claim link.
2426
2527 Git hosting, code browsing, and a JSON API in one fast Bun process, with
2628 **zero runtime dependencies** — the `package.json` dependency list is empty and
src/spawn/clients.ts +22
@@ -171,3 +171,25 @@ export async function clientRows(app: App): Promise<ClientRow[]> {
171171 a.name.localeCompare(b.name),
172172 );
173173 }
174
175 /** A forge's claim state in words, as /forges and the terminal say it. */
176 export const CLAIM_WORDS: Record<"yes" | "no" | "unknown" | "making", string> = {
177 yes: "Claimed",
178 no: "Not claimed yet",
179 unknown: "Unknown",
180 making: "Still being made",
181 };
182
183 export function claimState(r: ClientRow): keyof typeof CLAIM_WORDS {
184 if (!r.made) return "making";
185 return r.claimed === null ? "unknown" : r.claimed ? "yes" : "no";
186 }
187
188 /** "3 apps running", or which one is not and why; null when none are run here. */
189 export function runningWords(r: ClientRow): string | null {
190 const a = r.running;
191 if (a === null || a.total === 0) return null;
192 const word = `${a.running} ${a.running === 1 ? "app" : "apps"} running`;
193 if (a.notRunning.length === 0) return word;
194 return `${word} · ${a.notRunning.map((n) => `${n.slug}: ${n.why}`).join("; ")}`;
195 }
src/tui/forge.ts +13
@@ -33,6 +33,8 @@ import { canSeeMemory } from "../boards/memory.ts";
3333 import { boardsScreen } from "./screens/boards.ts";
3434 import { askedScreen } from "./screens/asked.ts";
3535 import { deciderScreen } from "./screens/decider.ts";
36 import { forgesScreen } from "./screens/forges.ts";
37 import { clientRows } from "../spawn/clients.ts";
3638 import { familyScreen } from "./screens/family.ts";
3739 import { familyLines, familyView } from "../spawn/family.ts";
3840 import { issueScreen } from "./screens/issue.ts";
@@ -388,6 +390,17 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
388390 hint: "what needs the owner, snapshots, failure domains",
389391 run: () => ctx.push(ownerScreen(session, ctx)),
390392 });
393 // The forges this one sent, as /forges lists them: an admin's only.
394 if (session.user?.isAdmin === true)
395 commands.push({
396 id: "forges",
397 title: "Forges you sent",
398 hint: "each forge sent, claimed or not, and its apps",
399 run: () =>
400 runAsync(ctx, async () => {
401 ctx.push(forgesScreen(ctx, await clientRows(session.app)));
402 }),
403 });
391404 commands.push({
392405 id: "family",
393406 title: "Family",
src/tui/screens/forges.ts added +51
@@ -0,0 +1,51 @@
1 /**
2 * The forges this one sent (#2211) in a card: one row per client with its
3 * claim state and the apps-running note, in the words /forges uses. Never a
4 * claim link; that stays on the page.
5 */
6
7 import type { Ctx, Screen } from "../app/app.ts";
8 import { binding } from "../app/keys.ts";
9 import { CLAIM_WORDS, claimState, runningWords, type ClientRow } from "../../spawn/clients.ts";
10 import { card } from "../ui/widgets.ts";
11 import { drawFacts } from "./common.ts";
12
13 /** [name, "for Maria · Claimed · 3 apps running"] per row; an empty line when none. */
14 export function forgeLines(rows: readonly ClientRow[]): [string, string][] {
15 if (rows.length === 0) return [["", "You have not sent a forge yet"]];
16 return rows.map((r) => [
17 r.name,
18 [r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], runningWords(r)]
19 .filter((m): m is string => m !== null)
20 .join(" · "),
21 ]);
22 }
23
24 /** The lines that fit in `room` rows; when some do not, the last row says how many. */
25 export function fitLines(lines: [string, string][], room: number): [string, string][] {
26 if (lines.length <= room) return lines;
27 const kept = lines.slice(0, Math.max(0, room - 1));
28 kept.push(["", `+${lines.length - kept.length} more (see /forges)`]);
29 return kept;
30 }
31
32 export function forgesScreen(ctx: Ctx, rows: readonly ClientRow[]): Screen {
33 const lines = forgeLines(rows);
34 return {
outdated

Rows past area.h are dropped without a word. Scroll, or end with '+N more'.

35 title: "Forges you sent",
36 keys: [binding(["esc"], "close")],
37 render(area, s) {
38 const c = ctx.theme.c;
39 const w = Math.min(100, Math.max(30, area.w - 6));
40 const h = Math.min(area.h, lines.length + 2);
41 const shown = fitLines(lines, h - 2);
42 const r = { x: area.x + ((area.w - w) >> 1), y: area.y + ((area.h - h) >> 1), w, h };
43 const inner = card(s, r, c, { title: "Forges you sent", focused: true });
44 drawFacts(s, { x: inner.x + 1, y: inner.y, w: Math.max(0, inner.w - 1), h: inner.h }, c, shown, c.card);
45 },
46 key(e) {
47 if (e.key === "esc" || e.key === "q") ctx.pop();
48 return true;
49 },
50 };
51 }
src/web/pages/forges.ts +7 −20
@@ -7,7 +7,7 @@
77 */
88
99 import type { Ctx } from "../../http/context.ts";
10 import { clientRows, type ClientRow } from "../../spawn/clients.ts";
10 import { CLAIM_WORDS, claimState, clientRows, runningWords, type ClientRow } from "../../spawn/clients.ts";
1111 import { notFoundPage, pageHead } from "../chrome.ts";
1212 import { html, type Html } from "../html.ts";
1313 import { page } from "../layout.ts";
@@ -17,31 +17,18 @@ import { btn, card, empty, time } from "../ui.ts";
1717 import { noRoom } from "../../spawn/door.ts";
1818 import { claimLinkBody } from "./new-forge.ts";
1919
20 const STATE: Record<"yes" | "no" | "unknown" | "making", string> = {
21 yes: "Claimed",
22 no: "Not claimed yet",
23 unknown: "Unknown",
24 making: "Still being made",
25 };
26
27 function stateOf(r: ClientRow): keyof typeof STATE {
28 if (!r.made) return "making";
29 return r.claimed === null ? "unknown" : r.claimed ? "yes" : "no";
30 }
3120
outdated

Remove these two aliases; import and use CLAIM_WORDS and claimState where STATE and stateOf are used.

3221 /** "3 apps running", or which one is not and why. */
3322 function runningNote(r: ClientRow): Html | null {
34 const a = r.running;
35 if (a === null || a.total === 0) return null;
36 const word = `${a.running} ${a.running === 1 ? "app" : "apps"} running`;
37 if (a.notRunning.length === 0) return html`<span data-apps="up">${word}</span>`;
38 return html`<span data-apps="down"
39 >${word} · ${a.notRunning.map((n, i) => html`${i > 0 ? "; " : ""}${n.slug}: ${n.why}`)}</span
23 const words = runningWords(r);
24 if (words === null) return null;
25 return html`<span data-apps="${r.running!.notRunning.length === 0 ? "up" : "down"}"
26 >${words}</span
4027 >`;
4128 }
4229
4330 function forgeRow(r: ClientRow): Html {
44 const state = stateOf(r);
31 const state = claimState(r);
4532 const meta = [
4633 r.for === null ? null : html`for ${r.for}`,
4734 r.apps.length === 0 ? null : html`${r.apps.join(", ")}`,
@@ -64,7 +51,7 @@ function forgeRow(r: ClientRow): Html {
6451 class="pill"
6552 ${state === "unknown" ? html`title="It did not answer just now"` : null}
6653 ${state === "yes" ? html`data-tone="accent"` : null}
67 >${STATE[state]}</span
54 >${CLAIM_WORDS[state]}</span
6855 >${state === "no"
6956 ? html`<form method="post" action="/forges/${r.name}/claim-link">
7057 ${btn("Send a fresh link", { size: "sm", title: "A new claim link; the old one stops working" })}
test/tui-forges.test.ts added +117
@@ -0,0 +1,117 @@
1 /** "Forges you sent" in the terminal palette (#2211). */
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 { createApp, type App as Forge } from "../src/app.ts";
7 import { createUser, getUserByName } from "../src/core/users.ts";
8 import { createForgeApp } from "../src/tui/forge.ts";
9 import { fitLines, forgeLines } from "../src/tui/screens/forges.ts";
10 import { runningWords, type ClientRow } from "../src/spawn/clients.ts";
11 import type { TuiIdentity } from "../src/tui/session.ts";
12 import { headlessTerminal } from "../src/tui/term/terminal.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14 import { settleApp } from "./helpers/tui.ts";
15
16 quietLogs();
17
18 const row = (over: Partial<ClientRow>): ClientRow => ({
19 name: "maria",
20 url: "https://maria.test",
21 for: "Maria's bakery",
22 apps: [],
23 sentAt: null,
24 made: true,
25 claimed: false,
26 lastActivityAt: null,
27 running: null,
28 ...over,
29 });
30
31 describe("forges you sent in the terminal", () => {
32 let dataDir: string;
33 let forge: Forge;
34
35 function boot(username: string | null) {
36 const terminal = headlessTerminal(140, 32);
37 const user = username === null ? undefined : getUserByName(forge.db, username)!;
38 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
39 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
40 app.start();
41 const key = (k: string, ctrl = false) =>
42 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
43 return {
44 text: () => app.screen.toText(),
45 stop: () => app.stop(),
46 async palette() {
47 key("k", true);
48 for (const ch of "Forges you sent") key(ch);
49 await settleApp(app);
50 return app.screen.toText();
51 },
52 async enter() {
53 key("enter");
54 await settleApp(app);
55 },
56 };
57 }
58
59 beforeAll(async () => {
60 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-forges-"));
61 forge = createApp({ dataDir, port: 0 });
62 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
63 const made = await createUser(forge.db, {
64 trusted: true,
65 username,
66 email: `${username}@forge.local`,
67 password: "a-good-passphrase",
68 adminIfFirst,
69 });
70 if (!made.ok) throw new Error(made.error);
71 }
72 });
73
74 afterAll(async () => {
75 forge.close();
76 await rm(dataDir, { recursive: true, force: true });
77 });
78
79 test("an admin is offered the command and sees the empty line", async () => {
80 const h = boot("alice");
81 expect(await h.palette()).toContain("each forge sent");
82 await h.enter();
83 expect(h.text()).toContain("You have not sent a forge yet");
84 h.stop();
85 });
86
87 test("a member and a guest are not offered it", async () => {
88 for (const who of ["bob", null]) {
89 const h = boot(who);
90 expect(await h.palette()).not.toContain("each forge sent");
91 h.stop();
92 }
93 });
94
95 test("a row says who, the claim state and the apps note; never a link", () => {
96 const up = { total: 3, running: 3, notRunning: [] };
97 const down = { total: 2, running: 1, notRunning: [{ slug: "notes", why: "build failed" }] };
98 expect(forgeLines([row({ running: up })])).toEqual([
99 ["maria", "for Maria's bakery · Not claimed yet · 3 apps running"],
100 ]);
101 expect(forgeLines([row({ claimed: true, running: down, for: null })])).toEqual([
102 ["maria", "Claimed · 1 app running · notes: build failed"],
103 ]);
104 expect(forgeLines([row({ claimed: null })])[0]![1]).toContain("Unknown");
105 expect(forgeLines([row({ made: false })])[0]![1]).toContain("Still being made");
106 expect(JSON.stringify(forgeLines([row({})]))).not.toContain("http");
107 expect(runningWords(row({ running: { total: 0, running: 0, notRunning: [] } }))).toBeNull();
108 });
109 });
110
111 test("a list taller than the card ends with how many are cut off", () => {
112 const lines = Array.from({ length: 10 }, (_, i): [string, string] => [`f${i}`, "x"]);
113 expect(fitLines(lines, 10)).toEqual(lines);
114 const cut = fitLines(lines, 4);
115 expect(cut).toHaveLength(4);
116 expect(cut[3]![1]).toBe("+7 more (see /forges)");
117 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs