dsh-plugin-workbench 0.0.5 → 0.0.7

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/src/index.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Registers one loopback-only generic RPC channel (`/dsh-plugin-files`) with
5
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
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
8
  * mutations and the editor write are explicit user actions over the
9
9
  * loopback-only channel, run unfenced like the /api write tools — the sandbox
10
10
  * service has no mkdir/rename/rm primitives, so those use node:fs/promises
@@ -16,9 +16,21 @@
16
16
  * dsh-plugin-image-tools uses for inline chat images. The route resolves and
17
17
  * stats the path through `ctx.fs` (so sandbox containment and file-ness apply
18
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.
19
27
  */
20
28
  import type { IncomingMessage, ServerResponse } from 'node:http'
21
- import { mkdir, rename as renameFs, rm, writeFile as writeFileNode } from 'node:fs/promises'
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'
22
34
  import type { Context } from '@deepseek-ai/cordis'
23
35
 
24
36
  export const name = 'dsh-plugin-workbench'
@@ -33,9 +45,21 @@ export const MAX_PREVIEW_BYTES = 512 * 1024
33
45
  /** Same-origin route serving raw bytes for image files (see module doc). */
34
46
  export const RAW_PREFIX = '/dsh-plugin-files/raw'
35
47
 
48
+ /** Same-origin SSE route streaming disk-change events to the preview pane. */
49
+ export const EVENTS_PREFIX = '/dsh-plugin-files/events'
50
+
36
51
  /** Images larger than this are never served to the preview (browser shows a hint). */
37
52
  export const MAX_IMAGE_BYTES = 20 * 1024 * 1024
38
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
+
39
63
  const IMAGE_MIME: Record<string, string> = {
40
64
  png: 'image/png',
41
65
  jpg: 'image/jpeg',
@@ -90,10 +114,17 @@ export interface FsWriteResult {
90
114
  /** Result of a context-menu mutation (create/rename/delete). */
91
115
  export interface FsMutationResult {
92
116
  path: string
117
+ /**
118
+ * Copy-only: `true` when the destination already existed and the copy was
119
+ * skipped (overwrite was false). Reported in the SUCCESS value — the generic
120
+ * RPC channel validates error bodies against the core's closed error-code
121
+ * union, so a custom error code like 'exists' would fail client-side parsing.
122
+ */
123
+ exists?: boolean
93
124
  }
94
125
 
95
126
  export type FilesRpcOk = { ok: true; value: FsListResult | FsReadResult | FsWriteResult | FsMutationResult }
96
- export type FilesRpcErr = { ok: false; error: { code: 'internal'; message: string; details: Record<string, never> } }
127
+ export type FilesRpcErr = { ok: false; error: { code: string; message: string; details: Record<string, never> } }
97
128
  export type FilesRpcResult = FilesRpcOk | FilesRpcErr
98
129
 
99
130
  /**
@@ -188,14 +219,26 @@ function pathOf(payload: unknown): string | undefined {
188
219
  * cancels the underlying fs call (or aborts between steps).
189
220
  */
190
221
  export function apply(ctx: Context): void {
222
+ // Per-apply watch state: created here (not module-level) so disable/reload
223
+ // cycles never leak watchers or SSE clients across applies.
224
+ const watchState: WatchState = {
225
+ dirs: new Map(),
226
+ files: new Map(),
227
+ selfWrites: new Map(),
228
+ clients: new Set(),
229
+ }
230
+
191
231
  const handler = async (endpoint: string, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> => {
192
232
  if (endpoint === 'list') return listDir(ctx, payload, signal)
193
233
  if (endpoint === 'read') return readFile(ctx, payload, signal)
194
- if (endpoint === 'write') return writeFile(ctx, payload, signal)
234
+ if (endpoint === 'write') return writeFile(ctx, watchState, payload, signal)
235
+ if (endpoint === 'watch') return setWatch(ctx, watchState, payload, signal)
195
236
  if (endpoint === 'createFile') return createFile(ctx, payload, signal)
196
237
  if (endpoint === 'createDir') return createDir(ctx, payload, signal)
197
238
  if (endpoint === 'rename') return renameEntry(ctx, payload, signal)
198
239
  if (endpoint === 'delete') return deleteEntry(ctx, payload, signal)
240
+ if (endpoint === 'copy') return copyEntry(ctx, payload, signal)
241
+ if (endpoint === 'reveal') return revealInExplorer(ctx, payload, signal)
199
242
  return fail(`unknown endpoint: ${endpoint}`)
200
243
  }
201
244
  // Effect-wrapped so HMR/disable cycles dispose the channel (the connection
@@ -214,6 +257,214 @@ export function apply(ctx: Context): void {
214
257
  void serveRaw(ctx, req, res)
215
258
  },
216
259
  }), 'dsh-plugin-workbench: raw image route')
260
+
261
+ // Disk-change stream: the SSE route + heartbeat + watcher lifecycle live in
262
+ // one effect so disposal closes every client and every fs.watch handle.
263
+ ctx.effect(() => {
264
+ const disposeRoute = ctx.webServer.register({
265
+ kind: 'exact',
266
+ path: EVENTS_PREFIX,
267
+ handler: (req, res) => sseHandler(watchState, req, res),
268
+ })
269
+ const heartbeat = setInterval(() => {
270
+ for (const res of watchState.clients) {
271
+ try {
272
+ res.write(': ping\n\n')
273
+ } catch {
274
+ // Dropped client — removed by its own 'close' event.
275
+ }
276
+ }
277
+ }, SSE_HEARTBEAT_MS)
278
+ return () => {
279
+ clearInterval(heartbeat)
280
+ disposeRoute()
281
+ disposeWatch(watchState)
282
+ }
283
+ }, 'dsh-plugin-workbench: disk change stream (SSE)')
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Disk watching (open-tab change detection)
288
+ //
289
+ // The client sends the full set of open tab paths; the host diffs it against
290
+ // the current watcher set. Each watched file lives under an `fs.watch` on its
291
+ // PARENT DIRECTORY (file-level handles die when editors atomic-rename, and
292
+ // directory watching also sees deletes), events are filtered by basename,
293
+ // coalesced per path, and pushed over the SSE route. `watch` is a pure
294
+ // reconciliation — call it as often as you like.
295
+ // ---------------------------------------------------------------------------
296
+
297
+ /** Per-apply watch registry (see the WatchState fields inline). */
298
+ interface WatchState {
299
+ /** Parent dir → its fs.watch handle and the watched basenames living in it. */
300
+ dirs: Map<string, { watcher: FSWatcher; basenames: Map<string, Set<string>> }>
301
+ /** OS path → client-facing path + pending coalescing timer. */
302
+ files: Map<string, { path: string; timer: ReturnType<typeof setTimeout> | undefined }>
303
+ /** OS path → timestamp of the plugin's own last write (self-change suppression). */
304
+ selfWrites: Map<string, number>
305
+ /** Connected SSE responses. */
306
+ clients: Set<ServerResponse>
307
+ }
308
+
309
+ /** Serve one SSE client connection (kept open until the browser disconnects). */
310
+ function sseHandler(state: WatchState, req: IncomingMessage, res: ServerResponse): void {
311
+ if ((req.method ?? 'GET').toUpperCase() !== 'GET') {
312
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' })
313
+ res.end('method not allowed')
314
+ return
315
+ }
316
+ res.writeHead(200, {
317
+ 'content-type': 'text/event-stream',
318
+ 'cache-control': 'no-cache',
319
+ connection: 'keep-alive',
320
+ 'x-accel-buffering': 'no',
321
+ })
322
+ res.write(': connected\n\n')
323
+ state.clients.add(res)
324
+ req.on('close', () => {
325
+ state.clients.delete(res)
326
+ })
327
+ }
328
+
329
+ /** Push one `change` frame to every connected SSE client. */
330
+ function emitChange(state: WatchState, path: string): void {
331
+ const frame = `event: change\ndata: ${JSON.stringify({ path })}\n\n`
332
+ for (const res of state.clients) {
333
+ try {
334
+ res.write(frame)
335
+ } catch {
336
+ // Client gone — dropped from the set by its 'close' event.
337
+ }
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Coalesce one filesystem event for an osPath into a single change
343
+ * notification (editors emit several events per save; the debounce collapses
344
+ * them). Events caused by this plugin's own saves are suppressed.
345
+ */
346
+ function scheduleEmit(state: WatchState, osPath: string): void {
347
+ const entry = state.files.get(osPath)
348
+ if (entry === undefined) return
349
+ const selfTs = state.selfWrites.get(osPath)
350
+ if (selfTs !== undefined && Date.now() - selfTs < SELF_WRITE_WINDOW_MS) return
351
+ if (entry.timer !== undefined) clearTimeout(entry.timer)
352
+ entry.timer = setTimeout(() => {
353
+ entry.timer = undefined
354
+ const still = state.files.get(osPath)
355
+ if (still === undefined) return
356
+ emitChange(state, still.path)
357
+ }, WATCH_DEBOUNCE_MS)
358
+ }
359
+
360
+ /** Start (or extend) the parent-dir watcher covering osPath. */
361
+ function watchDir(state: WatchState, dir: string, osPath: string): boolean {
362
+ let bucket = state.dirs.get(dir)
363
+ if (bucket === undefined) {
364
+ let watcher: FSWatcher
365
+ try {
366
+ watcher = watchFs(dir, { persistent: false }, (_eventType, filename) => {
367
+ const name = typeof filename === 'string' ? filename : undefined
368
+ if (name === undefined) {
369
+ // Platform omitted the filename: re-emit for every watched file here.
370
+ for (const os of state.files.keys()) {
371
+ if (dirname(os) === dir) scheduleEmit(state, os)
372
+ }
373
+ return
374
+ }
375
+ const bucketNow = state.dirs.get(dir)
376
+ const targets = bucketNow?.basenames.get(name)
377
+ if (targets === undefined) return
378
+ for (const os of targets) scheduleEmit(state, os)
379
+ })
380
+ } catch {
381
+ return false
382
+ }
383
+ bucket = { watcher, basenames: new Map() }
384
+ state.dirs.set(dir, bucket)
385
+ }
386
+ const name = basename(osPath)
387
+ let targets = bucket.basenames.get(name)
388
+ if (targets === undefined) {
389
+ targets = new Set()
390
+ bucket.basenames.set(name, targets)
391
+ }
392
+ targets.add(osPath)
393
+ return true
394
+ }
395
+
396
+ /** Stop watching one osPath (and its parent dir when nothing else uses it). */
397
+ function unwatch(state: WatchState, osPath: string): void {
398
+ const entry = state.files.get(osPath)
399
+ if (entry === undefined) return
400
+ if (entry.timer !== undefined) clearTimeout(entry.timer)
401
+ state.files.delete(osPath)
402
+ state.selfWrites.delete(osPath)
403
+ const dir = dirname(osPath)
404
+ const bucket = state.dirs.get(dir)
405
+ if (bucket === undefined) return
406
+ const name = basename(osPath)
407
+ const targets = bucket.basenames.get(name)
408
+ if (targets !== undefined) {
409
+ targets.delete(osPath)
410
+ if (targets.size === 0) bucket.basenames.delete(name)
411
+ }
412
+ if (bucket.basenames.size === 0) {
413
+ bucket.watcher.close()
414
+ state.dirs.delete(dir)
415
+ }
416
+ }
417
+
418
+ /** Close every watcher, pending timer and SSE client (effect disposal). */
419
+ function disposeWatch(state: WatchState): void {
420
+ for (const bucket of state.dirs.values()) bucket.watcher.close()
421
+ state.dirs.clear()
422
+ for (const entry of state.files.values()) {
423
+ if (entry.timer !== undefined) clearTimeout(entry.timer)
424
+ }
425
+ state.files.clear()
426
+ state.selfWrites.clear()
427
+ for (const res of state.clients) {
428
+ try {
429
+ res.end()
430
+ } catch {
431
+ // Already closed.
432
+ }
433
+ }
434
+ state.clients.clear()
435
+ }
436
+
437
+ /** Reconcile the watcher set with the client's open tab paths (idempotent). */
438
+ async function setWatch(ctx: Context, state: WatchState, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
439
+ const raw = typeof payload === 'object' && payload !== null ? (payload as { paths?: unknown }).paths : undefined
440
+ if (!Array.isArray(raw) || raw.some((p) => typeof p !== 'string')) {
441
+ return fail('watch: payload.paths must be an array of strings')
442
+ }
443
+ // Resolve each requested path to its OS path. Unresolvable paths (deleted,
444
+ // sandboxed) are skipped; the next sync round retries them.
445
+ const wanted = new Map<string, string>()
446
+ for (const p of raw as string[]) {
447
+ if (signal.aborted) break
448
+ try {
449
+ const target = await ctx.fs.resolve(p, { signal })
450
+ const osPath = ctx.fs.processPath(target)
451
+ // Key by OS path, keep the client's display path for the emit.
452
+ wanted.set(osPath, p)
453
+ } catch {
454
+ // Not resolvable this round — drop it.
455
+ }
456
+ }
457
+ // Remove watchers no longer wanted.
458
+ for (const osPath of [...state.files.keys()]) {
459
+ if (!wanted.has(osPath)) unwatch(state, osPath)
460
+ }
461
+ // Start missing watchers.
462
+ for (const [osPath, displayPath] of wanted) {
463
+ if (state.files.has(osPath)) continue
464
+ if (!watchDir(state, dirname(osPath), osPath)) continue
465
+ state.files.set(osPath, { path: displayPath, timer: undefined })
466
+ }
467
+ return { ok: true, value: { path: '' } }
217
468
  }
218
469
 
219
470
  async function serveRaw(ctx: Context, req: IncomingMessage, res: ServerResponse): Promise<void> {
@@ -305,7 +556,7 @@ async function readFile(ctx: Context, payload: unknown, signal: AbortSignal): Pr
305
556
  }
306
557
  }
307
558
 
308
- async function writeFile(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
559
+ async function writeFile(ctx: Context, state: WatchState, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
309
560
  const path = pathOf(payload)
310
561
  const content = typeof payload === 'object' && payload !== null ? (payload as { content?: unknown }).content : undefined
311
562
  if (path === undefined) return fail('write: payload.path must be a non-empty string')
@@ -318,7 +569,18 @@ async function writeFile(ctx: Context, payload: unknown, signal: AbortSignal): P
318
569
  mode: 'danger-full-access',
319
570
  workspaceRoot: ctx.fs.processPath(target),
320
571
  })
321
- return { ok: true, value: { path: ctx.fs.processPath(target), size: content.length } }
572
+ // This save will trip the fs.watch on the file's parent dir; suppress it
573
+ // so an own save never bounces back as a "changed on disk" event.
574
+ const osPath = ctx.fs.processPath(target)
575
+ state.selfWrites.set(osPath, Date.now())
576
+ // Prune stale markers occasionally.
577
+ if (state.selfWrites.size > 64) {
578
+ const cutoff = Date.now() - SELF_WRITE_WINDOW_MS * 4
579
+ for (const [os, ts] of state.selfWrites) {
580
+ if (ts < cutoff) state.selfWrites.delete(os)
581
+ }
582
+ }
583
+ return { ok: true, value: { path: osPath, size: content.length } }
322
584
  } catch (error) {
323
585
  return fail(mapError(error))
324
586
  }
@@ -399,3 +661,147 @@ async function deleteEntry(ctx: Context, payload: unknown, signal: AbortSignal):
399
661
  return fail(mapError(error))
400
662
  }
401
663
  }
