@gavin-hu/my-pi-agent 0.15.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,10 +73,9 @@ Then, from inside Pi:
73
73
  - `/bytefly [extension] [refresh]` opens the control center on Extensions, lists
74
74
  every loaded extension with its runtime health, enables or disables one, and
75
75
  clears a project override with `x`.
76
- - `/bytefly settings` opens the control center's Settings section: ten groups
77
- covering startup, notifications, diagnostics, pricing, transcript, terminal,
78
- worktree, snapshots, jobs, and memory, plus a **Theme** row, with
79
- `Space`/`Enter` to change a value and `Tab` for Global/Project scope.
76
+ - `/bytefly settings` opens the control center's Settings section: the curated
77
+ settings, grouped by area, plus a **Theme** row, with `Space`/`Enter` to change
78
+ a value and `Tab` for Global/Project scope.
80
79
  - `/bytefly doctor [extension] [refresh]` prints the doctor report —
81
80
  `run_doctor`'s text — and checks that each enabled extension has what it needs
82
81
  at runtime.
@@ -94,9 +93,8 @@ Nothing about the session is sent, and `PI_OFFLINE` skips the check;
94
93
 
95
94
  ## What you get
96
95
 
97
- Seventeen extensions and two packaged skills, grouped by what you are trying to
98
- do, plus two themes. Each extension's own `README.md` has the full tool and
99
- command surface.
96
+ 17 extensions, 2 packaged skills, and 2 themes, grouped by what you are trying
97
+ to do. Each extension's own `README.md` has the full tool and command surface.
100
98
 
101
99
  ### Plan and delegate
102
100
 
@@ -124,7 +122,7 @@ command surface.
124
122
  | Extension | Tools and commands | What it does |
125
123
  |---|---|---|
126
124
  | [`web-access`](./extensions/web-access/) | `web_search`, `web_fetch` | Search the web (keyless DuckDuckGo by default, optional SearXNG or Brave) and fetch a page as readable text, with paging, `find`, per-hop SSRF checks, and optional PDF support. |
127
- | [`file-browse`](./extensions/file-browse/) | `/browse` | Start a loopback, read-only file browser rooted at the working directory, with Markdown preview and a fuzzy quick-open palette. |
125
+ | [`file-browse`](./extensions/file-browse/) | `/browse` | Start a loopback, read-only file browser rooted at the working directory, with Markdown preview, a fuzzy quick-open palette, and live status for a page left open. |
128
126
  | [`document`](./extensions/document/) | `read_doc` | Extract text from a local PDF, DOCX, DOC, ODT, RTF, or XLSX in process, paged. |
129
127
  | [`file-open`](./extensions/file-open/) | `/open`, `open_path` | Open or reveal a file or directory in the OS-native application. |
130
128
  | [`wechat`](./extensions/wechat/) | `/wechat`, `send_wechat` | Bridge a WeChat chat to the live session over the Weixin iLink bot API; inbound text, images, and files become turns. |
package/README.zh-CN.md CHANGED
@@ -64,9 +64,8 @@ Windows、macOS 和 Linux 均可运行,完整检查套件在每个平台上都
64
64
 
65
65
  - `/bytefly [extension] [refresh]` 打开控制中心的 Extensions 区,列出每个已加载扩展
66
66
  及其运行时健康状况,启用或禁用一个扩展,并用 `x` 清除项目级覆盖。
67
- - `/bytefly settings` 打开控制中心的 Settings 区:十个分组覆盖启动、通知、
68
- 诊断、计价、转录、终端、worktree、快照、任务与监听器、记忆,并提供 **Theme**
69
- 行;用 `Space`/`Enter` 修改,用 `Tab` 切换 Global/Project 作用域。
67
+ - `/bytefly settings` 打开控制中心的 Settings 区:精选设置按类别分组,并提供
68
+ **Theme** 行;用 `Space`/`Enter` 修改,用 `Tab` 切换 Global/Project 作用域。
70
69
  - `/bytefly doctor [extension] [refresh]` 打印 doctor 报告(`run_doctor` 的文本),
71
70
  检查每个已启用扩展在运行时是否具备所需条件。
72
71
  - `/bytefly update` 立即重跑包更新检查,报告可更新的包、本包版本与 release URL,
@@ -81,7 +80,7 @@ Windows、macOS 和 Linux 均可运行,完整检查套件在每个平台上都
81
80
 
82
81
  ## 包含内容
83
82
 
84
- 十七个扩展和两个随包技能,按你想达成的目标分组,外加两个主题。每个扩展自己的 `README.md`
83
+ 17 个扩展、2 个随包技能,以及 2 个主题,按你想达成的目标分组。每个扩展自己的 `README.md`
85
84
  提供完整的工具与命令说明。
86
85
 
87
86
  ### 规划与委派
@@ -110,7 +109,7 @@ Windows、macOS 和 Linux 均可运行,完整检查套件在每个平台上都
110
109
  | 扩展 | 工具与命令 | 说明 |
111
110
  |---|---|---|
112
111
  | [`web-access`](./extensions/web-access/) | `web_search`, `web_fetch` | 搜索网络(默认无密钥的 DuckDuckGo,可选 SearXNG 或 Brave),并把页面抓取为可读文本,支持分页、`find`、逐跳 SSRF 检查以及可选的 PDF 支持。 |
113
- | [`file-browse`](./extensions/file-browse/) | `/browse` | 启动一个以工作目录为根的本地回环、只读文件浏览器,带 Markdown 预览和模糊快速打开面板。 |
112
+ | [`file-browse`](./extensions/file-browse/) | `/browse` | 启动一个以工作目录为根的本地回环、只读文件浏览器,带 Markdown 预览、模糊快速打开面板,以及为常开页面提供的实时状态。 |
114
113
  | [`document`](./extensions/document/) | `read_doc` | 在进程内从本地 PDF、DOCX、DOC、ODT、RTF 或 XLSX 提取文本,支持分页。 |
115
114
  | [`file-open`](./extensions/file-open/) | `/open`, `open_path` | 用操作系统原生应用打开或定位文件/目录。 |
116
115
  | [`wechat`](./extensions/wechat/) | `/wechat`, `send_wechat` | 通过微信 iLink 机器人 API 把微信聊天桥接到实时会话;入站文本、图片和文件会变成回合。 |
@@ -83,6 +83,13 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
83
83
  `worktree`; untracked files show as all-add, an unborn `HEAD` falls back to the
84
84
  index, and a non-repository is a plain empty state. Read-only, capped, and
85
85
  rendered with the shared diff colors.
86
+ - **Live status.** A page left open keeps up with the session: a `◉ live · 2s`
87
+ chip beside `read-only` says whether status is flowing (green, with the age of
88
+ the last frame), still connecting (accent), stalled (amber), or offline (red);
89
+ the branch chip, the `● N changes +A −D` chip, and the change markers in the
90
+ tree and the listing update in place; and the footer line reports `up 2m 14s ·
91
+ 37 requests`. One Server-Sent Events stream per page carries it, on the same
92
+ loopback server, so nothing extra is opened and nothing is written.
86
93
  - **No dependencies.** `node:http` plus the inline sprite, CSS, and client
87
94
  script.
88
95
 
@@ -114,6 +121,19 @@ beside `⎇ branch` and `⑂ worktree`; it
114
121
  is cleared on stop and on `session_shutdown`. The chip is session-local: it
115
122
  reflects only this session's server, with no cross-session sharing.
116
123
 
124
+ The page has its own live status, which is what a left-open tab reads. The
125
+ header chip starts hidden and is painted by the client from `/events`: `◉ live ·
126
+ 2s` in the success colour while frames arrive, `◉ connecting` before the first
127
+ one, `◉ stale · 9s` in the warning colour when three intervals pass without a
128
+ frame on an open connection, and `◉ offline` in the danger colour once the
129
+ stream errors. The same frame carries the branch (or detached head), the
130
+ uncommitted-change totals and paths, the server's uptime, and the requests it
131
+ has answered, so the branch chip, the changes chip, the markers, and the footer
132
+ line are painted from one snapshot rather than four requests. Losing the stream
133
+ leaves everything the page last showed in place: it is read-only, so a stale
134
+ label is never a stale action. `"live": false` turns the whole surface off — no
135
+ chip, no stream, no `/events` route — and so does JavaScript off.
136
+
117
137
  ## Routes
118
138
 
119
139
  | Route | Result |
@@ -126,6 +146,7 @@ reflects only this session's server, with no cross-session sharing.
126
146
  | `/diff/<rel>[?mode=…]` | one file's unified diff |
127
147
  | `/api/tree?path=<rel>` | JSON children for lazy tree expansion |
128
148
  | `/api/files?q=<query>` | JSON fuzzy path matches for the quick-open palette |
149
+ | `/events` | Server-Sent Events stream of `status` frames for the open page |
129
150
  | `/app.js` | the first-party client script |
130
151
  | `/theme.js` | the pre-paint theme bootstrap |
131
152
  | `/favicon.ico` | `204` (the icon is a data URI) |
@@ -150,7 +171,9 @@ document and the project document (project wins):
150
171
  "maxIndexEntries": 20000, // files collected for quick open before truncation
151
172
  "gitDiff": true, // offer the read-only diff view
152
173
  "diffContext": 3, // unified diff context lines
