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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2520-in-the-terminal-an-admin-marks-several-r → main 2 commits fork
3 files
changed +195
−3
README.md +5
| @@ -514,6 +514,11 @@ and `forge sync` below is what acts on it. | ||
| 514 | 514 | |
| 515 | 515 | **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). |
| 516 | 516 | |
| 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 | ||
| 517 | 522 | **On you, in the terminal**, answers to the same mutes and one-move verbs as |
| 518 | 523 | the web card. Repositories you muted are hidden and counted ("2 repositories |
| 519 | 524 | 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"; | ||
| 23 | 23 | import { nextStepsFor } from "../../core/next-steps.ts"; |
| 24 | 24 | import { agentsLine } from "../../web/studio/client_home.ts"; |
| 25 | 25 | import { mayView } from "../../core/sealed.ts"; |
| 26 | import { applyFleetIds, previewFleetIds, undoFleetOp } from "../../actions/fleet.ts"; | |
| 26 | 27 | import { pinRepo, unpinRepo } from "../../actions/origins.ts"; |
| 27 | 28 | import { getProposal } from "../../core/proposals.ts"; |
| 28 | 29 | import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts"; |
| @@ -44,6 +45,7 @@ import type { Operation } from "../../core/fleet_ops.ts"; | ||
| 44 | 45 | import { listPins, pinnedRepoIds, type Pin } from "../../core/pins.ts"; |
| 45 | 46 | import { listOrgsForUser } from "../../core/orgs.ts"; |
| 46 | 47 | import { |
| 48 | canAdmin, | |
| 47 | 49 | canRead, |
| 48 | 50 | getRepo, |
| 49 | 51 | getReposByIds, |
| @@ -134,6 +136,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 134 | 136 | /** The rows marked for one verb, by issue id. */ |
| 135 | 137 | const marked = new Set<string>(); |
| 136 | 138 | 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; | |
| 137 | 143 | /** Why planning is paused, for the autonomous apps where it is; as on the board. */ |
| 138 | 144 | let holds = new Map<string, string>(); |
| 139 | 145 | let forge: string[] | undefined; |
| @@ -210,6 +216,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 210 | 216 | repo.ownerId === f.user?.id || orgs.includes(repo.ownerName), |
| 211 | 217 | ) |
| 212 | 218 | : live; |
| 219 | for (const id of [...markedRepos]) | |
| 220 | if (!repos.some((repo) => repo.id === id)) markedRepos.delete(id); | |
| 213 | 221 | ctx.invalidate(); |
| 214 | 222 | }; |
| 215 | 223 | |
| @@ -423,6 +431,65 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 423 | 431 | ); |
| 424 | 432 | }; |
| 425 | 433 | |
| 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 | ||
| 426 | 493 | const undoSet = (): void => { |
| 427 | 494 | const op = lastSet; |
| 428 | 495 | if (op === undefined) { |
| @@ -654,7 +721,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 654 | 721 | binding(["S"], "ship"), |
| 655 | 722 | ] |
| 656 | 723 | : []), |
| 657 | ...(lastSet !== undefined ? [binding(["u"], "undo")] : []), | |
| 724 | ...(lastSet !== undefined || lastArchive !== undefined ? [binding(["u"], "undo")] : []), | |
| 658 | 725 | ...(silenced.size > 0 ? [binding(["L"], "listen again")] : []), |
| 659 | 726 | binding(["d"], "unpin", { |
| 660 | 727 | disabled: focus !== "machine" || pins.length === 0, |
| @@ -663,6 +730,9 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 663 | 730 | binding(["n"], "new repo", { disabled: !f.signedIn }), |
| 664 | 731 | binding(["A"], "account", { disabled: !f.signedIn }), |
| 665 | 732 | 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 | : []), | |
| 666 | 736 | binding(["r"], "refresh"), |
| 667 | 737 | ]; |
| 668 | 738 | }, |
| @@ -799,7 +869,19 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 799 | 869 | if (e.key === "l") return actOnWave("later"), true; |
| 800 | 870 | } |
| 801 | 871 | 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 | } | |
| 803 | 885 | if (e.key === "U" && f.user) return undoHand(), true; |
| 804 | 886 | if (e.key === "L" && f.user) return listenAgain(), true; |
| 805 | 887 | 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 { | ||
| 951 | 1033 | at("repos"), |
| 952 | 1034 | rows.map( |
| 953 | 1035 | (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}`, | |
| 955 | 1037 | secondary: holds.get(repo.id) ?? repo.description, |
| 956 | 1038 | meta: metaOf( |
| 957 | 1039 | 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 | }); |
new above
offline