dsh-plugin-workbench 0.0.4 → 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/lib/index.js CHANGED
@@ -1,10 +1,40 @@
1
+ import { mkdir, rename, rm, writeFile } from "node:fs/promises";
1
2
  //#region src/index.ts
2
3
  const name = "dsh-plugin-workbench";
3
- const inject = ["fs", "connection"];
4
+ const inject = [
5
+ "fs",
6
+ "connection",
7
+ "webServer"
8
+ ];
4
9
  /** Loopback-only logical RPC channel. */
5
10
  const CHANNEL = "/dsh-plugin-files";
6
11
  /** Files larger than this are never read for preview (client shows size + hint). */
7
12
  const MAX_PREVIEW_BYTES = 524288;
13
+ /** Same-origin route serving raw bytes for image files (see module doc). */
14
+ const RAW_PREFIX = "/dsh-plugin-files/raw";
15
+ /** Images larger than this are never served to the preview (browser shows a hint). */
16
+ const MAX_IMAGE_BYTES = 20971520;
17
+ const IMAGE_MIME = {
18
+ png: "image/png",
19
+ jpg: "image/jpeg",
20
+ jpeg: "image/jpeg",
21
+ gif: "image/gif",
22
+ webp: "image/webp",
23
+ avif: "image/avif",
24
+ bmp: "image/bmp",
25
+ ico: "image/x-icon",
26
+ svg: "image/svg+xml"
27
+ };
28
+ /** MIME type for a path's extension when it names a previewable image; undefined otherwise. */
29
+ function imageMimeOf(path) {
30
+ const idx = path.lastIndexOf(".");
31
+ if (idx < 0 || idx === path.length - 1) return void 0;
32
+ return IMAGE_MIME[path.slice(idx + 1).toLowerCase()];
33
+ }
34
+ /** True when the path names a previewable image file. */
35
+ function isImagePath(path) {
36
+ return imageMimeOf(path) !== void 0;
37
+ }
8
38
  /** Map an fs entry type onto the wire `kind` union. */
