dsh-plugin-workbench 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -33,6 +34,8 @@ export interface FsReadResult {
33
34
  /** Result of a context-menu mutation (create/rename/delete). */
34
35
  export interface FsMutationResult {
35
36
  path: string
37
+ /** Copy-only: `true` when the destination existed and the copy was skipped (client may ask about overwrite). */
38
+ exists?: boolean
36
39
  }
37
40
 
38
41
  interface SessionSummary {
@@ -51,10 +54,12 @@ export interface FileExplorerProps {
51
54
  t: (key: FilesKey, params?: Record<string, unknown>) => string
52
55
  listDir: (path: string, signal?: AbortSignal) => Promise<FsListResult>
53
56
  openPath: (path: string) => Promise<void>
57
+ revealInExplorer: (path: string, kind: 'file' | 'dir', signal?: AbortSignal) => Promise<FsMutationResult>
54
58
  createFile: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
55
59
  createDir: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
56
60
  renameFile: (path: string, to: string, signal?: AbortSignal) => Promise<FsMutationResult>
57
61
  removePath: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
62
+ copyPath: (from: string, to: string, overwrite: boolean, signal?: AbortSignal) => Promise<FsMutationResult>
58
63
  }
59
64
 
60
65
  function basenameOf(path: string): string {
@@ -75,6 +80,28 @@ function joinPath(dir: string, name: string): string {
75
80
  return dir.endsWith('\\') || dir.endsWith('/') ? dir + name : dir + sep + name
76
81
  }
77
82
 
83
+ /** "a.txt" → "a - Copy.txt", "folder" → "folder - Copy" (OS explorer duplicate naming). */
84
+ function copyName(name: string): string {
85
+ const idx = name.lastIndexOf('.')
86
+ if (idx <= 0) return `${name} - Copy`
87
+ return `${name.slice(0, idx)} - Copy${name.slice(idx)}`
88
+ }
89
+
90
+ /**
91
+ * Hidden folder next to a deleted item that holds it until undo restores it
92
+ * (delete = rename into here, undo = rename back — no bytes are copied).
93
+ */
94
+ const TRASH_NAME = '.dsh-trash'
95
+
96
+ /** Number of separators in a path — used to delete children before parents. */
97
+ function sepCount(path: string): number {
98
+ let n = 0
99
+ for (let i = 0; i < path.length; i += 1) {
100
+ if (path[i] === '/' || path[i] === '\\') n += 1
101
+ }
102
+ return n
103
+ }
104
+
78
105
  function formatSize(bytes: number): string {
79
106
  if (!Number.isFinite(bytes) || bytes < 0) return ''
80
107
  if (bytes < 1024) return `${bytes} B`
@@ -107,13 +134,21 @@ interface TreeRowProps {
107
134
  entry: FsListEntry
108
135
  depth: number
109
136
  isActive: boolean
137
+ isSelected: boolean
138
+ isCut: boolean
110
139
  isExpanded: boolean
140
+ isDropTarget: boolean
111
141
  onToggleDir: (path: string) => void
112
142
  onRefreshDir: (path: string) => void
113
- onSelect: (entry: FsListEntry) => void
143
+ onSelect: (entry: FsListEntry, e: ReactMouseEvent) => void
114
144
  onDoubleClick: (entry: FsListEntry) => void
115
145
  onOpenExternal: (path: string) => Promise<void>
116
146
  onContextMenu: (e: ReactMouseEvent, entry: FsListEntry) => void
147
+ onDragStart: (e: ReactDragEvent, entry: FsListEntry) => void
148
+ onDragEnd: () => void
149
+ onDragOverRow: (e: ReactDragEvent, entry: FsListEntry) => void
150
+ onDragLeaveRow: () => void
151
+ onDropRow: (e: ReactDragEvent, entry: FsListEntry) => void
117
152
  t: (key: FilesKey, params?: Record<string, unknown>) => string
118
153
  }
119
154
 
@@ -127,25 +162,39 @@ const TreeRow = memo(function TreeRow({
127
162
  entry,
128
163
  depth,
129
164
  isActive,
165
+ isSelected,
166
+ isCut,
130
167
  isExpanded,
168
+ isDropTarget,
131
169
  onToggleDir,
132
170
  onRefreshDir,
133
171
  onSelect,
134
172
  onDoubleClick,
135
173
  onOpenExternal,
136
174
  onContextMenu,
175
+ onDragStart,
176
+ onDragEnd,
177
+ onDragOverRow,
178
+ onDragLeaveRow,
179
+ onDropRow,
137
180
  t,
138
181
  }: TreeRowProps) {
139
182
  const isDir = entry.kind === 'dir'
140
183
  return (
141
184
  <div
142
- className={`${styles.row} ${isActive ? styles.rowSelected : ''}`}
185
+ className={`${styles.row} ${isSelected || isActive ? styles.rowSelected : ''}${isCut ? ` ${styles.rowCut}` : ''}${isDropTarget ? ` ${styles.rowDropTarget}` : ''}`}
143
186
  style={{ paddingLeft: 8 + depth * 14 }}
144
- onClick={() => onSelect(entry)}
187
+ draggable
188
+ onClick={(e) => onSelect(entry, e)}
145
189
  onDoubleClick={() => onDoubleClick(entry)}
146
190
  onContextMenu={(e) => onContextMenu(e, entry)}
191
+ onDragStart={(e) => onDragStart(e, entry)}
192
+ onDragEnd={onDragEnd}
193
+ onDragOver={(e) => onDragOverRow(e, entry)}
194
+ onDragLeave={onDragLeaveRow}
195
+ onDrop={(e) => onDropRow(e, entry)}
147
196
  role="treeitem"
148
- aria-selected={isActive}
197
+ aria-selected={isSelected || isActive}
149
198
  aria-expanded={isDir ? isExpanded : undefined}
150
199
  title={entry.name}
151
200
  >
@@ -201,15 +250,17 @@ export function FileExplorer({
201
250
  t,
202
251
  listDir,
203
252
  openPath,
253
+ revealInExplorer,
204
254
  createFile,
205
255
  createDir,
206
256
  renameFile,
207
257
  removePath,
258
+ copyPath,
208
259
  }: FileExplorerProps) {
209
260
  const sessionList = useSessions((s) => s)
210
261
  const currentId = sessionList.current
211
262
  const cwd = currentId !== undefined ? sessionList.byId[currentId]?.cwd : undefined
212
- const { active: activePath, theme } = useTabsState()
263
+ const { active: activePath, theme, undo: undoEntries } = useTabsState()
213
264
 
214
265
  const rootAbortRef = useRef<AbortController | null>(null)
215
266
 
@@ -225,9 +276,19 @@ export function FileExplorer({
225
276
  const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set())
226
277
  const [dirErrors, setDirErrors] = useState<Record<string, string>>({})
227
278
 
228
- // ---- context menu ----
279
+ // Explorer-like selection (path → kind). A plain click replaces it,
280
+ // Ctrl/Cmd+click toggles membership; copy/cut/paste act on it.
281
+ const [selected, setSelected] = useState<Record<string, FsListEntry['kind']>>({})
282
+ const clipboard = useClipboard()
283
+ const treeAreaRef = useRef<HTMLDivElement>(null)
284
+
285
+ // ---- drag & drop (move into a folder) ----
286
+ const dragPathsRef = useRef<string[] | null>(null)
287
+ const [dropTarget, setDropTarget] = useState<string | null>(null)
288
+
289
+ // ---- context menu (rows only: right-clicking blank space shows no menu) ----
229
290
  interface MenuState {
230
- kind: 'file' | 'dir' | 'root'
291
+ kind: 'file' | 'dir'
231
292
  path: string
232
293
  x: number
233
294
  y: number
@@ -303,6 +364,7 @@ export function FileExplorer({
303
364
  setChildren({})
304
365
  setLoadingDirs(new Set())
305
366
  setDirErrors({})
367
+ setSelected({})
306
368
  setCwd(cwd)
307
369
 
308
370
  if (cwd === undefined) {
@@ -449,6 +511,9 @@ export function FileExplorer({
449
511
  const openContextMenu = useCallback((e: ReactMouseEvent, entry: FsListEntry) => {
450
512
  e.preventDefault()
451
513
  e.stopPropagation()
514
+ // Explorer behavior: right-clicking an unselected item selects it; a
515
+ // right-click on an already-selected item keeps the multi-selection.
516
+ setSelected((prev) => (prev[entry.path] !== undefined ? prev : { [entry.path]: entry.kind }))
452
517
  setMenu({ kind: entry.kind === 'dir' ? 'dir' : 'file', path: entry.path, x: e.clientX, y: e.clientY })
453
518
  }, [])
454
519
 
@@ -471,21 +536,195 @@ export function FileExplorer({
471
536
  }
472
537
  }, [refreshDir, root])
473
538
 
539
+ /**
540
+ * Record one undoable operation for the current workspace. When the stack
541
+ * overflows, the oldest entry comes back evicted — if it was a delete, its
542
+ * trash item can never be restored from here again, so purge it (best
543
+ * effort; it may already be gone).
544
+ */
545
+ const recordUndo = useCallback((entry: UndoEntry) => {
546
+ const evicted = pushUndo(entry)
547
+ if (evicted !== undefined && evicted.kind === 'delete') {
548
+ void removePath(evicted.trash).catch(() => {
549
+ // already gone — nothing to purge
550
+ })
551
+ }
552
+ }, [removePath])
553
+
554
+ /**
555
+ * Reversible delete: rename the item into a hidden `.dsh-trash` folder next
556
+ * to it (instant — no bytes copied). Undo renames it back. The trash folder
557
+ * is created on demand and hidden from the tree.
558
+ */
559
+ const trashPath = useCallback(async (path: string): Promise<{ trash: string; parent: string }> => {
560
+ const parent = parentOf(path)
561
+ const trashDir = joinPath(parent, TRASH_NAME)
562
+ // Best-effort: the folder already exists after the first delete.
563
+ try {
564
+ await createDir(trashDir)
565
+ } catch {
566
+ // exists — fine
567
+ }
568
+ // Practically collision-free unique name; rename refuses if it ever collides.
569
+ const unique = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
570
+ const trash = joinPath(trashDir, `${unique}-${basenameOf(path)}`)
571
+ await renameFile(path, trash)
572
+ return { trash, parent }
573
+ }, [createDir, renameFile])
574
+
575
+ /**
576
+ * Apply clipboard/drag items into a target directory. `cut` moves (copy +
577
+ * remove source), `copy` duplicates. Every successful item records an undo
578
+ * entry — copy → remove the copy, move → rename it back. Overwriting copies
579
+ * are NOT recorded (the pre-existing content is gone for good). Moving an
580
+ * item into the folder it already lives in is a no-op; copying into the same
581
+ * folder duplicates it with a " - Copy" suffix, like the OS explorer.
582
+ */
583
+ const applyItems = useCallback(async (items: ClipboardItem[], targetDir: string, mode: ClipboardMode) => {
584
+ const failures: string[] = []
585
+ const refreshed = new Set<string>()
586
+ for (const item of items) {
587
+ let dest = joinPath(targetDir, item.name)
588
+ const sameDest = dest.toLowerCase() === item.path.toLowerCase()
589
+ if (mode === 'cut' && sameDest) continue
590
+ if (sameDest) dest = joinPath(targetDir, copyName(item.name))
591
+ let overwritten = false
592
+ try {
593
+ const copied = await copyPath(item.path, dest, false)
594
+ if (copied.exists === true) {
595
+ // Collision: ask before overwriting, like the OS file manager.
596
+ if (!window.confirm(t('confirm.overwrite', { name: item.name }))) continue
597
+ overwritten = true
598
+ await copyPath(item.path, dest, true)
599
+ }
600
+ if (mode === 'cut') {
601
+ await removePath(item.path)
602
+ // Keep any open tab pointed at the moved file.
603
+ retargetFile(item.path, dest)
604
+ refreshed.add(parentOf(item.path))
605
+ recordUndo({ kind: 'move', label: t('undo.move', { name: item.name }), from: item.path, to: dest, parent: targetDir })
606
+ } else if (!overwritten) {
607
+ recordUndo({ kind: 'copy', label: t('undo.copy', { name: item.name }), from: item.path, to: dest, parent: targetDir })
608
+ }
609
+ refreshed.add(targetDir)
610
+ } catch (error) {
611
+ failures.push(error instanceof Error ? error.message : String(error))
612
+ }
613
+ }
614
+ for (const p of refreshed) refreshDir(p)
615
+ return failures
616
+ }, [copyPath, recordUndo, refreshDir, removePath, retargetFile, t])
617
+
618
+ /** Undo the current workspace's most recent operation (Ctrl+Z / header button). */
619
+ const performUndo = useCallback(async () => {
620
+ const entry = popUndo()
621
+ if (entry === undefined) return
622
+ const refreshed = new Set<string>()
623
+ try {
624
+ switch (entry.kind) {
625
+ case 'copy':
626
+ await removePath(entry.to)
627
+ closeFilesUnder(entry.to)
628
+ refreshed.add(entry.parent)
629
+ break
630
+ case 'move':
631
+ // Rename back: the destination currently holds the moved item.
632
+ await renameFile(entry.to, entry.from)
633
+ retargetFile(entry.to, entry.from)
634
+ refreshed.add(entry.parent)
635
+ refreshed.add(parentOf(entry.from))
636
+ break
637
+ case 'rename':
638
+ await renameFile(entry.to, entry.from)
639
+ retargetFile(entry.to, entry.from)
640
+ refreshed.add(entry.parent)
641
+ break
642
+ case 'create':
643
+ await trashPath(entry.path)
644
+ closeFilesUnder(entry.path)
645
+ refreshed.add(entry.parent)
646
+ break
647
+ case 'delete':
648
+ await renameFile(entry.trash, entry.path)
649
+ refreshed.add(entry.parent)
650
+ break
651
+ }
652
+ } catch (error) {
653
+ // Put the entry back so the user can retry after fixing the cause, and
654
+ // surface the reason inline — EXCEPT a delete whose trash item no longer
655
+ // exists (purged by an overflow, or the .dsh-trash folder removed on
656
+ // disk): nothing left to restore, so drop it instead of a stuck retry.
657
+ const message = error instanceof Error ? error.message : String(error)
658
+ const gone = message.includes('does not exist') || message.includes('not found')
659
+ if (!(entry.kind === 'delete' && gone)) recordUndo(entry)
660
+ if (entry.parent === root) setRootError(message)
661
+ else setDirErrors((prev) => ({ ...prev, [entry.parent]: message }))
662
+ return
663
+ }
664
+ for (const p of refreshed) refreshDir(p)
665
+ }, [closeFilesUnder, popUndo, recordUndo, refreshDir, removePath, renameFile, retargetFile, root, trashPath])
666
+
667
+ /** Delete every selected item (moves each into .dsh-trash; all undoable). */
668
+ const deleteSelection = useCallback(async () => {
669
+ const entries = Object.entries(selected).map(([path, kind]) => ({ path, kind }))
670
+ if (entries.length === 0) return
671
+ // Children first so deleting a folder alongside its contents doesn't hit
672
+ // already-gone paths; anything under an already-trashed folder is skipped.
673
+ entries.sort((a, b) => sepCount(b.path) - sepCount(a.path))
674
+ const confirmMessage = entries.length === 1
675
+ ? (entries[0].kind === 'dir'
676
+ ? t('confirm.deleteDir', { name: basenameOf(entries[0].path) })
677
+ : t('confirm.deleteFile', { name: basenameOf(entries[0].path) }))
678
+ : t('confirm.deleteSelected', {
679
+ count: entries.length,
680
+ names: entries.slice(0, 3).map((e) => basenameOf(e.path)).join('、'),
681
+ })
682
+ if (!window.confirm(confirmMessage)) return
683
+ const failures: string[] = []
684
+ const refreshed = new Set<string>()
685
+ const trashedPrefixes: string[] = []
686
+ for (const { path, kind } of entries) {
687
+ const sep = path.includes('\\') ? '\\' : '/'
688
+ const prefix = path.endsWith('\\') || path.endsWith('/') ? path : path + sep
689
+ if (trashedPrefixes.some((d) => path.toLowerCase().startsWith(d.toLowerCase()))) continue
690
+ try {
691
+ const { trash, parent } = await trashPath(path)
692
+ if (kind === 'dir') trashedPrefixes.push(prefix)
693
+ closeFilesUnder(path)
694
+ recordUndo({ kind: 'delete', label: t('undo.delete', { name: basenameOf(path) }), path, trash, parent })
695
+ refreshed.add(parent)
696
+ } catch (error) {
697
+ failures.push(error instanceof Error ? error.message : String(error))
698
+ }
699
+ }
700
+ for (const p of refreshed) refreshDir(p)
701
+ setSelected({})
702
+ if (failures.length > 0) setRootError(failures.join('; '))
703
+ }, [closeFilesUnder, recordUndo, refreshDir, root, selected, t, trashPath])
704
+
474
705
  const onNewFile = useCallback((dirPath: string) => {
475
706
  const name = window.prompt(`${t('prompt.newFileName')}:`, '')
476
707
  if (name === null) return
477
708
  const trimmed = name.trim()
478
709
  if (trimmed === '') return
479
- void applyMutation(() => createFile(joinPath(dirPath, trimmed)), dirPath)
480
- }, [applyMutation, createFile, t])
710
+ const path = joinPath(dirPath, trimmed)
711
+ void applyMutation(async () => {
712
+ await createFile(path)
713
+ recordUndo({ kind: 'create', label: t('undo.create', { name: trimmed }), path, parent: dirPath })
714
+ }, dirPath)
715
+ }, [applyMutation, createFile, recordUndo, t])
481
716
 
482
717
  const onNewFolder = useCallback((dirPath: string) => {
483
718
  const name = window.prompt(`${t('prompt.newFolderName')}:`, '')
484
719
  if (name === null) return
485
720
  const trimmed = name.trim()
486
721
  if (trimmed === '') return
487
- void applyMutation(() => createDir(joinPath(dirPath, trimmed)), dirPath)
488
- }, [applyMutation, createDir, t])
722
+ const path = joinPath(dirPath, trimmed)
723
+ void applyMutation(async () => {
724
+ await createDir(path)
725
+ recordUndo({ kind: 'create', label: t('undo.create', { name: trimmed }), path, parent: dirPath })
726
+ }, dirPath)
727
+ }, [applyMutation, createDir, recordUndo, t])
489
728
 
490
729
  const onRename = useCallback((path: string) => {
491
730
  const current = basenameOf(path)
@@ -499,19 +738,26 @@ export function FileExplorer({
499
738
  await renameFile(path, to)
500
739
  // Keep any open tab pointing at the moved file.
501
740
  retargetFile(path, to)
741
+ recordUndo({ kind: 'rename', label: t('undo.rename', { name: current }), from: path, to, parent })
502
742
  }, parent)
503
- }, [applyMutation, renameFile, t])
743
+ }, [applyMutation, recordUndo, renameFile, retargetFile, t])
504
744
 
505
745
  const onDelete = useCallback((path: string, kind: 'file' | 'dir') => {
506
746
  const name = basenameOf(path)
507
747
  const message = kind === 'dir' ? t('confirm.deleteDir', { name }) : t('confirm.deleteFile', { name })
508
748
  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])
749
+ void (async () => {
750
+ try {
751
+ const { trash, parent } = await trashPath(path)
752
+ // Drop tabs for the deleted file (or everything under a deleted folder).
753
+ closeFilesUnder(path)
754
+ recordUndo({ kind: 'delete', label: t('undo.delete', { name }), path, trash, parent })
755
+ refreshDir(parent)
756
+ } catch (error) {
757
+ setRootError(error instanceof Error ? error.message : String(error))
758
+ }
759
+ })()
760
+ }, [closeFilesUnder, recordUndo, refreshDir, t, trashPath])
515
761
 
516
762
  const onCopyPath = useCallback((path: string) => {
517
763
  void navigator.clipboard.writeText(path).catch(() => {
@@ -519,7 +765,126 @@ export function FileExplorer({
519
765
  })
520
766
  }, [])
521
767
 
522
- const onRowClick = useCallback((entry: FsListEntry) => {
768
+ /** Reveal in the OS file manager; failures surface as an alert, never silently. */
769
+ const onReveal = useCallback((path: string, kind: 'file' | 'dir') => {
770
+ void revealInExplorer(path, kind).catch((error: unknown) => {
771
+ const message = error instanceof Error ? error.message : String(error)
772
+ window.alert(`${t('error.reveal')}:${message}`)
773
+ })
774
+ }, [revealInExplorer, t])
775
+
776
+ const selectedItems = useCallback((): ClipboardItem[] => (
777
+ Object.entries(selected).map(([path, kind]) => ({ path, name: basenameOf(path), kind }))
778
+ ), [selected])
779
+
780
+ const onCopySelection = useCallback((mode: ClipboardMode) => {
781
+ const items = selectedItems()
782
+ if (items.length === 0) return
783
+ copyToClipboard(items, mode)
784
+ }, [selectedItems])
785
+
786
+ /** Paste the clipboard into the current folder (or the one selected dir). */
787
+ const onPaste = useCallback(async () => {
788
+ const { items, mode } = clipboard
789
+ if (items.length === 0 || cwd === undefined) return
790
+ const selEntries = Object.entries(selected)
791
+ const target = selEntries.length === 1 && selEntries[0][1] === 'dir' ? selEntries[0][0] : cwd
792
+ const failures = await applyItems(items, target, mode)
793
+ // Explorer semantics: a cut clears the clipboard once the move lands; a
794
+ // plain copy stays armed so the user can paste into more folders.
795
+ if (mode === 'cut') clearClipboard()
796
+ setSelected({})
797
+ if (failures.length > 0) {
798
+ const message = failures.join('; ')
799
+ if (target === root) setRootError(message)
800
+ else setDirErrors((prev) => ({ ...prev, [target]: message }))
801
+ }
802
+ }, [applyItems, clipboard, clearClipboard, cwd, root, selected])
803
+
804
+ // ---- drag & drop (drag selected items onto a folder to move them) ----
805
+
806
+ const onRowDragStart = useCallback((e: ReactDragEvent, entry: FsListEntry) => {
807
+ // Dragging one member of a multi-selection moves the whole selection,
808
+ // exactly like the OS explorer.
809
+ const paths = selected[entry.path] !== undefined ? Object.keys(selected) : [entry.path]
810
+ dragPathsRef.current = paths
811
+ try {
812
+ e.dataTransfer.setData('text/plain', entry.path)
813
+ } catch {
814
+ // dataTransfer may be unavailable — the ref still carries the paths
815
+ }
816
+ e.dataTransfer.effectAllowed = 'move'
817
+ }, [selected])
818
+
819
+ const onRowDragEnd = useCallback(() => {
820
+ dragPathsRef.current = null
821
+ setDropTarget(null)
822
+ }, [])
823
+
824
+ const onRowDragOver = useCallback((e: ReactDragEvent, entry: FsListEntry) => {
825
+ if (dragPathsRef.current === null) return
826
+ e.stopPropagation()
827
+ if (entry.kind !== 'dir') return
828
+ e.preventDefault()
829
+ e.dataTransfer.dropEffect = 'move'
830
+ setDropTarget(entry.path)
831
+ }, [])
832
+
833
+ const onRowDragLeave = useCallback(() => setDropTarget(null), [])
834
+
835
+ /** Move the dragged paths into `targetDir` (a folder row or the tree area). */
836
+ const onDropMove = useCallback((e: ReactDragEvent, targetDir: string) => {
837
+ e.preventDefault()
838
+ e.stopPropagation()
839
+ setDropTarget(null)
840
+ const paths = dragPathsRef.current
841
+ dragPathsRef.current = null
842
+ if (paths === null || paths.length === 0) return
843
+ // Dropping a folder onto itself or into its own subtree is a no-op.
844
+ const items: ClipboardItem[] = paths.filter((path) => {
845
+ if (path.toLowerCase() === targetDir.toLowerCase()) return false
846
+ const sep = path.includes('\\') ? '\\' : '/'
847
+ const prefix = path.endsWith('\\') || path.endsWith('/') ? path : path + sep
848
+ return !targetDir.toLowerCase().startsWith(prefix.toLowerCase())
849
+ }).map((path) => ({
850
+ path,
851
+ name: basenameOf(path),
852
+ kind: selected[path] ?? 'file',
853
+ }))
854
+ if (items.length === 0) return
855
+ setSelected({})
856
+ void applyItems(items, targetDir, 'cut').then((failures) => {
857
+ if (failures.length > 0) {
858
+ const message = failures.join('; ')
859
+ if (targetDir === root) setRootError(message)
860
+ else setDirErrors((prev) => ({ ...prev, [targetDir]: message }))
861
+ }
862
+ })
863
+ }, [applyItems, root, selected])
864
+
865
+ const onRowDrop = useCallback((e: ReactDragEvent, entry: FsListEntry) => {
866
+ e.stopPropagation()
867
+ if (dragPathsRef.current === null || entry.kind !== 'dir') return
868
+ e.preventDefault()
869
+ onDropMove(e, entry.path)
870
+ }, [onDropMove])
871
+
872
+ const onRowClick = useCallback((entry: FsListEntry, e: ReactMouseEvent) => {
873
+ e.stopPropagation()
874
+ // Rows push keyboard focus onto the tree so Ctrl+C / Ctrl+V / Esc land
875
+ // here right after a click, instead of going to whatever had focus.
876
+ treeAreaRef.current?.focus({ preventScroll: true })
877
+ if (e.ctrlKey || e.metaKey) {
878
+ // Ctrl/Cmd+click toggles membership without opening / expanding.
879
+ setSelected((prev) => {
880
+ const next = { ...prev }
881
+ if (next[entry.path] !== undefined) delete next[entry.path]
882
+ else next[entry.path] = entry.kind
883
+ return next
884
+ })
885
+ return
886
+ }
887
+ setSelected({ [entry.path]: entry.kind })
523
888
  if (entry.kind === 'dir') {
524
889
  toggleDir(entry.path)
525
890
  return
@@ -532,6 +897,66 @@ export function FileExplorer({
532
897
  void openPath(entry.path)
533
898
  }, [openPath])
534
899
 
900
+ // Explorer-style keyboard: Ctrl/Cmd+C/X copy-cut, Ctrl/Cmd+V paste,
901
+ // Ctrl/Cmd+A select-all, Escape clears the selection (and cancels a cut).
902
+ const onTreeKeyDown = useCallback((e: ReactKeyboardEvent) => {
903
+ const mod = e.ctrlKey || e.metaKey
904
+ const key = e.key.toLowerCase()
905
+ if (mod && key === 'c') {
906
+ const items = selectedItems()
907
+ if (items.length === 0) return
908
+ e.preventDefault()
909
+ copyToClipboard(items, 'copy')
910
+ return
911
+ }
912
+ if (mod && key === 'x') {
913
+ const items = selectedItems()
914
+ if (items.length === 0) return
915
+ e.preventDefault()
916
+ copyToClipboard(items, 'cut')
917
+ return
918
+ }
919
+ if (mod && key === 'v') {
920
+ if (clipboard.items.length === 0) return
921
+ e.preventDefault()
922
+ void onPaste()
923
+ return
924
+ }
925
+ if (mod && key === 'a') {
926
+ e.preventDefault()
927
+ const all: Record<string, FsListEntry['kind']> = {}
928
+ const collect = (entries: FsListEntry[]) => {
929
+ for (const entry of entries) {
930
+ if (entry.name === TRASH_NAME) continue
931
+ all[entry.path] = entry.kind
932
+ if (entry.kind === 'dir' && expanded.has(entry.path) && children[entry.path] !== undefined) {
933
+ collect(children[entry.path] as FsListEntry[])
934
+ }
935
+ }
936
+ }
937
+ const rootEntries = root !== undefined ? children[root] : undefined
938
+ if (rootEntries !== undefined) collect(rootEntries)
939
+ setSelected(all)
940
+ return
941
+ }
942
+ if (mod && key === 'z' && !e.shiftKey) {
943
+ if (undoEntries.length === 0) return
944
+ e.preventDefault()
945
+ void performUndo()
946
+ return
947
+ }
948
+ if (e.key === 'Delete') {
949
+ if (Object.keys(selected).length === 0) return
950
+ e.preventDefault()
951
+ void deleteSelection()
952
+ return
953
+ }
954
+ if (e.key === 'Escape') {
955
+ setSelected({})
956
+ cancelCut()
957
+ }
958
+ }, [cancelCut, children, clipboard.items.length, deleteSelection, expanded, onPaste, performUndo, root, selectedItems, undoEntries.length])
959
+
535
960
  // ---- placeholder: no session / no cwd ----
536
961
  if (cwd === undefined) {
537
962
  return (
@@ -543,7 +968,13 @@ export function FileExplorer({
543
968
  )
544
969
  }
545
970
 
546
- const renderEntries = (entries: FsListEntry[], depth: number) => entries.map((entry) => {
971
+ // Cut items ghost in the tree until the paste moves them away.
972
+ const cutPaths = new Set(clipboard.mode === 'cut' ? clipboard.items.map((item) => item.path) : [])
973
+
974
+ /** Listings as the user sees them (the internal trash folder is hidden). */
975
+ const visibleEntries = (entries: FsListEntry[]) => entries.filter((entry) => entry.name !== TRASH_NAME)
976
+
977
+ const renderEntries = (entries: FsListEntry[], depth: number) => visibleEntries(entries).map((entry) => {
547
978
  const isDir = entry.kind === 'dir'
548
979
  const isExpanded = isDir && expanded.has(entry.path)
549
980
  const isLoading = isDir && loadingDirs.has(entry.path)
@@ -556,20 +987,28 @@ export function FileExplorer({
556
987
  entry={entry}
557
988
  depth={depth}
558
989
  isActive={entry.kind === 'file' && activePath === entry.path}
990
+ isSelected={selected[entry.path] !== undefined}
991
+ isCut={cutPaths.has(entry.path)}
559
992
  isExpanded={isExpanded}
993
+ isDropTarget={dropTarget === entry.path}
560
994
  onToggleDir={toggleDir}
561
995
  onRefreshDir={refreshDir}
562
996
  onSelect={onRowClick}
563
997
  onDoubleClick={onRowDoubleClick}
564
998
  onOpenExternal={openPath}
565
999
  onContextMenu={openContextMenu}
1000
+ onDragStart={onRowDragStart}
1001
+ onDragEnd={onRowDragEnd}
1002
+ onDragOverRow={onRowDragOver}
1003
+ onDragLeaveRow={onRowDragLeave}
1004
+ onDropRow={onRowDrop}
566
1005
  t={t}
567
1006
  />
568
1007
  {isDir && isExpanded && (
569
1008
  <div>
570
1009
  {isLoading && <div className={styles.rowHint} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{t('preview.loading')}</div>}
571
1010
  {error !== undefined && !isLoading && <div className={styles.rowError} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{error}</div>}
572
- {childEntries !== undefined && childEntries.length === 0 && !isLoading && (
1011
+ {childEntries !== undefined && visibleEntries(childEntries).length === 0 && !isLoading && (
573
1012
  <div className={styles.rowHint} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{t('preview.emptyDir')}</div>
574
1013
  )}
575
1014
  {childEntries !== undefined && renderEntries(childEntries, depth + 1)}
@@ -579,16 +1018,34 @@ export function FileExplorer({
579
1018
  )
580
1019
  })
581
1020
 
582
- const menuItems = (m: MenuState): Array<{ label: string; danger?: boolean; onClick: () => void } | 'divider'> => {
1021
+ interface MenuItem {
1022
+ label: string
1023
+ danger?: boolean
1024
+ disabled?: boolean
1025
+ onClick: () => void
1026
+ }
1027
+
1028
+ const menuItems = (m: MenuState): Array<MenuItem | 'divider'> => {
1029
+ // Right-clicking one member of a multi-selection operates on the whole
1030
+ // selection (Explorer behavior): show a batch delete instead of the
1031
+ // single-item one.
1032
+ const selectionCount = Object.keys(selected).length
1033
+ const deleteItem: MenuItem = selectionCount > 1
1034
+ ? { label: t('menu.deleteSelected', { count: selectionCount }), danger: true, onClick: () => void deleteSelection() }
1035
+ : { label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, m.kind) }
583
1036
  if (m.kind === 'file') {
584
1037
  return [
585
1038
  { label: t('menu.open'), onClick: () => openFile(m.path) },
586
1039
  'divider',
1040
+ { label: t('menu.copy'), onClick: () => onCopySelection('copy') },
1041
+ { label: t('menu.cut'), onClick: () => onCopySelection('cut') },
1042
+ 'divider',
587
1043
  { label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
1044
+ { label: t('menu.revealInExplorer'), onClick: () => onReveal(m.path, 'file') },
588
1045
  { label: t('menu.openSystem'), onClick: () => void openPath(m.path) },
589
1046
  'divider',
590
1047
  { label: t('menu.rename'), onClick: () => onRename(m.path) },
591
- { label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, 'file') },
1048
+ deleteItem,
592
1049
  ]
593
1050
  }
594
1051
  if (m.kind === 'dir') {
@@ -596,21 +1053,24 @@ export function FileExplorer({
596
1053
  { label: t('menu.newFile'), onClick: () => onNewFile(m.path) },
597
1054
  { label: t('menu.newFolder'), onClick: () => onNewFolder(m.path) },
598
1055
  'divider',
1056
+ { label: t('menu.copy'), onClick: () => onCopySelection('copy') },
1057
+ { label: t('menu.cut'), onClick: () => onCopySelection('cut') },
1058
+ { label: t('menu.paste'), disabled: clipboard.items.length === 0, onClick: () => void onPaste() },
1059
+ 'divider',
599
1060
  { label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
1061
+ { label: t('menu.revealInExplorer'), onClick: () => onReveal(m.path, 'dir') },
600
1062
  { label: t('menu.openSystem'), onClick: () => void openPath(m.path) },
601
1063
  { label: t('menu.refresh'), onClick: () => refreshDir(m.path) },
602
1064
  'divider',
603
1065
  { label: t('menu.rename'), onClick: () => onRename(m.path) },
604
- { label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, 'dir') },
1066
+ deleteItem,
605
1067
  ]
606
1068
  }
607
- // Empty tree area (the workspace root).
1069
+ // Empty tree area: right-clicking blank space intentionally shows no
1070
+ // custom menu (only file/folder rows do); this branch is unreachable but
1071
+ // kept for the type. Paste happens via Ctrl+V into the current folder.
608
1072
  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) },
1073
+ { label: t('menu.paste'), disabled: clipboard.items.length === 0, onClick: () => void onPaste() },
614
1074
  ]
615
1075
  }
616
1076
 
@@ -619,6 +1079,15 @@ export function FileExplorer({
619
1079
  <div className={styles.header}>
620
1080
  <span className={styles.headerTitle} title={root ?? cwd}>{basenameOf(root ?? cwd)}</span>
621
1081
  <span className={styles.headerActions}>
1082
+ <button
1083
+ type="button"
1084
+ className={styles.action}
1085
+ title={undoEntries.length > 0 ? `${t('action.undo')}:${undoEntries[undoEntries.length - 1].label}` : t('action.undo')}
1086
+ disabled={undoEntries.length === 0}
1087
+ onClick={() => void performUndo()}
1088
+ >
1089
+
1090
+ </button>
622
1091
  <button type="button" className={styles.action} title={t('action.theme')} onClick={toggleTheme}>
623
1092
  {theme === 'dark' ? '☀' : '🌙'}
624
1093
  </button>
@@ -626,18 +1095,49 @@ export function FileExplorer({
626
1095
  <button type="button" className={styles.action} title={t('tab.expand')} onClick={expandPreview}>{'>'}</button>
627
1096
  </span>
628
1097
  </div>
1098
+ {clipboard.items.length > 0 && (
1099
+ <div className={styles.clipboardBar} role="status">
1100
+ <span className={styles.clipboardText}>
1101
+ {clipboard.mode === 'cut'
1102
+ ? t('clipboard.cut', { count: clipboard.items.length })
1103
+ : t('clipboard.copied', { count: clipboard.items.length })}
1104
+ <span className={styles.clipboardHint}>{t('clipboard.pasteHint')}</span>
1105
+ </span>
1106
+ <button
1107
+ type="button"
1108
+ className={styles.action}
1109
+ title={t('clipboard.clear')}
1110
+ onClick={() => {
1111
+ clearClipboard()
1112
+ if (clipboard.mode === 'cut') setSelected({})
1113
+ }}
1114
+ >
1115
+
1116
+ </button>
1117
+ </div>
1118
+ )}
629
1119
  <div
1120
+ ref={treeAreaRef}
1121
+ tabIndex={0}
630
1122
  className={styles.treeArea}
631
- onContextMenu={(e) => {
1123
+ onKeyDown={onTreeKeyDown}
1124
+ onClick={(e) => {
1125
+ // Clicking blank space deselects (Explorer behavior).
1126
+ if (e.target === e.currentTarget) setSelected({})
1127
+ }}
1128
+ onDragOver={(e) => {
1129
+ // Blank tree area also accepts a drop: move into the current folder.
1130
+ if (dragPathsRef.current === null) return
632
1131
  e.preventDefault()
633
- const target = root ?? cwd
634
- if (target === undefined) return
635
- setMenu({ kind: 'root', path: target, x: e.clientX, y: e.clientY })
1132
+ e.dataTransfer.dropEffect = 'move'
1133
+ }}
1134
+ onDrop={(e) => {
1135
+ if (dragPathsRef.current !== null) onDropMove(e, cwd)
636
1136
  }}
637
1137
  >
638
1138
  {rootLoading && <div className={styles.rowHint}>{t('preview.loading')}</div>}
639
1139
  {rootError !== undefined && <div className={styles.rowError}>{rootError}</div>}
640
- {!rootLoading && rootError === undefined && root !== undefined && children[root] !== undefined && children[root].length === 0 && (
1140
+ {!rootLoading && rootError === undefined && root !== undefined && children[root] !== undefined && visibleEntries(children[root]).length === 0 && (
641
1141
  <div className={styles.rowHint}>{t('preview.emptyDir')}</div>
642
1142
  )}
643
1143
  {root !== undefined && children[root] !== undefined && renderEntries(children[root], 0)}
@@ -656,7 +1156,7 @@ export function FileExplorer({
656
1156
  <div
657
1157
  key={index}
658
1158
  role="menuitem"
659
- className={`${styles.contextMenuItem}${item.danger ? ` ${styles.contextMenuItemDanger}` : ''}`}
1159
+ className={`${styles.contextMenuItem}${item.danger ? ` ${styles.contextMenuItemDanger}` : ''}${item.disabled ? ` ${styles.contextMenuItemDisabled}` : ''}`}
660
1160
  onClick={() => runAction(item.onClick)}
661
1161
  >
662
1162
  {item.label}