@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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The quick-open index: every file path under the root, to `limit`, for the palette.
|
|
3
|
+
*
|
|
4
|
+
* A separate walk from the tree on purpose: the palette wants paths and nothing else, and
|
|
5
|
+
* it reads the whole tree once instead of per folder.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Dirent } from "node:fs";
|
|
9
|
+
import { readdir } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
/** Directory names the quick-open index never walks. */
|
|
13
|
+
export const INDEX_IGNORED_DIRS = new Set([".git", "node_modules"]);
|
|
14
|
+
|
|
15
|
+
export interface FileIndex {
|
|
16
|
+
files: string[];
|
|
17
|
+
truncated: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Walk `root` and collect file paths for the quick-open palette. Symlinks and
|
|
22
|
+
* the ignored directories are skipped, and the walk stops at `limit` files so a
|
|
23
|
+
* large tree cannot exhaust memory or time. Unreadable directories are skipped.
|
|
24
|
+
*/
|
|
25
|
+
export async function buildFileIndex(root: string, limit: number): Promise<FileIndex> {
|
|
26
|
+
const files: string[] = [];
|
|
27
|
+
let truncated = false;
|
|
28
|
+
|
|
29
|
+
async function walk(absDir: string, relDir: string): Promise<void> {
|
|
30
|
+
let dirents: Dirent[];
|
|
31
|
+
try {
|
|
32
|
+
dirents = await readdir(absDir, { withFileTypes: true });
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
dirents.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
37
|
+
for (const dirent of dirents) {
|
|
38
|
+
if (truncated) return;
|
|
39
|
+
if (dirent.isSymbolicLink()) continue;
|
|
40
|
+
const rel = relDir ? `${relDir}/${dirent.name}` : dirent.name;
|
|
41
|
+
if (dirent.isDirectory()) {
|
|
42
|
+
if (INDEX_IGNORED_DIRS.has(dirent.name)) continue;
|
|
43
|
+
await walk(join(absDir, dirent.name), rel);
|
|
44
|
+
} else if (dirent.isFile()) {
|
|
45
|
+
if (files.length >= limit) {
|
|
46
|
+
truncated = true;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
files.push(rel);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await walk(root, "");
|
|
55
|
+
files.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()) || a.localeCompare(b));
|
|
56
|
+
return { files, truncated };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type FileIndexProvider = () => Promise<FileIndex>;
|
|
60
|
+
|
|
61
|
+
export interface FileIndexOptions {
|
|
62
|
+
/** How long a built index is reused, in ms (default 10000). */
|
|
63
|
+
ttlMs?: number;
|
|
64
|
+
/** Wall-clock seam; defaults to the real clock. */
|
|
65
|
+
now?: () => number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A cached `buildFileIndex` for `root`; concurrent calls share one walk. */
|
|
69
|
+
export function createFileIndex(root: string, limit: number, options: FileIndexOptions = {}): FileIndexProvider {
|
|
70
|
+
const ttlMs = options.ttlMs ?? 10_000;
|
|
71
|
+
const now = options.now ?? (() => Date.now());
|
|
72
|
+
let cached: FileIndex | undefined;
|
|
73
|
+
let checkedAt = 0;
|
|
74
|
+
let pending: Promise<FileIndex> | undefined;
|
|
75
|
+
return async () => {
|
|
76
|
+
const time = now();
|
|
77
|
+
if (cached && time - checkedAt < ttlMs) return cached;
|
|
78
|
+
if (pending) return pending;
|
|
79
|
+
pending = buildFileIndex(root, limit)
|
|
80
|
+
.then((index) => {
|
|
81
|
+
cached = index;
|
|
82
|
+
checkedAt = now();
|
|
83
|
+
return index;
|
|
84
|
+
})
|
|
85
|
+
.finally(() => {
|
|
86
|
+
pending = undefined;
|
|
87
|
+
});
|
|
88
|
+
return pending;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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,
|
|
@@ -379,7 +378,6 @@ export async function handleRequest(
|
|
|
379
378
|
try {
|
|
380
379
|
if (pathname === "/app.js") return { status: 200, headers: scriptHeaders(), body: CLIENT_JS };
|
|
381
380
|
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
381
|
if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? "");
|
|
384
382
|
if (pathname === "/api/dir-rev") return await dirRevApi(ctx, search.get("path") ?? "");
|
|
385
383
|
if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? "");
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The elements a page emits for a resolved Markdown link or image.
|
|
3
|
+
*
|
|
4
|
+
* Two renderers reach a link: the Markdown renderer (`markdown.ts`) and the raw-HTML
|
|
5
|
+
* sanitizer (`raw-html.ts`). Both ask `urls.ts` what a destination resolves to, and both
|
|
6
|
+
* then build the same anchor and the same image — the external-rel attributes, the
|
|
7
|
+
* `md-img` class, and the lazy decoding — so those live here rather than in a fourth and
|
|
8
|
+
* fifth copy. Values are escaped here; the callers hand in what the document said.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { escapeHtml } from "../../../lib/escape.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The attributes an external link carries, for a caller that is building the element
|
|
15
|
+
* itself: the raw-HTML sanitizer rewrites an existing tag's attribute list and has no
|
|
16
|
+
* content string to hand an emitter.
|
|
17
|
+
*
|
|
18
|
+
* `noopener` because the target is another origin, and `noreferrer` because the page is a
|
|
19
|
+
* local view of the reader's own repository and should not tell anyone where they read it.
|
|
20
|
+
*/
|
|
21
|
+
export const EXTERNAL_ATTRS = ' target="_blank" rel="noopener noreferrer"';
|
|
22
|
+
|
|
23
|
+
/** An `<a>` for one destination; `title` and the rel attributes are omitted when absent. */
|
|
24
|
+
export function emitAnchor(input: { href: string; content: string; title?: string; external: boolean }): string {
|
|
25
|
+
const title = input.title === undefined ? "" : ` title="${escapeHtml(input.title)}"`;
|
|
26
|
+
return `<a href="${escapeHtml(input.href)}"${title}${input.external ? EXTERNAL_ATTRS : ""}>${input.content}</a>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A link standing in for an image the page may not show: an external `src` is blocked by
|
|
31
|
+
* the CSP, so the reader gets something to click, labelled by the alt text.
|
|
32
|
+
*/
|
|
33
|
+
export function emitImageLink(href: string, label: string): string {
|
|
34
|
+
return `<a href="${escapeHtml(href)}"${EXTERNAL_ATTRS}>${escapeHtml(label)}</a>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** An `<img>`: block-level spacing class, alt text, and the sizes a document may pin. */
|
|
38
|
+
export function emitImage(input: {
|
|
39
|
+
href: string;
|
|
40
|
+
alt: string;
|
|
41
|
+
title?: string;
|
|
42
|
+
/** A document may pin a pixel size; escaped like every other value the page emits. */
|
|
43
|
+
width?: string | number;
|
|
44
|
+
height?: string | number;
|
|
45
|
+
}): string {
|
|
46
|
+
const size = `${input.width === undefined ? "" : ` width="${escapeHtml(String(input.width))}"`}${
|
|
47
|
+
input.height === undefined ? "" : ` height="${escapeHtml(String(input.height))}"`
|
|
48
|
+
}`;
|
|
49
|
+
const title = input.title === undefined ? "" : ` title="${escapeHtml(input.title)}"`;
|
|
50
|
+
return `<img class="md-img" src="${escapeHtml(input.href)}" alt="${escapeHtml(input.alt)}"${size}${title} loading="lazy" decoding="async">`;
|
|
51
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* the page-only `browse-*` rules from `styles.ts`.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { brandLockup, docHead, type KeyHint, keysSheet, themeToggle } from "../../../lib/web/index.ts";
|
|
11
|
+
import { brandLockup, docHead, type KeyHint, keysSheet, tabTitle, themeToggle } from "../../../lib/web/index.ts";
|
|
12
12
|
import { escapeHtml } from "../../../lib/escape.ts";
|
|
13
13
|
import { renderChanges, renderGit, renderLiveChrome, renderLiveStatus, type LivePage } from "./chrome.ts";
|
|
14
14
|
import { type DirEntry, type FileView, type Listing, type TreeNode } from "../files/files.ts";
|
|
@@ -87,7 +87,6 @@ function renderLayout(layout: Layout): string {
|
|
|
87
87
|
<html lang="en">
|
|
88
88
|
<head>
|
|
89
89
|
${docHead({ title: layout.title, css: BROWSE_CSS })}
|
|
90
|
-
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%2358a6ff'/%3E%3C/svg%3E">
|
|
91
90
|
</head>
|
|
92
91
|
<body class="bf-app">
|
|
93
92
|
<a class="skip" href="#main">Skip to content</a>
|
|
@@ -221,7 +220,7 @@ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.
|
|
|
221
220
|
body += `<p class="bf-note">Showing the first ${page.listing.entries.length} of ${page.listing.total} entries.</p>`;
|
|
222
221
|
|
|
223
222
|
return renderLayout({
|
|
224
|
-
title:
|
|
223
|
+
title: tabTitle("browse", page.rel || folderName),
|
|
225
224
|
rootLabel: page.rootLabel,
|
|
226
225
|
tree: page.tree,
|
|
227
226
|
currentRel: page.rel,
|
|
@@ -371,7 +370,7 @@ export function renderFilePage(page: FilePage): string {
|
|
|
371
370
|
}
|
|
372
371
|
|
|
373
372
|
return renderLayout({
|
|
374
|
-
title:
|
|
373
|
+
title: tabTitle("browse", page.rel),
|
|
375
374
|
rootLabel: page.rootLabel,
|
|
376
375
|
tree: page.tree,
|
|
377
376
|
currentRel: page.rel,
|
|
@@ -444,7 +443,7 @@ ${page.files.map((file) => renderChangedRow(file, page.mode)).join("\n")}
|
|
|
444
443
|
</table>`;
|
|
445
444
|
const stat = page.stat ? `<pre class="browse-diff-stat">${escapeHtml(page.stat)}</pre>` : "";
|
|
446
445
|
return renderLayout({
|
|
447
|
-
title: "browse
|
|
446
|
+
title: tabTitle("browse", "changes"),
|
|
448
447
|
rootLabel: page.rootLabel,
|
|
449
448
|
tree: page.tree,
|
|
450
449
|
currentRel: "",
|
|
@@ -516,7 +515,7 @@ export function renderFileDiffPage(page: FileDiffPage): string {
|
|
|
516
515
|
if (page.diff.truncated) note = `<p class="bf-note">Showing the first ${page.diff.lines.length} diff lines.</p>`;
|
|
517
516
|
}
|
|
518
517
|
return renderLayout({
|
|
519
|
-
title:
|
|
518
|
+
title: tabTitle("browse", page.rel),
|
|
520
519
|
rootLabel: page.rootLabel,
|
|
521
520
|
tree: page.tree,
|
|
522
521
|
currentRel: page.rel,
|
|
@@ -544,7 +543,7 @@ export interface ErrorPage {
|
|
|
544
543
|
/** Render a status/error page with the same chrome. */
|
|
545
544
|
export function renderErrorPage(page: ErrorPage): string {
|
|
546
545
|
return renderLayout({
|
|
547
|
-
title:
|
|
546
|
+
title: tabTitle("browse", String(page.status)),
|
|
548
547
|
rootLabel: page.rootLabel,
|
|
549
548
|
tree: page.tree,
|
|
550
549
|
currentRel: "",
|
|
@@ -38,9 +38,9 @@ import {
|
|
|
38
38
|
sanitizeHtmlBlock,
|
|
39
39
|
sanitizeInlineTag,
|
|
40
40
|
} from "./raw-html.ts";
|
|
41
|
+
import { emitAnchor, emitImage, emitImageLink } from "./emit.ts";
|
|
41
42
|
import { resolveImage, resolveLink } from "./urls.ts";
|
|
42
43
|
|
|
43
|
-
const HTTP_SCHEME = /^https?:/i;
|
|
44
44
|
/** A GFM extended autolink: `http(s)://` or `www.` followed by a domain. */
|
|
45
45
|
const AUTOLINK = /^(?:https?:\/\/|www\.)[^\s<>]*/i;
|
|
46
46
|
|
|
@@ -643,20 +643,17 @@ function renderLink(parsed: ParsedLink, ctx: Context): string {
|
|
|
643
643
|
const label = renderInline(parsed.label, ctx);
|
|
644
644
|
const target = resolveLink(parsed.dest, ctx.baseDir);
|
|
645
645
|
if (!target) return label;
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
return
|
|
646
|
+
// The anchor's shape is `emit.ts`'s, the same one the raw-HTML sanitizer emits: a link
|
|
647
|
+
// written in Markdown and the same link written as HTML are the same link.
|
|
648
|
+
return emitAnchor({ href: target.href, content: label, title: parsed.title, external: target.external });
|
|
649
649
|
}
|
|
650
650
|
|
|
651
651
|
function renderImage(parsed: ParsedLink, ctx: Context): string {
|
|
652
652
|
const alt = escapeHtml(parsed.label);
|
|
653
653
|
const target = resolveImage(parsed.dest, ctx.baseDir);
|
|
654
654
|
if (!target) return alt;
|
|
655
|
-
if (!target.image)
|
|
656
|
-
|
|
657
|
-
}
|
|
658
|
-
const title = parsed.title ? ` title="${escapeHtml(parsed.title)}"` : "";
|
|
659
|
-
return `<img class="md-img" src="${escapeHtml(target.href)}" alt="${alt}" loading="lazy" decoding="async"${title}>`;
|
|
655
|
+
if (!target.image) return emitImageLink(target.href, parsed.label || parsed.dest);
|
|
656
|
+
return emitImage({ href: target.href, alt, title: parsed.title });
|
|
660
657
|
}
|
|
661
658
|
|
|
662
659
|
function matchRun(text: string, start: number, char: string): number {
|
|
@@ -812,8 +809,10 @@ function renderInline(text: string, ctx: Context): string {
|
|
|
812
809
|
const match = /^<((?:https?|mailto):[^>\s]+)>/i.exec(text.slice(i));
|
|
813
810
|
if (match) {
|
|
814
811
|
const href = match[1];
|
|
815
|
-
|
|
816
|
-
|
|
812
|
+
// Resolved rather than pattern-matched: `urls.ts` owns which schemes open in a
|
|
813
|
+
// new tab, and a `<mailto:>` link is external but does not.
|
|
814
|
+
const external = resolveLink(href, ctx.baseDir)?.external ?? false;
|
|
815
|
+
out += emitAnchor({ href, content: escapeHtml(href), external });
|
|
817
816
|
i += match[0].length;
|
|
818
817
|
continue;
|
|
819
818
|
}
|
|
@@ -836,8 +835,7 @@ function renderInline(text: string, ctx: Context): string {
|
|
|
836
835
|
// Require a domain, so a bare scheme is not a link.
|
|
837
836
|
if (dots > 0 && /\.[^./]/.test(label.slice(dots))) {
|
|
838
837
|
const href = /^www\./i.test(label) ? `http://${label}` : label;
|
|
839
|
-
|
|
840
|
-
out += `<a href="${escapeHtml(href)}"${external}>${escapeHtml(label)}</a>`;
|
|
838
|
+
out += emitAnchor({ href, content: escapeHtml(label), external: true });
|
|
841
839
|
i += label.length;
|
|
842
840
|
continue;
|
|
843
841
|
}
|