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

Finding things in the studio: the search box and the palette look for apps, changes and tells #131

Merged opened this

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

Merged

scout/forge:scout/87-finding-things-in-the-studio-the-search → main 1 commit fork

  • Merged. scout/87-finding-things-in-the-studio-the-search was merged into main as 7c25230.

Review

Sign in to review this change.
6 files changed +419 −6
src/web/layout.ts +28 −4
@@ -252,7 +252,7 @@ function helpButton(): Html {
252252
253253 // ---- palette index ----
254254
255 const STATIC_PALETTE: PaletteItem[] = [
255 const STATIC_PALETTE_DEV: PaletteItem[] = [
256256 { t: "Home", h: "/", c: "Go to" },
257257 { t: "Apps", h: "/apps", c: "Go to" },
258258 { t: "Map", h: "/map", c: "Go to" },
@@ -266,22 +266,46 @@ const STATIC_PALETTE: PaletteItem[] = [
266266 { t: "Origins", h: "/settings/origins", c: "Settings" },
267267 ];
268268
269 /**
270 * The studio's own verbs: no "New repository" or "New organization" — dev's
271 * own way of making one, which would land a studio reader on dev's own form
272 * unannounced. "Asks you" goes to the home, where the asks panel lives.
273 */
274 const STATIC_PALETTE_STUDIO: PaletteItem[] = [
275 { t: "Home", h: "/", c: "Go to" },
276 { t: "Apps", h: "/apps", c: "Go to" },
277 { t: "Map", h: "/map", c: "Go to" },
278 { t: "Asks you", h: "/", c: "Go to" },
279 { t: "New app", h: "/apps/new", c: "Create" },
280 { t: "Search", h: "/search", c: "Go to" },
281 { t: "Account settings", h: "/settings", c: "Settings" },
282 { t: "Access tokens", h: "/settings/tokens", c: "Settings" },
283 { t: "SSH keys", h: "/settings/keys", c: "Settings" },
284 { t: "Origins", h: "/settings/origins", c: "Settings" },
285 ];
286
269287 /**
270288 * The palette index rides as a `<script type="application/json">` data block.
271289 * The HTML spec stops parsing such a block as script, so the CSP never
272290 * applies and no nonce is needed — but `</script` must not appear inside it,
273291 * which escaping `<` guarantees.
292 *
293 * In the studio the by-name rows are apps, not every repository — the same
294 * visibility (`appsOf` is `listReposVisibleTo` underneath), just apps first.
274295 */
275296 function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html {
276 const repos = listReposVisibleTo(ctx.app.db, ctx.auth.user, { limit: 30 });
297 const studio = ctx.mode === "studio";
298 const repos = studio
299 ? appsOf(ctx, ctx.auth.user).repos.slice(0, 30)
300 : listReposVisibleTo(ctx.app.db, ctx.auth.user, { limit: 30 });
277301 const items: PaletteItem[] = [
278302 ...extra,
279303 ...repos.map((r) => ({
280304 t: `${r.ownerName}/${r.name}`,
281305 h: `/${r.ownerName}/${r.name}`,
282 c: "Repositories",
306 c: studio ? "Apps" : "Repositories",
283307 })),
284 ...STATIC_PALETTE,
308 ...(studio ? STATIC_PALETTE_STUDIO : STATIC_PALETTE_DEV),
285309 ];
286310 const json = JSON.stringify({ items }).replaceAll("<", "\\u003c");
287311 return raw(
src/web/pages/search.ts +8
@@ -24,6 +24,7 @@ import { html, when, type Html } from "../html.ts";
2424 import { icon } from "../icons.ts";
2525 import { page } from "../layout.ts";
2626 import { browseBase } from "../scope.ts";
27 import { studioSearchPage } from "../studio/search.ts";
2728 import {
2829 badge,
2930 btn,
@@ -229,6 +230,13 @@ function section(
229230 * connected origin, which is not a thing to do on every keystroke.
230231 */
231232 export async function searchPage(ctx: Ctx): Promise<Response> {
233 // In the studio a search is over apps, changes and tells, in the studio's
234 // own words — the same rule every other studio page follows. `?mode=dev`
235 // is the one-off way to dev's box without switching the browser.
236 const studio =
237 ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev";
238 if (studio) return studioSearchPage(ctx);
239
232240 const params = ctx.url.searchParams;
233241 const query = (params.get("q") ?? "").trim();
234242 const kind = searchKind(params.get("kind"));
src/web/studio/asks.ts +3 −2
@@ -35,8 +35,9 @@ import { btn, card, icon, row } from "../ui.ts";
3535 import { lookThumb, lookUrl } from "../pages/looks.ts";
3636 import { leanNote, leanVariant } from "./leans.ts";
3737
38 /** The apps a person can see, as repositories, with their records. */
39 export function appsOf(ctx: Ctx, user: User) {
38 /** The apps a person can see, as repositories, with their records — the
39 * same visibility a signed-out reader gets from `listReposVisibleTo` too. */
40 export function appsOf(ctx: Ctx, user: User | undefined) {
4041 const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 });
4142 const records = appsForRepos(
4243 ctx.app.db,
src/web/studio/search.ts added +208
@@ -0,0 +1,208 @@
1 /**
2 * Finding things in the studio: apps first, then the changes and tells that
3 * mention the query. The same route as dev's own `/search` (`pages/search.ts`
4 * delegates here in studio mode) and the same visibility — `appsOf` is
5 * `listReposVisibleTo` underneath, so a signed-out reader sees exactly the
6 * public apps a signed-out reader of dev's search would.
7 *
8 * Bounded, on purpose: one read for the apps (`appsOf`'s own 200-row cap,
9 * matched in memory — no per-row query) and one for the changes and tells
10 * together (`searchLocalIssues`, one `repo_id IN (...)` query), split by
11 * kind after the fact. Nothing here does a per-row query or a git read —
12 * that is what keeps a keystroke here as cheap as one in dev's own box.
13 */
14
15 import { appsRuntime } from "../../apps/runtime.ts";
16 import type { AppRecord } from "../../core/apps.ts";
17 import type { Repo } from "../../core/repos.ts";
18 import { cleanQuery, searchLocalIssues, type IssueHit } from "../../core/search.ts";
19 import type { Ctx } from "../../http/context.ts";
20 import { pageHead, repoBase } from "../chrome.ts";
21 import { html, when, type Html } from "../html.ts";
22 import { page } from "../layout.ts";
23 import { markMatch } from "../pages/search.ts";
24 import {
25 badge,
26 dot,
27 empty,
28 icon,
29 listCard,
30 row,
31 stateBadge,
32 time,
33 type Tone,
34 } from "../ui.ts";
35 import { appsOf } from "./asks.ts";
36 import { studioCss } from "./style.ts";
37
38 const APP_LIMIT = 20;
39 const CHANGE_LIMIT = 20;
40 const TELL_LIMIT = 20;
41 /** The raw read behind both: split by kind after, not asked for twice. */
42 const ISSUE_READ_LIMIT = CHANGE_LIMIT + TELL_LIMIT;
43
44 function matchesApp(repo: Repo, needle: string): boolean {
45 const q = needle.toLowerCase();
46 return (
47 repo.name.toLowerCase().includes(q) ||
48 repo.ownerName.toLowerCase().includes(q) ||
49 repo.description.toLowerCase().includes(q)
50 );
51 }
52
53 /** One app row: its name, one clamped line, and whether it is live. No
54 * per-row query — `runtime.status` reads what is already in memory. */
55 function appRow(ctx: Ctx, repo: Repo, record: AppRecord, query: string): Html {
56 const runtime = appsRuntime(ctx.app);
57 const envs = runtime === undefined ? [] : runtime.status(record);
58 const production = envs.find((e) => e.env === "production");
59 const state = production?.state ?? "stopped";
60 const tone: Tone =
61 state === "running" ? "success" : state === "failed" ? "danger" : "default";
62 return row({
63 href: repoBase(repo),
64 nav: true,
65 lead: dot(tone),
66 title: markMatch(repo.name, query),
67 titleAttr: repo.description || repo.name,
68 meta: repo.description
69 ? markMatch(repo.description, query)
70 : html`<span class="muted">No description</span>`,
71 trail: badge(
72 state === "running" ? "live" : state === "stopped" ? "not running" : state,
73 { tone },
74 ),
75 });
76 }
77
78 type LocalIssueHit = IssueHit & { source: "local" };
79
80 function issueHitHref(hit: LocalIssueHit): string {
81 const segment = hit.kind === "issue" ? "issues" : "pulls";
82 return `/${hit.repo.owner}/${hit.repo.name}/${segment}/${hit.number}`;
83 }
84
85 /** A change or a tell: which app it belongs to, and its own studio link —
86 * never the five-word badge (`pullWord`/`tellWord`), which reads git and a
87 * run per row and would make a keystroke here as slow as a page of them. */
88 function issueHitRow(hit: LocalIssueHit, query: string): Html {
89 return row({
90 href: issueHitHref(hit),
91 nav: true,
92 lead: icon(hit.kind === "issue" ? "issue" : "pull"),
93 title: markMatch(hit.title, query),
94 titleAttr: hit.title,
95 meta: html`${hit.repo.owner}/${hit.repo.name} · updated
96 ${time(hit.updatedAt)}`,
97 trail: stateBadge(hit.state),
98 });
99 }
100
101 function section(
102 title: string,
103 emptyIcon: Parameters<typeof icon>[0],
104 rows: Html[],
105 emptyText: string,
106 ): Html {
107 return html`<h2 class="mt">${title}</h2>
108 ${rows.length === 0
109 ? html`<p class="no-hits muted small">
110 ${icon(emptyIcon)}<span>${emptyText}</span>
111 </p>`
112 : listCard(rows)}`;
113 }
114
115 /**
116 * GET /search in the studio: apps first, then every change and tell whose
117 * title mentions the query, each saying which app it belongs to. An empty
118 * or overlong query is simply an empty result — `cleanQuery` is the same
119 * boundary dev's search enforces.
120 */
121 export function studioSearchPage(ctx: Ctx): Response {
122 const query = cleanQuery(ctx.url.searchParams.get("q") ?? "");
123
124 const form = html`<form
125 class="filters"
126 method="get"
127 action="/search"
128 role="search"
129 >
130 <input
131 class="input search"
132 type="search"
133 name="q"
134 value="${query}"
135 placeholder="Search your apps, changes and tells"
136 aria-label="Search"
137 autofocus
138 data-search-input
139 />
140 <button class="btn" data-variant="primary" data-size="sm" type="submit">
141 Search
142 </button>
143 </form>`;
144
145 if (query === "") {
146 return page({
147 ctx,
148 title: "Studio search",
149 styleCss: studioCss,
150 body: html`${pageHead("Search")} ${form}
151 ${empty({
152 icon: "search",
153 title: "Search your studio",
154 note: "An app's name or description, or a word from what was told or changed.",
155 })}`,
156 });
157 }
158
159 const { repos, records } = appsOf(ctx, ctx.auth.user);
160 const apps = repos.filter((r) => matchesApp(r, query)).slice(0, APP_LIMIT);
161
162 const issueHits = searchLocalIssues(
163 ctx.app.db,
164 repos,
165 query,
166 ISSUE_READ_LIMIT,
167 ).filter((h): h is LocalIssueHit => h.source === "local");
168 const changes = issueHits
169 .filter((h) => h.kind === "pull")
170 .slice(0, CHANGE_LIMIT);
171 const tells = issueHits
172 .filter((h) => h.kind === "issue")
173 .slice(0, TELL_LIMIT);
174
175 const body = html`${pageHead(html`Results for “${query}”`)} ${form}
176 ${section(
177 "Apps",
178 "rocket",
179 apps.map((r) => appRow(ctx, r, records.get(r.id)!, query)),
180 "No app matches",
181 )}
182 ${section(
183 "Changes",
184 "pull",
185 changes.map((h) => issueHitRow(h, query)),
186 "No change matches",
187 )}
188 ${section(
189 "Tells",
190 "issue",
191 tells.map((h) => issueHitRow(h, query)),
192 "No tell matches",
193 )}
194 ${when(
195 apps.length === 0 && changes.length === 0 && tells.length === 0,
196 () =>
197 html`<p class="muted small mt">
198 Nothing matched — try a different word.
199 </p>`,
200 )}`;
201
202 return page({
203 ctx,
204 title: `Studio search · ${query}`,
205 styleCss: studioCss,
206 body,
207 });
208 }
test/studio-words.test.ts +6
@@ -333,6 +333,9 @@ describe("dev's vocabulary never reaches a studio page", () => {
333333 { word: "working", path: `/alice/vocab/runs/${runId}` },
334334 { word: "look", path: `/alice/vocab/pulls/${openPullNumber}` },
335335 { word: "shipped", path: `/alice/vocab/pulls/${mergedPullNumber}` },
336 // A word from the app's own description, not its name — proving the
337 // search reaches the description, not just a name match.
338 { word: "search", path: "/search?q=catch" },
336339 ];
337340
338341 for (const p of pages) {
@@ -377,6 +380,9 @@ describe("dev's vocabulary never reaches a studio page", () => {
377380 visible,
378381 `${p.word} (${p.path}) named the merged branch "${mergedBranch}"`,
379382 ).not.toContain(mergedBranch);
383 // The search itself: "catch" is only in the app's description, never
384 // its name, so finding it here proves the description is reached.
385 if (p.word === "search") expect(visible).toContain("vocab");
380386 }
381387
382388 // A run id is a key, not a headline: it rides hrefs (the dev link, the
test/studio.test.ts +166
@@ -3450,3 +3450,169 @@ describe("this week's runs and spend are counted by the database, not read off a
34503450 expect(page).toContain("$0.00");
34513451 });
34523452 });
3453
3454 describe("finding things in the studio: /search", () => {
3455 let dataDir: string;
3456 let app: App;
3457 let servers: Servers;
3458 let base: string;
3459 let alice: User;
3460 let aliceCookie: string;
3461 let widgets: Repo;
3462 let tellNumber: number;
3463 let changeNumber: number;
3464
3465 beforeAll(async () => {
3466 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-search-"));
3467 app = createApp({ dataDir, port: 0 });
3468 servers = await serve(app);
3469 base = servers.url;
3470
3471 const a = await createUser(app.db, {
3472 username: "alice",
3473 email: "[email protected]",
3474 password: "a-good-passphrase",
3475 });
3476 if (!a.ok) throw new Error(a.error);
3477 alice = a.value;
3478 aliceCookie = (
3479 (
3480 await fetch(`${base}/signin`, {
3481 method: "POST",
3482 body: new URLSearchParams({
3483 username: "alice",
3484 password: "a-good-passphrase",
3485 }),
3486 redirect: "manual",
3487 })
3488 ).headers.get("set-cookie") ?? ""
3489 ).split(";")[0]!;
3490
3491 const made = await createRepo(app, alice, {
3492 name: "widgets",
3493 visibility: "public",
3494 description: "Sell handmade wooden chess sets",
3495 });
3496 if (!made.ok) throw new Error(String(made.error));
3497 widgets = made.value;
3498 const seeded = await commitToBranch(app, alice, widgets, {
3499 message: "Start",
3500 files: [{ path: "README.md", content: "start\n" }],
3501 });
3502 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
3503 const enabled = await enableApp(app, alice, widgets, { slug: "widgets" });
3504 if (!enabled.ok) throw new Error(String(enabled.error));
3505
3506 const told = openIssue(app, alice, widgets, {
3507 title: "Add a chess clock timer",
3508 body: "Players want a clock.",
3509 });
3510 if (!told.ok) throw new Error(String(told.error));
3511 tellNumber = told.value.number;
3512
3513 const branchMade = await commitToBranch(app, alice, widgets, {
3514 branch: "chess-clock",
3515 base: widgets.defaultBranch,
3516 message: "Add clock",
3517 files: [{ path: "clock.ts", content: "export {};\n" }],
3518 });
3519 if (!branchMade.ok) throw new Error(JSON.stringify(branchMade.error));
3520 const opened = await openPull(app, alice, widgets, {
3521 title: "Wire up the chess clock",
3522 body: "",
3523 baseBranch: widgets.defaultBranch,
3524 headBranch: "chess-clock",
3525 });
3526 if (!opened.ok) throw new Error(String(opened.error));
3527 changeNumber = opened.value.number;
3528
3529 // A private app, another owner's — never in alice's or a stranger's
3530 // results, however much its description matches.
3531 const bob = await createUser(app.db, {
3532 username: "bob",
3533 email: "[email protected]",
3534 password: "a-good-passphrase",
3535 });
3536 if (!bob.ok) throw new Error(bob.error);
3537 const secret = await createRepo(app, bob.value, {
3538 name: "secret",
3539 visibility: "private",
3540 description: "A private chess engine",
3541 });
3542 if (!secret.ok) throw new Error(String(secret.error));
3543 const secretSeeded = await commitToBranch(app, bob.value, secret.value, {
3544 message: "Start",
3545 files: [{ path: "README.md", content: "start\n" }],
3546 });
3547 if (!secretSeeded.ok) throw new Error(JSON.stringify(secretSeeded.error));
3548 const secretEnabled = await enableApp(app, bob.value, secret.value, {
3549 slug: "secret",
3550 });
3551 if (!secretEnabled.ok) throw new Error(String(secretEnabled.error));
3552 });
3553
3554 afterAll(async () => {
3555 servers.stop();
3556 app.close();
3557 await rm(dataDir, { recursive: true, force: true });
3558 });
3559
3560 const asAlice = (path: string) =>
3561 fetch(`${base}${path}`, {
3562 headers: { cookie: `${aliceCookie}; forge_mode=studio` },
3563 redirect: "manual",
3564 });
3565 const anonymous = (path: string) =>
3566 fetch(`${base}${path}`, {
3567 headers: { cookie: "forge_mode=studio" },
3568 redirect: "manual",
3569 });
3570 // The topbar carries the palette's own list of apps on every page, so an
3571 // assertion about the *results* reads the main content, not the whole
3572 // document.
3573 const mainOf = (html: string): string =>
3574 html.match(/<main[^>]*id="main"[\s\S]*?<\/main>/)?.[0] ?? html;
3575
3576 test("an app is found by a word from its own description", async () => {
3577 const res = await asAlice("/search?q=chess");
3578 expect(res.status).toBe(200);
3579 const html = await res.text();
3580 expect(html).toContain("widgets");
3581 expect(html).toContain("chess sets");
3582 // Bob's private app matches the same word but is never the viewer's to see.
3583 expect(html).not.toContain("secret");
3584 });
3585
3586 test("a change and a tell are found too, each saying which app they belong to", async () => {
3587 const html = await (await asAlice("/search?q=chess")).text();
3588 expect(html).toContain("Add a chess clock timer");
3589 expect(html).toContain(`/alice/widgets/issues/${tellNumber}`);
3590 expect(html).toContain("Wire up the chess clock");
3591 expect(html).toContain(`/alice/widgets/pulls/${changeNumber}`);
3592 expect(html).toContain("alice/widgets");
3593 });
3594
3595 test("an empty query is an empty result, not an error page", async () => {
3596 const res = await asAlice("/search");
3597 expect(res.status).toBe(200);
3598 const html = await res.text();
3599 expect(mainOf(html)).not.toContain("widgets");
3600 });
3601
3602 test("an overlong query is bounded, not a crash", async () => {
3603 const res = await asAlice(`/search?q=${"chess ".repeat(100)}`);
3604 expect(res.status).toBe(200);
3605 });
3606
3607 test("signed out, the same public visibility as dev's own search", async () => {
3608 const html = await (await anonymous("/search?q=chess")).text();
3609 expect(html).toContain("widgets");
3610 expect(html).not.toContain("secret");
3611 });
3612
3613 test("?mode=dev on the same route still answers in dev's own words", async () => {
3614 const html = await (await asAlice("/search?q=chess&mode=dev")).text();
3615 expect(html).toContain("Repositories");
3616 expect(html).toContain("Issues and pull requests");
3617 });
3618 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs