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

Each row of /apps?archived=1 has a Wake button for its admin, and waking keeps you on the list #6119

Merged opened this

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

Merged

scout/forge:scout/6086-each-row-of-apps-archived-1-has-a-wake-b → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/6086-each-row-of-apps-archived-1-has-a-wake-b was merged into main as 6bf89c4.

Review

Sign in to review this change.
5 files changed +88 −3
docs/TOUR.md +4 −1
@@ -240,7 +240,10 @@ An admin puts a repository away from **Settings → Archive**, with
240240 `PATCH /api/v1/repos/:owner/:name {"archived":true|false}` (admins only; anyone
241241 else gets 403), or in the terminal with `a` on the About card (`I`). The
242242 repository page and the card then say when it was archived, and an admin gets
243 a Wake button or the same key to undo it. Archiving twice keeps the first date.
243 a Wake button or the same key to undo it. The archived list
244 (`/apps?archived=1`) has a Wake button on each row its viewer administers, and
245 returns to the list; the terminal wakes from the About card with the same key
246 (`src/tui/screens/about.ts`). Archiving twice keeps the first date.
244247 An archived repository starts no new crew run. Assigning an issue to an agent,
245248 queueing a run again, asking the crew to propose a resolution, redirecting a
246249 wave and converging a fork each answer "This repository is archived; wake it to
src/web/pages/apps.ts +22 −1
@@ -38,6 +38,7 @@ import { appsForRepos, type AppRecord } from "../../core/apps.ts";
3838 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
3939 import type { Pull } from "../../core/pulls.ts";
4040 import {
41 canAdmin,
4142 canWrite,
4243 getRepoById,
4344 listReposVisibleTo,
@@ -147,6 +148,25 @@ export function envLabel(env: EnvName): string {
147148 return number === undefined ? "production" : `preview of #${number}`;
148149 }
149150
151 /** The archived list's Wake: a small form under each row the viewer administers. */
152 function wakeForms(ctx: Ctx, repos: readonly Repo[]): Map<string, Html> {
153 const out = new Map<string, Html>();
154 for (const repo of repos) {
155 if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) continue;
156 out.set(
157 repo.id,
158 html`<li>

A bare <li> in .row-children has no padding rule, so the button sits against the left rule. See #6867.

159 <form method="post" action="${repoBase(repo)}/settings/archive">
160 <input type="hidden" name="archived" value="0" />
161 <input type="hidden" name="to" value="/apps?archived=1" />
162 ${btn(html`Wake<span class="sr-only"> ${repo.ownerName}/${repo.name}</span>`)}
163 </form>
164 </li>`,
165 );

Name the app: btn("Wake") repeats identically on every row. Add a visually hidden suffix, e.g. Wake<span class="sr-only"> ${repo.ownerName}/${repo.name}</span> (use the design system's hidden-text helper if there is one), so each button is distinguishable by a screen reader.

166 }
167 return out;
168 }
169
150170 /**
151171 * GET /apps: every app the viewer can land, for every viewer, drawn as the
152172 * shelf (`shelfRows`, `studio/shelf.ts`) — the same row the home's *Your
@@ -203,11 +223,12 @@ export async function appsList(ctx: Ctx): Promise<Response> {
203223 },
204224 ])}
205225 </div>`;
226 const wake = archivedView ? wakeForms(ctx, shown.map((e) => e.repo)) : undefined;
206227 const rows = await timePhaseAsync(ctx.request, "shelf", () =>
207228 shelfRows(
208229 ctx,
209230 shown.map((e) => e.repo),
210 { firstPreview: firstPreviews(ctx.app.db, shown.map((e) => e.record)) },
231 { firstPreview: firstPreviews(ctx.app.db, shown.map((e) => e.record)), under: wake },
211232 ),
212233 );
213234 const cost = timePhase(ctx.request, "cost", () =>
src/web/pages/settings.ts +14 −1
@@ -450,6 +450,15 @@ export async function settingsUpdate(ctx: Ctx): Promise<Response> {
450450 return flashTo(ctx, `${base}/settings`, flash);
451451 }
452452
453 /** A return address that stays on this origin, as a path; anything else is undefined. */
454 function samePath(ctx: Ctx, to: string | undefined): string | undefined {
455 if (to === undefined || !to.startsWith("/") || to.startsWith("//") || /[\\\x00-\x1f]/.test(to))
456 return undefined;
457 const parsed = URL.parse(to, ctx.url.origin);
458 if (!parsed || parsed.origin !== ctx.url.origin) return undefined;
459 return parsed.pathname + parsed.search;
460 }
461
453462 /**
454463 * POST /:owner/:name/settings/archive — archive or wake. The state asked
455464 * for travels in the form, so a double submit is idempotent.
@@ -468,7 +477,11 @@ export async function archiveSubmit(ctx: Ctx): Promise<Response> {
468477 const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, {
469478 archived,
470479 });
471 const back = field(form, "to") === "repo" ? repoBase(repo) : `${repoBase(repo)}/settings`;
480 const to = field(form, "to");
481 const back =
482 to === "repo"
483 ? repoBase(repo)
484 : (samePath(ctx, to) ?? `${repoBase(repo)}/settings`);
472485 return flashTo(
473486 ctx,
474487 back,
src/web/studio/shelf.ts +4
@@ -65,6 +65,8 @@ export interface ShelfReads {
6565 /** Milliseconds from New app to the first preview, by repo id; a row
6666 * wears it beside its name only when it has one. */
6767 readonly firstPreview?: ReadonlyMap<string, number> | undefined;
68 /** Something to hang under a row, by repo id (the archived list's Wake). */
69 readonly under?: ReadonlyMap<string, Html> | undefined;
6870 }
6971
7072 /**
@@ -336,7 +338,9 @@ export async function shelfRows(
336338 ? repo.name
337339 : html`<span class="muted">${repo.ownerName}/</span>${repo.name}`;
338340 const first = reads.firstPreview?.get(repo.id);
341 const under = reads.under?.get(repo.id);
339342 return row({
343 ...(under === undefined ? {} : { children: [under] }),

children is the tree slot (nested row()s, per docs/design/sections/5-components.md). A control hung here gets the tree's indent and border and no padding. Follow-up in #6867: a control slot drawn as .row-actions.

340344 href: repoBase(repo),
341345 nav: true,
342346 lead: dot(tone),
test/apps-archived.test.ts +44
@@ -132,3 +132,47 @@ test("the stylesheet carries no merge-conflict markers", async () => {
132132 expect(content).not.toMatch(/^(<<<<<<<|=======|>>>>>>>)/m);
133133 expect(content).not.toContain(".filters");
134134 });
135
136 test("the archived list wakes an app in place, and ignores a return address off this origin", async () => {
137 await updateRepoSettings(app, alice, getRepo(app.db, "alice", "shelved")!, { archived: true });
138 const list = (await get("/apps?archived=1")).body;
139 expect(list).toContain('action="/alice/shelved/settings/archive"');
140 expect(list).toContain('name="to" value="/apps?archived=1"');
141 // Each button names its app, so a screen reader can tell the Wakes apart.
142 expect(list).toMatch(/Wake<span class="sr-only"> alice\/shelved<\/span>/);
143 const post = (to: string, headers: Record<string, string> = { origin: servers.url }) =>
144 fetch(`${servers.url}/alice/shelved/settings/archive`, {
145 method: "POST",
146 headers: { cookie, ...headers },
147 body: new URLSearchParams({ archived: "0", to }),
148 redirect: "manual",
149 });
150 // A cross-origin post is refused and changes nothing.
151 const foreign = await post("/apps?archived=1", { origin: "https://evil.example" });
152 expect(foreign.status).toBeGreaterThanOrEqual(400);
153 expect(getRepo(app.db, "alice", "shelved")!.archivedAt).not.toBeNull();
154 const woke = await post("/apps?archived=1");
155 expect(woke.status).toBe(303);
156 const back = new URL(woke.headers.get("location")!, servers.url);
157 expect(back.pathname + back.search).toBe("/apps?archived=1");
158 expect((await get("/apps?archived=1")).body).not.toContain(ROW_SHELVED);
159 expect((await get("/apps")).body).toContain(ROW_SHELVED);
160 for (const bad of ["https://evil.example/", "//evil.example", "/\\evil.example"]) {
161 await updateRepoSettings(app, alice, getRepo(app.db, "alice", "shelved")!, { archived: true });
162 const res = await post(bad);
163 expect(res.headers.get("location")).toBe("/alice/shelved/settings");
164 }
165 await updateRepoSettings(app, alice, getRepo(app.db, "alice", "shelved")!, { archived: false });
166 });
167
168 test("a viewer who does not administer an archived app gets no Wake button", async () => {
169 const made = await createRepo(app, alice, { name: "openshelf", visibility: "public" });
170 if (!made.ok) throw new Error(made.error);
171 const rec = makeAppRecord(app.db, { repoId: made.value.id, slug: "openshelf", access: "public" });
172 if (!rec.ok) throw new Error(rec.error);
173 await updateRepoSettings(app, alice, getRepo(app.db, "alice", "openshelf")!, { archived: true });
174 const anon = await (await fetch(`${servers.url}/apps?archived=1`)).text();
175 expect(anon).toContain('<a href="/alice/openshelf" class="row"');
176 expect(anon).not.toContain("settings/archive");
177 expect((await get("/apps?archived=1")).body).toContain("/alice/openshelf/settings/archive");
178 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs