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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11376-the-explorer-s-web-page-follows-its-curs → main 3 commits fork 2 threads · 2 unresolved
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 | ||
| 49 | 49 | |
| 50 | 50 | 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). |
| 51 | 51 | |
| 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 | ||
| 52 | 58 | ## Make it yours on an app row |
| 53 | 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`). |
| 54 | 60 | `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 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { |
| 10 | explore, | |
| 10 | explorePage as readPage, | |
| 11 | 11 | exploreLine, |
| 12 | 12 | exploreLineFrom, |
| 13 | 13 | exploreParams, |
| @@ -152,7 +152,14 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 152 | 152 | const note = exploreNote(unknown); |
| 153 | 153 | const suggestion = exploreSuggestion(line); |
| 154 | 154 | 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); | |
| 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; | |
| 156 | 163 | const weights = frontWeights(ctx.app.db); |
| 157 | 164 | notePhase(ctx.request, "read", performance.now() - ranking); |
| 158 | 165 | const rendering = performance.now(); |
| @@ -196,6 +203,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 196 | 203 | /> |
| 197 | 204 | ${btn("Go", { size: "sm" })} |
| 198 | 205 | </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``} | |
| 199 | 207 | ${note === "" ? html`` : html`<p class="muted mb" role="status">${note}${ |
| 200 | 208 | suggestion === undefined |
| 201 | 209 | ? html`` |
| @@ -238,7 +246,9 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 238 | 246 | : card(html`<ul class="list">${rows.map((r) => rowHtml(r, ctx.auth.user !== undefined, query, now, weights))}</ul>`, { |
| 239 | 247 | pad: false, |
| 240 | 248 | extra: "bare", |
| 241 | })}</div>`; | |
| 249 | })}${more === null | |
outdated
Principles 4 and 8, and phone use: hand-rolled |
||
| 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>`; | |
| 242 | 252 | const drawn = page({ ctx, title: "Explore", body, feed: { href: "/explore.atom", title: "Explore" } }); |
| 243 | 253 | // An unknown filter is a 400 here as on the API and the MCP; the page still draws the note and the results. |
| 244 | 254 | 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; } | ||
| 262 | 262 | .door-lead .bar { gap: 8px; flex-wrap: wrap; } |
| 263 | 263 | .door-start { width: 100%; max-width: 28rem; } |
| 264 | 264 | .door-start .btn { width: 100%; } |
| 265 | .explore-more { margin-top: 12px; } | |
| 266 | .explore-more .btn { width: 100%; min-height: 44px; } | |
| 265 | 267 | .door-says { min-height: 1lh; margin: 6px 0 0; } |
| 266 | 268 | .door-agent { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 520px; font-size: 13px; color: var(--muted-fg); } |
| 267 | 269 | .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("&", "&")); | |
| 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 | }); |
A rejected cursor silently shows page one. Add a one-line plain note so the person knows they were returned to the start.