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

Merged opened this

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

Merged

mason/forge:mason/11474-the-terminal-s-explore-list-says-there-a → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/11474-the-terminal-s-explore-list-says-there-a was merged into main as 8b2070d.

Review

Sign in to review this change.
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 (`
5353
5454 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.
5555
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.
5757
5858 ## Make it yours on an app row
5959 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 {
249249 * A search line with `kind:` or `sort:` is the explorer's (#7325): the rows
250250 * `/explore` ranks, answered by the same action, for the line last settled.
251251 */
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;
253257 let askedLine = "";
254258 /** The forge's front weights, read once per ranked line so the reasons agree with the order. */
255259 let exploredWeights: FrontWeights = DEFAULT_WEIGHTS;
@@ -273,11 +277,56 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
273277 if (askedLine !== line) return;
274278 const rows = page?.items ?? [];
275279 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: "" };
277281 at("repos").clamp(rows.length);
278282 ctx.invalidate();
279283 });
280284 };
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);

Non-blocking: parse here.line instead of filter.query, so the query always matches the line the cursor came from.

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 };
281330 /** One cursor per pane: moving in one must never move another. */
282331 const cursors: Record<PaneId, ListState> = {
283332 repos: new ListState(),
@@ -1043,7 +1092,9 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10431092 ? [binding(["t"], "ask for something")]
10441093 : []),
10451094 ...(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 })]),
10471098 binding(["M"], "migrate", { disabled: !f.signedIn }),
10481099 binding(["A"], "account", { disabled: !f.signedIn }),
10491100 binding(["s"], "sign in", { disabled: f.signedIn }),
@@ -1319,6 +1370,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
13191370 if (live) ctx.push(linkScreen(ctx, live, screen));
13201371 return true;
13211372 }
1373 if (e.key === "n" && exploring() && explored?.next != null) return morePage(), true;
13221374 if (e.key === "n" && f.signedIn) {
13231375 ctx.push(newRepoScreen(f, ctx));
13241376 return true;
@@ -1470,6 +1522,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
14701522 function drawExplored(s: Surface, r: Rect, c: Tokens): void {
14711523 const rows = exploredRows();
14721524 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 }
14731531 if (rows.length === 0) {
14741532 emptyState(s, r, c, {
14751533 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs