@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 +6 -8
- package/README.zh-CN.md +4 -5
- package/extensions/file-browse/README.md +61 -6
- package/extensions/file-browse/client.ts +3 -1
- package/extensions/file-browse/config.ts +15 -0
- package/extensions/file-browse/files.ts +183 -48
- package/extensions/file-browse/fuzzy.ts +65 -11
- package/extensions/file-browse/html.ts +78 -133
- package/extensions/file-browse/live-client.ts +158 -0
- package/extensions/file-browse/live.ts +222 -0
- package/extensions/file-browse/router.ts +33 -8
- package/extensions/file-browse/server.ts +66 -12
- package/extensions/file-browse/styles.ts +124 -0
- package/extensions/plan/web-router.ts +2 -12
- package/extensions/plan/web.ts +9 -3
- package/extensions/rewind/checklist.ts +10 -3
- package/extensions/rewind/git.ts +27 -14
- package/extensions/rewind/hunk-picker.ts +1 -1
- package/extensions/rewind/path-picker.ts +1 -1
- package/extensions/rewind/review-tui.ts +3 -3
- package/extensions/rewind/snapshot.ts +13 -4
- package/extensions/subagent/background-tui.ts +1 -1
- package/extensions/worktree/status.ts +14 -9
- package/lib/README.md +3 -2
- package/lib/git/read.ts +20 -0
- package/lib/http/headers.ts +20 -0
- package/lib/http/server.ts +22 -4
- package/lib/pool.ts +62 -0
- package/lib/tui/dock-screen.ts +12 -4
- package/package.json +1 -1
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:
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
98
|
-
do
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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),
|
|
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
|
-
| `
|
|
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),
|
|
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
|
-
/**
|
|
192
|
-
export
|
|
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:
|
|
289
|
+
let dirents: DirectoryEntryInfo[];
|
|
195
290
|
try {
|
|
196
|
-
dirents = await readdir(absDir
|
|
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
|
|
202
|
-
|
|
203
|
-
const
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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 =
|
|
320
|
+
const total = rows.length;
|
|
246
321
|
const truncated = total > limit;
|
|
247
|
-
|
|
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
|
-
|
|
326
|
-
listing =
|
|
327
|
-
}
|
|
328
|
-
|
|
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
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
|
122
|
+
const window: Candidate[] = [];
|
|
69
123
|
for (const path of paths) {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
124
|
+
const scored = scan(trimmed, path, false);
|
|
125
|
+
if (scored === undefined) continue;
|
|
126
|
+
keepBest(window, { path, score: scored.score }, limit);
|
|
72
127
|
}
|
|
73
|
-
|
|
74
|
-
return scored.slice(0, limit).map(({ path, positions }) => ({ path, positions }));
|
|
128
|
+
return window.map(({ path }) => ({ path, positions: fuzzyMatch(trimmed, path)?.positions ?? [] }));
|
|
75
129
|
}
|