Public: anyone can read this; the crew can change it.
Contraction: fold the no-JS search page into the search route #432
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/397-contraction-fold-the-no-js-search-page-i → main 1 commit fork
5 files
changed +288
−345
docs/STUDIO.md +9 −5
| @@ -10,11 +10,15 @@ whichever way its reader sees. | ||
| 10 | 10 | Finding something never leaves the page you're on. `/` focuses the search |
| 11 | 11 | box in the top bar, unless a field already has focus; `Cmd/Ctrl-K` opens the |
| 12 | 12 | 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 | |
| 18 | 22 | index — your own apps, then places and actions like Home, Apps, Map, New |
| 19 | 23 | app — and past two characters asks that very same search, folding its apps, |
| 20 | 24 | changes and tells in under their own headings (`rankPalette`, |
src/web/pages/search.ts +232 −125
| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Finding things, and confirming the things you cannot take back. |
| 3 | 3 | * |
| 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 | |
| 6 | 8 | * second, deliberate step every destructive POST passes through — and the |
| 7 | 9 | * only page in the product that knows which routes are dangerous, so a |
| 8 | 10 | * crafted link can neither invent reassuring words nor aim at an endpoint |
| @@ -10,9 +12,12 @@ | ||
| 10 | 12 | */ |
| 11 | 13 | |
| 12 | 14 | import { search } from "../../actions/search.ts"; |
| 15 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 16 | import type { AppRecord } from "../../core/apps.ts"; | |
| 13 | 17 | import { getPull } from "../../core/pulls.ts"; |
| 14 | 18 | import { canWrite, getRepo } from "../../core/repos.ts"; |
| 15 | 19 | import { |
| 20 | cleanQuery, | |
| 16 | 21 | searchKind, |
| 17 | 22 | type CodeHit, |
| 18 | 23 | type IssueHit, |
| @@ -26,11 +31,13 @@ import { html, when, type Html } from "../html.ts"; | ||
| 26 | 31 | import { icon } from "../icons.ts"; |
| 27 | 32 | import { page } from "../layout.ts"; |
| 28 | 33 | 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"; | |
| 30 | 36 | import { |
| 31 | 37 | badge, |
| 32 | 38 | btn, |
| 33 | 39 | card, |
| 40 | dot, | |
| 34 | 41 | empty, |
| 35 | 42 | field as formField, |
| 36 | 43 | listCard, |
| @@ -39,16 +46,54 @@ import { | ||
| 39 | 46 | seg, |
| 40 | 47 | stateBadge, |
| 41 | 48 | time, |
| 49 | type Tone, | |
| 42 | 50 | } from "../ui.ts"; |
| 43 | 51 | |
| 44 | 52 | // ---- search ---- |
| 45 | 53 | |
| 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 | ||
| 46 | 90 | /** |
| 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. | |
| 50 | 95 | */ |
| 51 | function repoHitRow(hit: RepoHit): Html { | |
| 96 | function repoHitRow(ctx: Ctx, a: Asked, apps: Apps, hit: RepoHit): Html { | |
| 52 | 97 | const counts = repoCounts({ |
| 53 | 98 | openIssues: hit.openIssues, |
| 54 | 99 | openPulls: hit.openPulls, |
| @@ -56,19 +101,29 @@ function repoHitRow(hit: RepoHit): Html { | ||
| 56 | 101 | ? { issuesIncludePulls: hit.openIssuesIncludingPulls } |
| 57 | 102 | : {}), |
| 58 | 103 | }); |
| 104 | const description = | |
| 105 | hit.description === "" ? null : html` · ${hit.description}`; | |
| 59 | 106 | 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); | |
| 60 | 110 | return row({ |
| 61 | href: `/${hit.owner}/${hit.name}`, | |
| 111 | href: `/${name}`, | |
| 62 | 112 | 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")}`, | |
| 72 | 127 | }); |
| 73 | 128 | } |
| 74 | 129 | const mirror = hit.mirroredAs; |
| @@ -94,25 +149,35 @@ function repoHitRow(hit: RepoHit): Html { | ||
| 94 | 149 | >${mirror === null |
| 95 | 150 | ? "not mirrored here" |
| 96 | 151 | : `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)}`, | |
| 101 | 154 | }); |
| 102 | 155 | } |
| 103 | 156 | |
| 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 | ||
| 104 | 172 | /** 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 { | |
| 107 | 174 | const where = |
| 108 | 175 | hit.source === "local" |
| 109 | 176 | ? `${hit.repo.owner}/${hit.repo.name}` |
| 110 | 177 | : hit.fullName; |
| 111 | 178 | return row({ |
| 112 | 179 | 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, | |
| 116 | 181 | nav: true, |
| 117 | 182 | lead: icon(hit.kind === "issue" ? "issue" : "pull"), |
| 118 | 183 | title: hit.title, |
| @@ -170,80 +235,118 @@ function codeHitRow(hit: CodeHit, query: string): Html { | ||
| 170 | 235 | </li>`; |
| 171 | 236 | } |
| 172 | 237 | |
| 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 | }; | |
| 179 | 264 | |
| 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 }); | |
| 182 | 271 | 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"); | |
| 184 | 274 | return `/search?${params.toString()}`; |
| 185 | 275 | } |
| 186 | 276 | |
| 187 | 277 | function section( |
| 188 | title: string, | |
| 189 | kind: SearchKind, | |
| 278 | a: Asked, | |
| 279 | [kind, , title, emptyText]: Words, | |
| 190 | 280 | 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, | |
| 198 | 282 | ): Html { |
| 283 | const emptyIcon = | |
| 284 | kind === "repos" ? "repo" : kind === "issues" ? "issue" : "code"; | |
| 199 | 285 | return html`<h2 class="mt" id="${kind}"> |
| 200 | 286 | ${title}${rows.length === 0 |
| 201 | 287 | ? null |
| 202 | 288 | : html` <span class="muted small num" |
| 203 | >${rows.length}${o.truncated ? "+" : ""}</span | |
| 289 | >${rows.length}${truncated ? "+" : ""}</span | |
| 204 | 290 | >`} |
| 205 | 291 | </h2> |
| 206 | 292 | ${rows.length === 0 |
| 207 | 293 | ? html`<p class="no-hits muted small"> |
| 208 | ${icon(o.emptyIcon)}<span>${o.emptyText}</span> | |
| 294 | ${icon(emptyIcon)}<span>${emptyText}</span> | |
| 209 | 295 | </p>` |
| 210 | 296 | : listCard(rows)} |
| 211 | 297 | ${when( |
| 212 | o.truncated, | |
| 298 | truncated, | |
| 213 | 299 | () => |
| 214 | 300 | html`<p class="muted small mt"> |
| 215 | 301 | More matched than fit here. |
| 216 | <a href="${searchHref(o.query, kind, o.live)}" | |
| 302 | <a href="${searchHref(a, { kind })}" | |
| 217 | 303 | >Show only ${title.toLowerCase()}</a |
| 218 | 304 | >. |
| 219 | 305 | </p>`, |
| 220 | 306 | )}`; |
| 221 | 307 | } |
| 222 | 308 | |
| 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 | ||
| 223 | 325 | /** |
| 224 | * GET /search — the whole forge, in one box. | |
| 326 | * GET /search — everything the viewer can see, in one box, with no script. | |
| 225 | 327 | * |
| 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. | |
| 230 | 333 | * |
| 231 | 334 | * `live=1` is opt-in and stays opt-in: it spends a round trip on every |
| 232 | 335 | * connected origin, which is not a thing to do on every keystroke. |
| 233 | 336 | */ |
| 234 | 337 | 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 | ||
| 242 | 338 | 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; | |
| 247 | 350 | |
| 248 | 351 | const form = html`<form |
| 249 | 352 | class="filters" |
| @@ -255,63 +358,75 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 255 | 358 | class="input search" |
| 256 | 359 | type="search" |
| 257 | 360 | 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"}" | |
| 260 | 365 | aria-label="Search" |
| 261 | 366 | autofocus |
| 262 | 367 | data-search-input |
| 263 | 368 | /> |
| 264 | 369 | ${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" />`, | |
| 267 | 377 | )} |
| 268 | ${when(live, () => html`<input type="hidden" name="live" value="1" />`)} | |
| 269 | 378 | <button class="btn" data-variant="primary" data-size="sm" type="submit"> |
| 270 | 379 | Search |
| 271 | 380 | </button> |
| 272 | 381 | </form>`; |
| 273 | 382 | |
| 274 | if (query === "") { | |
| 383 | if (a.query === "") { | |
| 275 | 384 | return page({ |
| 276 | 385 | ctx, |
| 277 | 386 | title: "Search", |
| 278 | 387 | body: html`${pageHead("Search")} ${form} |
| 279 | 388 | ${empty({ |
| 280 | 389 | 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.", | |
| 283 | 394 | })}`, |
| 284 | 395 | }); |
| 285 | 396 | } |
| 286 | 397 | |
| 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 | } | |
| 292 | 407 | |
| 293 | 408 | 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, | |
| 298 | 413 | })), |
| 299 | 414 | ); |
| 300 | 415 | |
| 301 | 416 | const liveToggle = when( |
| 302 | hasOrigins, | |
| 417 | ctx.auth.user !== undefined, | |
| 303 | 418 | () => |
| 304 | 419 | html`<p class="live-toggle small"> |
| 305 | 420 | ${icon("globe")} |
| 306 | ${live | |
| 421 | ${a.live | |
| 307 | 422 | ? html`<span |
| 308 | 423 | >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 | |
| 310 | 425 | >.</span |
| 311 | 426 | >` |
| 312 | 427 | : html`<span |
| 313 | 428 | >Origins are searched from the cache. |
| 314 | <a href="${searchHref(query, kind, true)}" | |
| 429 | <a href="${searchHref(a, { live: true })}" | |
| 315 | 430 | >Search live on origins</a |
| 316 | 431 | > |
| 317 | 432 | — a round trip to each connected origin, so it costs a |
| @@ -320,48 +435,40 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 320 | 435 | </p>`, |
| 321 | 436 | ); |
| 322 | 437 | |
| 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 | }; | |
| 324 | 453 | |
| 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>`, | |
| 363 | 470 | )}`; |
| 364 | return page({ ctx, title: `Search · ${query}`, body }); | |
| 471 | return page({ ctx, title: `Search · ${a.query}`, body }); | |
| 365 | 472 | } |
| 366 | 473 | |
| 367 | 474 | // ---- 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", () => { | ||
| 6950 | 6950 | expect(html).toContain("Repositories"); |
| 6951 | 6951 | expect(html).toContain("Issues and pull requests"); |
| 6952 | 6952 | }); |
| 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&kind=code&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&kind=repos&mode=dev"); | |
| 6997 | }); | |
| 6953 | 6998 | }); |
| 6954 | 6999 | |
| 6955 | 7000 | /** |
test/web-ui.test.ts +2 −3
| @@ -541,9 +541,8 @@ describe("web ui", () => { | ||
| 541 | 541 | ).text(); |
| 542 | 542 | expect(wildcard).not.toContain("needs a label"); |
| 543 | 543 | |
| 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. | |
| 547 | 546 | const whole = await ( |
| 548 | 547 | await fetch(`${base}/search?q=proj&mode=dev`, { |
| 549 | 548 | headers: { cookie: owner }, |
new above
offline