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

The terminal lists your archived repositories from the `:` palette, and `a` wakes the selected one #8115

Merged opened this

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

Merged

mason/forge:mason/8090-the-terminal-lists-your-archived-reposit → main 1 commit fork

  • Merged. mason/8090-the-terminal-lists-your-archived-reposit was merged into main as 3e0ebda.

Review

Sign in to review this change.
4 files changed +221 −0
src/tui/screens/archived.ts added +93
@@ -0,0 +1,93 @@
1 /**
2 * Archived repositories in the terminal: what /apps?archived=1 lists on the
3 * web. Archiving hides a repository from the dashboard, never from its
4 * readers; this screen is the way back. Enter opens the About card; `a` wakes
5 * the selected one through `updateRepoSettings`, offered only to a person who
6 * administers it.
7 */
8
9 import { updateRepoSettings } from "../../actions/repos.ts";
10 import { appsForRepos } from "../../core/apps.ts";
11 import { canAdmin, listReposVisibleTo, type Repo } from "../../core/repos.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { binding } from "../app/keys.ts";
14 import { style } from "../term/style.ts";
15 import { truncate } from "../term/width.ts";
16 import type { Session } from "../session.ts";
17 import { fill, len, split } from "../ui/layout.ts";
18 import { emptyState } from "../ui/widgets.ts";
19 import { aboutScreen } from "./about.ts";
20 import { ListState, drawList, type ListRow } from "./common.ts";
21 import type { PaletteEntry } from "./entries.ts";
22 import { local } from "./where.ts";
23
24 /** Newest archived first; visibility is decided in SQL, as the web's list does. */
25 function archivedRepos(f: Session): { repos: Repo[]; withApp: Set<string> } {
26 const repos = listReposVisibleTo(f.db, f.user, { limit: 200, archivedOnly: true }).sort((a, b) =>
27 (b.archivedAt ?? "").localeCompare(a.archivedAt ?? ""),
28 );
29 const withApp = new Set(appsForRepos(f.db, repos.map((r) => r.id)).keys());
30 return { repos, withApp };
31 }
32
33 export function archivedScreen(f: Session, ctx: Ctx): Screen {
34 const state = new ListState();
35 let { repos, withApp } = archivedRepos(f);
36 const selected = (): Repo | undefined => repos[state.index];
37 // A person's, an admin's: the About card's gate for `a`.
38 const mayWake = (repo: Repo | undefined): repo is Repo =>
39 repo !== undefined && f.user !== undefined && f.user.kind !== "agent" && canAdmin(f.db, repo, f.user);
40
41 const screen: Screen = {
42 title: "Archived repositories",
43 keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "about"), binding(["a"], "wake")],
44 render(area, s) {
45 const c = ctx.theme.c;
46 const [head, body] = split(area, "v", [len(1), fill(1)]);
47 if (repos.length === 0) {
48 emptyState(s, body!, c, { title: "Nothing archived." });
49 return;
50 }
51 const rows: ListRow[] = repos.map((r) => ({
52 title: `${r.ownerName}/${r.name}`,
53 secondary: (r.archivedAt ?? "").slice(0, 10),
54 meta: withApp.has(r.id) ? "app spun down" : "",
55 }));
56 const line = mayWake(selected()) ? "enter opens it · a wakes it" : "enter opens it";
57 s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
58 drawList(s, body!, c, state, rows);
59 },
60 key(e) {
61 if (state.key(e, repos.length)) return true;
62 if (e.ctrl || e.alt) return false;
63 const repo = selected();
64 if (repo === undefined) return false;
65 if (e.key === "enter") {
66 ctx.push(aboutScreen(f, ctx, screen, local(f, repo), undefined, ""));
67 return true;
68 }
69 if (e.key === "a" && mayWake(repo)) {
70 void updateRepoSettings(f.app, f.user, repo, { archived: false }).then((done) => {
71 if (done.ok) {
72 ({ repos, withApp } = archivedRepos(f));
73 state.clamp(repos.length);
74 ctx.toast("awake again", "success");
75 } else ctx.toast("that could not be changed", "danger");
76 ctx.invalidate();
77 });
78 return true;
79 }
80 return false;
81 },
82 };
83 return screen;
84 }
85
86 /** Archived repositories, as /apps?archived=1 has them. */
87 export const entry: PaletteEntry = {
88 id: "archived",
89 title: "Archived repositories",
90 hint: "put away, not gone: open one or wake it",
91 offered: (session) => session.user !== undefined,
92 run: (session, ctx) => ctx.push(archivedScreen(session, ctx)),
93 };
src/tui/screens/entries.ts +2
@@ -10,6 +10,7 @@ import type { Command } from "../app/palette.ts";
1010 import type { Session } from "../session.ts";
1111 import { entry as access } from "./access.ts";
1212 import { entry as agents } from "./agents.ts";
13 import { entry as archived } from "./archived.ts";
1314 import { entry as audit } from "./audit.ts";
1415 import { entry as boards } from "./boards.ts";
1516 import { entry as bulk } from "./bulk.ts";
@@ -70,6 +71,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
7071 limits,
7172 teams,
7273 agents,
74 archived,
7375 invites,
7476 bulk,
7577 subscriptions,
test/palette-roster.test.ts +3
@@ -104,6 +104,7 @@ test("a site-admin person is offered the whole roster", () => {
104104 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
105105 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
106106 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
107 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
107108 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
108109 ["origins", "Connected origins", "the hosts your mirrors come from"],
109110 ["domains", "Failure domains", "failure domains and their last drill"],
@@ -153,6 +154,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
153154 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
154155 ["can", "What I can do", "your role and what it allows here"],
155156 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
157 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
156158 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
157159 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
158160 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
@@ -199,6 +201,7 @@ test("a member person is offered the signed-in roster without the admin entries"
199201 ["model", "Model budget", "what your crew may spend, and add your own subscription"],
200202 ["can", "What I can do", "your role and what it allows here"],
201203 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
204 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
202205 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
203206 ["origins", "Connected origins", "the hosts your mirrors come from"],
204207 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
test/tui-archived.test.ts added +123
@@ -0,0 +1,123 @@
1 /**
2 * Archived repositories in the terminal: listed from the palette for those
3 * who may read them, woken with `a` by a person who administers one.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App as Forge } from "../src/app.ts";
10 import { createUser, getUserByName } from "../src/core/users.ts";
11 import { createRepo, getRepo, moveArchived } from "../src/core/repos.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import type { TuiIdentity } from "../src/tui/session.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { settleApp } from "./helpers/tui.ts";
17
18 quietLogs();
19
20 describe("archived repositories in the terminal", () => {
21 let dataDir: string;
22 let forge: Forge;
23
24 function boot(username: string) {
25 const terminal = headlessTerminal(140, 32);
26 const user = getUserByName(forge.db, username)!;
27 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
28 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
29 app.start();
30 const key = (k: string, ctrl = false) =>
31 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
32 return {
33 text: () => app.screen.toText(),
34 press: async (...keys: string[]) => {
35 keys.forEach((k) => key(k));
36 await settleApp(app);
37 },
38 palette: async () => {
39 key("k", true);
40 for (const ch of "Archived repositories") key(ch);
41 key("enter");
42 await settleApp(app);
43 },
44 stop: () => app.stop(),
45 };
46 }
47
48 const make = async (owner: string, name: string, visibility: "public" | "private", archived: boolean) => {
49 const made = await createRepo(forge.db, forge.reposRoot, {
50 ownerId: getUserByName(forge.db, owner)!.id,
51 name,
52 visibility,
53 });
54 if (!made.ok) throw new Error(made.error);
55 if (archived) moveArchived(forge.db, made.value.id, true);
56 };
57
58 beforeAll(async () => {
59 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-archived-"));
60 forge = createApp({ dataDir, port: 0 });
61 for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) {
62 const made = await createUser(forge.db, {
63 origin: "seed",
64 trusted: true,
65 username,
66 email: `${username}@forge.local`,
67 password: "a-good-passphrase",
68 adminIfFirst,
69 });
70 if (!made.ok) throw new Error(made.error);
71 }
72 await make("carol", "shelved", "public", true);
73 await make("carol", "busy", "public", false);
74 await make("bob", "hidden", "private", true);
75 await make("bob", "theirs", "public", true);
76 });
77 afterAll(async () => {
78 forge.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 test("an archived repository is listed, an unarchived one and someone else's private one are not", async () => {
83 const h = boot("carol");
84 await h.palette();
85 const text = h.text();
86 expect(text).toContain("carol/shelved");
87 expect(text).toContain("bob/theirs");
88 expect(text).not.toContain("carol/busy");
89 expect(text).not.toContain("bob/hidden");
90 h.stop();
91 });
92
93 test("a wakes the selected repository, and its row leaves the list", async () => {
94 const h = boot("carol");
95 await h.palette();
96 // Newest archived first: bob/theirs was archived last.
97 await h.press("j");
98 expect(h.text()).toContain("a wakes it");
99 await h.press("a");
100 expect(getRepo(forge.db, "carol", "shelved")!.archivedAt).toBeNull();
101 expect(h.text()).toContain("awake again");
102 expect(h.text()).not.toContain("carol/shelved");
103 h.stop();
104 });
105
106 test("a non-admin has no a", async () => {
107 const h = boot("carol");
108 await h.palette();
109 expect(h.text()).toContain("bob/theirs");
110 expect(h.text()).not.toContain("a wakes it");
111 await h.press("a");
112 expect(getRepo(forge.db, "bob", "theirs")!.archivedAt).not.toBeNull();
113 h.stop();
114 });
115
116 test("an empty list says Nothing archived.", async () => {
117 const h = boot("carol");
118 moveArchived(forge.db, getRepo(forge.db, "bob", "theirs")!.id, false);
119 await h.palette();
120 expect(h.text()).toContain("Nothing archived.");
121 h.stop();
122 });
123 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs