@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 +19 -12
- package/README.zh-CN.md +15 -9
- package/docs/extensions/README.md +2 -1
- package/docs/extensions/board.md +329 -151
- package/docs/extensions/bytefly.md +427 -273
- package/docs/extensions/file-browse.md +46 -44
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +86 -24
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +56 -28
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +48 -17
- package/extensions/board/format.ts +107 -9
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +202 -156
- package/extensions/board/result.ts +10 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +31 -10
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +7 -5
- package/extensions/board/tools.ts +211 -34
- package/extensions/board/transport/router.ts +27 -110
- package/extensions/board/transport/server.ts +76 -56
- package/extensions/board/types.ts +57 -0
- package/extensions/board/view/client.ts +24 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +6 -178
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +60 -89
- package/extensions/board/view/render.ts +228 -200
- package/extensions/board/view/styles.ts +72 -49
- 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 -11
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +93 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/header/component.ts +4 -20
- 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/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- 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/index.ts +27 -48
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -6
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +78 -46
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +23 -26
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +27 -22
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/runtime.ts +4 -4
- package/extensions/job/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +88 -18
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +24 -2
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +43 -4
- 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/tools.ts +2 -2
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +160 -0
- package/extensions/plan/web-chrome.ts +7 -5
- package/extensions/plan/web-client.ts +271 -325
- package/extensions/plan/web-collab.ts +7 -7
- package/extensions/plan/web-comments.ts +57 -16
- package/extensions/plan/web-composer.ts +91 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +258 -0
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +212 -0
- package/extensions/plan/web-page.ts +59 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +160 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +201 -0
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +77 -130
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/serve/config.ts +37 -0
- package/extensions/serve/index.ts +71 -0
- package/extensions/serve/registry.ts +251 -0
- package/extensions/session-bus/join-request.ts +16 -9
- package/extensions/session-bus/result.ts +1 -27
- 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/tools.ts +5 -4
- 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 +44 -9
- 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/todo/tui.ts +21 -3
- 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 +9 -7
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +111 -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/session.ts +84 -18
- package/lib/board/store.ts +268 -55
- package/lib/board/view.ts +114 -24
- package/lib/bus-request.ts +83 -0
- package/lib/format.ts +22 -0
- package/lib/http/index.ts +11 -7
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +25 -0
- package/lib/http/serve.ts +170 -43
- package/lib/http/server.ts +3 -3
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +20 -13
- package/lib/policy.ts +14 -1
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-roster.ts +74 -3
- package/lib/session-tool.ts +37 -21
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +3 -6
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +149 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +106 -2
- package/lib/web/names.ts +42 -0
- package/package.json +3 -2
- package/extensions/board/config.ts +0 -36
- 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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory listings and single-file reads: what a folder holds, capped and sorted, and
|
|
3
|
+
* what one file's bytes are.
|
|
4
|
+
*
|
|
5
|
+
* The filesystem reads are injected (`DirectoryFs`) so a test can count them: the property
|
|
6
|
+
* worth holding is that a directory is read once per entry, not once per row drawn.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { lstat, readdir, readFile, stat } from "node:fs/promises";
|
|
11
|
+
import type { Stats } from "node:fs";
|
|
12
|
+
import { mapConcurrent } from "../../../lib/pool.ts";
|
|
13
|
+
import { HttpError } from "./paths.ts";
|
|
14
|
+
import {
|
|
15
|
+
classifyByName,
|
|
16
|
+
contentTypeFor,
|
|
17
|
+
type EntryKind,
|
|
18
|
+
isImageName,
|
|
19
|
+
isMarkdownName,
|
|
20
|
+
isProbablyText,
|
|
21
|
+
} from "./classify.ts";
|
|
22
|
+
|
|
23
|
+
export interface DirEntry {
|
|
24
|
+
name: string;
|
|
25
|
+
/** Root-relative posix path. */
|
|
26
|
+
rel: string;
|
|
27
|
+
isDir: boolean;
|
|
28
|
+
isSymlink: boolean;
|
|
29
|
+
broken: boolean;
|
|
30
|
+
size: number;
|
|
31
|
+
mtimeMs: number;
|
|
32
|
+
kind: EntryKind;
|
|
33
|
+
isImage: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Listing {
|
|
37
|
+
entries: DirEntry[];
|
|
38
|
+
total: number;
|
|
39
|
+
truncated: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FileMeta {
|
|
43
|
+
size: number;
|
|
44
|
+
mtimeMs: number;
|
|
45
|
+
mime: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type FileView =
|
|
49
|
+
| (FileMeta & { kind: "text"; lines: string[]; truncated: boolean; totalLines: number })
|
|
50
|
+
| (FileMeta & { kind: "markdown"; lines: string[]; truncated: boolean; totalLines: number })
|
|
51
|
+
| (FileMeta & { kind: "image" })
|
|
52
|
+
| (FileMeta & { kind: "binary" })
|
|
53
|
+
| (FileMeta & { kind: "tooLarge" });
|
|
54
|
+
|
|
55
|
+
/** Whether the name has an image extension. */
|
|
56
|
+
export function toAbs(root: string, rel: string): string {
|
|
57
|
+
return rel ? join(root, ...rel.split("/")) : root;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The `Dirent` fields a listing reads; a test's fake need not be a real `Dirent`. */
|
|
61
|
+
export interface DirectoryEntryInfo {
|
|
62
|
+
name: string;
|
|
63
|
+
isDirectory(): boolean;
|
|
64
|
+
isSymbolicLink(): boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The `Stats` fields a listing reads. */
|
|
68
|
+
export interface DirectoryStats {
|
|
69
|
+
size: number;
|
|
70
|
+
mtimeMs: number;
|
|
71
|
+
isDirectory(): boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The three filesystem reads a listing makes.
|
|
76
|
+
*
|
|
77
|
+
* Injected so a test can observe them: the interesting property — a directory is
|
|
78
|
+
* read once per entry, not once per entry *and* once per row drawn — is a count,
|
|
79
|
+
* not a value, and a wall-clock assertion would be flaky on a loaded machine.
|
|
80
|
+
*/
|
|
81
|
+
export interface DirectoryFs {
|
|
82
|
+
readdir(path: string): Promise<DirectoryEntryInfo[]>;
|
|
83
|
+
lstat(path: string): Promise<DirectoryStats>;
|
|
84
|
+
stat(path: string): Promise<DirectoryStats>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Production reads; a symlink's size and kind come from the target. */
|
|
88
|
+
const NODE_DIRECTORY_FS: DirectoryFs = {
|
|
89
|
+
readdir: (path) => readdir(path, { withFileTypes: true }),
|
|
90
|
+
lstat: (path) => lstat(path),
|
|
91
|
+
stat: (path) => stat(path),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Reads in flight per listing: enough to hide latency, bounded against a burst. */
|
|
95
|
+
const LISTING_CONCURRENCY = 32;
|
|
96
|
+
|
|
97
|
+
/** A row while it is still missing its metadata. */
|
|
98
|
+
interface Row {
|
|
99
|
+
name: string;
|
|
100
|
+
rel: string;
|
|
101
|
+
isDir: boolean;
|
|
102
|
+
isSymlink: boolean;
|
|
103
|
+
broken: boolean;
|
|
104
|
+
size: number;
|
|
105
|
+
mtimeMs: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve one symlink: the link's own stat, then the target's.
|
|
110
|
+
*
|
|
111
|
+
* A symlink's directory-ness is the target's, so this cannot wait until after
|
|
112
|
+
* the sort; it is also the only entry whose classification needs a read.
|
|
113
|
+
*/
|
|
114
|
+
async function resolveSymlink(fs: DirectoryFs, absDir: string, row: Row): Promise<void> {
|
|
115
|
+
const abs = join(absDir, row.name);
|
|
116
|
+
try {
|
|
117
|
+
const info = await fs.lstat(abs);
|
|
118
|
+
row.size = info.size;
|
|
119
|
+
row.mtimeMs = info.mtimeMs;
|
|
120
|
+
} catch {
|
|
121
|
+
// Vanished between readdir and lstat: the target read decides below.
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const target = await fs.stat(abs);
|
|
125
|
+
row.isDir = target.isDirectory();
|
|
126
|
+
row.size = target.size;
|
|
127
|
+
row.mtimeMs = target.mtimeMs;
|
|
128
|
+
} catch {
|
|
129
|
+
row.broken = true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* List a directory, sorted (directories first, then case-insensitive name).
|
|
135
|
+
*
|
|
136
|
+
* A listing is latency per entry, not work per entry, so it is read in three
|
|
137
|
+
* passes instead of one entry at a time:
|
|
138
|
+
*
|
|
139
|
+
* 1. `readdir`, whose dirents already say whether each name is a directory;
|
|
140
|
+
* 2. the symlinks only, resolved through the pool, because their kind is the
|
|
141
|
+
* target's and the sort needs it;
|
|
142
|
+
* 3. the kept rows only, `stat`ed through the pool — so a directory of three
|
|
143
|
+
* thousand names pays for the two thousand it draws, not for every name, and
|
|
144
|
+
* pays them concurrently instead of in turn.
|
|
145
|
+
*
|
|
146
|
+
* `total` is still the directory's full entry count and `truncated` its `> limit`
|
|
147
|
+
* flip, so the page can say how many were dropped. Ties keep `readdir` order:
|
|
148
|
+
* `sort` is stable and the rows arrive unsorted.
|
|
149
|
+
*/
|
|
150
|
+
export async function listDirectory(
|
|
151
|
+
root: string,
|
|
152
|
+
relDir: string,
|
|
153
|
+
limit: number,
|
|
154
|
+
fs: DirectoryFs = NODE_DIRECTORY_FS,
|
|
155
|
+
): Promise<Listing> {
|
|
156
|
+
const absDir = toAbs(root, relDir);
|
|
157
|
+
let dirents: DirectoryEntryInfo[];
|
|
158
|
+
try {
|
|
159
|
+
dirents = await fs.readdir(absDir);
|
|
160
|
+
} catch {
|
|
161
|
+
throw new HttpError(404, "That folder could not be read.");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const links: Row[] = [];
|
|
165
|
+
const rows: Row[] = dirents.map((dirent) => {
|
|
166
|
+
const row: Row = {
|
|
167
|
+
name: dirent.name,
|
|
168
|
+
rel: relDir ? `${relDir}/${dirent.name}` : dirent.name,
|
|
169
|
+
isDir: dirent.isDirectory(),
|
|
170
|
+
isSymlink: dirent.isSymbolicLink(),
|
|
171
|
+
broken: false,
|
|
172
|
+
size: 0,
|
|
173
|
+
mtimeMs: 0,
|
|
174
|
+
};
|
|
175
|
+
if (row.isSymlink) links.push(row);
|
|
176
|
+
return row;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await mapConcurrent(links, LISTING_CONCURRENCY, (row) => resolveSymlink(fs, absDir, row));
|
|
180
|
+
|
|
181
|
+
// Directories first, then case-insensitive name. The sort runs before the cap,
|
|
182
|
+
// so the rows that survive it are the rows that would have been drawn.
|
|
183
|
+
rows.sort((a, b) => {
|
|
184
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
185
|
+
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const total = rows.length;
|
|
189
|
+
const truncated = total > limit;
|
|
190
|
+
const kept = truncated ? rows.slice(0, limit) : rows;
|
|
191
|
+
|
|
192
|
+
await mapConcurrent(
|
|
193
|
+
kept.filter((row) => !row.isSymlink),
|
|
194
|
+
LISTING_CONCURRENCY,
|
|
195
|
+
async (row) => {
|
|
196
|
+
try {
|
|
197
|
+
const info = await fs.stat(join(absDir, row.name));
|
|
198
|
+
row.size = info.size;
|
|
199
|
+
row.mtimeMs = info.mtimeMs;
|
|
200
|
+
} catch {
|
|
201
|
+
// Directory entry vanished between readdir and stat.
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const entries: DirEntry[] = kept.map((row) => {
|
|
207
|
+
const kind: EntryKind = row.isDir ? "directory" : classifyByName(row.name);
|
|
208
|
+
return {
|
|
209
|
+
name: row.name,
|
|
210
|
+
rel: row.rel,
|
|
211
|
+
isDir: row.isDir,
|
|
212
|
+
isSymlink: row.isSymlink,
|
|
213
|
+
broken: row.broken,
|
|
214
|
+
size: row.size,
|
|
215
|
+
mtimeMs: row.mtimeMs,
|
|
216
|
+
kind,
|
|
217
|
+
isImage: !row.isDir && kind === "image",
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return { entries, total, truncated };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Read a file into a renderable view, honouring the size and line caps. */
|
|
225
|
+
export async function readFileView(
|
|
226
|
+
root: string,
|
|
227
|
+
rel: string,
|
|
228
|
+
maxFileBytes: number,
|
|
229
|
+
maxTextLines: number,
|
|
230
|
+
markdownEnabled = true,
|
|
231
|
+
): Promise<FileView> {
|
|
232
|
+
const abs = toAbs(root, rel);
|
|
233
|
+
let info: Stats;
|
|
234
|
+
try {
|
|
235
|
+
info = await stat(abs);
|
|
236
|
+
} catch {
|
|
237
|
+
throw new HttpError(404, "That file could not be read.");
|
|
238
|
+
}
|
|
239
|
+
if (info.isDirectory()) throw new HttpError(404, "That path is a folder.");
|
|
240
|
+
|
|
241
|
+
const meta: FileMeta = { size: info.size, mtimeMs: info.mtimeMs, mime: contentTypeFor(rel) };
|
|
242
|
+
if (isImageName(rel)) return { kind: "image", ...meta };
|
|
243
|
+
if (info.size > maxFileBytes) return { kind: "tooLarge", ...meta };
|
|
244
|
+
|
|
245
|
+
let buffer: Buffer;
|
|
246
|
+
try {
|
|
247
|
+
buffer = await readFile(abs);
|
|
248
|
+
} catch {
|
|
249
|
+
throw new HttpError(404, "That file could not be read.");
|
|
250
|
+
}
|
|
251
|
+
if (!isProbablyText(buffer)) return { kind: "binary", ...meta };
|
|
252
|
+
|
|
253
|
+
const allLines = buffer.toString("utf-8").split(/\r\n|\r|\n/);
|
|
254
|
+
const truncated = allLines.length > maxTextLines;
|
|
255
|
+
const lines = truncated ? allLines.slice(0, maxTextLines) : allLines;
|
|
256
|
+
const kind = markdownEnabled && isMarkdownName(rel) ? "markdown" : "text";
|
|
257
|
+
if (kind === "markdown") {
|
|
258
|
+
return { kind: "markdown", ...meta, lines, truncated, totalLines: allLines.length };
|
|
259
|
+
}
|
|
260
|
+
return { kind: "text", ...meta, lines, truncated, totalLines: allLines.length };
|
|
261
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar tree: the current path's ancestors expanded, siblings collapsed.
|
|
3
|
+
*
|
|
4
|
+
* Its rows are the same `DirEntry` a listing produces, so the tree and the listing cannot
|
|
5
|
+
* disagree about what one name is.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { basename } from "node:path";
|
|
9
|
+
import type { EntryKind } from "./classify.ts";
|
|
10
|
+
import { type Listing, listDirectory } from "./list.ts";
|
|
11
|
+
|
|
12
|
+
export interface TreeNode {
|
|
13
|
+
name: string;
|
|
14
|
+
rel: string;
|
|
15
|
+
isDir: boolean;
|
|
16
|
+
isImage: boolean;
|
|
17
|
+
/** True for a symlink whose target cannot be resolved. */
|
|
18
|
+
broken: boolean;
|
|
19
|
+
kind: EntryKind;
|
|
20
|
+
children: TreeNode[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build the sidebar tree: the current path's ancestors are expanded so the
|
|
25
|
+
* active item is always visible, and siblings are shown collapsed.
|
|
26
|
+
*/
|
|
27
|
+
export async function buildTree(root: string, currentRel: string, limit: number): Promise<TreeNode> {
|
|
28
|
+
return buildNode(root, "", currentRel, basename(root) || root, limit);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A directory page's two views of one directory. */
|
|
32
|
+
export interface DirectoryView {
|
|
33
|
+
/** The current directory's own rows, for the page's listing. */
|
|
34
|
+
listing: Listing;
|
|
35
|
+
/** The sidebar tree, with the current path expanded. */
|
|
36
|
+
tree: TreeNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One walk for a directory page: the listing it draws, and the tree that shows
|
|
41
|
+
* where it sits.
|
|
42
|
+
*
|
|
43
|
+
* `listDirectory` is what decides whether the directory can be read at all, so it
|
|
44
|
+
* runs first and its 404 is the page's error. The walk then reuses that listing
|
|
45
|
+
* for the same directory instead of reading it a second time; the ancestors are
|
|
46
|
+
* still read once each, which is what the tree needs.
|
|
47
|
+
*/
|
|
48
|
+
export async function directoryView(root: string, currentRel: string, limit: number): Promise<DirectoryView> {
|
|
49
|
+
const listing = await listDirectory(root, currentRel, limit);
|
|
50
|
+
const tree = await buildNode(root, "", currentRel, basename(root) || root, limit, { rel: currentRel, listing });
|
|
51
|
+
return { listing, tree };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function buildNode(
|
|
55
|
+
root: string,
|
|
56
|
+
relDir: string,
|
|
57
|
+
currentRel: string,
|
|
58
|
+
label: string,
|
|
59
|
+
limit: number,
|
|
60
|
+
/** A listing the caller already read for this directory, so it is not re-read. */
|
|
61
|
+
known?: { rel: string; listing: Listing },
|
|
62
|
+
): Promise<TreeNode> {
|
|
63
|
+
const node: TreeNode = {
|
|
64
|
+
name: label,
|
|
65
|
+
rel: relDir,
|
|
66
|
+
isDir: true,
|
|
67
|
+
isImage: false,
|
|
68
|
+
broken: false,
|
|
69
|
+
kind: "directory",
|
|
70
|
+
children: [],
|
|
71
|
+
};
|
|
72
|
+
let listing: Listing;
|
|
73
|
+
if (known && known.rel === relDir) {
|
|
74
|
+
listing = known.listing;
|
|
75
|
+
} else {
|
|
76
|
+
try {
|
|
77
|
+
listing = await listDirectory(root, relDir, limit);
|
|
78
|
+
} catch {
|
|
79
|
+
return node;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const entry of listing.entries) {
|
|
83
|
+
const child: TreeNode = {
|
|
84
|
+
name: entry.name,
|
|
85
|
+
rel: entry.rel,
|
|
86
|
+
isDir: entry.isDir,
|
|
87
|
+
isImage: entry.isImage,
|
|
88
|
+
broken: entry.broken,
|
|
89
|
+
kind: entry.kind,
|
|
90
|
+
children: [],
|
|
91
|
+
};
|
|
92
|
+
if (entry.isDir) {
|
|
93
|
+
const onPath = currentRel === entry.rel || currentRel.startsWith(`${entry.rel}/`);
|
|
94
|
+
if (onPath) {
|
|
95
|
+
child.children = (await buildNode(root, entry.rel, currentRel, entry.name, limit, known)).children;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
node.children.push(child);
|
|
99
|
+
}
|
|
100
|
+
return node;
|
|
101
|
+
}
|
|
@@ -14,9 +14,8 @@ import { realpathSync } from "node:fs";
|
|
|
14
14
|
import { stat } from "node:fs/promises";
|
|
15
15
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { isExtensionEnabled, resolveEffectiveCwd } from "../../lib/env.ts";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import { loadConfig, randomPort } from "./config/config.ts";
|
|
17
|
+
import { requestMount } from "../../lib/http/index.ts";
|
|
18
|
+
import { loadConfig } from "./config/config.ts";
|
|
20
19
|
import { createGitDiff, createGitStatus } from "./git/git.ts";
|
|
21
20
|
import { openInBrowser } from "../../lib/browser.ts";
|
|
22
21
|
import { HttpError, resolveRequestPath } from "./files/paths.ts";
|
|
@@ -27,8 +26,6 @@ export interface FileBrowserDeps {
|
|
|
27
26
|
createServer?: typeof createFileServer;
|
|
28
27
|
/** Override the browser opener (tests). */
|
|
29
28
|
open?: typeof openInBrowser;
|
|
30
|
-
/** Override session-port selection (tests). */
|
|
31
|
-
pickPort?: () => number;
|
|
32
29
|
}
|
|
33
30
|
|
|
34
31
|
interface RunningServer {
|
|
@@ -40,40 +37,25 @@ interface RunningServer {
|
|
|
40
37
|
export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}): void {
|
|
41
38
|
if (!isExtensionEnabled("file-browse")) return;
|
|
42
39
|
let running: RunningServer | undefined;
|
|
43
|
-
/**
|
|
44
|
-
let
|
|
40
|
+
/** The session this page serves as; the mount lands on its one server. */
|
|
41
|
+
let sessionId = "";
|
|
45
42
|
|
|
46
|
-
|
|
47
|
-
const setBrowseStatus = (ctx: ExtensionContext, port: number | undefined): void => {
|
|
48
|
-
try {
|
|
49
|
-
const value =
|
|
50
|
-
port === undefined
|
|
51
|
-
? undefined
|
|
52
|
-
: `${paint(ctx.ui.theme, "ok", GLYPHS.browse)} ${paint(ctx.ui.theme, "accent", String(port))}`;
|
|
53
|
-
ctx.ui.setStatus(STATUS_KEYS.browse, value);
|
|
54
|
-
} catch {
|
|
55
|
-
// A UI without a theme or setStatus must not break the command.
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const stop = async (ctx?: ExtensionContext): Promise<boolean> => {
|
|
43
|
+
const stop = async (): Promise<boolean> => {
|
|
60
44
|
const current = running;
|
|
61
45
|
if (!current) return false;
|
|
62
46
|
running = undefined;
|
|
63
47
|
await current.server.close();
|
|
64
|
-
if (ctx) setBrowseStatus(ctx, undefined);
|
|
65
48
|
return true;
|
|
66
49
|
};
|
|
67
50
|
|
|
68
51
|
/**
|
|
69
|
-
*
|
|
70
|
-
* left to serve
|
|
71
|
-
* one. `/browse` starts it again on the same session port.
|
|
52
|
+
* Give the prefix back once the page that was its only reader has gone: nothing is
|
|
53
|
+
* left to serve. `/browse` starts it again on the session's port.
|
|
72
54
|
*/
|
|
73
55
|
const stopWhenPageLeft = async (): Promise<void> => {
|
|
74
56
|
const current = running;
|
|
75
57
|
if (!current) return;
|
|
76
|
-
if (await stop(
|
|
58
|
+
if (await stop()) current.ctx.ui.notify("browse: stopped — the last page was closed", "info");
|
|
77
59
|
};
|
|
78
60
|
|
|
79
61
|
const start = async (args: string, ctx: ExtensionContext): Promise<void> => {
|
|
@@ -86,15 +68,12 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
|
|
|
86
68
|
return;
|
|
87
69
|
}
|
|
88
70
|
if (target === "stop") {
|
|
89
|
-
ctx.ui.notify((await stop(
|
|
71
|
+
ctx.ui.notify((await stop()) ? "browse: stopped" : "browse: not running", "info");
|
|
90
72
|
return;
|
|
91
73
|
}
|
|
92
74
|
|
|
93
75
|
const cwd = resolveEffectiveCwd(ctx.cwd);
|
|
94
76
|
const config = loadConfig(cwd);
|
|
95
|
-
// A pinned config port wins; otherwise one random port is fixed for the session.
|
|
96
|
-
const unpinned = config.port <= 0;
|
|
97
|
-
const port = unpinned ? (sessionPort ?? (deps.pickPort ?? randomPort)()) : config.port;
|
|
98
77
|
let root = cwd;
|
|
99
78
|
if (target) {
|
|
100
79
|
try {
|
|
@@ -117,17 +96,19 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
|
|
|
117
96
|
} catch {
|
|
118
97
|
// Keep the unresolved root; the server factory reports a real failure.
|
|
119
98
|
}
|
|
120
|
-
if (running && running.server.root === realRoot
|
|
99
|
+
if (running && running.server.root === realRoot) {
|
|
121
100
|
ctx.ui.notify(`browse: ${running.server.url} (already running)`, "info");
|
|
122
101
|
return;
|
|
123
102
|
}
|
|
124
103
|
|
|
125
|
-
await stop(
|
|
104
|
+
await stop();
|
|
126
105
|
const create = deps.createServer ?? createFileServer;
|
|
127
106
|
try {
|
|
128
|
-
const
|
|
107
|
+
const outcome = await create({
|
|
129
108
|
root,
|
|
130
|
-
config
|
|
109
|
+
config,
|
|
110
|
+
mount: (spec) => requestMount(pi, spec),
|
|
111
|
+
session: sessionId,
|
|
131
112
|
git: createGitStatus(pi, root),
|
|
132
113
|
diff: createGitDiff(pi, root, {
|
|
133
114
|
context: config.diffContext,
|
|
@@ -137,21 +118,16 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
|
|
|
137
118
|
}),
|
|
138
119
|
onIdle: () => void stopWhenPageLeft(),
|
|
139
120
|
});
|
|
140
|
-
if (
|
|
121
|
+
if (outcome.kind === "refused") {
|
|
122
|
+
ctx.ui.notify(`browse: ${outcome.reason}`, "error");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const server = outcome.server;
|
|
141
126
|
running = { server, ctx };
|
|
142
|
-
setBrowseStatus(ctx, server.port);
|
|
143
127
|
ctx.ui.notify(`browse: ${server.root} at ${server.url}`, "info");
|
|
144
128
|
if (config.autoOpen) (deps.open ?? openInBrowser)(server.url);
|
|
145
129
|
} catch (error) {
|
|
146
|
-
|
|
147
|
-
if (code === "EADDRINUSE") {
|
|
148
|
-
ctx.ui.notify(
|
|
149
|
-
`browse: port ${port} is in use; run /browse again to try another or set "port" in the bytefly.json file-browse section`,
|
|
150
|
-
"error",
|
|
151
|
-
);
|
|
152
|
-
} else {
|
|
153
|
-
ctx.ui.notify(`browse: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
154
|
-
}
|
|
130
|
+
ctx.ui.notify(`browse: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
155
131
|
}
|
|
156
132
|
};
|
|
157
133
|
|
|
@@ -168,8 +144,11 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
|
|
|
168
144
|
},
|
|
169
145
|
});
|
|
170
146
|
|
|
171
|
-
pi.on("
|
|
172
|
-
|
|
173
|
-
|
|
147
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
148
|
+
sessionId = ctx.sessionManager.getSessionId();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
pi.on("session_shutdown", async () => {
|
|
152
|
+
await stop();
|
|
174
153
|
});
|
|
175
154
|
}
|
|
@@ -174,7 +174,7 @@ export interface LiveChannel {
|
|
|
174
174
|
* attached, so an idle server does no status work at all; the last page leaving
|
|
175
175
|
* parks it and the next page starts it again. What happens to a server nobody is
|
|
176
176
|
* reading — the idle grace and the close it leads to — is the server's policy, so
|
|
177
|
-
* the caller hands it to
|
|
177
|
+
* the caller hands it to its own mount instead of configuring it
|
|
178
178
|
* here.
|
|
179
179
|
*/
|
|
180
180
|
export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
isLocalHostHeader,
|
|
16
16
|
jsonError,
|
|
17
17
|
jsonResponse,
|
|
18
|
-
pageHeaders,
|
|
19
18
|
parseRequestUrl,
|
|
20
19
|
scriptHeaders,
|
|
21
20
|
type ServeResponse,
|
|
@@ -47,13 +46,16 @@ import {
|
|
|
47
46
|
renderErrorPage,
|
|
48
47
|
renderFileDiffPage,
|
|
49
48
|
renderFilePage,
|
|
49
|
+
hrefFor,
|
|
50
50
|
} from "../view/html.ts";
|
|
51
51
|
import type { ChangeSummary } from "../view/html.ts";
|
|
52
|
-
import {
|
|
52
|
+
import { HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "../files/paths.ts";
|
|
53
53
|
import { listingRevision } from "../live/rev.ts";
|
|
54
54
|
|
|
55
55
|
export interface ServeContext {
|
|
56
56
|
root: string;
|
|
57
|
+
/** The prefix this page answers under, e.g. `/files`; `""` for the origin's root. */
|
|
58
|
+
base: string;
|
|
57
59
|
config: ServeConfig;
|
|
58
60
|
/** Read-only git context for the page header; omitted when unavailable. */
|
|
59
61
|
git?: GitStatusProvider;
|
|
@@ -153,6 +155,7 @@ async function errorPage(
|
|
|
153
155
|
// Keep the minimal tree.
|
|
154
156
|
}
|
|
155
157
|
const body = renderErrorPage({
|
|
158
|
+
base: ctx.base,
|
|
156
159
|
rootLabel: ctx.root,
|
|
157
160
|
status,
|
|
158
161
|
message,
|
|
@@ -188,6 +191,7 @@ async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeR
|
|
|
188
191
|
return htmlResponse(
|
|
189
192
|
200,
|
|
190
193
|
renderDirectoryPage({
|
|
194
|
+
base: ctx.base,
|
|
191
195
|
rootLabel: ctx.root,
|
|
192
196
|
rel,
|
|
193
197
|
listing,
|
|
@@ -212,6 +216,7 @@ async function fileResponse(ctx: ServeContext, rel: string, raw: boolean): Promi
|
|
|
212
216
|
return htmlResponse(
|
|
213
217
|
200,
|
|
214
218
|
renderFilePage({
|
|
219
|
+
base: ctx.base,
|
|
215
220
|
rootLabel: ctx.root,
|
|
216
221
|
rel,
|
|
217
222
|
file,
|
|
@@ -236,6 +241,7 @@ async function diffOverview(ctx: ServeContext, mode: DiffMode): Promise<ServeRes
|
|
|
236
241
|
return htmlResponse(
|
|
237
242
|
200,
|
|
238
243
|
renderDiffPage({
|
|
244
|
+
base: ctx.base,
|
|
239
245
|
rootLabel: ctx.root,
|
|
240
246
|
mode,
|
|
241
247
|
files: list?.files ?? [],
|
|
@@ -268,6 +274,7 @@ async function diffFile(ctx: ServeContext, rawRel: string, mode: DiffMode): Prom
|
|
|
268
274
|
return htmlResponse(
|
|
269
275
|
200,
|
|
270
276
|
renderFileDiffPage({
|
|
277
|
+
base: ctx.base,
|
|
271
278
|
rootLabel: ctx.root,
|
|
272
279
|
rel,
|
|
273
280
|
mode,
|
|
@@ -304,12 +311,12 @@ async function raw(ctx: ServeContext, rawRel: string): Promise<ServeResponse> {
|
|
|
304
311
|
};
|
|
305
312
|
}
|
|
306
313
|
|
|
307
|
-
function treeApiEntry(entry: DirEntry): Record<string, unknown> {
|
|
314
|
+
function treeApiEntry(entry: DirEntry, base: string): Record<string, unknown> {
|
|
308
315
|
const spec = entry.isDir ? FOLDER_ICON : languageOf(entry.name);
|
|
309
316
|
return {
|
|
310
317
|
name: entry.name,
|
|
311
318
|
path: entry.rel,
|
|
312
|
-
href:
|
|
319
|
+
href: hrefFor(entry, base),
|
|
313
320
|
isDir: entry.isDir,
|
|
314
321
|
broken: entry.broken,
|
|
315
322
|
iconHref: iconHref(spec.icon),
|
|
@@ -324,7 +331,7 @@ async function treeApi(ctx: ServeContext, decodedPath: string): Promise<ServeRes
|
|
|
324
331
|
const info = await stat(abs).catch(() => undefined);
|
|
325
332
|
if (info && !info.isDirectory()) throw new HttpError(400, "That path is not a folder.");
|
|
326
333
|
const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries);
|
|
327
|
-
return jsonResponse(200, { path: rel, entries: listing.entries.map(treeApiEntry) });
|
|
334
|
+
return jsonResponse(200, { path: rel, entries: listing.entries.map((entry) => treeApiEntry(entry, ctx.base)) });
|
|
328
335
|
}
|
|
329
336
|
|
|
330
337
|
/** The revision of one folder's listing: what a page's reload control compares against. */
|
|
@@ -379,7 +386,6 @@ export async function handleRequest(
|
|
|
379
386
|
try {
|
|
380
387
|
if (pathname === "/app.js") return { status: 200, headers: scriptHeaders(), body: CLIENT_JS };
|
|
381
388
|
if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
|
|
382
|
-
if (pathname === "/favicon.ico") return { status: 204, headers: pageHeaders(), body: "" };
|
|
383
389
|
if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? "");
|
|
384
390
|
if (pathname === "/api/dir-rev") return await dirRevApi(ctx, search.get("path") ?? "");
|
|
385
391
|
if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? "");
|