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

Merged opened this in answer to

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

Merged

scout/forge:scout/7661-cmd-k-typing-a-repository-s-name-finds-i → main 7 commits fork 3 threads · 3 unresolved

  • Merged. scout/7661-cmd-k-typing-a-repository-s-name-finds-i was merged into main as b8227c9.

Review

Sign in to review this change.
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
460460 `/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`).
461461
462462 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 @@
1414 import { isActive } from "../core/users.ts";
1515 import { canSeeSealed } from "../core/sealed.ts";
1616 import type { App } from "../app.ts";
17 import { appsForRepos } from "../core/apps.ts";
1718 import { listOrigins } from "../core/origins.ts";
18 import { canRead, listReposVisibleTo } from "../core/repos.ts";
19 import { canRead, enterableApps, listReposVisibleTo } from "../core/repos.ts";
1920 import {
2021 cleanQuery,
2122 grepRepos,
@@ -82,6 +83,15 @@ export type SearchPhase = "repos" | "issues" | "code";
8283 * half — the same rows a signed-out repo listing shows — and no origins,
8384 * because an origin belongs to the person who connected it.
8485 */
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
8595 export async function search(
8696 app: App,
8797 actor: User | undefined,
@@ -120,7 +130,7 @@ export async function search(
120130 const repos = !wants("repos")
121131 ? []
122132 : input.repo !== undefined
123 ? matchOneRepo(input.repo, q)
133 ? matchOneRepo(input.repo, q, hasEnterableApp(app, actor, input.repo))
124134 : [
125135 ...searchLocalRepos(app.db, actor, q, limit),
126136 ...(actor ? searchOriginRepos(app.db, actor.id, q, limit) : []),
src/core/repos.ts +1
@@ -866,6 +866,7 @@ export function listReposByOwner(
866866 params.push(options.cursor);
867867 where += ` AND repo.id < ?${params.length}`;
868868 }
869 if (options.withoutArchived === true) where += " AND repo.archived_at IS NULL";
869870 params.push(pageLimit(options.limit));
870871 const rows = db
871872 .query<RepoRow, (string | number)[]>(
src/core/search.ts +57 −6
@@ -30,8 +30,9 @@ import { runGitCapture } from "../git/env.ts";
3030 import { isSafeRef } from "../git/guard.ts";
3131 import { repoDir } from "../git/repos.ts";
3232 import { GREP_LIMITS, grepSlots, type GrepSlots } from "../search/grep_slots.ts";
33 import { appsForRepos } from "./apps.ts";
3334 import { likeEscape } from "./issues.ts";
34 import { listReposVisibleTo, type Repo } from "./repos.ts";
35 import { enterableApps, listReposVisibleTo, type Repo } from "./repos.ts";
3536 import type { User } from "./users.ts";
3637
3738 /** The longest query we will run. Longer is a paste, not a search. */
@@ -71,6 +72,8 @@ export type RepoHit =
7172 readonly archived: boolean;
7273 readonly openIssues: number;
7374 readonly openPulls: number;
75 /** The repository runs an app. */
76 readonly app: boolean;
7477 }
7578 | {
7679 readonly source: "origin";
@@ -157,7 +160,13 @@ export function searchLocalRepos(
157160 query: string,
158161 limit: number,
159162 ): 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 );
161170 // `listReposVisibleTo` matches owner and name; description is the extra
162171 // reach a search box wants, so it is unioned in rather than replacing it.
163172 const byDescription = describedRepos(db, viewer, query, limit);
@@ -168,17 +177,58 @@ export function searchLocalRepos(
168177 seen.add(repo.id);
169178 repos.push(repo);
170179 }
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.
outdated

app comes from appsForRepos with no access check: a private app's existence and door reach strangers. Gate with enterableApps like paletteIndex.

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);
172222 }
173223
174224 /** 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[] {
176226 const needle = query.trim().toLowerCase();
177227 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)] : [];
179229 }
180230
181 function toRepoHit(repo: Repo): RepoHit {
231 function toRepoHit(repo: Repo, app: boolean): RepoHit {
182232 return {
183233 source: "local",
184234 id: repo.id,
@@ -189,6 +239,7 @@ function toRepoHit(repo: Repo): RepoHit {
189239 archived: repo.archivedAt !== null,
190240 openIssues: repo.counts?.openIssues ?? 0,
191241 openPulls: repo.counts?.openPulls ?? 0,
242 app,
192243 };
193244 }
194245
src/web/client.ts +31 −2
@@ -1614,7 +1614,12 @@ export const shellJs = /* js */ `
16141614 rows.push(r.source === 'origin'
16151615 ? { t: r.fullName, h: '/origins/' + r.originId + '/r/' + r.fullName, g: 'Repositories',
16161616 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 })());
16181623 });
16191624 // An issue lives at /issues/:n and a pull request at /pulls/:n in every
16201625 // mode (\`tellHref\`/\`changeHref\`, src/web/studio/nouns.ts).
@@ -1745,7 +1750,12 @@ export const shellJs = /* js */ `
17451750 var input = dlg.querySelector('.palette-input');
17461751 var list = dlg.querySelector('.palette-list');
17471752 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;
17491759 });
17501760 var tellApp = (loadIndex().tell || {}).app;
17511761 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 */ `
17741784 t.className = 'grow';
17751785 t.textContent = r.t;
17761786 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 }
17771793 if (r.d) {
17781794 var d = doc.createElement('span');
17791795 d.className = 'cat';
@@ -1827,6 +1843,14 @@ export const shellJs = /* js */ `
18271843 var a = row(r, 'palette-o' + nodes.length);
18281844 nodes.push(a); shown.push(r);
18291845 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 }
18301854 });
18311855 frag.appendChild(box);
18321856 });
@@ -1907,6 +1931,11 @@ export const shellJs = /* js */ `
19071931 else if (k === 'ArrowUp') { e.preventDefault(); select(at - 1); }
19081932 else if (k === 'Home') { e.preventDefault(); select(0); }
19091933 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 }
19101939 else if (k === 'Enter') {
19111940 e.preventDefault();
19121941 var q = input.value.trim();
src/web/layout.ts +70 −14
@@ -11,7 +11,13 @@ import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch
1111 import { protectionNotice } from "../kernel/protection.ts";
1212 import { administersAny } from "../core/admin_repos.ts";
1313 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";
1521 import { mayDirectCrew } from "../crew/direct.ts";
1622 import type { User } from "../core/users.ts";
1723 import type { Ctx } from "../http/context.ts";
@@ -50,6 +56,11 @@ export interface PaletteItem {
5056 t: string;
5157 h: string;
5258 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;
5364 }
5465
5566 export interface PageOptions {
@@ -341,24 +352,64 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html {
341352 // One forge (#603): a conversation's remote row is built with the
342353 // studio's nouns, which answer for every repository.
343354 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
345357 // 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);
351369 const apps = appsForRepos(
352370 ctx.app.db,
353371 repos.map((r) => r.id),
354372 );
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;
355389 const items: PaletteItem[] = [
356390 ...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 }),
362413 ...STATIC_PALETTE,
363414 ...(sendsForges(ctx)
364415 ? [{ t: "New forge", h: "/forges/new", c: "Create" }]
@@ -375,7 +426,12 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html {
375426 // API found, folded in the browser — build the same addresses the server
376427 // just rendered (`tellHref`/`changeHref`, `src/web/studio/nouns.ts`). The
377428 // 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");
379435 return raw(
380436 `<script type="application/json" id="palette-index">${json}</script>`,
381437 );
@@ -417,7 +473,7 @@ function overlays(): Html {
417473 aria-label="Results"
418474 ></div>
419475 <div class="palette-foot">
420 ${kbd(["↑", "↓"])} navigate ${kbd(["↵"])} open ${kbd(["esc"])} close
476 ${kbd(["↑", "↓"])} navigate ${kbd(["↵"])} open ${kbd(["⌘", "↵"])} its app ${kbd(["esc"])} close
421477 </div>
422478 </dialog>
423479 <dialog class="dialog" id="help-dialog" aria-labelledby="help-title">
src/web/palette.ts +17
@@ -17,6 +17,14 @@ export interface PaletteRow {
1717 readonly h: string;
1818 readonly g: string;
1919 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;
2028 }
2129
2230 /** A heading and the rows under it, best first. */
@@ -95,6 +103,15 @@ export function rankPalette(
95103 const detail = r.d === undefined ? 0 : fuzzyScore(q, r.d);
96104 if (title === 0 && detail === 0) return;
97105 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 }
98115 }
99116 seen.add(r.h);
100117 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)"
318318 headers: cookie === undefined ? {} : { cookie },
319319 redirect: "manual",
320320 }).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))]) {
322324 // The repository is public, so it is listed — once, plainly.
323325 expect(occurrences(page, 'href="/hal/ledger"')).toBe(1);
324326 expect(page).toContain("the books");
@@ -326,6 +328,9 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)"
326328 expect(page).not.toContain("quiet");
327329 expect(page).not.toContain('class="studio-word"');
328330 }
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");
329334 const own = await visit(ownerCookie);
330335 expect(own).toContain("not running");
331336 });
test/studio.test.ts +6 −3
@@ -179,10 +179,13 @@ describe("the studio", () => {
179179 /<script type="application\/json" id="palette-index">([\s\S]*?)<\/script>/,
180180 )?.[1];
181181 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;
183183 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();
186189 expect(at("alice/notes")).toBeLessThan(at("alice/palette-later"));
187190 });
188191

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs