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

The explorer says how many results a filter leaves and offers one plain 'clear filters' link #11443

Merged opened this

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

Merged

scout/forge:scout/11323-the-explorer-says-how-many-results-a-fil → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/11323-the-explorer-says-how-many-results-a-fil was merged into main as 4b713a7.

Review

Sign in to review this change.
8 files changed +155 −5
docs/design/areas/explore.md +4
@@ -1,3 +1,7 @@
11 # Explore
22
33 `/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.
4
5 The count line above the list (`[data-hits]`, a muted span at the end of the sort bar) is server-rendered with a 24px line height, so the list never moves; "Clear filters" appears in it only while a filter (not the sort) is active.
outdated

The how-to belongs in docs/features/explore.md; add it there, and add the matching terminal screen.

6
7 The how-to is in docs/features/explore.md.
docs/features/explore.md +6
@@ -64,3 +64,9 @@ An app's row carries `action: { do: "make_it_yours", label: "Make it yours" }` e
6464 `GET /explore.atom` is `/explore` as an Atom feed, public, for a feed reader. It holds the apps `/explore` ranks first for a stranger (default ranking, apps only, read through the page's own `explore`), at most 30 (`EXPLORE_ENTRIES` in `web/feeds.ts`), shown newest-made first, one entry each titled with the app's name, linked to its page (`rel="alternate"`) with its description as the summary. Private and archived apps are never in it. It is cached for five minutes (`cache-control: public, max-age=300`) and carries a weak `ETag`, so a reader that sends `If-None-Match` gets a 304. `/explore` links it with `<link rel="alternate" type="application/atom+xml">`. Server-Timing names `read` and `render`.
6565
6666 The terminal's palette has `Explore feed`, which opens or shows the feed's address (copy it into a reader).
67
68 ## The count and the way back (#11323)
69
70 Under the filters, `/explore` says how much a filter leaves: `66 results`, `2 apps`, `1 app`, `No apps match`, in the words of `exploreCount` in `src/actions/explore.ts` (the count is the whole filtered list, not the page). While a filter narrows the list (kind, words, archived, updated or owner; the sort only orders) the line ends with `Clear filters`, a link to bare `/explore`. When nothing matches it also offers `Drop <term>` for each filter, one tap to remove just that one. With no filter and nothing public the line is silent; the empty state speaks.
71
72 In the terminal the same sentence sits in the `Read as:` line under the search box (`Read as: kind:app · 2 apps · Esc clears`); Esc clears the line.
src/actions/explore.ts +20
@@ -376,6 +376,23 @@ export interface ExploreRow {
376376 readonly action?: typeof MAKE_IT_YOURS;
377377 }
378378
379 const NOUNS = {
380 app: ["app", "apps"],
381 person: ["person", "people"],
382 forge: ["forge", "forges"],
383 all: ["result", "results"],
384 } as const;
385
386 /** True when the line narrows the list; the sort only orders it. */
387 export const exploreFiltered = (q: ExploreQuery): boolean =>
388 q.kind !== "all" || q.text !== "" || q.archived || q.updated !== "all" || q.owner !== undefined;
389
390 /** How much a filter leaves, in words: "2 apps", "1 app", "No apps match". One sentence for the page and the terminal. */
391 export function exploreCount(query: ExploreQuery, total: number): string {
392 const noun = NOUNS[query.kind];
393 return total === 0 ? `No ${noun[1]} match` : `${total.toLocaleString("en-US")} ${noun[total === 1 ? 0 : 1]}`;
394 }
395
379396 /**
380397 * The ranking as last computed, per viewer (id and admin standing) and line
381398 * (#3394, #6129). A signed-out viewer has the one key every stranger shares;
@@ -406,6 +423,8 @@ export interface ExplorePage {
406423 readonly items: readonly ExploreRow[];
407424 /** Pass back as `cursor` for the page after; null on the last. */
408425 readonly nextCursor: string | null;
426 /** Every row the query leaves, whatever page this is. */
427 readonly total: number;
409428 }
410429
411430 /**
@@ -433,6 +452,7 @@ export async function explorePage(
433452 return {
434453 items,
435454 nextCursor: rest.length > ROWS ? writeCursor(items[items.length - 1]!, query) : null,
455 total: rows.length,
436456 };
437457 }
438458
src/tui/screens/dashboard.ts +4 −4
@@ -15,7 +15,7 @@
1515 */
1616
1717 import { DEFAULT_WEIGHTS, frontWeights, type FrontWeights } from "../../core/front.ts";
18 import { exploreLine, isExploreLine, exploreNote, exploreSuggestion, explorePage, parseExploreQuery, readExploreLine, whyOf, type ExploreRow } from "../../actions/explore.ts";
18 import { exploreCount, exploreFiltered, exploreLine, isExploreLine, exploreNote, exploreSuggestion, explorePage, parseExploreQuery, readExploreLine, whyOf, type ExploreRow } from "../../actions/explore.ts";
1919 import { protectionNotice } from "../../kernel/protection.ts";
2020 import { holdWords, plannerHold } from "../../crew/autonomy.ts";
2121 import { governorSeen } from "../../crew/governed.ts";
@@ -249,7 +249,7 @@ 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[] } | undefined;
252 let explored: { line: string; rows: readonly ExploreRow[]; total: number } | undefined;
253253 let askedLine = "";
254254 /** The forge's front weights, read once per ranked line so the reasons agree with the order. */
255255 let exploredWeights: FrontWeights = DEFAULT_WEIGHTS;
@@ -273,7 +273,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
273273 if (askedLine !== line) return;
274274 const rows = page?.items ?? [];
275275 exploredWeights = frontWeights(f.app.db);
276 explored = { line, rows };
276 explored = { line, rows, total: page?.total ?? 0 };
277277 at("repos").clamp(rows.length);
278278 ctx.invalidate();
279279 });
@@ -1182,7 +1182,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
11821182 const readAs =
11831183 read === undefined
11841184 ? undefined
1185 : `Read as: ${exploreLine(read.query) || "everything"}${mend === undefined ? "" : ` · Did you mean ${mend.line}?`}${note === "" ? "" : ` · ${note}`}`;
1185 : `Read as: ${exploreLine(read.query) || "everything"}${explored === undefined ? "" : ` · ${exploreCount(read.query, explored.total)}${exploreFiltered(read.query) ? " · Esc clears" : ""}`}${mend === undefined ? "" : ` · Did you mean ${mend.line}?`}${note === "" ? "" : ` · ${note}`}`;
11861186 const reduced = readAs ?? (f.user?.isAdmin === true ? protectionNotice() : undefined);
11871187 const [box, warn, list] = split(
11881188 main,
src/web/studio/explore.ts +31 −1
@@ -9,6 +9,8 @@
99 import {
1010 explorePage as readPage,
1111 exploreLine,
12 exploreCount,
13 exploreFiltered,
1214 exploreLineFrom,
1315 exploreParams,
1416 exploreNote,
@@ -144,6 +146,33 @@ function emptyState(ctx: Ctx, query: ExploreQuery): Html {
144146 });
145147 }
146148
149 /** What the filter's terms are, for a link that takes one away: the owner too, which `terms` leaves to the line. */
150 function removable(query: ExploreQuery): { term: string; without: ExploreQuery }[] {
151 const out = terms(query).filter((t) => !t.term.startsWith("sort:"));
152 if (query.owner !== undefined) {
153 const { owner: _owner, ...rest } = query;
154 out.push({ term: `owner:${query.owner}`, without: rest });
155 }
outdated

Principle 1/8: 'No apps match' should name the filter to remove (e.g. a link per active filter), not only a clear-all. Also the noun map is hand-built inline; keep it a named const.

156 return out;
157 }
158
159 /**
160 * How much is left, and the way back. A fixed-height line, so the list never
161 * moves. Unfiltered and empty it is silent: "Nothing public yet" says it.
162 * A miss names each filter that can be dropped, beside Clear filters.
163 */
164 function countLine(query: ExploreQuery, total: number): Html {
165 const on = exploreFiltered(query);
166 if (total === 0 && !on) return html``;
167 const drops =
168 total === 0
169 ? removable(query).map((t) => html` · <a href="${href(t.without)}">Drop ${shorten(t.term)}</a>`)
170 : [];
171 return html`<span class="muted" role="status" data-hits>${exploreCount(query, total)}${drops}${
172 on ? html` · <a href="/explore" data-clear-filters>Clear filters</a>` : html``
173 }</span>`;
174 }
175
147176 // GET /explore
148177 export async function explorePage(ctx: Ctx): Promise<Response> {
149178 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -188,7 +217,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
188217 { label: "This month", updated: "30d" as const },
189218 ];
190219 const body = html`<div data-explore>${pageHead("Explore", {
191 sub: "Apps and their makers, ranked by forks, ships, use and how lately — the numbers on every row.",
220 sub: "Apps and their makers, ranked by forks, ships, use and recency.",
192221 })}
193222 <form method="get" action="/explore" class="bar mb">
194223 <input
@@ -240,6 +269,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
240269 aria-pressed="${query.archived ? "true" : "false"}"
241270 >Show archived</a
242271 >
272 ${countLine(query, page0?.total ?? 0)}
243273 </div>
244274 ${rows.length === 0
245275 ? emptyState(ctx, query)
src/web/style/content.ts +1
@@ -262,6 +262,7 @@ 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 [data-hits] { line-height: 24px; }
265266 .explore-more { margin-top: 12px; }
266267 .explore-more .btn { width: 100%; min-height: 44px; }
267268 .door-says { min-height: 1lh; margin: 6px 0 0; }
test/explore-count.test.ts added +82
@@ -0,0 +1,82 @@
1 /** /explore says how many rows the filter leaves and offers one 'Clear filters' link only while filtered. */
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
20 beforeAll(async () => {
21 dir = await mkdtemp(join(tmpdir(), "forge-explore-count-"));
22 app = createApp({ dataDir: dir, port: 0 });
23 const made = await createUser(app.db, { origin: "seed", username: "olive", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: false });
24 if (!made.ok) throw new Error(made.error);
25 proveAll(app);
26 promote(app.db, made.value);
27 const bulk = Array.from({ length: 62 }, (_, i) => `bulk${i}`);
28 for (const n of ["garden", "gadget", "ledger", ...bulk]) {
29 const repo = await createRepo(app, made.value, { name: n, visibility: "public" });
30 if (!repo.ok) throw new Error(String(repo.error));
31 const rec = insertApp(app.db, { repoId: repo.value.id, slug: `olive-${n}`, access: "public" });
32 if (!rec.ok) throw new Error(rec.error);
33 }
34 servers = await serve(app);
35 }, 60_000);
36 afterAll(async () => {
37 await servers.stop();
38 app.db.close();
39 await rm(dir, { recursive: true, force: true });
40 });
41
42 const get = async (path: string) => (await fetch(`${servers.url}${path}`)).text();
43 const count = (html: string) => html.match(/data-hits[^>]*>([^<]*)/)?.[1]?.trim();
44
45 test("no filter: a count of results and no clear link", async () => {
46 const html = await get("/explore");
47 expect(count(html)).toBe("66 results");
48 expect(html).not.toContain("data-clear-filters");
49 });
50
51 test("one filter: the apps left and a link back to the bare path", async () => {
52 const html = await get("/explore?q=kind:app ga");
53 expect(count(html)).toMatch(/^2 apps/);
54 expect(html).toMatch(/<a href="\/explore" data-clear-filters>Clear filters<\/a>/);
55 });
56
57 test("a filter with no match says so and still offers the way back", async () => {
58 const html = await get("/explore?q=kind:app zzzzqqq");
59 expect(count(html)).toMatch(/^No apps match/);
60 expect(html).toContain("data-clear-filters");
61 });
62
63 test("a filter with no match names the one filter to drop", async () => {
64 const html = await get("/explore?q=kind:app zzzzqqq");
65 const hits = html.match(/data-hits>.*?<\/span>/s)?.[0] ?? "";
66 expect(hits).toContain('<a href="/explore?q=zzzzqqq">Drop kind:app</a>');
67 expect(hits).toContain('<a href="/explore?kind=app">Drop zzzzqqq</a>');
68 });
69
70 test("a filter that leaves one says '1 app', singular", async () => {
71 expect(count(await get("/explore?q=kind:app ledger"))).toMatch(/^1 app\b(?!s)/);
72 });
73
74 test("the count is the whole filtered list on a page after a cursor", async () => {
75 const first = await get("/explore?kind=app&q=bulk");
76 expect(count(first)).toMatch(/^62 apps/);
77 const next = first.match(/href="([^"]*cursor=[^"]*)"/)?.[1]?.replaceAll("&amp;", "&");
78 expect(next).toBeDefined();
79 const second = await get(next!);
80 expect(count(second)).toMatch(/^62 apps/);
81 expect(second).toContain("data-clear-filters");
82 });
test/tui-dashboard-explore.test.ts +7
@@ -191,3 +191,10 @@ test("each explorer line starts with why it ranks there, and does not say that n
191191 expect(text).toContain("2 forks");
192192 expect(text.match(/2 forks/g)?.length).toBe(1);
193193 });
194
195 test("the terminal says how many a line leaves and that Esc clears it", async () => {
196 const text = await typed("kind:app sort:forks");
197 expect(text).toContain("Read as: kind:app sort:forks · 3 apps · Esc clears");
198 const none = await typed("kind:app zzzzqqq");
199 expect(none).toContain("No apps match · Esc clears");
200 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs