Public: anyone can read this; the crew can change it.
A directory with more than 300 entries lists its first 300 and one link for the rest: test/'s page under 300 KB #6168
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6145-a-directory-with-more-than-300-entries-l → main 9 commits fork 4 threads · 4 unresolved
6 files
changed +178
−4
README.md +4
| @@ -738,6 +738,10 @@ other field in the same request is saved). Repositories migrated from a pasted | ||
| 738 | 738 | address before this existed were filled in from their migration's `push` |
| 739 | 739 | event, without a fork chain; one picked from a connected origin then has none. |
| 740 | 740 | |
| 741 | ## Big directories | |
| 742 | ||
| 743 | A directory page lists its first 300 entries, directories first; a "Show all N entries" row links to `?all=1`, and the footer says "Showing 300 of N entries". The side Files column keeps 100 rows per directory (its lazy `?fragment=explorer` too) with the same link. The terminal's file explorer is a scrolling, lazily loaded tree rather than a page, so it is unchanged. The limits are `TREE_WINDOW` and `COLUMN_WINDOW` in `src/web/pages/explorer.ts`. | |
| 744 | ||
| 741 | 745 | ## Search |
| 742 | 746 | |
| 743 | 747 | `/search` answers repositories and issues in full, and greps code repository by repository. A first search starts no new `git grep` after 30 ms (`codeBudgetMs` in the forge config; a soft stop, since the grep in flight still finishes, one batch of four) and shows what it has with one link, **More code results** (`?code=all`), that greps the rest. The API and the terminal palette always grep everything. |
docs/design/areas/code.md +2
| @@ -1,3 +1,5 @@ | ||
| 1 | 1 | # Code |
| 2 | 2 | |
| 3 | 3 | - `.blob-view` — the file view in the code browser (`renderBlob` in `src/web/pages/repo.ts`): on a Markdown file, wraps the Source/Rendered `.seg` toggle at the end of the file's bar, set 8px off the line count with its segments at 12.5px. |
| 4 | ||
| 5 | `.ex-more` also closes a windowed directory page and the side Files column: the "Show all N entries" link to `?all=1`. |
src/web/pages/browse.ts +1
| @@ -493,6 +493,7 @@ export async function browseTree(ctx: Ctx): Promise<Response> { | ||
| 493 | 493 | ref, |
| 494 | 494 | path, |
| 495 | 495 | entries: entries.value, |
| 496 | all: ctx.url.searchParams.get("all") === "1", | |
| 496 | 497 | }), |
| 497 | 498 | }); |
| 498 | 499 | } |
src/web/pages/explorer.ts +28 −1
| @@ -81,6 +81,22 @@ export async function loadExplorer( | ||
| 81 | 81 | }; |
| 82 | 82 | } |
| 83 | 83 | |
| 84 | /** The most entries a directory page lists before it links to the rest. */ | |
| 85 | export const TREE_WINDOW = 300; | |
| 86 | ||
| 87 | /** The side column is navigation, not the listing: it keeps fewer rows per directory. */ | |
| 88 | export const COLUMN_WINDOW = 100; | |
| 89 | ||
| 90 | /** Directories first, then files, the first TREE_WINDOW of them. */ | |
| 91 | export function treeWindow( | |
| 92 | entries: readonly TreeEntry[], | |
| 93 | limit = TREE_WINDOW, | |
| 94 | ): TreeEntry[] { | |
| 95 | const dirs = entries.filter((e) => e.type === "tree"); | |
| 96 | const rest = entries.filter((e) => e.type !== "tree"); | |
| 97 | return [...dirs, ...rest].slice(0, limit); | |
| 98 | } | |
| 99 | ||
| 84 | 100 | /** Directories first, then files, each alphabetical — a readable column. */ |
| 85 | 101 | function ordered(entries: readonly TreeEntry[]): TreeEntry[] { |
| 86 | 102 | return [...entries].sort((a, b) => |
| @@ -106,7 +122,9 @@ function levelList(data: ExplorerData, base: string, at: number): Html { | ||
| 106 | 122 | if (level === undefined) return html``; |
| 107 | 123 | const encoded = encodeURIComponent(data.ref); |
| 108 | 124 | const next = data.levels[at + 1]; |
| 109 | const rows = ordered(level.entries).map((entry) => { | |
| 125 | const shown = treeWindow(ordered(level.entries), COLUMN_WINDOW); | |
| 126 | const hidden = level.entries.length - shown.length; | |
| 127 | const rows = shown.map((entry) => { | |
| 110 | 128 | const path = join(level.path, entry.name); |
| 111 | 129 | if (entry.type === "tree") { |
| 112 | 130 | const open = next !== undefined && next.path === path; |
| @@ -151,6 +169,15 @@ function levelList(data: ExplorerData, base: string, at: number): Html { | ||
| 151 | 169 | ${rows.length === 0 |
| 152 | 170 | ? html`<li><span class="muted small">empty</span></li>` |
| 153 | 171 | : rows} |
| 172 | ${hidden > 0 | |
| 173 | ? html`<li> | |
| 174 | <a | |
| 175 | class="ex-more" | |
| 176 | href="${`${base}/tree/${encoded}${level.path === "" ? "" : `/${level.path}`}?all=1`}" | |
| 177 | >Show all ${level.entries.length.toLocaleString("en-US")} entries</a | |
| 178 | > | |
| 179 | </li>` | |
| 180 | : null} | |
| 154 | 181 | </ul>`; |
| 155 | 182 | } |
| 156 | 183 |
src/web/pages/repo.ts +27 −3
| @@ -101,6 +101,7 @@ import { | ||
| 101 | 101 | finderButton, |
| 102 | 102 | finderDialog, |
| 103 | 103 | loadExplorer, |
| 104 | treeWindow, | |
| 104 | 105 | } from "./explorer.ts"; |
| 105 | 106 | |
| 106 | 107 | export { crumbsFor, loadRepo, repoBase }; |
| @@ -361,8 +362,11 @@ export function treeListing( | ||
| 361 | 362 | ref: string, |
| 362 | 363 | path: string, |
| 363 | 364 | entries: readonly TreeEntry[], |
| 365 | all = false, | |
| 364 | 366 | ): Html { |
| 365 | 367 | const encoded = encodeURIComponent(ref); |
| 368 | const shown = all ? entries : treeWindow(entries); | |
| 369 | const hidden = entries.length - shown.length; | |
| 366 | 370 | const parent = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; |
| 367 | 371 | const rows: Html[] = []; |
| 368 | 372 | if (path !== "") { |
| @@ -375,7 +379,7 @@ export function treeListing( | ||
| 375 | 379 | }), |
| 376 | 380 | ); |
| 377 | 381 | } |
| 378 | for (const entry of entries) { | |
| 382 | for (const entry of shown) { | |
| 379 | 383 | const target = path === "" ? entry.name : `${path}/${entry.name}`; |
| 380 | 384 | const isTree = entry.type === "tree"; |
| 381 | 385 | rows.push( |
| @@ -394,8 +398,25 @@ export function treeListing( | ||
| 394 | 398 | }), |
| 395 | 399 | ); |
| 396 | 400 | } |
| 401 | if (hidden > 0) { | |
| 402 | rows.push( | |
| 403 | row({ | |
| 404 | href: `${base}/tree/${encoded}${path === "" ? "" : `/${path}`}?all=1`, | |
| 405 | title: html`Show all ${entries.length.toLocaleString("en-US")} entries`, | |
| 406 | nav: true, | |
| 407 | }), | |
| 408 | ); | |
| 409 | } | |
| 397 | 410 | const total = directoryTotal(entries); |
| 398 | return listCard(rows, total.text.trim() === "" ? {} : { foot: total }); | |
| 411 | const foot = | |
| 412 | hidden > 0 | |
| 413 | ? html`<span class="muted small num" | |
| 414 | >Showing ${shown.length.toLocaleString("en-US")} of | |
| 415 | ${entries.length.toLocaleString("en-US")} entries</span | |
| 416 | > | |
| 417 | ${total}` | |
| 418 | : total; | |
| 419 | return listCard(rows, foot.text.trim() === "" ? {} : { foot }); | |
| 399 | 420 | } |
| 400 | 421 | |
| 401 | 422 | /** |
| @@ -801,6 +822,8 @@ export async function renderCode( | ||
| 801 | 822 | ref: string; |
| 802 | 823 | path: string; |
| 803 | 824 | entries: readonly TreeEntry[]; |
| 825 | /** List every entry, not the first TREE_WINDOW (`?all=1`). */ | |
| 826 | all?: boolean; | |
| 804 | 827 | extra?: Html; |
| 805 | 828 | rail?: Html; |
| 806 | 829 | /** The README, when the caller has already read it (and timed the read). */ |
| @@ -842,7 +865,7 @@ export async function renderCode( | ||
| 842 | 865 | })} |
| 843 | 866 | </div>`; |
| 844 | 867 | const main = html`${bar} ${o.extra ?? null} |
| 845 | ${treeListing(base, ref, path, o.entries)} | |
| 868 | ${treeListing(base, ref, path, o.entries, o.all === true)} | |
| 846 | 869 | <div class="mt">${readme}</div>`; |
| 847 | 870 | return o.rail === undefined |
| 848 | 871 | ? main |
| @@ -1321,6 +1344,7 @@ export async function treePage(ctx: Ctx): Promise<Response> { | ||
| 1321 | 1344 | ref, |
| 1322 | 1345 | path, |
| 1323 | 1346 | entries: entries.value, |
| 1347 | all: ctx.url.searchParams.get("all") === "1", | |
| 1324 | 1348 | }), |
| 1325 | 1349 | }); |
| 1326 | 1350 | notePhase(ctx.request, "render", performance.now() - began); |
test/tree-window.test.ts added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | /** A directory of 1,200 entries lists 300 and links to the rest (#6168). */ | |
outdated
Wrong issue number in the header (#6145 vs this pull #6168). |
||
| 2 | ||
| 3 | import { describe, expect, test } from "bun:test"; | |
| 4 | import { afterAll, beforeAll } from "bun:test"; | |
| 5 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 6 | import { tmpdir } from "node:os"; | |
| 7 | import { join } from "node:path"; | |
| 8 | import { newApp } from "../src/actions/apps.ts"; | |
| 9 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { getRepo } from "../src/core/repos.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { treeListing } from "../src/web/pages/repo.ts"; | |
| 15 | import { proveAll } from "./helpers/verified.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { promote } from "./helpers/members.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | const entries = [ | |
| 22 | ...Array.from({ length: 1100 }, (_, i) => ({ | |
| 23 | mode: "100644", | |
| 24 | type: "blob", | |
| 25 | sha: "b".repeat(40), | |
| 26 | size: 100, | |
outdated
Row counting with |
||
| 27 | name: `f${String(i).padStart(4, "0")}.ts`, | |
| 28 | })), | |
| 29 | ...Array.from({ length: 100 }, (_, i) => ({ | |
| 30 | mode: "040000", | |
| 31 | type: "tree", | |
| 32 | sha: "a".repeat(40), | |
| 33 | size: -1, | |
| 34 | name: `d${String(i).padStart(3, "0")}`, | |
| 35 | })), | |
| 36 | ]; | |
| 37 | ||
| 38 | describe("tree window", () => { | |
| 39 | const page = treeListing("/o/r", "main", "test", entries as never).text; | |
| 40 | test("directories first, a labelled link to the rest, a footer that agrees", () => { | |
| 41 | expect(page).toContain('href="/o/r/tree/main/test/d099"'); | |
| 42 | expect(page).toContain('href="/o/r/blob/main/test/f0199.ts"'); | |
| 43 | expect(page).not.toContain("f0200.ts"); | |
| 44 | expect(page).toContain("Show all 1,200 entries"); | |
| 45 | expect(page).toContain('href="/o/r/tree/main/test?all=1"'); | |
| 46 | expect(page).toMatch(/Showing 300 of\s+1,200 entries/); | |
| 47 | expect(page.length).toBeLessThan(300 * 1024); | |
| 48 | }); | |
| 49 | test("all=1 lists everything and no link", () => { | |
| 50 | const all = treeListing("/o/r", "main", "test", entries as never, true).text; | |
| 51 | expect(all).toContain('href="/o/r/blob/main/test/f1099.ts"'); | |
| 52 | expect(all).not.toContain("?all=1"); | |
| 53 | expect(all).not.toContain("Showing"); | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | describe("through a request", () => { | |
| 58 | let root = ""; | |
| 59 | let app: App; | |
| 60 | let servers: Servers; | |
| 61 | beforeAll(async () => { | |
| 62 | root = mkdtempSync(join(tmpdir(), "forge-tree-window-")); | |
| 63 | app = createApp({ dataDir: root, port: 0 }); | |
| 64 | const u = await createUser(app.db, { | |
| 65 | origin: "seed", | |
| 66 | trusted: true, | |
| 67 | username: "alice", | |
| 68 | email: "[email protected]", | |
| 69 | password: "a-good-passphrase", | |
| 70 | adminIfFirst: true, | |
| 71 | }); | |
| 72 | if (!u.ok) throw new Error(u.error); | |
| 73 | promote(app.db, u.value); | |
| 74 | proveAll(app); | |
| 75 | const made = await newApp(app, { id: u.value.id, username: "alice" } as never, { | |
| 76 | name: "big", | |
| 77 | description: "x", | |
| 78 | visibility: "public", | |
| 79 | }); | |
| 80 | if (!made.ok) throw new Error(made.error); | |
| 81 | const repo = getRepo(app.db, "alice", "big"); | |
| 82 | const files = Array.from({ length: 1200 }, (_, i) => ({ | |
| 83 | path: `test/case-${String(i).padStart(4, "0")}.ts`, | |
| 84 | content: "x", | |
| 85 | })); | |
| 86 | const done = await commitToBranch(app, u.value, repo!, { files }); | |
| 87 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 88 | servers = await serve(app); | |
| 89 | }); | |
| 90 | afterAll(() => { | |
| 91 | servers.stop(); | |
| 92 | app.close(); | |
| 93 | rmSync(root, { recursive: true, force: true }); | |
| 94 | }); | |
| 95 | const get = async (q: string) => | |
| 96 | (await fetch(`${servers.url}/alice/big/tree/main/test${q}`)).text(); | |
| 97 | ||
| 98 | test("the whole page, side column included, stays under 300 KB", async () => { | |
| 99 | const page = await get(""); | |
| 100 | expect(page.length).toBeLessThan(300 * 1024); | |
| 101 | expect(page).toContain("case-0299.ts"); | |
| 102 | expect(page).not.toContain("case-0300.ts"); | |
| 103 | expect(page).toContain("Show all 1,200 entries"); | |
| 104 | }); | |
| 105 | test("?all=1 lists all 1,200 in the main listing", async () => { | |
| 106 | const page = await get("?all=1"); | |
| 107 | expect(page).toContain("case-1199.ts"); | |
| 108 | expect(page).toContain("case-0300.ts"); | |
| 109 | }); | |
| 110 | test("the side column's lazy fragment is windowed too", async () => { | |
| 111 | const frag = await get("?fragment=explorer"); | |
| 112 | expect(frag).toContain("case-0099.ts"); | |
| 113 | expect(frag).not.toContain("case-0100.ts"); | |
| 114 | expect(frag).toContain("Show all 1,200 entries"); | |
| 115 | }); | |
| 116 | }); |
new above
offline
Link text "N more" is not meaningful out of context. Make it "Show all N entries" (total, not hidden count).