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

Contraction: fold the no-JS search page into the search route #432

Merged opened this

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

Merged

mason/forge:mason/397-contraction-fold-the-no-js-search-page-i → main 1 commit fork

  • Merged. mason/397-contraction-fold-the-no-js-search-page-i was merged into main as b856283.

Review

Sign in to review this change.
5 files changed +288 −345
docs/STUDIO.md +9 −5
@@ -10,11 +10,15 @@ whichever way its reader sees.
1010 Finding something never leaves the page you're on. `/` focuses the search
1111 box in the top bar, unless a field already has focus; `Cmd/Ctrl-K` opens the
1212 command palette from anywhere, a field included. The box takes its query to
13 `/search` (`studioSearchPage`, `src/web/studio/search.ts`): apps first,
14 matched by name, owner or description, then every change and tell whose
15 title mentions the word, each saying which app it's on — the same
16 visibility as the rest of the studio, so a signed-out reader sees only the
17 public apps a signed-out reader would. The palette starts from a short local
13 `/search` (`searchPage`, `src/web/pages/search.ts`), the one search in
14 both faces, server-rendered with no script: every repository the viewer
15 can see, matched by name or description, an app saying whether it is live,
16 then every tell and change whose title mentions the word, each saying
17 which app it's on and linked by the studio's noun — the same `search()`
18 the API and the palette ask, so a signed-out reader sees only what a
19 signed-out reader would anywhere. The studio's face leaves code to dev's:
20 it says _Search the code_, a link to the same page with `?mode=dev`, which
21 keeps that face on every link and the form until the reader leaves. The palette starts from a short local
1822 index — your own apps, then places and actions like Home, Apps, Map, New
1923 app — and past two characters asks that very same search, folding its apps,
2024 changes and tells in under their own headings (`rankPalette`,
src/web/pages/search.ts +232 −125
@@ -1,8 +1,10 @@
11 /**
22 * Finding things, and confirming the things you cannot take back.
33 *
4 * `/search` is the no-JavaScript twin of the command palette: one query box
5 * over the repositories and issues the viewer may see. `/-/confirm` is the
4 * `/search` is the one search, in either face, and it needs no script: one
5 * query box over every repository and conversation the viewer may see. The
6 * studio face says it in the studio's nouns — apps, tells, changes — and
7 * leaves the code to dev's face, one link away. `/-/confirm` is the
68 * second, deliberate step every destructive POST passes through — and the
79 * only page in the product that knows which routes are dangerous, so a
810 * crafted link can neither invent reassuring words nor aim at an endpoint
@@ -10,9 +12,12 @@
1012 */
1113
1214 import { search } from "../../actions/search.ts";
15 import { appsRuntime } from "../../apps/runtime.ts";
16 import type { AppRecord } from "../../core/apps.ts";
1317 import { getPull } from "../../core/pulls.ts";
1418 import { canWrite, getRepo } from "../../core/repos.ts";
1519 import {
20 cleanQuery,
1621 searchKind,
1722 type CodeHit,
1823 type IssueHit,
@@ -26,11 +31,13 @@ import { html, when, type Html } from "../html.ts";
2631 import { icon } from "../icons.ts";
2732 import { page } from "../layout.ts";
2833 import { browseBase } from "../scope.ts";
29 import { studioSearchPage } from "../studio/search.ts";
34 import { appsOf } from "../studio/asks.ts";
35 import { changeHref, tellHref } from "../studio/nouns.ts";
3036 import {
3137 badge,
3238 btn,
3339 card,
40 dot,
3441 empty,
3542 field as formField,
3643 listCard,
@@ -39,16 +46,54 @@ import {
3946 seg,
4047 stateBadge,
4148 time,
49 type Tone,
4250 } from "../ui.ts";
4351
4452 // ---- search ----
4553
54 /** What was asked, and in which face — everything a link back needs. */
55 interface Asked {
56 readonly query: string;
57 readonly kind: SearchKind;
58 readonly live: boolean;
59 /** The studio's face: its nouns, and no code. */
60 readonly studio: boolean;
61 /** `?mode=dev` from a studio browser, kept on every link and the form. */
62 readonly escape: boolean;
63 }
64
65 /** The apps among what the viewer can see, by id and by `owner/name`. One
66 * read for the whole page, never one per row. */
67 interface Apps {
68 readonly byId: ReadonlyMap<string, AppRecord>;
69 readonly names: ReadonlySet<string>;
70 }
71
72 const NO_APPS: Apps = { byId: new Map(), names: new Set() };
73
74 const privateBadge = (yes: boolean): Html =>
75 when(yes, () => badge("Private", { icon: "lock", variant: "secondary" }));
76
77 /** Whether an app is live. No query — `runtime.status` reads what is
78 * already in memory. */
79 function appState(ctx: Ctx, record: AppRecord): { tone: Tone; word: string } {
80 const runtime = appsRuntime(ctx.app);
81 const envs = runtime === undefined ? [] : runtime.status(record);
82 const state = envs.find((e) => e.env === "production")?.state ?? "stopped";
83 const tone: Tone =
84 state === "running" ? "success" : state === "failed" ? "danger" : "default";
85 const word =
86 state === "running" ? "live" : state === "stopped" ? "not running" : state;
87 return { tone, word };
88 }
89
4690 /**
47 * One repository hit, ours or an origin's. The where-dot is the same glyph
48 * the repository pages use, so "this is somewhere else" reads the same in a
49 * result list as it does on the page you land on.
91 * One repository hit, ours or an origin's. In dev the where-dot is the same
92 * glyph the repository pages use, so "this is somewhere else" reads the same
93 * in a result list as it does on the page you land on; in the studio an app
94 * says whether it is live instead.
5095 */
51 function repoHitRow(hit: RepoHit): Html {
96 function repoHitRow(ctx: Ctx, a: Asked, apps: Apps, hit: RepoHit): Html {
5297 const counts = repoCounts({
5398 openIssues: hit.openIssues,
5499 openPulls: hit.openPulls,
@@ -56,19 +101,29 @@ function repoHitRow(hit: RepoHit): Html {
56101 ? { issuesIncludePulls: hit.openIssuesIncludingPulls }
57102 : {}),
58103 });
104 const description =
105 hit.description === "" ? null : html` · ${hit.description}`;
59106 if (hit.source === "local") {
107 const name = `${hit.owner}/${hit.name}`;
108 const record = apps.byId.get(hit.id);
109 const live = record === undefined ? undefined : appState(ctx, record);
60110 return row({
61 href: `/${hit.owner}/${hit.name}`,
111 href: `/${name}`,
62112 nav: true,
63 lead: icon("repo"),
64 title: `${hit.owner}/${hit.name}`,
65 titleAttr: `${hit.owner}/${hit.name}`,
66 meta: html`<span class="where-place" data-state="reading"
67 ><span class="where-dot" aria-hidden="true">●</span>workbench</span
68 >${hit.description === "" ? null : html` · ${hit.description}`}`,
69 trail: html`${counts}${when(hit.visibility === "private", () =>
70 badge("Private", { icon: "lock", variant: "secondary" }),
71 )}`,
113 lead: live === undefined ? icon("repo") : dot(live.tone),
114 title: name,
115 titleAttr: hit.description || name,
116 meta: a.studio
117 ? hit.description === ""
118 ? html`<span class="muted">No description</span>`
119 : html`${hit.description}`
120 : html`<span class="where-place" data-state="reading"
121 ><span class="where-dot" aria-hidden="true">●</span
122 >workbench</span
123 >${description}`,
124 trail: html`${a.studio
125 ? when(live, (l) => badge(l.word, { tone: l.tone }))
126 : counts}${privateBadge(hit.visibility === "private")}`,
72127 });
73128 }
74129 const mirror = hit.mirroredAs;
@@ -94,25 +149,35 @@ function repoHitRow(hit: RepoHit): Html {
94149 >${mirror === null
95150 ? "not mirrored here"
96151 : `mirrored as ${mirror.owner}/${mirror.name}`}</span
97 >${hit.description === "" ? null : html` · ${hit.description}`}`,
98 trail: html`${counts}${when(hit.private, () =>
99 badge("Private", { icon: "lock", variant: "secondary" }),
100 )}`,
152 >${description}`,
153 trail: html`${counts}${privateBadge(hit.private)}`,
101154 });
102155 }
103156
157 /** Where a conversation here lives: the studio's noun on an app, so a
158 * person is never handed two addresses for one thing; dev's otherwise. */
159 function localIssueHref(
160 a: Asked,
161 apps: Apps,
162 hit: IssueHit & { source: "local" },
163 ): string {
164 const base = `/${hit.repo.owner}/${hit.repo.name}`;
165 if (a.studio && apps.names.has(`${hit.repo.owner}/${hit.repo.name}`))
166 return hit.kind === "issue"
167 ? tellHref(base, hit.number)
168 : changeHref(base, hit.number);
169 return `${base}/${hit.kind === "issue" ? "issues" : "pulls"}/${hit.number}`;
170 }
171
104172 /** One conversation hit. An origin's own goes out to the origin. */
105 function issueHitRow(hit: IssueHit): Html {
106 const segment = hit.kind === "issue" ? "issues" : "pulls";
173 function issueHitRow(a: Asked, apps: Apps, hit: IssueHit): Html {
107174 const where =
108175 hit.source === "local"
109176 ? `${hit.repo.owner}/${hit.repo.name}`
110177 : hit.fullName;
111178 return row({
112179 href:
113 hit.source === "local"
114 ? `/${hit.repo.owner}/${hit.repo.name}/${segment}/${hit.number}`
115 : hit.htmlUrl,
180 hit.source === "local" ? localIssueHref(a, apps, hit) : hit.htmlUrl,
116181 nav: true,
117182 lead: icon(hit.kind === "issue" ? "issue" : "pull"),
118183 title: hit.title,
@@ -170,80 +235,118 @@ function codeHitRow(hit: CodeHit, query: string): Html {
170235 </li>`;
171236 }
172237
173 const KINDS: { value: SearchKind; label: string }[] = [
174 { value: "all", label: "Everything" },
175 { value: "repos", label: "Repositories" },
176 { value: "issues", label: "Issues & pulls" },
177 { value: "code", label: "Code" },
178 ];
238 /** Each kind in each face's words: [kind, chip, heading, nothing found].
239 * The studio has no code row — that search is dev's. */
240 type Words = readonly [SearchKind, string, string, string];
241 const WORDS: { studio: readonly Words[]; dev: readonly Words[] } = {
242 studio: [
243 ["all", "Everything", "", ""],
244 ["repos", "Apps", "Apps", "No app matches"],
245 [
246 "issues",
247 "Tells & changes",
248 "Tells and changes",
249 "No tell or change matches",
250 ],
251 ],
252 dev: [
253 ["all", "Everything", "", ""],
254 ["repos", "Repositories", "Repositories", "No repository matches"],
255 [
256 "issues",
257 "Issues & pulls",
258 "Issues and pull requests",
259 "No issue or pull request matches",
260 ],
261 ["code", "Code", "Code", "No line of code matches"],
262 ],
263 };
179264
180 function searchHref(query: string, kind: SearchKind, live: boolean): string {
181 const params = new URLSearchParams({ q: query });
265 function searchHref(
266 a: Asked,
267 over: { kind?: SearchKind; live?: boolean; escape?: boolean } = {},
268 ): string {
269 const kind = over.kind ?? a.kind;
270 const params = new URLSearchParams({ q: a.query });
182271 if (kind !== "all") params.set("kind", kind);
183 if (live) params.set("live", "1");
272 if (over.live ?? a.live) params.set("live", "1");
273 if (over.escape ?? a.escape) params.set("mode", "dev");
184274 return `/search?${params.toString()}`;
185275 }
186276
187277 function section(
188 title: string,
189 kind: SearchKind,
278 a: Asked,
279 [kind, , title, emptyText]: Words,
190280 rows: Html[],
191 o: {
192 truncated: boolean;
193 emptyIcon: Parameters<typeof icon>[0];
194 emptyText: string;
195 query: string;
196 live: boolean;
197 },
281 truncated: boolean,
198282 ): Html {
283 const emptyIcon =
284 kind === "repos" ? "repo" : kind === "issues" ? "issue" : "code";
199285 return html`<h2 class="mt" id="${kind}">
200286 ${title}${rows.length === 0
201287 ? null
202288 : html` <span class="muted small num"
203 >${rows.length}${o.truncated ? "+" : ""}</span
289 >${rows.length}${truncated ? "+" : ""}</span
204290 >`}
205291 </h2>
206292 ${rows.length === 0
207293 ? html`<p class="no-hits muted small">
208 ${icon(o.emptyIcon)}<span>${o.emptyText}</span>
294 ${icon(emptyIcon)}<span>${emptyText}</span>
209295 </p>`
210296 : listCard(rows)}
211297 ${when(
212 o.truncated,
298 truncated,
213299 () =>
214300 html`<p class="muted small mt">
215301 More matched than fit here.
216 <a href="${searchHref(o.query, kind, o.live)}"
302 <a href="${searchHref(a, { kind })}"
217303 >Show only ${title.toLowerCase()}</a
218304 >.
219305 </p>`,
220306 )}`;
221307 }
222308
309 /** The same `search()` the API and the palette call, so the sources can
310 * never disagree between surfaces. The studio leaves the code to dev, so
311 * its "everything" is the two cheap asks, never a grep. */
312 async function find(ctx: Ctx, a: Asked): Promise<SearchResults> {
313 const ask = (kind: SearchKind) =>
314 search(ctx.app, ctx.auth.user, { q: a.query, kind, live: a.live });
315 if (!a.studio || a.kind !== "all") return ask(a.kind);
316 const [repos, issues] = await Promise.all([ask("repos"), ask("issues")]);
317 return {
318 repos: repos.repos,
319 issues: issues.issues,
320 code: [],
321 truncated: { ...issues.truncated, repos: repos.truncated.repos },
322 };
323 }
324
223325 /**
224 * GET /search — the whole forge, in one box.
326 * GET /search — everything the viewer can see, in one box, with no script.
225327 *
226 * It is the no-JavaScript twin of the palette and the only search that is
227 * always right: the same `search()` action the API and the palette call, so
228 * the four sources (repositories here, repositories cached from every origin,
229 * conversations, and the code itself) can never disagree between surfaces.
328 * The face follows the mode, like every other page: the studio's nouns in
329 * the studio, dev's (and dev's code search) in dev, and `?mode=dev` is the
330 * one-off way to dev's face without switching the browser. Either way it
331 * is the same visibility, so a signed-out reader sees exactly the public
332 * repositories a signed-out reader would anywhere else.
230333 *
231334 * `live=1` is opt-in and stays opt-in: it spends a round trip on every
232335 * connected origin, which is not a thing to do on every keystroke.
233336 */
234337 export async function searchPage(ctx: Ctx): Promise<Response> {
235 // In the studio a search is over apps, changes and tells, in the studio's
236 // own words — the same rule every other studio page follows. `?mode=dev`
237 // is the one-off way to dev's box without switching the browser.
238 const studio =
239 ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev";
240 if (studio) return studioSearchPage(ctx);
241
242338 const params = ctx.url.searchParams;
243 const query = (params.get("q") ?? "").trim();
244 const kind = searchKind(params.get("kind"));
245 const live = params.get("live") === "1";
246 const hasOrigins = ctx.auth.user !== undefined;
339 const escape = params.get("mode") === "dev";
340 const studio = ctx.mode === "studio" && !escape;
341 const asked = searchKind(params.get("kind"));
342 const a: Asked = {
343 query: cleanQuery(params.get("q") ?? ""),
344 kind: studio && asked === "code" ? "all" : asked,
345 live: params.get("live") === "1",
346 studio,
347 escape,
348 };
349 const words = studio ? WORDS.studio : WORDS.dev;
247350
248351 const form = html`<form
249352 class="filters"
@@ -255,63 +358,75 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
255358 class="input search"
256359 type="search"
257360 name="q"
258 value="${query}"
259 placeholder="Search repositories, issues and code"
361 value="${a.query}"
362 placeholder="${studio
363 ? "Search your apps, changes and tells"
364 : "Search repositories, issues and code"}"
260365 aria-label="Search"
261366 autofocus
262367 data-search-input
263368 />
264369 ${when(
265 kind !== "all",
266 () => html`<input type="hidden" name="kind" value="${kind}" />`,
370 a.kind !== "all",
371 () => html`<input type="hidden" name="kind" value="${a.kind}" />`,
372 )}
373 ${when(a.live, () => html`<input type="hidden" name="live" value="1" />`)}
374 ${when(
375 escape,
376 () => html`<input type="hidden" name="mode" value="dev" />`,
267377 )}
268 ${when(live, () => html`<input type="hidden" name="live" value="1" />`)}
269378 <button class="btn" data-variant="primary" data-size="sm" type="submit">
270379 Search
271380 </button>
272381 </form>`;
273382
274 if (query === "") {
383 if (a.query === "") {
275384 return page({
276385 ctx,
277386 title: "Search",
278387 body: html`${pageHead("Search")} ${form}
279388 ${empty({
280389 icon: "search",
281 title: "Search this forge",
282 note: "Repository names, issue and pull-request titles, and the code itself — across everything you can see, here and on every origin you have connected.",
390 title: studio ? "Search your studio" : "Search this forge",
391 note: studio
392 ? "An app's name or description, or a word from what was told or changed."
393 : "Repository names, issue and pull-request titles, and the code itself — across everything you can see, here and on every origin you have connected.",
283394 })}`,
284395 });
285396 }
286397
287 const found: SearchResults = await search(ctx.app, ctx.auth.user, {
288 q: query,
289 kind,
290 live,
291 });
398 const found = await find(ctx, a);
399 let apps = NO_APPS;
400 if (studio) {
401 const seen = appsOf(ctx, ctx.auth.user);
402 apps = {
403 byId: seen.records,
404 names: new Set(seen.repos.map((r) => `${r.ownerName}/${r.name}`)),
405 };
406 }
292407
293408 const chips = seg(
294 KINDS.map((k) => ({
295 label: k.label,
296 href: searchHref(query, k.value, live),
297 current: k.value === kind,
409 words.map(([kind, label]) => ({
410 label,
411 href: searchHref(a, { kind }),
412 current: kind === a.kind,
298413 })),
299414 );
300415
301416 const liveToggle = when(
302 hasOrigins,
417 ctx.auth.user !== undefined,
303418 () =>
304419 html`<p class="live-toggle small">
305420 ${icon("globe")}
306 ${live
421 ${a.live
307422 ? html`<span
308423 >Asking every connected origin's own search, live.
309 <a href="${searchHref(query, kind, false)}">Use the cache</a
424 <a href="${searchHref(a, { live: false })}">Use the cache</a
310425 >.</span
311426 >`
312427 : html`<span
313428 >Origins are searched from the cache.
314 <a href="${searchHref(query, kind, true)}"
429 <a href="${searchHref(a, { live: true })}"
315430 >Search live on origins</a
316431 >
317432 — a round trip to each connected origin, so it costs a
@@ -320,48 +435,40 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
320435 </p>`,
321436 );
322437
323 const wants = (which: SearchKind) => kind === "all" || kind === which;
438 const rows: Record<SearchKind, () => [Html[], boolean]> = {
439 all: () => [[], false],
440 repos: () => [
441 found.repos.map((hit) => repoHitRow(ctx, a, apps, hit)),
442 found.truncated.repos,
443 ],
444 issues: () => [
445 found.issues.map((hit) => issueHitRow(a, apps, hit)),
446 found.truncated.issues,
447 ],
448 code: () => [
449 found.code.map((hit) => codeHitRow(hit, a.query)),
450 found.truncated.code,
451 ],
452 };
324453
325 const body = html`${pageHead(html`Results for “${query}”`)} ${form} ${chips}
326 ${liveToggle}
327 ${when(wants("repos"), () =>
328 section("Repositories", "repos", found.repos.map(repoHitRow), {
329 truncated: found.truncated.repos,
330 emptyIcon: "repo",
331 emptyText: "No repository matches",
332 query,
333 live,
334 }),
335 )}
336 ${when(wants("issues"), () =>
337 section(
338 "Issues and pull requests",
339 "issues",
340 found.issues.map(issueHitRow),
341 {
342 truncated: found.truncated.issues,
343 emptyIcon: "issue",
344 emptyText: "No issue or pull request matches",
345 query,
346 live,
347 },
348 ),
349 )}
350 ${when(wants("code"), () =>
351 section(
352 "Code",
353 "code",
354 found.code.map((hit) => codeHitRow(hit, query)),
355 {
356 truncated: found.truncated.code,
357 emptyIcon: "code",
358 emptyText: "No line of code matches",
359 query,
360 live,
361 },
362 ),
454 const body = html`${pageHead(html`Results for “${a.query}”`)} ${form}
455 ${chips} ${liveToggle}
456 ${words
457 .filter(
458 ([kind]) => kind !== "all" && (a.kind === "all" || a.kind === kind),
459 )
460 .map((w) => section(a, w, ...rows[w[0]]()))}
461 ${when(
462 studio,
463 () =>
464 html`<p class="muted small mt">
465 ${icon("code")} Looking for a line of code?
466 <a href="${searchHref(a, { kind: "code", escape: true })}"
467 >Search the code</a
468 >.
469 </p>`,
363470 )}`;
364 return page({ ctx, title: `Search · ${query}`, body });
471 return page({ ctx, title: `Search · ${a.query}`, body });
365472 }
366473
367474 // ---- confirmation ----
src/web/studio/search.ts deleted −212
@@ -1,212 +0,0 @@
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 { changeHref, tellHref } from "./nouns.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 /** Where the hit lives, in the studio's own nouns — every hit here is on
81 * an app (`appsOf` is what was searched), so the studio's address is the
82 * one this page can hand out without ever sending a reader somewhere the
83 * page behind it would not. */
84 function issueHitHref(hit: LocalIssueHit): string {
85 const base = `/${hit.repo.owner}/${hit.repo.name}`;
86 return hit.kind === "issue"
87 ? tellHref(base, hit.number)
88 : changeHref(base, hit.number);
89 }
90
91 /** A change or a tell: which app it belongs to, and its own studio link —
92 * never the five-word badge (`pullWord`/`tellWord`), which reads git and a
93 * run per row and would make a keystroke here as slow as a page of them. */
94 function issueHitRow(hit: LocalIssueHit, query: string): Html {
95 return row({
96 href: issueHitHref(hit),
97 nav: true,
98 lead: icon(hit.kind === "issue" ? "issue" : "pull"),
99 title: markMatch(hit.title, query),
100 titleAttr: hit.title,
101 meta: html`${hit.repo.owner}/${hit.repo.name} · updated
102 ${time(hit.updatedAt)}`,
103 trail: stateBadge(hit.state),
104 });
105 }
106
107 function section(
108 title: string,
109 emptyIcon: Parameters<typeof icon>[0],
110 rows: Html[],
111 emptyText: string,
112 ): Html {
113 return html`<h2 class="mt">${title}</h2>
114 ${rows.length === 0
115 ? html`<p class="no-hits muted small">
116 ${icon(emptyIcon)}<span>${emptyText}</span>
117 </p>`
118 : listCard(rows)}`;
119 }
120
121 /**
122 * GET /search in the studio: apps first, then every change and tell whose
123 * title mentions the query, each saying which app it belongs to. An empty
124 * or overlong query is simply an empty result — `cleanQuery` is the same
125 * boundary dev's search enforces.
126 */
127 export function studioSearchPage(ctx: Ctx): Response {
128 const query = cleanQuery(ctx.url.searchParams.get("q") ?? "");
129
130 const form = html`<form
131 class="filters"
132 method="get"
133 action="/search"
134 role="search"
135 >
136 <input
137 class="input search"
138 type="search"
139 name="q"
140 value="${query}"
141 placeholder="Search your apps, changes and tells"
142 aria-label="Search"
143 autofocus
144 data-search-input
145 />
146 <button class="btn" data-variant="primary" data-size="sm" type="submit">
147 Search
148 </button>
149 </form>`;
150
151 if (query === "") {
152 return page({
153 ctx,
154 title: "Studio search",
155 body: html`${pageHead("Search")} ${form}
156 ${empty({
157 icon: "search",
158 title: "Search your studio",
159 note: "An app's name or description, or a word from what was told or changed.",
160 })}`,
161 });
162 }
163
164 const { repos, records } = appsOf(ctx, ctx.auth.user);
165 const apps = repos.filter((r) => matchesApp(r, query)).slice(0, APP_LIMIT);
166
167 const issueHits = searchLocalIssues(
168 ctx.app.db,
169 repos,
170 query,
171 ISSUE_READ_LIMIT,
172 ).filter((h): h is LocalIssueHit => h.source === "local");
173 const changes = issueHits
174 .filter((h) => h.kind === "pull")
175 .slice(0, CHANGE_LIMIT);
176 const tells = issueHits
177 .filter((h) => h.kind === "issue")
178 .slice(0, TELL_LIMIT);
179
180 const body = html`${pageHead(html`Results for “${query}”`)} ${form}
181 ${section(
182 "Apps",
183 "rocket",
184 apps.map((r) => appRow(ctx, r, records.get(r.id)!, query)),
185 "No app matches",
186 )}
187 ${section(
188 "Changes",
189 "pull",
190 changes.map((h) => issueHitRow(h, query)),
191 "No change matches",
192 )}
193 ${section(
194 "Tells",
195 "issue",
196 tells.map((h) => issueHitRow(h, query)),
197 "No tell matches",
198 )}
199 ${when(
200 apps.length === 0 && changes.length === 0 && tells.length === 0,
201 () =>
202 html`<p class="muted small mt">
203 Nothing matched — try a different word.
204 </p>`,
205 )}`;
206
207 return page({
208 ctx,
209 title: `Studio search · ${query}`,
210 body,
211 });
212 }
test/studio.test.ts +45
@@ -6950,6 +6950,51 @@ describe("finding things in the studio: /search", () => {
69506950 expect(html).toContain("Repositories");
69516951 expect(html).toContain("Issues and pull requests");
69526952 });
6953
6954 // #397, carrying #241's rule: the one search finds every repository the
6955 // viewer can see, not only apps — and a tell on it links where it lives.
6956 test("a repository that is not an app is found by its name and its description, and stays its owner's alone", async () => {
6957 const plain = await createRepo(app, alice, {
6958 name: "project-notes",
6959 visibility: "private",
6960 description: "notes about a project",
6961 });
6962 if (!plain.ok) throw new Error(String(plain.error));
6963 const told = openIssue(app, alice, plain.value, {
6964 title: "Sort the project notes",
6965 body: "",
6966 });
6967 if (!told.ok) throw new Error(String(told.error));
6968
6969 const byName = mainOf(await (await asAlice("/search?q=notes")).text());
6970 expect(byName).toContain('href="/alice/project-notes"');
6971 expect(byName).toContain(
6972 `href="/alice/project-notes/issues/${told.value.number}"`,
6973 );
6974 expect(byName).not.toContain("Nothing matched");
6975 const byDescription = mainOf(
6976 await (await asAlice("/search?q=about+a+project")).text(),
6977 );
6978 expect(byDescription).toContain('href="/alice/project-notes"');
6979
6980 const stranger = mainOf(await (await anonymous("/search?q=notes")).text());
6981 expect(stranger).not.toContain("project-notes");
6982 });
6983
6984 test("the studio face leaves the code to dev, one link away, and ?mode=dev rides every link and the form", async () => {
6985 const studio = mainOf(await (await asAlice("/search?q=chess")).text());
6986 expect(studio).not.toContain('id="code"');
6987 expect(studio).toContain("/search?q=chess&amp;kind=code&amp;mode=dev");
6988 // A hand-written kind=code in the studio is its everything, not an error.
6989 const code = await asAlice("/search?q=chess&kind=code");
6990 expect(code.status).toBe(200);
6991 expect(mainOf(await code.text())).toContain('id="repos"');
6992
6993 const dev = mainOf(await (await asAlice("/search?q=chess&mode=dev")).text());
6994 expect(dev).toContain('id="code"');
6995 expect(dev).toContain('name="mode" value="dev"');
6996 expect(dev).toContain("/search?q=chess&amp;kind=repos&amp;mode=dev");
6997 });
69536998 });
69546999
69557000 /**
test/web-ui.test.ts +2 −3
@@ -541,9 +541,8 @@ describe("web ui", () => {
541541 ).text();
542542 expect(wildcard).not.toContain("needs a label");
543543
544 // `mode=dev`: the studio's own `/search` is over apps, changes and
545 // tells (see `studioSearchPage`), never a plain repository — `proj` is
546 // dev's box to find, the same escape hatch `searchPage` documents.
544 // `mode=dev`: dev's face of the one `/search` (`searchPage`), the one
545 // that names a repository as one.
547546 const whole = await (
548547 await fetch(`${base}/search?q=proj&mode=dev`, {
549548 headers: { cookie: owner },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs