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

The palette finds issues and pulls by number, people, settings and actions #10675

Merged opened this

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

Merged

scout/forge:scout/10659-the-palette-finds-issues-and-pulls-by-nu → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/10659-the-palette-finds-issues-and-pulls-by-nu was merged into main as 4385bdd.

Review

Sign in to review this change.
7 files changed +266 −9
docs/features/palette-rows.md added +9
@@ -0,0 +1,9 @@
1 # Palette rows: numbers, people, settings, actions
2
3 The palette finds, on a repository's page, `#123` (one row, "Go to #123", built in the browser from the page's own path; issues and pulls share one number space and each address redirects to the other kind, so it never dead-ends on a kind). For signed-in viewers it also carries people, more settings pages and one action, "Tell the crew". A title is found by the forge's own search behind the palette, not by a local list: no issue or pull rides in the page.
outdated

Stale: the index no longer carries issues or pulls (only here), and sealedScope is no longer used. The doc describes the first version. Rewrite it to match what ships. Principle 1: say once what it does.

4
5 Filtered on the server before the list exists (`src/web/palette_rows.ts`, `paletteIndex` in `src/web/layout.ts`): `here` (the page's repository, for `#N`) is sent only when it is public or the viewer may direct its crew; the rest are for a signed-in viewer only. An action row (`a: 1`) is something that sends rather than goes: the first tap or Enter arms it ("Tap or press Enter again to confirm", announced through a polite live region and `aria-description`), the second runs it. Navigation rows, the map and New app among them, never ask.
6
7 No terminal screen: the palette is a web surface and has no TUI counterpart.
8
9 Tests: `test/palette-rows.test.ts`.
src/web/client.ts +33
@@ -898,6 +898,7 @@ export const codeJs = /* js */ `
898898 var list = dlg.querySelector('.palette-list');
899899 var note = dlg.querySelector('.palette-foot > span');
900900 var at = 0;
901 var armed = null; // the action row awaiting its one confirmation
901902
902903 function render(rows) {
903904 var hits = search(rows, input.value);
@@ -1900,6 +1901,9 @@ const shellCore = /* js */ `
19001901 var r = { t: String(i.t), h: String(i.h), g: i.c ? String(i.c) : 'Go to' };
19011902 if (i.n) r.n = String(i.n);
19021903 if (i.m === 1) r.m = 1;
1904 if (typeof i.k === 'number') r.k = i.k;
1905 if (i.a === 1) r.a = 1;
1906 if (i.d) r.d = String(i.d);
19031907 if (i.x) r.x = String(i.x);
19041908 if (i.o && String(i.o).charAt(0) === '/') r.o = String(i.o);
19051909 if (i.p && typeof i.p === 'object' && r.h.charAt(0) === '/') r.p = i.p;
@@ -1928,6 +1932,7 @@ const shellCore = /* js */ `
19281932 a.setAttribute('aria-selected', 'false');
19291933 // Same-origin paths only: the index is ours, and this keeps it so.
19301934 a.setAttribute('href', r.h.charAt(0) === '/' ? r.h : '/');
1935 if (r.a) a.setAttribute('data-action', '');
19311936 if (r.p) a.setAttribute('data-post', JSON.stringify(r.p));
19321937 var t = doc.createElement('span');
19331938 t.className = 'grow';
@@ -1971,8 +1976,18 @@ const shellCore = /* js */ `
19711976 // when the list changes under it (a server answer landing); a new query
19721977 // starts at the top.
19731978 function render(keep) {
1979 armed = null;
19741980 var q = input.value.trim();
19751981 var pool = (q === '' ? recent : []).concat(items, q.length >= 2 ? served : []);
1982 // "#123" on a repository's page: that number, built here from the page's own path (the server read no issue for it).
1983 var hereBase = loadIndex().here;
1984 var num = /^#(\\d+)$/.exec(q);
1985 if (num && typeof hereBase === 'string' && hereBase.charAt(0) === '/') {
1986 // Issues and pulls share one number space and each address redirects to the other kind, so one row serves both.
1987 pool = [
1988 { t: 'Go to #' + num[1], h: hereBase + '/issues/' + num[1], g: 'Issues and pulls', d: '#' + num[1], k: Number(num[1]) },
1989 ].concat(pool);
1990 }
19761991 var groups = rankPalette(pool, q, recentHrefs, q === '' ? 24 : 20);
19771992 var was = keep && nodes[at] ? nodes[at].getAttribute('href') : null;
19781993 var frag = doc.createDocumentFragment();
@@ -2122,6 +2137,24 @@ const shellCore = /* js */ `
21222137 var a = e.target && e.target.closest ? e.target.closest('.palette-item') : null;
21232138 var i = a ? nodes.indexOf(a) : -1;
21242139 if (i === -1) return;
2140 // An action asks once: the first press arms the row, the second runs it.
2141 // Navigation rows have no data-action and never ask.
2142 if (a.hasAttribute('data-action') && armed !== a.getAttribute('href')) {
2143 e.preventDefault();
outdated

'press Enter again' fails for touch: a phone has no Enter key. The text swap is also silent for a screen reader. Use 'Tap or press Enter again' and announce the armed state through a live region.

2144 armed = a.getAttribute('href');
2145 var label = a.querySelector('.grow');
2146 var say = 'Tap or press Enter again to confirm: ' + shown[i].t;
2147 if (label) label.textContent = shown[i].t + ' \u2014 tap or press Enter again to confirm';
2148 a.setAttribute('aria-description', 'Tap or press Enter again to confirm');
2149 var live = dlg.querySelector('[data-armed]');
2150 if (!live) {
2151 live = doc.createElement('div'); live.className = 'sr-only';
2152 live.setAttribute('aria-live', 'polite'); live.setAttribute('data-armed', '');
2153 dlg.appendChild(live);
2154 }
2155 live.textContent = say;
2156 return;
2157 }
21252158 if (a.hasAttribute('data-tell')) {
21262159 e.preventDefault();
21272160 close();
src/web/layout.ts +30 −7
@@ -17,11 +17,13 @@ import { administersAny } from "../core/admin_repos.ts";
1717 import { appsForRepos } from "../core/apps.ts";
1818 import { foundryHref } from "../actions/foundry_door.ts";
1919 import { appsRuntime } from "../apps/runtime.ts";
20 import { actionRows, peopleRows, settingsRows } from "./palette_rows.ts";
2021 import { wordClip } from "../text/word_clip.ts";
2122 import { absorbedForNav } from "../actions/features.ts";
2223 import {
2324 enterableApps,
2425 getRepo,
26 type Repo,
2527 listReposByOwner,
2628 listReposVisibleTo,
2729 } from "../core/repos.ts";
@@ -77,6 +79,12 @@ export interface PaletteItem {
7779 m?: 1;
7880 /** A mutation: the palette posts these fields to `h`, as the page's own form does. */
7981 p?: Record<string, string>;
82 /** An issue or pull's number: `#123` lands on it. */
83 k?: number;
84 /** A person's detail or an issue's "#n". */
85 d?: string;
86 /** An action: the palette asks once before it runs. */
87 a?: 1;
8088 }
8189
8290 export interface PageOptions {
@@ -416,6 +424,15 @@ const NOT_AN_OWNER = new Set([
416424 "api", "releases", "audit", "admin", "tell", "login", "logout", "signup", "feed", "boards", "organizations", "static", "assets",
417425 ]);
418426
427 /** The repository the page is on, if its path names one; signed out, none (no read, #5885). */
428 function pageRepo(ctx: Ctx): Repo | undefined {
429 if (ctx.auth.user === undefined) return undefined;
430 const [owner, name] = ctx.url.pathname.split("/").filter((s) => s !== "");
431 return owner !== undefined && name !== undefined && !NOT_AN_OWNER.has(owner)
432 ? getRepo(ctx.app.db, owner, name)
433 : undefined;
434 }
435
419436 /**
420437 * Where the palette's "Tell the crew" row sends a sentence, as the feed
421438 * composer's `app` field names it: the repository the page is on when the
@@ -423,14 +440,9 @@ const NOT_AN_OWNER = new Set([
423440 * repository read. Absent for anyone who may not tell (signed out, or an
424441 * organization), and then the row is not drawn.
425442 */
426 function tellTarget(ctx: Ctx): { app: string } | undefined {
443 function tellTarget(ctx: Ctx, here: Repo | undefined): { app: string } | undefined {
427444 const user = ctx.auth.user;
428445 if (user === undefined || user.kind === "org") return undefined;
429 const [owner, name] = ctx.url.pathname.split("/").filter((s) => s !== "");
430 const here =
431 owner !== undefined && name !== undefined && !NOT_AN_OWNER.has(owner)
432 ? getRepo(ctx.app.db, owner, name)
433 : undefined;
434446 if (here !== undefined && mayDirectCrew(ctx.app.db, here, user))
435447 return { app: `${here.ownerName}/${here.name}` };
436448 return { app: "forge" };
@@ -483,8 +495,17 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html {
483495 });
484496 const states = open.length > 0 ? appsRuntime(ctx.app)?.statusMany(open) : undefined;
485497 const foundry = foundryHref(ctx.app, ctx.auth.user);
498 const here = pageRepo(ctx);
499 const tell = tellTarget(ctx, here);
500 const tellHref =
501 here !== undefined && tell?.app === `${here.ownerName}/${here.name}`
502 ? `/${tell.app}/tell?from=${encodeURIComponent(ctx.url.pathname)}`
503 : undefined;
486504 const items: PaletteItem[] = [
487505 ...extra,
506 ...peopleRows(ctx.app.db, ctx.auth.user),
507 ...settingsRows(ctx.auth.user),
508 ...actionRows(ctx.auth.user, tellHref),
488509 ...repos.map((r): PaletteItem => {
489510 const found = apps.get(r.id);
490511 const app = found !== undefined && enterable.has(found.id) ? found : undefined;
@@ -537,7 +558,9 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html {
537558 items,
538559 studio,
539560 me: ctx.auth.user?.username,
540 tell: tellTarget(ctx),
561 tell,
562 here:
563 here !== undefined && (here.visibility === "public" || tellHref !== undefined) ? `/${here.ownerName}/${here.name}` : undefined,
541564 }).replaceAll("<", "\\u003c");
542565 return raw(
543566 `<script type="application/json" id="palette-index">${json}</script>`,
src/web/palette.ts +16
@@ -25,6 +25,10 @@ export interface PaletteRow {
2525 readonly x?: string;
2626 /** Where Cmd/Ctrl+Enter goes: the app itself. */
2727 readonly o?: string;
28 /** An issue or pull's number: typing `#123` lands on it. */
29 readonly k?: number;
30 /** An action: asks one confirmation before it runs. */
31 readonly a?: 1;
2832 }
2933
3034 /** A heading and the rows under it, best first. */
@@ -101,6 +105,18 @@ export function rankPalette(
101105 if (q !== "") {
102106 const title = fuzzyScore(q, r.t);
103107 const detail = r.d === undefined ? 0 : fuzzyScore(q, r.d);
108 const num = /^#(\d+)$/.exec(q);
109 if (num !== null && r.k !== undefined) {
110 // "#123" lands on that number; a shorter run of digits finds its extensions after.
111 const want = num[1]!;
112 const have = String(r.k);
113 if (have === want) s = 5000;
114 else if (have.indexOf(want) === 0) s = 1000 - have.length;
115 else return;
116 seen.add(r.h);
117 scored.push({ r, s, i });
118 return;
119 }
104120 if (title === 0 && detail === 0) return;
105121 s = title + detail / 2 + (recent.indexOf(r.h) === -1 ? 0 : 50);
106122 // A repository named by what was typed beats every other kind of
src/web/palette_rows.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * The palette rows that depend on who is looking: the people, more settings
outdated

Broken comment wrapping, and ConversationRow is the wrong name now that there are no conversation rows. Name it for the one concept: a palette row.

3 * pages, and the actions. Plain data out. Every filter
4 * runs here, on the server, before the list exists: a row the viewer may not
5 * see is never serialised.
6 */
7 import type { Database } from "bun:sqlite";
8 import { listPeople, type User } from "../core/users.ts";
9
10 /** One serialised palette row (the shape `PaletteItem` in layout.ts names). */
11 export interface PaletteRow {
12 t: string;
13 h: string;
14 c: string;
15 /** An action: the palette asks once before it runs. */
16 a?: 1;
17 }
18
19 /** The people list is bounded: the palette is a local list, not a database. */
20 export const PALETTE_PEOPLE = 50;
21
22 /** Who is on the forge changes slowly: one read a half minute per database, not one per page. */
23 const PEOPLE_TTL_MS = 30_000;
24 const peopleCache = new WeakMap<Database, { at: number; rows: PaletteRow[] }>();
25
26 /** People, for a signed-in viewer only; accounts that are neither orgs nor agents. */
27 export function peopleRows(db: Database, user: User | undefined): PaletteRow[] {
28 if (user === undefined) return [];
29 let hit = peopleCache.get(db);
30 if (hit === undefined || Date.now() - hit.at > PEOPLE_TTL_MS) {
31 hit = {
32 at: Date.now(),
33 rows: listPeople(db, { limit: PALETTE_PEOPLE }).map((p) => ({
34 t: p.username,
35 h: `/${p.username}`,
36 c: "People",
37 })),
38 };
39 peopleCache.set(db, hit);
40 }
41 return hit.rows;
42 }
43
44 /** More settings pages than the static few; signed-in viewers only. */
45 export function settingsRows(user: User | undefined): PaletteRow[] {
46 if (user === undefined) return [];
47 return [
48 { t: "Password", h: "/settings/password", c: "Settings" },
49 { t: "Passkeys", h: "/settings/passkeys", c: "Settings" },
50 { t: "Subscriptions", h: "/settings/subscriptions", c: "Settings" },
51 ];
52 }
53
54 /**
55 * Actions: what sends something rather than going somewhere, so it asks one
56 * confirmation in the palette first. Only "Tell the crew" is one; "New app"
57 * and "Map" are navigation (already in the list) and never carry `a`.
58 * Signed-out viewers get none.
59 */
60 export function actionRows(
61 user: User | undefined,
62 tellHref: string | undefined,
63 ): PaletteRow[] {
64 if (user === undefined) return [];
65 return [
66 ...(tellHref === undefined
67 ? []
68 : [{ t: "Tell the crew", h: tellHref, c: "Actions", a: 1 as const }]),
69 ];
70 }
test/palette-rows.test.ts added +106
@@ -0,0 +1,106 @@
1 /** The palette finds issues and pulls by number, people, settings and actions (#10659). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts";
4 import { createRepo } from "../src/actions/repos.ts";
5 import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts";
6 import { createSession } from "../src/core/sessions.ts";
7 import {
8 actionRows,
9 peopleRows,
10 settingsRows,
11 } from "../src/web/palette_rows.ts";
12 import { rankPalette, type PaletteRow } from "../src/web/palette.ts";
13 import { shellJs } from "../src/web/client.ts";
14
15 let f: SnappyFixture;
16 beforeAll(async () => {
17 f = await snappyFixture();
18 }, 120_000);
19 afterAll(async () => {
20 await f.close();
21 });
22
23 const priv = () => f.repos[1]!; // private, owned by olive
24
25 function indexOf(page: string) {
26 const m = /<script type="application\/json" id="palette-index">([\s\S]*?)<\/script>/.exec(page);
27 if (m === null) throw new Error("no palette index");
28 return JSON.parse(m[1]!) as { here?: string; items: { t: string; h: string; c: string; a?: 1 }[] };
29 }
30 const read = async (path: string, cookie?: string) =>
31 indexOf(await (await fetch(`${f.base}${path}`, cookie ? { headers: { cookie } } : {})).text());
32
33 test("#N is one row built by the script: issues and pulls share a number space", () => {
34 expect(shellJs).toContain("hereBase + '/issues/' + num[1]");
35 // One row, not an issue and a pull: the other kind's address redirects, so neither is a dead link.
36 expect(shellJs).not.toContain("hereBase + '/pulls/'");
37 const rows: PaletteRow[] = [{ t: "Go to #7", h: "/olive/app-0/issues/7", g: "Issues and pulls", d: "#7", k: 7 }];
38 expect(rankPalette(rows, "#7", [], 10).flatMap((g) => g.rows.map((r) => r.h))).toEqual(["/olive/app-0/issues/7"]);
39 expect(rankPalette(rows, "#999", [], 5)).toEqual([]);
40 });
41
42 test("what is sent: `here` for a stranger, a member and a non-director reader of a private repo", async () => {
43 // A stranger to a private repository is told nothing of it.
44 expect((await read(`/olive/${priv().name}`)).here).toBeUndefined();
45 // A member who is not on it: the same.
46 expect((await read(`/olive/${priv().name}`, f.peerCookie)).here).toBeUndefined();
47 // Its owner, a director, gets it.
48 expect((await read(`/olive/${priv().name}`, f.ownerCookie)).here).toBe(`/olive/${priv().name}`);
49 // A reader who may not direct the crew (read-only through an org team) gets none of a private repo.
50 const org = createOrg(f.app.db, "yardorg", f.owner.id);
51 if (!org.ok) throw new Error(org.error);
52 const team = createTeam(f.app.db, org.value.id, "Readers", "read");
53 if (!team.ok) throw new Error(String(team.error));
54 addTeamMember(f.app.db, team.value, f.peer.id);
55 const made = await createRepo(f.app, org.value, { name: "ledger", visibility: "private" });
56 if (!made.ok) throw new Error(String(made.error));
57 const reader = `forge_session=${createSession(f.app.db, f.peer.id).token}`;
58 const page = await fetch(`${f.base}/yardorg/ledger`, { headers: { cookie: reader } });
59 expect(page.status).toBe(200); // the reader does see the page...
60 expect(indexOf(await page.text()).here).toBeUndefined(); // ...but the palette does not name it.
61 // A public repository names itself for a signed-in reader; signed out, no repository is read at all.
62 expect((await read(`/olive/app-0`, f.peerCookie)).here).toBe("/olive/app-0");
63 expect((await read(`/olive/app-0`)).here).toBeUndefined();
64 // And no issue or pull of any repository rides in the index.
65 const all = await read(`/olive/${priv().name}`, f.ownerCookie);
66 expect(all.items.some((i) => i.c === "Issues" || i.c === "Pull requests")).toBe(false);
67 });
68
69 test("people, settings and actions: signed-in only, only the sending one marked", async () => {
70 expect(peopleRows(f.app.db, undefined)).toEqual([]);
71 expect(settingsRows(undefined)).toEqual([]);
72 expect(actionRows(undefined, "/x")).toEqual([]);
73 expect(peopleRows(f.app.db, f.owner).map((p) => p.t)).toContain("pablo");
74 expect(settingsRows(f.owner).map((s) => s.h)).toContain("/settings/password");
75 const acts = actionRows(f.owner, "/olive/app-0/tell?from=%2F");
76 expect(acts.map((a) => a.t)).toEqual(["Tell the crew"]);
77 expect(actionRows(f.owner, undefined)).toEqual([]);
78 const items = (await read("/olive/app-0", f.ownerCookie)).items;
79 expect(items.filter((i) => i.a === 1).map((i) => i.t)).toEqual(["Tell the crew"]);
80 const stranger = (await read("/olive/app-0")).items;
81 expect(stranger.some((i) => i.a === 1 || i.c === "People" || i.c === "Actions")).toBe(false);
82 // Navigation, the map and New app among it, never carries the mark.
83 expect(items.filter((i) => i.h === "/map" || i.h === "/apps/new").some((i) => i.a)).toBe(false);
84 });
85
86 test("the script asks once, in words a thumb and a screen reader both get", () => {
87 expect(shellJs).toContain("data-action");
88 expect(shellJs).toContain("Tap or press Enter again to confirm");
89 expect(shellJs).not.toContain("\\u2014 press Enter"); // the old Enter-only wording
90 expect(shellJs).toContain("aria-live");
91 expect(shellJs).toContain("aria-description");
92 });
93
94 test("2,000 rows: the number lands first and the list stays capped", () => {
95 const rows: PaletteRow[] = Array.from({ length: 2000 }, (_, i) => ({
96 t: `Issue title number ${i} about the roof`,
97 h: `/o/n/issues/${i + 1}`,
98 g: "Issues",
99 d: `#${i + 1}`,
100 k: i + 1,
101 }));
102 const hits = rankPalette(rows, "#1234", [], 20);
103 expect(hits[0]!.rows[0]!.h).toBe("/o/n/issues/1234");
104 expect(hits.flatMap((g) => g.rows).length).toBeLessThanOrEqual(20);
105 expect(rankPalette(rows, "roof 17", [], 20).flatMap((g) => g.rows).length).toBeLessThanOrEqual(20);
106 });
test/pull-quiet.test.ts +2 −2
@@ -66,8 +66,8 @@ afterAll(async () => {
6666 const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
6767 const bytes = (t: string) => new TextEncoder().encode(t).length;
6868
69 // Measured ~31,650 (pull: 31,130 on main plus the verdict line and the change above the check) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze.
70 const BUDGET = { pull: 31_810, list: 25_400 };
69 // Measured ~31,650 (pull: 31,130 on main plus the verdict line and the change above the check) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze. The palette now also carries the people, three settings rows and the Tell-the-crew action (#10659): 31,869 measured on the pull page, so the pull budget is that plus ~160.
70 const BUDGET = { pull: 32_030, list: 25_400 };
7171
7272 test("the pull page's check is a line with a dot and its word, not a card around a badge", async () => {
7373 const text = await get(`/alice/r/pulls/${number}`);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs