Public: anyone can read this; the crew can change it.
In the studio, /apps is the shelf, not dev's table #132
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/112-in-the-studio-apps-is-the-shelf-not-dev → main 1 commit fork
3 files
changed +148
−1
docs/STUDIO.md +7
| @@ -242,6 +242,13 @@ same flow as ever. `/?mode=dev` is the repositories list, and — like | ||
| 242 | 242 | the toggle in the top bar — it switches this browser to dev from then on; |
| 243 | 243 | `/?mode=studio` switches it back, the same way. |
| 244 | 244 | |
| 245 | **Apps** in the top bar and *All N apps* on the home both land on `/apps` | |
| 246 | (`src/web/pages/apps.ts`), which in the studio is the whole shelf — every | |
| 247 | row the same `shelfRows` the home draws, never a second copy of them — with | |
| 248 | how many there are and a filter for whether they are live, each a link | |
| 249 | carrying `?live=1` rather than a form, and **New app** beside them. | |
| 250 | `?mode=dev` on the same URL is dev's table of repositories, unchanged. | |
| 251 | ||
| 245 | 252 | The top bar carries the same count, on every studio page: a pill beside the |
| 246 | 253 | wordmark, linking here, naming how many things ask the signed-in person — |
| 247 | 254 | `askCountFor` (`src/web/studio/asks.ts`), the candidates `decisionsFor` |
src/web/pages/apps.ts +73 −1
| @@ -54,6 +54,8 @@ import { field, readForm } from "../form.ts"; | ||
| 54 | 54 | import { html, when, type Html } from "../html.ts"; |
| 55 | 55 | import { flashTo, page, redirect } from "../layout.ts"; |
| 56 | 56 | import { liveClientJs, liveMount } from "../live.ts"; |
| 57 | import { shelfRows } from "../studio/shelf.ts"; | |
| 58 | import { studioCss } from "../studio/style.ts"; | |
| 57 | 59 | import { |
| 58 | 60 | badge, |
| 59 | 61 | btn, |
| @@ -67,6 +69,7 @@ import { | ||
| 67 | 69 | icon, |
| 68 | 70 | listCard, |
| 69 | 71 | row, |
| 72 | seg, | |
| 70 | 73 | selectField, |
| 71 | 74 | sha, |
| 72 | 75 | terminalLine, |
| @@ -187,7 +190,10 @@ export function appsCard(ctx: Ctx): Html { | ||
| 187 | 190 | } |
| 188 | 191 | |
| 189 | 192 | // GET /apps |
| 190 | export function appsList(ctx: Ctx): Response { | |
| 193 | export async function appsList(ctx: Ctx): Promise<Response> { | |
| 194 | // In the studio, the same list a person's home links to is the shelf, | |
| 195 | // not dev's table — `?mode=dev` still renders what follows, unchanged. | |
| 196 | if (ctx.mode === "studio") return await studioAppsList(ctx); | |
| 191 | 197 | const entries = appsForViewer(ctx); |
| 192 | 198 | const create = ctx.auth.user |
| 193 | 199 | ? btn("New app", { |
| @@ -213,6 +219,72 @@ export function appsList(ctx: Ctx): Response { | ||
| 213 | 219 | return page({ ctx, title: "Apps", body }); |
| 214 | 220 | } |
| 215 | 221 | |
| 222 | /** | |
| 223 | * GET /apps in the studio: every app the viewer can land, drawn as the | |
| 224 | * shelf (`shelfRows`, `studio/shelf.ts`) — the same row the home's *Your | |
| 225 | * apps* card draws, never a second copy of it. `?live=1` is a link, not a | |
| 226 | * form, narrowing the shelf to what production is actually serving; with | |
| 227 | * neither filter it's every app, newest first. Visibility and the owner's | |
| 228 | * facts are exactly `appsForViewer`'s and `shelfRows`'s own — a signed-out | |
| 229 | * viewer gets the public shelf, and a reader who cannot write to an app | |
| 230 | * never sees its crew or its cost. | |
| 231 | */ | |
| 232 | async function studioAppsList(ctx: Ctx): Promise<Response> { | |
| 233 | const entries = appsForViewer(ctx); | |
| 234 | const liveCount = entries.filter( | |
| 235 | (e) => e.production?.state === "running", | |
| 236 | ).length; | |
| 237 | const live = ctx.url.searchParams.get("live") === "1"; | |
| 238 | const shown = live | |
| 239 | ? entries.filter((e) => e.production?.state === "running") | |
| 240 | : entries; | |
| 241 | const create = ctx.auth.user | |
| 242 | ? btn("New app", { | |
| 243 | href: "/apps/new", | |
| 244 | variant: "primary", | |
| 245 | size: "sm", | |
| 246 | icon: "plus", | |
| 247 | shortcut: "c", | |
| 248 | }) | |
| 249 | : btn("Sign in to make one", { href: "/signin", size: "sm" }); | |
| 250 | const filters = | |
| 251 | entries.length === 0 | |
| 252 | ? null | |
| 253 | : html`<div class="filters mb"> | |
| 254 | ${seg([ | |
| 255 | { | |
| 256 | label: "All", | |
| 257 | href: "/apps", | |
| 258 | current: !live, | |
| 259 | count: entries.length, | |
| 260 | }, | |
| 261 | { | |
| 262 | label: "Live", | |
| 263 | href: "/apps?live=1", | |
| 264 | current: live, | |
| 265 | count: liveCount, | |
| 266 | }, | |
| 267 | ])} | |
| 268 | </div>`; | |
| 269 | const rows = await shelfRows( | |
| 270 | ctx, | |
| 271 | shown.map((e) => e.repo), | |
| 272 | ); | |
| 273 | const body = html`${pageHead("Apps", { actions: create })} | |
| 274 | ${filters} | |
| 275 | ${shown.length === 0 | |
| 276 | ? empty({ | |
| 277 | icon: "rocket", | |
| 278 | title: live ? "Nothing is live" : "Nothing runs here yet", | |
| 279 | note: live | |
| 280 | ? "Nothing is serving right now — clear the filter to see every app." | |
| 281 | : "A new app is a repository with a small working app in it, served at its own hostname a few seconds later.", | |
| 282 | action: create, | |
| 283 | }) | |
| 284 | : listCard(rows)}`; | |
| 285 | return page({ ctx, title: "Apps", body, styleCss: studioCss }); | |
| 286 | } | |
| 287 | ||
| 216 | 288 | // ---- new app ---- |
| 217 | 289 | |
| 218 | 290 | interface NewAppValues { |
test/studio.test.ts +68
| @@ -889,6 +889,74 @@ describe("the studio", () => { | ||
| 889 | 889 | expect(after).not.toContain('action="/studio/asks"'); |
| 890 | 890 | }); |
| 891 | 891 | |
| 892 | test("/apps in the studio is the shelf, not dev's table: an app the viewer can land, never one they cannot, and no dev table markup; ?mode=dev is unchanged", async () => { | |
| 893 | const bob = await createUser(app.db, { | |
| 894 | username: "bob", | |
| 895 | email: "[email protected]", | |
| 896 | password: "a-good-passphrase", | |
| 897 | }); | |
| 898 | if (!bob.ok) throw new Error(bob.error); | |
| 899 | const hidden = await createRepo(app, bob.value, { | |
| 900 | name: "hidden", | |
| 901 | visibility: "private", | |
| 902 | description: "bob's own, not alice's crew", | |
| 903 | }); | |
| 904 | if (!hidden.ok) throw new Error(String(hidden.error)); | |
| 905 | const seededHidden = await commitToBranch(app, bob.value, hidden.value, { | |
| 906 | message: "Start", | |
| 907 | files: [ | |
| 908 | { | |
| 909 | path: "package.json", | |
| 910 | content: | |
| 911 | '{"name":"hidden","scripts":{"start":"bun run server.ts"}}\n', | |
| 912 | }, | |
| 913 | { | |
| 914 | path: "server.ts", | |
| 915 | content: | |
| 916 | "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n", | |
| 917 | }, | |
| 918 | ], | |
| 919 | }); | |
| 920 | if (!seededHidden.ok) throw new Error(JSON.stringify(seededHidden.error)); | |
| 921 | const enabledHidden = await enableApp(app, bob.value, hidden.value, { | |
| 922 | slug: "hidden", | |
| 923 | }); | |
| 924 | if (!enabledHidden.ok) throw new Error(String(enabledHidden.error)); | |
| 925 | const bobCookie = ( | |
| 926 | ( | |
| 927 | await fetch(`${base}/signin`, { | |
| 928 | method: "POST", | |
| 929 | body: new URLSearchParams({ | |
| 930 | username: "bob", | |
| 931 | password: "a-good-passphrase", | |
| 932 | }), | |
| 933 | redirect: "manual", | |
| 934 | }) | |
| 935 | ).headers.get("set-cookie") ?? "" | |
| 936 | ).split(";")[0]!; | |
| 937 | const bobGet = (path: string, extra: Record<string, string> = {}) => | |
| 938 | fetch(`${base}${path}`, { | |
| 939 | headers: { cookie: `${bobCookie}; forge_mode=studio`, ...extra }, | |
| 940 | redirect: "manual", | |
| 941 | }); | |
| 942 | ||
| 943 | // Bob can land his own app, but alice never crewed him onto "notes" — | |
| 944 | // private and none of his — so the shelf omits it. | |
| 945 | const studio = await (await bobGet("/apps")).text(); | |
| 946 | expect(studio).toContain('href="/bob/hidden"'); | |
| 947 | expect(studio).not.toContain('href="/alice/notes"'); | |
| 948 | expect(studio).not.toContain("not serving"); | |
| 949 | expect(studio).toContain('href="/apps?live=1"'); | |
| 950 | ||
| 951 | const dev = await ( | |
| 952 | await bobGet("/apps", { cookie: `${bobCookie}; forge_mode=dev` }) | |
| 953 | ).text(); | |
| 954 | expect(dev).toContain( | |
| 955 | "Repositories that run. Production follows the default branch", | |
| 956 | ); | |
| 957 | expect(dev).not.toContain('href="/apps?live=1"'); | |
| 958 | }); | |
| 959 | ||
| 892 | 960 | test("signed out in studio, / leads with the public apps that are serving — their look, not a private one's", async () => { |
| 893 | 961 | // A second app, public, with a picture of its production. "notes" stays |
| 894 | 962 | // private, so its presence (or absence) is the negative case below. |
new above
offline