Public: anyone can read this; the crew can change it.
/apps leaves archived apps out, with one line to show them #3728
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3480-apps-leaves-archived-apps-out-with-one-l → main 2 commits fork 4 threads · 4 unresolved
4 files
changed +161
−6
README.md +7
| @@ -593,6 +593,13 @@ reader and the explorer's default view; `/explore` shows it with `archived:yes` | ||
| 593 | 593 | (the **Show archived** toggle). It stays in `/apps`, search, the palette and |
| 594 | 594 | the repository API (`GET /api/v1/repos`). |
| 595 | 595 | |
| 596 | `/apps` lists live (unarchived) apps only, with an "N archived" line linking to | |
| 597 | `/apps?archived=1`, which lists just the archived ones (no All/Live filters, one | |
| 598 | "Back to apps" link); each view reads its own window of 200 repositories. In | |
| 599 | the SSH terminal the dashboard's *Yours* leaves archived repositories out too; | |
| 600 | type in its filter (`/`) to reach them, as the filter searches every | |
| 601 | repository you can see. | |
| 602 | ||
| 596 | 603 | A site admin archives many at once from the home. In the **Your apps** card, |
| 597 | 604 | **Select** shows a checkbox per app (they take no room until then); tick some |
| 598 | 605 | and press **Archive**. This is the fleet's `archive` operation over those |
src/core/repos.ts +3
| @@ -652,6 +652,8 @@ export interface ListReposOptions { | ||
| 652 | 652 | appsFirst?: boolean; |
| 653 | 653 | /** Leave archived repositories out. @default false */ |
| 654 | 654 | withoutArchived?: boolean; |
| 655 | /** Only archived repositories. @default false */ | |
| 656 | archivedOnly?: boolean; | |
| 655 | 657 | } |
| 656 | 658 | |
| 657 | 659 | // Visibility in SQL: public, owned, or owned by an org the viewer is on a |
| @@ -683,6 +685,7 @@ export function listReposVisibleTo( | ||
| 683 | 685 | where += ` AND repo.id < ?${params.length}`; |
| 684 | 686 | } |
| 685 | 687 | if (options.withoutArchived === true) where += " AND repo.archived_at IS NULL"; |
| 688 | if (options.archivedOnly === true) where += " AND repo.archived_at IS NOT NULL"; | |
| 686 | 689 | params.push(pageLimit(options.limit)); |
| 687 | 690 | const rows = db |
| 688 | 691 | .query<RepoRow, (string | number)[]>( |
src/web/pages/apps.ts +41 −6
| @@ -99,8 +99,12 @@ interface AppEntry { | ||
| 99 | 99 | * (`canRead` resolves org ownership from the database), and a listing must |
| 100 | 100 | * not get slower as the forge grows more apps. |
| 101 | 101 | */ |
| 102 | function appsForViewer(ctx: Ctx): AppEntry[] { | |
| 103 | const repos = listReposVisibleTo(ctx.app.db, ctx.auth.user, { limit: 200 }); | |
| 102 | function appsForViewer(ctx: Ctx, archived: boolean): AppEntry[] { | |
| 103 | const repos = listReposVisibleTo( | |
| 104 | ctx.app.db, | |
| 105 | ctx.auth.user, | |
| 106 | archived ? { limit: 200, archivedOnly: true } : { limit: 200, withoutArchived: true }, | |
| 107 | ); | |
| 104 | 108 | const records = appsForRepos( |
| 105 | 109 | ctx.app.db, |
| 106 | 110 | repos.map((repo) => repo.id), |
| @@ -122,6 +126,18 @@ function appsForViewer(ctx: Ctx): AppEntry[] { | ||
| 122 | 126 | return out; |
| 123 | 127 | } |
| 124 | 128 | |
| 129 | /** How many archived apps the viewer can see (the "N archived" line). */ | |
| 130 | function archivedAppCount(ctx: Ctx): number { | |
| 131 | const repos = listReposVisibleTo(ctx.app.db, ctx.auth.user, { | |
| 132 | limit: 200, | |
| 133 | archivedOnly: true, | |
| 134 | }); | |
| 135 | return appsForRepos( | |
| 136 | ctx.app.db, | |
| 137 | repos.map((repo) => repo.id), | |
| 138 | ).size; | |
| 139 | } | |
| 140 | ||
| 125 | 141 | /** `production` or `preview of #12`, as a phrase that fits in a sentence. */ |
| 126 | 142 | export function envLabel(env: EnvName): string { |
| 127 | 143 | const number = envPull(env); |
| @@ -140,11 +156,19 @@ export function envLabel(env: EnvName): string { | ||
| 140 | 156 | */ |
| 141 | 157 | export async function appsList(ctx: Ctx): Promise<Response> { |
| 142 | 158 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 143 | const entries = timePhase(ctx.request, "apps", () => appsForViewer(ctx)); | |
| 159 | // Archived apps are put away, not gone: `?archived=1` lists only them. Each | |
| 160 | // view reads its own window, so archived repos never push live ones off. | |
| 161 | const archivedView = ctx.url.searchParams.get("archived") === "1"; | |
| 162 | const entries = timePhase(ctx.request, "apps", () => | |
| 163 | appsForViewer(ctx, archivedView), | |
| 164 | ); | |
| 165 | const archivedCount = archivedView | |
| 166 | ? 0 | |
| 167 | : timePhase(ctx.request, "archived", () => archivedAppCount(ctx)); | |
| 144 | 168 | const liveCount = entries.filter( |
| 145 | 169 | (e) => e.production?.state === "running", |
| 146 | 170 | ).length; |
| 147 | const live = ctx.url.searchParams.get("live") === "1"; | |
| 171 | const live = !archivedView && ctx.url.searchParams.get("live") === "1"; | |
| 148 | 172 | const shown = live |
| 149 | 173 | ? entries.filter((e) => e.production?.state === "running") |
| 150 | 174 | : entries; |
| @@ -158,7 +182,7 @@ export async function appsList(ctx: Ctx): Promise<Response> { | ||
| 158 | 182 | }) |
| 159 | 183 | : btn("Sign in to make one", { href: "/signin", size: "sm" }); |
| 160 | 184 | const filters = |
| 161 | entries.length === 0 | |
| 185 | archivedView || entries.length === 0 | |
| 162 | 186 | ? null |
| 163 | 187 | : html`<div class="filters mb"> |
| 164 | 188 | ${seg([ |
| @@ -190,10 +214,21 @@ export async function appsList(ctx: Ctx): Promise<Response> { | ||
| 190 | 214 | ${pageHead("Apps", { actions: create })} |
| 191 | 215 | ${cost} |
| 192 | 216 | ${filters} |
| 217 | ${archivedView | |
| 218 | ? html`<p class="muted small mb"><a href="/apps">Back to apps</a></p>` | |
| 219 | : archivedCount > 0 | |
| 220 | ? html`<p class="muted small mb"> | |
| 221 | <a href="/apps?archived=1">${archivedCount} archived</a> | |
| 222 | </p>` | |
| 223 | : null} | |
| 193 | 224 | ${shown.length === 0 |
| 194 | 225 | ? empty({ |
| 195 | 226 | icon: "rocket", |
| 196 | title: live ? "Nothing is live" : "Nothing runs here yet", | |
| 227 | title: archivedView | |
| 228 | ? "Nothing is archived" | |
| 229 | : live | |
| 230 | ? "Nothing is live" | |
| 231 | : "Nothing runs here yet", | |
The note below still reads as the new-app copy when archivedView is true. Give the archived empty state its own note. |
||
| 197 | 232 | note: live |
| 198 | 233 | ? "Nothing is serving right now — clear the filter to see every app." |
| 199 | 234 | : "A new app is a repository with a small working app in it, served at its own hostname a few seconds later.", |
test/apps-archived.test.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** `/apps` leaves archived apps out, says how many, and lists them at `?archived=1` (#3480). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createApp as makeAppRecord } from "../src/core/apps.ts"; | |
| 9 | import { getRepo } from "../src/core/repos.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { proveAll } from "./helpers/verified.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | let root = ""; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | let cookie = ""; | |
| 21 | let alice: User; | |
| 22 | ||
| 23 | beforeAll(async () => { | |
| 24 | root = mkdtempSync(join(tmpdir(), "forge-apps-archived-")); | |
| 25 | app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" }); | |
| 26 | const u = await createUser(app.db, { | |
| 27 | origin: "seed", | |
| 28 | trusted: true, | |
| 29 | username: "alice", | |
| 30 | email: "[email protected]", | |
| 31 | password: "a-good-passphrase", | |
| 32 | adminIfFirst: true, | |
| 33 | }); | |
| 34 | if (!u.ok) throw new Error(u.error); | |
| 35 | proveAll(app); | |
| 36 | alice = u.value; | |
| 37 | for (const name of ["keeper", "shelved"]) { | |
| 38 | const made = await createRepo(app, alice, { name }); | |
| 39 | if (!made.ok) throw new Error(made.error); | |
| 40 | const rec = makeAppRecord(app.db, { repoId: made.value.id, slug: name, access: "public" }); | |
| 41 | if (!rec.ok) throw new Error(rec.error); | |
| 42 | } | |
| 43 | servers = await serve(app); | |
| 44 | const signin = await fetch(`${servers.url}/signin`, { | |
| 45 | method: "POST", | |
| 46 | headers: { origin: servers.url }, | |
| 47 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 48 | redirect: "manual", | |
| 49 | }); | |
| 50 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 51 | }); | |
| 52 | afterAll(() => { | |
| 53 | servers.stop(); | |
| 54 | app.close(); | |
| 55 | rmSync(root, { recursive: true, force: true }); | |
| 56 | }); | |
| 57 | ||
| 58 | const ROW_KEEPER = '<a href="/alice/keeper" class="row"'; | |
| 59 | const ROW_SHELVED = '<a href="/alice/shelved" class="row"'; | |
| 60 | const get = async (path: string) => { | |
| 61 | const res = await fetch(`${servers.url}${path}`, { headers: { cookie } }); | |
| 62 | return { status: res.status, body: await res.text() }; | |
| 63 | }; | |
| 64 | ||
| 65 | test("archiving hides an app from /apps, lists it at ?archived=1, and waking brings it back", async () => { | |
| 66 | let page = await get("/apps"); | |
| 67 | expect(page.body).toContain(ROW_KEEPER); | |
| 68 | expect(page.body).toContain(ROW_SHELVED); | |
| 69 | expect(page.body).not.toContain("archived</a>"); | |
| 70 | ||
| 71 | const done = await updateRepoSettings(app, alice, getRepo(app.db, "alice", "shelved")!, { archived: true }); | |
| 72 | expect(done.ok).toBe(true); | |
| 73 | ||
| 74 | page = await get("/apps"); | |
| 75 | expect(page.body).toContain(ROW_KEEPER); | |
| 76 | expect(page.body).not.toContain(ROW_SHELVED); | |
| 77 | expect(page.body).toContain('href="/apps?archived=1">1 archived</a>'); | |
| 78 | ||
| 79 | const only = await get("/apps?archived=1"); | |
| 80 | expect(only.body).toContain(ROW_SHELVED); | |
| 81 | expect(only.body).not.toContain(ROW_KEEPER); | |
| 82 | ||
| 83 | expect((await get("/alice/shelved")).status).toBe(200); | |
| 84 | ||
| 85 | const woke = await updateRepoSettings(app, alice, getRepo(app.db, "alice", "shelved")!, { archived: false }); | |
| 86 | expect(woke.ok).toBe(true); | |
| 87 | page = await get("/apps"); | |
| 88 | expect(page.body).toContain(ROW_SHELVED); | |
| 89 | expect(page.body).not.toContain("archived</a>"); | |
| 90 | }); | |
| 91 | ||
| 92 | test("the archived view has no All/Live filters, only a way back", async () => { | |
| 93 | await updateRepoSettings(app, alice, getRepo(app.db, "alice", "shelved")!, { archived: true }); | |
| 94 | const only = await get("/apps?archived=1&live=1"); | |
| 95 | expect(only.body).toContain(ROW_SHELVED); | |
| 96 | expect(only.body).not.toContain('href="/apps?live=1"'); | |
outdated
Dead code: void keep/void shelved only silence unused bindings. Drop the bindings. |
||
| 97 | expect(only.body).toContain('href="/apps">Back to apps</a>'); | |
| 98 | await updateRepoSettings(app, alice, getRepo(app.db, "alice", "shelved")!, { archived: false }); | |
| 99 | }); | |
| 100 | ||
| 101 | test("a signed-out viewer does not see a private archived app", async () => { | |
| 102 | const made = await createRepo(app, alice, { name: "secret", visibility: "private" }); | |
| 103 | if (!made.ok) throw new Error(made.error); | |
| 104 | const rec = makeAppRecord(app.db, { repoId: made.value.id, slug: "secret", access: "private" }); | |
| 105 | if (!rec.ok) throw new Error(rec.error); | |
| 106 | await updateRepoSettings(app, alice, getRepo(app.db, "alice", "secret")!, { archived: true }); | |
| 107 | const anon = await (await fetch(`${servers.url}/apps?archived=1`)).text(); | |
| 108 | expect(anon).not.toContain("/alice/secret"); | |
| 109 | expect((await get("/apps?archived=1")).body).toContain("/alice/secret"); | |
| 110 | }); |
new above
offline
Hand-rolled muted paragraph for a view switch; DESIGN.md names seg() for this. Consider a Live / Archived seg shown when archivedCount > 0.