Public: anyone can read this; the crew can change it.
On the home, an admin selects apps and archives them in one tap, and the answer offers Undo #2524
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2512-on-the-home-an-admin-selects-apps-and-ar → main 5 commits fork 8 threads · 8 unresolved
README.md +8
| @@ -390,6 +390,14 @@ reader and the explorer's default view; `/explore` shows it with `archived:yes` | ||
| 390 | 390 | (the **Show archived** toggle). It stays in `/apps`, search, the palette and |
| 391 | 391 | the repository API (`GET /api/v1/repos`). |
| 392 | 392 | |
| 393 | A site admin archives many at once from the home. In the **Your apps** card, | |
| 394 | **Select** shows a checkbox per app (they take no room until then); tick some | |
| 395 | and press **Archive**. This is the fleet's `archive` operation over those | |
| 396 | repositories (`POST /home/archive`, same-origin, a site admin's), so it has the | |
| 397 | fleet's receipt: the page that answers lists what was archived and an **Undo** | |
| 398 | button for the person who ran it (kept for a day), after which the apps are back | |
| 399 | on the home. The terminal's dashboard archives and undoes the same way (below); either door refreshes an open web home (the `fleet.operated` event). | |
| 400 | ||
| 393 | 401 | ## Health |
| 394 | 402 | |
| 395 | 403 | `GET /api/v1/health` (and the MCP `health` tool) answer a site admin with |
src/core/reserved.ts +1
| @@ -72,6 +72,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 72 | 72 | "flow", |
| 73 | 73 | "front", |
| 74 | 74 | "go", |
| 75 | "home", | |
| 75 | 76 | "icons", |
| 76 | 77 | "live", |
| 77 | 78 | "login", |
src/http/server.ts +4
| @@ -248,6 +248,7 @@ import { | ||
| 248 | 248 | asksMute, |
| 249 | 249 | asksPage, |
| 250 | 250 | } from "../web/studio/asks_page.ts"; |
| 251 | import { homeArchive, homeArchived, homeArchiveUndo } from "../web/studio/home_archive.ts"; | |
| 251 | 252 | import { liveFeedFragment } from "../web/studio/feed.ts"; |
| 252 | 253 | import { serviceWorker } from "../web/sw.ts"; |
| 253 | 254 | import { explorePage } from "../web/studio/explore.ts"; |
| @@ -648,6 +649,9 @@ export function buildRouter(): Router<Handler> { | ||
| 648 | 649 | // Apps and people ranked by real numbers, narrowed in one line (#607). |
| 649 | 650 | router.add("GET", "/explore", explorePage); |
| 650 | 651 | router.add("POST", "/front", frontUpdate); |
| 652 | router.add("POST", "/home/archive", homeArchive); | |
| 653 | router.add("GET", "/home/archived/:op", homeArchived); | |
| 654 | router.add("POST", "/home/archived/:op/undo", homeArchiveUndo); | |
| 651 | 655 | router.add("GET", "/signin", signinPage); |
| 652 | 656 | router.add("POST", "/signin", signinSubmit); |
| 653 | 657 | router.add("GET", "/signin/parent", parentStart); |
src/web/client.ts +4
| @@ -1930,6 +1930,10 @@ export const liveRegionsJs = /* js */ ` | ||
| 1930 | 1930 | // post is in flight) is never pulled out from under the click; the next |
| 1931 | 1931 | // matching event tries the swap again. |
| 1932 | 1932 | if (mine.querySelector('[data-busy]')) return; |
| 1933 | // Likewise a region with a ticked box (apps picked for Archive, or the | |
| 1934 | // Select toggle itself): a tap on a phone does not focus a checkbox, so | |
| 1935 | // focus cannot be what protects the picks. | |
| 1936 | if (mine.querySelector('input[type=checkbox]:checked')) return; | |
| 1933 | 1937 | var active = document.activeElement; |
| 1934 | 1938 | if (active && mine.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return; |
| 1935 | 1939 | if (same(mine.innerHTML, theirs.innerHTML)) return; |
src/web/studio/feed_style.ts +3
| @@ -27,6 +27,9 @@ export const feedCss = /* css */ ` | ||
| 27 | 27 | /* Your apps: a narrow column on a desk, a tab away on a phone. */ |
| 28 | 28 | .home-tabs { display: none; margin-bottom: 12px; } |
| 29 | 29 | .home-app { gap: 8px; } |
| 30 | .home-pick { display: none; } | |
| 31 | #apps-select:checked ~ * .home-pick { display: inline-flex; } | |
| 32 | #apps-select:focus-visible ~ * .home-apps-verbs label { outline: 2px solid var(--accent, currentColor); } | |
| 30 | 33 | .home-apps-verbs { flex-wrap: wrap; padding: 10px 14px; } |
| 31 | 34 | @media (max-width: 899px) { |
| 32 | 35 | .home-tabs { display: inline-flex; } |
src/web/studio/home.ts +31 −4
| @@ -60,7 +60,7 @@ import { passkeyJs } from "../passkey-client.ts"; | ||
| 60 | 60 | const ON_YOU_LIVE = |
| 61 | 61 | "issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged,fleet.operated"; |
| 62 | 62 | /** The events that can change how an app on the rail is. */ |
| 63 | const APPS_LIVE = "app.serving,app.stopped,run.finished"; | |
| 63 | const APPS_LIVE = "app.serving,app.stopped,run.finished,fleet.operated"; | |
Adding |
||
| 64 | 64 | /** How many of your apps the rail names before "All apps". */ |
| 65 | 65 | const RAIL_APPS = 8; |
| 66 | 66 | |
| @@ -83,13 +83,23 @@ function yourApps(ctx: Ctx, user: User): Html { | ||
| 83 | 83 | ? undefined |
| 84 | 84 | : runtime.status(record).find((env) => env.env === "production")?.state; |
| 85 | 85 | return html`<li class="row home-app"> |
| 86 | ${when( | |
| 87 | user.isAdmin, | |
| 88 | () => | |
| 89 | html`<input | |
| 90 | class="home-pick" | |
| 91 | type="checkbox" | |
| 92 | name="repo" | |
| 93 | value="${repo.id}" | |
| 94 | aria-label="Select ${repo.name}" | |
| 95 | />`, | |
| 96 | )} | |
| 86 | 97 | <a class="truncate" href="${repoBase(repo)}">${repo.ownerName}/${repo.name}</a> |
| 87 | 98 | <span class="spacer"></span> |
| 88 | 99 | ${state === undefined ? null : envStateBadge(state)} |
| 89 | 100 | </li>`; |
| 90 | 101 | }); |
| 91 | return card( | |
| 92 | html`${rows.length === 0 | |
| 102 | const list = html`${rows.length === 0 | |
| 93 | 103 | ? html`<p class="muted small">No apps yet.</p>` |
| 94 | 104 | : html`<ul class="list"> |
| 95 | 105 | ${rows} |
| @@ -108,7 +118,24 @@ function yourApps(ctx: Ctx, user: User): Html { | ||
| 108 | 118 | ${when(user.isAdmin, () => |
| 109 | 119 | btn("Front page", { href: "/front", size: "sm", icon: "globe" }), |
| 110 | 120 | )} |
| 111 | </div>`, | |
| 121 | ${when( | |
| 122 | user.isAdmin && rows.length > 0, | |
| 123 | () => html`<label class="btn btn-sm" for="apps-select">Select</label> | |
| 124 | <span class="home-pick">${btn("Archive", { size: "sm" })}</span>`, | |
| 125 | )} | |
| 126 | </div>`; | |
| 127 | return card( | |
| 128 | // An admin's list is a form: ticks cost no space until Select shows them. | |
| 129 | user.isAdmin && rows.length > 0 | |
| 130 | ? html`<form method="post" action="/home/archive"> | |
| 131 | <input | |
This form is inside |
||
| 132 | class="sr-only" | |
| 133 | type="checkbox" | |
| 134 | id="apps-select" | |
| 135 | aria-label="Select apps" | |
| 136 | />${list} | |
| 137 | </form>` | |
| 138 | : list, | |
| 112 | 139 | { |
| 113 | 140 | head: html`${icon("rocket")} |
| 114 | 141 | <h2>Your apps</h2>`, |
src/web/studio/home_archive.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * Archiving apps from the home: an admin ticks apps in the rail and archives | |
| 3 | * them in one step. It is the fleet's own `archive` operation over the ticked | |
| 4 | * repositories (`actions/fleet.ts`) — nothing new is written here — and the | |
| 5 | * page that answers lists what was archived with the operation's undo. | |
| 6 | */ | |
| 7 | ||
| 8 | import { applyFleetIds, getFleetOp, undoFleetOp } from "../../actions/fleet.ts"; | |
| 9 | import type { Ctx } from "../../http/context.ts"; | |
| 10 | import { signInFirst } from "../chrome.ts"; | |
| 11 | import { readForm } from "../form.ts"; | |
| 12 | import { html } from "../html.ts"; | |
| 13 | import { page, redirect } from "../layout.ts"; | |
| 14 | import { btn } from "../ui.ts"; | |
| 15 | import { MAX_ROWS } from "../../fleet/selection.ts"; | |
| 16 | ||
| 17 | /** The ticked repository ids, once each, as long as they are plausible ids. */ | |
| 18 | function picked(form: Awaited<ReturnType<typeof readForm>>): string[] { | |
| 19 | if (form instanceof Response) return []; | |
| 20 | const ids = form | |
| 21 | .getAll("repo") | |
| 22 | .filter((v): v is string => typeof v === "string" && /^[\w-]{1,64}$/.test(v)); | |
| 23 | return [...new Set(ids)]; | |
| 24 | } | |
| 25 | ||
| 26 | // POST /home/archive — archive the ticked apps, a site admin's. | |
| 27 | export async function homeArchive(ctx: Ctx): Promise<Response> { | |
This action exists only on the web. The terminal dashboard needs the same select, archive and undo for an admin, through the same |
||
| 28 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 29 | const form = await readForm(ctx, "admin"); | |
| 30 | if (form instanceof Response) return form; | |
| 31 | if (!ctx.auth.user.isAdmin) return new Response("forbidden", { status: 403 }); | |
| 32 | const ids = picked(form); | |
| 33 | if (ids.length === 0 || ids.length > MAX_ROWS) | |
Low: ticking more than MAX_ROWS apps gets the flash "Tick the apps to archive first.", which is wrong for that case. Split the two cases, or say "Archive at most N at once". |
||
| 34 | return redirect("/", { | |
| 35 | url: ctx.url, | |
| 36 | flash: { tone: "danger", message: "Tick the apps to archive first." }, | |
| 37 | }); | |
| 38 | const op = await applyFleetIds( | |
| 39 | ctx.app, | |
| 40 | ctx.auth.user, | |
| 41 | "repo", | |
| 42 | ids, | |
| 43 | `home: ${ids.length} selected`, | |
| 44 | { verb: "archive", value: true }, | |
| 45 | ); | |
| 46 | if (!op.ok) | |
| 47 | return redirect("/", { | |
| 48 | url: ctx.url, | |
| 49 | flash: { tone: "danger", message: op.error.message }, | |
| 50 | }); | |
| 51 | return redirect(`/home/archived/${op.value.id}`); | |
| 52 | } | |
| 53 | ||
| 54 | // GET /home/archived/:op — what was archived, and the undo. | |
| 55 | export function homeArchived(ctx: Ctx): Response { | |
| 56 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 57 | const found = getFleetOp(ctx.app, ctx.auth.user, ctx.params.op ?? ""); | |
| 58 | if (!found.ok || found.value.verb !== "archive") | |
| 59 | return new Response("not found", { status: 404 }); | |
| 60 | const op = found.value; | |
| 61 | const wasUndo = op.undoOf !== null; | |
| 62 | const rows = op.rows.filter((r) => r.outcome === "ok"); | |
| 63 | const left = op.rows.filter((r) => r.outcome !== "ok"); | |
| 64 | // Only the person who ran it may undo it (`undoFleetOp`), whoever may read it. | |
| 65 | const canUndo = | |
outdated
Minor: |
||
| 66 | op.reversible && op.undoneBy === null && !wasUndo && op.actorId === ctx.auth.user.id; | |
Low: |
||
| 67 | const body = html`<h1>${wasUndo ? "Brought back" : "Archived"}</h1> | |
| 68 | <p id="home-archived" role="status"> | |
| 69 | ${rows.length} app${rows.length === 1 ? "" : "s"} | |
| 70 | ${wasUndo ? "back on the home" : "archived"}${left.length === 0 | |
| 71 | ? "" | |
| 72 | : `, ${left.length} left as they were`}. | |
| 73 | </p> | |
| 74 | <ul class="list"> | |
| 75 | ${rows.map((r) => html`<li class="row">${r.label}</li>`)} | |
| 76 | </ul> | |
| 77 | ${left.length === 0 | |
| 78 | ? null | |
| 79 | : html`<ul class="list muted"> | |
| 80 | ${left.map((r) => html`<li class="row">${r.label}: ${r.reason}</li>`)} | |
| 81 | </ul>`} | |
| 82 | <div class="bar mt"> | |
| 83 | ${canUndo | |
| 84 | ? html`<form method="post" action="/home/archived/${op.id}/undo"> | |
| 85 | ${btn("Undo", { variant: "primary" })} | |
| 86 | </form>` | |
| 87 | : null} | |
| 88 | ${btn("Home", { href: "/" })} | |
| 89 | </div> | |
| 90 | ${canUndo ? html`<p class="small muted">Undo is kept for a day.</p>` : null}`; | |
| 91 | return page({ ctx, title: wasUndo ? "Brought back" : "Archived", body }); | |
| 92 | } | |
| 93 | ||
| 94 | // POST /home/archived/:op/undo — the operation's own undo. | |
| 95 | export async function homeArchiveUndo(ctx: Ctx): Promise<Response> { | |
| 96 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 97 | const form = await readForm(ctx, "admin"); | |
| 98 | if (form instanceof Response) return form; | |
| 99 | const undone = await undoFleetOp(ctx.app, ctx.auth.user, ctx.params.op ?? ""); | |
| 100 | if (!undone.ok && undone.error.code === "not_found") | |
| 101 | return new Response("not found", { status: 404 }); | |
| 102 | if (!undone.ok) | |
| 103 | return redirect("/", { | |
| 104 | url: ctx.url, | |
| 105 | flash: { tone: "danger", message: undone.error.message }, | |
| 106 | }); | |
| 107 | return redirect(`/home/archived/${undone.value.id}`); | |
| 108 | } |
test/home-archive.test.ts added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | /** | |
| 2 | * Archiving from the home: an admin ticks apps and archives them in one | |
| 3 | * step; the answer lists them and offers the fleet's undo. | |
| 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 } from "../src/app.ts"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 13 | import { createSession } from "../src/core/sessions.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { promote } from "./helpers/members.ts"; | |
| 17 | ||
| 18 | describe("home archive", () => { | |
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let root: User; | |
| 23 | let rootCookie: string; | |
| 24 | let bobCookie: string; | |
| 25 | let repos: Repo[]; | |
| 26 | ||
| 27 | const get = (path: string, cookie: string) => | |
| 28 | fetch(`${servers.url}${path}`, { | |
| 29 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 30 | redirect: "manual", | |
| 31 | }); | |
| 32 | const post = (path: string, cookie: string, body: string, origin = servers.url) => | |
| 33 | fetch(`${servers.url}${path}`, { | |
| 34 | method: "POST", | |
| 35 | headers: { | |
| 36 | cookie: `${cookie}; forge_mode=studio`, | |
| 37 | origin, | |
| 38 | "content-type": "application/x-www-form-urlencoded", | |
| 39 | }, | |
| 40 | body, | |
| 41 | redirect: "manual", | |
| 42 | }); | |
| 43 | ||
| 44 | beforeAll(async () => { | |
| 45 | dataDir = await mkdtemp(join(tmpdir(), "forge-home-archive-")); | |
| 46 | app = createApp({ dataDir, port: 0 }); | |
| 47 | servers = await serve(app); | |
| 48 | const mk = async (username: string, first = false) => { | |
| 49 | const r = await createUser(app.db, { | |
| 50 | origin: "seed", | |
| 51 | trusted: true, | |
| 52 | username, | |
| 53 | email: `${username}@forge.local`, | |
| 54 | password: "a-good-passphrase", | |
| 55 | adminIfFirst: first, | |
| 56 | }); | |
| 57 | if (!r.ok) throw new Error(r.error); | |
| 58 | promote(app.db, r.value); | |
| 59 | return r.value; | |
| 60 | }; | |
| 61 | root = await mk("keeper", true); | |
| 62 | const bob = await mk("bob"); | |
| 63 | rootCookie = `forge_session=${createSession(app.db, root.id).token}`; | |
| 64 | bobCookie = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 65 | repos = []; | |
| 66 | for (const name of ["alpha", "beta", "gamma"]) { | |
| 67 | const made = await createRepo(app, root, { name, visibility: "public" }); | |
| 68 | if (!made.ok) throw new Error(String(made.error)); | |
| 69 | insertApp(app.db, { repoId: made.value.id, slug: name, access: "public" }); | |
| 70 | repos.push(made.value); | |
| 71 | } | |
| 72 | const mine = await createRepo(app, bob, { name: "mine", visibility: "public" }); | |
| 73 | if (!mine.ok) throw new Error(String(mine.error)); | |
| 74 | }, 30_000); | |
| 75 | ||
| 76 | afterAll(async () => { | |
| 77 | servers.stop(); | |
| 78 | app.close(); | |
| 79 | await rm(dataDir, { recursive: true, force: true }); | |
| 80 | }); | |
| 81 | ||
| 82 | const body = (...ids: string[]) => ids.map((id) => `repo=${id}`).join("&"); | |
| 83 | const home = async (cookie: string) => (await get("/live/apps", cookie)).text(); | |
| 84 | const full = async (cookie: string) => (await get("/", cookie)).text(); | |
| 85 | ||
| 86 | test("an admin archives two, the answer lists them, Undo brings them back", async () => { | |
| 87 | const before = await full(rootCookie); | |
| 88 | expect(before).toContain('name="repo"'); | |
| 89 | expect(before).toContain("keeper/alpha"); | |
| 90 | ||
| 91 | const res = await post("/home/archive", rootCookie, body(repos[0]!.id, repos[1]!.id)); | |
| 92 | expect(res.status).toBe(303); | |
| 93 | const receipt = res.headers.get("location")!; | |
| 94 | expect(receipt).toStartWith("/home/archived/"); | |
| 95 | ||
| 96 | const after = await home(rootCookie); | |
| 97 | expect(after).not.toContain("keeper/alpha"); | |
| 98 | expect(after).not.toContain("keeper/beta"); | |
| 99 | expect(after).toContain("keeper/gamma"); | |
| 100 | ||
| 101 | const answer = await (await get(receipt, rootCookie)).text(); | |
| 102 | expect(answer).toContain("keeper/alpha"); | |
| 103 | expect(answer).toContain("keeper/beta"); | |
| 104 | expect(answer.replace(/\s+/g, " ")).toContain("2 apps archived"); | |
| 105 | expect(answer).toContain(`action="${receipt}/undo"`); | |
| 106 | ||
| 107 | const undone = await post(`${receipt}/undo`, rootCookie, ""); | |
| 108 | expect(undone.status).toBe(303); | |
| 109 | const back = await home(rootCookie); | |
| 110 | expect(back).toContain("keeper/alpha"); | |
| 111 | expect(back).toContain("keeper/beta"); | |
| 112 | ||
| 113 | const twice = await post(`${receipt}/undo`, rootCookie, ""); | |
| 114 | expect(twice.status).toBe(303); | |
| 115 | expect(twice.headers.get("location")).toBe("/"); | |
| 116 | }); | |
| 117 | ||
| 118 | test("a cross-origin post is refused and archives nothing", async () => { | |
| 119 | const res = await post( | |
| 120 | "/home/archive", | |
| 121 | rootCookie, | |
| 122 | body(repos[2]!.id), | |
| 123 | "https://evil.example", | |
| 124 | ); | |
| 125 | expect(res.status).toBe(403); | |
| 126 | expect(await home(rootCookie)).toContain("keeper/gamma"); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a non-admin sees no checkboxes and cannot post", async () => { | |
| 130 | const page = await full(bobCookie); | |
| 131 | expect(page).not.toContain('name="repo"'); | |
| 132 | expect(page).not.toContain("apps-select"); | |
| 133 | const res = await post("/home/archive", bobCookie, body(repos[2]!.id)); | |
| 134 | expect(res.status).toBe(403); | |
| 135 | expect(await home(rootCookie)).toContain("keeper/gamma"); | |
| 136 | }); | |
| 137 | ||
| 138 | test("another admin reads the receipt but is not offered Undo", async () => { | |
| 139 | app.db.run("UPDATE user SET is_admin = 1 WHERE username = 'bob'"); | |
| 140 | const res = await post("/home/archive", rootCookie, body(repos[2]!.id)); | |
| 141 | const receipt = res.headers.get("location")!; | |
| 142 | const mine = await (await get(receipt, rootCookie)).text(); | |
| 143 | const theirs = await (await get(receipt, bobCookie)).text(); | |
| 144 | expect(mine).toContain("/undo"); | |
| 145 | expect(theirs).toContain("keeper/gamma"); | |
| 146 | expect(theirs).not.toContain("/undo"); | |
| 147 | app.db.run("UPDATE user SET is_admin = 0 WHERE username = 'bob'"); | |
| 148 | }); | |
| 149 | }); |
test/live-pages.test.ts +9
| @@ -169,6 +169,15 @@ describe("live browsing pages", () => { | ||
| 169 | 169 | const { liveRegionsJs } = await import("../src/web/client.ts"); |
| 170 | 170 | expect(liveRegionsJs).toContain("data-busy"); |
| 171 | 171 | }); |
| 172 | ||
| 173 | test("a live region never swaps out from under ticked boxes", async () => { | |
| 174 | const { liveRegionsJs } = await import("../src/web/client.ts"); | |
| 175 | expect(liveRegionsJs).toContain("input[type=checkbox]:checked"); | |
| 176 | // The guard sits in `swap`, before anything is replaced. | |
| 177 | const swap = liveRegionsJs.slice(liveRegionsJs.indexOf("function swap(")); | |
| 178 | expect(swap.indexOf("checkbox]:checked")).toBeGreaterThan(-1); | |
| 179 | expect(swap.indexOf("checkbox]:checked")).toBeLessThan(swap.indexOf("innerHTML = ")); | |
| 180 | }); | |
| 172 | 181 | }); |
| 173 | 182 | |
| 174 | 183 | describe("the beat of a region with data-live-every", () => { |
test/tui-archive.test.ts +80
| @@ -11,6 +11,9 @@ import { setAssignee } from "../src/core/issues.ts"; | ||
| 11 | 11 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 12 | 12 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 13 | 13 | import type { User } from "../src/core/users.ts"; |
| 14 | import { createSession } from "../src/core/sessions.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 14 | 17 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 15 | 18 | import type { TuiIdentity } from "../src/tui/session.ts"; |
| 16 | 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -144,3 +147,80 @@ describe("archive in the terminal", () => { | ||
| 144 | 147 | } |
| 145 | 148 | }); |
| 146 | 149 | }); |
| 150 | ||
| 151 | describe("a terminal archive and the web home", () => { | |
| 152 | let dataDir: string; | |
| 153 | let forge: Forge; | |
| 154 | let admin: User; | |
| 155 | let servers: Servers; | |
| 156 | ||
| 157 | function boot(user: User) { | |
| 158 | const terminal = headlessTerminal(120, 40); | |
| 159 | const { app } = createForgeApp( | |
| 160 | forge, | |
| 161 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 162 | { terminal, exitHooks: false }, | |
| 163 | ); | |
| 164 | app.start(); | |
| 165 | const press = async (...keys: string[]): Promise<void> => { | |
| 166 | for (const key of keys) | |
| 167 | app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 168 | await settleApp(app); | |
| 169 | }; | |
| 170 | return { app, press, text: () => app.screen.toText() }; | |
| 171 | } | |
| 172 | const apps = async (cookie: string) => | |
| 173 | ( | |
| 174 | await fetch(`${servers.url}/live/apps`, { | |
| 175 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 176 | }) | |
| 177 | ).text(); | |
| 178 | ||
| 179 | beforeAll(async () => { | |
| 180 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-bulk-archive-")); | |
| 181 | forge = createApp({ dataDir, port: 0 }); | |
| 182 | const a = await seedAdmin(forge, { | |
| 183 | username: "keeper", | |
| 184 | email: "[email protected]", | |
| 185 | password: "a-long-passphrase", | |
| 186 | }); | |
| 187 | if (!a.ok) throw new Error("signup failed"); | |
| 188 | admin = a.value; | |
| 189 | for (const name of ["alpha", "beta"]) { | |
| 190 | const made = await createRepo(forge, admin, { name, visibility: "public" }); | |
| 191 | if (!made.ok) throw new Error("repo failed"); | |
| 192 | insertApp(forge.db, { repoId: made.value.id, slug: name, access: "public" }); | |
| 193 | } | |
| 194 | servers = await serve(forge); | |
| 195 | }); | |
| 196 | ||
| 197 | afterAll(async () => { | |
| 198 | servers.stop(); | |
Call |
||
| 199 | forge.close(); | |
| 200 | await rm(dataDir, { recursive: true, force: true }); | |
| 201 | }); | |
| 202 | ||
| 203 | test("the web home drops what the terminal archives, and has it back on undo", async () => { | |
| 204 | const cookie = `forge_session=${createSession(forge.db, admin.id).token}`; | |
| 205 | expect(await apps(cookie)).toContain("keeper/alpha"); | |
| 206 | const h = boot(admin); | |
| 207 | try { | |
| 208 | await settleUntil(h.app, "keeper/alpha"); | |
| 209 | await h.press("space", "j", "space"); | |
| 210 | await h.press("a"); | |
| 211 | await h.press("enter"); | |
| 212 | await settleUntil(h.app, "archived 2"); | |
| 213 | expect(getRepo(forge.db, "keeper", "alpha")?.archivedAt).not.toBeNull(); | |
| 214 | expect(getRepo(forge.db, "keeper", "beta")?.archivedAt).not.toBeNull(); | |
| 215 | const gone = await apps(cookie); | |
| 216 | expect(gone).not.toContain("keeper/alpha"); | |
| 217 | expect(gone).not.toContain("keeper/beta"); | |
| 218 | await h.press("u"); | |
| 219 | await settleUntil(h.app, "undone"); | |
| 220 | expect(getRepo(forge.db, "keeper", "alpha")?.archivedAt).toBeNull(); | |
| 221 | expect(await apps(cookie)).toContain("keeper/alpha"); | |
| 222 | } finally { | |
| 223 | h.app.stop(); | |
| 224 | } | |
| 225 | }); | |
| 226 | }); |
Nit: the list is alphabetical, so
homebelongs aftergo.