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

`/displays` lists every display the viewer may read, on main #9532

Merged opened this

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

Merged

scout/forge:scout/9530-displays-lists-every-display-the-viewer → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/9530-displays-lists-every-display-the-viewer was merged into main as a0b6fc6.

Review

Sign in to review this change.
11 files changed +357 −0
docs/design/audit.md +1
@@ -69,6 +69,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
6969 - `/repos` — `pages/my-repos.ts` · the repositories you may write, from the account menu · `/` (your apps), `/:owner` (a profile's repositories) · none seen.
7070 - `/releases` — `pages/releases.ts` · the forge's kept releases and rollback · `/settings/backups` (both are "go back") · none seen.
7171 - `/changelog` — `pages/changelog.ts` · the last 30 releases with their dates and pulls, public · `/releases` (the signed-in kept releases) · none seen.
72 - `/displays` — `pages/displays.ts` · every display the viewer may read, each with its layout, repository and showcased demos; a stranger signs in first · none yet (read only; a repository's display is its own page) · none seen.
7273 - `/api` — `pages/api.ts` · the forge's OpenAPI operations by area, with search and Try it, public · the footer, the OpenAPI document, the terminal's API screen · none seen. An app's own spec is its `/:owner/:name/api` tab.
7374 - `/contribute` — `pages/contribute.ts` · what this forge is and where outside work is welcome, open to anyone · `/llms.txt`, MCP `contribute` tool · none seen.
7475 - `/agents`: `pages/agents.ts` · `/llms.txt` drawn as a page for an assistant whose reader takes only HTML, and for the person beside it, public · the home page's prompt and its Open in ChatGPT, Claude and Grok links · none seen.
docs/features/displays.md added +10
@@ -0,0 +1,10 @@
1 # Displays list
2
3 A display is a repository with a `display.json` on its default branch (title, layout `shelf`, `grid` or `graph`, and the repositories it draws demos from). The list shows every display you may read, and nothing you may not.
4
5 - **Page**: `/displays`. One row per display: title (linking to the repository), layout, repository, and how many showcased demos it shows. A stranger is sent to sign in. A repository whose `display.json` does not parse is listed as "display.json is invalid", linking to that repository so its owner can fix the file.
6 - **Terminal**: the "Displays" screen (`src/tui/screens/displays.ts`), opened from the palette. The same rows, from the same `listedDisplays` call. `j`/`k` move.
7
8 With no displays the page says what one is and how to make one: commit a `display.json` to a repository's default branch.
9
10 Not done: the list is read only. Creating or editing a display, a picker, and a build sidebar are separate work.
src/core/reserved.ts +1
@@ -135,6 +135,7 @@ export const SYSTEM_NAMES: readonly string[] = [
135135 "ssh",
136136 "status",
137137 "changelog",
138 "displays",
138139 "docs",
139140 "open-platform",
140141 ];
src/display/known.ts +5
@@ -251,3 +251,8 @@ export async function withoutDisplay(app: App, repos: readonly Repo[]): Promise<
251251 const read = await inTurn(repos, (repo) => displayOf(app, repo));
252252 return repos.filter((_, i) => read[i] === undefined);
253253 }
254
255 /** What a display is and where one comes from; the page and the terminal screen share it. */
256 export const DISPLAYS_NOTE = "A display shows a repository's showcased demos. Add a display.json to a repository's default branch to make one.";
257 /** The link text for a display whose file does not parse. */
258 export const INVALID_TITLE = "display.json is invalid";
src/http/server.ts +2
@@ -35,6 +35,7 @@ import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit
3535 import { boardsHoldPost } from "../web/pages/boards-holds.ts";
3636 import { nowPage } from "../web/pages/now.ts";
3737 import { myReposPage } from "../web/pages/my-repos.ts";
38 import { displaysPage } from "../web/pages/displays.ts";
3839 import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts";
3940 import { actingVia } from "../core/acting_via.ts";
4041 import {
@@ -833,6 +834,7 @@ export function buildRouter(): Router<Handler> {
833834 router.add("POST", "/bring/:id/:job/env", bringEnvSubmit);
834835 router.add("GET", "/new/issue", newIssueAnywhere);
835836 router.add("GET", "/repos", myReposPage);
837 router.add("GET", "/displays", displaysPage);
836838 router.add("GET", "/now/:stage", nowPage);
837839 router.add("GET", "/audit", auditPage);
838840 router.add("GET", "/releases", releasesPage);
src/tui/screens/displays.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * Displays (/displays on the web): every display the viewer may read, one
3 * row each — title, layout, repository, showcased demo count — from the same
4 * `listedDisplays` call as the page. Read only.
5 */
6
7 import { DISPLAYS_NOTE, INVALID_TITLE, listedDisplays, type ListedDisplay } from "../../display/known.ts";
8 import type { Ctx, Screen } from "../app/app.ts";
9 import { binding } from "../app/keys.ts";
10 import { style } from "../term/style.ts";
11 import { truncate } from "../term/width.ts";
12 import type { Session } from "../session.ts";
13 import { fill, len, split } from "../ui/layout.ts";
14 import { ListState, drawList, type ListRow } from "./common.ts";
15 import type { PaletteEntry } from "./entries.ts";
16
17 /** One display as a row: its title (or that its file is invalid), then layout, repository and demos. */
18 export function displayRow(d: ListedDisplay): ListRow {
19 const name = `${d.repo.ownerName}/${d.repo.name}`;
20 if (!d.config.ok) return { title: INVALID_TITLE, secondary: `${name} · no layout · no demos` };
21 const demos = d.demos ?? 0;
22 return { title: d.config.value.title, secondary: `${d.config.value.layout} · ${name} · ${demos} ${demos === 1 ? "demo" : "demos"}` };
23 }
24
25 export function displaysScreen(f: Session, ctx: Ctx): Screen {
26 const state = new ListState();
27 let shown: ListedDisplay[] | undefined;
28 return {
29 title: "Displays",
30 keys: [binding(["j", "k"], "move cursor")],
31 async load() {
32 shown = await listedDisplays(f.app, f.user);
33 },
34 render(area, s) {
35 const c = ctx.theme.c;
36 const [head, list] = split(area, "v", [len(1), fill(1)]);
37 const text = shown === undefined ? "Loading displays…" : shown.length === 0 ? DISPLAYS_NOTE : `${shown.length} displays you may read`;
38 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
39 drawList(s, list!, c, state, (shown ?? []).map(displayRow));
40 },
41 key(e) {
42 return state.key(e, shown?.length ?? 0);
43 },
44 };
45 }
46
47 /** Displays, as /displays lists them: anyone signed in. */
48 export const entry: PaletteEntry = {
49 id: "displays",
50 title: "Displays",
51 hint: "the displays you may read",
52 after: ["repos"],
53 offered: (session) => session.user !== undefined,
54 run: (session, ctx) => ctx.push(displaysScreen(session, ctx)),
55 };
src/tui/screens/entries.ts +2
@@ -20,6 +20,7 @@ import { entry as cloud } from "./cloud.ts";
2020 import { entry as connections } from "./connections.ts";
2121 import { entry as crewHandover } from "./crewhandover.ts";
2222 import { entry as crewSplits } from "./crewsplits.ts";
23 import { entry as displays } from "./displays.ts";
2324 import { entry as domains } from "./domains.ts";
2425 import { entry as edgeOnly } from "./edgeonly.ts";
2526 import { entry as feed } from "./feed.ts";
@@ -114,6 +115,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
114115 now,
115116 forgeView,
116117 repos,
118 displays,
117119 newIssue,
118120 ];
119121
src/web/pages/displays.ts added +57
@@ -0,0 +1,57 @@
1 /**
2 * `/displays`: every display the viewer may read — each repository with a
3 * `display.json` on its default branch (`display/config.ts`) — one row
4 * each: title, layout, repository, and how many showcased demos it shows.
5 * Read through the display cache (`display/known.ts`), so a warm page reads
6 * no git. Read only: making a display is not here.
7 */
8
9 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
10 import { DISPLAYS_NOTE, INVALID_TITLE, listedDisplays } from "../../display/known.ts";
11 import type { Ctx } from "../../http/context.ts";
12 import { pageHead, repoBase, signInFirst } from "../chrome.ts";
13 import { html } from "../html.ts";
14 import { page } from "../layout.ts";
15 import { empty } from "../ui.ts";
16
17 const LAYOUT_WORD = { shelf: "Shelf", grid: "Grid", graph: "Graph" } as const;
18
19 // GET /displays — every display the viewer may read; a stranger signs in first.
20 export async function displaysPage(ctx: Ctx): Promise<Response> {
21 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
22 if (ctx.auth.user === undefined) return signInFirst(ctx);
23 const rows = await timePhaseAsync(ctx.request, "displays", () => listedDisplays(ctx.app, ctx.auth.user));
24 return timePhase(ctx.request, "render", () =>
25 page({
26 ctx,
27 title: "Displays",
28 body: html`${pageHead("Displays")}
29 ${rows.length === 0
30 ? empty({ icon: "pin", title: "No displays", note: DISPLAYS_NOTE })
31 : html`<div class="table-scroll" role="region" tabindex="0" aria-label="Displays">
32 <table class="table" data-dense data-by="displays">
33 <thead>
34 <tr>
35 <th scope="col">Title</th>
36 <th scope="col">Layout</th>
37 <th scope="col">Repository</th>
38 <th scope="col">Demos</th>
39 </tr>
40 </thead>
41 <tbody>
42 ${rows.map((d) => {
43 const base = repoBase(d.repo);

Link text for a broken config reads as an error label, not a title. Make it say the file is invalid and keep the link to the repository so the owner can fix it.

44 const name = `${d.repo.ownerName}/${d.repo.name}`;
45 return html`<tr data-display="${name}">
46 <td><a href="${base}">${d.config.ok ? d.config.value.title : INVALID_TITLE}</a></td>
47 <td>${d.config.ok ? LAYOUT_WORD[d.config.value.layout] : "None"}</td>
48 <td><a class="mono" href="${base}">${name}</a></td>
49 <td data-demos>${d.demos === undefined ? "None" : String(d.demos)}</td>
50 </tr>`;
51 })}
52 </tbody>
53 </table>
54 </div>`}`,
55 }),
56 );
57 }
test/displays-list.test.ts added +220
@@ -0,0 +1,220 @@
1 /**
2 * `/displays` and its terminal screen: both list the displays the viewer may
outdated

This comment describes behaviour (New display, build sidebar, picker) that is not in this pull. Rewrite it.

3 * read through `listedDisplays` — title, layout, repository, showcased demo
4 * count — hide what the viewer cannot read, name a broken display.json as
5 * invalid, say what a display is when there are none, and send a stranger to
6 * sign in. Making a display is not tested here: it is not in this pull.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { AppsRuntime } from "../src/apps/runtime.ts";
15 import { getRepo, type Repo } from "../src/core/repos.ts";
16 import { createSession } from "../src/core/sessions.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { pinBuild, type Demo } from "../src/git/builds.ts";
19 import { repoDir } from "../src/git/repos.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { createForgeApp } from "../src/tui/forge.ts";
22 import { entry as displaysEntry, displaysScreen } from "../src/tui/screens/displays.ts";
23 import { ENTRIES } from "../src/tui/screens/entries.ts";
24 import { headlessTerminal } from "../src/tui/term/terminal.ts";
25 import { settleApp } from "./helpers/tui.ts";
26 import { statements } from "./helpers/statements.ts";
27 import { proveAll } from "./helpers/verified.ts";
28
29 const DOMAIN = "apps.test";
30 const T0 = 1_800_000_000;
31 const demo = (name: string, version: string, showcase: boolean): Demo => ({ name, version, page: "/", showcase, launch: "shots" });
32
33 describe("the displays list, page and terminal screen", () => {
34 let dataDir: string;
35 let app: App;
36 let servers: Servers;
37 let runtime: AppsRuntime;
38 let alice: User;
39 let bob: User;
40 let tick = 0;
41
42 const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`;
43 const get = (path: string, u?: User, extra: Record<string, string> = {}) =>
44 fetch(`${servers.url}${path}`, { headers: { ...(u === undefined ? {} : { cookie: cookie(u) }), ...extra }, redirect: "manual" });
45 const text = async (path: string, u?: User) => {
46 const r = await get(path, u);
47 expect(r.status, path).toBe(200);
48 return r.text();
49 };
50 const repo = (name: string): Repo => getRepo(app.db, "alice", name)!;
51 const writeDisplay = async (name: string, config: unknown) => {
52 const made = await commitToBranch(app, alice, repo(name), {
53 message: "display",
54 files: [{ path: "display.json", content: typeof config === "string" ? config : JSON.stringify(config) }],
55 });
56 if (!made.ok) throw new Error(JSON.stringify(made.error));
57 };
58 const pin = async (name: string, label: string, d?: Demo, parent?: string) => {
59 const made = await commitToBranch(app, alice, repo(name), { message: label, files: [{ path: `${label}.txt`, content: label }] });
60 if (!made.ok) throw new Error(JSON.stringify(made.error));
61 const pinned = await pinBuild(repoDir(app.reposRoot, "alice", name), {
62 label,
63 oid: made.value.commitOid,
64 note: "",
65 source: "commit",
66 tagger: { name: "alice", email: "[email protected]" },
67 at: T0 + tick++ * 60,
68 ...(d === undefined ? {} : { demo: d }),
69 ...(parent === undefined ? {} : { parent }),
70 });
71 if (!pinned.ok) throw new Error(pinned.error);
72 };
73 /** The displays a page lists, by repository, with their demo counts. */
74 const listed = (page: string) =>
75 Object.fromEntries([...page.matchAll(/<tr data-display="([^"]+)">[\s\S]*?<td data-demos>([^<]*)<\/td>/g)].map((m) => [m[1]!, m[2]!]));
76 beforeAll(async () => {
77 dataDir = await mkdtemp(join(tmpdir(), "forge-displays-"));
78 app = createApp({ dataDir, port: 0 });
79 servers = await serve(app);
80 runtime = new AppsRuntime(app, { domain: DOMAIN, scheme: "http", publicPort: servers.port, forgeUrl: servers.url });
81 await runtime.start();
82 for (const name of ["alice", "bob"]) {
83 const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: true });
84 if (!made.ok) throw new Error(made.error);
85 if (name === "alice") alice = made.value;
86 else bob = made.value;
87 }
88 proveAll(app);
89 for (const [name, visibility] of [
90 ["karts", "public"],
91 ["secret", "private"],
92 ["arcade", "public"],
93 ["shelf", "public"],
94 ["hidden", "private"],
95 ["broken", "public"],
96 ["fresh", "public"],
97 ["nova-vanilla", "public"],
98 ["plainfam", "public"],
99 ] as const) {
100 const made = await createRepo(app, alice, { name, visibility });
101 if (!made.ok) throw new Error(String(made.error));
102 }
103 // karts: two showcased demos and a draft; secret: one showcased, private.
104 await pin("karts", "fiesta", demo("krazy-karts", "1.0", true));
105 await pin("karts", "london", demo("krazy-karts", "2.0", true));
106 await pin("karts", "draft", demo("krazy-karts", "3.0", false));
107 await pin("secret", "hush", demo("secret-game", "0.1", true));
108 await writeDisplay("arcade", { title: "Arcade night", layout: "grid", sources: ["alice/karts", "alice/secret"] });
109 await writeDisplay("shelf", { title: "Shelf night", layout: "shelf", sources: ["alice/karts"] });
110 await writeDisplay("hidden", { title: "Hidden night", layout: "graph", sources: ["alice/karts"] });
111 await writeDisplay("broken", "{ nope");
112
113 // A repository with pins and no display.json is no display.
114 await pin("nova-vanilla", "v1");
115 await pin("nova-vanilla", "v2", demo("nova", "1", true));
116 // plainfam: pins and a demo, none showcased.
117 await pin("plainfam", "p1", demo("plain", "1", false));
118 }, 60_000);
119
120 afterAll(async () => {
121 await runtime.stop();
122 servers.stop();
123 app.close();
124 await rm(dataDir, { recursive: true, force: true });
125 });
126
127 describe("/displays", () => {
128 test("lists only the displays the viewer reads, each with its showcased demos counted", async () => {
129 const mine = await text("/displays", alice);
130 expect(listed(mine)).toEqual({ "alice/arcade": "3", "alice/shelf": "2", "alice/hidden": "2", "alice/broken": "None" });
131 expect(mine).toContain('href="/alice/arcade"');
132 expect(mine).toContain("Arcade night");
133 expect(mine).toContain("display.json is invalid");
134 // A private source's showcased demo is published: it counts for another reader too.
135 expect(listed(await text("/displays", bob))).toEqual({ "alice/arcade": "3", "alice/shelf": "2", "alice/broken": "None" });
136 // Iterations are no display.json display.
137 expect(mine).not.toContain('data-display="alice/nova');
138 });
139
140 test("a pin that joins the showcase, or a display that changes, is counted again", async () => {
141 await pin("karts", "rome", demo("krazy-karts", "5.0", true));
142 expect(listed(await text("/displays", alice))["alice/shelf"]).toBe("3");
143 });
144
145 test("its statements do not grow with its rows", async () => {
146 const cost = () => statements(app.db, async () => void (await get("/displays", alice)).arrayBuffer());
147 await get("/displays", alice);
148 const few = await cost();
149 for (const name of ["more1", "more2", "more3"]) {
150 const made = await createRepo(app, alice, { name, visibility: "public" });
151 if (!made.ok) throw new Error(String(made.error));
152 await writeDisplay(name, { title: name, layout: "grid", sources: ["alice/karts"] });
153 }
154 await get("/displays", alice);
155 expect(Object.keys(listed(await text("/displays", alice)))).toContain("alice/more3");
156 expect(await cost()).toBe(few);
157 });
158
159 test("the empty page says what a display is and where one comes from", async () => {
160 const dir = await mkdtemp(join(tmpdir(), "forge-displays-empty-"));
161 const bare = createApp({ dataDir: dir, port: 0 });
162 const bareServers = await serve(bare);
163 try {
164 const made = await createUser(bare.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
165 if (!made.ok) throw new Error(made.error);
166 const r = await fetch(`${bareServers.url}/displays`, { headers: { cookie: `forge_session=${createSession(bare.db, made.value.id).token}` } });
167 const page = await r.text();
168 expect(page).toContain("No displays");
169 expect(page).toContain("A display shows a repository&#39;s showcased demos.");
170 expect(page).toContain("Add a display.json to a repository&#39;s default branch");
171 } finally {
172 bareServers.stop();
173 bare.close();
174 await rm(dir, { recursive: true, force: true });
175 }
176 });
177
178 test("the terminal screen lists the same rows as the page, and is in the palette", async () => {
179 const screenText = async (u: User) => {
180 const { app: tui, session } = createForgeApp(
181 app,
182 { sshUsername: u.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: u },
183 { terminal: headlessTerminal(200, 40), exitHooks: false },
184 );
185 tui.start();
186 tui.ctx.push(displaysScreen(session, tui.ctx));
187 await settleApp(tui);
188 const out = tui.screen.toText();
189 tui.stop?.();
190 return out;
191 };
192 const mine = await screenText(alice);
193 for (const name of Object.keys(listed(await text("/displays", alice)))) expect(mine).toContain(name);
194 expect(mine).toContain("Arcade night");
195 expect(mine).toContain("display.json is invalid");
196 expect(mine).toContain("alice/hidden");
197 expect(await screenText(bob)).not.toContain("alice/hidden");
198 expect(ENTRIES).toContain(displaysEntry);
199 });
200
201 test("a stranger is sent to sign in", async () => {
202 const r = await get("/displays");
203 expect(r.status).toBe(303);
204 expect(r.headers.get("location")).toContain("/signin");
205 });
206
207 test("carries the security floor", async () => {
208 const r = await get("/displays", alice);
209 expect(r.headers.get("x-content-type-options")).toBe("nosniff");
210 expect(r.headers.get("content-security-policy")).not.toBeNull();
211 expect(r.headers.get("referrer-policy")).not.toBeNull();
212 });
213
214 test("names its Server-Timing phases", async () => {
215 const r = await get("/displays", alice);
216 const names = (r.headers.get("server-timing") ?? "").split(",").map((p) => p.trim().split(";")[0]);
217 expect(names).toEqual(expect.arrayContaining(["session", "displays", "render"]));
218 });
219 });
220 });
test/forge-looks.test.ts +1
@@ -58,6 +58,7 @@ const NOT_A_PAGE: Record<string, string> = {
5858 "/events": "a live event stream that never ends",
5959 "/boards": "the list of boards; /board is pictured, and the slot went to the preview seed's repository as its owner (#8865); covered by test/boards-page.test.ts",
6060 "/changelog": "the merged changes in words; /releases is pictured, and the slot went to the preview seed's pull as its owner (#8865); covered by test/changelog.test.ts",
61 "/displays": "a list of displays behind sign-in; its slot is not pictured; covered by test/displays-list.test.ts",
6162 "/search": "a search box; its slot went to the preview seed's /next as its owner (#8866)",
6263 "/contribute": "a static page about how to help; its slot went to the preview seed's /bets as its owner (#8866)",
6364 "/migrate": "an import form, nothing to picture until a job runs; the look slot went to /repos",
test/palette-roster.test.ts +3
@@ -49,6 +49,7 @@ test("a site-admin person is offered the whole roster", () => {
4949 ["explore", "Explore", "on the web"],
5050 ["profile", "Open a profile", "username or org"],
5151 ["repos", "Repositories", "the repositories you may write"],
52 ["displays", "Displays", "the displays you may read"],
5253 ["new-issue-anywhere", "New issue", "pick a repository"],
5354 ["repo", "Open a repository", "owner/name"],
5455 ["issues", "Issues in a repository", "owner/name"],
@@ -150,6 +151,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
150151 ["explore", "Explore", "on the web"],
151152 ["profile", "Open a profile", "username or org"],
152153 ["repos", "Repositories", "the repositories you may write"],
154 ["displays", "Displays", "the displays you may read"],
153155 ["new-issue-anywhere", "New issue", "pick a repository"],
154156 ["repo", "Open a repository", "owner/name"],
155157 ["issues", "Issues in a repository", "owner/name"],
@@ -215,6 +217,7 @@ test("a member person is offered the signed-in roster without the admin entries"
215217 ["explore", "Explore", "on the web"],
216218 ["profile", "Open a profile", "username or org"],
217219 ["repos", "Repositories", "the repositories you may write"],
220 ["displays", "Displays", "the displays you may read"],
218221 ["new-issue-anywhere", "New issue", "pick a repository"],
219222 ["repo", "Open a repository", "owner/name"],
220223 ["issues", "Issues in a repository", "owner/name"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs