dsh-plugin-workbench 0.0.5 → 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.
@@ -4,11 +4,12 @@
4
4
  * selection store so the `explorer.preview` slot can render the split view.
5
5
  */
6
6
  import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
7
- import type { MouseEvent as ReactMouseEvent } from 'react'
7
+ import type { DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'
8
8
  import styles from './files.module.css'
9
9
  import { FileIcon } from './fileIcons'
10
10
  import type { FilesKey } from './locales'
11
- import { closeFilesUnder, expandPreview, openFile, retargetFile, 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'
12
13
 
13
14
  export interface FsListEntry {
14
15
  name: string
@@ -51,10 +52,12 @@ export interface FileExplorerProps {
51
52
  t: (key: FilesKey, params?: Record<string, unknown>) => string
52
53
  listDir: (path: string, signal?: AbortSignal) => Promise<FsListResult>
53
54
  openPath: (path: string) => Promise<void>
55
+ revealInExplorer: (path: string, kind: 'file' | 'dir', signal?: AbortSignal) => Promise<FsMutationResult>
54
56
  createFile: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
55
57
  createDir: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
56
58
  renameFile: (path: string, to: string, signal?: AbortSignal) => Promise<FsMutationResult>
57
59
  removePath: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
60
+ copyPath: (from: string, to: string, overwrite: boolean, signal?: AbortSignal) => Promise<FsMutationResult>
58
61
  }
59
62
 
60
63
  function basenameOf(path: string): string {
@@ -75,6 +78,28 @@ function joinPath(dir: string, name: string): string {
75
78
  return dir.endsWith('\\') || dir.endsWith('/') ? dir + name : dir + sep + name
76
79
  }
77
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
+
78
103
  function formatSize(bytes: number): string {
79
104
  if (!Number.isFinite(bytes) || bytes < 0) return ''
80
105
  if (bytes < 1024) return `${bytes} B`
@@ -107,13 +132,21 @@ interface TreeRowProps {
107
132
  entry: FsListEntry
108
133
  depth: number
109
134
  isActive: boolean
135
+ isSelected: boolean
136
+ isCut: boolean
110
137
  isExpanded: boolean
138
+ isDropTarget: boolean
111
139
  onToggleDir: (path: string) => void
112
140
  onRefreshDir: (path: string) => void
113
- onSelect: (entry: FsListEntry) => void
141
+ onSelect: (entry: FsListEntry, e: ReactMouseEvent) => void
114
142
  onDoubleClick: (entry: FsListEntry) => void
115
143
  onOpenExternal: (path: string) => Promise<void>
116
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
117
150
  t: (key: FilesKey, params?: Record<string, unknown>) => string
118
151
  }
119
152
 
@@ -127,25 +160,39 @@ const TreeRow = memo(function TreeRow({
127
160
  entry,
128
161
  depth,
129
162
  isActive,
163
+ isSelected,
164
+ isCut,
130
165
  isExpanded,
166
+ isDropTarget,
131
167
  onToggleDir,
132
168
  onRefreshDir,
133
169
  onSelect,
134
170
  onDoubleClick,
135
171
  onOpenExternal,
136
172
  onContextMenu,
173
+ onDragStart,
174
+ onDragEnd,
175
+ onDragOverRow,
176
+ onDragLeaveRow,
177
+ onDropRow,
137
178
  t,
138
179
  }: TreeRowProps) {
139
180
  const isDir = entry.kind === 'dir'
140
181
  return (
141
182
  <div
142
- className={`${styles.row} ${isActive ? styles.rowSelected : ''}`}
183
+ className={`${styles.row} ${isSelected || isActive ? styles.rowSelected : ''}${isCut ? ` ${styles.rowCut}` : ''}${isDropTarget ? ` ${styles.rowDropTarget}` : ''}`}
143
184
  style={{ paddingLeft: 8 + depth * 14 }}
144
- onClick={() => onSelect(entry)}
185
+ draggable
186
+ onClick={(e) => onSelect(entry, e)}
145
187
  onDoubleClick={() => onDoubleClick(entry)}
146
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)}
147
194
  role="treeitem"
148
- aria-selected={isActive}
195
+ aria-selected={isSelected || isActive}
149
196
  aria-expanded={isDir ? isExpanded : undefined}
150
197
  title={entry.name}
151
198
  >
@@ -201,15 +248,17 @@ export function FileExplorer({
201
248
  t,
202
249
  listDir,
203
250
  openPath,
251
+ revealInExplorer,
204
252
  createFile,
205
253
  createDir,
206
254
  renameFile,
207
255
  removePath,
256
+ copyPath,
208
257
  }: FileExplorerProps) {
209
258
  const sessionList = useSessions((s) => s)
210
259
  const currentId = sessionList.current
211
260
  const cwd = currentId !== undefined ? sessionList.byId[currentId]?.cwd : undefined
212
- const { active: activePath, theme } = useTabsState()
261
+ const { active: activePath, theme, undo: undoEntries } = useTabsState()
213
262
 
214
263
  const rootAbortRef = useRef<AbortController | null>(null)
215
264
 
@@ -225,9 +274,19 @@ export function FileExplorer({
225
274
  const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set())
226
275
  const [dirErrors, setDirErrors] = useState<Record<string, string>>({})
227
276
 
228
- // ---- context menu ----
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) ----
229
288
  interface MenuState {
230
- kind: 'file' | 'dir' | 'root'
289
+ kind: 'file' | 'dir'
231
290
  path: string
232
291
  x: number
233
292
  y: number
@@ -303,6 +362,7 @@ export function FileExplorer({
303
362
  setChildren({})
304
363
  setLoadingDirs(new Set())
305
364
  setDirErrors({})
365
+ setSelected({})
306
366
  setCwd(cwd)
307
367
 
308
368
  if (cwd === undefined) {
@@ -449,6 +509,9 @@ export function FileExplorer({
449
509
  const openContextMenu = useCallback((e: ReactMouseEvent, entry: FsListEntry) => {
450
510
  e.preventDefault()
451
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 }))
452
515
  setMenu({ kind: entry.kind === 'dir' ? 'dir' : 'file', path: entry.path, x: e.clientX, y: e.clientY })
453
516
  }, [])
454
517
 
@@ -471,21 +534,196 @@ export function FileExplorer({
471
534
  }
472
535
  }, [refreshDir, root])
473
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
+
474
704
  const onNewFile = useCallback((dirPath: string) => {
475
705
  const name = window.prompt(`${t('prompt.newFileName')}:`, '')
476
706
  if (name === null) return
477
707
  const trimmed = name.trim()
478
708
  if (trimmed === '') return
479
- void applyMutation(() => createFile(joinPath(dirPath, trimmed)), dirPath)
480
- }, [applyMutation, createFile, t])
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])
481
715
 
482
716
  const onNewFolder = useCallback((dirPath: string) => {
483
717
  const name = window.prompt(`${t('prompt.newFolderName')}:`, '')
484
718
  if (name === null) return
485
719
  const trimmed = name.trim()
486
720
  if (trimmed === '') return
487
- void applyMutation(() => createDir(joinPath(dirPath, trimmed)), dirPath)
488
- }, [applyMutation, createDir, t])
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])
489
727
 
490
728
  const onRename = useCallback((path: string) => {
491
729
  const current = basenameOf(path)
@@ -499,19 +737,26 @@ export function FileExplorer({
499
737
  await renameFile(path, to)
500
738
  // Keep any open tab pointing at the moved file.
501
739
  retargetFile(path, to)
740
+ recordUndo({ kind: 'rename', label: t('undo.rename', { name: current }), from: path, to, parent })
502
741
  }, parent)
503
- }, [applyMutation, renameFile, t])
742
+ }, [applyMutation, recordUndo, renameFile, retargetFile, t])
504
743
 
505
744
  const onDelete = useCallback((path: string, kind: 'file' | 'dir') => {
506
745
  const name = basenameOf(path)
507
746
  const message = kind === 'dir' ? t('confirm.deleteDir', { name }) : t('confirm.deleteFile', { name })
508
747
  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])
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])
515
760
 
516
761
  const onCopyPath = useCallback((path: string) => {
517
762
  void navigator.clipboard.writeText(path).catch(() => {
@@ -519,7 +764,126 @@ export function FileExplorer({
519
764
  })
520
765
  }, [])
521
766
 
522
- const onRowClick = useCallback((entry: FsListEntry) => {
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 })
523
887
  if (entry.kind === 'dir') {
524
888
  toggleDir(entry.path)
525
889
  return
@@ -532,6 +896,66 @@ export function FileExplorer({
532
896
  void openPath(entry.path)
533
897
  }, [openPath])
534
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
+
535
959
  // ---- placeholder: no session / no cwd ----
536
960
  if (cwd === undefined) {
537
961
  return (
@@ -543,7 +967,13 @@ export function FileExplorer({
543
967
  )
544
968
  }
545
969
 
546
- 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) => {
547
977
  const isDir = entry.kind === 'dir'
548
978
  const isExpanded = isDir && expanded.has(entry.path)
549
979
  const isLoading = isDir && loadingDirs.has(entry.path)
@@ -556,20 +986,28 @@ export function FileExplorer({
556
986
  entry={entry}
557
987
  depth={depth}
558
988
  isActive={entry.kind === 'file' && activePath === entry.path}
989
+ isSelected={selected[entry.path] !== undefined}
990
+ isCut={cutPaths.has(entry.path)}
559
991
  isExpanded={isExpanded}
992
+ isDropTarget={dropTarget === entry.path}
560
993
  onToggleDir={toggleDir}
561
994
  onRefreshDir={refreshDir}
562
995
  onSelect={onRowClick}
563
996
  onDoubleClick={onRowDoubleClick}
564
997
  onOpenExternal={openPath}
565
998
  onContextMenu={openContextMenu}
999
+ onDragStart={onRowDragStart}
1000
+ onDragEnd={onRowDragEnd}
1001
+ onDragOverRow={onRowDragOver}
1002
+ onDragLeaveRow={onRowDragLeave}
1003
+ onDropRow={onRowDrop}
566
1004
  t={t}
567
1005
  />
568
1006
  {isDir && isExpanded && (
569
1007
  <div>
570
1008
  {isLoading && <div className={styles.rowHint} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{t('preview.loading')}</div>}
571
1009
  {error !== undefined && !isLoading && <div className={styles.rowError} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{error}</div>}
572
- {childEntries !== undefined && childEntries.length === 0 && !isLoading && (
1010
+ {childEntries !== undefined && visibleEntries(childEntries).length === 0 && !isLoading && (
573
1011
  <div className={styles.rowHint} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{t('preview.emptyDir')}</div>
574
1012
  )}
575
1013
  {childEntries !== undefined && renderEntries(childEntries, depth + 1)}
@@ -579,16 +1017,34 @@ export function FileExplorer({
579
1017
  )
580
1018
  })
581
1019
 
582
- const menuItems = (m: MenuState): Array<{ label: string; danger?: boolean; onClick: () => void } | 'divider'> => {
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) }
583
1035
  if (m.kind === 'file') {
584
1036
  return [
585
1037
  { label: t('menu.open'), onClick: () => openFile(m.path) },
586
1038
  'divider',
1039
+ { label: t('menu.copy'), onClick: () => onCopySelection('copy') },
1040
+ { label: t('menu.cut'), onClick: () => onCopySelection('cut') },
1041
+ 'divider',
587
1042
  { label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
1043
+ { label: t('menu.revealInExplorer'), onClick: () => onReveal(m.path, 'file') },
588
1044
  { label: t('menu.openSystem'), onClick: () => void openPath(m.path) },
589
1045
  'divider',
590
1046
  { label: t('menu.rename'), onClick: () => onRename(m.path) },
591
- { label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, 'file') },
1047
+ deleteItem,
592
1048
  ]
593
1049
  }
594
1050
  if (m.kind === 'dir') {
@@ -596,21 +1052,24 @@ export function FileExplorer({
596
1052
  { label: t('menu.newFile'), onClick: () => onNewFile(m.path) },
597
1053
  { label: t('menu.newFolder'), onClick: () => onNewFolder(m.path) },
598
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',
599
1059
  { label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
1060
+ { label: t('menu.revealInExplorer'), onClick: () => onReveal(m.path, 'dir') },
600
1061
  { label: t('menu.openSystem'), onClick: () => void openPath(m.path) },
601
1062
  { label: t('menu.refresh'), onClick: () => refreshDir(m.path) },
602
1063
  'divider',
603
1064
  { label: t('menu.rename'), onClick: () => onRename(m.path) },
604
- { label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, 'dir') },
1065
+ deleteItem,
605
1066
  ]
606
1067
  }
607
- // Empty tree area (the workspace root).
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.
608
1071
  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) },
1072
+ { label: t('menu.paste'), disabled: clipboard.items.length === 0, onClick: () => void onPaste() },
614
1073
  ]
615
1074
  }
616
1075
 
@@ -619,6 +1078,15 @@ export function FileExplorer({
619
1078
  <div className={styles.header}>
620
1079
  <span className={styles.headerTitle} title={root ?? cwd}>{basenameOf(root ?? cwd)}</span>
621
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>
622
1090
  <button type="button" className={styles.action} title={t('action.theme')} onClick={toggleTheme}>
623
1091
  {theme === 'dark' ? '☀' : '🌙'}
624
1092
  </button>
@@ -626,18 +1094,49 @@ export function FileExplorer({
626
1094
  <button type="button" className={styles.action} title={t('tab.expand')} onClick={expandPreview}>{'>'}</button>
627
1095
  </span>
628
1096
  </div>
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
+ )}
629
1118
  <div
1119
+ ref={treeAreaRef}
1120
+ tabIndex={0}
630
1121
  className={styles.treeArea}
631
- onContextMenu={(e) => {
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
632
1130
  e.preventDefault()
633
- const target = root ?? cwd
634
- if (target === undefined) return
635
- setMenu({ kind: 'root', path: target, x: e.clientX, y: e.clientY })
1131
+ e.dataTransfer.dropEffect = 'move'
1132
+ }}
1133
+ onDrop={(e) => {
1134
+ if (dragPathsRef.current !== null) onDropMove(e, cwd)
636
1135
  }}
637
1136
  >
638
1137
  {rootLoading && <div className={styles.rowHint}>{t('preview.loading')}</div>}
639
1138
  {rootError !== undefined && <div className={styles.rowError}>{rootError}</div>}
640
- {!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 && (
641
1140
  <div className={styles.rowHint}>{t('preview.emptyDir')}</div>
642
1141
  )}
643
1142
  {root !== undefined && children[root] !== undefined && renderEntries(children[root], 0)}
@@ -656,7 +1155,7 @@ export function FileExplorer({
656
1155
  <div
657
1156
  key={index}
658
1157
  role="menuitem"
659
- className={`${styles.contextMenuItem}${item.danger ? ` ${styles.contextMenuItemDanger}` : ''}`}
1158
+ className={`${styles.contextMenuItem}${item.danger ? ` ${styles.contextMenuItemDanger}` : ''}${item.disabled ? ` ${styles.contextMenuItemDisabled}` : ''}`}
660
1159
  onClick={() => runAction(item.onClick)}
661
1160
  >
662
1161
  {item.label}