dsh-plugin-workbench 0.0.4 → 0.0.6
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/CHANGELOG.md +65 -2
- package/README.md +29 -7
- package/lib/client.js +6770 -117
- package/lib/client.js.map +1 -1
- package/lib/index.js +467 -7
- package/package.json +2 -1
- package/src/client/FileExplorer.tsx +740 -13
- package/src/client/FilePreview.tsx +279 -17
- package/src/client/files.module.css +389 -3
- package/src/client/highlight.ts +18 -1
- package/src/client/index.ts +27 -4
- package/src/client/locales.ts +72 -0
- package/src/client/markdown.ts +69 -0
- package/src/client/store.ts +157 -2
- package/src/dsh.d.ts +9 -0
- package/src/index.ts +601 -10
package/src/index.ts
CHANGED
|
@@ -2,14 +2,39 @@
|
|
|
2
2
|
* Host half of the workbench plugin.
|
|
3
3
|
*
|
|
4
4
|
* Registers one loopback-only generic RPC channel (`/dsh-plugin-files`) with
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* list/read/write endpoints over `ctx.fs` (the sandboxed filesystem service),
|
|
6
|
+
* plus New File / New Folder / Rename / Delete / Copy for the explorer's
|
|
7
|
+
* context menu and a `reveal` endpoint that shows an item in the OS file manager. Reads pass through untouched in every sandbox mode; the context-menu
|
|
8
|
+
* mutations and the editor write are explicit user actions over the
|
|
9
|
+
* loopback-only channel, run unfenced like the /api write tools — the sandbox
|
|
10
|
+
* service has no mkdir/rename/rm primitives, so those use node:fs/promises
|
|
11
|
+
* after the same `ctx.fs.resolve` → `processPath` resolution as every read.
|
|
12
|
+
*
|
|
13
|
+
* Image preview: files with a known image extension are never read over the
|
|
14
|
+
* RPC channel. Instead a same-origin web route (`ctx.webServer`) serves their
|
|
15
|
+
* raw bytes straight into the preview pane's `<img>` tag — the same pattern
|
|
16
|
+
* dsh-plugin-image-tools uses for inline chat images. The route resolves and
|
|
17
|
+
* stats the path through `ctx.fs` (so sandbox containment and file-ness apply
|
|
18
|
+
* exactly as for RPC reads) and only ever serves image extensions.
|
|
19
|
+
*
|
|
20
|
+
* Disk watching: the client keeps the host's watch set in sync with the open
|
|
21
|
+
* tabs via the `watch` endpoint. The host watches each file's PARENT DIRECTORY
|
|
22
|
+
* with `fs.watch` (survives atomic editor renames on Windows, unlike watching
|
|
23
|
+
* the file itself), coalesces events per path, and pushes `change` frames over
|
|
24
|
+
* a same-origin SSE route (`/dsh-plugin-files/events`) that the preview pane
|
|
25
|
+
* consumes. Writes this plugin performs itself are suppressed for a short
|
|
26
|
+
* window so a save never bounces back as a "changed on disk" event.
|
|
8
27
|
*/
|
|
28
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
29
|
+
import { spawn } from 'node:child_process'
|
|
30
|
+
import { watch as watchFs } from 'node:fs'
|
|
31
|
+
import type { FSWatcher } from 'node:fs'
|
|
32
|
+
import { basename, dirname } from 'node:path'
|
|
33
|
+
import { mkdir, rename as renameFs, rm, cp, writeFile as writeFileNode } from 'node:fs/promises'
|
|
9
34
|
import type { Context } from '@deepseek-ai/cordis'
|
|
10
35
|
|
|
11
36
|
export const name = 'dsh-plugin-workbench'
|
|
12
|
-
export const inject = ['fs', 'connection']
|
|
37
|
+
export const inject = ['fs', 'connection', 'webServer']
|
|
13
38
|
|
|
14
39
|
/** Loopback-only logical RPC channel. */
|
|
15
40
|
export const CHANNEL = '/dsh-plugin-files'
|
|
@@ -17,6 +42,48 @@ export const CHANNEL = '/dsh-plugin-files'
|
|
|
17
42
|
/** Files larger than this are never read for preview (client shows size + hint). */
|
|
18
43
|
export const MAX_PREVIEW_BYTES = 512 * 1024
|
|
19
44
|
|
|
45
|
+
/** Same-origin route serving raw bytes for image files (see module doc). */
|
|
46
|
+
export const RAW_PREFIX = '/dsh-plugin-files/raw'
|
|
47
|
+
|
|
48
|
+
/** Same-origin SSE route streaming disk-change events to the preview pane. */
|
|
49
|
+
export const EVENTS_PREFIX = '/dsh-plugin-files/events'
|
|
50
|
+
|
|
51
|
+
/** Images larger than this are never served to the preview (browser shows a hint). */
|
|
52
|
+
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
53
|
+
|
|
54
|
+
/** Coalesce bursty editor writes into a single change notification. */
|
|
55
|
+
const WATCH_DEBOUNCE_MS = 300
|
|
56
|
+
|
|
57
|
+
/** Ignore fs.watch events caused by this plugin's own saves (see module doc). */
|
|
58
|
+
const SELF_WRITE_WINDOW_MS = 1500
|
|
59
|
+
|
|
60
|
+
/** SSE keep-alive interval (proxies may otherwise drop idle connections). */
|
|
61
|
+
const SSE_HEARTBEAT_MS = 15000
|
|
62
|
+
|
|
63
|
+
const IMAGE_MIME: Record<string, string> = {
|
|
64
|
+
png: 'image/png',
|
|
65
|
+
jpg: 'image/jpeg',
|
|
66
|
+
jpeg: 'image/jpeg',
|
|
67
|
+
gif: 'image/gif',
|
|
68
|
+
webp: 'image/webp',
|
|
69
|
+
avif: 'image/avif',
|
|
70
|
+
bmp: 'image/bmp',
|
|
71
|
+
ico: 'image/x-icon',
|
|
72
|
+
svg: 'image/svg+xml',
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** MIME type for a path's extension when it names a previewable image; undefined otherwise. */
|
|
76
|
+
export function imageMimeOf(path: string): string | undefined {
|
|
77
|
+
const idx = path.lastIndexOf('.')
|
|
78
|
+
if (idx < 0 || idx === path.length - 1) return undefined
|
|
79
|
+
return IMAGE_MIME[path.slice(idx + 1).toLowerCase()]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True when the path names a previewable image file. */
|
|
83
|
+
export function isImagePath(path: string): boolean {
|
|
84
|
+
return imageMimeOf(path) !== undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
20
87
|
export type FsKind = 'dir' | 'file' | 'other'
|
|
21
88
|
|
|
22
89
|
export interface FsListEntry {
|
|
@@ -44,8 +111,13 @@ export interface FsWriteResult {
|
|
|
44
111
|
size: number
|
|
45
112
|
}
|
|
46
113
|
|
|
47
|
-
|
|
48
|
-
export
|
|
114
|
+
/** Result of a context-menu mutation (create/rename/delete). */
|
|
115
|
+
export interface FsMutationResult {
|
|
116
|
+
path: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type FilesRpcOk = { ok: true; value: FsListResult | FsReadResult | FsWriteResult | FsMutationResult }
|
|
120
|
+
export type FilesRpcErr = { ok: false; error: { code: string; message: string; details: Record<string, never> } }
|
|
49
121
|
export type FilesRpcResult = FilesRpcOk | FilesRpcErr
|
|
50
122
|
|
|
51
123
|
/**
|
|
@@ -98,6 +170,15 @@ const FS_ERROR_MESSAGES: Record<string, string> = {
|
|
|
98
170
|
FS_SANDBOX_DENIED: 'sandbox denied',
|
|
99
171
|
FS_ABORTED: 'aborted',
|
|
100
172
|
FS_IO_ERROR: 'io error',
|
|
173
|
+
// node:fs error codes surfaced by the context-menu mutations below.
|
|
174
|
+
EEXIST: 'file or folder already exists',
|
|
175
|
+
ENOENT: 'path does not exist',
|
|
176
|
+
ENOTEMPTY: 'folder is not empty',
|
|
177
|
+
EPERM: 'permission denied',
|
|
178
|
+
EACCES: 'permission denied',
|
|
179
|
+
ENOTDIR: 'not a directory',
|
|
180
|
+
EISDIR: 'is a directory',
|
|
181
|
+
EBUSY: 'file is in use',
|
|
101
182
|
}
|
|
102
183
|
|
|
103
184
|
/** Human-readable message for a thrown value, honoring the fs error code taxonomy. */
|
|
@@ -131,13 +212,298 @@ function pathOf(payload: unknown): string | undefined {
|
|
|
131
212
|
* cancels the underlying fs call (or aborts between steps).
|
|
132
213
|
*/
|
|
133
214
|
export function apply(ctx: Context): void {
|
|
215
|
+
// Per-apply watch state: created here (not module-level) so disable/reload
|
|
216
|
+
// cycles never leak watchers or SSE clients across applies.
|
|
217
|
+
const watchState: WatchState = {
|
|
218
|
+
dirs: new Map(),
|
|
219
|
+
files: new Map(),
|
|
220
|
+
selfWrites: new Map(),
|
|
221
|
+
clients: new Set(),
|
|
222
|
+
}
|
|
223
|
+
|
|
134
224
|
const handler = async (endpoint: string, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> => {
|
|
135
225
|
if (endpoint === 'list') return listDir(ctx, payload, signal)
|
|
136
226
|
if (endpoint === 'read') return readFile(ctx, payload, signal)
|
|
137
|
-
if (endpoint === 'write') return writeFile(ctx, payload, signal)
|
|
227
|
+
if (endpoint === 'write') return writeFile(ctx, watchState, payload, signal)
|
|
228
|
+
if (endpoint === 'watch') return setWatch(ctx, watchState, payload, signal)
|
|
229
|
+
if (endpoint === 'createFile') return createFile(ctx, payload, signal)
|
|
230
|
+
if (endpoint === 'createDir') return createDir(ctx, payload, signal)
|
|
231
|
+
if (endpoint === 'rename') return renameEntry(ctx, payload, signal)
|
|
232
|
+
if (endpoint === 'delete') return deleteEntry(ctx, payload, signal)
|
|
233
|
+
if (endpoint === 'copy') return copyEntry(ctx, payload, signal)
|
|
234
|
+
if (endpoint === 'reveal') return revealInExplorer(ctx, payload, signal)
|
|
138
235
|
return fail(`unknown endpoint: ${endpoint}`)
|
|
139
236
|
}
|
|
140
|
-
|
|
237
|
+
// Effect-wrapped so HMR/disable cycles dispose the channel (the connection
|
|
238
|
+
// service registers the HTTP carrier for the channel the same way); a plain
|
|
239
|
+
// call here would leak the route on reload and collide on re-apply.
|
|
240
|
+
ctx.effect(() => ctx.connection.rpc.handle(CHANNEL, handler, { authority: 'loopback' }), 'dsh-plugin-workbench: files rpc channel')
|
|
241
|
+
|
|
242
|
+
// Raw image bytes for the preview pane. The client builds the URL as
|
|
243
|
+
// `${RAW_PREFIX}/${encodeURIComponent(path)}`; the suffix is decoded back to
|
|
244
|
+
// the absolute path, then resolved/statted through the sandboxed fs service
|
|
245
|
+
// before any byte is read (containment parity with the RPC read endpoint).
|
|
246
|
+
ctx.effect(() => ctx.webServer.register({
|
|
247
|
+
kind: 'prefix',
|
|
248
|
+
path: RAW_PREFIX,
|
|
249
|
+
handler: (req, res) => {
|
|
250
|
+
void serveRaw(ctx, req, res)
|
|
251
|
+
},
|
|
252
|
+
}), 'dsh-plugin-workbench: raw image route')
|
|
253
|
+
|
|
254
|
+
// Disk-change stream: the SSE route + heartbeat + watcher lifecycle live in
|
|
255
|
+
// one effect so disposal closes every client and every fs.watch handle.
|
|
256
|
+
ctx.effect(() => {
|
|
257
|
+
const disposeRoute = ctx.webServer.register({
|
|
258
|
+
kind: 'exact',
|
|
259
|
+
path: EVENTS_PREFIX,
|
|
260
|
+
handler: (req, res) => sseHandler(watchState, req, res),
|
|
261
|
+
})
|
|
262
|
+
const heartbeat = setInterval(() => {
|
|
263
|
+
for (const res of watchState.clients) {
|
|
264
|
+
try {
|
|
265
|
+
res.write(': ping\n\n')
|
|
266
|
+
} catch {
|
|
267
|
+
// Dropped client — removed by its own 'close' event.
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}, SSE_HEARTBEAT_MS)
|
|
271
|
+
return () => {
|
|
272
|
+
clearInterval(heartbeat)
|
|
273
|
+
disposeRoute()
|
|
274
|
+
disposeWatch(watchState)
|
|
275
|
+
}
|
|
276
|
+
}, 'dsh-plugin-workbench: disk change stream (SSE)')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Disk watching (open-tab change detection)
|
|
281
|
+
//
|
|
282
|
+
// The client sends the full set of open tab paths; the host diffs it against
|
|
283
|
+
// the current watcher set. Each watched file lives under an `fs.watch` on its
|
|
284
|
+
// PARENT DIRECTORY (file-level handles die when editors atomic-rename, and
|
|
285
|
+
// directory watching also sees deletes), events are filtered by basename,
|
|
286
|
+
// coalesced per path, and pushed over the SSE route. `watch` is a pure
|
|
287
|
+
// reconciliation — call it as often as you like.
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/** Per-apply watch registry (see the WatchState fields inline). */
|
|
291
|
+
interface WatchState {
|
|
292
|
+
/** Parent dir → its fs.watch handle and the watched basenames living in it. */
|
|
293
|
+
dirs: Map<string, { watcher: FSWatcher; basenames: Map<string, Set<string>> }>
|
|
294
|
+
/** OS path → client-facing path + pending coalescing timer. */
|
|
295
|
+
files: Map<string, { path: string; timer: ReturnType<typeof setTimeout> | undefined }>
|
|
296
|
+
/** OS path → timestamp of the plugin's own last write (self-change suppression). */
|
|
297
|
+
selfWrites: Map<string, number>
|
|
298
|
+
/** Connected SSE responses. */
|
|
299
|
+
clients: Set<ServerResponse>
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Serve one SSE client connection (kept open until the browser disconnects). */
|
|
303
|
+
function sseHandler(state: WatchState, req: IncomingMessage, res: ServerResponse): void {
|
|
304
|
+
if ((req.method ?? 'GET').toUpperCase() !== 'GET') {
|
|
305
|
+
res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' })
|
|
306
|
+
res.end('method not allowed')
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
res.writeHead(200, {
|
|
310
|
+
'content-type': 'text/event-stream',
|
|
311
|
+
'cache-control': 'no-cache',
|
|
312
|
+
connection: 'keep-alive',
|
|
313
|
+
'x-accel-buffering': 'no',
|
|
314
|
+
})
|
|
315
|
+
res.write(': connected\n\n')
|
|
316
|
+
state.clients.add(res)
|
|
317
|
+
req.on('close', () => {
|
|
318
|
+
state.clients.delete(res)
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Push one `change` frame to every connected SSE client. */
|
|
323
|
+
function emitChange(state: WatchState, path: string): void {
|
|
324
|
+
const frame = `event: change\ndata: ${JSON.stringify({ path })}\n\n`
|
|
325
|
+
for (const res of state.clients) {
|
|
326
|
+
try {
|
|
327
|
+
res.write(frame)
|
|
328
|
+
} catch {
|
|
329
|
+
// Client gone — dropped from the set by its 'close' event.
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Coalesce one filesystem event for an osPath into a single change
|
|
336
|
+
* notification (editors emit several events per save; the debounce collapses
|
|
337
|
+
* them). Events caused by this plugin's own saves are suppressed.
|
|
338
|
+
*/
|
|
339
|
+
function scheduleEmit(state: WatchState, osPath: string): void {
|
|
340
|
+
const entry = state.files.get(osPath)
|
|
341
|
+
if (entry === undefined) return
|
|
342
|
+
const selfTs = state.selfWrites.get(osPath)
|
|
343
|
+
if (selfTs !== undefined && Date.now() - selfTs < SELF_WRITE_WINDOW_MS) return
|
|
344
|
+
if (entry.timer !== undefined) clearTimeout(entry.timer)
|
|
345
|
+
entry.timer = setTimeout(() => {
|
|
346
|
+
entry.timer = undefined
|
|
347
|
+
const still = state.files.get(osPath)
|
|
348
|
+
if (still === undefined) return
|
|
349
|
+
emitChange(state, still.path)
|
|
350
|
+
}, WATCH_DEBOUNCE_MS)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Start (or extend) the parent-dir watcher covering osPath. */
|
|
354
|
+
function watchDir(state: WatchState, dir: string, osPath: string): boolean {
|
|
355
|
+
let bucket = state.dirs.get(dir)
|
|
356
|
+
if (bucket === undefined) {
|
|
357
|
+
let watcher: FSWatcher
|
|
358
|
+
try {
|
|
359
|
+
watcher = watchFs(dir, { persistent: false }, (_eventType, filename) => {
|
|
360
|
+
const name = typeof filename === 'string' ? filename : undefined
|
|
361
|
+
if (name === undefined) {
|
|
362
|
+
// Platform omitted the filename: re-emit for every watched file here.
|
|
363
|
+
for (const os of state.files.keys()) {
|
|
364
|
+
if (dirname(os) === dir) scheduleEmit(state, os)
|
|
365
|
+
}
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
const bucketNow = state.dirs.get(dir)
|
|
369
|
+
const targets = bucketNow?.basenames.get(name)
|
|
370
|
+
if (targets === undefined) return
|
|
371
|
+
for (const os of targets) scheduleEmit(state, os)
|
|
372
|
+
})
|
|
373
|
+
} catch {
|
|
374
|
+
return false
|
|
375
|
+
}
|
|
376
|
+
bucket = { watcher, basenames: new Map() }
|
|
377
|
+
state.dirs.set(dir, bucket)
|
|
378
|
+
}
|
|
379
|
+
const name = basename(osPath)
|
|
380
|
+
let targets = bucket.basenames.get(name)
|
|
381
|
+
if (targets === undefined) {
|
|
382
|
+
targets = new Set()
|
|
383
|
+
bucket.basenames.set(name, targets)
|
|
384
|
+
}
|
|
385
|
+
targets.add(osPath)
|
|
386
|
+
return true
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Stop watching one osPath (and its parent dir when nothing else uses it). */
|
|
390
|
+
function unwatch(state: WatchState, osPath: string): void {
|
|
391
|
+
const entry = state.files.get(osPath)
|
|
392
|
+
if (entry === undefined) return
|
|
393
|
+
if (entry.timer !== undefined) clearTimeout(entry.timer)
|
|
394
|
+
state.files.delete(osPath)
|
|
395
|
+
state.selfWrites.delete(osPath)
|
|
396
|
+
const dir = dirname(osPath)
|
|
397
|
+
const bucket = state.dirs.get(dir)
|
|
398
|
+
if (bucket === undefined) return
|
|
399
|
+
const name = basename(osPath)
|
|
400
|
+
const targets = bucket.basenames.get(name)
|
|
401
|
+
if (targets !== undefined) {
|
|
402
|
+
targets.delete(osPath)
|
|
403
|
+
if (targets.size === 0) bucket.basenames.delete(name)
|
|
404
|
+
}
|
|
405
|
+
if (bucket.basenames.size === 0) {
|
|
406
|
+
bucket.watcher.close()
|
|
407
|
+
state.dirs.delete(dir)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Close every watcher, pending timer and SSE client (effect disposal). */
|
|
412
|
+
function disposeWatch(state: WatchState): void {
|
|
413
|
+
for (const bucket of state.dirs.values()) bucket.watcher.close()
|
|
414
|
+
state.dirs.clear()
|
|
415
|
+
for (const entry of state.files.values()) {
|
|
416
|
+
if (entry.timer !== undefined) clearTimeout(entry.timer)
|
|
417
|
+
}
|
|
418
|
+
state.files.clear()
|
|
419
|
+
state.selfWrites.clear()
|
|
420
|
+
for (const res of state.clients) {
|
|
421
|
+
try {
|
|
422
|
+
res.end()
|
|
423
|
+
} catch {
|
|
424
|
+
// Already closed.
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
state.clients.clear()
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Reconcile the watcher set with the client's open tab paths (idempotent). */
|
|
431
|
+
async function setWatch(ctx: Context, state: WatchState, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
432
|
+
const raw = typeof payload === 'object' && payload !== null ? (payload as { paths?: unknown }).paths : undefined
|
|
433
|
+
if (!Array.isArray(raw) || raw.some((p) => typeof p !== 'string')) {
|
|
434
|
+
return fail('watch: payload.paths must be an array of strings')
|
|
435
|
+
}
|
|
436
|
+
// Resolve each requested path to its OS path. Unresolvable paths (deleted,
|
|
437
|
+
// sandboxed) are skipped; the next sync round retries them.
|
|
438
|
+
const wanted = new Map<string, string>()
|
|
439
|
+
for (const p of raw as string[]) {
|
|
440
|
+
if (signal.aborted) break
|
|
441
|
+
try {
|
|
442
|
+
const target = await ctx.fs.resolve(p, { signal })
|
|
443
|
+
const osPath = ctx.fs.processPath(target)
|
|
444
|
+
// Key by OS path, keep the client's display path for the emit.
|
|
445
|
+
wanted.set(osPath, p)
|
|
446
|
+
} catch {
|
|
447
|
+
// Not resolvable this round — drop it.
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// Remove watchers no longer wanted.
|
|
451
|
+
for (const osPath of [...state.files.keys()]) {
|
|
452
|
+
if (!wanted.has(osPath)) unwatch(state, osPath)
|
|
453
|
+
}
|
|
454
|
+
// Start missing watchers.
|
|
455
|
+
for (const [osPath, displayPath] of wanted) {
|
|
456
|
+
if (state.files.has(osPath)) continue
|
|
457
|
+
if (!watchDir(state, dirname(osPath), osPath)) continue
|
|
458
|
+
state.files.set(osPath, { path: displayPath, timer: undefined })
|
|
459
|
+
}
|
|
460
|
+
return { ok: true, value: { path: '' } }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function serveRaw(ctx: Context, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
464
|
+
const text = (code: number, body: string): void => {
|
|
465
|
+
res.writeHead(code, { 'content-type': 'text/plain; charset=utf-8' })
|
|
466
|
+
res.end(body)
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
if ((req.method ?? 'GET').toUpperCase() !== 'GET') {
|
|
470
|
+
text(405, 'method not allowed')
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
474
|
+
const rest = url.pathname.slice(RAW_PREFIX.length).replace(/^\/+/, '')
|
|
475
|
+
if (rest.length === 0) {
|
|
476
|
+
text(404, 'not found')
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
const path = decodeURIComponent(rest)
|
|
480
|
+
const mime = imageMimeOf(path)
|
|
481
|
+
if (mime === undefined) {
|
|
482
|
+
text(404, 'not an image')
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
const target = await ctx.fs.resolve(path)
|
|
486
|
+
const info = await ctx.fs.stat(target)
|
|
487
|
+
if (info === undefined || info.type !== 'file') {
|
|
488
|
+
text(404, 'not found')
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
if ((info.size ?? 0) > MAX_IMAGE_BYTES) {
|
|
492
|
+
text(413, 'image too large')
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
const bytes = await ctx.fs.readBytes(target, undefined, MAX_IMAGE_BYTES)
|
|
496
|
+
res.writeHead(200, {
|
|
497
|
+
'content-type': mime,
|
|
498
|
+
'content-length': bytes.byteLength,
|
|
499
|
+
'cache-control': 'private, max-age=300',
|
|
500
|
+
'x-content-type-options': 'nosniff',
|
|
501
|
+
})
|
|
502
|
+
res.end(Buffer.from(bytes))
|
|
503
|
+
} catch (error) {
|
|
504
|
+
if (!res.headersSent) text(500, 'internal error')
|
|
505
|
+
else res.destroy()
|
|
506
|
+
}
|
|
141
507
|
}
|
|
142
508
|
|
|
143
509
|
async function listDir(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
@@ -183,7 +549,7 @@ async function readFile(ctx: Context, payload: unknown, signal: AbortSignal): Pr
|
|
|
183
549
|
}
|
|
184
550
|
}
|
|
185
551
|
|
|
186
|
-
async function writeFile(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
552
|
+
async function writeFile(ctx: Context, state: WatchState, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
187
553
|
const path = pathOf(payload)
|
|
188
554
|
const content = typeof payload === 'object' && payload !== null ? (payload as { content?: unknown }).content : undefined
|
|
189
555
|
if (path === undefined) return fail('write: payload.path must be a non-empty string')
|
|
@@ -196,8 +562,233 @@ async function writeFile(ctx: Context, payload: unknown, signal: AbortSignal): P
|
|
|
196
562
|
mode: 'danger-full-access',
|
|
197
563
|
workspaceRoot: ctx.fs.processPath(target),
|
|
198
564
|
})
|
|
199
|
-
|
|
565
|
+
// This save will trip the fs.watch on the file's parent dir; suppress it
|
|
566
|
+
// so an own save never bounces back as a "changed on disk" event.
|
|
567
|
+
const osPath = ctx.fs.processPath(target)
|
|
568
|
+
state.selfWrites.set(osPath, Date.now())
|
|
569
|
+
// Prune stale markers occasionally.
|
|
570
|
+
if (state.selfWrites.size > 64) {
|
|
571
|
+
const cutoff = Date.now() - SELF_WRITE_WINDOW_MS * 4
|
|
572
|
+
for (const [os, ts] of state.selfWrites) {
|
|
573
|
+
if (ts < cutoff) state.selfWrites.delete(os)
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return { ok: true, value: { path: osPath, size: content.length } }
|
|
577
|
+
} catch (error) {
|
|
578
|
+
return fail(mapError(error))
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
// Context-menu mutations (New File / New Folder / Rename / Delete)
|
|
584
|
+
//
|
|
585
|
+
// The sandboxed `ctx.fs` service exposes no mkdir/rename/rm primitives, so
|
|
586
|
+
// these run node:fs/promises directly. Every path is first run through
|
|
587
|
+
// `ctx.fs.resolve` (which realpaths the nearest existing ancestor, so new
|
|
588
|
+
// targets resolve too) and the operation executes at `processPath(target)` —
|
|
589
|
+
// the same resolution the read/write endpoints use. Like `write`, these are
|
|
590
|
+
// explicit user actions over the loopback-only channel; nothing here is ever
|
|
591
|
+
// reached by a remote caller.
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
async function createFile(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
595
|
+
const path = pathOf(payload)
|
|
596
|
+
if (path === undefined) return fail('createFile: payload.path must be a non-empty string')
|
|
597
|
+
try {
|
|
598
|
+
const target = await ctx.fs.resolve(path, { signal })
|
|
599
|
+
const osPath = ctx.fs.processPath(target)
|
|
600
|
+
// 'wx' fails when the file already exists (VS Code New File semantics).
|
|
601
|
+
await writeFileNode(osPath, '', { flag: 'wx' })
|
|
602
|
+
return { ok: true, value: { path: osPath } }
|
|
603
|
+
} catch (error) {
|
|
604
|
+
return fail(mapError(error))
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function createDir(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
609
|
+
const path = pathOf(payload)
|
|
610
|
+
if (path === undefined) return fail('createDir: payload.path must be a non-empty string')
|
|
611
|
+
try {
|
|
612
|
+
const target = await ctx.fs.resolve(path, { signal })
|
|
613
|
+
const osPath = ctx.fs.processPath(target)
|
|
614
|
+
// Non-recursive: the parent must already exist; EEXIST when the folder does.
|
|
615
|
+
await mkdir(osPath)
|
|
616
|
+
return { ok: true, value: { path: osPath } }
|
|
617
|
+
} catch (error) {
|
|
618
|
+
return fail(mapError(error))
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function renameEntry(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
623
|
+
const path = pathOf(payload)
|
|
624
|
+
const to = typeof payload === 'object' && payload !== null ? (payload as { to?: unknown }).to : undefined
|
|
625
|
+
if (path === undefined) return fail('rename: payload.path must be a non-empty string')
|
|
626
|
+
if (typeof to !== 'string' || to.trim().length === 0) return fail('rename: payload.to must be a non-empty string')
|
|
627
|
+
try {
|
|
628
|
+
const target = await ctx.fs.resolve(path, { signal })
|
|
629
|
+
const osPath = ctx.fs.processPath(target)
|
|
630
|
+
const toTarget = await ctx.fs.resolve(to, { signal })
|
|
631
|
+
const toOsPath = ctx.fs.processPath(toTarget)
|
|
632
|
+
// fs.rename overwrites silently on POSIX; refuse when the destination exists.
|
|
633
|
+
const existing = await ctx.fs.stat(toTarget, signal)
|
|
634
|
+
if (existing !== undefined) return fail('rename: destination already exists')
|
|
635
|
+
await renameFs(osPath, toOsPath)
|
|
636
|
+
return { ok: true, value: { path: toOsPath } }
|
|
637
|
+
} catch (error) {
|
|
638
|
+
return fail(mapError(error))
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function deleteEntry(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
643
|
+
const path = pathOf(payload)
|
|
644
|
+
if (path === undefined) return fail('delete: payload.path must be a non-empty string')
|
|
645
|
+
try {
|
|
646
|
+
const target = await ctx.fs.resolve(path, { signal })
|
|
647
|
+
const osPath = ctx.fs.processPath(target)
|
|
648
|
+
const info = await ctx.fs.stat(target, signal)
|
|
649
|
+
if (info === undefined) return fail(`path not found: ${path}`)
|
|
650
|
+
// Folders are removed recursively (the client confirms before calling).
|
|
651
|
+
await rm(osPath, { recursive: info.type === 'directory', force: true })
|
|
652
|
+
return { ok: true, value: { path: osPath } }
|
|
653
|
+
} catch (error) {
|
|
654
|
+
return fail(mapError(error))
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
// Copy (the explorer's Copy / Cut + Paste)
|
|
660
|
+
//
|
|
661
|
+
// `fs.cp` handles files and (recursively) folders; `errorOnExist` turns a
|
|
662
|
+
// colliding destination into a distinct `exists` error so the client can ask
|
|
663
|
+
// the user whether to overwrite, exactly like the OS file manager. The same
|
|
664
|
+
// `ctx.fs.resolve` → `processPath` resolution as every other endpoint applies,
|
|
665
|
+
// so sandbox containment and error mapping stay uniform.
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
async function copyEntry(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
669
|
+
// Note: the client sends `from`, not `path` (which is what pathOf reads).
|
|
670
|
+
const from = typeof payload === 'object' && payload !== null ? (payload as { from?: unknown }).from : undefined
|
|
671
|
+
const to = typeof payload === 'object' && payload !== null ? (payload as { to?: unknown }).to : undefined
|
|
672
|
+
const overwrite = typeof payload === 'object' && payload !== null && (payload as { overwrite?: unknown }).overwrite === true
|
|
673
|
+
if (typeof from !== 'string' || from.trim().length === 0) return fail('copy: payload.from must be a non-empty string')
|
|
674
|
+
if (typeof to !== 'string' || to.trim().length === 0) return fail('copy: payload.to must be a non-empty string')
|
|
675
|
+
try {
|
|
676
|
+
const fromTarget = await ctx.fs.resolve(from, { signal })
|
|
677
|
+
const fromOs = ctx.fs.processPath(fromTarget)
|
|
678
|
+
const info = await ctx.fs.stat(fromTarget, signal)
|
|
679
|
+
if (info === undefined) return fail(`path not found: ${from}`)
|
|
680
|
+
const toTarget = await ctx.fs.resolve(to, { signal })
|
|
681
|
+
const toOs = ctx.fs.processPath(toTarget)
|
|
682
|
+
// Refuse trivial self-copies and copying a folder into itself or one of
|
|
683
|
+
// its own descendants (comparisons case-insensitive — Windows).
|
|
684
|
+
const samePath = fromOs.toLowerCase() === toOs.toLowerCase()
|
|
685
|
+
if (samePath) return fail('copy: source and destination are the same path')
|
|
686
|
+
if (info.type === 'directory') {
|
|
687
|
+
const sep = fromOs.includes('\\') ? '\\' : '/'
|
|
688
|
+
const prefix = fromOs.endsWith('\\') || fromOs.endsWith('/') ? fromOs : fromOs + sep
|
|
689
|
+
if (toOs.toLowerCase().startsWith(prefix.toLowerCase())) return fail('copy: cannot copy a folder into itself')
|
|
690
|
+
}
|
|
691
|
+
await cp(fromOs, toOs, { recursive: true, force: overwrite, errorOnExist: !overwrite })
|
|
692
|
+
return { ok: true, value: { path: toOs } }
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (isFsErrorCode(error, 'ERR_FS_CP_EEXIST')) {
|
|
695
|
+
return { ok: false, error: { code: 'exists', message: 'destination already exists', details: {} } }
|
|
696
|
+
}
|
|
697
|
+
return fail(mapError(error))
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ---------------------------------------------------------------------------
|
|
702
|
+
// Reveal in the OS file manager ("在资源管理器打开")
|
|
703
|
+
//
|
|
704
|
+
// Spawns the platform's file-manager command so the item shows up in the
|
|
705
|
+
// system explorer — files are SELECTED inside their containing folder
|
|
706
|
+
// (Windows `explorer /select,`, macOS `open -R`), folders are OPENED directly
|
|
707
|
+
// (`explorer <dir>`, `open <dir>`). WSL paths are translated through
|
|
708
|
+
// `wslpath` first; desktop Linux falls back to `xdg-open` (file → its parent
|
|
709
|
+
// directory, folder → itself). Paths are resolved through `ctx.fs` first, so
|
|
710
|
+
// the sandbox containment applies exactly as for every other endpoint; the
|
|
711
|
+
// spawn itself is a local desktop action, the same trust level as the existing
|
|
712
|
+
// "open in system" gesture (`ctx.workspaces.openPath`).
|
|
713
|
+
// ---------------------------------------------------------------------------
|
|
714
|
+
|
|
715
|
+
/** Spawn one short-lived desktop command; resolves once the process launched. */
|
|
716
|
+
function runDesktop(command: string, args: string[]): Promise<void> {
|
|
717
|
+
return new Promise((resolve, reject) => {
|
|
718
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true })
|
|
719
|
+
child.once('error', reject)
|
|
720
|
+
child.once('spawn', () => {
|
|
721
|
+
// The window stays after the harness exits; nothing to wait for.
|
|
722
|
+
child.unref()
|
|
723
|
+
resolve()
|
|
724
|
+
})
|
|
725
|
+
})
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Run one command and capture its stdout; rejects on non-zero exit. */
|
|
729
|
+
function execCapture(command: string, args: string[]): Promise<string> {
|
|
730
|
+
return new Promise((resolve, reject) => {
|
|
731
|
+
const child = spawn(command, args, { windowsHide: true })
|
|
732
|
+
let out = ''
|
|
733
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
734
|
+
out += chunk.toString()
|
|
735
|
+
})
|
|
736
|
+
child.once('error', reject)
|
|
737
|
+
child.once('close', (code) => {
|
|
738
|
+
if (code === 0) resolve(out)
|
|
739
|
+
else reject(new Error(`${command} exited with code ${code}`))
|
|
740
|
+
})
|
|
741
|
+
})
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Reveal one resolved OS path in the platform's file manager. */
|
|
745
|
+
async function revealNative(osPath: string, isDir: boolean, signal: AbortSignal): Promise<void> {
|
|
746
|
+
signal.throwIfAborted()
|
|
747
|
+
const platform = process.platform
|
|
748
|
+
if (platform === 'win32') {
|
|
749
|
+
// explorer returns exit code 1 when it opens a NEW window, so exit codes
|
|
750
|
+
// carry no meaning; a clean spawn is success. Files are selected in their
|
|
751
|
+
// folder; folders are opened directly.
|
|
752
|
+
await runDesktop('explorer.exe', isDir ? [osPath] : ['/select,', osPath])
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
if (platform === 'darwin') {
|
|
756
|
+
// `open -R` reveals in Finder; plain `open` opens a folder.
|
|
757
|
+
await runDesktop('open', isDir ? [osPath] : ['-R', osPath])
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
if (platform === 'linux') {
|
|
761
|
+
// WSL: translate to a Windows path and hand it to the Windows desktop.
|
|
762
|
+
const env = process.env
|
|
763
|
+
if (env.WSL_DISTRO_NAME !== undefined || env.WSL_INTEROP !== undefined) {
|
|
764
|
+
const windowsPath = (await execCapture('wslpath', ['-w', osPath])).replace(/[\r\n]+$/, '')
|
|
765
|
+
if (windowsPath === '') throw new Error('wslpath returned no Windows path')
|
|
766
|
+
await runDesktop('explorer.exe', isDir ? [windowsPath] : ['/select,', windowsPath])
|
|
767
|
+
return
|
|
768
|
+
}
|
|
769
|
+
// Desktop Linux: the default file manager opens folders; files open in
|
|
770
|
+
// their parent directory.
|
|
771
|
+
await runDesktop('xdg-open', [isDir ? osPath : dirname(osPath)])
|
|
772
|
+
return
|
|
773
|
+
}
|
|
774
|
+
throw new Error(`reveal in the file manager is unsupported on ${platform}`)
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** RPC endpoint: reveal one explorer path in the OS file manager. */
|
|
778
|
+
async function revealInExplorer(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
779
|
+
const path = pathOf(payload)
|
|
780
|
+
const kind = typeof payload === 'object' && payload !== null ? (payload as { kind?: unknown }).kind : undefined
|
|
781
|
+
if (path === undefined) return fail('reveal: payload.path must be a non-empty string')
|
|
782
|
+
try {
|
|
783
|
+
const target = await ctx.fs.resolve(path, { signal })
|
|
784
|
+
const info = await ctx.fs.stat(target, signal)
|
|
785
|
+
if (info === undefined) return fail(`path not found: ${path}`)
|
|
786
|
+
const isDir = kind === 'dir' || info.type === 'directory'
|
|
787
|
+
const osPath = ctx.fs.processPath(target)
|
|
788
|
+
await revealNative(osPath, isDir, signal)
|
|
789
|
+
return { ok: true, value: { path: osPath } }
|
|
200
790
|
} catch (error) {
|
|
791
|
+
if (signal.aborted) return fail('reveal: aborted')
|
|
201
792
|
return fail(mapError(error))
|
|
202
793
|
}
|
|
203
794
|
}
|