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

The terminal's launcher search ranks like /explore when the line has `kind:` or `sort:` words, with each row's numbers #7356

Merged opened this

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

Merged

scout/forge:scout/7325-the-terminal-s-launcher-search-ranks-lik → main 2 commits fork

  • Merged. scout/7325-the-terminal-s-launcher-search-ranks-lik was merged into main as e7df551.

Review

Sign in to review this change.
3 files changed +269 −5
README.md +7
@@ -255,6 +255,13 @@ page, in the terminal's pull view, and as `proposal` in the pull's JSON
255255
256256 A spawned forge can offer its change to its parent with a proposal token, and an admin sets how it follows the parent. Details, API, MCP and terminal doors: [`docs/FOLLOWING.md`](docs/FOLLOWING.md#proposing-to-a-parent-forge).
257257
258 The terminal's launcher search (`/`) is the explorer when the line has a
259 `kind:` or `sort:` word: `kind:app sort:forks garden` ranks as `/explore`
260 does, by the same action, with each row's forks, merges, hours in use and
261 freshness, and a "Read as:" line says how the line was understood. Enter on an
262 app opens it; a person or forge row has no terminal page and says where the
263 web has it. A plain line still matches your repositories by name.
264
258265 ## On your machine
259266
260267 The forge holds the truth; `forge` keeps a copy of the parts you work on, the
src/tui/screens/dashboard.ts +103 −5
@@ -14,6 +14,7 @@
1414 * whole of this screen's design.
1515 */
1616
17 import { exploreLine, explorePage, parseExploreQuery, type ExploreRow } from "../../actions/explore.ts";
1718 import { protectionNotice } from "../../kernel/protection.ts";
1819 import { holdWords, plannerHold } from "../../crew/autonomy.ts";
1920 import { forgeFlakes, forgeLane, forgeNeeds } from "../../actions/health.ts";
@@ -208,6 +209,36 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
208209 /** The panels this admin pinned to the web home, by title and board, in their order. */
209210 let panelLines: string[] = [];
210211 const filter = new Filter();
212 /**
213 * A search line with `kind:` or `sort:` is the explorer's (#7325): the rows
214 * `/explore` ranks, answered by the same action, for the line last settled.
215 */
216 let explored: { line: string; rows: readonly ExploreRow[] } | undefined;
217 let askedLine = "";
218 const exploring = (): boolean => /(^|\s)(kind|sort):/i.test(filter.query);
219 const exploredRows = (): readonly ExploreRow[] => (exploring() ? (explored?.rows ?? []) : []);
220 const syncExplore = (): void => {
221 if (!exploring()) {
222 explored = undefined;
223 askedLine = "";
224 return;
225 }
226 const query = parseExploreQuery(filter.query);
227 const line = exploreLine(query);
228 if (line === askedLine) return;
229 askedLine = line;
230 // The rows kept belong to the old line; none are shown under the new one.
231 explored = undefined;
232 runAsync(ctx, async () => {
233 const page = await explorePage(f.app, f.user, query, null).catch(() => null);
234 // A newer line was typed while this one was ranked; its answer wins.
235 if (askedLine !== line) return;
236 const rows = page?.items ?? [];
237 explored = { line, rows };
238 at("repos").clamp(rows.length);
239 ctx.invalidate();
240 });
241 };
211242 /** One cursor per pane: moving in one must never move another. */
212243 const cursors: Record<PaneId, ListState> = {
213244 repos: new ListState(),
@@ -761,7 +792,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
761792 /** Yours with an empty filter; anything you can see once you type —
762793 * the one way to find someone else's repository from here. */
763794 const shown = (): Repo[] =>
764 (filter.query !== "" ? all : repos).filter((repo) =>
795 exploring() ? [] : (filter.query !== "" ? all : repos).filter((repo) =>
765796 filter.matches(`${repo.ownerName}/${repo.name} ${repo.description}`),
766797 );
767798
@@ -779,7 +810,9 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
779810 const at = (pane: PaneId): ListState => cursors[pane];
780811 const size = (pane: PaneId): number =>
781812 pane === "repos"
782 ? shown().length
813 ? exploring()
814 ? exploredRows().length
815 : shown().length
783816 : pane === "onyou"
784817 ? onYou.length + waves.length
785818 : pins.length;
@@ -795,6 +828,18 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
795828 if (repo !== undefined) ctx.push(repoScreen(f, ctx, local(f, repo)));
796829 };
797830
831 /** `enter` on an explorer row: an app opens as any repository; the rest are the web's. */
832 const openExplored = (row: ExploreRow | undefined): void => {
833 if (row === undefined) return;
834 if (row.kind === "app" && row.repo !== undefined) {
835 const repo = getRepo(f.db, row.repo.owner, row.repo.name);
836 if (repo !== undefined && canRead(f.db, repo, f.user)) return openRepo(repo);
837 ctx.toast(`${row.name} is not here any more`, "attn");
838 return;
839 }
840 ctx.toast(`${row.name} has no terminal page; the web has it at ${row.url}`, "attn");
841 };
842
798843 const openPin = (pin: Pin | undefined): void => {
799844 if (pin === undefined) return;
800845 const repo = getRepo(f.db, pin.ownerName, pin.name);
@@ -846,7 +891,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
846891
847892 /** `enter` on whatever the focused pane's cursor is on. */
848893 const openHere = (): void => {
849 if (focus === "repos") openRepo(shown()[at("repos").index]);
894 if (focus === "repos") {
895 if (exploring()) openExplored(exploredRows()[at("repos").index]);
896 else openRepo(shown()[at("repos").index]);
897 }
850898 else if (focus === "onyou") openAssigned(onYou[at("onyou").index]);
851899 else openPin(pins[at("machine").index]);
852900 };
@@ -1007,7 +1055,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10071055
10081056 // A site admin is told once, under the filter, when the forge runs
10091057 // with less than its default protection — the web footer's line.
1010 const reduced = f.user?.isAdmin === true ? protectionNotice() : undefined;
1058 const readAs = exploring()
1059 ? `Read as: ${exploreLine(parseExploreQuery(filter.query)) || "everything"}`
1060 : undefined;
1061 const reduced = readAs ?? (f.user?.isAdmin === true ? protectionNotice() : undefined);
10111062 const [box, warn, list] = split(
10121063 main,
10131064 "v",
@@ -1040,7 +1091,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10401091 },
10411092 key(e) {
10421093 if (filter.key(e)) {
1043 at("repos").clamp(shown().length);
1094 syncExplore();
1095 at("repos").clamp(exploring() ? exploredRows().length : shown().length);
10441096 focus = "repos";
10451097 return true;
10461098 }
@@ -1071,6 +1123,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10711123 if (e.key === "l") return actOnWave("later"), true;
10721124 }
10731125 if (e.key === "B" && f.user && asideWaves.length > 0) return bringBackWaves(), true;
1126 if (focus === "repos" && f.user !== undefined && exploring() && (e.key === "space" || e.key === "a" || e.key === "p")) {
1127 ctx.toast("pin, mark and archive act on your repositories; esc clears the explorer line", "attn");
1128 return true;
1129 }
10741130 if (focus === "repos" && f.user !== undefined) {
10751131 const repo = shown()[at("repos").index];
10761132 if (e.key === "space" && repo !== undefined && mayArchive(repo)) {
@@ -1235,7 +1291,49 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
12351291 return parts.length > 0 ? parts.join(" · ") : undefined;
12361292 }
12371293
1294 /** A row's numbers as the page says them; nothing that is zero. */
1295 function numbersOf(row: ExploreRow): string {
1296 const n = row.numbers;
1297 return metaOf(
1298 row.kind === "person" ? plural(n.apps, "app") : "",
1299 n.forks > 0 ? plural(n.forks, "fork") : "",
1300 n.ships > 0 ? `${n.ships} shipped this month` : "",
1301 n.awakeHours > 0 ? `${n.awakeHours} h in use` : "",
1302 row.shippedAt !== null ? `shipped ${ago(row.shippedAt)}` : "",
1303 );
1304 }
1305
1306 function drawExplored(s: Surface, r: Rect, c: Tokens): void {
1307 const rows = exploredRows();
1308 const on = focus === "repos";
1309 if (rows.length === 0) {
1310 emptyState(s, r, c, {
1311 title: explored === undefined ? "Ranking…" : `Nothing matches ${explored.line}`,
1312 body: "Drop a word to step back; esc clears the line.",
1313 });
1314 return;
1315 }
1316 drawList(
1317 s,
1318 r,
1319 c,
1320 at("repos"),
1321 rows.map((row): ListRow => ({ title: row.name, secondary: row.description, meta: numbersOf(row) })),
1322 on,
1323 );
1324 hitList(ctx, r, at("repos"), rows.length, {
1325 select: () => {
1326 focus = "repos";
1327 },
1328 open: (index) => {
1329 focus = "repos";
1330 openExplored(rows[index]);
1331 },
1332 });
1333 }
1334
12381335 function drawRepos(s: Surface, r: Rect, c: Tokens, set: IconSet): void {
1336 if (exploring()) return drawExplored(s, r, c);
12391337 const rows = shown();
12401338 const on = focus === "repos";
12411339 if (rows.length === 0) {
test/tui-dashboard-explore.test.ts added +159
@@ -0,0 +1,159 @@
1 /** The launcher's search line with `kind:` or `sort:` is the explorer's, ranked by the page's action (#7325). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App as Forge } from "../src/app.ts";
7 import { explorePage, parseExploreQuery } from "../src/actions/explore.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { createApp as insertApp } from "../src/core/apps.ts";
10 import type { Repo } from "../src/core/repos.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { headlessTerminal } from "../src/tui/term/terminal.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15 import { proveAll } from "./helpers/verified.ts";
16 import { promote } from "./helpers/members.ts";
17 import { settleApp } from "./helpers/tui.ts";
18
19 quietLogs();
20
21 let dir: string;
22 let forge: Forge;
23 let alice: User;
24 let bob: User;
25
26 const appOf = async (owner: User, name: string): Promise<Repo> => {
27 const made = await createRepo(forge, owner, { name, visibility: "public", description: `the ${name} app` });
28 if (!made.ok) throw new Error(String(made.error));
29 const record = insertApp(forge.db, { repoId: made.value.id, slug: `${owner.username}-${name}`, access: "public" });
30 if (!record.ok) throw new Error(record.error);
31 return made.value;
32 };
33
34 const forkOf = async (parent: Repo, owner: User, name: string): Promise<void> => {
35 const made = await createRepo(forge, owner, { name, visibility: "public" });
36 if (!made.ok) throw new Error(String(made.error));
37 forge.db.query(`UPDATE repo SET fork_parent_id = ?1 WHERE id = ?2`).run(parent.id, made.value.id);
38 };
39
40 beforeAll(async () => {
41 dir = await mkdtemp(join(tmpdir(), "forge-tui-explore-"));
42 forge = createApp({ dataDir: dir, port: 0 });
43 const make = async (username: string): Promise<User> => {
44 const u = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@forge.test`, password: "a-long-passphrase" });
45 if (!u.ok) throw new Error(u.error);
46 proveAll(forge);
47 promote(forge.db, u.value);
48 return u.value;
49 };
50 alice = await make("alice");
51 bob = await make("bob");
52 const zed = await appOf(alice, "zed");
53 const mid = await appOf(alice, "mid");
54 await appOf(alice, "ant");
55 await forkOf(zed, bob, "zed-one");
56 await forkOf(zed, bob, "zed-two");
57 await forkOf(mid, bob, "mid-one");
58 const vault = await appOf(bob, "vault");
59 forge.db.query(`UPDATE repo SET visibility = 'private' WHERE id = ?1`).run(vault.id);
60 });
61
62 afterAll(async () => {
63 forge.close();
64 await rm(dir, { recursive: true, force: true });
65 });
66
67 async function typed(line: string, then: string[] = [], user: User | undefined = alice): Promise<string> {
68 const terminal = headlessTerminal(200, 32);
69 const { app } = createForgeApp(
70 forge,
71 { sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user },
72 { terminal, exitHooks: false },
73 );
74 app.start();
75 await settleApp(app);
76 const press = (key: string): void => app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
77 press("/");
78 for (const ch of line) press(ch);
79 // The ranking is answered off the keystroke; wait for it before pressing on.
80 await settleApp(app);
81 for (const key of then) press(key);
82 await settleApp(app);
83 const text = app.screen.toText();
84 app.stop();
85 return text;
86 }
87
88 test("kind:app sort:forks lists the apps in the order the page's action gives, with their numbers", async () => {
89 const page = await explorePage(forge, alice, parseExploreQuery("kind:app sort:forks"), null);
90 const order = page!.items.map((r) => r.name);
91 expect(order).toEqual(["alice/zed", "alice/mid", "alice/ant"]);
92 const text = await typed("kind:app sort:forks");
93 const at = (name: string): number => text.indexOf(name);
94 for (const name of order) expect(at(name)).toBeGreaterThan(-1);
95 const positions = order.map(at);
96 expect(positions).toEqual([...positions].sort((a, b) => a - b));
97 expect(text).toContain("2 forks");
98 expect(text).toContain("1 fork");
99 expect(text).toContain("Read as: kind:app sort:forks");
100 });
101
102 test("an unknown word is said back as the text the query understood", async () => {
103 const text = await typed("kind:app sort:nonsense");
104 expect(text).toContain("Read as: kind:app sort:nonsense");
105 expect(text).toContain("Nothing matches kind:app sort:nonsense");
106 });
107
108 test("a plain search still matches repositories by name, with no explorer line", async () => {
109 const text = await typed("zed-one");
110 expect(text).toContain("bob/zed-one");
111 expect(text).not.toContain("alice/ant");
112 expect(text).not.toContain("Read as:");
113 });
114
115 test("enter on a person row says it has no terminal page", async () => {
116 const text = await typed("kind:person", ["enter", "enter"]);
117 expect(text).toContain("no terminal page");
118 expect(text).toContain("Read as: kind:person");
119 });
120
121 test("enter on an app row opens it", async () => {
122 const text = await typed("kind:app sort:forks", ["enter", "enter"]);
123 expect(text).toContain("alice › alice/zed");
124 expect(text).not.toContain("Read as:");
125 });
126
127 test("a guest's explorer rows hold no private repository; its owner's do", async () => {
128 expect(await typed("kind:app", [], bob)).toContain("bob/vault");
129 expect(await typed("kind:app", [], alice)).not.toContain("bob/vault");
130 const guest = await typed("kind:app", [], undefined);
131 expect(guest).toContain("alice/zed");
132 expect(guest).not.toContain("bob/vault");
133 });
134
135 test("pin, mark and archive on an explorer row say they do not apply", async () => {
136 const text = await typed("kind:app sort:forks", ["enter", "p"]);
137 expect(text).toContain("act on your repositories");
138 });
139
140 test("while a new line is ranked, the old rows are not shown under it", async () => {
141 const terminal = headlessTerminal(200, 32);
142 const { app } = createForgeApp(
143 forge,
144 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: alice },
145 { terminal, exitHooks: false },
146 );
147 app.start();
148 await settleApp(app);
149 const press = (key: string): void => app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
150 press("/");
151 for (const ch of "kind:app") press(ch);
152 await settleApp(app);
153 press(" ");
154 for (const ch of "sort:forks") press(ch);
155 app.frame();
156 const text = app.screen.toText();
157 app.stop();
158 expect(text).not.toContain("alice/zed");
159 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs