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.
- package/CHANGELOG.md +32 -1
- package/README.md +18 -5
- package/lib/client.js +6399 -155
- package/lib/client.js.map +1 -1
- package/lib/index.js +302 -5
- package/package.json +2 -1
- package/src/client/FileExplorer.tsx +537 -38
- package/src/client/FilePreview.tsx +182 -6
- package/src/client/files.module.css +279 -0
- package/src/client/highlight.ts +18 -1
- package/src/client/index.ts +23 -4
- package/src/client/locales.ts +48 -4
- package/src/client/markdown.ts +69 -0
- package/src/client/store.ts +132 -2
- package/src/index.ts +400 -7
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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'
|
|
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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1065
|
+
deleteItem,
|
|
605
1066
|
]
|
|
606
1067
|
}
|
|
607
|
-
// Empty tree area
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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}
|