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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7325-the-terminal-s-launcher-search-ranks-lik → main 2 commits fork
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 | ||
| 255 | 255 | |
| 256 | 256 | 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#proposing-to-a-parent-forge). |
| 257 | 257 | |
| 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 | ||
| 258 | 265 | ## On your machine |
| 259 | 266 | |
| 260 | 267 | 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 @@ | ||
| 14 | 14 | * whole of this screen's design. |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import { exploreLine, explorePage, parseExploreQuery, type ExploreRow } from "../../actions/explore.ts"; | |
| 17 | 18 | import { protectionNotice } from "../../kernel/protection.ts"; |
| 18 | 19 | import { holdWords, plannerHold } from "../../crew/autonomy.ts"; |
| 19 | 20 | import { forgeFlakes, forgeLane, forgeNeeds } from "../../actions/health.ts"; |
| @@ -208,6 +209,36 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 208 | 209 | /** The panels this admin pinned to the web home, by title and board, in their order. */ |
| 209 | 210 | let panelLines: string[] = []; |
| 210 | 211 | 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 | }; | |
| 211 | 242 | /** One cursor per pane: moving in one must never move another. */ |
| 212 | 243 | const cursors: Record<PaneId, ListState> = { |
| 213 | 244 | repos: new ListState(), |
| @@ -761,7 +792,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 761 | 792 | /** Yours with an empty filter; anything you can see once you type — |
| 762 | 793 | * the one way to find someone else's repository from here. */ |
| 763 | 794 | const shown = (): Repo[] => |
| 764 | (filter.query !== "" ? all : repos).filter((repo) => | |
| 795 | exploring() ? [] : (filter.query !== "" ? all : repos).filter((repo) => | |
| 765 | 796 | filter.matches(`${repo.ownerName}/${repo.name} ${repo.description}`), |
| 766 | 797 | ); |
| 767 | 798 | |
| @@ -779,7 +810,9 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 779 | 810 | const at = (pane: PaneId): ListState => cursors[pane]; |
| 780 | 811 | const size = (pane: PaneId): number => |
| 781 | 812 | pane === "repos" |
| 782 | ? shown().length | |
| 813 | ? exploring() | |
| 814 | ? exploredRows().length | |
| 815 | : shown().length | |
| 783 | 816 | : pane === "onyou" |
| 784 | 817 | ? onYou.length + waves.length |
| 785 | 818 | : pins.length; |
| @@ -795,6 +828,18 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 795 | 828 | if (repo !== undefined) ctx.push(repoScreen(f, ctx, local(f, repo))); |
| 796 | 829 | }; |
| 797 | 830 | |
| 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 | ||
| 798 | 843 | const openPin = (pin: Pin | undefined): void => { |
| 799 | 844 | if (pin === undefined) return; |
| 800 | 845 | const repo = getRepo(f.db, pin.ownerName, pin.name); |
| @@ -846,7 +891,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 846 | 891 | |
| 847 | 892 | /** `enter` on whatever the focused pane's cursor is on. */ |
| 848 | 893 | 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 | } | |
| 850 | 898 | else if (focus === "onyou") openAssigned(onYou[at("onyou").index]); |
| 851 | 899 | else openPin(pins[at("machine").index]); |
| 852 | 900 | }; |
| @@ -1007,7 +1055,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1007 | 1055 | |
| 1008 | 1056 | // A site admin is told once, under the filter, when the forge runs |
| 1009 | 1057 | // 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); | |
| 1011 | 1062 | const [box, warn, list] = split( |
| 1012 | 1063 | main, |
| 1013 | 1064 | "v", |
| @@ -1040,7 +1091,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1040 | 1091 | }, |
| 1041 | 1092 | key(e) { |
| 1042 | 1093 | if (filter.key(e)) { |
| 1043 | at("repos").clamp(shown().length); | |
| 1094 | syncExplore(); | |
| 1095 | at("repos").clamp(exploring() ? exploredRows().length : shown().length); | |
| 1044 | 1096 | focus = "repos"; |
| 1045 | 1097 | return true; |
| 1046 | 1098 | } |
| @@ -1071,6 +1123,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1071 | 1123 | if (e.key === "l") return actOnWave("later"), true; |
| 1072 | 1124 | } |
| 1073 | 1125 | 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 | } | |
| 1074 | 1130 | if (focus === "repos" && f.user !== undefined) { |
| 1075 | 1131 | const repo = shown()[at("repos").index]; |
| 1076 | 1132 | if (e.key === "space" && repo !== undefined && mayArchive(repo)) { |
| @@ -1235,7 +1291,49 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1235 | 1291 | return parts.length > 0 ? parts.join(" · ") : undefined; |
| 1236 | 1292 | } |
| 1237 | 1293 | |
| 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 | ||
| 1238 | 1335 | function drawRepos(s: Surface, r: Rect, c: Tokens, set: IconSet): void { |
| 1336 | if (exploring()) return drawExplored(s, r, c); | |
| 1239 | 1337 | const rows = shown(); |
| 1240 | 1338 | const on = focus === "repos"; |
| 1241 | 1339 | 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 | }); |
new above
offline