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

In the terminal, an admin marks several repositories and archives them in one keypress, and u undoes it #2523

Merged opened this

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

Merged

scout/forge:scout/2520-in-the-terminal-an-admin-marks-several-r → main 2 commits fork

  • Merged. scout/2520-in-the-terminal-an-admin-marks-several-r was merged into main as 4943aff.

Review

Sign in to review this change.
3 files changed +195 −3
README.md +5
@@ -514,6 +514,11 @@ and `forge sync` below is what acts on it.
514514
515515 **Chips on the feed**: kind chips switch a kind of post off: `reviews` (pulls), `decisions` (decisions and choose decks), `crew` (tells, replies, looks) and `releases`; a person chip switches one person or agent off. Saved per user, one filter (`chipHides`) behind the home feed, `/api/v1/feed`, the Asks list and the terminal's inbox, and the MCP `feed` and `asks` tools, which also take `hide_kinds` and `hide_people` to replace the saved filter for that one call (`[]` lifts it). `/live/apps` (your apps) is not feed items, so chips do not apply there. `GET /api/v1/feed/chips` answers `{kinds, people}`; `POST /api/v1/feed/chips` takes `{"kind": "kind"|"person", "value": "reviews"|"<username>", "hidden": true|false}` (scope `rw`, same-origin). In the terminal dashboard `1`–`4` switch the kind chips `h` on an On you row switches its author off (not your own) and the author's rows leave the card, and `P` brings every person back. The `feed_chips` tool reads them (no arguments, any token) and switches one (`kind`, `value`, `hidden`; an `rw` token).
516516
517 **Archive many, in the terminal:** on the home's repositories, an admin of a
518 repository marks it with `space`, `a` previews the marked ones by name and
519 archives them through the fleet `archive` verb (`applyFleetIds`), and `u` undoes
520 it (`undoFleetOp`). A non-admin has neither key.
521
517522 **On you, in the terminal**, answers to the same mutes and one-move verbs as
518523 the web card. Repositories you muted are hidden and counted ("2 repositories
519524 muted"). On the On you card: `space` marks a row, `x` closes the marked rows
src/tui/screens/dashboard.ts +85 −3
@@ -23,6 +23,7 @@ import { clientAppRows } from "../../core/client-apps.ts";
2323 import { nextStepsFor } from "../../core/next-steps.ts";
2424 import { agentsLine } from "../../web/studio/client_home.ts";
2525 import { mayView } from "../../core/sealed.ts";
26 import { applyFleetIds, previewFleetIds, undoFleetOp } from "../../actions/fleet.ts";
2627 import { pinRepo, unpinRepo } from "../../actions/origins.ts";
2728 import { getProposal } from "../../core/proposals.ts";
2829 import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts";
@@ -44,6 +45,7 @@ import type { Operation } from "../../core/fleet_ops.ts";
4445 import { listPins, pinnedRepoIds, type Pin } from "../../core/pins.ts";
4546 import { listOrgsForUser } from "../../core/orgs.ts";
4647 import {
48 canAdmin,
4749 canRead,
4850 getRepo,
4951 getReposByIds,
@@ -134,6 +136,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
134136 /** The rows marked for one verb, by issue id. */
135137 const marked = new Set<string>();
136138 let lastSet: Operation | undefined;
139 /** The repositories marked for archiving, by id; only ones the viewer administers. */
140 const markedRepos = new Set<string>();
141 /** The receipt of the last archive made here, which `u` reverses once. */
142 let lastArchive: string | undefined;
137143 /** Why planning is paused, for the autonomous apps where it is; as on the board. */
138144 let holds = new Map<string, string>();
139145 let forge: string[] | undefined;
@@ -210,6 +216,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
210216 repo.ownerId === f.user?.id || orgs.includes(repo.ownerName),
211217 )
212218 : live;
219 for (const id of [...markedRepos])
220 if (!repos.some((repo) => repo.id === id)) markedRepos.delete(id);
213221 ctx.invalidate();
214222 };
215223
@@ -423,6 +431,65 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
423431 );
424432 };
425433
434 /** Whether the viewer may archive this repository (the action decides again). */
435 const mayArchive = (repo: Repo): boolean =>
436 f.user !== undefined && f.user.kind === "user" && canAdmin(f.db, repo, f.user);
437
438 /** Archive the marked repositories: a one-line preview naming them, then the fleet `archive` verb. */
439 const archiveMarked = (): void => {
440 const picked = repos.filter((repo) => markedRepos.has(repo.id) && mayArchive(repo));
441 if (picked.length === 0 || f.user === undefined) return;
442 const ids = picked.map((repo) => repo.id);
443 const names = picked.map((repo) => `${repo.ownerName}/${repo.name}`);
444 const query = `archive ${names.join(" ")}`;
445 const action = { verb: "archive", value: true };
446 const plan = previewFleetIds(f.app, f.user, "repo", ids, query, action);
447 if (!plan.ok) {
448 ctx.toast(plan.error.message, "danger");
449 return;
450 }
451 if (plan.value.counts.change === 0) {
452 ctx.toast("nothing to archive", "attn");
453 return;
454 }
455 ctx.push(
456 confirmScreen(ctx, screen, {
457 title: `Archive ${plural(plan.value.counts.change, "repository", "repositories")}?`,
458 body: [names.join(", ")],
459 actions: [
460 { label: "Archive", variant: "danger" },
461 { label: "Cancel", variant: "ghost" },
462 ],
463 onChoose: (choice) => {
464 if (choice !== 0) return;
465 runAsync(ctx, async () => {
466 const done = await applyFleetIds(f.app, f.user, "repo", ids, query, action);
467 if (!done.ok) {
468 ctx.toast(done.error.message, "danger");
469 return;
470 }
471 const n = done.value.rows.filter((r) => r.outcome === "ok").length;
472 lastArchive = n > 0 ? done.value.id : undefined;
473 markedRepos.clear();
474 reload();
475 ctx.toast(`archived ${n} — u undoes it`, n > 0 ? "success" : "attn");
476 });
477 },
478 }),
479 );
480 };
481
482 const undoArchive = (): void => {
483 const id = lastArchive;
484 if (id === undefined) return;
485 runAsync(ctx, async () => {
486 const done = await undoFleetOp(f.app, f.user, id);
487 lastArchive = undefined;
488 reload();
489 ctx.toast(done.ok ? "undone" : done.error.message, done.ok ? "success" : "danger");
490 });
491 };
492
426493 const undoSet = (): void => {
427494 const op = lastSet;
428495 if (op === undefined) {
@@ -654,7 +721,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
654721 binding(["S"], "ship"),
655722 ]
656723 : []),
657 ...(lastSet !== undefined ? [binding(["u"], "undo")] : []),
724 ...(lastSet !== undefined || lastArchive !== undefined ? [binding(["u"], "undo")] : []),
658725 ...(silenced.size > 0 ? [binding(["L"], "listen again")] : []),
659726 binding(["d"], "unpin", {
660727 disabled: focus !== "machine" || pins.length === 0,
@@ -663,6 +730,9 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
663730 binding(["n"], "new repo", { disabled: !f.signedIn }),
664731 binding(["A"], "account", { disabled: !f.signedIn }),
665732 binding(["s"], "sign in", { disabled: f.signedIn }),
733 ...(focus === "repos" && f.user !== undefined && shown().some(mayArchive)
734 ? [binding(["space"], "mark"), ...(markedRepos.size > 0 ? [binding(["a"], "archive marked")] : [])]
735 : []),
666736 binding(["r"], "refresh"),
667737 ];
668738 },
@@ -799,7 +869,19 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
799869 if (e.key === "l") return actOnWave("later"), true;
800870 }
801871 if (e.key === "B" && f.user && asideWaves.length > 0) return bringBackWaves(), true;
802 if (e.key === "u" && f.user) return undoSet(), true;
872 if (focus === "repos" && f.user !== undefined) {
873 const repo = shown()[at("repos").index];
874 if (e.key === "space" && repo !== undefined && mayArchive(repo)) {
875 if (!markedRepos.delete(repo.id)) markedRepos.add(repo.id);
876 ctx.invalidate();
877 return true;
878 }
879 if (e.key === "a") return archiveMarked(), true;
880 }
881 if (e.key === "u" && f.user) {
882 if (lastArchive !== undefined) return undoArchive(), true;
883 return undoSet(), true;
884 }
803885 if (e.key === "U" && f.user) return undoHand(), true;
804886 if (e.key === "L" && f.user) return listenAgain(), true;
805887 if (e.key === "C" && f.user) return ctx.push(deciderScreen(f, ctx)), true;
@@ -951,7 +1033,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
9511033 at("repos"),
9521034 rows.map(
9531035 (repo): ListRow => ({
954 title: `${repoMark(pinned.has(repo.id), set)}${repo.ownerName}/${repo.name}`,
1036 title: `${markedRepos.has(repo.id) ? "[x] " : ""}${repoMark(pinned.has(repo.id), set)}${repo.ownerName}/${repo.name}`,
9551037 secondary: holds.get(repo.id) ?? repo.description,
9561038 meta: metaOf(
9571039 repo.visibility === "private" ? "private" : "",
test/tui-archive-many.test.ts added +105
@@ -0,0 +1,105 @@
1 /** Mark several repositories on the terminal's home and archive them in one keypress; `u` undoes it (#2520). */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App as Forge } from "../src/app.ts";
9 import { getRepo } from "../src/core/repos.ts";
10 import { createUser, getUserByName, type User } from "../src/core/users.ts";
11 import { createForgeApp } from "../src/tui/forge.ts";
12 import { headlessTerminal } from "../src/tui/term/terminal.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14 import { settleApp, settleUntil } from "./helpers/tui.ts";
15
16 quietLogs();
17
18 describe("archive many repositories from the terminal home", () => {
19 let dataDir: string;
20 let forge: Forge;
21 let alice: User;
22 let bob: User;
23
24 function boot(user: User) {
25 const terminal = headlessTerminal(120, 40);
26 const { app } = createForgeApp(
27 forge,
28 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
29 { terminal, exitHooks: false },
30 );
31 app.start();
32 const press = async (...keys: string[]): Promise<void> => {
33 for (const key of keys)
34 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
35 await settleApp(app);
36 };
37 return { app, press, text: () => app.screen.toText() };
38 }
39 const archived = (name: string): boolean =>
40 getRepo(forge.db, "alice", name)?.archivedAt != null;
41
42 beforeAll(async () => {
43 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-archive-many-"));
44 forge = createApp({ dataDir, port: 0 });
45 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
46 const made = await createUser(forge.db, {
47 origin: "seed",
48 trusted: true,
49 username,
50 email: `${username}@forge.local`,
51 password: "a-good-passphrase",
52 adminIfFirst,
53 });
54 if (!made.ok) throw new Error("user");
55 }
56 alice = getUserByName(forge.db, "alice")!;
57 bob = getUserByName(forge.db, "bob")!;
58 for (const name of ["one", "two", "three"]) {
59 const made = await createRepo(forge, alice, { name, visibility: "public" });
60 if (!made.ok) throw new Error("repo");
61 }
62 });
63
64 afterAll(async () => {
65 await rm(dataDir, { recursive: true, force: true });
66 });
67
68 test("mark two, archive with a preview, then u wakes both", async () => {
69 const h = boot(alice);
70 try {
71 await settleUntil(h.app, "alice/one");
72 // Rows are newest first or by name; mark whichever two are under the cursor.
73 await h.press("space", "j", "space");
74 expect(h.text().match(/\[x\]/g)?.length).toBe(2);
75 await h.press("a");
76 expect(h.text()).toContain("Archive 2 repositories?");
77 await h.press("enter");
78 await settleUntil(h.app, "archived 2");
79 expect(h.text()).toContain("u undoes it");
80 const gone = ["one", "two", "three"].filter(archived);
81 expect(gone.length).toBe(2);
82 await h.press("u");
83 await settleUntil(h.app, "undone");
84 expect(["one", "two", "three"].filter(archived)).toEqual([]);
85 } finally {
86 h.app.stop();
87 }
88 });
89
90 test("a non-admin cannot mark or archive", async () => {
91 const h = boot(bob);
92 try {
93 await h.press("/");
94 for (const ch of "alice/") await h.press(ch);
95 await h.press("enter");
96 await settleUntil(h.app, "alice/one");
97 await h.press("space", "a");
98 expect(h.text()).not.toContain("[x]");
99 expect(h.text()).not.toContain("Archive");
100 expect(["one", "two", "three"].filter(archived)).toEqual([]);
101 } finally {
102 h.app.stop();
103 }
104 });
105 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs