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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8090-the-terminal-lists-your-archived-reposit → main 1 commit fork
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"; | ||
| 10 | 10 | import type { Session } from "../session.ts"; |
| 11 | 11 | import { entry as access } from "./access.ts"; |
| 12 | 12 | import { entry as agents } from "./agents.ts"; |
| 13 | import { entry as archived } from "./archived.ts"; | |
| 13 | 14 | import { entry as audit } from "./audit.ts"; |
| 14 | 15 | import { entry as boards } from "./boards.ts"; |
| 15 | 16 | import { entry as bulk } from "./bulk.ts"; |
| @@ -70,6 +71,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 70 | 71 | limits, |
| 71 | 72 | teams, |
| 72 | 73 | agents, |
| 74 | archived, | |
| 73 | 75 | invites, |
| 74 | 76 | bulk, |
| 75 | 77 | subscriptions, |
test/palette-roster.test.ts +3
| @@ -104,6 +104,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 104 | 104 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 105 | 105 | ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"], |
| 106 | 106 | ["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"], | |
| 107 | 108 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 108 | 109 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 109 | 110 | ["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", | ||
| 153 | 154 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 154 | 155 | ["can", "What I can do", "your role and what it allows here"], |
| 155 | 156 | ["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"], | |
| 156 | 158 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 157 | 159 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 158 | 160 | ["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" | ||
| 199 | 201 | ["model", "Model budget", "what your crew may spend, and add your own subscription"], |
| 200 | 202 | ["can", "What I can do", "your role and what it allows here"], |
| 201 | 203 | ["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"], | |
| 202 | 205 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 203 | 206 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 204 | 207 | ["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 | }); |
new above
offline