@yiln-dsh/dsh-plugin-file-explorer 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ # dsh-plugin-file-explorer bundle patch.
2
+ #
3
+ # Adds the host row that serves the browser bundle's /_dsh/file-explorer API.
4
+ # The client half is declared by dsh.client and loaded with the web profile.
5
+
6
+ - insert:
7
+ - id: dsh-file-explorer
8
+ name: '@yiln-dsh/dsh-plugin-file-explorer'
9
+ inject:
10
+ - webServer
11
+ config: {}
package/index.js ADDED
@@ -0,0 +1,519 @@
1
+ /**
2
+ * dsh-plugin-file-explorer — Host half (static DSH bundle).
3
+ *
4
+ * Registers exact HTTP routes under /_dsh/file-explorer for the browser
5
+ * bundle: list (with parent path), read (text preview), download (data URL),
6
+ * and delete.
7
+ */
8
+
9
+ function parentOf(path) {
10
+ if (!path) return null
11
+ const cleaned = path.replace(/[/\\]+$/, '')
12
+ if (cleaned === '' || /^[A-Za-z]:$/.test(cleaned)) return null
13
+ const slash = Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\'))
14
+ if (slash < 0) return null
15
+ if (slash === 0) return cleaned[0] === '/' ? '/' : null
16
+ const parent = cleaned.slice(0, slash)
17
+ return parent === '' ? null : parent
18
+ }
19
+
20
+ function bytesToBase64(bytes) {
21
+ let binary = ''
22
+ const chunk = 0x8000
23
+ for (let i = 0; i < bytes.length; i += chunk) {
24
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk))
25
+ }
26
+ return btoa(binary)
27
+ }
28
+
29
+ function guessMime(name) {
30
+ const ext = (name.split('.').pop() || '').toLowerCase()
31
+ const map = {
32
+ json: 'application/json', txt: 'text/plain', md: 'text/markdown',
33
+ js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript',
34
+ ts: 'text/typescript', tsx: 'text/typescript', jsx: 'text/javascript',
35
+ html: 'text/html', htm: 'text/html', css: 'text/css',
36
+ csv: 'text/csv', yml: 'text/yaml', yaml: 'text/yaml',
37
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
38
+ pdf: 'application/pdf', zip: 'application/zip', gz: 'application/gzip',
39
+ }
40
+ return map[ext] || 'application/octet-stream'
41
+ }
42
+
43
+ function sendJson(res, status, value) {
44
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
45
+ res.end(JSON.stringify(value))
46
+ }
47
+
48
+ async function readJson(req) {
49
+ let body = ''
50
+ for await (const chunk of req) body += chunk
51
+ if (body.length === 0) return {}
52
+ return JSON.parse(body)
53
+ }
54
+
55
+ /** Strip git's "fatal: "/"error: " prefixes and collapse whitespace into one line. */
56
+ function cleanGitError(stderr, fallback) {
57
+ const text = typeof stderr === 'string' ? stderr.trim() : ''
58
+ if (text === '') return fallback
59
+ const firstLine = text.split('\n')[0].replace(/^(fatal|error|warning):\s*/i, '').trim()
60
+ return firstLine === '' ? fallback : firstLine
61
+ }
62
+
63
+ /** A commit-ish accepted from the browser: hex abbreviations or the WORKING sentinel. */
64
+ function isSafeCommitish(value) {
65
+ return typeof value === 'string' && /^([0-9a-fA-F]{6,40}|WORKING)$/.test(value)
66
+ }
67
+
68
+ /** A path argument for `git ... -- <path>`: never empty, never option-like, no control characters. */
69
+ function isSafeGitPath(value) {
70
+ return (
71
+ typeof value === 'string' &&
72
+ value.length > 0 &&
73
+ value.length <= 1024 &&
74
+ !value.startsWith('-') &&
75
+ !/[\0\r\n]/.test(value)
76
+ )
77
+ }
78
+
79
+ export const name = 'file-explorer'
80
+
81
+ /** Hard dependency: the active web server (which the auth webserver also provides). */
82
+ export const inject = ['webServer']
83
+
84
+ export function apply(ctx) {
85
+ const webServer = ctx.get('webServer')
86
+ if (webServer === undefined) return
87
+
88
+ const disposers = []
89
+ const addRoute = (path, handler) => {
90
+ disposers.push(webServer.register({ kind: 'exact', path, handler }))
91
+ }
92
+
93
+ addRoute('/_dsh/file-explorer/list', async (req, res) => {
94
+ if (req.method === 'POST') req = await readJson(req)
95
+ const fs = ctx.get('fs')
96
+ if (fs === undefined) {
97
+ sendJson(res, 200, { ok: false, error: 'filesystem service unavailable' })
98
+ return
99
+ }
100
+
101
+ let requestedPath
102
+ if (typeof req.path === 'string' && req.path.trim() !== '') {
103
+ requestedPath = req.path
104
+ } else {
105
+ const policy = ctx.get('sandboxPolicy')
106
+ if (policy && typeof policy.workspaceRoot === 'string') requestedPath = policy.workspaceRoot
107
+ }
108
+ if (typeof requestedPath !== 'string') {
109
+ sendJson(res, 200, { ok: false, error: 'no directory to list' })
110
+ return
111
+ }
112
+
113
+ try {
114
+ const target = await fs.resolve(requestedPath)
115
+ const entries = await fs.listDir(target)
116
+ sendJson(res, 200, {
117
+ ok: true,
118
+ path: target.displayPath,
119
+ parent: parentOf(target.displayPath),
120
+ entries: entries.map((entry) => ({
121
+ name: entry.name,
122
+ type: entry.type,
123
+ size: typeof entry.size === 'number' ? entry.size : null,
124
+ path: entry.target.displayPath,
125
+ })),
126
+ })
127
+ } catch (error) {
128
+ sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
129
+ }
130
+ })
131
+
132
+ addRoute('/_dsh/file-explorer/read', async (req, res) => {
133
+ if (req.method === 'POST') req = await readJson(req)
134
+ const fs = ctx.get('fs')
135
+ if (fs === undefined) {
136
+ sendJson(res, 200, { ok: false, error: 'filesystem service unavailable' })
137
+ return
138
+ }
139
+ if (typeof req.path !== 'string') {
140
+ sendJson(res, 200, { ok: false, error: 'missing file path' })
141
+ return
142
+ }
143
+
144
+ try {
145
+ const target = await fs.resolve(req.path)
146
+ const info = await fs.stat(target)
147
+ const size = info && typeof info.size === 'number' ? info.size : null
148
+ const limit = 256 * 1024
149
+ if (size !== null && size > limit) {
150
+ sendJson(res, 200, { ok: true, name: target.displayPath.split('/').pop(), size, tooLarge: true })
151
+ return
152
+ }
153
+ const bytes = await fs.readBytes(target, undefined, limit)
154
+ const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes)
155
+ let binary = false
156
+ for (let i = 0; i < bytes.length; i += 1) {
157
+ if (bytes[i] === 0) { binary = true; break }
158
+ }
159
+ if (!binary && text.replace(/\uFFFD/g, '').length * 10 < text.length * 9) binary = true
160
+ sendJson(res, 200, {
161
+ ok: true,
162
+ name: target.displayPath.split('/').pop(),
163
+ size,
164
+ text: binary ? null : text,
165
+ binary,
166
+ })
167
+ } catch (error) {
168
+ sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
169
+ }
170
+ })
171
+
172
+ addRoute('/_dsh/file-explorer/download', async (req, res) => {
173
+ if (req.method === 'POST') req = await readJson(req)
174
+ const fs = ctx.get('fs')
175
+ if (fs === undefined) {
176
+ sendJson(res, 200, { ok: false, error: 'filesystem service unavailable' })
177
+ return
178
+ }
179
+ if (typeof req.path !== 'string') {
180
+ sendJson(res, 200, { ok: false, error: 'missing file path' })
181
+ return
182
+ }
183
+
184
+ try {
185
+ const target = await fs.resolve(req.path)
186
+ const info = await fs.stat(target)
187
+ const size = info && typeof info.size === 'number' ? info.size : null
188
+ const limit = 64 * 1024 * 1024
189
+ if (size !== null && size > limit) {
190
+ sendJson(res, 200, { ok: false, error: 'file too large for explorer download' })
191
+ return
192
+ }
193
+ const bytes = await fs.readBytes(target, undefined, limit)
194
+ const name = target.displayPath.split('/').pop()
195
+ sendJson(res, 200, { ok: true, name, size, dataUrl: `data:${guessMime(name)};base64,${bytesToBase64(bytes)}` })
196
+ } catch (error) {
197
+ sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
198
+ }
199
+ })
200
+
201
+ addRoute('/_dsh/file-explorer/delete', async (req, res) => {
202
+ if (req.method === 'POST') req = await readJson(req)
203
+ if (typeof req.path !== 'string') {
204
+ sendJson(res, 200, { ok: false, error: 'missing file path' })
205
+ return
206
+ }
207
+ const subprocess = ctx.get('subprocess')
208
+ const fs = ctx.get('fs')
209
+ if (subprocess === undefined) {
210
+ sendJson(res, 200, { ok: false, error: 'subprocess service unavailable' })
211
+ return
212
+ }
213
+ if (fs === undefined) {
214
+ sendJson(res, 200, { ok: false, error: 'filesystem service unavailable' })
215
+ return
216
+ }
217
+
218
+ try {
219
+ const target = await fs.resolve(req.path)
220
+ const info = await fs.stat(target)
221
+ if (!info) {
222
+ sendJson(res, 200, { ok: false, error: 'file does not exist' })
223
+ return
224
+ }
225
+ // Directories are removed recursively; the UI gates this behind a
226
+ // second-click confirm before the request is ever sent.
227
+ const args = info.type === 'directory' ? ['rm', '-rf', '--'] : ['rm', '-f', '--']
228
+ const path = fs.processPath(target)
229
+ const handle = subprocess.spawn({
230
+ argv: [...args, path],
231
+ cwd: '/',
232
+ stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } },
233
+ graceMs: 30000,
234
+ })
235
+ const outcome = await handle.done
236
+ if (outcome.exitCode !== 0) {
237
+ sendJson(res, 200, { ok: false, error: `remove failed with exit code ${outcome.exitCode}` })
238
+ return
239
+ }
240
+ sendJson(res, 200, { ok: true })
241
+ } catch (error) {
242
+ sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
243
+ }
244
+ })
245
+
246
+ // --- git graph API -------------------------------------------------------
247
+ // Read-only git plumbing behind the browser's graph view. Every route
248
+ // resolves the repository root from the requested directory first, then
249
+ // runs git with machine-readable separators; nothing but JSON crosses the
250
+ // boundary.
251
+ let gitPathPromise = null
252
+ const resolveGit = () => {
253
+ const subprocess = ctx.get('subprocess')
254
+ if (subprocess === undefined) return Promise.reject(new Error('subprocess service unavailable'))
255
+ if (gitPathPromise === null) {
256
+ gitPathPromise = subprocess
257
+ .resolveExecutable('git')
258
+ .catch((error) => {
259
+ gitPathPromise = null
260
+ throw error
261
+ })
262
+ }
263
+ return gitPathPromise
264
+ }
265
+
266
+ const runGit = async (git, cwd, args, maxBytes) => {
267
+ const subprocess = ctx.get('subprocess')
268
+ if (subprocess === undefined) throw new Error('subprocess service unavailable')
269
+ const handle = subprocess.spawn({
270
+ argv: [git, ...args],
271
+ cwd,
272
+ stdio: { stdin: 'ignore', stdout: { maxBytes }, stderr: { maxBytes: 8192 } },
273
+ graceMs: 15000,
274
+ })
275
+ const outcome = await handle.done
276
+ const collect = (reader) => {
277
+ if (!reader) return { text: '', truncated: false }
278
+ const read = reader.readFrom(0)
279
+ return { text: read.text, truncated: read.lossy === true }
280
+ }
281
+ const out = collect(handle.collected.stdout)
282
+ const err = collect(handle.collected.stderr)
283
+ return { code: outcome.exitCode, stdout: out.text, stderr: err.text, truncated: out.truncated }
284
+ }
285
+
286
+ // Resolve the requested (or workspace-root) directory to a process path.
287
+ const resolveDir = async (requestedPath) => {
288
+ const fs = ctx.get('fs')
289
+ if (fs === undefined) throw new Error('filesystem service unavailable')
290
+ let dir = typeof requestedPath === 'string' && requestedPath.trim() !== '' ? requestedPath : undefined
291
+ if (dir === undefined) {
292
+ const policy = ctx.get('sandboxPolicy')
293
+ if (policy && typeof policy.workspaceRoot === 'string') dir = policy.workspaceRoot
294
+ }
295
+ if (typeof dir !== 'string') throw new Error('no directory given')
296
+ const target = await fs.resolve(dir)
297
+ return fs.processPath(target)
298
+ }
299
+
300
+ // Resolve the containing repository: { git, root } or throws a user-facing error.
301
+ const resolveRepo = async (requestedPath) => {
302
+ const dir = await resolveDir(requestedPath)
303
+ const git = await resolveGit()
304
+ const top = await runGit(git, dir, ['rev-parse', '--show-toplevel'], 4096)
305
+ if (top.code !== 0) throw new Error(cleanGitError(top.stderr, 'not a git repository'))
306
+ return { git, root: top.stdout.trim() }
307
+ }
308
+
309
+ const parseRefList = (stdout) => {
310
+ const heads = new Set()
311
+ const remotes = new Set()
312
+ const tags = new Set()
313
+ for (const line of stdout.split('\n')) {
314
+ const ref = line.trim()
315
+ if (ref.startsWith('refs/heads/')) heads.add(ref.slice('refs/heads/'.length))
316
+ else if (ref.startsWith('refs/remotes/')) remotes.add(ref.slice('refs/remotes/'.length))
317
+ else if (ref.startsWith('refs/tags/')) tags.add(ref.slice('refs/tags/'.length))
318
+ }
319
+ return { heads, remotes, tags }
320
+ }
321
+
322
+ // Classify one %D decorator token ("HEAD -> main", "origin/main", "tag: v1", ...) into a pill.
323
+ const classifyRef = (token, refs) => {
324
+ if (token === 'HEAD') return { kind: 'detached', name: 'HEAD' }
325
+ if (token.startsWith('HEAD -> ')) return { kind: 'head', name: token.slice('HEAD -> '.length) }
326
+ if (token.startsWith('tag: ')) return { kind: 'tag', name: token.slice('tag: '.length) }
327
+ if (refs.heads.has(token)) return { kind: 'branch', name: token }
328
+ if (refs.remotes.has(token)) return { kind: 'remote', name: token }
329
+ return { kind: 'branch', name: token }
330
+ }
331
+
332
+ addRoute('/_dsh/file-explorer/git-log', async (req, res) => {
333
+ if (req.method === 'POST') req = await readJson(req)
334
+ try {
335
+ const { git, root } = await resolveRepo(req.path)
336
+ const headRes = await runGit(git, root, ['rev-parse', '--abbrev-ref', 'HEAD'], 4096)
337
+ const head = headRes.code === 0 ? headRes.stdout.trim() : null
338
+ const refsRes = await runGit(git, root, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes', 'refs/tags'], 256 * 1024)
339
+ const refs = parseRefList(refsRes.stdout)
340
+ const limitRaw = Number(req.limit)
341
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 2000) : 300
342
+ const args = ['log', '--all', '--date-order', `--max-count=${limit}`, '--no-color', '--pretty=%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%D%x1f%s%x1e']
343
+ if (req.all === false) args.splice(1, 1)
344
+ const logRes = await runGit(git, root, args, 8 * 1024 * 1024)
345
+ if (logRes.code !== 0) {
346
+ sendJson(res, 200, { ok: false, error: cleanGitError(logRes.stderr, 'git log failed') })
347
+ return
348
+ }
349
+ const commits = []
350
+ for (const record of logRes.stdout.split('\x1e')) {
351
+ const line = record.startsWith('\n') ? record.slice(1) : record
352
+ if (line.trim() === '') continue
353
+ const f = line.split('\x1f')
354
+ const decorated = f[5] || ''
355
+ commits.push({
356
+ hash: f[0] || '',
357
+ parents: f[1] ? f[1].split(' ').filter(Boolean) : [],
358
+ author: f[2] || '',
359
+ email: f[3] || '',
360
+ date: Number(f[4]) || 0,
361
+ refs: decorated.split(',').map((part) => part.trim()).filter(Boolean).map((token) => classifyRef(token, refs)),
362
+ subject: f[6] || '',
363
+ })
364
+ }
365
+ sendJson(res, 200, { ok: true, root, head, commits, truncated: logRes.truncated })
366
+ } catch (error) {
367
+ sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
368
+ }
369
+ })
370
+
371
+ addRoute('/_dsh/file-explorer/git-commit', async (req, res) => {
372
+ if (req.method === 'POST') req = await readJson(req)
373
+ if (!isSafeCommitish(req.hash) || req.hash === 'WORKING') {
374
+ sendJson(res, 200, { ok: false, error: 'invalid commit hash' })
375
+ return
376
+ }
377
+ try {
378
+ const { git, root } = await resolveRepo(req.path)
379
+ const show = await runGit(git, root, ['show', '-s', '--no-color', '--format=%H%x1f%an%x1f%at%x1f%B', req.hash], 256 * 1024)
380
+ if (show.code !== 0) {
381
+ sendJson(res, 200, { ok: false, error: cleanGitError(show.stderr, 'unknown commit') })
382
+ return
383
+ }
384
+ const f = show.stdout.split('\x1f')
385
+ // -m --first-parent diffs merge commits against their first parent,
386
+ // matching the graph view's linear history; --root covers the initial commit.
387
+ const tree = await runGit(git, root, ['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', '-m', '--first-parent', '--no-color', '-z', req.hash], 1024 * 1024)
388
+ const files = []
389
+ if (tree.code === 0) {
390
+ const tokens = tree.stdout.split('\0')
391
+ for (let i = 0; i < tokens.length;) {
392
+ const status = tokens[i]
393
+ if (status === '') break
394
+ i += 1
395
+ const letter = status[0]
396
+ if ((letter === 'R' || letter === 'C') && i + 1 < tokens.length) {
397
+ files.push({ status: letter, oldPath: tokens[i], path: tokens[i + 1] })
398
+ i += 2
399
+ } else if (i < tokens.length) {
400
+ files.push({ status: letter, path: tokens[i] })
401
+ i += 1
402
+ }
403
+ }
404
+ }
405
+ sendJson(res, 200, {
406
+ ok: true,
407
+ hash: (f[0] || req.hash).trim(),
408
+ author: (f[1] || '').trim(),
409
+ date: Number(f[2]) || 0,
410
+ message: (f[3] || '').replace(/\n+$/, ''),
411
+ files,
412
+ })
413
+ } catch (error) {
414
+ sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
415
+ }
416
+ })
417
+
418
+ addRoute('/_dsh/file-explorer/git-diff', async (req, res) => {
419
+ if (req.method === 'POST') req = await readJson(req)
420
+ if (!isSafeCommitish(req.hash)) {
421
+ sendJson(res, 200, { ok: false, error: 'invalid commit hash' })
422
+ return
423
+ }
424
+ if (!isSafeGitPath(req.file)) {
425
+ sendJson(res, 200, { ok: false, error: 'invalid file path' })
426
+ return
427
+ }
428
+ try {
429
+ const { git, root } = await resolveRepo(req.path)
430
+ const args = req.hash === 'WORKING'
431
+ ? ['diff', 'HEAD', '--no-color', '--no-ext-diff', '--', req.file]
432
+ : ['show', '--no-color', '--no-ext-diff', '--format=', '-m', '--first-parent', req.hash, '--', req.file]
433
+ const diff = await runGit(git, root, args, 512 * 1024)
434
+ if (diff.code !== 0) {
435
+ sendJson(res, 200, { ok: false, error: cleanGitError(diff.stderr, 'git diff failed') })
436
+ return
437
+ }
438
+ sendJson(res, 200, { ok: true, patch: diff.stdout, truncated: diff.truncated })
439
+ } catch (error) {
440
+ sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
441
+ }
442
+ })
443
+
444
+ addRoute('/_dsh/file-explorer/git-status', async (req, res) => {
445
+ if (req.method === 'POST') req = await readJson(req)
446
+ try {
447
+ const { git, root } = await resolveRepo(req.path)
448
+ const status = await runGit(git, root, ['status', '--porcelain=v1', '-b'], 1024 * 1024)
449
+ if (status.code !== 0) {
450
+ sendJson(res, 200, { ok: false, error: cleanGitError(status.stderr, 'git status failed') })
451
+ return
452
+ }
453
+ const lines = status.stdout.split('\n')
454
+ let branch = null
455
+ let unborn = false
456
+ let upstream = null
457
+ let ahead = 0
458
+ let behind = 0
459
+ const header = lines.length > 0 ? lines[0] : ''
460
+ if (header.startsWith('## ')) {
461
+ const info = header.slice(3)
462
+ if (/^HEAD \(no branch\)$/.test(info)) {
463
+ branch = null
464
+ } else if (info.startsWith('No commits yet on ')) {
465
+ branch = info.slice('No commits yet on '.length)
466
+ unborn = true
467
+ } else {
468
+ const dots = info.indexOf('...')
469
+ if (dots === -1) {
470
+ branch = info
471
+ } else {
472
+ branch = info.slice(0, dots)
473
+ const rest = info.slice(dots + 3)
474
+ const bracket = rest.indexOf(' [')
475
+ upstream = bracket === -1 ? rest : rest.slice(0, bracket)
476
+ const flags = bracket === -1 ? '' : rest.slice(bracket + 2).replace(/\]$/, '')
477
+ for (const flag of flags.split(',')) {
478
+ const part = flag.trim().split(' ')
479
+ if (part[0] === 'ahead') ahead = Number(part[1]) || 0
480
+ if (part[0] === 'behind') behind = Number(part[1]) || 0
481
+ }
482
+ }
483
+ }
484
+ }
485
+ const entries = []
486
+ for (const line of lines.slice(1)) {
487
+ if (line.length < 4) continue
488
+ const x = line[0]
489
+ const y = line[1]
490
+ const rawPath = line.slice(3)
491
+ const arrow = x === 'R' || x === 'C' ? rawPath.indexOf(' -> ') : -1
492
+ if (arrow !== -1) {
493
+ entries.push({ status: x, oldPath: rawPath.slice(0, arrow), path: rawPath.slice(arrow + 4) })
494
+ } else {
495
+ entries.push({ status: x === ' ' ? y : x, path: rawPath })
496
+ }
497
+ }
498
+ sendJson(res, 200, { ok: true, root, branch, unborn, upstream, ahead, behind, entries })
499
+ } catch (error) {
500
+ sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
501
+ }
502
+ })
503
+
504
+ ctx.effect(() => () => {
505
+ while (disposers.length > 0) {
506
+ const dispose = disposers.pop()
507
+ try {
508
+ dispose()
509
+ } catch (_e) {
510
+ // Route teardown is best-effort while the web server is shutting down.
511
+ }
512
+ }
513
+ })
514
+ }
515
+
516
+ /** Attach inject on the default-exported apply: the loader unwraps default exports and drops named exports. */
517
+ apply.inject = ['webServer']
518
+
519
+ export default apply
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@yiln-dsh/dsh-plugin-file-explorer",
3
+ "version": "0.4.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "DSH bundle plugin that replaces the Web GUI details column with a workspace file explorer.",
8
+ "type": "module",
9
+ "main": "index.js",
10
+ "exports": {
11
+ ".": "./index.js",
12
+ "./client": "./client.js",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "dsh": {
16
+ "bundle": {
17
+ "patch": "./cordis.patch.yml"
18
+ },
19
+ "client": {
20
+ "inject": [
21
+ "@deepseek-ai/dsh-client-runtime",
22
+ "@deepseek-ai/dsh-client-ui-layout",
23
+ "@deepseek-ai/dsh-client-ui-renderer"
24
+ ],
25
+ "platform": "web"
26
+ }
27
+ },
28
+ "files": [
29
+ "index.js",
30
+ "client.js",
31
+ "cordis.patch.yml",
32
+ "README.md"
33
+ ],
34
+ "peerDependencies": {
35
+ "@deepseek-ai/cordis": ">=4.0.0",
36
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.0",
37
+ "@deepseek-ai/dsh-client-ui-layout": ">=0.1.0-rc.0",
38
+ "@deepseek-ai/dsh-client-ui-renderer": ">=0.1.0-rc.0"
39
+ },
40
+ "engines": {
41
+ "node": ">=22"
42
+ },
43
+ "license": "MIT"
44
+ }