664
+
665
+ // ---------------------------------------------------------------------------
666
+ // Copy (the explorer's Copy / Cut + Paste)
667
+ //
668
+ // `fs.cp` handles files and (recursively) folders; `errorOnExist` turns a
669
+ // colliding destination into an `ERR_FS_CP_EEXIST` throw, which is reported as
670
+ // a SUCCESS with `exists: true` so the client can ask the user whether to
671
+ // overwrite, exactly like the OS file manager. The same `ctx.fs.resolve` →
672
+ // `processPath` resolution as every other endpoint applies, so sandbox
673
+ // containment and error mapping stay uniform. (Reported in the value, never as
674
+ // an error body: generic-channel errors must use the core's closed error-code
675
+ // union, and a custom code there would fail the client-side response parse.)
676
+ // ---------------------------------------------------------------------------
677
+
678
+ async function copyEntry(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
679
+ // Note: the client sends `from`, not `path` (which is what pathOf reads).
680
+ const from = typeof payload === 'object' && payload !== null ? (payload as { from?: unknown }).from : undefined
681
+ const to = typeof payload === 'object' && payload !== null ? (payload as { to?: unknown }).to : undefined
682
+ const overwrite = typeof payload === 'object' && payload !== null && (payload as { overwrite?: unknown }).overwrite === true
683
+ if (typeof from !== 'string' || from.trim().length === 0) return fail('copy: payload.from must be a non-empty string')
684
+ if (typeof to !== 'string' || to.trim().length === 0) return fail('copy: payload.to must be a non-empty string')
685
+ let toOs = ''
686
+ try {
687
+ const fromTarget = await ctx.fs.resolve(from, { signal })
688
+ const fromOs = ctx.fs.processPath(fromTarget)
689
+ const info = await ctx.fs.stat(fromTarget, signal)
690
+ if (info === undefined) return fail(`path not found: ${from}`)
691
+ const toTarget = await ctx.fs.resolve(to, { signal })
692
+ toOs = ctx.fs.processPath(toTarget)
693
+ // Refuse trivial self-copies and copying a folder into itself or one of
694
+ // its own descendants (comparisons case-insensitive — Windows).
695
+ const samePath = fromOs.toLowerCase() === toOs.toLowerCase()
696
+ if (samePath) return fail('copy: source and destination are the same path')
697
+ if (info.type === 'directory') {
698
+ const sep = fromOs.includes('\\') ? '\\' : '/'
699
+ const prefix = fromOs.endsWith('\\') || fromOs.endsWith('/') ? fromOs : fromOs + sep
700
+ if (toOs.toLowerCase().startsWith(prefix.toLowerCase())) return fail('copy: cannot copy a folder into itself')
701
+ }
702
+ await cp(fromOs, toOs, { recursive: true, force: overwrite, errorOnExist: !overwrite })
703
+ return { ok: true, value: { path: toOs } }
704
+ } catch (error) {
705
+ if (isFsErrorCode(error, 'ERR_FS_CP_EEXIST')) {
706
+ // Destination already exists and overwrite was not requested: report the
707
+ // collision as a successful no-op so the client can ask about overwrite.
708
+ return { ok: true, value: { path: toOs, exists: true } }
709
+ }
710
+ return fail(mapError(error))
711
+ }
712
+ }
713
+
714
+ // ---------------------------------------------------------------------------
715
+ // Reveal in the OS file manager ("在资源管理器打开")
716
+ //
717
+ // Spawns the platform's file-manager command so the item shows up in the
718
+ // system explorer — files are SELECTED inside their containing folder
719
+ // (Windows `explorer /select,`, macOS `open -R`), folders are OPENED directly
720
+ // (`explorer <dir>`, `open <dir>`). WSL paths are translated through
721
+ // `wslpath` first; desktop Linux falls back to `xdg-open` (file → its parent
722
+ // directory, folder → itself). Paths are resolved through `ctx.fs` first, so
723
+ // the sandbox containment applies exactly as for every other endpoint; the
724
+ // spawn itself is a local desktop action, the same trust level as the existing
725
+ // "open in system" gesture (`ctx.workspaces.openPath`).
726
+ // ---------------------------------------------------------------------------
727
+
728
+ /** Spawn one short-lived desktop command; resolves once the process launched. */
729
+ function runDesktop(command: string, args: string[]): Promise<void> {
730
+ return new Promise((resolve, reject) => {
731
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true })
732
+ child.once('error', reject)
733
+ child.once('spawn', () => {
734
+ // The window stays after the harness exits; nothing to wait for.
735
+ child.unref()
736
+ resolve()
737
+ })
738
+ })
739
+ }
740
+
741
+ /** Run one command and capture its stdout; rejects on non-zero exit. */
742
+ function execCapture(command: string, args: string[]): Promise<string> {
743
+ return new Promise((resolve, reject) => {
744
+ const child = spawn(command, args, { windowsHide: true })
745
+ let out = ''
746
+ child.stdout.on('data', (chunk: Buffer) => {
747
+ out += chunk.toString()
748
+ })
749
+ child.once('error', reject)
750
+ child.once('close', (code) => {
751
+ if (code === 0) resolve(out)
752
+ else reject(new Error(`${command} exited with code ${code}`))
753
+ })
754
+ })
755
+ }
756
+
757
+ /** Reveal one resolved OS path in the platform's file manager. */
758
+ async function revealNative(osPath: string, isDir: boolean, signal: AbortSignal): Promise<void> {
759
+ signal.throwIfAborted()
760
+ const platform = process.platform
761
+ if (platform === 'win32') {
762
+ // explorer returns exit code 1 when it opens a NEW window, so exit codes
763
+ // carry no meaning; a clean spawn is success. Files are selected in their
764
+ // folder; folders are opened directly.
765
+ await runDesktop('explorer.exe', isDir ? [osPath] : ['/select,', osPath])
766
+ return
767
+ }
768
+ if (platform === 'darwin') {
769
+ // `open -R` reveals in Finder; plain `open` opens a folder.
770
+ await runDesktop('open', isDir ? [osPath] : ['-R', osPath])
771
+ return
772
+ }
773
+ if (platform === 'linux') {
774
+ // WSL: translate to a Windows path and hand it to the Windows desktop.
775
+ const env = process.env
776
+ if (env.WSL_DISTRO_NAME !== undefined || env.WSL_INTEROP !== undefined) {
777
+ const windowsPath = (await execCapture('wslpath', ['-w', osPath])).replace(/[\r\n]+$/, '')
778
+ if (windowsPath === '') throw new Error('wslpath returned no Windows path')
779
+ await runDesktop('explorer.exe', isDir ? [windowsPath] : ['/select,', windowsPath])
780
+ return
781
+ }
782
+ // Desktop Linux: the default file manager opens folders; files open in
783
+ // their parent directory.
784
+ await runDesktop('xdg-open', [isDir ? osPath : dirname(osPath)])
785
+ return
786
+ }
787
+ throw new Error(`reveal in the file manager is unsupported on ${platform}`)
788
+ }
789
+
790
+ /** RPC endpoint: reveal one explorer path in the OS file manager. */
791
+ async function revealInExplorer(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
792
+ const path = pathOf(payload)
793
+ const kind = typeof payload === 'object' && payload !== null ? (payload as { kind?: unknown }).kind : undefined
794
+ if (path === undefined) return fail('reveal: payload.path must be a non-empty string')
795
+ try {
796
+ const target = await ctx.fs.resolve(path, { signal })
797
+ const info = await ctx.fs.stat(target, signal)
798
+ if (info === undefined) return fail(`path not found: ${path}`)
799
+ const isDir = kind === 'dir' || info.type === 'directory'
800
+ const osPath = ctx.fs.processPath(target)
801
+ await revealNative(osPath, isDir, signal)
802
+ return { ok: true, value: { path: osPath } }
803
+ } catch (error) {
804
+ if (signal.aborted) return fail('reveal: aborted')
805
+ return fail(mapError(error))
806
+ }
807
+ }