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.
@@ -3,11 +3,13 @@
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 { DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, 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 { cancelCut, clearClipboard, closeFilesUnder, copyToClipboard, expandPreview, openFile, popUndo, pushUndo, retargetFile, setCwd, toggleTheme, useClipboard, useTabsState } from './store'
12
+ import type { ClipboardItem, ClipboardMode, UndoEntry } from './store'
11
13
 
12
14
  export interface FsListEntry {
13
15
  name: string
@@ -29,6 +31,11 @@ export interface FsReadResult {
29
31
  truncated: boolean
30
32
  }
31
33
 
34
+ /** Result of a context-menu mutation (create/rename/delete). */
35
+ export interface FsMutationResult {
36
+ path: string
37
+ }
38
+
32
39
  interface SessionSummary {
33
40
  id: string
34
41
  cwd?: string
@@ -45,6 +52,12 @@ export interface FileExplorerProps {
45
52
  t: (key: FilesKey, params?: Record<string, unknown>) => string
46
53
  listDir: (path: string, signal?: AbortSignal) => Promise<FsListResult>
47
54
  openPath: (path: string) => Promise<void>
55
+ revealInExplorer: (path: string, kind: 'file' | 'dir', signal?: AbortSignal) => Promise<FsMutationResult>
56
+ createFile: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
57
+ createDir: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
58
+ renameFile: (path: string, to: string, signal?: AbortSignal) => Promise<FsMutationResult>
59
+ removePath: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
60
+ copyPath: (from: string, to: string, overwrite: boolean, signal?: AbortSignal) => Promise<FsMutationResult>
48
61
  }
49
62
 
50
63
  function basenameOf(path: string): string {
@@ -53,6 +66,40 @@ function basenameOf(path: string): string {
53
66
  return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
54
67
  }
55
68
 
69
+ /** Parent directory of a path ('' for a bare drive root — never used for such). */
70
+ function parentOf(path: string): string {
71
+ const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
72
+ return idx > 0 ? path.slice(0, idx) : path
73
+ }
74
+
75
+ /** Append a child name to a directory path, honoring its separator style. */
76
+ function joinPath(dir: string, name: string): string {
77
+ const sep = dir.includes('\\') ? '\\' : '/'
78
+ return dir.endsWith('\\') || dir.endsWith('/') ? dir + name : dir + sep + name
79
+ }
80
+
81
+ /** "a.txt" → "a - Copy.txt", "folder" → "folder - Copy" (OS explorer duplicate naming). */
82
+ function copyName(name: string): string {
83
+ const idx = name.lastIndexOf('.')
84
+ if (idx <= 0) return `${name} - Copy`
85
+ return `${name.slice(0, idx)} - Copy${name.slice(idx)}`
86
+ }
87
+
88
+ /**
89
+ * Hidden folder next to a deleted item that holds it until undo restores it
90
+ * (delete = rename into here, undo = rename back — no bytes are copied).
91
+ */
92
+ const TRASH_NAME = '.dsh-trash'
93
+
94
+ /** Number of separators in a path — used to delete children before parents. */
95
+ function sepCount(path: string): number {
96
+ let n = 0
97
+ for (let i = 0; i < path.length; i += 1) {
98
+ if (path[i] === '/' || path[i] === '\\') n += 1
99
+ }
100
+ return n
101
+ }
102
+
56
103
  function formatSize(bytes: number): string {
57
104
  if (!Number.isFinite(bytes) || bytes < 0) return ''
58
105
  if (bytes < 1024) return `${bytes} B`
@@ -85,12 +132,21 @@ interface TreeRowProps {
85
132
  entry: FsListEntry
86
133
  depth: number
87
134
  isActive: boolean
135
+ isSelected: boolean
136
+ isCut: boolean
88
137
  isExpanded: boolean
138
+ isDropTarget: boolean
89
139
  onToggleDir: (path: string) => void
90
140
  onRefreshDir: (path: string) => void
91
- onSelect: (entry: FsListEntry) => void
141
+ onSelect: (entry: FsListEntry, e: ReactMouseEvent) => void
92
142
  onDoubleClick: (entry: FsListEntry) => void
93
143
  onOpenExternal: (path: string) => Promise<void>
144
+ onContextMenu: (e: ReactMouseEvent, entry: FsListEntry) => void
145
+ onDragStart: (e: ReactDragEvent, entry: FsListEntry) => void
146
+ onDragEnd: () => void
147
+ onDragOverRow: (e: ReactDragEvent, entry: FsListEntry) => void
148
+ onDragLeaveRow: () => void
149
+ onDropRow: (e: ReactDragEvent, entry: FsListEntry) => void
94
150
  t: (key: FilesKey, params?: Record<string, unknown>) => string
95
151
  }
96
152
 
@@ -104,23 +160,39 @@ const TreeRow = memo(function TreeRow({
104
160
  entry,
105
161
  depth,
106
162
  isActive,
163
+ isSelected,
164
+ isCut,
107
165
  isExpanded,
166
+ isDropTarget,
108
167
  onToggleDir,
109
168
  onRefreshDir,
110
169
  onSelect,
111
170
  onDoubleClick,
112
171
  onOpenExternal,
172
+ onContextMenu,
173
+ onDragStart,
174
+ onDragEnd,
175
+ onDragOverRow,
176
+ onDragLeaveRow,
177
+ onDropRow,
113
178
  t,
114
179
  }: TreeRowProps) {
115
180
  const isDir = entry.kind === 'dir'
116
181
  return (
117
182
  <div
118
- className={`${styles.row} ${isActive ? styles.rowSelected : ''}`}
183
+ className={`${styles.row} ${isSelected || isActive ? styles.rowSelected : ''}${isCut ? ` ${styles.rowCut}` : ''}${isDropTarget ? ` ${styles.rowDropTarget}` : ''}`}
119
184
  style={{ paddingLeft: 8 + depth * 14 }}
120
- onClick={() => onSelect(entry)}
185
+ draggable
186
+ onClick={(e) => onSelect(entry, e)}
121
187
  onDoubleClick={() => onDoubleClick(entry)}
188
+ onContextMenu={(e) => onContextMenu(e, entry)}
189
+ onDragStart={(e) => onDragStart(e, entry)}
190
+ onDragEnd={onDragEnd}
191
+ onDragOver={(e) => onDragOverRow(e, entry)}
192
+ onDragLeave={onDragLeaveRow}
193
+ onDrop={(e) => onDropRow(e, entry)}
122
194
  role="treeitem"
123
- aria-selected={isActive}
195
+ aria-selected={isSelected || isActive}
124
196
  aria-expanded={isDir ? isExpanded : undefined}
125
197
  title={entry.name}
126
198
  >
@@ -170,11 +242,23 @@ const TreeRow = memo(function TreeRow({
170
242
  )
171
243
  })
172
244
 
173
- export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileExplorerProps) {
245
+ export function FileExplorer({
246
+ width,
247
+ useSessions,
248
+ t,
249
+ listDir,
250
+ openPath,
251
+ revealInExplorer,
252
+ createFile,
253
+ createDir,
254
+ renameFile,
255
+ removePath,
256
+ copyPath,
257
+ }: FileExplorerProps) {
174
258
  const sessionList = useSessions((s) => s)
175
259
  const currentId = sessionList.current
176
260
  const cwd = currentId !== undefined ? sessionList.byId[currentId]?.cwd : undefined
177
- const { active: activePath, theme } = useTabsState()
261
+ const { active: activePath, theme, undo: undoEntries } = useTabsState()
178
262
 
179
263
  const rootAbortRef = useRef<AbortController | null>(null)
180
264
 
@@ -190,6 +274,67 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
190
274
  const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set())
191
275
  const [dirErrors, setDirErrors] = useState<Record<string, string>>({})
192
276
 
277
+ // Explorer-like selection (path → kind). A plain click replaces it,
278
+ // Ctrl/Cmd+click toggles membership; copy/cut/paste act on it.
279
+ const [selected, setSelected] = useState<Record<string, FsListEntry['kind']>>({})
280
+ const clipboard = useClipboard()
281
+ const treeAreaRef = useRef<HTMLDivElement>(null)
282
+
283
+ // ---- drag & drop (move into a folder) ----
284
+ const dragPathsRef = useRef<string[] | null>(null)
285
+ const [dropTarget, setDropTarget] = useState<string | null>(null)
286
+
287
+ // ---- context menu (rows only: right-clicking blank space shows no menu) ----
288
+ interface MenuState {
289
+ kind: 'file' | 'dir'
290
+ path: string
291
+ x: number
292
+ y: number
293
+ }
294
+ const [menu, setMenu] = useState<MenuState | undefined>(undefined)
295
+ const [menuPos, setMenuPos] = useState<{ x: number; y: number } | undefined>(undefined)
296
+ const menuRef = useRef<HTMLDivElement>(null)
297
+
298
+ // Measure and clamp the menu into the viewport before the browser paints
299
+ // (layout effects run pre-paint, so the raw position never shows).
300
+ useLayoutEffect(() => {
301
+ if (menu === undefined) {
302
+ setMenuPos(undefined)
303
+ return
304
+ }
305
+ const el = menuRef.current
306
+ if (el === null) return
307
+ const rect = el.getBoundingClientRect()
308
+ setMenuPos({
309
+ x: Math.max(4, Math.min(menu.x, window.innerWidth - rect.width - 4)),
310
+ y: Math.max(4, Math.min(menu.y, window.innerHeight - rect.height - 4)),
311
+ })
312
+ }, [menu])
313
+
314
+ // Close the menu on outside click / Escape / window blur.
315
+ useEffect(() => {
316
+ if (menu === undefined) return undefined
317
+ const onKeyDown = (e: KeyboardEvent) => {
318
+ if (e.key === 'Escape') setMenu(undefined)
319
+ }
320
+ const onMouseDown = (e: MouseEvent) => {
321
+ const el = menuRef.current
322
+ if (el !== null && e.target instanceof Node && el.contains(e.target)) return
323
+ setMenu(undefined)
324
+ }
325
+ const onBlur = () => setMenu(undefined)
326
+ document.addEventListener('keydown', onKeyDown)
327
+ // mousedown (not click): closing before a menu item's click still lets the
328
+ // click dispatch on the item, and closes when clicking anywhere else.
329
+ document.addEventListener('mousedown', onMouseDown)
330
+ window.addEventListener('blur', onBlur)
331
+ return () => {
332
+ document.removeEventListener('keydown', onKeyDown)
333
+ document.removeEventListener('mousedown', onMouseDown)
334
+ window.removeEventListener('blur', onBlur)
335
+ }
336
+ }, [menu])
337
+
193
338
  // Latest tree snapshot for the polling tick (avoids stale closures).
194
339
  const treeRef = useRef({ root, children, expanded, rootLoading })
195
340
  treeRef.current = { root, children, expanded, rootLoading }
@@ -217,6 +362,7 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
217
362
  setChildren({})
218
363
  setLoadingDirs(new Set())
219
364
  setDirErrors({})
365
+ setSelected({})
220
366
  setCwd(cwd)
221
367
 
222
368
  if (cwd === undefined) {
@@ -358,7 +504,386 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
358
504
  void loadDir(dirPath)
359
505
  }, [loadDir])
360
506
 
361
- const onRowClick = useCallback((entry: FsListEntry) => {
507
+ // ---- context-menu actions ----
508
+
509
+ const openContextMenu = useCallback((e: ReactMouseEvent, entry: FsListEntry) => {
510
+ e.preventDefault()
511
+ e.stopPropagation()
512
+ // Explorer behavior: right-clicking an unselected item selects it; a
513
+ // right-click on an already-selected item keeps the multi-selection.
514
+ setSelected((prev) => (prev[entry.path] !== undefined ? prev : { [entry.path]: entry.kind }))
515
+ setMenu({ kind: entry.kind === 'dir' ? 'dir' : 'file', path: entry.path, x: e.clientX, y: e.clientY })
516
+ }, [])
517
+
518
+ const runAction = useCallback((action: () => void) => {
519
+ setMenu(undefined)
520
+ action()
521
+ }, [])
522
+
523
+ // One mutation pipeline shared by New File / New Folder / Rename / Delete:
524
+ // run the op, refresh the touched directory, and surface failures inline in
525
+ // the tree (under the directory row, or at the column top for the root).
526
+ const applyMutation = useCallback(async (op: () => Promise<unknown>, refreshPath: string) => {
527
+ try {
528
+ await op()
529
+ refreshDir(refreshPath)
530
+ } catch (error) {
531
+ const message = error instanceof Error ? error.message : String(error)
532
+ if (refreshPath === root) setRootError(message)
533
+ else setDirErrors((prev) => ({ ...prev, [refreshPath]: message }))
534
+ }
535
+ }, [refreshDir, root])
536
+
537
+ /**
538
+ * Record one undoable operation for the current workspace. When the stack
539
+ * overflows, the oldest entry comes back evicted — if it was a delete, its
540
+ * trash item can never be restored from here again, so purge it (best
541
+ * effort; it may already be gone).
542
+ */
543
+ const recordUndo = useCallback((entry: UndoEntry) => {
544
+ const evicted = pushUndo(entry)
545
+ if (evicted !== undefined && evicted.kind === 'delete') {
546
+ void removePath(evicted.trash).catch(() => {
547
+ // already gone — nothing to purge
548
+ })
549
+ }
550
+ }, [removePath])
551
+
552
+ /**
553
+ * Reversible delete: rename the item into a hidden `.dsh-trash` folder next
554
+ * to it (instant — no bytes copied). Undo renames it back. The trash folder
555
+ * is created on demand and hidden from the tree.
556
+ */
557
+ const trashPath = useCallback(async (path: string): Promise<{ trash: string; parent: string }> => {
558
+ const parent = parentOf(path)
559
+ const trashDir = joinPath(parent, TRASH_NAME)
560
+ // Best-effort: the folder already exists after the first delete.
561
+ try {
562
+ await createDir(trashDir)
563
+ } catch {
564
+ // exists — fine
565
+ }
566
+ // Practically collision-free unique name; rename refuses if it ever collides.
567
+ const unique = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
568
+ const trash = joinPath(trashDir, `${unique}-${basenameOf(path)}`)
569
+ await renameFile(path, trash)
570
+ return { trash, parent }
571
+ }, [createDir, renameFile])
572
+
573
+ /**
574
+ * Apply clipboard/drag items into a target directory. `cut` moves (copy +
575
+ * remove source), `copy` duplicates. Every successful item records an undo
576
+ * entry — copy → remove the copy, move → rename it back. Overwriting copies
577
+ * are NOT recorded (the pre-existing content is gone for good). Moving an
578
+ * item into the folder it already lives in is a no-op; copying into the same
579
+ * folder duplicates it with a " - Copy" suffix, like the OS explorer.
580
+ */
581
+ const applyItems = useCallback(async (items: ClipboardItem[], targetDir: string, mode: ClipboardMode) => {
582
+ const failures: string[] = []
583
+ const refreshed = new Set<string>()
584
+ for (const item of items) {
585
+ let dest = joinPath(targetDir, item.name)
586
+ const sameDest = dest.toLowerCase() === item.path.toLowerCase()
587
+ if (mode === 'cut' && sameDest) continue
588
+ if (sameDest) dest = joinPath(targetDir, copyName(item.name))
589
+ let overwritten = false
590
+ try {
591
+ try {
592
+ await copyPath(item.path, dest, false)
593
+ } catch (error) {
594
+ if ((error as { code?: string }).code !== 'exists') throw error
595
+ if (!window.confirm(t('confirm.overwrite', { name: item.name }))) continue
596
+ overwritten = true
597
+ await copyPath(item.path, dest, true)
598
+ }
599
+ if (mode === 'cut') {
600
+ await removePath(item.path)
601
+ // Keep any open tab pointed at the moved file.
602
+ retargetFile(item.path, dest)
603
+ refreshed.add(parentOf(item.path))
604
+ recordUndo({ kind: 'move', label: t('undo.move', { name: item.name }), from: item.path, to: dest, parent: targetDir })
605
+ } else if (!overwritten) {
606
+ recordUndo({ kind: 'copy', label: t('undo.copy', { name: item.name }), from: item.path, to: dest, parent: targetDir })
607
+ }
608
+ refreshed.add(targetDir)
609
+ } catch (error) {
610
+ failures.push(error instanceof Error ? error.message : String(error))
611
+ }
612
+ }
613
+ for (const p of refreshed) refreshDir(p)
614
+ return failures
615
+ }, [copyPath, recordUndo, refreshDir, removePath, retargetFile, t])
616
+
617
+ /** Undo the current workspace's most recent operation (Ctrl+Z / header button). */
618
+ const performUndo = useCallback(async () => {
619
+ const entry = popUndo()
620
+ if (entry === undefined) return
621
+ const refreshed = new Set<string>()
622
+ try {
623
+ switch (entry.kind) {
624
+ case 'copy':
625
+ await removePath(entry.to)
626
+ closeFilesUnder(entry.to)
627
+ refreshed.add(entry.parent)
628
+ break
629
+ case 'move':
630
+ // Rename back: the destination currently holds the moved item.
631
+ await renameFile(entry.to, entry.from)
632
+ retargetFile(entry.to, entry.from)
633
+ refreshed.add(entry.parent)
634
+ refreshed.add(parentOf(entry.from))
635
+ break
636
+ case 'rename':
637
+ await renameFile(entry.to, entry.from)
638
+ retargetFile(entry.to, entry.from)
639
+ refreshed.add(entry.parent)
640
+ break
641
+ case 'create':
642
+ await trashPath(entry.path)
643
+ closeFilesUnder(entry.path)
644
+ refreshed.add(entry.parent)
645
+ break
646
+ case 'delete':
647
+ await renameFile(entry.trash, entry.path)
648
+ refreshed.add(entry.parent)
649
+ break
650
+ }
651
+ } catch (error) {
652
+ // Put the entry back so the user can retry after fixing the cause, and
653
+ // surface the reason inline — EXCEPT a delete whose trash item no longer
654
+ // exists (purged by an overflow, or the .dsh-trash folder removed on
655
+ // disk): nothing left to restore, so drop it instead of a stuck retry.
656
+ const message = error instanceof Error ? error.message : String(error)
657
+ const gone = message.includes('does not exist') || message.includes('not found')
658
+ if (!(entry.kind === 'delete' && gone)) recordUndo(entry)
659
+ if (entry.parent === root) setRootError(message)
660
+ else setDirErrors((prev) => ({ ...prev, [entry.parent]: message }))
661
+ return
662
+ }
663
+ for (const p of refreshed) refreshDir(p)
664
+ }, [closeFilesUnder, popUndo, recordUndo, refreshDir, removePath, renameFile, retargetFile, root, trashPath])
665
+
666
+ /** Delete every selected item (moves each into .dsh-trash; all undoable). */
667
+ const deleteSelection = useCallback(async () => {
668
+ const entries = Object.entries(selected).map(([path, kind]) => ({ path, kind }))
669
+ if (entries.length === 0) return
670
+ // Children first so deleting a folder alongside its contents doesn't hit
671
+ // already-gone paths; anything under an already-trashed folder is skipped.
672
+ entries.sort((a, b) => sepCount(b.path) - sepCount(a.path))
673
+ const confirmMessage = entries.length === 1
674
+ ? (entries[0].kind === 'dir'
675
+ ? t('confirm.deleteDir', { name: basenameOf(entries[0].path) })
676
+ : t('confirm.deleteFile', { name: basenameOf(entries[0].path) }))
677
+ : t('confirm.deleteSelected', {
678
+ count: entries.length,
679
+ names: entries.slice(0, 3).map((e) => basenameOf(e.path)).join('、'),
680
+ })
681
+ if (!window.confirm(confirmMessage)) return
682
+ const failures: string[] = []
683
+ const refreshed = new Set<string>()
684
+ const trashedPrefixes: string[] = []
685
+ for (const { path, kind } of entries) {
686
+ const sep = path.includes('\\') ? '\\' : '/'
687
+ const prefix = path.endsWith('\\') || path.endsWith('/') ? path : path + sep
688
+ if (trashedPrefixes.some((d) => path.toLowerCase().startsWith(d.toLowerCase()))) continue
689
+ try {
690
+ const { trash, parent } = await trashPath(path)
691
+ if (kind === 'dir') trashedPrefixes.push(prefix)
692
+ closeFilesUnder(path)
693
+ recordUndo({ kind: 'delete', label: t('undo.delete', { name: basenameOf(path) }), path, trash, parent })
694
+ refreshed.add(parent)
695
+ } catch (error) {
696
+ failures.push(error instanceof Error ? error.message : String(error))
697
+ }
698
+ }
699
+ for (const p of refreshed) refreshDir(p)
700
+ setSelected({})
701
+ if (failures.length > 0) setRootError(failures.join('; '))
702
+ }, [closeFilesUnder, recordUndo, refreshDir, root, selected, t, trashPath])
703
+
704
+ const onNewFile = useCallback((dirPath: string) => {
705
+ const name = window.prompt(`${t('prompt.newFileName')}:`, '')
706
+ if (name === null) return
707
+ const trimmed = name.trim()
708
+ if (trimmed === '') return
709
+ const path = joinPath(dirPath, trimmed)
710
+ void applyMutation(async () => {
711
+ await createFile(path)
712
+ recordUndo({ kind: 'create', label: t('undo.create', { name: trimmed }), path, parent: dirPath })
713
+ }, dirPath)
714
+ }, [applyMutation, createFile, recordUndo, t])
715
+
716
+ const onNewFolder = useCallback((dirPath: string) => {
717
+ const name = window.prompt(`${t('prompt.newFolderName')}:`, '')
718
+ if (name === null) return
719
+ const trimmed = name.trim()
720
+ if (trimmed === '') return
721
+ const path = joinPath(dirPath, trimmed)
722
+ void applyMutation(async () => {
723
+ await createDir(path)
724
+ recordUndo({ kind: 'create', label: t('undo.create', { name: trimmed }), path, parent: dirPath })
725
+ }, dirPath)
726
+ }, [applyMutation, createDir, recordUndo, t])
727
+
728
+ const onRename = useCallback((path: string) => {
729
+ const current = basenameOf(path)
730
+ const name = window.prompt(`${t('prompt.renameTo')}:`, current)
731
+ if (name === null) return
732
+ const trimmed = name.trim()
733
+ if (trimmed === '' || trimmed === current) return
734
+ const parent = parentOf(path)
735
+ const to = joinPath(parent, trimmed)
736
+ void applyMutation(async () => {
737
+ await renameFile(path, to)
738
+ // Keep any open tab pointing at the moved file.
739
+ retargetFile(path, to)
740
+ recordUndo({ kind: 'rename', label: t('undo.rename', { name: current }), from: path, to, parent })
741
+ }, parent)
742
+ }, [applyMutation, recordUndo, renameFile, retargetFile, t])
743
+
744
+ const onDelete = useCallback((path: string, kind: 'file' | 'dir') => {
745
+ const name = basenameOf(path)
746
+ const message = kind === 'dir' ? t('confirm.deleteDir', { name }) : t('confirm.deleteFile', { name })
747
+ if (!window.confirm(message)) return
748
+ void (async () => {
749
+ try {
750
+ const { trash, parent } = await trashPath(path)
751
+ // Drop tabs for the deleted file (or everything under a deleted folder).
752
+ closeFilesUnder(path)
753
+ recordUndo({ kind: 'delete', label: t('undo.delete', { name }), path, trash, parent })
754
+ refreshDir(parent)
755
+ } catch (error) {
756
+ setRootError(error instanceof Error ? error.message : String(error))
757
+ }
758
+ })()
759
+ }, [closeFilesUnder, recordUndo, refreshDir, t, trashPath])
760
+
761
+ const onCopyPath = useCallback((path: string) => {
762
+ void navigator.clipboard.writeText(path).catch(() => {
763
+ // clipboard unavailable (permissions) — nothing else to do
764
+ })
765
+ }, [])
766
+
767
+ /** Reveal in the OS file manager; failures surface as an alert, never silently. */
768
+ const onReveal = useCallback((path: string, kind: 'file' | 'dir') => {
769
+ void revealInExplorer(path, kind).catch((error: unknown) => {
770
+ const message = error instanceof Error ? error.message : String(error)
771
+ window.alert(`${t('error.reveal')}:${message}`)
772
+ })
773
+ }, [revealInExplorer, t])
774
+
775
+ const selectedItems = useCallback((): ClipboardItem[] => (
776
+ Object.entries(selected).map(([path, kind]) => ({ path, name: basenameOf(path), kind }))
777
+ ), [selected])
778
+
779
+ const onCopySelection = useCallback((mode: ClipboardMode) => {
780
+ const items = selectedItems()
781
+ if (items.length === 0) return
782
+ copyToClipboard(items, mode)
783
+ }, [selectedItems])
784
+
785
+ /** Paste the clipboard into the current folder (or the one selected dir). */
786
+ const onPaste = useCallback(async () => {
787
+ const { items, mode } = clipboard
788
+ if (items.length === 0 || cwd === undefined) return
789
+ const selEntries = Object.entries(selected)
790
+ const target = selEntries.length === 1 && selEntries[0][1] === 'dir' ? selEntries[0][0] : cwd
791
+ const failures = await applyItems(items, target, mode)
792
+ // Explorer semantics: a cut clears the clipboard once the move lands; a
793
+ // plain copy stays armed so the user can paste into more folders.
794
+ if (mode === 'cut') clearClipboard()
795
+ setSelected({})
796
+ if (failures.length > 0) {
797
+ const message = failures.join('; ')
798
+ if (target === root) setRootError(message)
799
+ else setDirErrors((prev) => ({ ...prev, [target]: message }))
800
+ }
801
+ }, [applyItems, clipboard, clearClipboard, cwd, root, selected])
802
+
803
+ // ---- drag & drop (drag selected items onto a folder to move them) ----
804
+
805
+ const onRowDragStart = useCallback((e: ReactDragEvent, entry: FsListEntry) => {
806
+ // Dragging one member of a multi-selection moves the whole selection,
807
+ // exactly like the OS explorer.
808
+ const paths = selected[entry.path] !== undefined ? Object.keys(selected) : [entry.path]
809
+ dragPathsRef.current = paths
810
+ try {
811
+ e.dataTransfer.setData('text/plain', entry.path)
812
+ } catch {
813
+ // dataTransfer may be unavailable — the ref still carries the paths
814
+ }
815
+ e.dataTransfer.effectAllowed = 'move'
816
+ }, [selected])
817
+
818
+ const onRowDragEnd = useCallback(() => {
819
+ dragPathsRef.current = null
820
+ setDropTarget(null)
821
+ }, [])
822
+
823
+ const onRowDragOver = useCallback((e: ReactDragEvent, entry: FsListEntry) => {
824
+ if (dragPathsRef.current === null) return
825
+ e.stopPropagation()
826
+ if (entry.kind !== 'dir') return
827
+ e.preventDefault()
828
+ e.dataTransfer.dropEffect = 'move'
829
+ setDropTarget(entry.path)
830
+ }, [])
831
+
832
+ const onRowDragLeave = useCallback(() => setDropTarget(null), [])
833
+
834
+ /** Move the dragged paths into `targetDir` (a folder row or the tree area). */
835
+ const onDropMove = useCallback((e: ReactDragEvent, targetDir: string) => {
836
+ e.preventDefault()
837
+ e.stopPropagation()
838
+ setDropTarget(null)
839
+ const paths = dragPathsRef.current
840
+ dragPathsRef.current = null
841
+ if (paths === null || paths.length === 0) return
842
+ // Dropping a folder onto itself or into its own subtree is a no-op.
843
+ const items: ClipboardItem[] = paths.filter((path) => {
844
+ if (path.toLowerCase() === targetDir.toLowerCase()) return false
845
+ const sep = path.includes('\\') ? '\\' : '/'
846
+ const prefix = path.endsWith('\\') || path.endsWith('/') ? path : path + sep
847
+ return !targetDir.toLowerCase().startsWith(prefix.toLowerCase())
848
+ }).map((path) => ({
849
+ path,
850
+ name: basenameOf(path),
851
+ kind: selected[path] ?? 'file',
852
+ }))
853
+ if (items.length === 0) return
854
+ setSelected({})
855
+ void applyItems(items, targetDir, 'cut').then((failures) => {
856
+ if (failures.length > 0) {
857
+ const message = failures.join('; ')
858
+ if (targetDir === root) setRootError(message)
859
+ else setDirErrors((prev) => ({ ...prev, [targetDir]: message }))
860
+ }
861
+ })
862
+ }, [applyItems, root, selected])
863
+
864
+ const onRowDrop = useCallback((e: ReactDragEvent, entry: FsListEntry) => {
865
+ e.stopPropagation()
866
+ if (dragPathsRef.current === null || entry.kind !== 'dir') return
867
+ e.preventDefault()
868
+ onDropMove(e, entry.path)
869
+ }, [onDropMove])
870
+
871
+ const onRowClick = useCallback((entry: FsListEntry, e: ReactMouseEvent) => {
872
+ e.stopPropagation()
873
+ // Rows push keyboard focus onto the tree so Ctrl+C / Ctrl+V / Esc land
874
+ // here right after a click, instead of going to whatever had focus.
875
+ treeAreaRef.current?.focus({ preventScroll: true })
876
+ if (e.ctrlKey || e.metaKey) {
877
+ // Ctrl/Cmd+click toggles membership without opening / expanding.
878
+ setSelected((prev) => {
879
+ const next = { ...prev }
880
+ if (next[entry.path] !== undefined) delete next[entry.path]
881
+ else next[entry.path] = entry.kind
882
+ return next
883
+ })
884
+ return
885
+ }
886
+ setSelected({ [entry.path]: entry.kind })
362
887
  if (entry.kind === 'dir') {
363
888
  toggleDir(entry.path)
364
889
  return
@@ -371,6 +896,66 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
371
896
  void openPath(entry.path)
372
897
  }, [openPath])
373
898
 
899
+ // Explorer-style keyboard: Ctrl/Cmd+C/X copy-cut, Ctrl/Cmd+V paste,
900
+ // Ctrl/Cmd+A select-all, Escape clears the selection (and cancels a cut).
901
+ const onTreeKeyDown = useCallback((e: ReactKeyboardEvent) => {
902
+ const mod = e.ctrlKey || e.metaKey
903
+ const key = e.key.toLowerCase()
904
+ if (mod && key === 'c') {
905
+ const items = selectedItems()
906
+ if (items.length === 0) return
907
+ e.preventDefault()
908
+ copyToClipboard(items, 'copy')
909
+ return
910
+ }
911
+ if (mod && key === 'x') {
912
+ const items = selectedItems()
913
+ if (items.length === 0) return
914
+ e.preventDefault()
915
+ copyToClipboard(items, 'cut')
916
+ return
917
+ }
918
+ if (mod && key === 'v') {
919
+ if (clipboard.items.length === 0) return
920
+ e.preventDefault()
921
+ void onPaste()
922
+ return
923
+ }
924
+ if (mod && key === 'a') {
925
+ e.preventDefault()
926
+ const all: Record<string, FsListEntry['kind']> = {}
927
+ const collect = (entries: FsListEntry[]) => {
928
+ for (const entry of entries) {
929
+ if (entry.name === TRASH_NAME) continue
930
+ all[entry.path] = entry.kind
931
+ if (entry.kind === 'dir' && expanded.has(entry.path) && children[entry.path] !== undefined) {
932
+ collect(children[entry.path] as FsListEntry[])
933
+ }
934
+ }
935
+ }
936
+ const rootEntries = root !== undefined ? children[root] : undefined
937
+ if (rootEntries !== undefined) collect(rootEntries)
938
+ setSelected(all)
939
+ return
940
+ }
941
+ if (mod && key === 'z' && !e.shiftKey) {
942
+ if (undoEntries.length === 0) return
943
+ e.preventDefault()
944
+ void performUndo()
945
+ return
946
+ }
947
+ if (e.key === 'Delete') {
948
+ if (Object.keys(selected).length === 0) return
949
+ e.preventDefault()
950
+ void deleteSelection()
951
+ return
952
+ }
953
+ if (e.key === 'Escape') {
954
+ setSelected({})
955
+ cancelCut()
956
+ }
957
+ }, [cancelCut, children, clipboard.items.length, deleteSelection, expanded, onPaste, performUndo, root, selectedItems, undoEntries.length])
958
+
374
959
  // ---- placeholder: no session / no cwd ----
375
960
  if (cwd === undefined) {
376
961
  return (
@@ -382,7 +967,13 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
382
967
  )
383
968
  }
384
969
 
385
- const renderEntries = (entries: FsListEntry[], depth: number) => entries.map((entry) => {
970
+ // Cut items ghost in the tree until the paste moves them away.
971
+ const cutPaths = new Set(clipboard.mode === 'cut' ? clipboard.items.map((item) => item.path) : [])
972
+
973
+ /** Listings as the user sees them (the internal trash folder is hidden). */
974
+ const visibleEntries = (entries: FsListEntry[]) => entries.filter((entry) => entry.name !== TRASH_NAME)
975
+
976
+ const renderEntries = (entries: FsListEntry[], depth: number) => visibleEntries(entries).map((entry) => {
386
977
  const isDir = entry.kind === 'dir'
387
978
  const isExpanded = isDir && expanded.has(entry.path)
388
979
  const isLoading = isDir && loadingDirs.has(entry.path)
@@ -395,19 +986,28 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
395
986
  entry={entry}
396
987
  depth={depth}
397
988
  isActive={entry.kind === 'file' && activePath === entry.path}
989
+ isSelected={selected[entry.path] !== undefined}
990
+ isCut={cutPaths.has(entry.path)}
398
991
  isExpanded={isExpanded}
992
+ isDropTarget={dropTarget === entry.path}
399
993
  onToggleDir={toggleDir}
400
994
  onRefreshDir={refreshDir}
401
995
  onSelect={onRowClick}
402
996
  onDoubleClick={onRowDoubleClick}
403
997
  onOpenExternal={openPath}
998
+ onContextMenu={openContextMenu}
999
+ onDragStart={onRowDragStart}
1000
+ onDragEnd={onRowDragEnd}
1001
+ onDragOverRow={onRowDragOver}
1002
+ onDragLeaveRow={onRowDragLeave}
1003
+ onDropRow={onRowDrop}
404
1004
  t={t}
405
1005
  />
406
1006
  {isDir && isExpanded && (
407
1007
  <div>
408
1008
  {isLoading && <div className={styles.rowHint} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{t('preview.loading')}</div>}
409
1009
  {error !== undefined && !isLoading && <div className={styles.rowError} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{error}</div>}
410
- {childEntries !== undefined && childEntries.length === 0 && !isLoading && (
1010
+ {childEntries !== undefined && visibleEntries(childEntries).length === 0 && !isLoading && (
411
1011
  <div className={styles.rowHint} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{t('preview.emptyDir')}</div>
412
1012
  )}
413
1013
  {childEntries !== undefined && renderEntries(childEntries, depth + 1)}
@@ -417,11 +1017,76 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
417
1017
  )
418
1018
  })
419
1019
 
1020
+ interface MenuItem {
1021
+ label: string
1022
+ danger?: boolean
1023
+ disabled?: boolean
1024
+ onClick: () => void
1025
+ }
1026
+
1027
+ const menuItems = (m: MenuState): Array<MenuItem | 'divider'> => {
1028
+ // Right-clicking one member of a multi-selection operates on the whole
1029
+ // selection (Explorer behavior): show a batch delete instead of the
1030
+ // single-item one.
1031
+ const selectionCount = Object.keys(selected).length
1032
+ const deleteItem: MenuItem = selectionCount > 1
1033
+ ? { label: t('menu.deleteSelected', { count: selectionCount }), danger: true, onClick: () => void deleteSelection() }
1034
+ : { label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, m.kind) }
1035
+ if (m.kind === 'file') {
1036
+ return [
1037
+ { label: t('menu.open'), onClick: () => openFile(m.path) },
1038
+ 'divider',
1039
+ { label: t('menu.copy'), onClick: () => onCopySelection('copy') },
1040
+ { label: t('menu.cut'), onClick: () => onCopySelection('cut') },
1041
+ 'divider',
1042
+ { label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
1043
+ { label: t('menu.revealInExplorer'), onClick: () => onReveal(m.path, 'file') },
1044
+ { label: t('menu.openSystem'), onClick: () => void openPath(m.path) },
1045
+ 'divider',
1046
+ { label: t('menu.rename'), onClick: () => onRename(m.path) },
1047
+ deleteItem,
1048
+ ]
1049
+ }
1050
+ if (m.kind === 'dir') {
1051
+ return [
1052
+ { label: t('menu.newFile'), onClick: () => onNewFile(m.path) },
1053
+ { label: t('menu.newFolder'), onClick: () => onNewFolder(m.path) },
1054
+ 'divider',
1055
+ { label: t('menu.copy'), onClick: () => onCopySelection('copy') },
1056
+ { label: t('menu.cut'), onClick: () => onCopySelection('cut') },
1057
+ { label: t('menu.paste'), disabled: clipboard.items.length === 0, onClick: () => void onPaste() },
1058
+ 'divider',
1059
+ { label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
1060
+ { label: t('menu.revealInExplorer'), onClick: () => onReveal(m.path, 'dir') },
1061
+ { label: t('menu.openSystem'), onClick: () => void openPath(m.path) },
1062
+ { label: t('menu.refresh'), onClick: () => refreshDir(m.path) },
1063
+ 'divider',
1064
+ { label: t('menu.rename'), onClick: () => onRename(m.path) },
1065
+ deleteItem,
1066
+ ]
1067
+ }
1068
+ // Empty tree area: right-clicking blank space intentionally shows no
1069
+ // custom menu (only file/folder rows do); this branch is unreachable but
1070
+ // kept for the type. Paste happens via Ctrl+V into the current folder.
1071
+ return [
1072
+ { label: t('menu.paste'), disabled: clipboard.items.length === 0, onClick: () => void onPaste() },
1073
+ ]
1074
+ }
1075
+
420
1076
  return (
421
1077
  <div className={styles.column} style={{ width: width > 0 ? width : undefined }} data-pane="explorer" data-fe-theme={theme}>
422
1078
  <div className={styles.header}>
423
1079
  <span className={styles.headerTitle} title={root ?? cwd}>{basenameOf(root ?? cwd)}</span>
424
1080
  <span className={styles.headerActions}>
1081
+ <button
1082
+ type="button"
1083
+ className={styles.action}
1084
+ title={undoEntries.length > 0 ? `${t('action.undo')}:${undoEntries[undoEntries.length - 1].label}` : t('action.undo')}
1085
+ disabled={undoEntries.length === 0}
1086
+ onClick={() => void performUndo()}
1087
+ >
1088
+
1089
+ </button>
425
1090
  <button type="button" className={styles.action} title={t('action.theme')} onClick={toggleTheme}>
426
1091
  {theme === 'dark' ? '☀' : '🌙'}
427
1092
  </button>
@@ -429,14 +1094,76 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
429
1094
  <button type="button" className={styles.action} title={t('tab.expand')} onClick={expandPreview}>{'>'}</button>
430
1095
  </span>
431
1096
  </div>
432
- <div className={styles.treeArea}>
1097
+ {clipboard.items.length > 0 && (
1098
+ <div className={styles.clipboardBar} role="status">
1099
+ <span className={styles.clipboardText}>
1100
+ {clipboard.mode === 'cut'
1101
+ ? t('clipboard.cut', { count: clipboard.items.length })
1102
+ : t('clipboard.copied', { count: clipboard.items.length })}
1103
+ <span className={styles.clipboardHint}>{t('clipboard.pasteHint')}</span>
1104
+ </span>
1105
+ <button
1106
+ type="button"
1107
+ className={styles.action}
1108
+ title={t('clipboard.clear')}
1109
+ onClick={() => {
1110
+ clearClipboard()
1111
+ if (clipboard.mode === 'cut') setSelected({})
1112
+ }}
1113
+ >
1114
+
1115
+ </button>
1116
+ </div>
1117
+ )}
1118
+ <div
1119
+ ref={treeAreaRef}
1120
+ tabIndex={0}
1121
+ className={styles.treeArea}
1122
+ onKeyDown={onTreeKeyDown}
1123
+ onClick={(e) => {
1124
+ // Clicking blank space deselects (Explorer behavior).
1125
+ if (e.target === e.currentTarget) setSelected({})
1126
+ }}
1127
+ onDragOver={(e) => {
1128
+ // Blank tree area also accepts a drop: move into the current folder.
1129
+ if (dragPathsRef.current === null) return
1130
+ e.preventDefault()
1131
+ e.dataTransfer.dropEffect = 'move'
1132
+ }}
1133
+ onDrop={(e) => {
1134
+ if (dragPathsRef.current !== null) onDropMove(e, cwd)
1135
+ }}
1136
+ >
433
1137
  {rootLoading && <div className={styles.rowHint}>{t('preview.loading')}</div>}
434
1138
  {rootError !== undefined && <div className={styles.rowError}>{rootError}</div>}
435
- {!rootLoading && rootError === undefined && root !== undefined && children[root] !== undefined && children[root].length === 0 && (
1139
+ {!rootLoading && rootError === undefined && root !== undefined && children[root] !== undefined && visibleEntries(children[root]).length === 0 && (
436
1140
  <div className={styles.rowHint}>{t('preview.emptyDir')}</div>
437
1141
  )}
438
1142
  {root !== undefined && children[root] !== undefined && renderEntries(children[root], 0)}
439
1143
  </div>
1144
+ {menu !== undefined && (
1145
+ <div
1146
+ ref={menuRef}
1147
+ className={styles.contextMenu}
1148
+ role="menu"
1149
+ style={menuPos !== undefined ? { left: menuPos.x, top: menuPos.y } : { left: menu.x, top: menu.y, visibility: 'hidden' }}
1150
+ >
1151
+ {menuItems(menu).map((item, index) =>
1152
+ item === 'divider' ? (
1153
+ <div key={index} className={styles.contextMenuDivider} />
1154
+ ) : (
1155
+ <div
1156
+ key={index}
1157
+ role="menuitem"
1158
+ className={`${styles.contextMenuItem}${item.danger ? ` ${styles.contextMenuItemDanger}` : ''}${item.disabled ? ` ${styles.contextMenuItemDisabled}` : ''}`}
1159
+ onClick={() => runAction(item.onClick)}
1160
+ >
1161
+ {item.label}
1162
+ </div>
1163
+ ),
1164
+ )}
1165
+ </div>
1166
+ )}
440
1167
  </div>
441
1168
  )
442
1169
  }