Public: anyone can read this; the crew can change it.
Cmd-K: typing a repository's name finds it, and a repository and its app read as one thing #7775
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7661-cmd-k-typing-a-repository-s-name-finds-i → main 7 commits fork 3 threads · 3 unresolved
11 files
changed +319
−28
README.md +2
| @@ -460,3 +460,5 @@ The terminal's palette has `Signed in` for anyone signed in: your browsers, toke | ||
| 460 | 460 | `/changelog` lists the last 30 releases of `FORGE_REPO` (public only) with their pulls, for anyone; the terminal's palette has `Changelog` for the same list. In the terminal, `/` takes a word and keeps the releases whose version or pull titles contain it (`esc` clears it); Enter on a release lists its pulls, and Enter on a pull opens it (`docs/features/changelog.md`). |
| 461 | 461 | |
| 462 | 462 | A repository admin cuts a wave from two doors, both through `cutWaveFor` (`src/actions/cut_wave.ts`): Settings → *Cut a wave*, and `c` on the terminal's waves screen, which says how many pulls would go in, asks `cut wave N? y/n` (`y` and `n` choose outright) and shows the draft pull's number (Enter opens it). Each opens a draft pull listing the pulls merged since the last wave; nothing merged, an earlier wave that does not parse, or a viewer who is not an admin is refused in the action's words, writing nothing. |
| 463 | ||
| 464 | Cmd-K finds a repository by its name first (exact, then prefix; your own ahead); a repository with an app has an "Open the app of …" option under it, and Cmd/Ctrl+Enter on the repository opens the app (`docs/features/cmd-k-repositories.md`). |
docs/features/cmd-k-repositories.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # Cmd-K: repositories and their apps | |
| 2 | ||
| 3 | One row per repository, under "Repositories". A repository with an app says so on its row ("App · running"); Enter goes to the repository; the app is its own option right under it ("Open the app of owner/name"), so arrow keys, touch and screen readers reach it, and Cmd/Ctrl+Enter on the repository is the shortcut. Only someone who may enter the app is shown it, in the index and in the server's search alike (`enterableApps`). | |
| 4 | ||
| 5 | Ranking: a name typed exactly, then a name it begins, outranks every other kind of match; the viewer's own repositories lead their tier. The index (rendered with the page) holds the viewer's own repositories first, thirty in all; the server's search (`searchLocalRepos`) ranks the same way over a wide page, so a repository outside the index still arrives first. | |
| 6 | ||
| 7 | Still split without earning it (not changed here): the top-nav and `/apps` shelf, the palette's "Apps" and "New app" rows, the TUI palette's repo list. | |
| 8 | ||
| 9 | The terminal's palette (`src/tui/`) is not changed: it lists screens and repositories by its own ranking, and doing both there is a separate, larger change. The index adds one batched read of app state per page render (`statusMany`: one query, in-memory environments); the search reads up to 100 repositories per query. |
src/actions/search.ts +12 −2
| @@ -14,8 +14,9 @@ | ||
| 14 | 14 | import { isActive } from "../core/users.ts"; |
| 15 | 15 | import { canSeeSealed } from "../core/sealed.ts"; |
| 16 | 16 | import type { App } from "../app.ts"; |
| 17 | import { appsForRepos } from "../core/apps.ts"; | |
| 17 | 18 | import { listOrigins } from "../core/origins.ts"; |
| 18 | import { canRead, listReposVisibleTo } from "../core/repos.ts"; | |
| 19 | import { canRead, enterableApps, listReposVisibleTo } from "../core/repos.ts"; | |
| 19 | 20 | import { |
| 20 | 21 | cleanQuery, |
| 21 | 22 | grepRepos, |
| @@ -82,6 +83,15 @@ export type SearchPhase = "repos" | "issues" | "code"; | ||
| 82 | 83 | * half — the same rows a signed-out repo listing shows — and no origins, |
| 83 | 84 | * because an origin belongs to the person who connected it. |
| 84 | 85 | */ |
| 86 | /** Whether the repository runs an app the actor may enter (a private one is its writers'). */ | |
| 87 | function hasEnterableApp(app: App, actor: User | undefined, repo: Repo): boolean { | |
| 88 | const record = appsForRepos(app.db, [repo.id]).get(repo.id); | |
| 89 | return ( | |
| 90 | record !== undefined && | |
| 91 | enterableApps(app.db, [{ repo, app: record }], actor, { named: false }).has(record.id) | |
| 92 | ); | |
| 93 | } | |
| 94 | ||
| 85 | 95 | export async function search( |
| 86 | 96 | app: App, |
| 87 | 97 | actor: User | undefined, |
| @@ -120,7 +130,7 @@ export async function search( | ||
| 120 | 130 | const repos = !wants("repos") |
| 121 | 131 | ? [] |
| 122 | 132 | : input.repo !== undefined |
| 123 | ? matchOneRepo(input.repo, q) | |
| 133 | ? matchOneRepo(input.repo, q, hasEnterableApp(app, actor, input.repo)) | |
| 124 | 134 | : [ |
| 125 | 135 | ...searchLocalRepos(app.db, actor, q, limit), |
| 126 | 136 | ...(actor ? searchOriginRepos(app.db, actor.id, q, limit) : []), |
src/core/repos.ts +1
| @@ -866,6 +866,7 @@ export function listReposByOwner( | ||
| 866 | 866 | params.push(options.cursor); |
| 867 | 867 | where += ` AND repo.id < ?${params.length}`; |
| 868 | 868 | } |
| 869 | if (options.withoutArchived === true) where += " AND repo.archived_at IS NULL"; | |
| 869 | 870 | params.push(pageLimit(options.limit)); |
| 870 | 871 | const rows = db |
| 871 | 872 | .query<RepoRow, (string | number)[]>( |
src/core/search.ts +57 −6
| @@ -30,8 +30,9 @@ import { runGitCapture } from "../git/env.ts"; | ||
| 30 | 30 | import { isSafeRef } from "../git/guard.ts"; |
| 31 | 31 | import { repoDir } from "../git/repos.ts"; |
| 32 | 32 | import { GREP_LIMITS, grepSlots, type GrepSlots } from "../search/grep_slots.ts"; |
| 33 | import { appsForRepos } from "./apps.ts"; | |
| 33 | 34 | import { likeEscape } from "./issues.ts"; |
| 34 | import { listReposVisibleTo, type Repo } from "./repos.ts"; | |
| 35 | import { enterableApps, listReposVisibleTo, type Repo } from "./repos.ts"; | |
| 35 | 36 | import type { User } from "./users.ts"; |
| 36 | 37 | |
| 37 | 38 | /** The longest query we will run. Longer is a paste, not a search. */ |
| @@ -71,6 +72,8 @@ export type RepoHit = | ||
| 71 | 72 | readonly archived: boolean; |
| 72 | 73 | readonly openIssues: number; |
| 73 | 74 | readonly openPulls: number; |
| 75 | /** The repository runs an app. */ | |
| 76 | readonly app: boolean; | |
| 74 | 77 | } |
| 75 | 78 | | { |
| 76 | 79 | readonly source: "origin"; |
| @@ -157,7 +160,13 @@ export function searchLocalRepos( | ||
| 157 | 160 | query: string, |
| 158 | 161 | limit: number, |
| 159 | 162 | ): RepoHit[] { |
| 160 | const repos = listReposVisibleTo(db, viewer, { query, limit }); | |
| 163 | // A wide page, then ranked: with many forks of similar names the newest | |
| 164 | // `limit` would cut the repository whose name was typed. | |
| 165 | const repos = rankByName( | |
| 166 | listReposVisibleTo(db, viewer, { query, limit: 100 }), | |
| 167 | viewer, | |
| 168 | query, | |
| 169 | ); | |
| 161 | 170 | // `listReposVisibleTo` matches owner and name; description is the extra |
| 162 | 171 | // reach a search box wants, so it is unioned in rather than replacing it. |
| 163 | 172 | const byDescription = describedRepos(db, viewer, query, limit); |
| @@ -168,17 +177,58 @@ export function searchLocalRepos( | ||
| 168 | 177 | seen.add(repo.id); |
| 169 | 178 | repos.push(repo); |
| 170 | 179 | } |
| 171 | return repos.slice(0, limit).map(toRepoHit); | |
| 180 | const page = repos.slice(0, limit); | |
| 181 | // A private app is the writers' to know of; to anyone else the row is plain. | |
| 182 | const apps = appsForRepos( | |
| 183 | db, | |
| 184 | page.map((r) => r.id), | |
| 185 | ); | |
| 186 | const enterable = enterableApps( | |
| 187 | db, | |
| 188 | page.flatMap((repo) => { | |
| 189 | const app = apps.get(repo.id); | |
| 190 | return app === undefined ? [] : [{ repo, app }]; | |
| 191 | }), | |
| 192 | viewer, | |
| 193 | { named: false }, | |
| 194 | ); | |
| 195 | return page.map((r) => { | |
| 196 | const app = apps.get(r.id); | |
| 197 | return toRepoHit(r, app !== undefined && enterable.has(app.id)); | |
| 198 | }); | |
| 199 | } | |
| 200 | ||
| 201 | /** | |
| 202 | * Exact name first, then names that begin with what was typed, then the | |
| 203 | * rest; within a tier the viewer's own repositories lead. Stable, so the | |
| 204 | * listing's newest-first order decides among equals. | |
| 205 | */ | |
| 206 | function rankByName( | |
| 207 | repos: Repo[], | |
| 208 | viewer: User | undefined, | |
| 209 | query: string, | |
| 210 | ): Repo[] { | |
| 211 | const q = query.trim().toLowerCase(); | |
| 212 | const tier = (r: Repo): number => { | |
| 213 | const name = r.name.toLowerCase(); | |
| 214 | const exact = name === q || `${r.ownerName}/${name}`.toLowerCase() === q; | |
| 215 | const t = exact ? 0 : name.startsWith(q) ? 1 : 2; | |
| 216 | return t * 2 + (viewer !== undefined && r.ownerId === viewer.id ? 0 : 1); | |
| 217 | }; | |
| 218 | return repos | |
| 219 | .map((r, i) => ({ r, i, t: tier(r) })) | |
| 220 | .sort((a, b) => a.t - b.t || a.i - b.i) | |
| 221 | .map((x) => x.r); | |
| 172 | 222 | } |
| 173 | 223 | |
| 174 | 224 | /** The one repository a scoped search is about, when its owner, name or description holds the words. */ |
| 175 | export function matchOneRepo(repo: Repo, query: string): RepoHit[] { | |
| 225 | export function matchOneRepo(repo: Repo, query: string, app: boolean): RepoHit[] { | |
| 176 | 226 | const needle = query.trim().toLowerCase(); |
| 177 | 227 | const hay = `${repo.ownerName}/${repo.name} ${repo.description}`.toLowerCase(); |
| 178 | return needle !== "" && hay.includes(needle) ? [toRepoHit(repo)] : []; | |
| 228 | return needle !== "" && hay.includes(needle) ? [toRepoHit(repo, app)] : []; | |
| 179 | 229 | } |
| 180 | 230 | |
| 181 | function toRepoHit(repo: Repo): RepoHit { | |
| 231 | function toRepoHit(repo: Repo, app: boolean): RepoHit { | |
| 182 | 232 | return { |
| 183 | 233 | source: "local", |
| 184 | 234 | id: repo.id, |
| @@ -189,6 +239,7 @@ function toRepoHit(repo: Repo): RepoHit { | ||
| 189 | 239 | archived: repo.archivedAt !== null, |
| 190 | 240 | openIssues: repo.counts?.openIssues ?? 0, |
| 191 | 241 | openPulls: repo.counts?.openPulls ?? 0, |
| 242 | app, | |
| 192 | 243 | }; |
| 193 | 244 | } |
| 194 | 245 |
src/web/client.ts +31 −2
| @@ -1614,7 +1614,12 @@ export const shellJs = /* js */ ` | ||
| 1614 | 1614 | rows.push(r.source === 'origin' |
| 1615 | 1615 | ? { t: r.fullName, h: '/origins/' + r.originId + '/r/' + r.fullName, g: 'Repositories', |
| 1616 | 1616 | d: r.originKind ? 'on ' + r.originKind : 'on an origin' } |
| 1617 | : { t: r.owner + '/' + r.name, h: '/' + r.owner + '/' + r.name, g: 'Repositories' }); | |
| 1617 | : (function () { | |
| 1618 | var row = { t: r.owner + '/' + r.name, h: '/' + r.owner + '/' + r.name, g: 'Repositories', n: r.name }; | |
| 1619 | if (r.owner === loadIndex().me) row.m = 1; | |
| 1620 | if (r.app) { row.x = 'App'; row.o = row.h + '/app/production/open'; } | |
| 1621 | return row; | |
| 1622 | })()); | |
| 1618 | 1623 | }); |
| 1619 | 1624 | // An issue lives at /issues/:n and a pull request at /pulls/:n in every |
| 1620 | 1625 | // mode (\`tellHref\`/\`changeHref\`, src/web/studio/nouns.ts). |
| @@ -1745,7 +1750,12 @@ export const shellJs = /* js */ ` | ||
| 1745 | 1750 | var input = dlg.querySelector('.palette-input'); |
| 1746 | 1751 | var list = dlg.querySelector('.palette-list'); |
| 1747 | 1752 | var items = (loadIndex().items || []).map(function (i) { |
| 1748 | return { t: String(i.t), h: String(i.h), g: i.c ? String(i.c) : 'Go to' }; | |
| 1753 | var r = { t: String(i.t), h: String(i.h), g: i.c ? String(i.c) : 'Go to' }; | |
| 1754 | if (i.n) r.n = String(i.n); | |
| 1755 | if (i.m === 1) r.m = 1; | |
| 1756 | if (i.x) r.x = String(i.x); | |
| 1757 | if (i.o && String(i.o).charAt(0) === '/') r.o = String(i.o); | |
| 1758 | return r; | |
| 1749 | 1759 | }); |
| 1750 | 1760 | var tellApp = (loadIndex().tell || {}).app; |
| 1751 | 1761 | var recent = readRecent().map(function (r) { return { t: r.t, h: r.h, g: 'Recent', d: r.g }; }); |
| @@ -1774,6 +1784,12 @@ export const shellJs = /* js */ ` | ||
| 1774 | 1784 | t.className = 'grow'; |
| 1775 | 1785 | t.textContent = r.t; |
| 1776 | 1786 | a.appendChild(t); |
| 1787 | if (r.x) { | |
| 1788 | var x = doc.createElement('span'); | |
| 1789 | x.className = 'cat'; | |
| 1790 | x.textContent = r.x; | |
The app label is a plain span; make it a link so touch and screen-reader users can reach the app. |
||
| 1791 | a.appendChild(x); | |
| 1792 | } | |
| 1777 | 1793 | if (r.d) { |
| 1778 | 1794 | var d = doc.createElement('span'); |
| 1779 | 1795 | d.className = 'cat'; |
| @@ -1827,6 +1843,14 @@ export const shellJs = /* js */ ` | ||
| 1827 | 1843 | var a = row(r, 'palette-o' + nodes.length); |
| 1828 | 1844 | nodes.push(a); shown.push(r); |
| 1829 | 1845 | box.appendChild(a); |
| 1846 | // The app is its own option under its repository: reachable by | |
| 1847 | // arrow, touch and screen reader, not only by Cmd/Ctrl+Enter. | |
| 1848 | if (r.o) { | |
| 1849 | var ar = { t: 'Open the app of ' + r.t, h: r.o, g: g.title }; | |
| 1850 | var aa = row(ar, 'palette-o' + nodes.length); | |
| 1851 | nodes.push(aa); shown.push(ar); | |
| 1852 | box.appendChild(aa); | |
| 1853 | } | |
| 1830 | 1854 | }); |
| 1831 | 1855 | frag.appendChild(box); |
| 1832 | 1856 | }); |
| @@ -1907,6 +1931,11 @@ export const shellJs = /* js */ ` | ||
| 1907 | 1931 | else if (k === 'ArrowUp') { e.preventDefault(); select(at - 1); } |
| 1908 | 1932 | else if (k === 'Home') { e.preventDefault(); select(0); } |
| 1909 | 1933 | else if (k === 'End') { e.preventDefault(); select(nodes.length - 1); } |
| 1934 | else if (k === 'Enter' && (e.metaKey || e.ctrlKey) && shown[at] && shown[at].o) { | |
| 1935 | e.preventDefault(); | |
| 1936 | remember(shown[at]); | |
| 1937 | location.href = shown[at].o; | |
| 1938 | } | |
| 1910 | 1939 | else if (k === 'Enter') { |
| 1911 | 1940 | e.preventDefault(); |
| 1912 | 1941 | var q = input.value.trim(); |
src/web/layout.ts +70 −14
| @@ -11,7 +11,13 @@ import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch | ||
| 11 | 11 | import { protectionNotice } from "../kernel/protection.ts"; |
| 12 | 12 | import { administersAny } from "../core/admin_repos.ts"; |
| 13 | 13 | import { appsForRepos } from "../core/apps.ts"; |
| 14 | import { getRepo, listReposVisibleTo } from "../core/repos.ts"; | |
| 14 | import { appsRuntime } from "../apps/runtime.ts"; | |
| 15 | import { | |
| 16 | enterableApps, | |
| 17 | getRepo, | |
| 18 | listReposByOwner, | |
| 19 | listReposVisibleTo, | |
| 20 | } from "../core/repos.ts"; | |
| 15 | 21 | import { mayDirectCrew } from "../crew/direct.ts"; |
| 16 | 22 | import type { User } from "../core/users.ts"; |
| 17 | 23 | import type { Ctx } from "../http/context.ts"; |
| @@ -50,6 +56,11 @@ export interface PaletteItem { | ||
| 50 | 56 | t: string; |
| 51 | 57 | h: string; |
| 52 | 58 | c?: string; |
| 59 | /** A repository's bare name; its app's label and door; whether the viewer owns it. */ | |
| 60 | n?: string; | |
| 61 | x?: string; | |
| 62 | o?: string; | |
| 63 | m?: 1; | |
| 53 | 64 | } |
| 54 | 65 | |
| 55 | 66 | export interface PageOptions { |
| @@ -341,24 +352,64 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html { | ||
| 341 | 352 | // One forge (#603): a conversation's remote row is built with the |
| 342 | 353 | // studio's nouns, which answer for every repository. |
| 343 | 354 | const studio = true; |
| 344 | // Apps first, then the other repositories, thirty in all; archived ones | |
| 355 | // The viewer's own first, apps ahead of the rest in each half, thirty in | |
| 356 | // all; archived ones | |
| 345 | 357 | // are put away, found by typing the full name into search. |
| 346 | const repos = listReposVisibleTo(ctx.app.db, ctx.auth.user, { | |
| 347 | limit: 30, | |
| 348 | appsFirst: true, | |
| 349 | withoutArchived: true, | |
| 350 | }); | |
| 358 | const wanted = { limit: 30, appsFirst: true, withoutArchived: true }; | |
| 359 | const mine = ctx.auth.user | |
| 360 | ? listReposByOwner(ctx.app.db, ctx.auth.user.username, ctx.auth.user, wanted) | |
| 361 | : []; | |
| 362 | const taken = new Set(mine.map((r) => r.id)); | |
| 363 | const repos = [ | |
| 364 | ...mine, | |
| 365 | ...listReposVisibleTo(ctx.app.db, ctx.auth.user, wanted).filter( | |
| 366 | (r) => !taken.has(r.id), | |
| 367 | ), | |
| 368 | ].slice(0, 30); | |
| 351 | 369 | const apps = appsForRepos( |
| 352 | 370 | ctx.app.db, |
| 353 | 371 | repos.map((r) => r.id), |
| 354 | 372 | ); |
| 373 | // A private app is the writers' to see; to anyone else the row is plain. | |
| 374 | // Asked once for the page, never per row. | |
| 375 | const enterable = enterableApps( | |
| 376 | ctx.app.db, | |
| 377 | repos.flatMap((repo) => { | |
| 378 | const app = apps.get(repo.id); | |
| 379 | return app === undefined ? [] : [{ repo, app }]; | |
| 380 | }), | |
| 381 | ctx.auth.user, | |
| 382 | { named: false }, | |
| 383 | ); | |
| 384 | const open = repos.flatMap((r) => { | |
| 385 | const app = apps.get(r.id); | |
| 386 | return app !== undefined && enterable.has(app.id) ? [app] : []; | |
| 387 | }); | |
| 388 | const states = open.length > 0 ? appsRuntime(ctx.app)?.statusMany(open) : undefined; | |
| 355 | 389 | const items: PaletteItem[] = [ |
| 356 | 390 | ...extra, |
| 357 | ...repos.map((r) => ({ | |
| 358 | t: `${r.ownerName}/${r.name}`, | |
| 359 | h: `/${r.ownerName}/${r.name}`, | |
| 360 | c: apps.has(r.id) ? "Apps" : "Repositories", | |
| 361 | })), | |
| 391 | ...repos.map((r): PaletteItem => { | |
| 392 | const found = apps.get(r.id); | |
| 393 | const app = found !== undefined && enterable.has(found.id) ? found : undefined; | |
| 394 | const base = `/${r.ownerName}/${r.name}`; | |
| 395 | const state = | |
| 396 | app === undefined | |
| 397 | ? undefined | |
| 398 | : states?.get(app.id)?.find((e) => e.env === "production")?.state; | |
| 399 | return { | |
| 400 | t: `${r.ownerName}/${r.name}`, | |
| 401 | h: base, | |
| 402 | c: "Repositories", | |
| 403 | n: r.name, | |
| 404 | ...(r.ownerId === ctx.auth.user?.id ? { m: 1 as const } : {}), | |
| 405 | ...(app === undefined | |
| 406 | ? {} | |
| 407 | : { | |
| 408 | x: `App · ${state ?? "not running"}`, | |
| 409 | o: `${base}/app/production/open`, | |
| 410 | }), | |
| 411 | }; | |
| 412 | }), | |
| 362 | 413 | ...STATIC_PALETTE, |
| 363 | 414 | ...(sendsForges(ctx) |
| 364 | 415 | ? [{ t: "New forge", h: "/forges/new", c: "Create" }] |
| @@ -375,7 +426,12 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html { | ||
| 375 | 426 | // API found, folded in the browser — build the same addresses the server |
| 376 | 427 | // just rendered (`tellHref`/`changeHref`, `src/web/studio/nouns.ts`). The |
| 377 | 428 | // palette can never hand out a noun the page behind it would not. |
| 378 | const json = JSON.stringify({ items, studio, tell: tellTarget(ctx) }).replaceAll("<", "\\u003c"); | |
| 429 | const json = JSON.stringify({ | |
| 430 | items, | |
| 431 | studio, | |
| 432 | me: ctx.auth.user?.username, | |
| 433 | tell: tellTarget(ctx), | |
| 434 | }).replaceAll("<", "\\u003c"); | |
| 379 | 435 | return raw( |
| 380 | 436 | `<script type="application/json" id="palette-index">${json}</script>`, |
| 381 | 437 | ); |
| @@ -417,7 +473,7 @@ function overlays(): Html { | ||
| 417 | 473 | aria-label="Results" |
| 418 | 474 | ></div> |
| 419 | 475 | <div class="palette-foot"> |
| 420 | ${kbd(["↑", "↓"])} navigate ${kbd(["↵"])} open ${kbd(["esc"])} close | |
| 476 | ${kbd(["↑", "↓"])} navigate ${kbd(["↵"])} open ${kbd(["⌘", "↵"])} its app ${kbd(["esc"])} close | |
| 421 | 477 | </div> |
| 422 | 478 | </dialog> |
| 423 | 479 | <dialog class="dialog" id="help-dialog" aria-labelledby="help-title"> |
src/web/palette.ts +17
| @@ -17,6 +17,14 @@ export interface PaletteRow { | ||
| 17 | 17 | readonly h: string; |
| 18 | 18 | readonly g: string; |
| 19 | 19 | readonly d?: string; |
| 20 | /** A repository's bare name: an exact or prefix landing on it outranks everything. */ | |
| 21 | readonly n?: string; | |
| 22 | /** The viewer owns this repository: its name matches lead the others. */ | |
| 23 | readonly m?: 1; | |
| 24 | /** What the app is, for a repository that has one ("App · running"); shown, never scored. */ | |
| 25 | readonly x?: string; | |
| 26 | /** Where Cmd/Ctrl+Enter goes: the app itself. */ | |
| 27 | readonly o?: string; | |
| 20 | 28 | } |
| 21 | 29 | |
| 22 | 30 | /** A heading and the rows under it, best first. */ |
| @@ -95,6 +103,15 @@ export function rankPalette( | ||
| 95 | 103 | const detail = r.d === undefined ? 0 : fuzzyScore(q, r.d); |
| 96 | 104 | if (title === 0 && detail === 0) return; |
| 97 | 105 | s = title + detail / 2 + (recent.indexOf(r.h) === -1 ? 0 : 50); |
| 106 | // A repository named by what was typed beats every other kind of | |
| 107 | // landing: exact, then prefix; the viewer's own ahead of the rest. | |
| 108 | if (r.n !== undefined && !/\s/.test(q)) { | |
| 109 | const ql = q.toLowerCase(); | |
| 110 | const nl = r.n.toLowerCase(); | |
| 111 | const tl = r.t.toLowerCase(); | |
| 112 | const tier = nl === ql || tl === ql ? 3000 : nl.indexOf(ql) === 0 || tl.indexOf(ql) === 0 ? 2000 : 0; | |
| 113 | if (tier > 0) s = tier + (r.m === 1 ? 500 : 0) + s / 10; | |
| 114 | } | |
| 98 | 115 | } |
| 99 | 116 | seen.add(r.h); |
| 100 | 117 | scored.push({ r, s, i }); |
test/palette-repos.test.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * Cmd-K: a repository's name finds it first, and a repository and its app are | |
| 3 | * one row (#7661). | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { search } from "../src/actions/search.ts"; | |
| 12 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | import { rankPalette, type PaletteRow } from "../src/web/palette.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { promote } from "./helpers/members.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | let dataDir = ""; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let cookie = ""; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dataDir = await mkdtemp(join(tmpdir(), "forge-palette-repos-")); | |
| 28 | app = createApp({ dataDir, port: 0 }); | |
| 29 | servers = await serve(app); | |
| 30 | const pw = "a-long-palette-passphrase"; | |
| 31 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: pw, adminIfFirst: true }); | |
| 32 | if (!made.ok) throw new Error(made.error); | |
| 33 | const other = await createUser(app.db, { origin: "seed", trusted: true, username: "zed", email: "[email protected]", password: pw }); | |
| 34 | if (!other.ok) throw new Error(other.error); | |
| 35 | promote(app.db, other.value); | |
| 36 | const res = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "trevato", password: pw }), redirect: "manual" }); | |
| 37 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 38 | // The owner's real repository is the oldest; sixty newer forks and lookalikes follow. | |
| 39 | const first = await createRepo(app, made.value, { name: "forge", visibility: "public" }); | |
| 40 | if (!first.ok) throw new Error(String(first.error)); | |
| 41 | createAppRecord(app.db, { repoId: first.value.id, slug: "forge", access: "public" }); | |
| 42 | for (let i = 0; i < 60; i++) { | |
| 43 | const r = await createRepo(app, other.value, { name: i % 2 === 0 ? `forge-fork-${i}` : `my-forge-${i}`, visibility: "public" }); | |
| 44 | if (!r.ok) throw new Error(String(r.error)); | |
| 45 | } | |
| 46 | const exact = await createRepo(app, other.value, { name: "forge", visibility: "public" }); | |
| 47 | if (!exact.ok) throw new Error(String(exact.error)); | |
| 48 | }, 60_000); | |
| 49 | ||
| 50 | afterAll(async () => { | |
| 51 | await servers.stop(); | |
| 52 | await rm(dataDir, { recursive: true, force: true }); | |
| 53 | }); | |
| 54 | ||
| 55 | test("the server's search puts exact names first, the viewer's own ahead, however many forks are newer", async () => { | |
| 56 | const user = getUserByName(app.db, "trevato")!; | |
| 57 | const out = await search(app, user, { q: "forge", kind: "repos", limit: 5 }); | |
| 58 | const names = out.repos.map((r) => (r.source === "local" ? `${r.owner}/${r.name}` : r.fullName)); | |
| 59 | expect(names.slice(0, 2)).toEqual(["trevato/forge", "zed/forge"]); | |
| 60 | expect(names[2]).toMatch(/^zed\/forge-fork-/); | |
| 61 | }); | |
| 62 | ||
| 63 | const row = (t: string, extra: Partial<PaletteRow> = {}): PaletteRow => ({ t, h: `/${t}`, g: "Repositories", n: t.split("/")[1] ?? t, ...extra }); | |
| 64 | ||
| 65 | test("exact and prefix name matches outrank word-start and substring ones; the viewer's own lead", () => { | |
| 66 | const rows = [ | |
| 67 | row("zed/my-forge-1"), | |
| 68 | row("zed/forge-fork-2"), | |
| 69 | row("zed/forge", {}), | |
| 70 | row("trevato/forge", { m: 1 }), | |
| 71 | row("trevato/forgery", { m: 1 }), | |
| 72 | { t: "Forges you sent", h: "/forges", g: "Go to" }, | |
| 73 | ]; | |
| 74 | const flat = rankPalette(rows, "forge", [], 20).flatMap((g) => g.rows.map((r) => r.t)); | |
| 75 | expect(flat.slice(0, 4)).toEqual(["trevato/forge", "zed/forge", "trevato/forgery", "zed/forge-fork-2"]); | |
| 76 | // A name that only contains the word (not a prefix) trails every prefix. | |
| 77 | expect(flat.indexOf("zed/my-forge-1")).toBeGreaterThan(flat.indexOf("zed/forge-fork-2")); | |
| 78 | }); | |
| 79 | ||
| 80 | test("one group, one row per repository, the app on the row and one key away", async () => { | |
| 81 | const html = await (await fetch(`${servers.url}/explore`, { headers: { cookie } })).text(); | |
| 82 | const m = /<script type="application\/json" id="palette-index">([\s\S]*?)<\/script>/.exec(html)!; | |
| 83 | const items = (JSON.parse(m[1]!) as { items: { t: string; c?: string; x?: string; o?: string; m?: number }[] }).items; | |
| 84 | expect(items.filter((i) => i.t === "trevato/forge")).toHaveLength(1); | |
| 85 | expect(items.some((i) => i.c === "Apps")).toBe(false); | |
| 86 | expect(items.find((i) => i.t === "trevato/forge")?.m).toBe(1); | |
| 87 | // An app-less repository carries no app words. | |
| 88 | const mine = items.find((i) => i.t === "trevato/forge")!; | |
| 89 | expect(mine.x).toStartWith("App · "); | |
| 90 | expect(mine.o).toBe("/trevato/forge/app/production/open"); | |
| 91 | expect(items.find((i) => i.t === "zed/forge")?.x).toBeUndefined(); | |
| 92 | }); | |
| 93 | ||
outdated
String-presence check proves nothing about behaviour; test the key path or remove. |
||
| 94 | test("a private app is not known to a stranger by search, and is to its owner", async () => { | |
| 95 | const owner = getUserByName(app.db, "trevato")!; | |
| 96 | const zed = getUserByName(app.db, "zed")!; | |
| 97 | const made = await createRepo(app, owner, { name: "vault", visibility: "public" }); | |
| 98 | if (!made.ok) throw new Error(String(made.error)); | |
| 99 | createAppRecord(app.db, { repoId: made.value.id, slug: "vault", access: "private" }); | |
| 100 | const flag = async (who: typeof owner | undefined) => { | |
| 101 | const out = await search(app, who, { q: "vault", kind: "repos", limit: 5 }); | |
| 102 | const hit = out.repos.find((r) => r.source === "local" && r.name === "vault"); | |
| 103 | return hit?.source === "local" ? hit.app : undefined; | |
| 104 | }; | |
| 105 | expect(await flag(owner)).toBe(true); | |
| 106 | expect(await flag(zed)).toBe(false); | |
| 107 | expect(await flag(undefined)).toBe(false); | |
| 108 | }); |
test/studio-door.test.ts +6 −1
| @@ -318,7 +318,9 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)" | ||
| 318 | 318 | headers: cookie === undefined ? {} : { cookie }, |
| 319 | 319 | redirect: "manual", |
| 320 | 320 | }).then((r) => r.text()); |
| 321 | for (const page of [await visit(), await visit(strangerCookie)]) { | |
| 321 | // The palette index lists other, public apps and their state; the page is what is under test. | |
| 322 | const bare = (html: string) => html.replace(/<script[^>]*id="palette-index">.*?<\/script>/s, ""); | |
| 323 | for (const page of [bare(await visit()), bare(await visit(strangerCookie))]) { | |
| 322 | 324 | // The repository is public, so it is listed — once, plainly. |
| 323 | 325 | expect(occurrences(page, 'href="/hal/ledger"')).toBe(1); |
| 324 | 326 | expect(page).toContain("the books"); |
| @@ -326,6 +328,9 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)" | ||
| 326 | 328 | expect(page).not.toContain("quiet"); |
| 327 | 329 | expect(page).not.toContain('class="studio-word"'); |
| 328 | 330 | } |
| 331 | // Nor does the palette hand a visitor the private app's door. | |
| 332 | for (const raw of [await visit(), await visit(strangerCookie)]) | |
| 333 | expect(raw).not.toContain("/hal/ledger/app/production/open"); | |
| 329 | 334 | const own = await visit(ownerCookie); |
| 330 | 335 | expect(own).toContain("not running"); |
| 331 | 336 | }); |
test/studio.test.ts +6 −3
| @@ -179,10 +179,13 @@ describe("the studio", () => { | ||
| 179 | 179 | /<script type="application\/json" id="palette-index">([\s\S]*?)<\/script>/, |
| 180 | 180 | )?.[1]; |
| 181 | 181 | if (block === undefined) throw new Error("no palette-index on the home"); |
| 182 | const rows = (JSON.parse(block) as { items: { t: string; c: string }[] }).items; | |
| 182 | const rows = (JSON.parse(block) as { items: { t: string; c: string; x?: string; o?: string }[] }).items; | |
| 183 | 183 | const at = (t: string) => rows.findIndex((r) => r.t === t); |
| 184 | expect(rows[at("alice/notes")]?.c).toBe("Apps"); | |
| 185 | expect(rows[at("alice/palette-later")]?.c).toBe("Repositories"); | |
| 184 | // A repository and its app are one row; the app shows as its label and door. | |
| 185 | expect(rows[at("alice/notes")]?.c).toBe("Repositories"); | |
| 186 | expect(rows[at("alice/notes")]?.x).toStartWith("App · "); | |
| 187 | expect(rows[at("alice/notes")]?.o).toBe("/alice/notes/app/production/open"); | |
| 188 | expect(rows[at("alice/palette-later")]?.x).toBeUndefined(); | |
| 186 | 189 | expect(at("alice/notes")).toBeLessThan(at("alice/palette-later")); |
| 187 | 190 | }); |
| 188 | 191 |
new above
offline
app comes from appsForRepos with no access check: a private app's existence and door reach strangers. Gate with enterableApps like paletteIndex.