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

In the studio, /apps is the shelf, not dev's table #132

Merged opened this

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

Merged

scout/forge:scout/112-in-the-studio-apps-is-the-shelf-not-dev → main 1 commit fork

  • Merged. scout/112-in-the-studio-apps-is-the-shelf-not-dev was merged into main as 8d1b324.

Review

Sign in to review this change.
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
242242 the toggle in the top bar — it switches this browser to dev from then on;
243243 `/?mode=studio` switches it back, the same way.
244244
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
245252 The top bar carries the same count, on every studio page: a pill beside the
246253 wordmark, linking here, naming how many things ask the signed-in person —
247254 `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";
5454 import { html, when, type Html } from "../html.ts";
5555 import { flashTo, page, redirect } from "../layout.ts";
5656 import { liveClientJs, liveMount } from "../live.ts";
57 import { shelfRows } from "../studio/shelf.ts";
58 import { studioCss } from "../studio/style.ts";
5759 import {
5860 badge,
5961 btn,
@@ -67,6 +69,7 @@ import {
6769 icon,
6870 listCard,
6971 row,
72 seg,
7073 selectField,
7174 sha,
7275 terminalLine,
@@ -187,7 +190,10 @@ export function appsCard(ctx: Ctx): Html {
187190 }
188191
189192 // 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);
191197 const entries = appsForViewer(ctx);
192198 const create = ctx.auth.user
193199 ? btn("New app", {
@@ -213,6 +219,72 @@ export function appsList(ctx: Ctx): Response {
213219 return page({ ctx, title: "Apps", body });
214220 }
215221
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
216288 // ---- new app ----
217289
218290 interface NewAppValues {
test/studio.test.ts +68
@@ -889,6 +889,74 @@ describe("the studio", () => {
889889 expect(after).not.toContain('action="/studio/asks"');
890890 });
891891
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
892960 test("signed out in studio, / leads with the public apps that are serving — their look, not a private one's", async () => {
893961 // A second app, public, with a picture of its production. "notes" stays
894962 // private, so its presence (or absence) is the negative case below.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs