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

Merged opened this

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

Merged

scout/forge:scout/6145-a-directory-with-more-than-300-entries-l → main 9 commits fork 4 threads · 4 unresolved

  • Merged. scout/6145-a-directory-with-more-than-300-entries-l was merged into main as 8a66468.

Review

Sign in to review this change.
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
738738 address before this existed were filled in from their migration's `push`
739739 event, without a fork chain; one picked from a connected origin then has none.
740740
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
741745 ## Search
742746
743747 `/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 @@
11 # Code
22
33 - `.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> {
493493 ref,
494494 path,
495495 entries: entries.value,
496 all: ctx.url.searchParams.get("all") === "1",
496497 }),
497498 });
498499 }
src/web/pages/explorer.ts +28 −1
@@ -81,6 +81,22 @@ export async function loadExplorer(
8181 };
8282 }
8383
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
84100 /** Directories first, then files, each alphabetical — a readable column. */
85101 function ordered(entries: readonly TreeEntry[]): TreeEntry[] {
86102 return [...entries].sort((a, b) =>
@@ -106,7 +122,9 @@ function levelList(data: ExplorerData, base: string, at: number): Html {
106122 if (level === undefined) return html``;
107123 const encoded = encodeURIComponent(data.ref);
108124 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) => {
110128 const path = join(level.path, entry.name);
111129 if (entry.type === "tree") {
112130 const open = next !== undefined && next.path === path;
@@ -151,6 +169,15 @@ function levelList(data: ExplorerData, base: string, at: number): Html {
151169 ${rows.length === 0
152170 ? html`<li><span class="muted small">empty</span></li>`
153171 : 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}
154181 </ul>`;
155182 }
156183
src/web/pages/repo.ts +27 −3
@@ -101,6 +101,7 @@ import {
101101 finderButton,
102102 finderDialog,
103103 loadExplorer,
104 treeWindow,
104105 } from "./explorer.ts";
105106
106107 export { crumbsFor, loadRepo, repoBase };
@@ -361,8 +362,11 @@ export function treeListing(
361362 ref: string,
362363 path: string,
363364 entries: readonly TreeEntry[],
365 all = false,
364366 ): Html {
365367 const encoded = encodeURIComponent(ref);
368 const shown = all ? entries : treeWindow(entries);
369 const hidden = entries.length - shown.length;
366370 const parent = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
367371 const rows: Html[] = [];
368372 if (path !== "") {
@@ -375,7 +379,7 @@ export function treeListing(
375379 }),
376380 );
377381 }
378 for (const entry of entries) {
382 for (const entry of shown) {
379383 const target = path === "" ? entry.name : `${path}/${entry.name}`;
380384 const isTree = entry.type === "tree";
381385 rows.push(
@@ -394,8 +398,25 @@ export function treeListing(
394398 }),
395399 );
396400 }
401 if (hidden > 0) {
402 rows.push(
403 row({
404 href: `${base}/tree/${encoded}${path === "" ? "" : `/${path}`}?all=1`,
outdated

Link text "N more" is not meaningful out of context. Make it "Show all N entries" (total, not hidden count).

405 title: html`Show all ${entries.length.toLocaleString("en-US")} entries`,
406 nav: true,
407 }),
408 );
409 }
397410 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 });
399420 }
400421
401422 /**
@@ -801,6 +822,8 @@ export async function renderCode(
801822 ref: string;
802823 path: string;
803824 entries: readonly TreeEntry[];
825 /** List every entry, not the first TREE_WINDOW (`?all=1`). */
826 all?: boolean;
804827 extra?: Html;
805828 rail?: Html;
806829 /** The README, when the caller has already read it (and timed the read). */
@@ -842,7 +865,7 @@ export async function renderCode(
842865 })}
843866 </div>`;
844867 const main = html`${bar} ${o.extra ?? null}
845 ${treeListing(base, ref, path, o.entries)}
868 ${treeListing(base, ref, path, o.entries, o.all === true)}
846869 <div class="mt">${readme}</div>`;
847870 return o.rail === undefined
848871 ? main
@@ -1321,6 +1344,7 @@ export async function treePage(ctx: Ctx): Promise<Response> {
13211344 ref,
13221345 path,
13231346 entries: entries.value,
1347 all: ctx.url.searchParams.get("all") === "1",
13241348 }),
13251349 });
13261350 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 - 2 is brittle; assert on the all-entries link and on specific names. Also add a request-level test so the side column is covered.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs