@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.
- package/LICENSE +38 -0
- package/README.i18n.yaml +6 -0
- package/README.md +66 -0
- package/README.zh.md +66 -0
- package/cordis.patch.yml +13 -0
- package/lib/assets/mermaid.min.js +3587 -0
- package/lib/client.js +7621 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3207 -0
- package/lib/tsconfig.client.tsbuildinfo +1 -0
- package/lib/tsconfig.host.tsbuildinfo +1 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +104 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/api.d.ts +75 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
- package/lib/types/client/chat/file-ref.d.ts +35 -0
- package/lib/types/client/chat/file-ref.d.ts.map +1 -0
- package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
- package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
- package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
- package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
- package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
- package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
- package/lib/types/client/components/FileIcon.d.ts +15 -0
- package/lib/types/client/components/FileIcon.d.ts.map +1 -0
- package/lib/types/client/components/ScmPanel.d.ts +23 -0
- package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
- package/lib/types/client/components/a11y.d.ts +12 -0
- package/lib/types/client/components/a11y.d.ts.map +1 -0
- package/lib/types/client/components/icons.d.ts +41 -0
- package/lib/types/client/components/icons.d.ts.map +1 -0
- package/lib/types/client/components/overlay.d.ts +45 -0
- package/lib/types/client/components/overlay.d.ts.map +1 -0
- package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
- package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
- package/lib/types/client/drag/file-drag.d.ts +47 -0
- package/lib/types/client/drag/file-drag.d.ts.map +1 -0
- package/lib/types/client/drag.d.ts +31 -0
- package/lib/types/client/drag.d.ts.map +1 -0
- package/lib/types/client/file-source.d.ts +29 -0
- package/lib/types/client/file-source.d.ts.map +1 -0
- package/lib/types/client/fileType.d.ts +31 -0
- package/lib/types/client/fileType.d.ts.map +1 -0
- package/lib/types/client/floating.d.ts +27 -0
- package/lib/types/client/floating.d.ts.map +1 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
- package/lib/types/client/hooks/useStore.d.ts +10 -0
- package/lib/types/client/hooks/useStore.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +57 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/layout.d.ts +104 -0
- package/lib/types/client/layout.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +125 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/maximize.d.ts +24 -0
- package/lib/types/client/maximize.d.ts.map +1 -0
- package/lib/types/client/mention.d.ts +44 -0
- package/lib/types/client/mention.d.ts.map +1 -0
- package/lib/types/client/mount.d.ts +22 -0
- package/lib/types/client/mount.d.ts.map +1 -0
- package/lib/types/client/persist.d.ts +55 -0
- package/lib/types/client/persist.d.ts.map +1 -0
- package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
- package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
- package/lib/types/client/picker/file-picker.d.ts +62 -0
- package/lib/types/client/picker/file-picker.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
- package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
- package/lib/types/client/preview/content.d.ts +43 -0
- package/lib/types/client/preview/content.d.ts.map +1 -0
- package/lib/types/client/preview/markdown.d.ts +60 -0
- package/lib/types/client/preview/markdown.d.ts.map +1 -0
- package/lib/types/client/preview/mermaid.d.ts +78 -0
- package/lib/types/client/preview/mermaid.d.ts.map +1 -0
- package/lib/types/client/preview/selection.d.ts +26 -0
- package/lib/types/client/preview/selection.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +75 -0
- package/lib/types/client/reference.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +206 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/client/store.d.ts +279 -0
- package/lib/types/client/store.d.ts.map +1 -0
- package/lib/types/core/types.d.ts +132 -0
- package/lib/types/core/types.d.ts.map +1 -0
- package/lib/types/host/fs-service.d.ts +96 -0
- package/lib/types/host/fs-service.d.ts.map +1 -0
- package/lib/types/host/gate.d.ts +45 -0
- package/lib/types/host/gate.d.ts.map +1 -0
- package/lib/types/host/git-runner.d.ts +80 -0
- package/lib/types/host/git-runner.d.ts.map +1 -0
- package/lib/types/host/git-service.d.ts +130 -0
- package/lib/types/host/git-service.d.ts.map +1 -0
- package/lib/types/host/loopback.d.ts +25 -0
- package/lib/types/host/loopback.d.ts.map +1 -0
- package/lib/types/host/poll-guard.d.ts +55 -0
- package/lib/types/host/poll-guard.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +54 -0
- package/lib/types/host/routes.d.ts.map +1 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/package.json +85 -0
- package/src/client/FileManagerSettingsCard.tsx +117 -0
- package/src/client/PluginSettingsCard.tsx +337 -0
- package/src/client/api.ts +141 -0
- package/src/client/chat/file-ref.ts +144 -0
- package/src/client/chat/mermaid-chat.tsx +102 -0
- package/src/client/chat/placeholder-hint.tsx +46 -0
- package/src/client/components/ExplorerPanel.tsx +534 -0
- package/src/client/components/FileIcon.tsx +46 -0
- package/src/client/components/ScmPanel.tsx +480 -0
- package/src/client/components/a11y.ts +20 -0
- package/src/client/components/icons.tsx +274 -0
- package/src/client/components/overlay.tsx +225 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/drag/DragFileInlay.tsx +89 -0
- package/src/client/drag/file-drag.ts +67 -0
- package/src/client/drag.ts +154 -0
- package/src/client/file-source.ts +58 -0
- package/src/client/fileType.ts +129 -0
- package/src/client/floating.ts +63 -0
- package/src/client/hooks/useResizableSplit.ts +91 -0
- package/src/client/hooks/useStore.ts +15 -0
- package/src/client/index.ts +389 -0
- package/src/client/layout.ts +546 -0
- package/src/client/locales.ts +247 -0
- package/src/client/maximize.ts +34 -0
- package/src/client/mount.tsx +74 -0
- package/src/client/persist.ts +194 -0
- package/src/client/picker/FilePickerModal.tsx +506 -0
- package/src/client/picker/file-picker.ts +86 -0
- package/src/client/preview/PreviewPanel.tsx +233 -0
- package/src/client/preview/PreviewTabs.tsx +159 -0
- package/src/client/preview/PreviewToolbar.tsx +160 -0
- package/src/client/preview/content.tsx +617 -0
- package/src/client/preview/markdown.ts +351 -0
- package/src/client/preview/mermaid.ts +274 -0
- package/src/client/preview/selection.ts +82 -0
- package/src/client/reference.ts +161 -0
- package/src/client/settings-card.module.css +316 -0
- package/src/client/settings-form.ts +451 -0
- package/src/client/store.ts +1290 -0
- package/src/client/styles/chip.module.css +38 -0
- package/src/client/styles/drag.module.css +30 -0
- package/src/client/styles/explorer.module.css +371 -0
- package/src/client/styles/picker.module.css +344 -0
- package/src/client/styles/preview.module.css +904 -0
- package/src/client/styles/scm.module.css +380 -0
- package/src/client/styles/tokens.module.css +480 -0
- package/src/core/types.ts +162 -0
- package/src/host/fs-service.ts +513 -0
- package/src/host/gate.ts +74 -0
- package/src/host/git-runner.ts +116 -0
- package/src/host/git-service.ts +395 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/poll-guard.ts +109 -0
- package/src/host/routes.ts +695 -0
- package/src/index.ts +97 -0
- 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
|
+
}
|