153
- "maxDiffLines": 4000 // diff lines rendered before truncation
174
+ "maxDiffLines": 4000, // diff lines rendered before truncation
175
+ "live": true, // stream live status to open pages
176
+ "liveIntervalMs": 3000 // ms between status frames while a page is connected
154
177
  }
155
178
  ```
156
179
 
@@ -185,6 +208,13 @@ document and the project document (project wins):
185
208
  - **Highlighting safety.** The highlighter escapes every literal before it
186
209
  wraps it in a token span, so a code file's content cannot inject markup or
187
210
  attributes; only the fixed `tok-*` class names reach the page.
211
+ - **Event stream.** `/events` is a `GET` on the same loopback server, behind
212
+ the same method and `Host` checks, and it only reads: the frame is the branch,
213
+ the change paths, an uptime, and a request count, all of which the page it
214
+ feeds already renders. It carries no page policy (`text/event-stream` is not a
215
+ document), sends `Cache-Control: no-store`, and cannot be cached or replayed.
216
+ A tick runs only while a page is attached, and the timer is `unref`ed, so an
217
+ idle server does no work and a forgotten tab cannot hold the process open.
188
218
  - **Caps.** `maxFileBytes` (inline rendering), `maxTextLines` (rendered lines),
189
219
  `maxThumbBytes` (thumbnails), and `maxDirEntries` (listings) bound every read.
190
220
  - **Git reads.** The header chip and the diff view run read-only git
@@ -200,6 +230,11 @@ document and the project document (project wins):
200
230
  type renders exactly as before. State that the tokenizer does not carry
201
231
  across lines (shell heredocs, interpolation inside a template literal) is
202
232
  approximated rather than parsed.
233
+ - Live status updates what the page already contains: the header chips, the
234
+ markers on the rows it rendered, and the footer line. A file that appears on
235
+ disk joins the listing on the next reload, the `/diff` table and a file page's
236
+ `changes` action stay page-load snapshots, and the branch chip is only patched
237
+ when the page was served inside a repository.
203
238
  - `/browse` has no auth; a malicious local page could probe the port. The `Host`
204
239
  allowlist and the absence of CORS headers mitigate reads, and the server is
205
240
  read-only.
@@ -221,7 +256,7 @@ document and the project document (project wins):
221
256
  |---|---|
222
257
  | Command | `/browse` registers a single command from the factory; no tools are exposed. |
223
258
  | State | The running server, its root, and the session port live in a module-local closure; nothing is persisted and no session entries are written. |
224
- | Lifecycle | The factory opens no socket; the server is created on `/browse` and closed idempotently by `/browse stop` and `session_shutdown`. |
259
+ | Lifecycle | The factory opens no socket; the server is created on `/browse` and closed idempotently by `/browse stop` and `session_shutdown`, which also ends every attached status stream. |
225
260
 
226
261
  ## Design notes
227
262
 
@@ -309,6 +344,19 @@ document and the project document (project wins):
309
344
  with the file view. `git diff --stat` supplies the chip totals and
310
345
  `changeDirs` derives the directory markers; the changed lines are highlighted
311
346
  per hunk by rebuilding the old and new sides for `lib/highlight`.
347
+ - **Live status.** `live.ts` builds one snapshot per tick — the git status,
348
+ the `head` change index, the server's uptime, and its request count — and
349
+ `frameOf` serializes it as one `status` event. `createLiveChannel` holds the
350
+ attached pages and starts the ticker on the first attach, so an idle server
351
+ does no work; it shares a single in-flight snapshot between pages, drops a
352
+ page whose write fails, and stops on the last detach and on `close()`, which
353
+ `server.ts` calls before closing the socket. Both providers are already
354
+ TTL-cached, so a tick re-reads at most one `git status`, and every read
355
+ failure degrades to `null` rather than a failed frame. The event-stream
356
+ headers and the `retry:` preamble live in `lib/http` with the rest of the
357
+ loopback policy, shared with plan's review page. The client half is
358
+ `live-client.ts`: one `EventSource`, one snapshot paint (`textContent` only,
359
+ never markup), a one-second age ticker, and a `pagehide` cleanup.
312
360
  - **Streaming responses.** The server streams `filePath` responses and
313
361
  suppresses bodies for `HEAD`, so large `/raw` files are not buffered.
314
362
  - **Root confinement over feature breadth.** The root is deliberately confined
@@ -325,7 +373,7 @@ document and the project document (project wins):
325
373
  | [`../../lib/escape.ts`](../../lib/escape.ts) | `escapeHtml`, the shared HTML escaping used by every renderer |
326
374
  | [`../../lib/highlight.ts`](../../lib/highlight.ts) | First-party escape-first syntax tokenizer for the code view (shared with `plan`) |
327
375
  | [`../../lib/web/index.ts`](../../lib/web/index.ts) | The shared Bytefly web brand system: tokens, base, components, layout, runtime, and markup |
328
- | `files.ts` | Classification (incl. Markdown), listings, text reads, sidebar tree |
376
+ | `files.ts` | Classification (incl. Markdown), text reads, and one page-level walk (`directoryView`) that returns a directory's listing and its sidebar tree |
329
377
  | `diff.ts` | Pure unified-diff and porcelain-status parsing for the diff view |
330
378
  | `fuzzy.ts` | Fuzzy path matching and ranking for the quick-open palette |
331
379
  | `markdown.ts` | First-party escape-first Markdown-to-HTML renderer (fenced code highlighted; raw HTML through `raw-html.ts`) |
@@ -334,10 +382,13 @@ document and the project document (project wins):
334
382
  | `git.ts` | Read-only branch/dirty provider for the header chip |
335
383
  | `icons.ts` | Inline SVG sprite and the per-language mapping |
336
384
  | `client.ts` | The `/app.js` source (tree toggle, filter, sort, wrap, copy, quick open) |
337
- | `html.ts` | Page renderers, sortable headers, and the page-only `browse-*` CSS (the `bf-*` kit comes from `lib/web`) |
385
+ | `live-client.ts` | The client's live status block: the event stream, the chips, the markers, and the footer line (interpolated into `client.ts`) |
386
+ | `live.ts` | The live status snapshot, its frame, and the fan-out channel that ticks it |
387
+ | `styles.ts` | The page-only `browse-*` stylesheet (the `bf-*` kit comes from `lib/web`) |
388
+ | `html.ts` | Page renderers: the shared shell, the two-pane layout, the listing, the cards, and the diff body |
338
389
  | `router.ts` | Pure request handler: routes, the `/raw` sandbox policy, and the error page |
339
390
  | `server.ts` | `node:http` server: this extension's handler over the shared bind, writer, and close |
340
- | [`../../lib/http/index.ts`](../../lib/http/index.ts) | Loopback HTTP layer shared with `plan`: page/script/JSON headers, `ServeResponse`, the request-envelope helpers, bind/respond/close |
391
+ | [`../../lib/http/index.ts`](../../lib/http/index.ts) | Loopback HTTP layer shared with `plan`: page/script/JSON/event-stream headers, `ServeResponse`, the request-envelope helpers, bind/respond/close, and `openEventStream` |
341
392
  | [`../../lib/browser.ts`](../../lib/browser.ts) | Cross-platform browser opener (shared with `plan`) |
342
393
 
343
394
  ## Testing
@@ -360,5 +411,9 @@ on first start, reused, and re-rolled after a conflict), the git status provider
360
411
  and provider (line kinds, line numbers, status and rename parsing, mode
361
412
  filtering, untracked all-add, unborn-HEAD fallback, literal pathspec, and TTL
362
413
  cache), the diff renderers (highlighted changed lines, marker sets, and chip
363
- totals), and the command
414
+ totals), the live status feed (snapshot fields, degraded providers, the frame,
415
+ and the channel's ticker driven by an injected timer), the `/events` route and
416
+ its 404 when the channel is off, a real socket that reads a page's first frame
417
+ and the ticker's next one and sees the stream end when the server closes, the
418
+ live chrome a page renders when on and not when off, and the command
364
419
  lifecycle with injected seams.
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { keysDialogScript, SIDEBAR_COLLAPSE_MAX, THEME_TOGGLE_JS } from "../../lib/web/index.ts";
15
+ import { LIVE_CLIENT_JS } from "./live-client.ts";
15
16
 
16
17
  export const CLIENT_JS = `(function () {
17
18
  "use strict";
@@ -716,5 +717,6 @@ export const CLIENT_JS = `(function () {
716
717
 
717
718
  // The control starts in the state the rendered tree implies.
718
719
  syncFoldButton();
719
- })();
720
+
721
+ ${LIVE_CLIENT_JS}})();
720
722
  `;
@@ -36,6 +36,10 @@ export interface ServeConfig {
36
36
  diffContext: number;
37
37
  /** Diff lines rendered before the view is truncated. */
38
38
  maxDiffLines: number;
39
+ /** Push live status to open pages over `/events`. */
40
+ live: boolean;
41
+ /** How often a connected page is sent a status frame, in ms. */
42
+ liveIntervalMs: number;
39
43
  }
40
44
 
41
45
  export const DEFAULT_CONFIG: ServeConfig = {
@@ -52,6 +56,8 @@ export const DEFAULT_CONFIG: ServeConfig = {
52
56
  gitDiff: true,
53
57
  diffContext: 3,
54
58
  maxDiffLines: 4000,
59
+ live: true,
60
+ liveIntervalMs: 3000,
55
61
  };
56
62
 
57
63
  /** Base of the per-session port range. */
@@ -60,6 +66,13 @@ export const PORT_BASE = 4780;
60
66
  /** Number of ports in the per-session range. */
61
67
  export const PORT_RANGE = 1000;
62
68
 
69
+ /** Fastest live-status cadence a page may be given, short enough to watch and
70
+ * long enough that the 2 s provider caches absorb most ticks. */
71
+ export const LIVE_INTERVAL_MIN = 250;
72
+
73
+ /** Slowest live-status cadence, so a page cannot look abandoned. */
74
+ export const LIVE_INTERVAL_MAX = 60_000;
75
+
63
76
  /** A random port in the per-session range `PORT_BASE`–`PORT_BASE + PORT_RANGE - 1`. */
64
77
  export function randomPort(): number {
65
78
  return PORT_BASE + randomInt(PORT_RANGE);
@@ -85,6 +98,8 @@ export function normalizeConfig(
85
98
  gitDiff: asBoolean(raw.gitDiff, base.gitDiff),
86
99
  diffContext: clampInteger(raw.diffContext, base.diffContext, 0, 100),
87
100
  maxDiffLines: clampInteger(raw.maxDiffLines, base.maxDiffLines, 1, 1_000_000),
101
+ live: asBoolean(raw.live, base.live),
102
+ liveIntervalMs: clampInteger(raw.liveIntervalMs, base.liveIntervalMs, LIVE_INTERVAL_MIN, LIVE_INTERVAL_MAX),
88
103
  };
89
104
  }
90
105
 
@@ -8,6 +8,7 @@
8
8
  import type { Dirent, Stats } from "node:fs";
9
9
  import { readFile, readdir, stat, lstat } from "node:fs/promises";
10
10
  import { basename, extname, join } from "node:path";
11
+ import { mapConcurrent } from "../../lib/pool.ts";
11
12
  import { HttpError } from "./paths.ts";
12
13
 
13
14
  export type EntryKind = "directory" | "text" | "image" | "binary";
@@ -188,63 +189,168 @@ function toAbs(root: string, rel: string): string {
188
189
  return rel ? join(root, ...rel.split("/")) : root;
189
190
  }
190
191
 
191
- /** List a directory, sorted (directories first, then case-insensitive name). */
192
- export async function listDirectory(root: string, relDir: string, limit: number): Promise<Listing> {
192
+ /** The `Dirent` fields a listing reads; a test's fake need not be a real `Dirent`. */
193
+ export interface DirectoryEntryInfo {
194
+ name: string;
195
+ isDirectory(): boolean;
196
+ isSymbolicLink(): boolean;
197
+ }
198
+
199
+ /** The `Stats` fields a listing reads. */
200
+ export interface DirectoryStats {
201
+ size: number;
202
+ mtimeMs: number;
203
+ isDirectory(): boolean;
204
+ }
205
+
206
+ /**
207
+ * The three filesystem reads a listing makes.
208
+ *
209
+ * Injected so a test can observe them: the interesting property — a directory is
210
+ * read once per entry, not once per entry *and* once per row drawn — is a count,
211
+ * not a value, and a wall-clock assertion would be flaky on a loaded machine.
212
+ */
213
+ export interface DirectoryFs {
214
+ readdir(path: string): Promise<DirectoryEntryInfo[]>;
215
+ lstat(path: string): Promise<DirectoryStats>;
216
+ stat(path: string): Promise<DirectoryStats>;
217
+ }
218
+
219
+ /** Production reads; a symlink's size and kind come from the target. */
220
+ const NODE_DIRECTORY_FS: DirectoryFs = {
221
+ readdir: (path) => readdir(path, { withFileTypes: true }),
222
+ lstat: (path) => lstat(path),
223
+ stat: (path) => stat(path),
224
+ };
225
+
226
+ /** Reads in flight per listing: enough to hide latency, bounded against a burst. */
227
+ const LISTING_CONCURRENCY = 32;
228
+
229
+ /** A row while it is still missing its metadata. */
230
+ interface Row {
231
+ name: string;
232
+ rel: string;
233
+ isDir: boolean;
234
+ isSymlink: boolean;
235
+ broken: boolean;
236
+ size: number;
237
+ mtimeMs: number;
238
+ }
239
+
240
+ /**
241
+ * Resolve one symlink: the link's own stat, then the target's.
242
+ *
243
+ * A symlink's directory-ness is the target's, so this cannot wait until after
244
+ * the sort; it is also the only entry whose classification needs a read.
245
+ */
246
+ async function resolveSymlink(fs: DirectoryFs, absDir: string, row: Row): Promise<void> {
247
+ const abs = join(absDir, row.name);
248
+ try {
249
+ const info = await fs.lstat(abs);
250
+ row.size = info.size;
251
+ row.mtimeMs = info.mtimeMs;
252
+ } catch {
253
+ // Vanished between readdir and lstat: the target read decides below.
254
+ }
255
+ try {
256
+ const target = await fs.stat(abs);
257
+ row.isDir = target.isDirectory();
258
+ row.size = target.size;
259
+ row.mtimeMs = target.mtimeMs;
260
+ } catch {
261
+ row.broken = true;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * List a directory, sorted (directories first, then case-insensitive name).
267
+ *
268
+ * A listing is latency per entry, not work per entry, so it is read in three
269
+ * passes instead of one entry at a time:
270
+ *
271
+ * 1. `readdir`, whose dirents already say whether each name is a directory;
272
+ * 2. the symlinks only, resolved through the pool, because their kind is the
273
+ * target's and the sort needs it;
274
+ * 3. the kept rows only, `stat`ed through the pool — so a directory of three
275
+ * thousand names pays for the two thousand it draws, not for every name, and
276
+ * pays them concurrently instead of in turn.
277
+ *
278
+ * `total` is still the directory's full entry count and `truncated` its `> limit`
279
+ * flip, so the page can say how many were dropped. Ties keep `readdir` order:
280
+ * `sort` is stable and the rows arrive unsorted.
281
+ */
282
+ export async function listDirectory(
283
+ root: string,
284
+ relDir: string,
285
+ limit: number,
286
+ fs: DirectoryFs = NODE_DIRECTORY_FS,
287
+ ): Promise<Listing> {
193
288
  const absDir = toAbs(root, relDir);
194
- let dirents: Dirent[];
289
+ let dirents: DirectoryEntryInfo[];
195
290
  try {
196
- dirents = await readdir(absDir, { withFileTypes: true });
291
+ dirents = await fs.readdir(absDir);
197
292
  } catch {
198
293
  throw new HttpError(404, "That folder could not be read.");
199
294
  }
200
295
 
201
- const entries: DirEntry[] = [];
202
- for (const dirent of dirents) {
203
- const abs = join(absDir, dirent.name);
204
- const isSymlink = dirent.isSymbolicLink();
205
- let isDir = dirent.isDirectory();
206
- let broken = false;
207
- let size = 0;
208
- let mtimeMs = 0;
209
- try {
210
- const info = isSymlink ? await lstat(abs) : await stat(abs);
211
- size = info.size;
212
- mtimeMs = info.mtimeMs;
213
- } catch {
214
- // Directory entry vanished between readdir and stat.
215
- }
216
- if (isSymlink) {
217
- try {
218
- const target = await stat(abs);
219
- isDir = target.isDirectory();
220
- size = target.size;
221
- mtimeMs = target.mtimeMs;
222
- } catch {
223
- broken = true;
224
- }
225
- }
226
- const kind: EntryKind = isDir ? "directory" : classifyByName(dirent.name);
227
- entries.push({
296
+ const links: Row[] = [];
297
+ const rows: Row[] = dirents.map((dirent) => {
298
+ const row: Row = {
228
299
  name: dirent.name,
229
300
  rel: relDir ? `${relDir}/${dirent.name}` : dirent.name,
230
- isDir,
231
- isSymlink,
232
- broken,
233
- size,
234
- mtimeMs,
235
- kind,
236
- isImage: !isDir && kind === "image",
237
- });
238
- }
301
+ isDir: dirent.isDirectory(),
302
+ isSymlink: dirent.isSymbolicLink(),
303
+ broken: false,
304
+ size: 0,
305
+ mtimeMs: 0,
306
+ };
307
+ if (row.isSymlink) links.push(row);
308
+ return row;
309
+ });
310
+
311
+ await mapConcurrent(links, LISTING_CONCURRENCY, (row) => resolveSymlink(fs, absDir, row));
239
312
 
240
- entries.sort((a, b) => {
313
+ // Directories first, then case-insensitive name. The sort runs before the cap,
314
+ // so the rows that survive it are the rows that would have been drawn.
315
+ rows.sort((a, b) => {
241
316
  if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
242
317
  return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
243
318
  });
244
319
 
245
- const total = entries.length;
320
+ const total = rows.length;
246
321
  const truncated = total > limit;
247
- return { entries: truncated ? entries.slice(0, limit) : entries, total, truncated };
322
+ const kept = truncated ? rows.slice(0, limit) : rows;
323
+
324
+ await mapConcurrent(
325
+ kept.filter((row) => !row.isSymlink),
326
+ LISTING_CONCURRENCY,
327
+ async (row) => {
328
+ try {
329
+ const info = await fs.stat(join(absDir, row.name));
330
+ row.size = info.size;
331
+ row.mtimeMs = info.mtimeMs;
332
+ } catch {
333
+ // Directory entry vanished between readdir and stat.
334
+ }
335
+ },
336
+ );
337
+
338
+ const entries: DirEntry[] = kept.map((row) => {
339
+ const kind: EntryKind = row.isDir ? "directory" : classifyByName(row.name);
340
+ return {
341
+ name: row.name,
342
+ rel: row.rel,
343
+ isDir: row.isDir,
344
+ isSymlink: row.isSymlink,
345
+ broken: row.broken,
346
+ size: row.size,
347
+ mtimeMs: row.mtimeMs,
348
+ kind,
349
+ isImage: !row.isDir && kind === "image",
350
+ };
351
+ });
352
+
353
+ return { entries, total, truncated };
248
354
  }
249
355
 
250
356
  /** Read a file into a renderable view, honouring the size and line caps. */
@@ -305,12 +411,37 @@ export async function buildTree(root: string, currentRel: string, limit: number)
305
411
  return buildNode(root, "", currentRel, basename(root) || root, limit);
306
412
  }
307
413
 
414
+ /** A directory page's two views of one directory. */
415
+ export interface DirectoryView {
416
+ /** The current directory's own rows, for the page's listing. */
417
+ listing: Listing;
418
+ /** The sidebar tree, with the current path expanded. */
419
+ tree: TreeNode;
420
+ }
421
+
422
+ /**
423
+ * One walk for a directory page: the listing it draws, and the tree that shows
424
+ * where it sits.
425
+ *
426
+ * `listDirectory` is what decides whether the directory can be read at all, so it
427
+ * runs first and its 404 is the page's error. The walk then reuses that listing
428
+ * for the same directory instead of reading it a second time; the ancestors are
429
+ * still read once each, which is what the tree needs.
430
+ */
431
+ export async function directoryView(root: string, currentRel: string, limit: number): Promise<DirectoryView> {
432
+ const listing = await listDirectory(root, currentRel, limit);
433
+ const tree = await buildNode(root, "", currentRel, basename(root) || root, limit, { rel: currentRel, listing });
434
+ return { listing, tree };
435
+ }
436
+
308
437
  async function buildNode(
309
438
  root: string,
310
439
  relDir: string,
311
440
  currentRel: string,
312
441
  label: string,
313
442
  limit: number,
443
+ /** A listing the caller already read for this directory, so it is not re-read. */
444
+ known?: { rel: string; listing: Listing },
314
445
  ): Promise<TreeNode> {
315
446
  const node: TreeNode = {
316
447
  name: label,
@@ -322,10 +453,14 @@ async function buildNode(
322
453
  children: [],
323
454
  };
324
455
  let listing: Listing;
325
- try {
326
- listing = await listDirectory(root, relDir, limit);
327
- } catch {
328
- return node;
456
+ if (known && known.rel === relDir) {
457
+ listing = known.listing;
458
+ } else {
459
+ try {
460
+ listing = await listDirectory(root, relDir, limit);
461
+ } catch {
462
+ return node;
463
+ }
329
464
  }
330
465
  for (const entry of listing.entries) {
331
466
  const child: TreeNode = {
@@ -340,7 +475,7 @@ async function buildNode(
340
475
  if (entry.isDir) {
341
476
  const onPath = currentRel === entry.rel || currentRel.startsWith(`${entry.rel}/`);
342
477
  if (onPath) {
343
- child.children = (await buildNode(root, entry.rel, currentRel, entry.name, limit)).children;
478
+ child.children = (await buildNode(root, entry.rel, currentRel, entry.name, limit, known)).children;
344
479
  }
345
480
  }
346
481
  node.children.push(child);
@@ -8,6 +8,10 @@
8
8
  * parent directory. The matched character positions are returned so the client
9
9
  * can highlight them; the palette never sees HTML.
10
10
  *
11
+ * The palette runs this on every keystroke over an index of thousands of paths,
12
+ * so ranking keeps only the best `limit` candidates instead of sorting them all:
13
+ * the highlighted columns are computed for those winners and no others.
14
+ *
11
15
  * Pure and dependency-free so it can be unit-tested without a DOM.
12
16
  */
13
17
 
@@ -24,15 +28,30 @@ export interface FuzzyResult {
24
28
  /** Queries longer than this are treated as no match. */
25
29
  const MAX_QUERY_LENGTH = 256;
26
30
 
27
- /** Score one path against a query; `undefined` when it is not a subsequence. */
28
- export function fuzzyMatch(query: string, text: string): FuzzyMatch | undefined {
29
- if (query === "") return { score: 0, positions: [] };
31
+ /** A candidate while it is still unranked: a path and its score. */
32
+ interface Candidate {
33
+ path: string;
34
+ score: number;
35
+ }
36
+
37
+ /** A scan's result; `positions` is collected only when the caller asked for it. */
38
+ interface Scan {
39
+ score: number;
40
+ positions?: number[];
41
+ }
42
+
43
+ /**
44
+ * The one subsequence scan. `collect` fills the matched columns, which the
45
+ * ranking pass does not need and allocates an array for on every candidate.
46
+ */
47
+ function scan(query: string, text: string, collect: boolean): Scan | undefined {
48
+ if (query === "") return collect ? { score: 0, positions: [] } : { score: 0 };
30
49
  if (query.length > MAX_QUERY_LENGTH) return undefined;
31
50
 
32
51
  const lowerQuery = query.toLowerCase();
33
52
  const lowerText = text.toLowerCase();
34
53
  const baseStart = text.lastIndexOf("/") + 1;
35
- const positions: number[] = [];
54
+ const positions: number[] | undefined = collect ? [] : undefined;
36
55
  let score = 0;
37
56
  let cursor = 0;
38
57
  let previous = -2;
@@ -40,7 +59,7 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch | undefined
40
59
  for (let index = 0; index < lowerQuery.length; index++) {
41
60
  const found = lowerText.indexOf(lowerQuery[index], cursor);
42
61
  if (found === -1) return undefined;
43
- positions.push(found);
62
+ positions?.push(found);
44
63
 
45
64
  let bonus = 1;
46
65
  if (text[found] === query[index]) bonus += 1;
@@ -55,21 +74,56 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch | undefined
55
74
  previous = found;
56
75
  cursor = found + 1;
57
76
  }
58
- return { score, positions };
77
+ return positions ? { score, positions } : { score };
78
+ }
79
+
80
+ /** Score one path against a query; `undefined` when it is not a subsequence. */
81
+ export function fuzzyMatch(query: string, text: string): FuzzyMatch | undefined {
82
+ const scanned = scan(query, text, true);
83
+ return scanned ? { score: scanned.score, positions: scanned.positions ?? [] } : undefined;
84
+ }
85
+
86
+ /**
87
+ * The palette's one total order: the higher score first, then the shorter path
88
+ * (a basename match beats a name buried in a directory), then the path itself so
89
+ * two candidates never compare equal.
90
+ */
91
+ function compare(a: Candidate, b: Candidate): number {
92
+ if (a.score !== b.score) return b.score - a.score;
93
+ if (a.path.length !== b.path.length) return a.path.length - b.path.length;
94
+ return a.path.localeCompare(b.path);
95
+ }
96
+
97
+ /** Keep `candidate` in `window` — sorted by {@link compare}, never longer than `limit`. */
98
+ function keepBest(window: Candidate[], candidate: Candidate, limit: number): void {
99
+ let low = 0;
100
+ let high = window.length;
101
+ while (low < high) {
102
+ const middle = (low + high) >> 1;
103
+ if (compare(window[middle], candidate) <= 0) low = middle + 1;
104
+ else high = middle;
105
+ }
106
+ if (low >= limit) return;
107
+ window.splice(low, 0, candidate);
108
+ if (window.length > limit) window.pop();
59
109
  }
60
110
 
61
111
  /**
62
112
  * Rank `paths` against `query` and return the best `limit` matches. An empty
63
113
  * query returns nothing: the palette shows the index size instead.
114
+ *
115
+ * The result is the same list a full sort would produce — `keepBest` inserts on
116
+ * the same order — but the work is bounded by `limit` rather than by the index,
117
+ * and the matched columns are computed for the winners only.
64
118
  */
65
119
  export function fuzzySearch(query: string, paths: readonly string[], limit: number): FuzzyResult[] {
66
120
  const trimmed = query.trim();
67
121
  if (trimmed === "" || limit <= 0) return [];
68
- const scored: Array<FuzzyResult & { score: number }> = [];
122
+ const window: Candidate[] = [];
69
123
  for (const path of paths) {
70
- const match = fuzzyMatch(trimmed, path);
71
- if (match) scored.push({ path, positions: match.positions, score: match.score });
124
+ const scored = scan(trimmed, path, false);
125
+ if (scored === undefined) continue;
126
+ keepBest(window, { path, score: scored.score }, limit);
72
127
  }
73
- scored.sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path));
74
- return scored.slice(0, limit).map(({ path, positions }) => ({ path, positions }));
128
+ return window.map(({ path }) => ({ path, positions: fuzzyMatch(trimmed, path)?.positions ?? [] }));
75
129
  }