dsh-plugin-workbench 0.0.3 → 0.0.5
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 +54 -0
- package/README.md +17 -7
- package/lib/client.js +641 -160
- package/lib/client.js.map +1 -1
- package/lib/index.js +169 -6
- package/package.json +1 -1
- package/src/client/FileExplorer.tsx +357 -64
- package/src/client/FilePreview.tsx +171 -34
- package/src/client/files.module.css +144 -3
- package/src/client/index.ts +5 -1
- package/src/client/locales.ts +30 -0
- package/src/client/store.ts +25 -0
- package/src/dsh.d.ts +9 -0
- package/src/index.ts +204 -6
package/src/index.ts
CHANGED
|
@@ -2,14 +2,27 @@
|
|
|
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 for the explorer's context
|
|
7
|
+
* menu. 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.
|
|
8
19
|
*/
|
|
20
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
21
|
+
import { mkdir, rename as renameFs, rm, writeFile as writeFileNode } from 'node:fs/promises'
|
|
9
22
|
import type { Context } from '@deepseek-ai/cordis'
|
|
10
23
|
|
|
11
24
|
export const name = 'dsh-plugin-workbench'
|
|
12
|
-
export const inject = ['fs', 'connection']
|
|
25
|
+
export const inject = ['fs', 'connection', 'webServer']
|
|
13
26
|
|
|
14
27
|
/** Loopback-only logical RPC channel. */
|
|
15
28
|
export const CHANNEL = '/dsh-plugin-files'
|
|
@@ -17,6 +30,36 @@ export const CHANNEL = '/dsh-plugin-files'
|
|
|
17
30
|
/** Files larger than this are never read for preview (client shows size + hint). */
|
|
18
31
|
export const MAX_PREVIEW_BYTES = 512 * 1024
|
|
19
32
|
|
|
33
|
+
/** Same-origin route serving raw bytes for image files (see module doc). */
|
|
34
|
+
export const RAW_PREFIX = '/dsh-plugin-files/raw'
|
|
35
|
+
|
|
36
|
+
/** Images larger than this are never served to the preview (browser shows a hint). */
|
|
37
|
+
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
const IMAGE_MIME: Record<string, string> = {
|
|
40
|
+
png: 'image/png',
|
|
41
|
+
jpg: 'image/jpeg',
|
|
42
|
+
jpeg: 'image/jpeg',
|
|
43
|
+
gif: 'image/gif',
|
|
44
|
+
webp: 'image/webp',
|
|
45
|
+
avif: 'image/avif',
|
|
46
|
+
bmp: 'image/bmp',
|
|
47
|
+
ico: 'image/x-icon',
|
|
48
|
+
svg: 'image/svg+xml',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** MIME type for a path's extension when it names a previewable image; undefined otherwise. */
|
|
52
|
+
export function imageMimeOf(path: string): string | undefined {
|
|
53
|
+
const idx = path.lastIndexOf('.')
|
|
54
|
+
if (idx < 0 || idx === path.length - 1) return undefined
|
|
55
|
+
return IMAGE_MIME[path.slice(idx + 1).toLowerCase()]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True when the path names a previewable image file. */
|
|
59
|
+
export function isImagePath(path: string): boolean {
|
|
60
|
+
return imageMimeOf(path) !== undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
20
63
|
export type FsKind = 'dir' | 'file' | 'other'
|
|
21
64
|
|
|
22
65
|
export interface FsListEntry {
|
|
@@ -44,7 +87,12 @@ export interface FsWriteResult {
|
|
|
44
87
|
size: number
|
|
45
88
|
}
|
|
46
89
|
|
|
47
|
-
|
|
90
|
+
/** Result of a context-menu mutation (create/rename/delete). */
|
|
91
|
+
export interface FsMutationResult {
|
|
92
|
+
path: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type FilesRpcOk = { ok: true; value: FsListResult | FsReadResult | FsWriteResult | FsMutationResult }
|
|
48
96
|
export type FilesRpcErr = { ok: false; error: { code: 'internal'; message: string; details: Record<string, never> } }
|
|
49
97
|
export type FilesRpcResult = FilesRpcOk | FilesRpcErr
|
|
50
98
|
|
|
@@ -98,6 +146,15 @@ const FS_ERROR_MESSAGES: Record<string, string> = {
|
|
|
98
146
|
FS_SANDBOX_DENIED: 'sandbox denied',
|
|
99
147
|
FS_ABORTED: 'aborted',
|
|
100
148
|
FS_IO_ERROR: 'io error',
|
|
149
|
+
// node:fs error codes surfaced by the context-menu mutations below.
|
|
150
|
+
EEXIST: 'file or folder already exists',
|
|
151
|
+
ENOENT: 'path does not exist',
|
|
152
|
+
ENOTEMPTY: 'folder is not empty',
|
|
153
|
+
EPERM: 'permission denied',
|
|
154
|
+
EACCES: 'permission denied',
|
|
155
|
+
ENOTDIR: 'not a directory',
|
|
156
|
+
EISDIR: 'is a directory',
|
|
157
|
+
EBUSY: 'file is in use',
|
|
101
158
|
}
|
|
102
159
|
|
|
103
160
|
/** Human-readable message for a thrown value, honoring the fs error code taxonomy. */
|
|
@@ -135,9 +192,74 @@ export function apply(ctx: Context): void {
|
|
|
135
192
|
if (endpoint === 'list') return listDir(ctx, payload, signal)
|
|
136
193
|
if (endpoint === 'read') return readFile(ctx, payload, signal)
|
|
137
194
|
if (endpoint === 'write') return writeFile(ctx, payload, signal)
|
|
195
|
+
if (endpoint === 'createFile') return createFile(ctx, payload, signal)
|
|
196
|
+
if (endpoint === 'createDir') return createDir(ctx, payload, signal)
|
|
197
|
+
if (endpoint === 'rename') return renameEntry(ctx, payload, signal)
|
|
198
|
+
if (endpoint === 'delete') return deleteEntry(ctx, payload, signal)
|
|
138
199
|
return fail(`unknown endpoint: ${endpoint}`)
|
|
139
200
|
}
|
|
140
|
-
|
|
201
|
+
// Effect-wrapped so HMR/disable cycles dispose the channel (the connection
|
|
202
|
+
// service registers the HTTP carrier for the channel the same way); a plain
|
|
203
|
+
// call here would leak the route on reload and collide on re-apply.
|
|
204
|
+
ctx.effect(() => ctx.connection.rpc.handle(CHANNEL, handler, { authority: 'loopback' }), 'dsh-plugin-workbench: files rpc channel')
|
|
205
|
+
|
|
206
|
+
// Raw image bytes for the preview pane. The client builds the URL as
|
|
207
|
+
// `${RAW_PREFIX}/${encodeURIComponent(path)}`; the suffix is decoded back to
|
|
208
|
+
// the absolute path, then resolved/statted through the sandboxed fs service
|
|
209
|
+
// before any byte is read (containment parity with the RPC read endpoint).
|
|
210
|
+
ctx.effect(() => ctx.webServer.register({
|
|
211
|
+
kind: 'prefix',
|
|
212
|
+
path: RAW_PREFIX,
|
|
213
|
+
handler: (req, res) => {
|
|
214
|
+
void serveRaw(ctx, req, res)
|
|
215
|
+
},
|
|
216
|
+
}), 'dsh-plugin-workbench: raw image route')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function serveRaw(ctx: Context, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
220
|
+
const text = (code: number, body: string): void => {
|
|
221
|
+
res.writeHead(code, { 'content-type': 'text/plain; charset=utf-8' })
|
|
222
|
+
res.end(body)
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
if ((req.method ?? 'GET').toUpperCase() !== 'GET') {
|
|
226
|
+
text(405, 'method not allowed')
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
230
|
+
const rest = url.pathname.slice(RAW_PREFIX.length).replace(/^\/+/, '')
|
|
231
|
+
if (rest.length === 0) {
|
|
232
|
+
text(404, 'not found')
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
const path = decodeURIComponent(rest)
|
|
236
|
+
const mime = imageMimeOf(path)
|
|
237
|
+
if (mime === undefined) {
|
|
238
|
+
text(404, 'not an image')
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
const target = await ctx.fs.resolve(path)
|
|
242
|
+
const info = await ctx.fs.stat(target)
|
|
243
|
+
if (info === undefined || info.type !== 'file') {
|
|
244
|
+
text(404, 'not found')
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
if ((info.size ?? 0) > MAX_IMAGE_BYTES) {
|
|
248
|
+
text(413, 'image too large')
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
const bytes = await ctx.fs.readBytes(target, undefined, MAX_IMAGE_BYTES)
|
|
252
|
+
res.writeHead(200, {
|
|
253
|
+
'content-type': mime,
|
|
254
|
+
'content-length': bytes.byteLength,
|
|
255
|
+
'cache-control': 'private, max-age=300',
|
|
256
|
+
'x-content-type-options': 'nosniff',
|
|
257
|
+
})
|
|
258
|
+
res.end(Buffer.from(bytes))
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (!res.headersSent) text(500, 'internal error')
|
|
261
|
+
else res.destroy()
|
|
262
|
+
}
|
|
141
263
|
}
|
|
142
264
|
|
|
143
265
|
async function listDir(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
@@ -201,3 +323,79 @@ async function writeFile(ctx: Context, payload: unknown, signal: AbortSignal): P
|
|
|
201
323
|
return fail(mapError(error))
|
|
202
324
|
}
|
|
203
325
|
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Context-menu mutations (New File / New Folder / Rename / Delete)
|
|
329
|
+
//
|
|
330
|
+
// The sandboxed `ctx.fs` service exposes no mkdir/rename/rm primitives, so
|
|
331
|
+
// these run node:fs/promises directly. Every path is first run through
|
|
332
|
+
// `ctx.fs.resolve` (which realpaths the nearest existing ancestor, so new
|
|
333
|
+
// targets resolve too) and the operation executes at `processPath(target)` —
|
|
334
|
+
// the same resolution the read/write endpoints use. Like `write`, these are
|
|
335
|
+
// explicit user actions over the loopback-only channel; nothing here is ever
|
|
336
|
+
// reached by a remote caller.
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
async function createFile(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
340
|
+
const path = pathOf(payload)
|
|
341
|
+
if (path === undefined) return fail('createFile: payload.path must be a non-empty string')
|
|
342
|
+
try {
|
|
343
|
+
const target = await ctx.fs.resolve(path, { signal })
|
|
344
|
+
const osPath = ctx.fs.processPath(target)
|
|
345
|
+
// 'wx' fails when the file already exists (VS Code New File semantics).
|
|
346
|
+
await writeFileNode(osPath, '', { flag: 'wx' })
|
|
347
|
+
return { ok: true, value: { path: osPath } }
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return fail(mapError(error))
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function createDir(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
354
|
+
const path = pathOf(payload)
|
|
355
|
+
if (path === undefined) return fail('createDir: payload.path must be a non-empty string')
|
|
356
|
+
try {
|
|
357
|
+
const target = await ctx.fs.resolve(path, { signal })
|
|
358
|
+
const osPath = ctx.fs.processPath(target)
|
|
359
|
+
// Non-recursive: the parent must already exist; EEXIST when the folder does.
|
|
360
|
+
await mkdir(osPath)
|
|
361
|
+
return { ok: true, value: { path: osPath } }
|
|
362
|
+
} catch (error) {
|
|
363
|
+
return fail(mapError(error))
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function renameEntry(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
368
|
+
const path = pathOf(payload)
|
|
369
|
+
const to = typeof payload === 'object' && payload !== null ? (payload as { to?: unknown }).to : undefined
|
|
370
|
+
if (path === undefined) return fail('rename: payload.path must be a non-empty string')
|
|
371
|
+
if (typeof to !== 'string' || to.trim().length === 0) return fail('rename: payload.to must be a non-empty string')
|
|
372
|
+
try {
|
|
373
|
+
const target = await ctx.fs.resolve(path, { signal })
|
|
374
|
+
const osPath = ctx.fs.processPath(target)
|
|
375
|
+
const toTarget = await ctx.fs.resolve(to, { signal })
|
|
376
|
+
const toOsPath = ctx.fs.processPath(toTarget)
|
|
377
|
+
// fs.rename overwrites silently on POSIX; refuse when the destination exists.
|
|
378
|
+
const existing = await ctx.fs.stat(toTarget, signal)
|
|
379
|
+
if (existing !== undefined) return fail('rename: destination already exists')
|
|
380
|
+
await renameFs(osPath, toOsPath)
|
|
381
|
+
return { ok: true, value: { path: toOsPath } }
|
|
382
|
+
} catch (error) {
|
|
383
|
+
return fail(mapError(error))
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function deleteEntry(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
|
|
388
|
+
const path = pathOf(payload)
|
|
389
|
+
if (path === undefined) return fail('delete: payload.path must be a non-empty string')
|
|
390
|
+
try {
|
|
391
|
+
const target = await ctx.fs.resolve(path, { signal })
|
|
392
|
+
const osPath = ctx.fs.processPath(target)
|
|
393
|
+
const info = await ctx.fs.stat(target, signal)
|
|
394
|
+
if (info === undefined) return fail(`path not found: ${path}`)
|
|
395
|
+
// Folders are removed recursively (the client confirms before calling).
|
|
396
|
+
await rm(osPath, { recursive: info.type === 'directory', force: true })
|
|
397
|
+
return { ok: true, value: { path: osPath } }
|
|
398
|
+
} catch (error) {
|
|
399
|
+
return fail(mapError(error))
|
|
400
|
+
}
|
|
401
|
+
}
|