@lijian-ui/dsh-file-manager 0.1.20

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 (169) hide show
  1. package/LICENSE +38 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +66 -0
  4. package/README.zh.md +66 -0
  5. package/cordis.patch.yml +13 -0
  6. package/lib/assets/mermaid.min.js +3587 -0
  7. package/lib/client.js +7621 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/index.js +3207 -0
  10. package/lib/tsconfig.client.tsbuildinfo +1 -0
  11. package/lib/tsconfig.host.tsbuildinfo +1 -0
  12. package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
  13. package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
  14. package/lib/types/client/PluginSettingsCard.d.ts +104 -0
  15. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  16. package/lib/types/client/api.d.ts +75 -0
  17. package/lib/types/client/api.d.ts.map +1 -0
  18. package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
  19. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
  20. package/lib/types/client/chat/file-ref.d.ts +35 -0
  21. package/lib/types/client/chat/file-ref.d.ts.map +1 -0
  22. package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
  23. package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
  24. package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
  25. package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
  26. package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
  27. package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
  28. package/lib/types/client/components/FileIcon.d.ts +15 -0
  29. package/lib/types/client/components/FileIcon.d.ts.map +1 -0
  30. package/lib/types/client/components/ScmPanel.d.ts +23 -0
  31. package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
  32. package/lib/types/client/components/a11y.d.ts +12 -0
  33. package/lib/types/client/components/a11y.d.ts.map +1 -0
  34. package/lib/types/client/components/icons.d.ts +41 -0
  35. package/lib/types/client/components/icons.d.ts.map +1 -0
  36. package/lib/types/client/components/overlay.d.ts +45 -0
  37. package/lib/types/client/components/overlay.d.ts.map +1 -0
  38. package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
  39. package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
  40. package/lib/types/client/drag/file-drag.d.ts +47 -0
  41. package/lib/types/client/drag/file-drag.d.ts.map +1 -0
  42. package/lib/types/client/drag.d.ts +31 -0
  43. package/lib/types/client/drag.d.ts.map +1 -0
  44. package/lib/types/client/file-source.d.ts +29 -0
  45. package/lib/types/client/file-source.d.ts.map +1 -0
  46. package/lib/types/client/fileType.d.ts +31 -0
  47. package/lib/types/client/fileType.d.ts.map +1 -0
  48. package/lib/types/client/floating.d.ts +27 -0
  49. package/lib/types/client/floating.d.ts.map +1 -0
  50. package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
  51. package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
  52. package/lib/types/client/hooks/useStore.d.ts +10 -0
  53. package/lib/types/client/hooks/useStore.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +57 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/layout.d.ts +104 -0
  57. package/lib/types/client/layout.d.ts.map +1 -0
  58. package/lib/types/client/locales.d.ts +125 -0
  59. package/lib/types/client/locales.d.ts.map +1 -0
  60. package/lib/types/client/maximize.d.ts +24 -0
  61. package/lib/types/client/maximize.d.ts.map +1 -0
  62. package/lib/types/client/mention.d.ts +44 -0
  63. package/lib/types/client/mention.d.ts.map +1 -0
  64. package/lib/types/client/mount.d.ts +22 -0
  65. package/lib/types/client/mount.d.ts.map +1 -0
  66. package/lib/types/client/persist.d.ts +55 -0
  67. package/lib/types/client/persist.d.ts.map +1 -0
  68. package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
  69. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
  70. package/lib/types/client/picker/file-picker.d.ts +62 -0
  71. package/lib/types/client/picker/file-picker.d.ts.map +1 -0
  72. package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
  73. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
  74. package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
  75. package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
  76. package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
  77. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
  78. package/lib/types/client/preview/content.d.ts +43 -0
  79. package/lib/types/client/preview/content.d.ts.map +1 -0
  80. package/lib/types/client/preview/markdown.d.ts +60 -0
  81. package/lib/types/client/preview/markdown.d.ts.map +1 -0
  82. package/lib/types/client/preview/mermaid.d.ts +78 -0
  83. package/lib/types/client/preview/mermaid.d.ts.map +1 -0
  84. package/lib/types/client/preview/selection.d.ts +26 -0
  85. package/lib/types/client/preview/selection.d.ts.map +1 -0
  86. package/lib/types/client/reference.d.ts +75 -0
  87. package/lib/types/client/reference.d.ts.map +1 -0
  88. package/lib/types/client/settings-form.d.ts +206 -0
  89. package/lib/types/client/settings-form.d.ts.map +1 -0
  90. package/lib/types/client/store.d.ts +279 -0
  91. package/lib/types/client/store.d.ts.map +1 -0
  92. package/lib/types/core/types.d.ts +132 -0
  93. package/lib/types/core/types.d.ts.map +1 -0
  94. package/lib/types/host/fs-service.d.ts +96 -0
  95. package/lib/types/host/fs-service.d.ts.map +1 -0
  96. package/lib/types/host/gate.d.ts +45 -0
  97. package/lib/types/host/gate.d.ts.map +1 -0
  98. package/lib/types/host/git-runner.d.ts +80 -0
  99. package/lib/types/host/git-runner.d.ts.map +1 -0
  100. package/lib/types/host/git-service.d.ts +130 -0
  101. package/lib/types/host/git-service.d.ts.map +1 -0
  102. package/lib/types/host/loopback.d.ts +25 -0
  103. package/lib/types/host/loopback.d.ts.map +1 -0
  104. package/lib/types/host/poll-guard.d.ts +55 -0
  105. package/lib/types/host/poll-guard.d.ts.map +1 -0
  106. package/lib/types/host/routes.d.ts +54 -0
  107. package/lib/types/host/routes.d.ts.map +1 -0
  108. package/lib/types/index.d.ts +35 -0
  109. package/lib/types/index.d.ts.map +1 -0
  110. package/lib/types/mount-once.d.ts +25 -0
  111. package/lib/types/mount-once.d.ts.map +1 -0
  112. package/package.json +85 -0
  113. package/src/client/FileManagerSettingsCard.tsx +117 -0
  114. package/src/client/PluginSettingsCard.tsx +337 -0
  115. package/src/client/api.ts +141 -0
  116. package/src/client/chat/file-ref.ts +144 -0
  117. package/src/client/chat/mermaid-chat.tsx +102 -0
  118. package/src/client/chat/placeholder-hint.tsx +46 -0
  119. package/src/client/components/ExplorerPanel.tsx +534 -0
  120. package/src/client/components/FileIcon.tsx +46 -0
  121. package/src/client/components/ScmPanel.tsx +480 -0
  122. package/src/client/components/a11y.ts +20 -0
  123. package/src/client/components/icons.tsx +274 -0
  124. package/src/client/components/overlay.tsx +225 -0
  125. package/src/client/css-modules.d.ts +5 -0
  126. package/src/client/drag/DragFileInlay.tsx +89 -0
  127. package/src/client/drag/file-drag.ts +67 -0
  128. package/src/client/drag.ts +154 -0
  129. package/src/client/file-source.ts +58 -0
  130. package/src/client/fileType.ts +129 -0
  131. package/src/client/floating.ts +63 -0
  132. package/src/client/hooks/useResizableSplit.ts +91 -0
  133. package/src/client/hooks/useStore.ts +15 -0
  134. package/src/client/index.ts +389 -0
  135. package/src/client/layout.ts +546 -0
  136. package/src/client/locales.ts +247 -0
  137. package/src/client/maximize.ts +34 -0
  138. package/src/client/mount.tsx +74 -0
  139. package/src/client/persist.ts +194 -0
  140. package/src/client/picker/FilePickerModal.tsx +506 -0
  141. package/src/client/picker/file-picker.ts +86 -0
  142. package/src/client/preview/PreviewPanel.tsx +233 -0
  143. package/src/client/preview/PreviewTabs.tsx +159 -0
  144. package/src/client/preview/PreviewToolbar.tsx +160 -0
  145. package/src/client/preview/content.tsx +617 -0
  146. package/src/client/preview/markdown.ts +351 -0
  147. package/src/client/preview/mermaid.ts +274 -0
  148. package/src/client/preview/selection.ts +82 -0
  149. package/src/client/reference.ts +161 -0
  150. package/src/client/settings-card.module.css +316 -0
  151. package/src/client/settings-form.ts +451 -0
  152. package/src/client/store.ts +1290 -0
  153. package/src/client/styles/chip.module.css +38 -0
  154. package/src/client/styles/drag.module.css +30 -0
  155. package/src/client/styles/explorer.module.css +371 -0
  156. package/src/client/styles/picker.module.css +344 -0
  157. package/src/client/styles/preview.module.css +904 -0
  158. package/src/client/styles/scm.module.css +380 -0
  159. package/src/client/styles/tokens.module.css +480 -0
  160. package/src/core/types.ts +162 -0
  161. package/src/host/fs-service.ts +513 -0
  162. package/src/host/gate.ts +74 -0
  163. package/src/host/git-runner.ts +116 -0
  164. package/src/host/git-service.ts +395 -0
  165. package/src/host/loopback.ts +63 -0
  166. package/src/host/poll-guard.ts +109 -0
  167. package/src/host/routes.ts +695 -0
  168. package/src/index.ts +97 -0
  169. package/src/mount-once.ts +48 -0