9
39
  function kindOf(type) {
10
40
  if (type === "directory") return "dir";
@@ -42,7 +72,15 @@ const FS_ERROR_MESSAGES = {
42
72
  FS_PERMISSION_DENIED: "permission denied",
43
73
  FS_SANDBOX_DENIED: "sandbox denied",
44
74
  FS_ABORTED: "aborted",
45
- FS_IO_ERROR: "io error"
75
+ FS_IO_ERROR: "io error",
76
+ EEXIST: "file or folder already exists",
77
+ ENOENT: "path does not exist",
78
+ ENOTEMPTY: "folder is not empty",
79
+ EPERM: "permission denied",
80
+ EACCES: "permission denied",
81
+ ENOTDIR: "not a directory",
82
+ EISDIR: "is a directory",
83
+ EBUSY: "file is in use"
46
84
  };
47
85
  /** Human-readable message for a thrown value, honoring the fs error code taxonomy. */
48
86
  function mapError(error) {
@@ -80,10 +118,65 @@ function apply(ctx) {
80
118
  const handler = async (endpoint, payload, signal) => {
81
119
  if (endpoint === "list") return listDir(ctx, payload, signal);
82
120
  if (endpoint === "read") return readFile(ctx, payload, signal);
83
- if (endpoint === "write") return writeFile(ctx, payload, signal);
121
+ if (endpoint === "write") return writeFile$1(ctx, payload, signal);
122
+ if (endpoint === "createFile") return createFile(ctx, payload, signal);
123
+ if (endpoint === "createDir") return createDir(ctx, payload, signal);
124
+ if (endpoint === "rename") return renameEntry(ctx, payload, signal);
125
+ if (endpoint === "delete") return deleteEntry(ctx, payload, signal);
84
126
  return fail(`unknown endpoint: ${endpoint}`);
85
127
  };
86
- ctx.connection.rpc.handle(CHANNEL, handler, { authority: "loopback" });
128
+ ctx.effect(() => ctx.connection.rpc.handle(CHANNEL, handler, { authority: "loopback" }), "dsh-plugin-workbench: files rpc channel");
129
+ ctx.effect(() => ctx.webServer.register({
130
+ kind: "prefix",
131
+ path: RAW_PREFIX,
132
+ handler: (req, res) => {
133
+ serveRaw(ctx, req, res);
134
+ }
135
+ }), "dsh-plugin-workbench: raw image route");
136
+ }
137
+ async function serveRaw(ctx, req, res) {
138
+ const text = (code, body) => {
139
+ res.writeHead(code, { "content-type": "text/plain; charset=utf-8" });
140
+ res.end(body);
141
+ };
142
+ try {
143
+ if ((req.method ?? "GET").toUpperCase() !== "GET") {
144
+ text(405, "method not allowed");
145
+ return;
146
+ }
147
+ const rest = new URL(req.url ?? "/", "http://dsh.internal").pathname.slice(21).replace(/^\/+/, "");
148
+ if (rest.length === 0) {
149
+ text(404, "not found");
150
+ return;
151
+ }
152
+ const path = decodeURIComponent(rest);
153
+ const mime = imageMimeOf(path);
154
+ if (mime === void 0) {
155
+ text(404, "not an image");
156
+ return;
157
+ }
158
+ const target = await ctx.fs.resolve(path);
159
+ const info = await ctx.fs.stat(target);
160
+ if (info === void 0 || info.type !== "file") {
161
+ text(404, "not found");
162
+ return;
163
+ }
164
+ if ((info.size ?? 0) > 20971520) {
165
+ text(413, "image too large");
166
+ return;
167
+ }
168
+ const bytes = await ctx.fs.readBytes(target, void 0, MAX_IMAGE_BYTES);
169
+ res.writeHead(200, {
170
+ "content-type": mime,
171
+ "content-length": bytes.byteLength,
172
+ "cache-control": "private, max-age=300",
173
+ "x-content-type-options": "nosniff"
174
+ });
175
+ res.end(Buffer.from(bytes));
176
+ } catch (error) {
177
+ if (!res.headersSent) text(500, "internal error");
178
+ else res.destroy();
179
+ }
87
180
  }
88
181
  async function listDir(ctx, payload, signal) {
89
182
  const path = pathOf(payload);
@@ -153,7 +246,7 @@ async function readFile(ctx, payload, signal) {
153
246
  return fail(mapError(error));
154
247
  }
155
248
  }
156
- async function writeFile(ctx, payload, signal) {
249
+ async function writeFile$1(ctx, payload, signal) {
157
250
  const path = pathOf(payload);
158
251
  const content = typeof payload === "object" && payload !== null ? payload.content : void 0;
159
252
  if (path === void 0) return fail("write: payload.path must be a non-empty string");
@@ -175,5 +268,75 @@ async function writeFile(ctx, payload, signal) {
175
268
  return fail(mapError(error));
176
269
  }
177
270
  }
271
+ async function createFile(ctx, payload, signal) {
272
+ const path = pathOf(payload);
273
+ if (path === void 0) return fail("createFile: payload.path must be a non-empty string");
274
+ try {
275
+ const target = await ctx.fs.resolve(path, { signal });
276
+ const osPath = ctx.fs.processPath(target);
277
+ await writeFile(osPath, "", { flag: "wx" });
278
+ return {
279
+ ok: true,
280
+ value: { path: osPath }
281
+ };
282
+ } catch (error) {
283
+ return fail(mapError(error));
284
+ }
285
+ }
286
+ async function createDir(ctx, payload, signal) {
287
+ const path = pathOf(payload);
288
+ if (path === void 0) return fail("createDir: payload.path must be a non-empty string");
289
+ try {
290
+ const target = await ctx.fs.resolve(path, { signal });
291
+ const osPath = ctx.fs.processPath(target);
292
+ await mkdir(osPath);
293
+ return {
294
+ ok: true,
295
+ value: { path: osPath }
296
+ };
297
+ } catch (error) {
298
+ return fail(mapError(error));
299
+ }
300
+ }
301
+ async function renameEntry(ctx, payload, signal) {
302
+ const path = pathOf(payload);
303
+ const to = typeof payload === "object" && payload !== null ? payload.to : void 0;
304
+ if (path === void 0) return fail("rename: payload.path must be a non-empty string");
305
+ if (typeof to !== "string" || to.trim().length === 0) return fail("rename: payload.to must be a non-empty string");
306
+ try {
307
+ const target = await ctx.fs.resolve(path, { signal });
308
+ const osPath = ctx.fs.processPath(target);
309
+ const toTarget = await ctx.fs.resolve(to, { signal });
310
+ const toOsPath = ctx.fs.processPath(toTarget);
311
+ if (await ctx.fs.stat(toTarget, signal) !== void 0) return fail("rename: destination already exists");
312
+ await rename(osPath, toOsPath);
313
+ return {
314
+ ok: true,
315
+ value: { path: toOsPath }
316
+ };
317
+ } catch (error) {
318
+ return fail(mapError(error));
319
+ }
320
+ }
321
+ async function deleteEntry(ctx, payload, signal) {
322
+ const path = pathOf(payload);
323
+ if (path === void 0) return fail("delete: payload.path must be a non-empty string");
324
+ try {
325
+ const target = await ctx.fs.resolve(path, { signal });
326
+ const osPath = ctx.fs.processPath(target);
327
+ const info = await ctx.fs.stat(target, signal);
328
+ if (info === void 0) return fail(`path not found: ${path}`);
329
+ await rm(osPath, {
330
+ recursive: info.type === "directory",
331
+ force: true
332
+ });
333
+ return {
334
+ ok: true,
335
+ value: { path: osPath }
336
+ };
337
+ } catch (error) {
338
+ return fail(mapError(error));
339
+ }
340
+ }
178
341
  //#endregion
179
- export { CHANNEL, MAX_PREVIEW_BYTES, apply, inject, kindOf, mapDirEntry, mapError, name, sortEntries };
342
+ export { CHANNEL, MAX_IMAGE_BYTES, MAX_PREVIEW_BYTES, RAW_PREFIX, apply, imageMimeOf, inject, isImagePath, kindOf, mapDirEntry, mapError, name, sortEntries };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-workbench",
3
3
  "description": "VS Code-style workspace file explorer + editable preview for the dsh web GUI",
4
- "version": "0.0.4",
4
+ "version": "0.0.5",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.21.0",
7
7
  "engines": {
@@ -3,11 +3,12 @@
3
3
  * (patched) ui-layout AppFrame. File selection is pushed into the shared
4
4
  * selection store so the `explorer.preview` slot can render the split view.
5
5
  */
6
- import { memo, useCallback, useEffect, useRef, useState } from 'react'
6
+ import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
7
+ import type { MouseEvent as ReactMouseEvent } from 'react'
7
8
  import styles from './files.module.css'
8
9
  import { FileIcon } from './fileIcons'
9
10
  import type { FilesKey } from './locales'
10
- import { expandPreview, openFile, setCwd, toggleTheme, useTabsState } from './store'
11
+ import { closeFilesUnder, expandPreview, openFile, retargetFile, setCwd, toggleTheme, useTabsState } from './store'
11
12
 
12
13
  export interface FsListEntry {
13
14
  name: string
@@ -29,6 +30,11 @@ export interface FsReadResult {
29
30
  truncated: boolean
30
31
  }
31
32
 
33
+ /** Result of a context-menu mutation (create/rename/delete). */
34
+ export interface FsMutationResult {
35
+ path: string
36
+ }
37
+
32
38
  interface SessionSummary {
33
39
  id: string
34
40
  cwd?: string
@@ -45,6 +51,10 @@ export interface FileExplorerProps {
45
51
  t: (key: FilesKey, params?: Record<string, unknown>) => string
46
52
  listDir: (path: string, signal?: AbortSignal) => Promise<FsListResult>
47
53
  openPath: (path: string) => Promise<void>
54
+ createFile: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
55
+ createDir: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
56
+ renameFile: (path: string, to: string, signal?: AbortSignal) => Promise<FsMutationResult>
57
+ removePath: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
48
58
  }
49
59
 
50
60
  function basenameOf(path: string): string {
@@ -53,6 +63,18 @@ function basenameOf(path: string): string {
53
63
  return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
54
64
  }
55
65
 
66
+ /** Parent directory of a path ('' for a bare drive root — never used for such). */
67
+ function parentOf(path: string): string {
68
+ const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
69
+ return idx > 0 ? path.slice(0, idx) : path
70
+ }
71
+
72
+ /** Append a child name to a directory path, honoring its separator style. */
73
+ function joinPath(dir: string, name: string): string {
74
+ const sep = dir.includes('\\') ? '\\' : '/'
75
+ return dir.endsWith('\\') || dir.endsWith('/') ? dir + name : dir + sep + name
76
+ }
77
+
56
78
  function formatSize(bytes: number): string {
57
79
  if (!Number.isFinite(bytes) || bytes < 0) return ''
58
80
  if (bytes < 1024) return `${bytes} B`
@@ -91,6 +113,7 @@ interface TreeRowProps {
91
113
  onSelect: (entry: FsListEntry) => void
92
114
  onDoubleClick: (entry: FsListEntry) => void
93
115
  onOpenExternal: (path: string) => Promise<void>
116
+ onContextMenu: (e: ReactMouseEvent, entry: FsListEntry) => void
94
117
  t: (key: FilesKey, params?: Record<string, unknown>) => string
95
118
  }
96
119
 
@@ -110,6 +133,7 @@ const TreeRow = memo(function TreeRow({
110
133
  onSelect,
111
134
  onDoubleClick,
112
135
  onOpenExternal,
136
+ onContextMenu,
113
137
  t,
114
138
  }: TreeRowProps) {
115
139
  const isDir = entry.kind === 'dir'
@@ -119,6 +143,7 @@ const TreeRow = memo(function TreeRow({
119
143
  style={{ paddingLeft: 8 + depth * 14 }}
120
144
  onClick={() => onSelect(entry)}
121
145
  onDoubleClick={() => onDoubleClick(entry)}
146
+ onContextMenu={(e) => onContextMenu(e, entry)}
122
147
  role="treeitem"
123
148
  aria-selected={isActive}
124
149
  aria-expanded={isDir ? isExpanded : undefined}
@@ -170,7 +195,17 @@ const TreeRow = memo(function TreeRow({
170
195
  )
171
196
  })
172
197
 
173
- export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileExplorerProps) {
198
+ export function FileExplorer({
199
+ width,
200
+ useSessions,
201
+ t,
202
+ listDir,
203
+ openPath,
204
+ createFile,
205
+ createDir,
206
+ renameFile,
207
+ removePath,
208
+ }: FileExplorerProps) {
174
209
  const sessionList = useSessions((s) => s)
175
210
  const currentId = sessionList.current
176
211
  const cwd = currentId !== undefined ? sessionList.byId[currentId]?.cwd : undefined
@@ -190,6 +225,57 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
190
225
  const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set())
191
226
  const [dirErrors, setDirErrors] = useState<Record<string, string>>({})
192
227
 
228
+ // ---- context menu ----
229
+ interface MenuState {
230
+ kind: 'file' | 'dir' | 'root'
231
+ path: string
232
+ x: number
233
+ y: number
234
+ }
235
+ const [menu, setMenu] = useState<MenuState | undefined>(undefined)
236
+ const [menuPos, setMenuPos] = useState<{ x: number; y: number } | undefined>(undefined)
237
+ const menuRef = useRef<HTMLDivElement>(null)
238
+
239
+ // Measure and clamp the menu into the viewport before the browser paints
240
+ // (layout effects run pre-paint, so the raw position never shows).
241
+ useLayoutEffect(() => {
242
+ if (menu === undefined) {
243
+ setMenuPos(undefined)
244
+ return
245
+ }
246
+ const el = menuRef.current
247
+ if (el === null) return
248
+ const rect = el.getBoundingClientRect()
249
+ setMenuPos({
250
+ x: Math.max(4, Math.min(menu.x, window.innerWidth - rect.width - 4)),
251
+ y: Math.max(4, Math.min(menu.y, window.innerHeight - rect.height - 4)),
252
+ })
253
+ }, [menu])
254
+
255
+ // Close the menu on outside click / Escape / window blur.
256
+ useEffect(() => {
257
+ if (menu === undefined) return undefined
258
+ const onKeyDown = (e: KeyboardEvent) => {
259
+ if (e.key === 'Escape') setMenu(undefined)
260
+ }
261
+ const onMouseDown = (e: MouseEvent) => {
262
+ const el = menuRef.current
263
+ if (el !== null && e.target instanceof Node && el.contains(e.target)) return
264
+ setMenu(undefined)
265
+ }
266
+ const onBlur = () => setMenu(undefined)
267
+ document.addEventListener('keydown', onKeyDown)
268
+ // mousedown (not click): closing before a menu item's click still lets the
269
+ // click dispatch on the item, and closes when clicking anywhere else.
270
+ document.addEventListener('mousedown', onMouseDown)
271
+ window.addEventListener('blur', onBlur)
272
+ return () => {
273
+ document.removeEventListener('keydown', onKeyDown)
274
+ document.removeEventListener('mousedown', onMouseDown)
275
+ window.removeEventListener('blur', onBlur)
276
+ }
277
+ }, [menu])
278
+
193
279
  // Latest tree snapshot for the polling tick (avoids stale closures).
194
280
  const treeRef = useRef({ root, children, expanded, rootLoading })
195
281
  treeRef.current = { root, children, expanded, rootLoading }
@@ -358,6 +444,81 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
358
444
  void loadDir(dirPath)
359
445
  }, [loadDir])
360
446
 
447
+ // ---- context-menu actions ----
448
+
449
+ const openContextMenu = useCallback((e: ReactMouseEvent, entry: FsListEntry) => {
450
+ e.preventDefault()
451
+ e.stopPropagation()
452
+ setMenu({ kind: entry.kind === 'dir' ? 'dir' : 'file', path: entry.path, x: e.clientX, y: e.clientY })
453
+ }, [])
454
+
455
+ const runAction = useCallback((action: () => void) => {
456
+ setMenu(undefined)
457
+ action()
458
+ }, [])
459
+
460
+ // One mutation pipeline shared by New File / New Folder / Rename / Delete:
461
+ // run the op, refresh the touched directory, and surface failures inline in
462
+ // the tree (under the directory row, or at the column top for the root).
463
+ const applyMutation = useCallback(async (op: () => Promise<unknown>, refreshPath: string) => {
464
+ try {
465
+ await op()
466
+ refreshDir(refreshPath)
467
+ } catch (error) {
468
+ const message = error instanceof Error ? error.message : String(error)
469
+ if (refreshPath === root) setRootError(message)
470
+ else setDirErrors((prev) => ({ ...prev, [refreshPath]: message }))
471
+ }
472
+ }, [refreshDir, root])
473
+
474
+ const onNewFile = useCallback((dirPath: string) => {
475
+ const name = window.prompt(`${t('prompt.newFileName')}:`, '')
476
+ if (name === null) return
477
+ const trimmed = name.trim()
478
+ if (trimmed === '') return
479
+ void applyMutation(() => createFile(joinPath(dirPath, trimmed)), dirPath)
480
+ }, [applyMutation, createFile, t])
481
+
482
+ const onNewFolder = useCallback((dirPath: string) => {
483
+ const name = window.prompt(`${t('prompt.newFolderName')}:`, '')
484
+ if (name === null) return
485
+ const trimmed = name.trim()
486
+ if (trimmed === '') return
487
+ void applyMutation(() => createDir(joinPath(dirPath, trimmed)), dirPath)
488
+ }, [applyMutation, createDir, t])
489
+
490
+ const onRename = useCallback((path: string) => {
491
+ const current = basenameOf(path)
492
+ const name = window.prompt(`${t('prompt.renameTo')}:`, current)
493
+ if (name === null) return
494
+ const trimmed = name.trim()
495
+ if (trimmed === '' || trimmed === current) return
496
+ const parent = parentOf(path)
497
+ const to = joinPath(parent, trimmed)
498
+ void applyMutation(async () => {
499
+ await renameFile(path, to)
500
+ // Keep any open tab pointing at the moved file.
501
+ retargetFile(path, to)
502
+ }, parent)
503
+ }, [applyMutation, renameFile, t])
504
+
505
+ const onDelete = useCallback((path: string, kind: 'file' | 'dir') => {
506
+ const name = basenameOf(path)
507
+ const message = kind === 'dir' ? t('confirm.deleteDir', { name }) : t('confirm.deleteFile', { name })
508
+ if (!window.confirm(message)) return
509
+ void applyMutation(async () => {
510
+ await removePath(path)
511
+ // Drop tabs for the deleted file (or everything under a deleted folder).
512
+ closeFilesUnder(path)
513
+ }, parentOf(path))
514
+ }, [applyMutation, removePath, t])
515
+
516
+ const onCopyPath = useCallback((path: string) => {
517
+ void navigator.clipboard.writeText(path).catch(() => {
518
+ // clipboard unavailable (permissions) — nothing else to do
519
+ })
520
+ }, [])
521
+
361
522
  const onRowClick = useCallback((entry: FsListEntry) => {
362
523
  if (entry.kind === 'dir') {
363
524
  toggleDir(entry.path)
@@ -401,6 +562,7 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
401
562
  onSelect={onRowClick}
402
563
  onDoubleClick={onRowDoubleClick}
403
564
  onOpenExternal={openPath}
565
+ onContextMenu={openContextMenu}
404
566
  t={t}
405
567
  />
406
568
  {isDir && isExpanded && (
@@ -417,6 +579,41 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
417
579
  )
418
580
  })
419
581
 
582
+ const menuItems = (m: MenuState): Array<{ label: string; danger?: boolean; onClick: () => void } | 'divider'> => {
583
+ if (m.kind === 'file') {
584
+ return [
585
+ { label: t('menu.open'), onClick: () => openFile(m.path) },
586
+ 'divider',
587
+ { label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
588
+ { label: t('menu.openSystem'), onClick: () => void openPath(m.path) },
589
+ 'divider',
590
+ { label: t('menu.rename'), onClick: () => onRename(m.path) },
591
+ { label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, 'file') },
592
+ ]
593
+ }
594
+ if (m.kind === 'dir') {
595
+ return [
596
+ { label: t('menu.newFile'), onClick: () => onNewFile(m.path) },
597
+ { label: t('menu.newFolder'), onClick: () => onNewFolder(m.path) },
598
+ 'divider',
599
+ { label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
600
+ { label: t('menu.openSystem'), onClick: () => void openPath(m.path) },
601
+ { label: t('menu.refresh'), onClick: () => refreshDir(m.path) },
602
+ 'divider',
603
+ { label: t('menu.rename'), onClick: () => onRename(m.path) },
604
+ { label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, 'dir') },
605
+ ]
606
+ }
607
+ // Empty tree area (the workspace root).
608
+ return [
609
+ { label: t('menu.newFile'), onClick: () => onNewFile(m.path) },
610
+ { label: t('menu.newFolder'), onClick: () => onNewFolder(m.path) },
611
+ 'divider',
612
+ { label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
613
+ { label: t('menu.refresh'), onClick: () => refreshDir(m.path) },
614
+ ]
615
+ }
616
+
420
617
  return (
421
618
  <div className={styles.column} style={{ width: width > 0 ? width : undefined }} data-pane="explorer" data-fe-theme={theme}>
422
619
  <div className={styles.header}>
@@ -429,7 +626,15 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
429
626
  <button type="button" className={styles.action} title={t('tab.expand')} onClick={expandPreview}>{'>'}</button>
430
627
  </span>
431
628
  </div>
432
- <div className={styles.treeArea}>
629
+ <div
630
+ className={styles.treeArea}
631
+ onContextMenu={(e) => {
632
+ e.preventDefault()
633
+ const target = root ?? cwd
634
+ if (target === undefined) return
635
+ setMenu({ kind: 'root', path: target, x: e.clientX, y: e.clientY })
636
+ }}
637
+ >
433
638
  {rootLoading && <div className={styles.rowHint}>{t('preview.loading')}</div>}
434
639
  {rootError !== undefined && <div className={styles.rowError}>{rootError}</div>}
435
640
  {!rootLoading && rootError === undefined && root !== undefined && children[root] !== undefined && children[root].length === 0 && (
@@ -437,6 +642,29 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
437
642
  )}
438
643
  {root !== undefined && children[root] !== undefined && renderEntries(children[root], 0)}
439
644
  </div>
645
+ {menu !== undefined && (
646
+ <div
647
+ ref={menuRef}
648
+ className={styles.contextMenu}
649
+ role="menu"
650
+ style={menuPos !== undefined ? { left: menuPos.x, top: menuPos.y } : { left: menu.x, top: menu.y, visibility: 'hidden' }}
651
+ >
652
+ {menuItems(menu).map((item, index) =>
653
+ item === 'divider' ? (
654
+ <div key={index} className={styles.contextMenuDivider} />
655
+ ) : (
656
+ <div
657
+ key={index}
658
+ role="menuitem"
659
+ className={`${styles.contextMenuItem}${item.danger ? ` ${styles.contextMenuItemDanger}` : ''}`}
660
+ onClick={() => runAction(item.onClick)}
661
+ >
662
+ {item.label}
663
+ </div>
664
+ ),
665
+ )}
666
+ </div>
667
+ )}
440
668
  </div>
441
669
  )
442
670
  }