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.
- package/CHANGELOG.md +42 -1
- package/README.md +28 -5
- package/lib/client.js +6396 -155
- package/lib/client.js.map +1 -1
- package/lib/index.js +302 -5
- package/package.json +4 -2
- package/src/client/FileExplorer.tsx +538 -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 +413 -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
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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'
|
|
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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1066
|
+
deleteItem,
|
|
605
1067
|
]
|
|
606
1068
|
}
|
|
607
|
-
// Empty tree area
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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}
|