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

An admin deletes a label from the terminal's Labels screen with `d` after a confirm #7967

Merged opened this

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

Merged

mason/forge:mason/7843-an-admin-deletes-a-label-from-the-termin → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/7843-an-admin-deletes-a-label-from-the-termin was merged into main as 4e89e6a.

Review

Sign in to review this change.
3 files changed +99 −12
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), *Labels* (its labels with their hex; an admin presses `n`, types a name and six hex digits, `bug d73a4a`, the `#` optional, and the list shows the new label at once — a reader is not offered `n`), 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* (its labels with their hex; an admin presses `n`, types a name and six hex digits, `bug d73a4a`, the `#` optional, and the list shows the new label at once — `d` on a label asks first, naming it, with Keep the default, then deletes it; a reader is offered neither `n` nor `d`), 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 +36 −9
@@ -7,14 +7,14 @@
77
88 import { LABEL_WORDS } from "../../actions/label_words.ts";
99 import { requireRepoAdmin } from "../../actions/access.ts";
10 import { createLabel } from "../../actions/repos.ts";
10 import { createLabel, deleteLabel } from "../../actions/repos.ts";
1111 import { canRead, type Repo } from "../../core/repos.ts";
1212 import { listLabels, type Label } from "../../core/issues.ts";
1313 import type { Ctx, Screen } from "../app/app.ts";
1414 import { binding } from "../app/keys.ts";
1515 import type { Command } from "../app/palette.ts";
1616 import { style } from "../term/style.ts";
17 import { ListState, promptScreen } from "./common.ts";
17 import { ListState, confirmScreen, promptScreen } from "./common.ts";
1818 import { labelChip } from "../ui/widgets.ts";
1919 import type { Session } from "../session.ts";
2020
@@ -22,11 +22,14 @@ import type { Session } from "../session.ts";
2222 export const NOT_FOUND_LINE = "No such repository";
2323 export const NOT_ALLOWED_LINE = "Only a repository admin can make a label.";
2424 export const NO_LABELS_LINE = "No labels yet.";
25 export const DELETED_LINE = "Deleted that label.";
26 export const GONE_LINE = "That label is already gone, or you can no longer delete it.";
2527
2628 export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
2729 let labels: Label[] | undefined;
2830 let hidden = false;
2931 const state = new ListState();
32 let note = "";
3033 // Only whoever `requireRepoAdmin` admits is offered `n`.
3134 const admin = (): boolean => requireRepoAdmin(f.app, f.user, repo).ok;
3235
@@ -42,11 +45,27 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4245 ctx.invalidate();
4346 return undefined;
4447 };
48 const ask = (picked: Label): void => {
49 ctx.push(
50 confirmScreen(ctx, screen, {
51 title: "Delete a label",
52 body: [`Delete the label ${picked.name}? Issues carrying it lose it.`],
53 actions: [{ label: "Keep", variant: "ghost" }, { label: "Delete", variant: "danger" }],
54 onChoose: (i) => {
55 if (i < 1) return;
56 note = deleteLabel(f.app, f.user, repo, picked.id).ok ? DELETED_LINE : GONE_LINE;
57 labels = listLabels(f.db, repo.id);
58 state.clamp(labels.length);
59 ctx.invalidate();
60 },
61 }),
62 );
63 };
4564 const screen: Screen = {
4665 title: "Labels",
4766 // Read each time: a demoted admin stops being offered `n`.
4867 get keys() {
49 return [binding(["j", "k"], "move"), ...(admin() ? [binding(["n"], "new label")] : []), binding(["esc", "q"], "back")];
68 return [binding(["j", "k"], "move"), ...(admin() ? [binding(["n"], "new label"), binding(["d"], "delete label")] : []), binding(["esc", "q"], "back")];
5069 },
5170 async load() {
5271 // Gated here as well as on the command: the screen is exported.
@@ -60,9 +79,15 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
6079 const line = (y: number, text: string) => s.text(area.x + 1, area.y + y, text, style(c.fg, c.bg), right);
6180 if (hidden) return void line(0, NOT_FOUND_LINE);
6281 if (labels === undefined) return void line(0, "Reading…");
63 if (labels.length === 0) return void line(0, NO_LABELS_LINE);
64 const from = state.window(area.h, labels.length);
65 for (let i = 0; i < area.h; i++) {
82 if (labels.length === 0) {
83 line(0, NO_LABELS_LINE);
84 if (note !== "") line(area.h - 1, note);
85 return;
86 }
87 const rows = note === "" ? area.h : Math.max(1, area.h - 1);
88 if (note !== "") line(area.h - 1, note);
89 const from = state.window(rows, labels.length);
90 for (let i = 0; i < rows; i++) {
6691 const l = labels[from + i];
6792 if (l === undefined) break;
6893 const picked = from + i === state.index;
@@ -72,10 +97,13 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
7297 }
7398 },
7499 key(e) {
100 note = "";
75101 if (e.key === "j" || e.key === "down") state.move(1, labels?.length ?? 0);
76102 else if (e.key === "k" || e.key === "up") state.move(-1, labels?.length ?? 0);
77103 else if (e.key === "esc" || e.key === "q") ctx.pop();
outdated

Clear note for every key, not only here. Right now it survives j/k and n, and keeps the list a row short.

78 else if (e.key === "n" && !e.ctrl && !e.alt && !hidden && admin()) {
104 else if (e.key === "d" && !e.ctrl && !e.alt && !hidden && admin() && labels?.[state.index] !== undefined) {
105 ask(labels[state.index]!);
106 } else if (e.key === "n" && !e.ctrl && !e.alt && !hidden && admin()) {
79107 ctx.push(
80108 promptScreen(ctx, screen, {
81109 title: "New label",
@@ -83,8 +111,7 @@ export function labelsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
83111 onSubmit: make,
84112 }),
85113 );
86 }
87 else return false;
114 } else return false;
88115 ctx.invalidate();
89116 return true;
90117 },
test/tui-labels.test.ts +62 −2
@@ -3,7 +3,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test";
33 import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
6 import { createRepo } from "../src/actions/repos.ts";
6 import { createRepo, deleteLabel } from "../src/actions/repos.ts";
77 import { createApp, type App } from "../src/app.ts";
88 import { LABEL_WORDS } from "../src/actions/label_words.ts";
99 import { createLabel, listLabels } from "../src/core/issues.ts";
@@ -11,7 +11,7 @@ import type { Repo } from "../src/core/repos.ts";
1111 import { createUser, type User } from "../src/core/users.ts";
1212 import { createForgeApp, Session } from "../src/tui/forge.ts";
1313 import type { Ctx } from "../src/tui/app/app.ts";
14 import { labelsCommands, labelsScreen, NOT_ALLOWED_LINE, NOT_FOUND_LINE, NO_LABELS_LINE } from "../src/tui/screens/labels.ts";
14 import { DELETED_LINE, GONE_LINE, labelsCommands, labelsScreen, NOT_ALLOWED_LINE, NOT_FOUND_LINE, NO_LABELS_LINE } from "../src/tui/screens/labels.ts";
outdated

Stray blank line in the imports; remove it.

1515 import { repoScreen } from "../src/tui/screens/repo.ts";
1616 import { local } from "../src/tui/screens/where.ts";
1717 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -220,3 +220,63 @@ test("a # before the colour and a name of several words are accepted", async ()
220220 expect(text).toContain("#7057ff");
221221 expect(listLabels(app.db, bare.id).map((l) => l.name)).toContain("good first issue");
222222 });
223
224 async function deleting(repo: Repo, user: User | undefined, keys: string[], beforeLast?: () => void) {
225 const terminal = headlessTerminal(100, 30);
226 const { app: tui, session } = createForgeApp(app, user ? { ...guest, user } : guest, { terminal, exitHooks: false });
227 tui.start();
228 tui.ctx.push(labelsScreen(session, tui.ctx, repo));
229 await settleApp(tui);
230 const press = (key: string) => tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
231 keys.forEach((k, i) => {
232 if (i === keys.length - 1) beforeLast?.();
233 press(k);
234 });
235 await settleApp(tui);
236 const text = tui.screen.toText();
237 tui.stop?.();
238 return text;
239 }
240
241 test("d asks naming the label; cancel is the default and keeps it", async () => {
242 const mid = await deleting(many, alice, ["d"]);
243 expect(mid).toContain("l00");
244 expect(mid).toContain("Delete the label l00?");
245 const text = await deleting(many, alice, ["d", "enter"]);
246 expect(text).not.toContain(DELETED_LINE);
247 expect(listLabels(app.db, many.id).map((l) => l.name)).toContain("l00");
248 });
249
250 test("d then Delete removes the label and the list drops it", async () => {
251 createLabel(app.db, open.id, "aaa-doomed", "123456");
252 const text = await deleting(open, alice, ["d", "right", "enter"]);
253 expect(text).toContain(DELETED_LINE);
254 expect(text).not.toContain("aaa-doomed");
255 expect(listLabels(app.db, open.id).map((l) => l.name)).not.toContain("aaa-doomed");
256 });
257
258 test("the result line clears on the next key", async () => {
259 createLabel(app.db, open.id, "aac-doomed", "123456");
260 const text = await deleting(open, alice, ["d", "right", "enter", "j"]);
261 expect(text).not.toContain(DELETED_LINE);
262 });
263
264 test("a delete that fails says so and changes nothing", async () => {
265 const doomed = createLabel(app.db, open.id, "aab-gone", "123456");
266 if (!doomed.ok) throw new Error("label");
267 const text = await deleting(open, alice, ["d", "right", "enter"], () => {
268 deleteLabel(app, alice, open, doomed.value.id);
269 });
270 expect(text).toContain(GONE_LINE);
271 expect(text).not.toContain(DELETED_LINE);
272 });
273
274 test("a reader is not offered d and d deletes nothing", async () => {
275 const stranger = new Session(app, guest);
276 const ctx = { invalidate() {}, push() { throw new Error("pushed"); } } as unknown as Ctx;
277 const screen = labelsScreen(stranger, ctx, open);
278 expect(screen.keys.some((k) => k.keys.includes("d"))).toBe(false);
279 await screen.load!(new AbortController().signal);
280 expect(screen.key!({ type: "key", key: "d", ctrl: false, alt: false, shift: false } as never)).toBe(false);
281 expect(labelsScreen(new Session(app, { ...guest, user: alice }), ctx, open).keys.some((k) => k.keys.includes("d"))).toBe(true);
282 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs