@gavin-hu/my-pi-agent 0.19.0 → 0.20.0
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 +17 -11
- package/README.zh-CN.md +13 -8
- package/docs/extensions/README.md +1 -1
- package/docs/extensions/board.md +286 -123
- package/docs/extensions/bytefly.md +381 -223
- package/docs/extensions/file-browse.md +14 -7
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +56 -11
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +45 -17
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +23 -5
- package/extensions/board/format.ts +107 -9
- package/extensions/board/join.ts +1 -1
- package/extensions/board/page.ts +170 -94
- package/extensions/board/result.ts +9 -0
- package/extensions/board/schema.ts +22 -7
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +1 -0
- package/extensions/board/tools.ts +186 -21
- package/extensions/board/transport/router.ts +27 -108
- package/extensions/board/transport/server.ts +18 -29
- package/extensions/board/types.ts +67 -0
- package/extensions/board/view/client.ts +23 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +5 -171
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +48 -88
- package/extensions/board/view/render.ts +213 -190
- package/extensions/board/view/styles.ts +65 -48
- package/extensions/board/view/team.ts +63 -41
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -8
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +117 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/panel/commands.ts +28 -1
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings.ts +72 -23
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/check/commands.ts +2 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +8 -547
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/transport/router.ts +0 -2
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +6 -7
- package/extensions/file-browse/view/markdown.ts +11 -13
- package/extensions/file-browse/view/raw-html.ts +18 -13
- package/extensions/job/runtime.ts +4 -4
- package/extensions/plan/feedback.ts +40 -16
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +17 -1
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +31 -2
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +10 -1
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +89 -0
- package/extensions/plan/web-chrome.ts +3 -5
- package/extensions/plan/web-client.ts +179 -330
- package/extensions/plan/web-collab.ts +3 -3
- package/extensions/plan/web-comments.ts +18 -8
- package/extensions/plan/web-composer.ts +80 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +134 -0
- package/extensions/plan/web-outline.ts +192 -0
- package/extensions/plan/web-page.ts +4 -1
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +194 -0
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/session-bus/rows.ts +7 -3
- package/extensions/session-bus/runtime.ts +100 -38
- package/extensions/session-bus/store.ts +14 -2
- package/extensions/session-bus/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +41 -8
- package/extensions/team/format.ts +14 -0
- package/extensions/team/index.ts +2 -1
- package/extensions/team/tools.ts +30 -9
- package/extensions/team/types.ts +17 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/wechat/bridge.ts +43 -172
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/worktree/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +15 -285
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +6 -5
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +86 -32
- package/lib/board/round-text.ts +11 -2
- package/lib/board/round.ts +25 -20
- package/lib/board/rules.ts +144 -11
- package/lib/board/store.ts +239 -52
- package/lib/board/view.ts +94 -10
- package/lib/format.ts +22 -0
- package/lib/http/serve.ts +3 -0
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +14 -1
- package/lib/session-roster.ts +74 -3
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/glyphs.ts +2 -3
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -0
- package/package.json +1 -1
- package/extensions/board/view/actions.ts +0 -306
- package/extensions/board/writes.ts +0 -99
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/job/process.ts +0 -35
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { resolveEffectiveCwd } from "../../lib/env.ts";
|
|
13
|
+
import { planModeRefusal } from "../../lib/plan-entry.ts";
|
|
13
14
|
import { isPlanModeEnabled } from "../../lib/plan-state.ts";
|
|
14
15
|
import { withRailsSuppressed } from "../../lib/rails.ts";
|
|
15
16
|
import { formatCheckReport, formatCheckSummary } from "./format.ts";
|
|
@@ -107,7 +108,7 @@ export function registerCheckCommand(pi: ExtensionAPI, runtime: CheckRuntime): v
|
|
|
107
108
|
},
|
|
108
109
|
handler: async (args, ctx) => {
|
|
109
110
|
if (isPlanModeEnabled(ctx.sessionManager.getBranch())) {
|
|
110
|
-
ctx.ui.notify("
|
|
111
|
+
ctx.ui.notify(planModeRefusal("running checks"), "warning");
|
|
111
112
|
return;
|
|
112
113
|
}
|
|
113
114
|
const target = parseTarget(args, ctx);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a file's kind is, from its name and — for an extensionless one — a byte sample.
|
|
3
|
+
*
|
|
4
|
+
* Every surface that names a kind (the listing's rows, the tree, a preview's routing) asks
|
|
5
|
+
* here, so "is this an image" has one answer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { basename, extname } from "node:path";
|
|
9
|
+
|
|
10
|
+
export type EntryKind = "directory" | "text" | "image" | "binary";
|
|
11
|
+
|
|
12
|
+
/** Image extension (no dot) to MIME type; the single source for image classification. */
|
|
13
|
+
const IMAGE_MIME: Record<string, string> = {
|
|
14
|
+
png: "image/png",
|
|
15
|
+
jpg: "image/jpeg",
|
|
16
|
+
jpeg: "image/jpeg",
|
|
17
|
+
gif: "image/gif",
|
|
18
|
+
webp: "image/webp",
|
|
19
|
+
svg: "image/svg+xml",
|
|
20
|
+
bmp: "image/bmp",
|
|
21
|
+
ico: "image/x-icon",
|
|
22
|
+
avif: "image/avif",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Image extensions without a leading dot. */
|
|
26
|
+
export const IMAGE_EXTENSIONS: ReadonlySet<string> = new Set(Object.keys(IMAGE_MIME));
|
|
27
|
+
|
|
28
|
+
const MARKDOWN_EXTS = new Set([".md", ".markdown", ".mdown", ".mkd", ".mkdn"]);
|
|
29
|
+
const TEXT_EXTS = new Set([
|
|
30
|
+
".txt",
|
|
31
|
+
".md",
|
|
32
|
+
".markdown",
|
|
33
|
+
".rst",
|
|
34
|
+
".adoc",
|
|
35
|
+
".org",
|
|
36
|
+
".log",
|
|
37
|
+
".json",
|
|
38
|
+
".jsonc",
|
|
39
|
+
".json5",
|
|
40
|
+
".yaml",
|
|
41
|
+
".yml",
|
|
42
|
+
".toml",
|
|
43
|
+
".ini",
|
|
44
|
+
".cfg",
|
|
45
|
+
".conf",
|
|
46
|
+
".xml",
|
|
47
|
+
".csv",
|
|
48
|
+
".tsv",
|
|
49
|
+
".ts",
|
|
50
|
+
".tsx",
|
|
51
|
+
".js",
|
|
52
|
+
".jsx",
|
|
53
|
+
".mjs",
|
|
54
|
+
".cjs",
|
|
55
|
+
".py",
|
|
56
|
+
".go",
|
|
57
|
+
".rs",
|
|
58
|
+
".java",
|
|
59
|
+
".c",
|
|
60
|
+
".h",
|
|
61
|
+
".cc",
|
|
62
|
+
".cpp",
|
|
63
|
+
".hpp",
|
|
64
|
+
".cs",
|
|
65
|
+
".rb",
|
|
66
|
+
".php",
|
|
67
|
+
".sh",
|
|
68
|
+
".bash",
|
|
69
|
+
".zsh",
|
|
70
|
+
".ps1",
|
|
71
|
+
".swift",
|
|
72
|
+
".kt",
|
|
73
|
+
".kts",
|
|
74
|
+
".scala",
|
|
75
|
+
".lua",
|
|
76
|
+
".r",
|
|
77
|
+
".pl",
|
|
78
|
+
".sql",
|
|
79
|
+
".css",
|
|
80
|
+
".scss",
|
|
81
|
+
".sass",
|
|
82
|
+
".less",
|
|
83
|
+
".html",
|
|
84
|
+
".htm",
|
|
85
|
+
".vue",
|
|
86
|
+
".svelte",
|
|
87
|
+
".astro",
|
|
88
|
+
]);
|
|
89
|
+
const TEXT_NAMES = new Set([
|
|
90
|
+
"license",
|
|
91
|
+
"licence",
|
|
92
|
+
"makefile",
|
|
93
|
+
"dockerfile",
|
|
94
|
+
"readme",
|
|
95
|
+
"changelog",
|
|
96
|
+
"authors",
|
|
97
|
+
"notice",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
export function isImageName(name: string): boolean {
|
|
101
|
+
return IMAGE_EXTENSIONS.has(extname(name).slice(1).toLowerCase());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whether the name has a Markdown extension. */
|
|
105
|
+
export function isMarkdownName(name: string): boolean {
|
|
106
|
+
return MARKDOWN_EXTS.has(extname(name).toLowerCase());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Extension-based classification for listings (no file contents read). */
|
|
110
|
+
export function classifyByName(name: string): EntryKind {
|
|
111
|
+
const ext = extname(name).toLowerCase();
|
|
112
|
+
if (IMAGE_EXTENSIONS.has(ext.slice(1))) return "image";
|
|
113
|
+
if (TEXT_EXTS.has(ext)) return "text";
|
|
114
|
+
const lower = basename(name).toLowerCase();
|
|
115
|
+
if (TEXT_NAMES.has(lower)) return "text";
|
|
116
|
+
if (!ext && name.startsWith(".")) return "text";
|
|
117
|
+
return "binary";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True when the sample looks like binary data (NUL or many control bytes). */
|
|
121
|
+
export function isProbablyText(sample: Buffer): boolean {
|
|
122
|
+
if (sample.length === 0) return true;
|
|
123
|
+
if (sample.includes(0)) return false;
|
|
124
|
+
let suspicious = 0;
|
|
125
|
+
for (const byte of sample) {
|
|
126
|
+
if (byte === 9 || byte === 10 || byte === 13 || byte === 12 || byte === 8) continue;
|
|
127
|
+
if (byte < 32 || byte === 127) suspicious++;
|
|
128
|
+
}
|
|
129
|
+
return suspicious / sample.length < 0.3;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** MIME type for a name; text is always served as plain text. */
|
|
133
|
+
export function contentTypeFor(name: string): string {
|
|
134
|
+
const ext = extname(name).slice(1).toLowerCase();
|
|
135
|
+
return IMAGE_MIME[ext] ?? "application/octet-stream";
|
|
136
|
+
}
|
|
@@ -1,551 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Filesystem reads for the browse extension: classification,
|
|
3
|
-
* listings, capped text reads, and the small sidebar tree. All paths are
|
|
4
|
-
* absolute and already vetted by `paths.ts`; this module never touches the raw
|
|
5
|
-
* request URL.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { Dirent, Stats } from "node:fs";
|
|
9
|
-
import { readFile, readdir, stat, lstat } from "node:fs/promises";
|
|
10
|
-
import { basename, extname, join } from "node:path";
|
|
11
|
-
import { mapConcurrent } from "../../../lib/pool.ts";
|
|
12
|
-
import { HttpError } from "./paths.ts";
|
|
13
|
-
|
|
14
|
-
export type EntryKind = "directory" | "text" | "image" | "binary";
|
|
15
|
-
|
|
16
|
-
/** Image extension (no dot) to MIME type; the single source for image classification. */
|
|
17
|
-
const IMAGE_MIME: Record<string, string> = {
|
|
18
|
-
png: "image/png",
|
|
19
|
-
jpg: "image/jpeg",
|
|
20
|
-
jpeg: "image/jpeg",
|
|
21
|
-
gif: "image/gif",
|
|
22
|
-
webp: "image/webp",
|
|
23
|
-
svg: "image/svg+xml",
|
|
24
|
-
bmp: "image/bmp",
|
|
25
|
-
ico: "image/x-icon",
|
|
26
|
-
avif: "image/avif",
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
/** Image extensions without a leading dot. */
|
|
30
|
-
export const IMAGE_EXTENSIONS: ReadonlySet<string> = new Set(Object.keys(IMAGE_MIME));
|
|
31
|
-
|
|
32
|
-
const MARKDOWN_EXTS = new Set([".md", ".markdown", ".mdown", ".mkd", ".mkdn"]);
|
|
33
|
-
const TEXT_EXTS = new Set([
|
|
34
|
-
".txt",
|
|
35
|
-
".md",
|
|
36
|
-
".markdown",
|
|
37
|
-
".rst",
|
|
38
|
-
".adoc",
|
|
39
|
-
".org",
|
|
40
|
-
".log",
|
|
41
|
-
".json",
|
|
42
|
-
".jsonc",
|
|
43
|
-
".json5",
|
|
44
|
-
".yaml",
|
|
45
|
-
".yml",
|
|
46
|
-
".toml",
|
|
47
|
-
".ini",
|
|
48
|
-
".cfg",
|
|
49
|
-
".conf",
|
|
50
|
-
".xml",
|
|
51
|
-
".csv",
|
|
52
|
-
".tsv",
|
|
53
|
-
".ts",
|
|
54
|
-
".tsx",
|
|
55
|
-
".js",
|
|
56
|
-
".jsx",
|
|
57
|
-
".mjs",
|
|
58
|
-
".cjs",
|
|
59
|
-
".py",
|
|
60
|
-
".go",
|
|
61
|
-
".rs",
|
|
62
|
-
".java",
|
|
63
|
-
".c",
|
|
64
|
-
".h",
|
|
65
|
-
".cc",
|
|
66
|
-
".cpp",
|
|
67
|
-
".hpp",
|
|
68
|
-
".cs",
|
|
69
|
-
".rb",
|
|
70
|
-
".php",
|
|
71
|
-
".sh",
|
|
72
|
-
".bash",
|
|
73
|
-
".zsh",
|
|
74
|
-
".ps1",
|
|
75
|
-
".swift",
|
|
76
|
-
".kt",
|
|
77
|
-
".kts",
|
|
78
|
-
".scala",
|
|
79
|
-
".lua",
|
|
80
|
-
".r",
|
|
81
|
-
".pl",
|
|
82
|
-
".sql",
|
|
83
|
-
".css",
|
|
84
|
-
".scss",
|
|
85
|
-
".sass",
|
|
86
|
-
".less",
|
|
87
|
-
".html",
|
|
88
|
-
".htm",
|
|
89
|
-
".vue",
|
|
90
|
-
".svelte",
|
|
91
|
-
".astro",
|
|
92
|
-
]);
|
|
93
|
-
const TEXT_NAMES = new Set([
|
|
94
|
-
"license",
|
|
95
|
-
"licence",
|
|
96
|
-
"makefile",
|
|
97
|
-
"dockerfile",
|
|
98
|
-
"readme",
|
|
99
|
-
"changelog",
|
|
100
|
-
"authors",
|
|
101
|
-
"notice",
|
|
102
|
-
]);
|
|
103
|
-
|
|
104
|
-
export interface DirEntry {
|
|
105
|
-
name: string;
|
|
106
|
-
/** Root-relative posix path. */
|
|
107
|
-
rel: string;
|
|
108
|
-
isDir: boolean;
|
|
109
|
-
isSymlink: boolean;
|
|
110
|
-
broken: boolean;
|
|
111
|
-
size: number;
|
|
112
|
-
mtimeMs: number;
|
|
113
|
-
kind: EntryKind;
|
|
114
|
-
isImage: boolean;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export interface Listing {
|
|
118
|
-
entries: DirEntry[];
|
|
119
|
-
total: number;
|
|
120
|
-
truncated: boolean;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export interface FileMeta {
|
|
124
|
-
size: number;
|
|
125
|
-
mtimeMs: number;
|
|
126
|
-
mime: string;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export type FileView =
|
|
130
|
-
| (FileMeta & { kind: "text"; lines: string[]; truncated: boolean; totalLines: number })
|
|
131
|
-
| (FileMeta & { kind: "markdown"; lines: string[]; truncated: boolean; totalLines: number })
|
|
132
|
-
| (FileMeta & { kind: "image" })
|
|
133
|
-
| (FileMeta & { kind: "binary" })
|
|
134
|
-
| (FileMeta & { kind: "tooLarge" });
|
|
135
|
-
|
|
136
|
-
/** Whether the name has an image extension. */
|
|
137
|
-
export function isImageName(name: string): boolean {
|
|
138
|
-
return IMAGE_EXTENSIONS.has(extname(name).slice(1).toLowerCase());
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Whether the name has a Markdown extension. */
|
|
142
|
-
export function isMarkdownName(name: string): boolean {
|
|
143
|
-
return MARKDOWN_EXTS.has(extname(name).toLowerCase());
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Extension-based classification for listings (no file contents read). */
|
|
147
|
-
export function classifyByName(name: string): EntryKind {
|
|
148
|
-
const ext = extname(name).toLowerCase();
|
|
149
|
-
if (IMAGE_EXTENSIONS.has(ext.slice(1))) return "image";
|
|
150
|
-
if (TEXT_EXTS.has(ext)) return "text";
|
|
151
|
-
const lower = basename(name).toLowerCase();
|
|
152
|
-
if (TEXT_NAMES.has(lower)) return "text";
|
|
153
|
-
if (!ext && name.startsWith(".")) return "text";
|
|
154
|
-
return "binary";
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** True when the sample looks like binary data (NUL or many control bytes). */
|
|
158
|
-
export function isProbablyText(sample: Buffer): boolean {
|
|
159
|
-
if (sample.length === 0) return true;
|
|
160
|
-
if (sample.includes(0)) return false;
|
|
161
|
-
let suspicious = 0;
|
|
162
|
-
for (const byte of sample) {
|
|
163
|
-
if (byte === 9 || byte === 10 || byte === 13 || byte === 12 || byte === 8) continue;
|
|
164
|
-
if (byte < 32 || byte === 127) suspicious++;
|
|
165
|
-
}
|
|
166
|
-
return suspicious / sample.length < 0.3;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** MIME type for a name; text is always served as plain text. */
|
|
170
|
-
export function contentTypeFor(name: string): string {
|
|
171
|
-
const ext = extname(name).slice(1).toLowerCase();
|
|
172
|
-
return IMAGE_MIME[ext] ?? "application/octet-stream";
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function toAbs(root: string, rel: string): string {
|
|
176
|
-
return rel ? join(root, ...rel.split("/")) : root;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** The `Dirent` fields a listing reads; a test's fake need not be a real `Dirent`. */
|
|
180
|
-
export interface DirectoryEntryInfo {
|
|
181
|
-
name: string;
|
|
182
|
-
isDirectory(): boolean;
|
|
183
|
-
isSymbolicLink(): boolean;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** The `Stats` fields a listing reads. */
|
|
187
|
-
export interface DirectoryStats {
|
|
188
|
-
size: number;
|
|
189
|
-
mtimeMs: number;
|
|
190
|
-
isDirectory(): boolean;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* The three filesystem reads a listing makes.
|
|
195
|
-
*
|
|
196
|
-
* Injected so a test can observe them: the interesting property — a directory is
|
|
197
|
-
* read once per entry, not once per entry *and* once per row drawn — is a count,
|
|
198
|
-
* not a value, and a wall-clock assertion would be flaky on a loaded machine.
|
|
199
|
-
*/
|
|
200
|
-
export interface DirectoryFs {
|
|
201
|
-
readdir(path: string): Promise<DirectoryEntryInfo[]>;
|
|
202
|
-
lstat(path: string): Promise<DirectoryStats>;
|
|
203
|
-
stat(path: string): Promise<DirectoryStats>;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** Production reads; a symlink's size and kind come from the target. */
|
|
207
|
-
const NODE_DIRECTORY_FS: DirectoryFs = {
|
|
208
|
-
readdir: (path) => readdir(path, { withFileTypes: true }),
|
|
209
|
-
lstat: (path) => lstat(path),
|
|
210
|
-
stat: (path) => stat(path),
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
/** Reads in flight per listing: enough to hide latency, bounded against a burst. */
|
|
214
|
-
const LISTING_CONCURRENCY = 32;
|
|
215
|
-
|
|
216
|
-
/** A row while it is still missing its metadata. */
|
|
217
|
-
interface Row {
|
|
218
|
-
name: string;
|
|
219
|
-
rel: string;
|
|
220
|
-
isDir: boolean;
|
|
221
|
-
isSymlink: boolean;
|
|
222
|
-
broken: boolean;
|
|
223
|
-
size: number;
|
|
224
|
-
mtimeMs: number;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Resolve one symlink: the link's own stat, then the target's.
|
|
2
|
+
* Filesystem reads for the browse extension: classification, listing, tree, and index.
|
|
229
3
|
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
4
|
+
* Four jobs that a caller wants as one surface, so they are four modules behind this
|
|
5
|
+
* barrel rather than one 551-line module named after none of them. All paths are absolute
|
|
6
|
+
* and already vetted by `paths.ts`; nothing here touches the raw request URL.
|
|
232
7
|
*/
|
|
233
|
-
async function resolveSymlink(fs: DirectoryFs, absDir: string, row: Row): Promise<void> {
|
|
234
|
-
const abs = join(absDir, row.name);
|
|
235
|
-
try {
|
|
236
|
-
const info = await fs.lstat(abs);
|
|
237
|
-
row.size = info.size;
|
|
238
|
-
row.mtimeMs = info.mtimeMs;
|
|
239
|
-
} catch {
|
|
240
|
-
// Vanished between readdir and lstat: the target read decides below.
|
|
241
|
-
}
|
|
242
|
-
try {
|
|
243
|
-
const target = await fs.stat(abs);
|
|
244
|
-
row.isDir = target.isDirectory();
|
|
245
|
-
row.size = target.size;
|
|
246
|
-
row.mtimeMs = target.mtimeMs;
|
|
247
|
-
} catch {
|
|
248
|
-
row.broken = true;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* List a directory, sorted (directories first, then case-insensitive name).
|
|
254
|
-
*
|
|
255
|
-
* A listing is latency per entry, not work per entry, so it is read in three
|
|
256
|
-
* passes instead of one entry at a time:
|
|
257
|
-
*
|
|
258
|
-
* 1. `readdir`, whose dirents already say whether each name is a directory;
|
|
259
|
-
* 2. the symlinks only, resolved through the pool, because their kind is the
|
|
260
|
-
* target's and the sort needs it;
|
|
261
|
-
* 3. the kept rows only, `stat`ed through the pool — so a directory of three
|
|
262
|
-
* thousand names pays for the two thousand it draws, not for every name, and
|
|
263
|
-
* pays them concurrently instead of in turn.
|
|
264
|
-
*
|
|
265
|
-
* `total` is still the directory's full entry count and `truncated` its `> limit`
|
|
266
|
-
* flip, so the page can say how many were dropped. Ties keep `readdir` order:
|
|
267
|
-
* `sort` is stable and the rows arrive unsorted.
|
|
268
|
-
*/
|
|
269
|
-
export async function listDirectory(
|
|
270
|
-
root: string,
|
|
271
|
-
relDir: string,
|
|
272
|
-
limit: number,
|
|
273
|
-
fs: DirectoryFs = NODE_DIRECTORY_FS,
|
|
274
|
-
): Promise<Listing> {
|
|
275
|
-
const absDir = toAbs(root, relDir);
|
|
276
|
-
let dirents: DirectoryEntryInfo[];
|
|
277
|
-
try {
|
|
278
|
-
dirents = await fs.readdir(absDir);
|
|
279
|
-
} catch {
|
|
280
|
-
throw new HttpError(404, "That folder could not be read.");
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const links: Row[] = [];
|
|
284
|
-
const rows: Row[] = dirents.map((dirent) => {
|
|
285
|
-
const row: Row = {
|
|
286
|
-
name: dirent.name,
|
|
287
|
-
rel: relDir ? `${relDir}/${dirent.name}` : dirent.name,
|
|
288
|
-
isDir: dirent.isDirectory(),
|
|
289
|
-
isSymlink: dirent.isSymbolicLink(),
|
|
290
|
-
broken: false,
|
|
291
|
-
size: 0,
|
|
292
|
-
mtimeMs: 0,
|
|
293
|
-
};
|
|
294
|
-
if (row.isSymlink) links.push(row);
|
|
295
|
-
return row;
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
await mapConcurrent(links, LISTING_CONCURRENCY, (row) => resolveSymlink(fs, absDir, row));
|
|
299
|
-
|
|
300
|
-
// Directories first, then case-insensitive name. The sort runs before the cap,
|
|
301
|
-
// so the rows that survive it are the rows that would have been drawn.
|
|
302
|
-
rows.sort((a, b) => {
|
|
303
|
-
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
304
|
-
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
const total = rows.length;
|
|
308
|
-
const truncated = total > limit;
|
|
309
|
-
const kept = truncated ? rows.slice(0, limit) : rows;
|
|
310
|
-
|
|
311
|
-
await mapConcurrent(
|
|
312
|
-
kept.filter((row) => !row.isSymlink),
|
|
313
|
-
LISTING_CONCURRENCY,
|
|
314
|
-
async (row) => {
|
|
315
|
-
try {
|
|
316
|
-
const info = await fs.stat(join(absDir, row.name));
|
|
317
|
-
row.size = info.size;
|
|
318
|
-
row.mtimeMs = info.mtimeMs;
|
|
319
|
-
} catch {
|
|
320
|
-
// Directory entry vanished between readdir and stat.
|
|
321
|
-
}
|
|
322
|
-
},
|
|
323
|
-
);
|
|
324
|
-
|
|
325
|
-
const entries: DirEntry[] = kept.map((row) => {
|
|
326
|
-
const kind: EntryKind = row.isDir ? "directory" : classifyByName(row.name);
|
|
327
|
-
return {
|
|
328
|
-
name: row.name,
|
|
329
|
-
rel: row.rel,
|
|
330
|
-
isDir: row.isDir,
|
|
331
|
-
isSymlink: row.isSymlink,
|
|
332
|
-
broken: row.broken,
|
|
333
|
-
size: row.size,
|
|
334
|
-
mtimeMs: row.mtimeMs,
|
|
335
|
-
kind,
|
|
336
|
-
isImage: !row.isDir && kind === "image",
|
|
337
|
-
};
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
return { entries, total, truncated };
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/** Read a file into a renderable view, honouring the size and line caps. */
|
|
344
|
-
export async function readFileView(
|
|
345
|
-
root: string,
|
|
346
|
-
rel: string,
|
|
347
|
-
maxFileBytes: number,
|
|
348
|
-
maxTextLines: number,
|
|
349
|
-
markdownEnabled = true,
|
|
350
|
-
): Promise<FileView> {
|
|
351
|
-
const abs = toAbs(root, rel);
|
|
352
|
-
let info: Stats;
|
|
353
|
-
try {
|
|
354
|
-
info = await stat(abs);
|
|
355
|
-
} catch {
|
|
356
|
-
throw new HttpError(404, "That file could not be read.");
|
|
357
|
-
}
|
|
358
|
-
if (info.isDirectory()) throw new HttpError(404, "That path is a folder.");
|
|
359
|
-
|
|
360
|
-
const meta: FileMeta = { size: info.size, mtimeMs: info.mtimeMs, mime: contentTypeFor(rel) };
|
|
361
|
-
if (isImageName(rel)) return { kind: "image", ...meta };
|
|
362
|
-
if (info.size > maxFileBytes) return { kind: "tooLarge", ...meta };
|
|
363
|
-
|
|
364
|
-
let buffer: Buffer;
|
|
365
|
-
try {
|
|
366
|
-
buffer = await readFile(abs);
|
|
367
|
-
} catch {
|
|
368
|
-
throw new HttpError(404, "That file could not be read.");
|
|
369
|
-
}
|
|
370
|
-
if (!isProbablyText(buffer)) return { kind: "binary", ...meta };
|
|
371
|
-
|
|
372
|
-
const allLines = buffer.toString("utf-8").split(/\r\n|\r|\n/);
|
|
373
|
-
const truncated = allLines.length > maxTextLines;
|
|
374
|
-
const lines = truncated ? allLines.slice(0, maxTextLines) : allLines;
|
|
375
|
-
const kind = markdownEnabled && isMarkdownName(rel) ? "markdown" : "text";
|
|
376
|
-
if (kind === "markdown") {
|
|
377
|
-
return { kind: "markdown", ...meta, lines, truncated, totalLines: allLines.length };
|
|
378
|
-
}
|
|
379
|
-
return { kind: "text", ...meta, lines, truncated, totalLines: allLines.length };
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
export interface TreeNode {
|
|
383
|
-
name: string;
|
|
384
|
-
rel: string;
|
|
385
|
-
isDir: boolean;
|
|
386
|
-
isImage: boolean;
|
|
387
|
-
/** True for a symlink whose target cannot be resolved. */
|
|
388
|
-
broken: boolean;
|
|
389
|
-
kind: EntryKind;
|
|
390
|
-
children: TreeNode[];
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* Build the sidebar tree: the current path's ancestors are expanded so the
|
|
395
|
-
* active item is always visible, and siblings are shown collapsed.
|
|
396
|
-
*/
|
|
397
|
-
export async function buildTree(root: string, currentRel: string, limit: number): Promise<TreeNode> {
|
|
398
|
-
return buildNode(root, "", currentRel, basename(root) || root, limit);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/** A directory page's two views of one directory. */
|
|
402
|
-
export interface DirectoryView {
|
|
403
|
-
/** The current directory's own rows, for the page's listing. */
|
|
404
|
-
listing: Listing;
|
|
405
|
-
/** The sidebar tree, with the current path expanded. */
|
|
406
|
-
tree: TreeNode;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* One walk for a directory page: the listing it draws, and the tree that shows
|
|
411
|
-
* where it sits.
|
|
412
|
-
*
|
|
413
|
-
* `listDirectory` is what decides whether the directory can be read at all, so it
|
|
414
|
-
* runs first and its 404 is the page's error. The walk then reuses that listing
|
|
415
|
-
* for the same directory instead of reading it a second time; the ancestors are
|
|
416
|
-
* still read once each, which is what the tree needs.
|
|
417
|
-
*/
|
|
418
|
-
export async function directoryView(root: string, currentRel: string, limit: number): Promise<DirectoryView> {
|
|
419
|
-
const listing = await listDirectory(root, currentRel, limit);
|
|
420
|
-
const tree = await buildNode(root, "", currentRel, basename(root) || root, limit, { rel: currentRel, listing });
|
|
421
|
-
return { listing, tree };
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
async function buildNode(
|
|
425
|
-
root: string,
|
|
426
|
-
relDir: string,
|
|
427
|
-
currentRel: string,
|
|
428
|
-
label: string,
|
|
429
|
-
limit: number,
|
|
430
|
-
/** A listing the caller already read for this directory, so it is not re-read. */
|
|
431
|
-
known?: { rel: string; listing: Listing },
|
|
432
|
-
): Promise<TreeNode> {
|
|
433
|
-
const node: TreeNode = {
|
|
434
|
-
name: label,
|
|
435
|
-
rel: relDir,
|
|
436
|
-
isDir: true,
|
|
437
|
-
isImage: false,
|
|
438
|
-
broken: false,
|
|
439
|
-
kind: "directory",
|
|
440
|
-
children: [],
|
|
441
|
-
};
|
|
442
|
-
let listing: Listing;
|
|
443
|
-
if (known && known.rel === relDir) {
|
|
444
|
-
listing = known.listing;
|
|
445
|
-
} else {
|
|
446
|
-
try {
|
|
447
|
-
listing = await listDirectory(root, relDir, limit);
|
|
448
|
-
} catch {
|
|
449
|
-
return node;
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
for (const entry of listing.entries) {
|
|
453
|
-
const child: TreeNode = {
|
|
454
|
-
name: entry.name,
|
|
455
|
-
rel: entry.rel,
|
|
456
|
-
isDir: entry.isDir,
|
|
457
|
-
isImage: entry.isImage,
|
|
458
|
-
broken: entry.broken,
|
|
459
|
-
kind: entry.kind,
|
|
460
|
-
children: [],
|
|
461
|
-
};
|
|
462
|
-
if (entry.isDir) {
|
|
463
|
-
const onPath = currentRel === entry.rel || currentRel.startsWith(`${entry.rel}/`);
|
|
464
|
-
if (onPath) {
|
|
465
|
-
child.children = (await buildNode(root, entry.rel, currentRel, entry.name, limit, known)).children;
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
node.children.push(child);
|
|
469
|
-
}
|
|
470
|
-
return node;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/** Directory names the quick-open index never walks. */
|
|
474
|
-
export const INDEX_IGNORED_DIRS = new Set([".git", "node_modules"]);
|
|
475
|
-
|
|
476
|
-
export interface FileIndex {
|
|
477
|
-
files: string[];
|
|
478
|
-
truncated: boolean;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Walk `root` and collect file paths for the quick-open palette. Symlinks and
|
|
483
|
-
* the ignored directories are skipped, and the walk stops at `limit` files so a
|
|
484
|
-
* large tree cannot exhaust memory or time. Unreadable directories are skipped.
|
|
485
|
-
*/
|
|
486
|
-
export async function buildFileIndex(root: string, limit: number): Promise<FileIndex> {
|
|
487
|
-
const files: string[] = [];
|
|
488
|
-
let truncated = false;
|
|
489
|
-
|
|
490
|
-
async function walk(absDir: string, relDir: string): Promise<void> {
|
|
491
|
-
let dirents: Dirent[];
|
|
492
|
-
try {
|
|
493
|
-
dirents = await readdir(absDir, { withFileTypes: true });
|
|
494
|
-
} catch {
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
dirents.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
498
|
-
for (const dirent of dirents) {
|
|
499
|
-
if (truncated) return;
|
|
500
|
-
if (dirent.isSymbolicLink()) continue;
|
|
501
|
-
const rel = relDir ? `${relDir}/${dirent.name}` : dirent.name;
|
|
502
|
-
if (dirent.isDirectory()) {
|
|
503
|
-
if (INDEX_IGNORED_DIRS.has(dirent.name)) continue;
|
|
504
|
-
await walk(join(absDir, dirent.name), rel);
|
|
505
|
-
} else if (dirent.isFile()) {
|
|
506
|
-
if (files.length >= limit) {
|
|
507
|
-
truncated = true;
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
files.push(rel);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
await walk(root, "");
|
|
516
|
-
files.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()) || a.localeCompare(b));
|
|
517
|
-
return { files, truncated };
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
export type FileIndexProvider = () => Promise<FileIndex>;
|
|
521
|
-
|
|
522
|
-
export interface FileIndexOptions {
|
|
523
|
-
/** How long a built index is reused, in ms (default 10000). */
|
|
524
|
-
ttlMs?: number;
|
|
525
|
-
/** Wall-clock seam; defaults to the real clock. */
|
|
526
|
-
now?: () => number;
|
|
527
|
-
}
|
|
528
8
|
|
|
529
|
-
|
|
530
|
-
export
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
let cached: FileIndex | undefined;
|
|
534
|
-
let checkedAt = 0;
|
|
535
|
-
let pending: Promise<FileIndex> | undefined;
|
|
536
|
-
return async () => {
|
|
537
|
-
const time = now();
|
|
538
|
-
if (cached && time - checkedAt < ttlMs) return cached;
|
|
539
|
-
if (pending) return pending;
|
|
540
|
-
pending = buildFileIndex(root, limit)
|
|
541
|
-
.then((index) => {
|
|
542
|
-
cached = index;
|
|
543
|
-
checkedAt = now();
|
|
544
|
-
return index;
|
|
545
|
-
})
|
|
546
|
-
.finally(() => {
|
|
547
|
-
pending = undefined;
|
|
548
|
-
});
|
|
549
|
-
return pending;
|
|
550
|
-
};
|
|
551
|
-
}
|
|
9
|
+
export * from "./classify.ts";
|
|
10
|
+
export * from "./index.ts";
|
|
11
|
+
export * from "./list.ts";
|
|
12
|
+
export * from "./tree.ts";
|