Public: anyone can read this; the crew can change it.
The terminal lists a repository's labels on a read-only Labels screen #7845
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7841-the-terminal-lists-a-repository-s-labels → main 3 commits fork
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): | ||
| 149 | 149 | one opens the run's log; a run on work you may not see is left out), |
| 150 | 150 | *Tell the crew*, *Report a problem* (one text, an issue |
| 151 | 151 | 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* | |
| 153 | 153 | (docs/VISION.md on the default branch: the purpose, each pillar numbered, |
| 154 | 154 | the rules, through the one read MCP `vision` makes; when there is none the |
| 155 | 155 | 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"; | ||
| 131 | 131 | import { forgeItCommands } from "./forge_it.ts"; |
| 132 | 132 | import { makeItYoursScreen, yoursCommands } from "./yours.ts"; |
| 133 | 133 | import { visionCommands } from "./vision.ts"; |
| 134 | import { labelsCommands } from "./labels.ts"; | |
| 134 | 135 | import { appEnvCommands } from "./appenv.ts"; |
| 135 | 136 | import { externalEditor, editorHint, hasExternalEditor } from "./external.ts"; |
| 136 | 137 | import { finderScreen } from "./finder.ts"; |
| @@ -1442,6 +1443,7 @@ export function workspaceScreen( | ||
| 1442 | 1443 | ...forgeItCommands(f, ctx, repo, () => screen), |
| 1443 | 1444 | ...hooksCommands(f, ctx, repo), |
| 1444 | 1445 | ...visionCommands(f, ctx, repo), |
| 1446 | ...labelsCommands(f, ctx, repo), | |
| 1445 | 1447 | ...appEnvCommands(f, ctx, repo), |
| 1446 | 1448 | ], |
| 1447 | 1449 | 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 | }); |
new above
offline