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

The explorer's web page follows its cursor: a server-rendered 'More' link carries the same filters to the next page #11413

Merged opened this

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

Merged

scout/forge:scout/11376-the-explorer-s-web-page-follows-its-curs → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/11376-the-explorer-s-web-page-follows-its-curs was merged into main as a1eebd9.

Review

Sign in to review this change.
5 files changed +114 −3
docs/design/areas/explore.md added +3
@@ -0,0 +1,3 @@
1 # Explore
2
3 `/explore` (`src/web/studio/explore.ts`). Paging is one `btn` (outline, "More results") in `.explore-more`, which makes it full width and 44px tall so it is a thumb-sized target on a 360px phone. It is an ordinary link, so it works without a script.
docs/features/explore.md +6
@@ -49,6 +49,12 @@ The three return the same ordered rows (`test/explore-parity.test.ts`; the URL f
4949
5050 An unknown filter word is refused the same way everywhere: `400` from the API (`{ "error": "unknown_filter", "message": "sort:bogus is not a sort — use score, forks, …", "suggest"? }`), an `unknown_filter` error from the MCP tool, and `400` on the page, which still draws the note, the did-you-mean link and the results. This replaces the earlier note-only answer (#7595).
5151
52 ## Paging (#11376)
53
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
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.
57
5258 ## Make it yours on an app row
5359 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`).
5460 `label` is the page button's text and `do` is the name of the MCP tool that forks; it plans unless called with `dryRun: false`.
src/web/studio/explore.ts +13 −3
@@ -7,7 +7,7 @@
77 */
88
99 import {
10 explore,
10 explorePage as readPage,
1111 exploreLine,
1212 exploreLineFrom,
1313 exploreParams,
@@ -152,7 +152,14 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
152152 const note = exploreNote(unknown);
153153 const suggestion = exploreSuggestion(line);
154154 const ranking = performance.now();
155 const rows = await explore(ctx.app, ctx.auth.user, query);
155 // A cursor that is not one for this query (junk, over-long, another sort) shows the first page.
156 const asked = ctx.url.searchParams.get("cursor");
157 const after = asked === null ? null : await readPage(ctx.app, ctx.auth.user, query, asked);
outdated

A rejected cursor silently shows page one. Add a one-line plain note so the person knows they were returned to the start.

158 const returned = asked !== null && after === null;
159 const page0 = after ?? (await readPage(ctx.app, ctx.auth.user, query, null));
160 const here = href(query);
161 const rows = page0?.items ?? [];
162 const more = page0?.nextCursor ?? null;
156163 const weights = frontWeights(ctx.app.db);
157164 notePhase(ctx.request, "read", performance.now() - ranking);
158165 const rendering = performance.now();
@@ -196,6 +203,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
196203 />
197204 ${btn("Go", { size: "sm" })}
198205 </form>
206 ${returned ? html`<p class="muted mb" role="status">That page link is not one for this list, so this is the first page.</p>` : html``}
199207 ${note === "" ? html`` : html`<p class="muted mb" role="status">${note}${
200208 suggestion === undefined
201209 ? html``
@@ -238,7 +246,9 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
238246 : card(html`<ul class="list">${rows.map((r) => rowHtml(r, ctx.auth.user !== undefined, query, now, weights))}</ul>`, {
239247 pad: false,
240248 extra: "bare",
241 })}</div>`;
249 })}${more === null
outdated

Principles 4 and 8, and phone use: hand-rolled <p class="mt"><a class="muted">More</a></p> is a tiny muted target. Use the named component, make the target thumb-sized, word it 'More results'. Also compute href(query) once instead of twice.

250 ? html``
251 : html`<div class="explore-more">${btn("More results", { href: `${here}${here.includes("?") ? "&" : "?"}cursor=${encodeURIComponent(more)}`, variant: "outline", extra: "explore-more-link" })}</div>`}</div>`;
242252 const drawn = page({ ctx, title: "Explore", body, feed: { href: "/explore.atom", title: "Explore" } });
243253 // An unknown filter is a 400 here as on the API and the MCP; the page still draws the note and the results.
244254 const response = unknown.length === 0 ? drawn : new Response(drawn.body, { status: 400, headers: drawn.headers });
src/web/style/content.ts +2
@@ -262,6 +262,8 @@ form.inline { display: inline; margin-left: auto; }
262262 .door-lead .bar { gap: 8px; flex-wrap: wrap; }
263263 .door-start { width: 100%; max-width: 28rem; }
264264 .door-start .btn { width: 100%; }
265 .explore-more { margin-top: 12px; }
266 .explore-more .btn { width: 100%; min-height: 44px; }
265267 .door-says { min-height: 1lh; margin: 6px 0 0; }
266268 .door-agent { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 520px; font-size: 13px; color: var(--muted-fg); }
267269 .door-agent a { margin-left: 8px; color: var(--fg); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--border-strong); }
test/explore-cursor-web.test.ts added +90
@@ -0,0 +1,90 @@
1 /** /explore follows its cursor without a script: a "More" link carries the filters to the next page. */
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 } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createApp as insertApp } from "../src/core/apps.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12 import { proveAll } from "./helpers/verified.ts";
13 import { promote } from "./helpers/members.ts";
14
15 quietLogs();
16 let dir: string;
17 let app: App;
18 let servers: Servers;
19 const TOTAL = 65;
20
21 beforeAll(async () => {
22 dir = await mkdtemp(join(tmpdir(), "forge-explore-cursor-"));
23 app = createApp({ dataDir: dir, port: 0 });
24 const made = await createUser(app.db, { origin: "seed", username: "olive", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: false });
25 if (!made.ok) throw new Error(made.error);
26 proveAll(app);
27 promote(app.db, made.value);
28 for (let i = 0; i < TOTAL; i++) {
29 const n = `app${String(i).padStart(3, "0")}`;
30 const repo = await createRepo(app, made.value, { name: n, visibility: "public" });
31 if (!repo.ok) throw new Error(String(repo.error));
32 const rec = insertApp(app.db, { repoId: repo.value.id, slug: `olive-${n}`, access: "public" });
33 if (!rec.ok) throw new Error(rec.error);
34 }
35 servers = await serve(app);
36 }, 120_000);
37 afterAll(async () => {
38 await servers.stop();
39 app.db.close();
40 await rm(dir, { recursive: true, force: true });
41 });
42
43 const get = async (path: string) => {
44 const res = await fetch(`${servers.url}${path}`);
45 return { status: res.status, html: await res.text() };
46 };
47 const rows = (html: string): string[] => [...html.matchAll(/<a href="(\/olive\/app\d+)">/g)].map((m) => m[1]!);
48 const more = (html: string): string[] => [...html.matchAll(/<a[^>]*href="([^"]*)"[^>]*explore-more-link/g)].map((m) => m[1]!.replaceAll("&amp;", "&"));
49
50 test("walking the More link reads every row once and the last page has none", async () => {
51 const seen: string[] = [];
52 let path: string | undefined = "/explore?kind=app&owner=olive&sort=name";
53 let pages = 0;
54 while (path !== undefined) {
55 const { status, html } = await get(path);
56 expect(status).toBe(200);
57 seen.push(...rows(html));
58 const links = more(html);
59 expect(links.length).toBeLessThanOrEqual(1);
60 if (links[0] !== undefined) {
61 expect(links[0]).toContain("kind=app");
62 expect(links[0]).toContain("owner=olive");
63 expect(links[0]).toContain("sort=name");
64 }
65 path = links[0];
66 expect(++pages).toBeLessThan(10);
67 }
68 expect(pages).toBe(2);
69 expect(seen.length).toBe(TOTAL);
70 expect(new Set(seen).size).toBe(TOTAL);
71 });
72
73 test("a junk, over-long or other-sort cursor shows the first page", async () => {
74 const first = await get("/explore?kind=app&owner=olive&sort=name");
75 const link = more(first.html)[0]!;
76 const cursor = new URL(link, "http://x").searchParams.get("cursor")!;
77 for (const bad of ["!!notbase64!!", "A".repeat(5000), "e30", encodeURIComponent(cursor) + "x"]) {
78 const res = await get(`/explore?kind=app&owner=olive&sort=name&cursor=${bad}`);
79 expect(res.status).toBe(200);
80 expect(rows(res.html)).toEqual(rows(first.html));
81 expect(res.html).toContain("is not one for this list");
82 }
83 // A name-sort cursor under another sort is refused the same way.
84 const other = await get(`/explore?kind=app&owner=olive&sort=forks&cursor=${encodeURIComponent(cursor)}`);
85 expect(other.status).toBe(200);
86 expect(rows(other.html).length).toBe(60);
87 expect(other.html).toContain("is not one for this list");
88 expect(first.html).not.toContain("is not one for this list");
89 expect(first.html).toContain("More results");
90 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs