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

The terminal lists a repository's labels on a read-only Labels screen #7845

Merged opened this

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

Merged

scout/forge:scout/7841-the-terminal-lists-a-repository-s-labels → main 3 commits fork

  • Merged. scout/7841-the-terminal-lists-a-repository-s-labels was merged into main as 5390918.

Review

Sign in to review this change.
4 files changed +214 −1
docs/APPS.md +1 −1
@@ -149,7 +149,7 @@ Under the box, four starter sentences are plain links (no script):
149149 one opens the run's log; a run on work you may not see is left out),
150150 *Tell the crew*, *Report a problem* (one text, an issue
151151 titled by its first line, through `openIssueThrough`) and *Make it yours*
152 (for those who cannot write the repository: `Y` on the repository screen, or the palette row, asks for a name, defaulting to the original's, and a visibility, `tab` to switch, then shows the plan and copies only after a confirm, and the copy opens), and *Vision*
152 (for those who cannot write the repository: `Y` on the repository screen, or the palette row, asks for a name, defaulting to the original's, and a visibility, `tab` to switch, then shows the plan and copies only after a confirm, and the copy opens), *Labels* (a read-only list of its labels with their hex), and *Vision*
153153 (docs/VISION.md on the default branch: the purpose, each pillar numbered,
154154 the rules, through the one read MCP `vision` makes; when there is none the
155155 terminal says so in one line of its own, where MCP answers an empty `order`;
src/tui/screens/labels.ts added +74
@@ -0,0 +1,74 @@
1 /**
2 * The Labels command, a repository's (#7841): each label the repository
3 * defines, as the issue screen draws it — the colour chip, then its hex —
4 * read with `listLabels`, name order. Read-only, no counts. Offered to, and
5 * read for, only someone who may read the repository.
6 */
7
8 import { canRead, type Repo } from "../../core/repos.ts";
9 import { listLabels, type Label } from "../../core/issues.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import type { Command } from "../app/palette.ts";
13 import { style } from "../term/style.ts";
14 import { ListState } from "./common.ts";
15 import { labelChip } from "../ui/widgets.ts";
16 import type { Session } from "../session.ts";
17
18 /** What a viewer who may not read the repository sees: what the page's 404 says. */
19 export const NOT_FOUND_LINE = "No such repository";
20 export const NO_LABELS_LINE = "No labels yet.";
21
22 export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
23 let labels: Label[] | undefined;
24 let hidden = false;
25 const state = new ListState();
26 return {
27 title: "Labels",
28 keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")],
29 async load() {
30 // Gated here as well as on the command: the screen is exported.
31 hidden = !canRead(f.db, repo, f.user);
32 labels = hidden ? [] : listLabels(f.db, repo.id);
33 ctx.invalidate();
34 },
35 render(area, s) {
36 const c = ctx.theme.c;
37 const right = area.x + area.w;
38 const line = (y: number, text: string) => s.text(area.x + 1, area.y + y, text, style(c.fg, c.bg), right);
39 if (hidden) return void line(0, NOT_FOUND_LINE);
40 if (labels === undefined) return void line(0, "Reading…");
41 if (labels.length === 0) return void line(0, NO_LABELS_LINE);
42 const from = state.window(area.h, labels.length);
43 for (let i = 0; i < area.h; i++) {
44 const l = labels[from + i];
45 if (l === undefined) break;
46 const picked = from + i === state.index;
47 s.text(area.x, area.y + i, picked ? ">" : " ", style(c.accent, c.bg), right);
48 const x = labelChip(s, area.x + 1, area.y + i, c, { text: l.name, color: l.color }, right);
49 s.text(x + 2, area.y + i, `#${l.color.replace(/^#/, "")}`, style(picked ? c.fg : c.muted, c.bg), right);
50 }
51 },
52 key(e) {
53 if (e.key === "j" || e.key === "down") state.move(1, labels?.length ?? 0);
54 else if (e.key === "k" || e.key === "up") state.move(-1, labels?.length ?? 0);
55 else if (e.key === "esc" || e.key === "q") ctx.pop();
56 else return false;
57 ctx.invalidate();
58 return true;
59 },
60 };
61 }
62
63 /** The palette's door on a repository screen, for whoever may read it. */
64 export function labelsCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
65 if (repo === undefined || !canRead(f.db, repo, f.user)) return [];
66 return [
67 {
68 id: "labels",
69 title: `${repo.ownerName}/${repo.name} — Labels`,
70 hint: "its labels and their colours",
71 run: () => ctx.push(labelsScreen(f, ctx, repo)),
72 },
73 ];
74 }
src/tui/screens/workspace.ts +2
@@ -131,6 +131,7 @@ import { workingCommands } from "./working.ts";
131131 import { forgeItCommands } from "./forge_it.ts";
132132 import { makeItYoursScreen, yoursCommands } from "./yours.ts";
133133 import { visionCommands } from "./vision.ts";
134 import { labelsCommands } from "./labels.ts";
134135 import { appEnvCommands } from "./appenv.ts";
135136 import { externalEditor, editorHint, hasExternalEditor } from "./external.ts";
136137 import { finderScreen } from "./finder.ts";
@@ -1442,6 +1443,7 @@ export function workspaceScreen(
14421443 ...forgeItCommands(f, ctx, repo, () => screen),
14431444 ...hooksCommands(f, ctx, repo),
14441445 ...visionCommands(f, ctx, repo),
1446 ...labelsCommands(f, ctx, repo),
14451447 ...appEnvCommands(f, ctx, repo),
14461448 ],
14471449 get title() {
test/tui-labels.test.ts added +137
@@ -0,0 +1,137 @@
1 /** The terminal's Labels screen (#7841): a repository's labels with their hex; only for readers. */
2 import { afterAll, beforeAll, 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 { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createLabel } from "../src/core/issues.ts";
9 import type { Repo } from "../src/core/repos.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import { createForgeApp, Session } from "../src/tui/forge.ts";
12 import type { Ctx } from "../src/tui/app/app.ts";
13 import { labelsCommands, labelsScreen, NOT_FOUND_LINE, NO_LABELS_LINE } from "../src/tui/screens/labels.ts";
14 import { repoScreen } from "../src/tui/screens/repo.ts";
15 import { local } from "../src/tui/screens/where.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { settleApp } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 let dir: string;
23 let app: App;
24 let open: Repo;
25 let secret: Repo;
26 let bare: Repo;
27 let many: Repo;
28 let alice: User;
29 const guest = { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test" };
30
31 beforeAll(async () => {
32 dir = await mkdtemp(join(tmpdir(), "forge-tui-labels-"));
33 app = createApp({ dataDir: dir, port: 0 });
34 const made = await createUser(app.db, {
35 origin: "seed",
36 trusted: true,
37 username: "alice",
38 email: "[email protected]",
39 password: "a-good-passphrase",
40 adminIfFirst: true,
41 });
42 if (!made.ok) throw new Error(made.error);
43 alice = made.value;
44 const mk = async (name: string, visibility: "public" | "private") => {
45 const repo = await createRepo(app, made.value, { name, visibility });
46 if (!repo.ok) throw new Error(JSON.stringify(repo.error));
47 return repo.value;
48 };
49 open = await mk("lab", "public");
50 secret = await mk("secret", "private");
51 bare = await mk("bare", "public");
52 many = await mk("many", "public");
53 for (let i = 0; i < 40; i++) createLabel(app.db, many.id, `l${String(i).padStart(2, "0")}`, "123456");
54 for (const r of [open, secret]) {
55 createLabel(app.db, r.id, r === open ? "bug" : "hidden-label", r === open ? "d73a4a" : "00ff00");
56 }
57 createLabel(app.db, open.id, "docs", "0075ca");
58 }, 60_000);
59
60 afterAll(async () => {
61 app.close();
62 await rm(dir, { recursive: true, force: true });
63 });
64
65 async function screenText(repo: Repo, user?: User, keys: string[] = []): Promise<string> {
66 const terminal = headlessTerminal(100, 30);
67 const { app: tui, session } = createForgeApp(app, user ? { ...guest, user } : guest, { terminal, exitHooks: false });
68 tui.start();
69 tui.ctx.push(labelsScreen(session, tui.ctx, repo));
70 await settleApp(tui);
71 for (const key of keys) tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
72 await settleApp(tui);
73 const text = tui.screen.toText();
74 tui.stop?.();
75 return text;
76 }
77
78 test("a reader sees each label with its hex, in name order", async () => {
79 const text = await screenText(open);
80 expect(text).toContain("bug");
81 expect(text).toContain("#d73a4a");
82 expect(text).toContain("#0075ca");
83 expect(text.indexOf("bug")).toBeLessThan(text.indexOf("docs"));
84 });
85
86 test("a non-member opening a private repository's screen gets No such repository and no label", async () => {
87 const text = await screenText(secret);
88 expect(text).toContain(NOT_FOUND_LINE);
89 expect(text).not.toContain("hidden-label");
90 expect(text).not.toContain("#00ff00");
91 });
92
93 test("the command is offered to a reader and not for a repository the viewer cannot read", () => {
94 const ctx = {} as Ctx;
95 const stranger = new Session(app, guest);
96 expect(labelsCommands(stranger, ctx, open).map((c) => c.id)).toEqual(["labels"]);
97 expect(labelsCommands(stranger, ctx, secret)).toEqual([]);
98 expect(labelsCommands(stranger, ctx, undefined)).toEqual([]);
99 });
100
101 test("the repository screen's commands carry Labels, and running it opens the screen", async () => {
102 const terminal = headlessTerminal(100, 30);
103 const { app: tui, session } = createForgeApp(app, guest, { terminal, exitHooks: false });
104 tui.start();
105 const screen = repoScreen(session, tui.ctx, local(session, open));
106 tui.ctx.push(screen);
107 await settleApp(tui);
108 const command = screen.commands?.().find((c) => c.id === "labels");
109 expect(command?.title).toBe("alice/lab — Labels");
110 command!.run();
111 await settleApp(tui);
112 const text = tui.screen.toText();
113 tui.stop?.();
114 expect(text).toContain("#d73a4a");
115 });
116
117 test("the owner opening the private repository's screen sees its labels", async () => {
118 const text = await screenText(secret, alice);
119 expect(text).toContain("hidden-label");
120 expect(text).toContain("#00ff00");
121 expect(text).not.toContain(NOT_FOUND_LINE);
122 });
123
124 test("a repository with no labels says so", async () => {
125 expect(await screenText(bare)).toContain(NO_LABELS_LINE);
126 });
127
128 test("j moves a visible > marker and scrolls later labels into view", async () => {
129 const first = await screenText(many);
130 expect(first).toContain("> ");
131 expect(first).toContain("l00");
132 expect(first).not.toContain("l39");
133 const text = await screenText(many, undefined, Array(39).fill("j"));
134 expect(text).toContain("l39");
135 expect(text).not.toContain("l00");
136 expect(text.split("\n").find((l) => l.includes("l39"))).toContain(">");
137 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs