Public: anyone can read this; the crew can change it.
The terminal's explore list says there are more results and a key pages to them #11722
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11474-the-terminal-s-explore-list-says-there-a → main 3 commits fork 1 thread · 1 unresolved
3 files
changed +186
−4
docs/features/explore.md +1 −1
| @@ -53,7 +53,7 @@ An unknown filter word is refused the same way everywhere: `400` from the API (` | ||
| 53 | 53 | |
| 54 | 54 | A list holds 60 rows a page. When there are more, `/explore` ends with a full-width **More results** button, a plain link to the same view (kind, sort, since, owner, archived and the words) plus `cursor=…`; the last page has none, and it works without JavaScript. A cursor belongs to one sort: one that is malformed, over 400 characters or made under another sort shows the first page with a one-line note, never an error. `GET /api/v1/explore` returns `next_cursor` and takes `?cursor=`, and the MCP `explore` tool takes and returns the same cursor. |
| 55 | 55 | |
| 56 | The terminal's explore lines show the first page only: it has no way to page yet (a known gap). Narrow the line (`owner:`, `updated:`, more words) to reach what is past the first 60. | |
| 56 | The terminal's explore lines page the same way: when the answer carries `next_cursor` the list ends with one line, `more results — n`, and `n` fetches the next page and appends it; the last page shows no line. The cursor is kept with the line it came from, so changing the line starts again at the first page. A cursor the forge refuses shows the first page with one plain line (`that page is no longer there — showing the first page`), never an error screen. A fetch that fails (not a refusal) keeps the pages already shown and says `could not fetch more results — n to retry`. The line takes the list's last row, so it shows on any terminal tall enough for a list. | |
| 57 | 57 | |
| 58 | 58 | ## Make it yours on an app row |
| 59 | 59 | An app's row carries `action: { do: "make_it_yours", label: "Make it yours" }` exactly when `copyable` is true (`MAKE_IT_YOURS` in `actions/explore.ts`). |
src/tui/screens/dashboard.ts +61 −3
| @@ -249,7 +249,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 249 | 249 | * A search line with `kind:` or `sort:` is the explorer's (#7325): the rows |
| 250 | 250 | * `/explore` ranks, answered by the same action, for the line last settled. |
| 251 | 251 | */ |
| 252 | let explored: { line: string; rows: readonly ExploreRow[]; total: number } | undefined; | |
| 252 | let explored: | |
| 253 | | { line: string; rows: readonly ExploreRow[]; total: number; next: string | null; note: string } | |
| 254 | | undefined; | |
| 255 | /** Whether a next page is being fetched, so a held `n` asks once. */ | |
| 256 | let paging = false; | |
| 253 | 257 | let askedLine = ""; |
| 254 | 258 | /** The forge's front weights, read once per ranked line so the reasons agree with the order. */ |
| 255 | 259 | let exploredWeights: FrontWeights = DEFAULT_WEIGHTS; |
| @@ -273,11 +277,56 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 273 | 277 | if (askedLine !== line) return; |
| 274 | 278 | const rows = page?.items ?? []; |
| 275 | 279 | exploredWeights = frontWeights(f.app.db); |
| 276 | explored = { line, rows, total: page?.total ?? 0 }; | |
| 280 | explored = { line, rows, total: page?.total ?? 0, next: page?.nextCursor ?? null, note: "" }; | |
| 277 | 281 | at("repos").clamp(rows.length); |
| 278 | 282 | ctx.invalidate(); |
| 279 | 283 | }); |
| 280 | 284 | }; |
| 285 | /** | |
| 286 | * `n` on an explorer line: the next page, appended. The cursor belongs to | |
| 287 | * the line it came from (it lives in `explored`, which a new line drops); one | |
| 288 | * the forge refuses gives the first page again with a plain line. | |
| 289 | */ | |
| 290 | const morePage = (): void => { | |
| 291 | const here = explored; | |
| 292 | if (here === undefined || here.next === null || paging) return; | |
| 293 | const query = parseExploreQuery(filter.query); | |
| 294 | const cursor = here.next; | |
| 295 | paging = true; | |
| 296 | runAsync(ctx, async () => { | |
| 297 | try { | |
| 298 | let page: Awaited<ReturnType<typeof explorePage>>; | |
| 299 | try { | |
| 300 | page = await explorePage(f.app, f.user, query, cursor); | |
| 301 | } catch { | |
| 302 | // A failed read is not a refusal: the pages already shown stay, and `n` tries again. | |
| 303 | if (askedLine !== here.line || explored !== here) return; | |
| 304 | explored = { ...here, note: "could not fetch more results — n to retry" }; | |
| 305 | ctx.invalidate(); | |
| 306 | return; | |
| 307 | } | |
| 308 | if (askedLine !== here.line || explored !== here) return; | |
| 309 | if (page === null) { | |
| 310 | // The forge refused the cursor: start again from the first page. | |
| 311 | const first = await explorePage(f.app, f.user, query, null).catch(() => null); | |
| 312 | if (askedLine !== here.line || explored !== here) return; | |
| 313 | explored = { | |
| 314 | line: here.line, | |
| 315 | rows: first?.items ?? here.rows, | |
| 316 | total: first?.total ?? here.total, | |
| 317 | next: first?.nextCursor ?? null, | |
| 318 | note: "that page is no longer there — showing the first page", | |
| 319 | }; | |
| 320 | at("repos").clamp(explored.rows.length); | |
| 321 | } else { | |
| 322 | explored = { ...here, rows: [...here.rows, ...page.items], total: page.total, next: page.nextCursor, note: "" }; | |
| 323 | } | |
| 324 | ctx.invalidate(); | |
| 325 | } finally { | |
| 326 | paging = false; | |
| 327 | } | |
| 328 | }); | |
| 329 | }; | |
| 281 | 330 | /** One cursor per pane: moving in one must never move another. */ |
| 282 | 331 | const cursors: Record<PaneId, ListState> = { |
| 283 | 332 | repos: new ListState(), |
| @@ -1043,7 +1092,9 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1043 | 1092 | ? [binding(["t"], "ask for something")] |
| 1044 | 1093 | : []), |
| 1045 | 1094 | ...(clientLive.size > 0 ? [binding(["o"], "open the app")] : []), |
| 1046 | binding(["n"], "new repo", { disabled: !f.signedIn }), | |
| 1095 | ...(exploring() && explored?.next != null | |
| 1096 | ? [binding(["n"], "more results")] | |
| 1097 | : [binding(["n"], "new repo", { disabled: !f.signedIn })]), | |
| 1047 | 1098 | binding(["M"], "migrate", { disabled: !f.signedIn }), |
| 1048 | 1099 | binding(["A"], "account", { disabled: !f.signedIn }), |
| 1049 | 1100 | binding(["s"], "sign in", { disabled: f.signedIn }), |
| @@ -1319,6 +1370,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1319 | 1370 | if (live) ctx.push(linkScreen(ctx, live, screen)); |
| 1320 | 1371 | return true; |
| 1321 | 1372 | } |
| 1373 | if (e.key === "n" && exploring() && explored?.next != null) return morePage(), true; | |
| 1322 | 1374 | if (e.key === "n" && f.signedIn) { |
| 1323 | 1375 | ctx.push(newRepoScreen(f, ctx)); |
| 1324 | 1376 | return true; |
| @@ -1470,6 +1522,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1470 | 1522 | function drawExplored(s: Surface, r: Rect, c: Tokens): void { |
| 1471 | 1523 | const rows = exploredRows(); |
| 1472 | 1524 | const on = focus === "repos"; |
| 1525 | const tail = [explored?.note ?? "", explored?.next != null ? "more results — n" : ""].filter((x) => x !== "").join(" · "); | |
| 1526 | if (tail !== "" && r.h >= 2) { | |
| 1527 | const [body, line] = split(r, "v", [fill(1), len(1)]); | |
| 1528 | s.text(line!.x, line!.y, truncate(tail, line!.w), style(c.attn, c.bg), line!.x + line!.w); | |
| 1529 | r = body!; | |
| 1530 | } | |
| 1473 | 1531 | if (rows.length === 0) { |
| 1474 | 1532 | emptyState(s, r, c, { |
| 1475 | 1533 | title: explored === undefined ? "Ranking…" : `Nothing matches ${explored.line}`, |
test/tui-explore-paging.test.ts added +124
| @@ -0,0 +1,124 @@ | ||
| 1 | /** The terminal's explorer pages with `n` when the answer has a next cursor (#11474). */ | |
| 2 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; | |
| 3 | import * as explore from "../src/actions/explore.ts"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 8 | import { createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 12 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | import { proveAll } from "./helpers/verified.ts"; | |
| 15 | import { promote } from "./helpers/members.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | let dir: string; | |
| 21 | let forge: Forge; | |
| 22 | let alice: User; | |
| 23 | const COUNT = 61; | |
| 24 | const pad = (i: number): string => `app${String(i).padStart(2, "0")}`; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dir = await mkdtemp(join(tmpdir(), "forge-tui-paging-")); | |
| 28 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 29 | const u = await createUser(forge.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 30 | if (!u.ok) throw new Error(u.error); | |
| 31 | proveAll(forge); | |
| 32 | promote(forge.db, u.value); | |
| 33 | alice = u.value; | |
| 34 | for (let i = 1; i <= COUNT; i++) { | |
| 35 | const made = await createRepo(forge, alice, { name: pad(i), visibility: "public" }); | |
| 36 | if (!made.ok) throw new Error(String(made.error)); | |
| 37 | const rec = insertApp(forge.db, { repoId: made.value.id, slug: `alice-${pad(i)}`, access: "public" }); | |
| 38 | if (!rec.ok) throw new Error(rec.error); | |
| 39 | } | |
| 40 | }); | |
| 41 | ||
| 42 | afterAll(async () => { | |
| 43 | forge.close(); | |
| 44 | await rm(dir, { recursive: true, force: true }); | |
| 45 | }); | |
| 46 | ||
| 47 | async function session(line: string, keys: string[], rows = 90): Promise<string> { | |
| 48 | const terminal = headlessTerminal(160, rows); | |
| 49 | const { app } = createForgeApp( | |
| 50 | forge, | |
| 51 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: alice }, | |
| 52 | { terminal, exitHooks: false }, | |
| 53 | ); | |
| 54 | app.start(); | |
| 55 | await settleApp(app); | |
| 56 | const press = (key: string): void => app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 57 | press("/"); | |
| 58 | for (const ch of line) press(ch); | |
| 59 | await settleApp(app); | |
| 60 | for (const k of keys) { | |
| 61 | press(k); | |
| 62 | await settleApp(app); | |
| 63 | } | |
| 64 | const text = app.screen.toText(); | |
| 65 | app.stop(); | |
| 66 | return text; | |
| 67 | } | |
| 68 | ||
| 69 | const shownApps = (text: string): number => new Set(text.match(/alice\/app\d\d/g) ?? []).size; | |
| 70 | ||
| 71 | test("61 rows give a first page of 60 and a line; n adds the rest and the line goes", async () => { | |
| 72 | const first = await session("kind:app sort:name", ["enter"]); | |
| 73 | expect(shownApps(first)).toBe(60); | |
| 74 | expect(first).toContain("more results — n"); | |
| 75 | expect(first).not.toContain("alice/app61"); | |
| 76 | ||
| 77 | const second = await session("kind:app sort:name", ["enter", "n"]); | |
| 78 | expect(shownApps(second)).toBe(61); | |
| 79 | expect(second).toContain("alice/app61"); | |
| 80 | expect(second).not.toContain("more results"); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a short list shows no line, and n there is not a page", async () => { | |
| 84 | const text = await session("kind:app sort:name owner:nobody", ["enter", "n"]); | |
| 85 | expect(text).not.toContain("more results"); | |
| 86 | // `n` was not a page: it is still the new-repo key. | |
| 87 | expect(text).toContain("New repository"); | |
| 88 | }); | |
| 89 | ||
| 90 | /** Answer the first call for real and the next ones as `later` says. */ | |
| 91 | async function withLater(later: () => Promise<never>, keys: string[], rows?: number): Promise<{ text: string; calls: number }> { | |
| 92 | const real = explore.explorePage; | |
| 93 | let calls = 0; | |
| 94 | const spy = spyOn(explore, "explorePage").mockImplementation((...args) => { | |
| 95 | calls++; | |
| 96 | return args[3] === null ? real(...args) : later(); | |
| 97 | }); | |
| 98 | try { | |
| 99 | const text = await session("kind:app sort:name", keys, rows); | |
| 100 | return { text, calls }; | |
| 101 | } finally { | |
| 102 | spy.mockRestore(); | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | test("a cursor the forge refuses shows the first page again with one plain line", async () => { | |
| 107 | const { text } = await withLater(async () => null as never, ["enter", "n"]); | |
| 108 | expect(text).toContain("that page is no longer there — showing the first page"); | |
| 109 | expect(shownApps(text)).toBe(60); | |
| 110 | expect(text).not.toContain("alice/app61"); | |
| 111 | expect(text).toContain("more results — n"); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a failed fetch keeps the rows and says to try again, not that the page is gone", async () => { | |
| 115 | const { text } = await withLater(async () => { throw new Error("boom"); }, ["enter", "n"]); | |
| 116 | expect(text).toContain("could not fetch more results — n to retry"); | |
| 117 | expect(text).not.toContain("no longer there"); | |
| 118 | expect(shownApps(text)).toBe(60); | |
| 119 | }); | |
| 120 | ||
| 121 | test("on a short terminal the more-results line still has its row", async () => { | |
| 122 | const text = await session("kind:app sort:name", ["enter"], 14); | |
| 123 | expect(text).toContain("more results — n"); | |
| 124 | }); |
new above
offline
Non-blocking: parse here.line instead of filter.query, so the query always matches the line the cursor came from.