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

/apps leaves archived apps out, with one line to show them #3728

Merged opened this

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

Merged

scout/forge:scout/3480-apps-leaves-archived-apps-out-with-one-l → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/3480-apps-leaves-archived-apps-out-with-one-l was merged into main as 84e28ee.

Review

Sign in to review this change.
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`
593593 (the **Show archived** toggle). It stays in `/apps`, search, the palette and
594594 the repository API (`GET /api/v1/repos`).
595595
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
596603 A site admin archives many at once from the home. In the **Your apps** card,
597604 **Select** shows a checkbox per app (they take no room until then); tick some
598605 and press **Archive**. This is the fleet's `archive` operation over those
src/core/repos.ts +3
@@ -652,6 +652,8 @@ export interface ListReposOptions {
652652 appsFirst?: boolean;
653653 /** Leave archived repositories out. @default false */
654654 withoutArchived?: boolean;
655 /** Only archived repositories. @default false */
656 archivedOnly?: boolean;
655657 }
656658
657659 // Visibility in SQL: public, owned, or owned by an org the viewer is on a
@@ -683,6 +685,7 @@ export function listReposVisibleTo(
683685 where += ` AND repo.id < ?${params.length}`;
684686 }
685687 if (options.withoutArchived === true) where += " AND repo.archived_at IS NULL";
688 if (options.archivedOnly === true) where += " AND repo.archived_at IS NOT NULL";
686689 params.push(pageLimit(options.limit));
687690 const rows = db
688691 .query<RepoRow, (string | number)[]>(
src/web/pages/apps.ts +41 −6
@@ -99,8 +99,12 @@ interface AppEntry {
9999 * (`canRead` resolves org ownership from the database), and a listing must
100100 * not get slower as the forge grows more apps.
101101 */
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 );
104108 const records = appsForRepos(
105109 ctx.app.db,
106110 repos.map((repo) => repo.id),
@@ -122,6 +126,18 @@ function appsForViewer(ctx: Ctx): AppEntry[] {
122126 return out;
123127 }
124128
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
125141 /** `production` or `preview of #12`, as a phrase that fits in a sentence. */
126142 export function envLabel(env: EnvName): string {
127143 const number = envPull(env);
@@ -140,11 +156,19 @@ export function envLabel(env: EnvName): string {
140156 */
141157 export async function appsList(ctx: Ctx): Promise<Response> {
142158 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));
144168 const liveCount = entries.filter(
145169 (e) => e.production?.state === "running",
146170 ).length;
147 const live = ctx.url.searchParams.get("live") === "1";
171 const live = !archivedView && ctx.url.searchParams.get("live") === "1";
148172 const shown = live
149173 ? entries.filter((e) => e.production?.state === "running")
150174 : entries;
@@ -158,7 +182,7 @@ export async function appsList(ctx: Ctx): Promise<Response> {
158182 })
159183 : btn("Sign in to make one", { href: "/signin", size: "sm" });
160184 const filters =
161 entries.length === 0
185 archivedView || entries.length === 0
162186 ? null
163187 : html`<div class="filters mb">
164188 ${seg([
@@ -190,10 +214,21 @@ export async function appsList(ctx: Ctx): Promise<Response> {
190214 ${pageHead("Apps", { actions: create })}
191215 ${cost}
192216 ${filters}
217 ${archivedView

Hand-rolled muted paragraph for a view switch; DESIGN.md names seg() for this. Consider a Live / Archived seg shown when archivedCount > 0.

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}
193224 ${shown.length === 0
194225 ? empty({
195226 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.

197232 note: live
198233 ? "Nothing is serving right now — clear the filter to see every app."
199234 : "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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs