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