@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.
Files changed (158) hide show
  1. package/README.md +17 -11
  2. package/README.zh-CN.md +13 -8
  3. package/docs/extensions/README.md +1 -1
  4. package/docs/extensions/board.md +286 -123
  5. package/docs/extensions/bytefly.md +381 -223
  6. package/docs/extensions/file-browse.md +14 -7
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +56 -11
  9. package/docs/extensions/session-bus.md +23 -10
  10. package/docs/extensions/team.md +45 -17
  11. package/docs/extensions/wechat.md +2 -1
  12. package/docs/extensions/worktree.md +30 -15
  13. package/extensions/board/commands.ts +23 -5
  14. package/extensions/board/format.ts +107 -9
  15. package/extensions/board/join.ts +1 -1
  16. package/extensions/board/page.ts +170 -94
  17. package/extensions/board/result.ts +9 -0
  18. package/extensions/board/schema.ts +22 -7
  19. package/extensions/board/seat.ts +9 -10
  20. package/extensions/board/snapshot.ts +1 -0
  21. package/extensions/board/tools.ts +186 -21
  22. package/extensions/board/transport/router.ts +27 -108
  23. package/extensions/board/transport/server.ts +18 -29
  24. package/extensions/board/types.ts +67 -0
  25. package/extensions/board/view/client.ts +23 -13
  26. package/extensions/board/view/dialog.ts +43 -0
  27. package/extensions/board/view/keys.ts +28 -24
  28. package/extensions/board/view/live.ts +5 -171
  29. package/extensions/board/view/names.ts +91 -0
  30. package/extensions/board/view/page.ts +48 -88
  31. package/extensions/board/view/render.ts +213 -190
  32. package/extensions/board/view/styles.ts +65 -48
  33. package/extensions/board/view/team.ts +63 -41
  34. package/extensions/bytefly/footer/budget.ts +6 -5
  35. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  36. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  37. package/extensions/bytefly/footer/config.ts +19 -8
  38. package/extensions/bytefly/footer/price/price.ts +30 -8
  39. package/extensions/bytefly/footer/register.ts +5 -5
  40. package/extensions/bytefly/footer/render/format.ts +18 -0
  41. package/extensions/bytefly/footer/render/lines.ts +23 -7
  42. package/extensions/bytefly/footer/render/segments.ts +117 -31
  43. package/extensions/bytefly/footer/types.ts +29 -5
  44. package/extensions/bytefly/footer/unpriced.ts +42 -17
  45. package/extensions/bytefly/panel/commands.ts +28 -1
  46. package/extensions/bytefly/panel/filter.ts +68 -0
  47. package/extensions/bytefly/panel/format.ts +16 -15
  48. package/extensions/bytefly/panel/join.ts +56 -13
  49. package/extensions/bytefly/panel/prices.ts +43 -0
  50. package/extensions/bytefly/panel/settings.ts +72 -23
  51. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  52. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  53. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  54. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  55. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  56. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  57. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  58. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  59. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  60. package/extensions/bytefly/panel/tui.ts +306 -259
  61. package/extensions/check/commands.ts +2 -1
  62. package/extensions/file-browse/files/classify.ts +136 -0
  63. package/extensions/file-browse/files/files.ts +8 -547
  64. package/extensions/file-browse/files/index.ts +90 -0
  65. package/extensions/file-browse/files/list.ts +261 -0
  66. package/extensions/file-browse/files/tree.ts +101 -0
  67. package/extensions/file-browse/transport/router.ts +0 -2
  68. package/extensions/file-browse/view/emit.ts +51 -0
  69. package/extensions/file-browse/view/html.ts +6 -7
  70. package/extensions/file-browse/view/markdown.ts +11 -13
  71. package/extensions/file-browse/view/raw-html.ts +18 -13
  72. package/extensions/job/runtime.ts +4 -4
  73. package/extensions/plan/feedback.ts +40 -16
  74. package/extensions/plan/format.ts +10 -3
  75. package/extensions/plan/index.ts +17 -1
  76. package/extensions/plan/plan-files.ts +88 -0
  77. package/extensions/plan/plans.ts +19 -23
  78. package/extensions/plan/policy.ts +10 -5
  79. package/extensions/plan/runtime.ts +31 -2
  80. package/extensions/plan/todos.ts +51 -3
  81. package/extensions/plan/tools/exit.ts +10 -1
  82. package/extensions/plan/tools/todo-seed.ts +4 -2
  83. package/extensions/plan/tools/write.ts +12 -1
  84. package/extensions/plan/types.ts +15 -0
  85. package/extensions/plan/web-blocks.ts +89 -0
  86. package/extensions/plan/web-chrome.ts +3 -5
  87. package/extensions/plan/web-client.ts +179 -330
  88. package/extensions/plan/web-collab.ts +3 -3
  89. package/extensions/plan/web-comments.ts +18 -8
  90. package/extensions/plan/web-composer.ts +80 -0
  91. package/extensions/plan/web-draft.ts +40 -0
  92. package/extensions/plan/web-keys.ts +134 -0
  93. package/extensions/plan/web-outline.ts +192 -0
  94. package/extensions/plan/web-page.ts +4 -1
  95. package/extensions/plan/web-plan.ts +150 -0
  96. package/extensions/plan/web-router.ts +0 -1
  97. package/extensions/plan/web-settle.ts +194 -0
  98. package/extensions/rewind/commands/review.ts +2 -1
  99. package/extensions/session-bus/rows.ts +7 -3
  100. package/extensions/session-bus/runtime.ts +100 -38
  101. package/extensions/session-bus/store.ts +14 -2
  102. package/extensions/session-bus/tui.ts +1 -1
  103. package/extensions/session-bus/types.ts +10 -1
  104. package/extensions/subagent/commands.ts +2 -1
  105. package/extensions/team/commands.ts +41 -8
  106. package/extensions/team/format.ts +14 -0
  107. package/extensions/team/index.ts +2 -1
  108. package/extensions/team/tools.ts +30 -9
  109. package/extensions/team/types.ts +17 -0
  110. package/extensions/todo/index.ts +4 -1
  111. package/extensions/todo/nudge.ts +8 -2
  112. package/extensions/wechat/bridge.ts +43 -172
  113. package/extensions/wechat/intake.ts +245 -0
  114. package/extensions/worktree/commands.ts +24 -17
  115. package/extensions/worktree/exit.ts +12 -6
  116. package/extensions/worktree/git.ts +22 -14
  117. package/extensions/worktree/guard.ts +15 -285
  118. package/extensions/worktree/handoff.ts +10 -2
  119. package/extensions/worktree/index.ts +2 -1
  120. package/extensions/worktree/lifecycle.ts +2 -0
  121. package/extensions/worktree/registry.ts +20 -11
  122. package/extensions/worktree/root-tools.ts +13 -3
  123. package/extensions/worktree/runtime.ts +8 -2
  124. package/extensions/worktree/screen.ts +15 -8
  125. package/extensions/worktree/shell.ts +303 -0
  126. package/extensions/worktree/state.ts +1 -1
  127. package/extensions/worktree/status.ts +42 -9
  128. package/extensions/worktree/tools.ts +19 -13
  129. package/lib/README.md +6 -5
  130. package/lib/board/card.ts +192 -3
  131. package/lib/board/links.ts +26 -2
  132. package/lib/board/page-pointer.ts +86 -32
  133. package/lib/board/round-text.ts +11 -2
  134. package/lib/board/round.ts +25 -20
  135. package/lib/board/rules.ts +144 -11
  136. package/lib/board/store.ts +239 -52
  137. package/lib/board/view.ts +94 -10
  138. package/lib/format.ts +22 -0
  139. package/lib/http/serve.ts +3 -0
  140. package/lib/http/sink.ts +34 -9
  141. package/lib/plan-entry.ts +11 -0
  142. package/lib/policy.ts +14 -1
  143. package/lib/session-roster.ts +74 -3
  144. package/lib/spawn.ts +37 -3
  145. package/lib/stray-fields.ts +45 -0
  146. package/lib/tool-names.ts +13 -0
  147. package/lib/tui/glyphs.ts +2 -3
  148. package/lib/web/README.md +52 -2
  149. package/lib/web/client-runtime.ts +123 -0
  150. package/lib/web/index.ts +4 -2
  151. package/lib/web/markup.ts +88 -1
  152. package/lib/web/names.ts +42 -0
  153. package/package.json +1 -1
  154. package/extensions/board/view/actions.ts +0 -306
  155. package/extensions/board/writes.ts +0 -99
  156. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  157. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  158. 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: `browse · ${page.rel || folderName}`,
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: `browse · ${page.rel}`,
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 · changes",
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: `browse · ${page.rel}`,
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: `browse · ${page.status}`,
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
- const title = parsed.title ? ` title="${escapeHtml(parsed.title)}"` : "";
647
- const external = target.external ? ` target="_blank" rel="noopener noreferrer"` : "";
648
- return `<a href="${escapeHtml(target.href)}"${title}${external}>${label}</a>`;
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
- return `<a href="${escapeHtml(target.href)}" target="_blank" rel="noopener noreferrer">${alt || escapeHtml(parsed.dest)}</a>`;
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
- const external = HTTP_SCHEME.test(href) ? ` target="_blank" rel="noopener noreferrer"` : "";
816
- out += `<a href="${escapeHtml(href)}"${external}>${escapeHtml(href)}</a>`;
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
- const external = HTTP_SCHEME.test(href) ? ` target="_blank" rel="noopener noreferrer"` : "";
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
  }