@@ -0,0 +1,695 @@
1
+ /**
2
+ * /filemgr/* route layer: JSON envelope (ok/error with stable codes) for
3
+ * the fs/git operations and one SSE stream (git status changes) per project
4
+ * root. The services own gating and parsing; this layer owns HTTP shape and
5
+ * subscriber bookkeeping.
6
+ *
7
+ * There is deliberately no fs-change push: the explorer/preview refresh on
8
+ * user actions and the SCM tab polls git status here, so a recursive
9
+ * fs.watch over the workspace (a measurable cost on large trees) is not
10
+ * needed. Subscribers only exist while an SCM panel is visible, so the
11
+ * git poll below runs only during that window.
12
+ * @module dsh-filemgr/host/routes
13
+ */
14
+
15
+ import type { IncomingMessage, ServerResponse } from 'node:http'
16
+ import { createReadStream } from 'node:fs'
17
+ import { dirname } from 'node:path'
18
+ import { pipeline } from 'node:stream/promises'
19
+ import { readFile, stat } from 'node:fs/promises'
20
+ import { fileURLToPath } from 'node:url'
21
+ import type { Context } from '@deepseek-ai/cordis'
22
+ import type {} from '@deepseek-ai/dsh-host-webserver'
23
+ import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
24
+ import type { PanelEnvelope, PanelError } from '../core/types.ts'
25
+ import type { FsService } from './fs-service.ts'
26
+ import type { GitService } from './git-service.ts'
27
+ import { PollGuard } from './poll-guard.ts'
28
+ import { isLoopbackRequest } from './loopback.ts'
29
+
30
+ const OK = (value: unknown): PanelEnvelope<unknown> => ({ ok: true, value })
31
+ const FAIL = (error: PanelError): PanelEnvelope<never> => ({ ok: false, error })
32
+
33
+ /** Structural request failure (never a workspace fault). */
34
+ const BAD_REQUEST: PanelError = { code: 'internal', message: 'malformed request' }
35
+
36
+ /**
37
+ * Platform argv for "reveal in file manager" (select the entry). Windows
38
+ * Explorer selects via /select,; macOS Finder via open -R; Linux desktops
39
+ * have no select mode, so xdg-open opens the parent directory.
40
+ */
41
+ export function revealArgv(platform: NodeJS.Platform, abs: string): string[] {
42
+ if (platform === 'win32') return ['explorer.exe', `/select,${abs}`]
43
+ if (platform === 'darwin') return ['open', '-R', abs]
44
+ return ['xdg-open', dirname(abs)]
45
+ }
46
+
47
+ /** Platform argv for "open with the default app". */
48
+ export function openArgv(platform: NodeJS.Platform, abs: string): string[] {
49
+ if (platform === 'win32') return ['cmd.exe', '/c', 'start', '', abs]
50
+ if (platform === 'darwin') return ['open', abs]
51
+ return ['xdg-open', abs]
52
+ }
53
+
54
+ /**
55
+ * Spawn one OS GUI command fire-and-forget: Explorer / Finder / xdg-open
56
+ * detach immediately and their exit codes are not meaningful, so nothing is
57
+ * awaited beyond the spawn itself (failures still surface as an error).
58
+ */
59
+ function spawnOsCommand(ctx: Context, argv: string[]): PanelError | null {
60
+ const spec: SubprocessSpawnSpec = {
61
+ argv,
62
+ cwd: dirname(argv[argv.length - 1] ?? process.cwd()),
63
+ stdio: {
64
+ stdin: 'ignore',
65
+ stdout: { maxBytes: 1 << 16 },
66
+ stderr: { maxBytes: 1 << 16 },
67
+ },
68
+ graceMs: 5_000,
69
+ }
70
+ try {
71
+ const handle = ctx.subprocess.spawn(spec)
72
+ void handle.done.catch(() => {})
73
+ return null
74
+ } catch (error) {
75
+ ctx.logger.warn(`dsh-filemgr: OS command failed ([${argv.join(', ')}]): ${String(error)}`)
76
+ return { code: 'internal', message: 'cannot run OS command' }
77
+ }
78
+ }
79
+
80
+ /** One SSE subscriber: a root and its last pushed git signature. */
81
+ interface Subscriber {
82
+ root: string
83
+ lastGit: string
84
+ res: ServerResponse
85
+ }
86
+
87
+ /**
88
+ * Poll interval for git-status changes while subscribers are connected.
89
+ * Kept deliberately long (30s): on Windows a cold git.exe costs ~0.7s per
90
+ * spawn, and the SCM panel already refreshes event-driven (fs watch for
91
+ * file edits) and on window focus — the poll only needs to catch
92
+ * out-of-band .git writes (commits/checkouts from other tools).
93
+ */
94
+ const GIT_POLL_MS = 30_000
95
+ /** SSE keep-alive comment interval (proxies drop idle connections). */
96
+ const HEARTBEAT_MS = 15_000
97
+
98
+ /**
99
+ * Parse a `Range: bytes=start-end` header against the file size. RFC 7233
100
+ * lets a server ignore any Range it does not support, so unknown units,
101
+ * malformed headers and multi-range requests all return null (the caller
102
+ * answers 200 with the full body); only a syntactically valid single range
103
+ * that cannot be satisfied returns 'invalid' (the caller answers 416).
104
+ * Suffix ranges (`bytes=-N`) select the last N bytes. Range support added
105
+ * after human review on #242 (pdf seeking); ignore-instead-of-416 for
106
+ * unsupported shapes per maintainer feedback.
107
+ */
108
+ export function parseRangeHeader(
109
+ header: string | undefined,
110
+ size: number,
111
+ ): { start: number; end: number } | 'invalid' | null {
112
+ if (header === undefined) return null
113
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
114
+ if (match === null || (match[1] === '' && match[2] === '')) return null
115
+ if (match[1] === '') {
116
+ const suffix = Number(match[2])
117
+ if (suffix <= 0 || size === 0) return 'invalid'
118
+ return { start: Math.max(0, size - suffix), end: size - 1 }
119
+ }
120
+ const start = Number(match[1])
121
+ const end = match[2] === '' ? size - 1 : Math.min(Number(match[2]), size - 1)
122
+ if (size === 0 || start > end || start >= size) return 'invalid'
123
+ return { start, end }
124
+ }
125
+
126
+ /** Strip the weak prefix and quotes so entity-tags compare by opaque value. */
127
+ function normalizeEtag(value: string): string {
128
+ return value.trim().replace(/^W\//, '').replace(/^"|"$/g, '')
129
+ }
130
+
131
+ /**
132
+ * Whether an If-None-Match header matches the current etag. Handles `*` and
133
+ * comma-separated entity-tag lists; GET revalidation uses weak comparison
134
+ * (RFC 9110), so the weak prefix is ignored on both sides.
135
+ */
136
+ export function ifNoneMatchSaidFresh(header: string | undefined, etag: string): boolean {
137
+ if (header === undefined) return false
138
+ const current = normalizeEtag(etag)
139
+ return header.split(',').some((candidate) => {
140
+ const tag = candidate.trim()
141
+ return tag === '*' || normalizeEtag(tag) === current
142
+ })
143
+ }
144
+
145
+ /**
146
+ * Deadline for one git-status subprocess inside pollGit. Not an execution
147
+ * timeout — the subprocess' own graceMs limits a single binary run; this is
148
+ * the route layer's guard against a hung status (e.g. a wedged git daemon on
149
+ * a cold path) that would otherwise leave the anti-overlap guard (owned by
150
+ * PollGuard) wedged forever and silence SCM. Owned here so the deadline is independent
151
+ * of any service-level setting.
152
+ */
153
+ const GIT_STATUS_TIMEOUT_MS = 15_000
154
+
155
+ /**
156
+ * PollGuard loop bounds. The poll is stopped by the SSE subscriber lifecycle
157
+ * (start on first subscriber, stop when the last disconnects), so the guard's
158
+ * own deadline is never reached in practice (MAX_SAFE_INTEGER ms ~ no
159
+ * deadline), preserving the former setInterval which kept polling as long as
160
+ * any stream was connected. maxBackoffMs equals the interval so a rejected
161
+ * run retries at the same cadence as a healthy one (interval unchanged).
162
+ */
163
+ const GIT_POLL_DEADLINE_MS = Number.MAX_SAFE_INTEGER
164
+ const GIT_POLL_MAX_BACKOFF_MS = GIT_POLL_MS
165
+
166
+ /** Write the shared non-loopback rejection (same body as dsh-ssh). */
167
+ function forbidden(res: ServerResponse): void {
168
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
169
+ res.end(JSON.stringify({ error: 'forbidden: loopback-only' }))
170
+ }
171
+
172
+ /** Read a JSON request body into an unknown value; null when unparseable. */
173
+ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
174
+ const chunks: Buffer[] = []
175
+ let total = 0
176
+ for await (const chunk of req) {
177
+ const buffer = chunk as Buffer
178
+ chunks.push(buffer)
179
+ total += buffer.length
180
+ if (total > 1 << 20) return null
181
+ }
182
+ const text = Buffer.concat(chunks).toString('utf8')
183
+ if (text === '') return null
184
+ try {
185
+ return JSON.parse(text) as unknown
186
+ } catch {
187
+ return null
188
+ }
189
+ }
190
+
191
+ /** Extract the required string field from a JSON object payload. */
192
+ function strField(payload: unknown, key: string): string | null {
193
+ if (typeof payload !== 'object' || payload === null) return null
194
+ const value = (payload as Record<string, unknown>)[key]
195
+ return typeof value === 'string' && value !== '' ? value : null
196
+ }
197
+
198
+ /** Extract a string field, accepting the empty string as a value. */
199
+ function strOrEmpty(payload: unknown, key: string): string | null {
200
+ if (typeof payload !== 'object' || payload === null) return null
201
+ const value = (payload as Record<string, unknown>)[key]
202
+ return typeof value === 'string' ? value : null
203
+ }
204
+
205
+ /** Extract a string array field (defaults to []). */
206
+ function strArray(payload: unknown, key: string): string[] | null {
207
+ if (typeof payload !== 'object' || payload === null) return null
208
+ const value = (payload as Record<string, unknown>)[key]
209
+ if (value === undefined) return []
210
+ if (!Array.isArray(value)) return null
211
+ if (!value.every((item) => typeof item === 'string')) return null
212
+ return value as string[]
213
+ }
214
+
215
+ /** Write one JSON envelope response. */
216
+ function json(res: ServerResponse, envelope: PanelEnvelope<unknown>, status = 200): void {
217
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
218
+ res.end(JSON.stringify(envelope))
219
+ }
220
+
221
+ /**
222
+ * Register the /filemgr routes (prefix for JSON, exact for the SSE
223
+ * stream — longest-prefix-wins keeps them disjoint).
224
+ * @param ctx - context carrying the webServer service.
225
+ * @param fs - the gated filesystem service.
226
+ * @param git - the gated git service.
227
+ * @returns the route disposers.
228
+ */
229
+ export function registerPanelRoutes(ctx: Context, fs: FsService, git: GitService): () => void {
230
+ const subscribers = new Set<Subscriber>()
231
+ // A single PollGuard instance cannot be restarted after stop(), so a fresh
232
+ // guard is created for each subscriber lifecycle (0 -> 1) and discarded when
233
+ // the last subscriber disconnects — mirroring the former create/clear of the
234
+ // setInterval so polling stays alive while any stream is connected.
235
+ let gitPoll: PollGuard | undefined
236
+ let heartbeatTimer: NodeJS.Timeout | undefined
237
+
238
+ const push = (subscriber: Subscriber, payload: unknown): void => {
239
+ subscriber.res.write(`event: change\ndata: ${JSON.stringify(payload)}\n\n`)
240
+ }
241
+
242
+ // One-shot availability state: a machine without a git binary must not
243
+ // re-spawn ENOENT every 2s tick. The probe result is cached inside the git
244
+ // service, so this runs once, logs at most once, and then git polling stops
245
+ // for the rest of this route instance while fs watching keeps working.
246
+ let gitProbed = false
247
+ let gitUnavailable = false
248
+ // Anti-overlap is owned by PollGuard (at most one run in flight at a time;
249
+ // a tick that arrives mid-run is dropped), replacing the old `polling` bool.
250
+ const pollGit = async (): Promise<void> => {
251
+ if (!gitProbed) {
252
+ gitProbed = true
253
+ if (!(await git.gitAvailable())) {
254
+ gitUnavailable = true
255
+ ctx.logger.warn('dsh-filemgr: git binary unavailable, SCM polling disabled')
256
+ for (const subscriber of subscribers) push(subscriber, { kind: 'gitUnavailable' })
257
+ }
258
+ }
259
+ if (gitUnavailable) return
260
+ await Promise.all([...subscribers].map(async (subscriber) => {
261
+ try {
262
+ // Subscribers were gated when the stream opened, so use the
263
+ // canonical git methods (no double gate per 2s tick). repoOf inside
264
+ // them re-runs `rev-parse --show-toplevel` only after its TTL
265
+ // expires: a non-repo root never spawns a git status, and a repo
266
+ // created or removed while the host is running (git init / deleting
267
+ // .git) is still discovered by a later tick. The poll interval
268
+ // therefore keeps running while any subscriber is connected.
269
+ if (!(await git.isRepositoryCanonical(subscriber.root))) return
270
+ // Capture and clear the timeout handle once the race settles so a
271
+ // fast round never leaves a dead 15s timer pinning the closure.
272
+ let timeout: ReturnType<typeof setTimeout> | undefined
273
+ const status = await Promise.race([
274
+ git.statusCanonical(subscriber.root),
275
+ new Promise<never>((_, reject) => {
276
+ timeout = setTimeout(() => reject(new Error('git status timed out')), GIT_STATUS_TIMEOUT_MS)
277
+ }),
278
+ ]).finally(() => { if (timeout !== undefined) clearTimeout(timeout) })
279
+ if (status === null) return
280
+ const key = `${status.branch}|${JSON.stringify(status.staged)}|${JSON.stringify(status.unstaged)}|${JSON.stringify(status.untracked)}`
281
+ if (key === subscriber.lastGit) return
282
+ subscriber.lastGit = key
283
+ push(subscriber, { kind: 'git', status })
284
+ } catch (error: unknown) {
285
+ ctx.logger.warn(`dsh-filemgr: git poll failed for ${subscriber.root}: ${String(error)}`)
286
+ }
287
+ }))
288
+ }
289
+
290
+ const startGitPoll = (): void => {
291
+ if (gitPoll !== undefined) return
292
+ gitPoll = new PollGuard({
293
+ intervalMs: GIT_POLL_MS,
294
+ deadlineMs: GIT_POLL_DEADLINE_MS,
295
+ maxBackoffMs: GIT_POLL_MAX_BACKOFF_MS,
296
+ onRun: pollGit,
297
+ })
298
+ gitPoll.start()
299
+ }
300
+ const stopGitPoll = (): void => {
301
+ if (gitPoll === undefined) return
302
+ gitPoll.stop()
303
+ gitPoll = undefined
304
+ }
305
+
306
+ /**
307
+ * GET /filemgr/raw: stream one workspace file (markdown image srcs,
308
+ * pdf preview). Gated like every other operation; FsService.readRaw only
309
+ * resolves and stats the path, the bytes are piped straight from disk with
310
+ * the derived mime — the whole file never sits in host memory. Single byte
311
+ * ranges are honored (206/416) so the browser pdf viewer can seek large
312
+ * files; unsupported range shapes are ignored per RFC 7233 (200 full
313
+ * body). ETag/Last-Modified (size+mtime) keep no-cache revalidation cheap:
314
+ * unchanged files answer 304, If-Range mismatches fall back to 200.
315
+ */
316
+ const serveRaw = async (req: IncomingMessage, url: URL, res: ServerResponse): Promise<void> => {
317
+ const root = url.searchParams.get('root')
318
+ const path = url.searchParams.get('path')
319
+ if (root === null || root === '' || path === null || path === '') {
320
+ json(res, FAIL(BAD_REQUEST), 400)
321
+ return
322
+ }
323
+ const result = await fs.readRaw(root, path)
324
+ if (!('abs' in result)) {
325
+ const status = result.code === 'path-outside-root' || result.code === 'is-directory' ? 403 : 404
326
+ json(res, FAIL(result), status)
327
+ return
328
+ }
329
+ // Validators from size+mtime: no-cache forces revalidation, and a match
330
+ // answers 304 instead of re-streaming — scrolling a large pdf issues many
331
+ // range requests, so they must be cheap (maintainer feedback on #242).
332
+ const etag = `W/"${result.size}-${Math.floor(result.mtime)}"`
333
+ const lastModified = new Date(result.mtime).toUTCString()
334
+ const baseHeaders: Record<string, string | number> = {
335
+ 'content-type': result.mime,
336
+ 'cache-control': 'no-cache',
337
+ 'x-content-type-options': 'nosniff',
338
+ 'accept-ranges': 'bytes',
339
+ etag,
340
+ 'last-modified': lastModified,
341
+ }
342
+ if (ifNoneMatchSaidFresh(req.headers['if-none-match'], etag) && req.headers.range === undefined) {
343
+ res.writeHead(304, baseHeaders)
344
+ res.end()
345
+ return
346
+ }
347
+ // If-Range guards a range against a changed file: a mismatch falls back
348
+ // to the full 200 body rather than serving a stale slice.
349
+ const ifRange = req.headers['if-range']
350
+ const range = ifRange !== undefined && ifRange !== etag && ifRange !== lastModified
351
+ ? null
352
+ : parseRangeHeader(req.headers.range, result.size)
353
+ if (range === 'invalid') {
354
+ res.writeHead(416, { ...baseHeaders, 'content-range': `bytes */${result.size}` })
355
+ res.end()
356
+ return
357
+ }
358
+ const headers: Record<string, string | number> = { ...baseHeaders }
359
+ if (range === null) {
360
+ headers['content-length'] = result.size
361
+ res.writeHead(200, headers)
362
+ } else {
363
+ headers['content-range'] = `bytes ${range.start}-${range.end}/${result.size}`
364
+ headers['content-length'] = range.end - range.start + 1
365
+ res.writeHead(206, headers)
366
+ }
367
+ try {
368
+ await pipeline(createReadStream(result.abs, range === null ? undefined : { start: range.start, end: range.end }), res)
369
+ } catch {
370
+ // Client aborted mid-stream or the file vanished after stat: the
371
+ // response is already committed, so just tear it down.
372
+ res.destroy()
373
+ }
374
+ }
375
+
376
+ /**
377
+ * GET /filemgr/vendor/mermaid.js: the mermaid IIFE bundle shipped in
378
+ * the package (lib/assets/mermaid.min.js, copied from the mermaid npm
379
+ * dependency at build time). Same-origin for the browser half (no CDN),
380
+ * loopback-fenced like every other route. One read is cached per plugin
381
+ * instance; the size+mtime pair doubles as the ETag so the browser
382
+ * revalidation is a cheap 304. A missing asset (build without the copy
383
+ * step) 404s and the client keeps plain code blocks.
384
+ */
385
+ let mermaidAsset: { data: Buffer; etag: string } | undefined
386
+ const serveVendorMermaid = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
387
+ if (mermaidAsset === undefined) {
388
+ // Candidate layouts: the built lib half (lib/index.js -> lib/assets/)
389
+ // and the source tree (src/host/routes.ts -> lib/assets), so tests
390
+ // running against src serve the same build-copied asset.
391
+ const candidates = ['./assets/mermaid.min.js', '../../lib/assets/mermaid.min.js']
392
+ for (const relative of candidates) {
393
+ try {
394
+ const assetPath = fileURLToPath(new URL(relative, import.meta.url))
395
+ const [data, info] = await Promise.all([readFile(assetPath), stat(assetPath)])
396
+ mermaidAsset = { data, etag: `"${data.length}-${info.mtimeMs.toString(16)}"` }
397
+ break
398
+ } catch {
399
+ // try the next layout
400
+ }
401
+ }
402
+ if (mermaidAsset === undefined) {
403
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' })
404
+ res.end(JSON.stringify({ error: 'mermaid vendor asset missing' }))
405
+ return
406
+ }
407
+ }
408
+ if (req.headers['if-none-match'] === mermaidAsset.etag) {
409
+ res.writeHead(304, { etag: mermaidAsset.etag })
410
+ res.end()
411
+ return
412
+ }
413
+ res.writeHead(200, {
414
+ 'content-type': 'application/javascript; charset=utf-8',
415
+ 'content-length': mermaidAsset.data.length,
416
+ 'cache-control': 'no-cache',
417
+ etag: mermaidAsset.etag,
418
+ 'x-content-type-options': 'nosniff',
419
+ })
420
+ res.end(mermaidAsset.data)
421
+ }
422
+
423
+ const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
424
+ // Loopback fence first: never let a LAN client reach any /filemgr
425
+ // operation, regardless of method or content-type.
426
+ if (!isLoopbackRequest(req)) {
427
+ forbidden(res)
428
+ return
429
+ }
430
+ if (req.method === 'GET') {
431
+ const url = new URL(req.url ?? '/', 'http://x')
432
+ if (url.pathname === '/filemgr/raw') {
433
+ await serveRaw(req, url, res)
434
+ return
435
+ }
436
+ if (url.pathname === '/filemgr/vendor/mermaid.js') {
437
+ await serveVendorMermaid(req, res)
438
+ return
439
+ }
440
+ res.writeHead(405)
441
+ res.end()
442
+ return
443
+ }
444
+ if (req.method !== 'POST') {
445
+ res.writeHead(405)
446
+ res.end()
447
+ return
448
+ }
449
+ // Require an explicit JSON content-type: cross-site simple requests (no
450
+ // preflight) cannot set application/json, so this blocks form-based CSRF
451
+ // from driving the fs/git routes.
452
+ const contentType = req.headers['content-type'] ?? ''
453
+ if (!contentType.toLowerCase().startsWith('application/json')) {
454
+ json(res, FAIL(BAD_REQUEST), 415)
455
+ return
456
+ }
457
+ const pathname = new URL(req.url ?? '/', 'http://x').pathname
458
+ const payload = await readJsonBody(req)
459
+ if (payload === null) {
460
+ json(res, FAIL(BAD_REQUEST))
461
+ return
462
+ }
463
+ const root = strField(payload, 'root')
464
+ if (root === null) {
465
+ json(res, FAIL(BAD_REQUEST))
466
+ return
467
+ }
468
+ switch (pathname) {
469
+ case '/filemgr/list': {
470
+ const path = strField(payload, 'path') ?? ''
471
+ const result = await fs.list(root, path)
472
+ json(res, 'entries' in result ? OK(result) : FAIL(result))
473
+ return
474
+ }
475
+ case '/filemgr/read': {
476
+ const path = strField(payload, 'path')
477
+ if (path === null) {
478
+ json(res, FAIL(BAD_REQUEST))
479
+ return
480
+ }
481
+ const asImage = typeof payload === 'object' && payload !== null
482
+ ? (payload as Record<string, unknown>).asImage === true
483
+ : false
484
+ const result = await fs.read(root, path, asImage)
485
+ json(res, 'content' in result ? OK(result) : FAIL(result))
486
+ return
487
+ }
488
+ case '/filemgr/write': {
489
+ const path = strField(payload, 'path')
490
+ const content = strOrEmpty(payload, 'content')
491
+ if (path === null || content === null) {
492
+ json(res, FAIL(BAD_REQUEST))
493
+ return
494
+ }
495
+ const rawBase = typeof payload === 'object' && payload !== null
496
+ ? (payload as Record<string, unknown>).baseMtime
497
+ : undefined
498
+ const baseMtime = typeof rawBase === 'number' && Number.isFinite(rawBase) ? rawBase : undefined
499
+ const result = await fs.write(root, path, content, baseMtime)
500
+ json(res, 'mtime' in result ? OK(result) : FAIL(result))
501
+ return
502
+ }
503
+ case '/filemgr/search': {
504
+ const query = strField(payload, 'query') ?? ''
505
+ const result = await fs.search(root, query)
506
+ json(res, 'hits' in result ? OK(result) : FAIL(result))
507
+ return
508
+ }
509
+ case '/filemgr/delete': {
510
+ const path = strField(payload, 'path')
511
+ if (path === null) {
512
+ json(res, FAIL(BAD_REQUEST))
513
+ return
514
+ }
515
+ const result = await fs.delete(root, path)
516
+ json(res, 'ok' in result ? OK(result) : FAIL(result))
517
+ return
518
+ }
519
+ case '/filemgr/reveal': {
520
+ const path = strField(payload, 'path')
521
+ if (path === null) {
522
+ json(res, FAIL(BAD_REQUEST))
523
+ return
524
+ }
525
+ const resolved = await fs.resolveAbsolute(root, path)
526
+ if (!('ok' in resolved)) {
527
+ json(res, FAIL(resolved))
528
+ return
529
+ }
530
+ const error = spawnOsCommand(ctx, revealArgv(process.platform, resolved.abs))
531
+ json(res, error === null ? OK({ ok: true as const }) : FAIL(error))
532
+ return
533
+ }
534
+ case '/filemgr/open-with-default': {
535
+ const path = strField(payload, 'path')
536
+ if (path === null) {
537
+ json(res, FAIL(BAD_REQUEST))
538
+ return
539
+ }
540
+ const resolved = await fs.resolveAbsolute(root, path)
541
+ if (!('ok' in resolved)) {
542
+ json(res, FAIL(resolved))
543
+ return
544
+ }
545
+ const error = spawnOsCommand(ctx, openArgv(process.platform, resolved.abs))
546
+ json(res, error === null ? OK({ ok: true as const }) : FAIL(error))
547
+ return
548
+ }
549
+ case '/filemgr/rename': {
550
+ const path = strField(payload, 'path')
551
+ const newName = strField(payload, 'newName')
552
+ if (path === null || newName === null) {
553
+ json(res, FAIL(BAD_REQUEST))
554
+ return
555
+ }
556
+ const result = await fs.rename(root, path, newName)
557
+ json(res, 'ok' in result ? OK(result) : FAIL(result))
558
+ return
559
+ }
560
+ case '/filemgr/mkdir': {
561
+ const path = strField(payload, 'path')
562
+ if (path === null) {
563
+ json(res, FAIL(BAD_REQUEST))
564
+ return
565
+ }
566
+ const result = await fs.mkdir(root, path)
567
+ json(res, 'ok' in result ? OK(result) : FAIL(result))
568
+ return
569
+ }
570
+ case '/filemgr/new-file': {
571
+ const path = strField(payload, 'path')
572
+ if (path === null) {
573
+ json(res, FAIL(BAD_REQUEST))
574
+ return
575
+ }
576
+ const result = await fs.newFile(root, path)
577
+ json(res, 'ok' in result ? OK(result) : FAIL(result))
578
+ return
579
+ }
580
+ case '/filemgr/git-status': {
581
+ const result = await git.status(root)
582
+ json(res, result === null ? OK(null) : 'root' in result ? OK(result) : FAIL(result))
583
+ return
584
+ }
585
+ case '/filemgr/git-diff': {
586
+ const path = strField(payload, 'path')
587
+ if (path === null) {
588
+ json(res, FAIL(BAD_REQUEST))
589
+ return
590
+ }
591
+ const staged = typeof payload === 'object' && payload !== null
592
+ ? (payload as Record<string, unknown>).staged === true
593
+ : false
594
+ const result = await git.diff(root, path, staged)
595
+ json(res, 'content' in result ? OK(result) : FAIL(result))
596
+ return
597
+ }
598
+ case '/filemgr/git-stage': {
599
+ const paths = strArray(payload, 'paths')
600
+ if (paths === null) {
601
+ json(res, FAIL(BAD_REQUEST))
602
+ return
603
+ }
604
+ const result = await git.stage(root, paths)
605
+ json(res, 'applied' in result ? OK(result) : FAIL(result))
606
+ return
607
+ }
608
+ case '/filemgr/git-unstage': {
609
+ const paths = strArray(payload, 'paths')
610
+ if (paths === null) {
611
+ json(res, FAIL(BAD_REQUEST))
612
+ return
613
+ }
614
+ const result = await git.unstage(root, paths)
615
+ json(res, 'applied' in result ? OK(result) : FAIL(result))
616
+ return
617
+ }
618
+ case '/filemgr/git-discard': {
619
+ const paths = strArray(payload, 'paths')
620
+ if (paths === null) {
621
+ json(res, FAIL(BAD_REQUEST))
622
+ return
623
+ }
624
+ const result = await git.discard(root, paths)
625
+ json(res, 'applied' in result ? OK(result) : FAIL(result))
626
+ return
627
+ }
628
+ default:
629
+ res.writeHead(404)
630
+ res.end()
631
+ }
632
+ }
633
+
634
+ const sse = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
635
+ // Reject non-loopback clients before gating the root or opening the
636
+ // stream: a LAN-exposed deployment must not offer a subscription at all.
637
+ if (!isLoopbackRequest(req)) {
638
+ forbidden(res)
639
+ return
640
+ }
641
+ const url = new URL(req.url ?? '/', 'http://x')
642
+ const root = url.searchParams.get('root')
643
+ if (root === null || root === '') {
644
+ res.writeHead(400)
645
+ res.end()
646
+ return
647
+ }
648
+ // Gate the requested root before opening the stream: an unowned path must
649
+ // not be able to subscribe to watch/git events. The canonical root becomes
650
+ // the subscriber's root for both the watcher and git polling.
651
+ const gated = await fs.verify(root)
652
+ if (!gated.ok) {
653
+ json(res, FAIL(gated.error), 400)
654
+ return
655
+ }
656
+ res.writeHead(200, {
657
+ 'content-type': 'text/event-stream; charset=utf-8',
658
+ 'cache-control': 'no-cache',
659
+ connection: 'keep-alive',
660
+ })
661
+ res.write('retry: 2000\n\n')
662
+ const subscriber: Subscriber = { root: gated.canonical, lastGit: '', res }
663
+ subscribers.add(subscriber)
664
+ // A stream opened after the one-shot probe already failed gets the
665
+ // unavailable event right away; streams open during the probe receive it
666
+ // from the probe's broadcast above.
667
+ if (gitUnavailable) push(subscriber, { kind: 'gitUnavailable' })
668
+ startGitPoll()
669
+ if (heartbeatTimer === undefined) {
670
+ heartbeatTimer = setInterval(() => {
671
+ for (const current of subscribers) current.res.write(': ping\n\n')
672
+ }, HEARTBEAT_MS)
673
+ }
674
+ req.on('close', () => {
675
+ subscribers.delete(subscriber)
676
+ if (subscribers.size === 0) {
677
+ stopGitPoll()
678
+ if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer)
679
+ heartbeatTimer = undefined
680
+ }
681
+ })
682
+ }
683
+
684
+ const disposers = [
685
+ ctx.webServer.register({ kind: 'prefix', path: '/filemgr', handler }),
686
+ ctx.webServer.register({ kind: 'exact', path: '/filemgr/events', handler: sse }),
687
+ ]
688
+ return () => {
689
+ for (const dispose of disposers) dispose()
690
+ stopGitPoll()
691
+ if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer)
692
+ for (const subscriber of subscribers) subscriber.res.end()
693
+ subscribers.clear()
694
+ }
695
+ }