@gavin-hu/my-pi-agent 0.18.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 +66 -52
- package/README.zh-CN.md +56 -46
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +507 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
- package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
- package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
- package/docs/extensions/file-browse.md +561 -0
- package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
- package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
- package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
- package/{extensions/job/README.md → docs/extensions/job.md} +62 -52
- package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
- package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +298 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
- package/docs/extensions/team.md +254 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
- package/extensions/board/commands.ts +105 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +170 -0
- package/extensions/board/index.ts +40 -0
- package/extensions/board/input.ts +54 -0
- package/extensions/board/join.ts +42 -0
- package/extensions/board/page.ts +388 -0
- package/extensions/board/result.ts +108 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +90 -0
- package/extensions/board/seat.ts +28 -0
- package/extensions/board/snapshot.ts +42 -0
- package/extensions/board/tools.ts +444 -0
- package/extensions/board/transport/router.ts +91 -0
- package/extensions/board/transport/server.ts +138 -0
- package/extensions/board/types.ts +121 -0
- package/extensions/board/view/client.ts +51 -0
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +125 -0
- package/extensions/board/view/live.ts +231 -0
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +118 -0
- package/extensions/board/view/render.ts +519 -0
- package/extensions/board/view/styles.ts +228 -0
- package/extensions/board/view/team.ts +327 -0
- 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 +67 -7
- 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-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +75 -43
- 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/bytefly/picker/discovery.ts +0 -2
- package/extensions/bytefly/picker/types.ts +0 -2
- package/extensions/bytefly/update/check.ts +39 -0
- package/extensions/bytefly/update/format.ts +15 -0
- package/extensions/bytefly/update/register.ts +73 -34
- package/extensions/bytefly/update/state.ts +6 -3
- package/extensions/check/commands.ts +2 -1
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +12 -0
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
- package/extensions/file-browse/index.ts +4 -4
- package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
- package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
- package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
- package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
- package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
- package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
- package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
- package/extensions/file-read/schema.ts +2 -1
- package/extensions/job/runtime.ts +4 -4
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +21 -16
- package/extensions/plan/feedback.ts +185 -52
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +23 -5
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-index.ts +43 -9
- package/extensions/plan/plan-text.ts +33 -23
- package/extensions/plan/plans.ts +27 -34
- package/extensions/plan/policy.ts +30 -17
- package/extensions/plan/review.ts +3 -27
- package/extensions/plan/runtime.ts +52 -11
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +3 -22
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +20 -95
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/tui.ts +5 -34
- package/extensions/plan/types.ts +15 -8
- 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 -8
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +3 -58
- package/extensions/plan/web-settle.ts +194 -0
- package/extensions/plan/web-share-session.ts +24 -30
- package/extensions/plan/web-streams.ts +14 -14
- package/extensions/plan/web-tokens.ts +2 -2
- package/extensions/plan/web.ts +66 -120
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
- package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
- package/extensions/rewind/{config.ts → config/config.ts} +1 -1
- package/extensions/rewind/index.ts +3 -3
- package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
- package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
- package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
- package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
- package/extensions/rewind/{format.ts → text/format.ts} +3 -3
- package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
- package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
- package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
- package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
- package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
- package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
- package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
- package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
- package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
- package/extensions/rewind/{status.ts → view/status.ts} +4 -4
- package/extensions/session-bus/commands.ts +17 -69
- package/extensions/session-bus/format.ts +22 -45
- package/extensions/session-bus/index.ts +13 -7
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +32 -4
- package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
- package/extensions/session-bus/protocol.ts +19 -49
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +16 -51
- package/extensions/session-bus/rows.ts +11 -5
- package/extensions/session-bus/runtime.ts +220 -267
- package/extensions/session-bus/store.ts +55 -7
- package/extensions/session-bus/tools.ts +31 -66
- package/extensions/session-bus/tui.ts +5 -5
- package/extensions/session-bus/types.ts +23 -14
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +145 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +119 -0
- package/extensions/team/index.ts +34 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +267 -0
- package/extensions/team/types.ts +65 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/web-access/fetch/page.ts +4 -1
- package/extensions/web-access/fetch/structured.ts +447 -0
- package/extensions/web-access/fetch/tool.ts +2 -1
- package/extensions/web-access/search/tool.ts +2 -1
- package/extensions/wechat/bridge.ts +106 -251
- package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
- package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
- package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
- package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
- package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
- package/extensions/wechat/dialog/channels.ts +136 -0
- package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
- package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
- package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
- package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
- package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
- package/extensions/wechat/{state.ts → state/state.ts} +14 -2
- package/extensions/wechat/{format.ts → text/format.ts} +3 -3
- package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
- package/extensions/wechat/tools.ts +29 -12
- package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
- package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
- package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
- package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
- package/extensions/wechat/transport/outbound.ts +142 -0
- package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
- package/extensions/wechat/types.ts +10 -1
- package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
- package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
- 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 +18 -290
- 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 +11 -8
- package/lib/board/card.ts +479 -0
- package/lib/board/links.ts +141 -0
- package/lib/board/page-pointer.ts +310 -0
- package/lib/board/round-text.ts +81 -0
- package/lib/board/round.ts +94 -0
- package/lib/board/rules.ts +437 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +497 -0
- package/lib/board/view.ts +234 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +35 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/serve.ts +237 -0
- package/lib/http/server.ts +12 -10
- package/lib/http/sink.ts +135 -0
- package/lib/http/token.ts +38 -0
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +25 -2
- package/lib/requirements.ts +6 -7
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +201 -0
- package/lib/session-tool.ts +184 -0
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +38 -2
- package/lib/tui/glyphs.ts +3 -2
- package/lib/web/README.md +57 -4
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/components.ts +5 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -0
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +26 -14
- package/skills/next/SKILL.md +4 -3
- package/skills/project-docs/SKILL.md +133 -0
- package/skills/project-docs/references/audit.md +131 -0
- package/skills/project-docs/references/changelog.md +105 -0
- package/skills/project-docs/references/discovery.md +127 -0
- package/skills/project-docs/references/readme.md +80 -0
- package/skills/project-docs/references/release.md +84 -0
- package/skills/project-docs/references/roadmap.md +93 -0
- package/skills/project-docs/scripts/state.ts +316 -0
- package/skills/refactor/SKILL.md +100 -125
- package/skills/refactor/references/handoff.md +35 -0
- package/skills/refactor/references/signals.md +54 -0
- package/skills/refactor/scripts/inventory.ts +587 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/file-browse/README.md +0 -506
- package/extensions/file-browse/files.ts +0 -564
- package/extensions/file-browse/server.ts +0 -141
- package/extensions/job/process.ts +0 -35
- package/extensions/plan/split.ts +0 -87
- package/extensions/session-bus/README.md +0 -247
- package/extensions/session-bus/team.ts +0 -102
- package/extensions/wechat/outbound.ts +0 -100
- package/lib/plans/sections.ts +0 -172
- package/lib/plans/setting.ts +0 -52
- package/lib/plans/split.ts +0 -215
- package/lib/plans/store.ts +0 -19
- package/lib/plans/team.ts +0 -151
- package/skills/changelog/SKILL.md +0 -148
- package/skills/parallel-dev/SKILL.md +0 -150
- /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
- /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
- /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
- /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
- /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
- /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
- /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
- /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
- /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
- /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
- /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
|
@@ -1,564 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Filesystem reads for the browse extension: classification, capped directory
|
|
3
|
-
* listings, capped text reads, and the small sidebar tree. All paths are
|
|
4
|
-
* absolute and already vetted by `paths.ts`; this module never touches the raw
|
|
5
|
-
* request URL.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { Dirent, Stats } from "node:fs";
|
|
9
|
-
import { readFile, readdir, stat, lstat } from "node:fs/promises";
|
|
10
|
-
import { basename, extname, join } from "node:path";
|
|
11
|
-
import { mapConcurrent } from "../../lib/pool.ts";
|
|
12
|
-
import { HttpError } from "./paths.ts";
|
|
13
|
-
|
|
14
|
-
export type EntryKind = "directory" | "text" | "image" | "binary";
|
|
15
|
-
|
|
16
|
-
/** Image extension (no dot) to MIME type; the single source for image classification. */
|
|
17
|
-
const IMAGE_MIME: Record<string, string> = {
|
|
18
|
-
png: "image/png",
|
|
19
|
-
jpg: "image/jpeg",
|
|
20
|
-
jpeg: "image/jpeg",
|
|
21
|
-
gif: "image/gif",
|
|
22
|
-
webp: "image/webp",
|
|
23
|
-
svg: "image/svg+xml",
|
|
24
|
-
bmp: "image/bmp",
|
|
25
|
-
ico: "image/x-icon",
|
|
26
|
-
avif: "image/avif",
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
/** Image extensions without a leading dot. */
|
|
30
|
-
export const IMAGE_EXTENSIONS: ReadonlySet<string> = new Set(Object.keys(IMAGE_MIME));
|
|
31
|
-
|
|
32
|
-
const MARKDOWN_EXTS = new Set([".md", ".markdown", ".mdown", ".mkd", ".mkdn"]);
|
|
33
|
-
const TEXT_EXTS = new Set([
|
|
34
|
-
".txt",
|
|
35
|
-
".md",
|
|
36
|
-
".markdown",
|
|
37
|
-
".rst",
|
|
38
|
-
".adoc",
|
|
39
|
-
".org",
|
|
40
|
-
".log",
|
|
41
|
-
".json",
|
|
42
|
-
".jsonc",
|
|
43
|
-
".json5",
|
|
44
|
-
".yaml",
|
|
45
|
-
".yml",
|
|
46
|
-
".toml",
|
|
47
|
-
".ini",
|
|
48
|
-
".cfg",
|
|
49
|
-
".conf",
|
|
50
|
-
".xml",
|
|
51
|
-
".csv",
|
|
52
|
-
".tsv",
|
|
53
|
-
".ts",
|
|
54
|
-
".tsx",
|
|
55
|
-
".js",
|
|
56
|
-
".jsx",
|
|
57
|
-
".mjs",
|
|
58
|
-
".cjs",
|
|
59
|
-
".py",
|
|
60
|
-
".go",
|
|
61
|
-
".rs",
|
|
62
|
-
".java",
|
|
63
|
-
".c",
|
|
64
|
-
".h",
|
|
65
|
-
".cc",
|
|
66
|
-
".cpp",
|
|
67
|
-
".hpp",
|
|
68
|
-
".cs",
|
|
69
|
-
".rb",
|
|
70
|
-
".php",
|
|
71
|
-
".sh",
|
|
72
|
-
".bash",
|
|
73
|
-
".zsh",
|
|
74
|
-
".ps1",
|
|
75
|
-
".swift",
|
|
76
|
-
".kt",
|
|
77
|
-
".kts",
|
|
78
|
-
".scala",
|
|
79
|
-
".lua",
|
|
80
|
-
".r",
|
|
81
|
-
".pl",
|
|
82
|
-
".sql",
|
|
83
|
-
".css",
|
|
84
|
-
".scss",
|
|
85
|
-
".sass",
|
|
86
|
-
".less",
|
|
87
|
-
".html",
|
|
88
|
-
".htm",
|
|
89
|
-
".vue",
|
|
90
|
-
".svelte",
|
|
91
|
-
".astro",
|
|
92
|
-
]);
|
|
93
|
-
const TEXT_NAMES = new Set([
|
|
94
|
-
"license",
|
|
95
|
-
"licence",
|
|
96
|
-
"makefile",
|
|
97
|
-
"dockerfile",
|
|
98
|
-
"readme",
|
|
99
|
-
"changelog",
|
|
100
|
-
"authors",
|
|
101
|
-
"notice",
|
|
102
|
-
]);
|
|
103
|
-
|
|
104
|
-
export interface DirEntry {
|
|
105
|
-
name: string;
|
|
106
|
-
/** Root-relative posix path. */
|
|
107
|
-
rel: string;
|
|
108
|
-
isDir: boolean;
|
|
109
|
-
isSymlink: boolean;
|
|
110
|
-
broken: boolean;
|
|
111
|
-
size: number;
|
|
112
|
-
mtimeMs: number;
|
|
113
|
-
kind: EntryKind;
|
|
114
|
-
isImage: boolean;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export interface Listing {
|
|
118
|
-
entries: DirEntry[];
|
|
119
|
-
total: number;
|
|
120
|
-
truncated: boolean;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export interface FileMeta {
|
|
124
|
-
size: number;
|
|
125
|
-
mtimeMs: number;
|
|
126
|
-
mime: string;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export type FileView =
|
|
130
|
-
| (FileMeta & { kind: "text"; lines: string[]; truncated: boolean; totalLines: number })
|
|
131
|
-
| (FileMeta & { kind: "markdown"; lines: string[]; truncated: boolean; totalLines: number })
|
|
132
|
-
| (FileMeta & { kind: "image" })
|
|
133
|
-
| (FileMeta & { kind: "binary" })
|
|
134
|
-
| (FileMeta & { kind: "tooLarge" });
|
|
135
|
-
|
|
136
|
-
/** Whether the name has an image extension. */
|
|
137
|
-
export function isImageName(name: string): boolean {
|
|
138
|
-
return IMAGE_EXTENSIONS.has(extname(name).slice(1).toLowerCase());
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Whether the name has a Markdown extension. */
|
|
142
|
-
export function isMarkdownName(name: string): boolean {
|
|
143
|
-
return MARKDOWN_EXTS.has(extname(name).toLowerCase());
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Extension-based classification for listings (no file contents read). */
|
|
147
|
-
export function classifyByName(name: string): EntryKind {
|
|
148
|
-
const ext = extname(name).toLowerCase();
|
|
149
|
-
if (IMAGE_EXTENSIONS.has(ext.slice(1))) return "image";
|
|
150
|
-
if (TEXT_EXTS.has(ext)) return "text";
|
|
151
|
-
const lower = basename(name).toLowerCase();
|
|
152
|
-
if (TEXT_NAMES.has(lower)) return "text";
|
|
153
|
-
if (!ext && name.startsWith(".")) return "text";
|
|
154
|
-
return "binary";
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** True when the sample looks like binary data (NUL or many control bytes). */
|
|
158
|
-
export function isProbablyText(sample: Buffer): boolean {
|
|
159
|
-
if (sample.length === 0) return true;
|
|
160
|
-
if (sample.includes(0)) return false;
|
|
161
|
-
let suspicious = 0;
|
|
162
|
-
for (const byte of sample) {
|
|
163
|
-
if (byte === 9 || byte === 10 || byte === 13 || byte === 12 || byte === 8) continue;
|
|
164
|
-
if (byte < 32 || byte === 127) suspicious++;
|
|
165
|
-
}
|
|
166
|
-
return suspicious / sample.length < 0.3;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Compact byte size: `68 B`, `12.4 kB`, `1.5 MB`. */
|
|
170
|
-
export function formatSize(bytes: number): string {
|
|
171
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
172
|
-
const units = ["kB", "MB", "GB", "TB"];
|
|
173
|
-
let value = bytes;
|
|
174
|
-
let index = -1;
|
|
175
|
-
do {
|
|
176
|
-
value /= 1024;
|
|
177
|
-
index++;
|
|
178
|
-
} while (value >= 1024 && index < units.length - 1);
|
|
179
|
-
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[index]}`;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** MIME type for a name; text is always served as plain text. */
|
|
183
|
-
export function contentTypeFor(name: string): string {
|
|
184
|
-
const ext = extname(name).slice(1).toLowerCase();
|
|
185
|
-
return IMAGE_MIME[ext] ?? "application/octet-stream";
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function toAbs(root: string, rel: string): string {
|
|
189
|
-
return rel ? join(root, ...rel.split("/")) : root;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** The `Dirent` fields a listing reads; a test's fake need not be a real `Dirent`. */
|
|
193
|
-
export interface DirectoryEntryInfo {
|
|
194
|
-
name: string;
|
|
195
|
-
isDirectory(): boolean;
|
|
196
|
-
isSymbolicLink(): boolean;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** The `Stats` fields a listing reads. */
|
|
200
|
-
export interface DirectoryStats {
|
|
201
|
-
size: number;
|
|
202
|
-
mtimeMs: number;
|
|
203
|
-
isDirectory(): boolean;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* The three filesystem reads a listing makes.
|
|
208
|
-
*
|
|
209
|
-
* Injected so a test can observe them: the interesting property — a directory is
|
|
210
|
-
* read once per entry, not once per entry *and* once per row drawn — is a count,
|
|
211
|
-
* not a value, and a wall-clock assertion would be flaky on a loaded machine.
|
|
212
|
-
*/
|
|
213
|
-
export interface DirectoryFs {
|
|
214
|
-
readdir(path: string): Promise<DirectoryEntryInfo[]>;
|
|
215
|
-
lstat(path: string): Promise<DirectoryStats>;
|
|
216
|
-
stat(path: string): Promise<DirectoryStats>;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Production reads; a symlink's size and kind come from the target. */
|
|
220
|
-
const NODE_DIRECTORY_FS: DirectoryFs = {
|
|
221
|
-
readdir: (path) => readdir(path, { withFileTypes: true }),
|
|
222
|
-
lstat: (path) => lstat(path),
|
|
223
|
-
stat: (path) => stat(path),
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
/** Reads in flight per listing: enough to hide latency, bounded against a burst. */
|
|
227
|
-
const LISTING_CONCURRENCY = 32;
|
|
228
|
-
|
|
229
|
-
/** A row while it is still missing its metadata. */
|
|
230
|
-
interface Row {
|
|
231
|
-
name: string;
|
|
232
|
-
rel: string;
|
|
233
|
-
isDir: boolean;
|
|
234
|
-
isSymlink: boolean;
|
|
235
|
-
broken: boolean;
|
|
236
|
-
size: number;
|
|
237
|
-
mtimeMs: number;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Resolve one symlink: the link's own stat, then the target's.
|
|
242
|
-
*
|
|
243
|
-
* A symlink's directory-ness is the target's, so this cannot wait until after
|
|
244
|
-
* the sort; it is also the only entry whose classification needs a read.
|
|
245
|
-
*/
|
|
246
|
-
async function resolveSymlink(fs: DirectoryFs, absDir: string, row: Row): Promise<void> {
|
|
247
|
-
const abs = join(absDir, row.name);
|
|
248
|
-
try {
|
|
249
|
-
const info = await fs.lstat(abs);
|
|
250
|
-
row.size = info.size;
|
|
251
|
-
row.mtimeMs = info.mtimeMs;
|
|
252
|
-
} catch {
|
|
253
|
-
// Vanished between readdir and lstat: the target read decides below.
|
|
254
|
-
}
|
|
255
|
-
try {
|
|
256
|
-
const target = await fs.stat(abs);
|
|
257
|
-
row.isDir = target.isDirectory();
|
|
258
|
-
row.size = target.size;
|
|
259
|
-
row.mtimeMs = target.mtimeMs;
|
|
260
|
-
} catch {
|
|
261
|
-
row.broken = true;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* List a directory, sorted (directories first, then case-insensitive name).
|
|
267
|
-
*
|
|
268
|
-
* A listing is latency per entry, not work per entry, so it is read in three
|
|
269
|
-
* passes instead of one entry at a time:
|
|
270
|
-
*
|
|
271
|
-
* 1. `readdir`, whose dirents already say whether each name is a directory;
|
|
272
|
-
* 2. the symlinks only, resolved through the pool, because their kind is the
|
|
273
|
-
* target's and the sort needs it;
|
|
274
|
-
* 3. the kept rows only, `stat`ed through the pool — so a directory of three
|
|
275
|
-
* thousand names pays for the two thousand it draws, not for every name, and
|
|
276
|
-
* pays them concurrently instead of in turn.
|
|
277
|
-
*
|
|
278
|
-
* `total` is still the directory's full entry count and `truncated` its `> limit`
|
|
279
|
-
* flip, so the page can say how many were dropped. Ties keep `readdir` order:
|
|
280
|
-
* `sort` is stable and the rows arrive unsorted.
|
|
281
|
-
*/
|
|
282
|
-
export async function listDirectory(
|
|
283
|
-
root: string,
|
|
284
|
-
relDir: string,
|
|
285
|
-
limit: number,
|
|
286
|
-
fs: DirectoryFs = NODE_DIRECTORY_FS,
|
|
287
|
-
): Promise<Listing> {
|
|
288
|
-
const absDir = toAbs(root, relDir);
|
|
289
|
-
let dirents: DirectoryEntryInfo[];
|
|
290
|
-
try {
|
|
291
|
-
dirents = await fs.readdir(absDir);
|
|
292
|
-
} catch {
|
|
293
|
-
throw new HttpError(404, "That folder could not be read.");
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const links: Row[] = [];
|
|
297
|
-
const rows: Row[] = dirents.map((dirent) => {
|
|
298
|
-
const row: Row = {
|
|
299
|
-
name: dirent.name,
|
|
300
|
-
rel: relDir ? `${relDir}/${dirent.name}` : dirent.name,
|
|
301
|
-
isDir: dirent.isDirectory(),
|
|
302
|
-
isSymlink: dirent.isSymbolicLink(),
|
|
303
|
-
broken: false,
|
|
304
|
-
size: 0,
|
|
305
|
-
mtimeMs: 0,
|
|
306
|
-
};
|
|
307
|
-
if (row.isSymlink) links.push(row);
|
|
308
|
-
return row;
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
await mapConcurrent(links, LISTING_CONCURRENCY, (row) => resolveSymlink(fs, absDir, row));
|
|
312
|
-
|
|
313
|
-
// Directories first, then case-insensitive name. The sort runs before the cap,
|
|
314
|
-
// so the rows that survive it are the rows that would have been drawn.
|
|
315
|
-
rows.sort((a, b) => {
|
|
316
|
-
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
317
|
-
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
const total = rows.length;
|
|
321
|
-
const truncated = total > limit;
|
|
322
|
-
const kept = truncated ? rows.slice(0, limit) : rows;
|
|
323
|
-
|
|
324
|
-
await mapConcurrent(
|
|
325
|
-
kept.filter((row) => !row.isSymlink),
|
|
326
|
-
LISTING_CONCURRENCY,
|
|
327
|
-
async (row) => {
|
|
328
|
-
try {
|
|
329
|
-
const info = await fs.stat(join(absDir, row.name));
|
|
330
|
-
row.size = info.size;
|
|
331
|
-
row.mtimeMs = info.mtimeMs;
|
|
332
|
-
} catch {
|
|
333
|
-
// Directory entry vanished between readdir and stat.
|
|
334
|
-
}
|
|
335
|
-
},
|
|
336
|
-
);
|
|
337
|
-
|
|
338
|
-
const entries: DirEntry[] = kept.map((row) => {
|
|
339
|
-
const kind: EntryKind = row.isDir ? "directory" : classifyByName(row.name);
|
|
340
|
-
return {
|
|
341
|
-
name: row.name,
|
|
342
|
-
rel: row.rel,
|
|
343
|
-
isDir: row.isDir,
|
|
344
|
-
isSymlink: row.isSymlink,
|
|
345
|
-
broken: row.broken,
|
|
346
|
-
size: row.size,
|
|
347
|
-
mtimeMs: row.mtimeMs,
|
|
348
|
-
kind,
|
|
349
|
-
isImage: !row.isDir && kind === "image",
|
|
350
|
-
};
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
return { entries, total, truncated };
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/** Read a file into a renderable view, honouring the size and line caps. */
|
|
357
|
-
export async function readFileView(
|
|
358
|
-
root: string,
|
|
359
|
-
rel: string,
|
|
360
|
-
maxFileBytes: number,
|
|
361
|
-
maxTextLines: number,
|
|
362
|
-
markdownEnabled = true,
|
|
363
|
-
): Promise<FileView> {
|
|
364
|
-
const abs = toAbs(root, rel);
|
|
365
|
-
let info: Stats;
|
|
366
|
-
try {
|
|
367
|
-
info = await stat(abs);
|
|
368
|
-
} catch {
|
|
369
|
-
throw new HttpError(404, "That file could not be read.");
|
|
370
|
-
}
|
|
371
|
-
if (info.isDirectory()) throw new HttpError(404, "That path is a folder.");
|
|
372
|
-
|
|
373
|
-
const meta: FileMeta = { size: info.size, mtimeMs: info.mtimeMs, mime: contentTypeFor(rel) };
|
|
374
|
-
if (isImageName(rel)) return { kind: "image", ...meta };
|
|
375
|
-
if (info.size > maxFileBytes) return { kind: "tooLarge", ...meta };
|
|
376
|
-
|
|
377
|
-
let buffer: Buffer;
|
|
378
|
-
try {
|
|
379
|
-
buffer = await readFile(abs);
|
|
380
|
-
} catch {
|
|
381
|
-
throw new HttpError(404, "That file could not be read.");
|
|
382
|
-
}
|
|
383
|
-
if (!isProbablyText(buffer)) return { kind: "binary", ...meta };
|
|
384
|
-
|
|
385
|
-
const allLines = buffer.toString("utf-8").split(/\r\n|\r|\n/);
|
|
386
|
-
const truncated = allLines.length > maxTextLines;
|
|
387
|
-
const lines = truncated ? allLines.slice(0, maxTextLines) : allLines;
|
|
388
|
-
const kind = markdownEnabled && isMarkdownName(rel) ? "markdown" : "text";
|
|
389
|
-
if (kind === "markdown") {
|
|
390
|
-
return { kind: "markdown", ...meta, lines, truncated, totalLines: allLines.length };
|
|
391
|
-
}
|
|
392
|
-
return { kind: "text", ...meta, lines, truncated, totalLines: allLines.length };
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
export interface TreeNode {
|
|
396
|
-
name: string;
|
|
397
|
-
rel: string;
|
|
398
|
-
isDir: boolean;
|
|
399
|
-
isImage: boolean;
|
|
400
|
-
/** True for a symlink whose target cannot be resolved. */
|
|
401
|
-
broken: boolean;
|
|
402
|
-
kind: EntryKind;
|
|
403
|
-
children: TreeNode[];
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* Build the sidebar tree: the current path's ancestors are expanded so the
|
|
408
|
-
* active item is always visible, and siblings are shown collapsed.
|
|
409
|
-
*/
|
|
410
|
-
export async function buildTree(root: string, currentRel: string, limit: number): Promise<TreeNode> {
|
|
411
|
-
return buildNode(root, "", currentRel, basename(root) || root, limit);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/** A directory page's two views of one directory. */
|
|
415
|
-
export interface DirectoryView {
|
|
416
|
-
/** The current directory's own rows, for the page's listing. */
|
|
417
|
-
listing: Listing;
|
|
418
|
-
/** The sidebar tree, with the current path expanded. */
|
|
419
|
-
tree: TreeNode;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* One walk for a directory page: the listing it draws, and the tree that shows
|
|
424
|
-
* where it sits.
|
|
425
|
-
*
|
|
426
|
-
* `listDirectory` is what decides whether the directory can be read at all, so it
|
|
427
|
-
* runs first and its 404 is the page's error. The walk then reuses that listing
|
|
428
|
-
* for the same directory instead of reading it a second time; the ancestors are
|
|
429
|
-
* still read once each, which is what the tree needs.
|
|
430
|
-
*/
|
|
431
|
-
export async function directoryView(root: string, currentRel: string, limit: number): Promise<DirectoryView> {
|
|
432
|
-
const listing = await listDirectory(root, currentRel, limit);
|
|
433
|
-
const tree = await buildNode(root, "", currentRel, basename(root) || root, limit, { rel: currentRel, listing });
|
|
434
|
-
return { listing, tree };
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
async function buildNode(
|
|
438
|
-
root: string,
|
|
439
|
-
relDir: string,
|
|
440
|
-
currentRel: string,
|
|
441
|
-
label: string,
|
|
442
|
-
limit: number,
|
|
443
|
-
/** A listing the caller already read for this directory, so it is not re-read. */
|
|
444
|
-
known?: { rel: string; listing: Listing },
|
|
445
|
-
): Promise<TreeNode> {
|
|
446
|
-
const node: TreeNode = {
|
|
447
|
-
name: label,
|
|
448
|
-
rel: relDir,
|
|
449
|
-
isDir: true,
|
|
450
|
-
isImage: false,
|
|
451
|
-
broken: false,
|
|
452
|
-
kind: "directory",
|
|
453
|
-
children: [],
|
|
454
|
-
};
|
|
455
|
-
let listing: Listing;
|
|
456
|
-
if (known && known.rel === relDir) {
|
|
457
|
-
listing = known.listing;
|
|
458
|
-
} else {
|
|
459
|
-
try {
|
|
460
|
-
listing = await listDirectory(root, relDir, limit);
|
|
461
|
-
} catch {
|
|
462
|
-
return node;
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
for (const entry of listing.entries) {
|
|
466
|
-
const child: TreeNode = {
|
|
467
|
-
name: entry.name,
|
|
468
|
-
rel: entry.rel,
|
|
469
|
-
isDir: entry.isDir,
|
|
470
|
-
isImage: entry.isImage,
|
|
471
|
-
broken: entry.broken,
|
|
472
|
-
kind: entry.kind,
|
|
473
|
-
children: [],
|
|
474
|
-
};
|
|
475
|
-
if (entry.isDir) {
|
|
476
|
-
const onPath = currentRel === entry.rel || currentRel.startsWith(`${entry.rel}/`);
|
|
477
|
-
if (onPath) {
|
|
478
|
-
child.children = (await buildNode(root, entry.rel, currentRel, entry.name, limit, known)).children;
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
node.children.push(child);
|
|
482
|
-
}
|
|
483
|
-
return node;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/** Directory names the quick-open index never walks. */
|
|
487
|
-
export const INDEX_IGNORED_DIRS = new Set([".git", "node_modules"]);
|
|
488
|
-
|
|
489
|
-
export interface FileIndex {
|
|
490
|
-
files: string[];
|
|
491
|
-
truncated: boolean;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Walk `root` and collect file paths for the quick-open palette. Symlinks and
|
|
496
|
-
* the ignored directories are skipped, and the walk stops at `limit` files so a
|
|
497
|
-
* large tree cannot exhaust memory or time. Unreadable directories are skipped.
|
|
498
|
-
*/
|
|
499
|
-
export async function buildFileIndex(root: string, limit: number): Promise<FileIndex> {
|
|
500
|
-
const files: string[] = [];
|
|
501
|
-
let truncated = false;
|
|
502
|
-
|
|
503
|
-
async function walk(absDir: string, relDir: string): Promise<void> {
|
|
504
|
-
let dirents: Dirent[];
|
|
505
|
-
try {
|
|
506
|
-
dirents = await readdir(absDir, { withFileTypes: true });
|
|
507
|
-
} catch {
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
dirents.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
511
|
-
for (const dirent of dirents) {
|
|
512
|
-
if (truncated) return;
|
|
513
|
-
if (dirent.isSymbolicLink()) continue;
|
|
514
|
-
const rel = relDir ? `${relDir}/${dirent.name}` : dirent.name;
|
|
515
|
-
if (dirent.isDirectory()) {
|
|
516
|
-
if (INDEX_IGNORED_DIRS.has(dirent.name)) continue;
|
|
517
|
-
await walk(join(absDir, dirent.name), rel);
|
|
518
|
-
} else if (dirent.isFile()) {
|
|
519
|
-
if (files.length >= limit) {
|
|
520
|
-
truncated = true;
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
files.push(rel);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
await walk(root, "");
|
|
529
|
-
files.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()) || a.localeCompare(b));
|
|
530
|
-
return { files, truncated };
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
export type FileIndexProvider = () => Promise<FileIndex>;
|
|
534
|
-
|
|
535
|
-
export interface FileIndexOptions {
|
|
536
|
-
/** How long a built index is reused, in ms (default 10000). */
|
|
537
|
-
ttlMs?: number;
|
|
538
|
-
/** Wall-clock seam; defaults to the real clock. */
|
|
539
|
-
now?: () => number;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
/** A cached `buildFileIndex` for `root`; concurrent calls share one walk. */
|
|
543
|
-
export function createFileIndex(root: string, limit: number, options: FileIndexOptions = {}): FileIndexProvider {
|
|
544
|
-
const ttlMs = options.ttlMs ?? 10_000;
|
|
545
|
-
const now = options.now ?? (() => Date.now());
|
|
546
|
-
let cached: FileIndex | undefined;
|
|
547
|
-
let checkedAt = 0;
|
|
548
|
-
let pending: Promise<FileIndex> | undefined;
|
|
549
|
-
return async () => {
|
|
550
|
-
const time = now();
|
|
551
|
-
if (cached && time - checkedAt < ttlMs) return cached;
|
|
552
|
-
if (pending) return pending;
|
|
553
|
-
pending = buildFileIndex(root, limit)
|
|
554
|
-
.then((index) => {
|
|
555
|
-
cached = index;
|
|
556
|
-
checkedAt = now();
|
|
557
|
-
return index;
|
|
558
|
-
})
|
|
559
|
-
.finally(() => {
|
|
560
|
-
pending = undefined;
|
|
561
|
-
});
|
|
562
|
-
return pending;
|
|
563
|
-
};
|
|
564
|
-
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The HTTP server for the browse extension.
|
|
3
|
-
*
|
|
4
|
-
* Wraps `node:http` around the pure `handleRequest`: it resolves the root once,
|
|
5
|
-
* binds `127.0.0.1`, streams `/raw` responses from disk, feeds the live status
|
|
6
|
-
* channel, and exposes an idempotent `close()`. The request handler is injectable
|
|
7
|
-
* so tests can avoid a socket when they only need routing.
|
|
8
|
-
*
|
|
9
|
-
* The bind, the response writer, the event-stream head, and the close come from
|
|
10
|
-
* `lib/http`, which `plan` shares; what stays here is this server's own request
|
|
11
|
-
* handler, its request count, and the catch that turns a thrown handler into a
|
|
12
|
-
* plain-text 500.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { realpathSync } from "node:fs";
|
|
16
|
-
import { createServer } from "node:http";
|
|
17
|
-
import {
|
|
18
|
-
bindLoopback,
|
|
19
|
-
createCloser,
|
|
20
|
-
openEventStream,
|
|
21
|
-
respond,
|
|
22
|
-
textResponse,
|
|
23
|
-
type ServeResponse,
|
|
24
|
-
} from "../../lib/http/index.ts";
|
|
25
|
-
import { IDLE_STOP_MS, type ServeConfig } from "./config.ts";
|
|
26
|
-
import { createFileIndex } from "./files.ts";
|
|
27
|
-
import type { GitDiffProvider, GitStatusProvider } from "./git.ts";
|
|
28
|
-
import { createLiveChannel, createLiveStatus } from "./live.ts";
|
|
29
|
-
import { handleRequest, type ServeContext } from "./router.ts";
|
|
30
|
-
|
|
31
|
-
export interface FileServer {
|
|
32
|
-
/** Origin URL, e.g. `http://127.0.0.1:49152/`. */
|
|
33
|
-
url: string;
|
|
34
|
-
/** The bound port. */
|
|
35
|
-
port: number;
|
|
36
|
-
/** Resolved absolute root that is actually served. */
|
|
37
|
-
root: string;
|
|
38
|
-
/** Stop listening. Safe to call more than once or before `listen` settles. */
|
|
39
|
-
close(): Promise<void>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface FileServerOptions {
|
|
43
|
-
root: string;
|
|
44
|
-
config: ServeConfig;
|
|
45
|
-
/** Read-only git context for the page header; omitted when unavailable. */
|
|
46
|
-
git?: GitStatusProvider;
|
|
47
|
-
/** Read-only diff provider for the change and diff views; omitted when unavailable. */
|
|
48
|
-
diff?: GitDiffProvider;
|
|
49
|
-
/** Called once the last page has been gone for `idleStopMs`; omitted, the server waits for `close()`. */
|
|
50
|
-
onIdle?: () => void;
|
|
51
|
-
/** Grace before `onIdle`, in ms; defaults to `IDLE_STOP_MS`. */
|
|
52
|
-
idleStopMs?: number;
|
|
53
|
-
/** Override the request handler (tests). */
|
|
54
|
-
handler?: typeof handleRequest;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Start a read-only server rooted at `options.root` on 127.0.0.1. */
|
|
58
|
-
export async function createFileServer(options: FileServerOptions): Promise<FileServer> {
|
|
59
|
-
const root = realpathSync.native(options.root);
|
|
60
|
-
const { config } = options;
|
|
61
|
-
// The status feed's two server-owned facts: how long it has been up, and how
|
|
62
|
-
// many requests it has answered. Both are read per frame, never cached.
|
|
63
|
-
let requests = 0;
|
|
64
|
-
const status = config.live
|
|
65
|
-
? createLiveStatus({
|
|
66
|
-
git: options.git,
|
|
67
|
-
diff: config.gitDiff ? options.diff : undefined,
|
|
68
|
-
startedAt: Date.now(),
|
|
69
|
-
requests: () => requests,
|
|
70
|
-
})
|
|
71
|
-
: undefined;
|
|
72
|
-
const live = status
|
|
73
|
-
? createLiveChannel({
|
|
74
|
-
status,
|
|
75
|
-
intervalMs: config.liveIntervalMs,
|
|
76
|
-
idleMs: options.idleStopMs ?? IDLE_STOP_MS,
|
|
77
|
-
onIdle: options.onIdle,
|
|
78
|
-
})
|
|
79
|
-
: undefined;
|
|
80
|
-
const context: ServeContext = {
|
|
81
|
-
root,
|
|
82
|
-
config,
|
|
83
|
-
git: options.git,
|
|
84
|
-
diff: options.diff,
|
|
85
|
-
index: createFileIndex(root, config.maxIndexEntries),
|
|
86
|
-
live: status,
|
|
87
|
-
};
|
|
88
|
-
const handler = options.handler ?? handleRequest;
|
|
89
|
-
|
|
90
|
-
const server = createServer((request, response) => {
|
|
91
|
-
requests += 1;
|
|
92
|
-
const answer = async (): Promise<ServeResponse> => {
|
|
93
|
-
try {
|
|
94
|
-
return await handler(context, request.method ?? "GET", request.url ?? "/", request.headers);
|
|
95
|
-
} catch {
|
|
96
|
-
return textResponse(500, "Internal error");
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
void answer().then((result) => {
|
|
100
|
-
// A HEAD of the stream is answered as a plain response: there is no body to
|
|
101
|
-
// hold open, and the client would otherwise wait on frames it asked not to get.
|
|
102
|
-
if (!result.stream || !live || (request.method ?? "GET").toUpperCase() === "HEAD") {
|
|
103
|
-
respond(request, response, result);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
// Checked before the head is written: a refusal must not leave a stream the
|
|
107
|
-
// client would wait on. `204` is the one status an event source does not
|
|
108
|
-
// reconnect after, so a page beyond the cap goes quiet instead of retrying.
|
|
109
|
-
if (live.atCapacity()) {
|
|
110
|
-
respond(request, response, { status: 204, headers: { "Cache-Control": "no-store" }, body: "" });
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
openEventStream(response, result);
|
|
114
|
-
const detach = live.attach({
|
|
115
|
-
write: (frame) => {
|
|
116
|
-
response.write(frame);
|
|
117
|
-
},
|
|
118
|
-
end: () => {
|
|
119
|
-
if (!response.writableEnded) response.end();
|
|
120
|
-
},
|
|
121
|
-
});
|
|
122
|
-
request.on("close", () => {
|
|
123
|
-
detach();
|
|
124
|
-
if (!response.writableEnded) response.end();
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
const port = await bindLoopback(server, config.port);
|
|
130
|
-
const closeServer = createCloser(server);
|
|
131
|
-
return {
|
|
132
|
-
url: `http://127.0.0.1:${port}/`,
|
|
133
|
-
port,
|
|
134
|
-
root,
|
|
135
|
-
close: async () => {
|
|
136
|
-
// End the streams first, so a connected page does not hold the close open.
|
|
137
|
-
live?.close();
|
|
138
|
-
await closeServer();
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
}
|