dsh-plugin-workbench 0.0.3 → 0.0.5
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 +54 -0
- package/README.md +17 -7
- package/lib/client.js +641 -160
- package/lib/client.js.map +1 -1
- package/lib/index.js +169 -6
- package/package.json +1 -1
- package/src/client/FileExplorer.tsx +357 -64
- package/src/client/FilePreview.tsx +171 -34
- package/src/client/files.module.css +144 -3
- package/src/client/index.ts +5 -1
- package/src/client/locales.ts +30 -0
- package/src/client/store.ts +25 -0
- package/src/dsh.d.ts +9 -0
- package/src/index.ts +204 -6
|
@@ -3,11 +3,12 @@
|
|
|
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 { useCallback, useEffect, useRef, useState } from 'react'
|
|
6
|
+
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
7
|
+
import type { 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 { closeFilesUnder, expandPreview, openFile, retargetFile, setCwd, toggleTheme, useTabsState } from './store'
|
|
11
12
|
|
|
12
13
|
export interface FsListEntry {
|
|
13
14
|
name: string
|
|
@@ -29,6 +30,11 @@ export interface FsReadResult {
|
|
|
29
30
|
truncated: boolean
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** Result of a context-menu mutation (create/rename/delete). */
|
|
34
|
+
export interface FsMutationResult {
|
|
35
|
+
path: string
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
interface SessionSummary {
|
|
33
39
|
id: string
|
|
34
40
|
cwd?: string
|
|
@@ -45,6 +51,10 @@ export interface FileExplorerProps {
|
|
|
45
51
|
t: (key: FilesKey, params?: Record<string, unknown>) => string
|
|
46
52
|
listDir: (path: string, signal?: AbortSignal) => Promise<FsListResult>
|
|
47
53
|
openPath: (path: string) => Promise<void>
|
|
54
|
+
createFile: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
|
|
55
|
+
createDir: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
|
|
56
|
+
renameFile: (path: string, to: string, signal?: AbortSignal) => Promise<FsMutationResult>
|
|
57
|
+
removePath: (path: string, signal?: AbortSignal) => Promise<FsMutationResult>
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
function basenameOf(path: string): string {
|
|
@@ -53,6 +63,18 @@ function basenameOf(path: string): string {
|
|
|
53
63
|
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
|
|
54
64
|
}
|
|
55
65
|
|
|
66
|
+
/** Parent directory of a path ('' for a bare drive root — never used for such). */
|
|
67
|
+
function parentOf(path: string): string {
|
|
68
|
+
const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
|
69
|
+
return idx > 0 ? path.slice(0, idx) : path
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Append a child name to a directory path, honoring its separator style. */
|
|
73
|
+
function joinPath(dir: string, name: string): string {
|
|
74
|
+
const sep = dir.includes('\\') ? '\\' : '/'
|
|
75
|
+
return dir.endsWith('\\') || dir.endsWith('/') ? dir + name : dir + sep + name
|
|
76
|
+
}
|
|
77
|
+
|
|
56
78
|
function formatSize(bytes: number): string {
|
|
57
79
|
if (!Number.isFinite(bytes) || bytes < 0) return ''
|
|
58
80
|
if (bytes < 1024) return `${bytes} B`
|
|
@@ -64,6 +86,12 @@ function formatSize(bytes: number): string {
|
|
|
64
86
|
/** How often the visible tree is re-listed to pick up disk changes. */
|
|
65
87
|
const REFRESH_MS = 2000
|
|
66
88
|
|
|
89
|
+
/** How many previously-expanded folders to re-list at once on workspace switch. */
|
|
90
|
+
const DIR_LOAD_BATCH = 4
|
|
91
|
+
|
|
92
|
+
/** Pause between batches when restoring expanded folders. */
|
|
93
|
+
const DIR_LOAD_GAP_MS = 50
|
|
94
|
+
|
|
67
95
|
const EMPTY_EXPANDED = new Set<string>()
|
|
68
96
|
|
|
69
97
|
/** True when two directory listings are identical (name/kind/size). */
|
|
@@ -75,7 +103,109 @@ function sameEntries(a: FsListEntry[], b: FsListEntry[]): boolean {
|
|
|
75
103
|
return true
|
|
76
104
|
}
|
|
77
105
|
|
|
78
|
-
|
|
106
|
+
interface TreeRowProps {
|
|
107
|
+
entry: FsListEntry
|
|
108
|
+
depth: number
|
|
109
|
+
isActive: boolean
|
|
110
|
+
isExpanded: boolean
|
|
111
|
+
onToggleDir: (path: string) => void
|
|
112
|
+
onRefreshDir: (path: string) => void
|
|
113
|
+
onSelect: (entry: FsListEntry) => void
|
|
114
|
+
onDoubleClick: (entry: FsListEntry) => void
|
|
115
|
+
onOpenExternal: (path: string) => Promise<void>
|
|
116
|
+
onContextMenu: (e: ReactMouseEvent, entry: FsListEntry) => void
|
|
117
|
+
t: (key: FilesKey, params?: Record<string, unknown>) => string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* One tree row, memoized so opening/activating a file (which changes the
|
|
122
|
+
* active-path highlight) re-renders only the affected row — with a workspace
|
|
123
|
+
* full of files, re-rendering the whole tree on every click is what made
|
|
124
|
+
* opening files feel laggy.
|
|
125
|
+
*/
|
|
126
|
+
const TreeRow = memo(function TreeRow({
|
|
127
|
+
entry,
|
|
128
|
+
depth,
|
|
129
|
+
isActive,
|
|
130
|
+
isExpanded,
|
|
131
|
+
onToggleDir,
|
|
132
|
+
onRefreshDir,
|
|
133
|
+
onSelect,
|
|
134
|
+
onDoubleClick,
|
|
135
|
+
onOpenExternal,
|
|
136
|
+
onContextMenu,
|
|
137
|
+
t,
|
|
138
|
+
}: TreeRowProps) {
|
|
139
|
+
const isDir = entry.kind === 'dir'
|
|
140
|
+
return (
|
|
141
|
+
<div
|
|
142
|
+
className={`${styles.row} ${isActive ? styles.rowSelected : ''}`}
|
|
143
|
+
style={{ paddingLeft: 8 + depth * 14 }}
|
|
144
|
+
onClick={() => onSelect(entry)}
|
|
145
|
+
onDoubleClick={() => onDoubleClick(entry)}
|
|
146
|
+
onContextMenu={(e) => onContextMenu(e, entry)}
|
|
147
|
+
role="treeitem"
|
|
148
|
+
aria-selected={isActive}
|
|
149
|
+
aria-expanded={isDir ? isExpanded : undefined}
|
|
150
|
+
title={entry.name}
|
|
151
|
+
>
|
|
152
|
+
<span
|
|
153
|
+
className={styles.chevron}
|
|
154
|
+
onClick={(e) => {
|
|
155
|
+
e.stopPropagation()
|
|
156
|
+
if (isDir) onToggleDir(entry.path)
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
{isDir ? (isExpanded ? '▾' : '▸') : ''}
|
|
160
|
+
</span>
|
|
161
|
+
<span className={styles.icon}>
|
|
162
|
+
{isDir ? (isExpanded ? '📂' : '📁') : entry.kind === 'file' ? <FileIcon name={entry.name} /> : '·'}
|
|
163
|
+
</span>
|
|
164
|
+
<span className={styles.name}>{entry.name}</span>
|
|
165
|
+
{entry.kind === 'file' && entry.size !== undefined && (
|
|
166
|
+
<span className={styles.size}>{formatSize(entry.size)}</span>
|
|
167
|
+
)}
|
|
168
|
+
<span className={styles.actions}>
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
className={styles.action}
|
|
172
|
+
title={t('action.open')}
|
|
173
|
+
onClick={(e) => {
|
|
174
|
+
e.stopPropagation()
|
|
175
|
+
void onOpenExternal(entry.path)
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
↗
|
|
179
|
+
</button>
|
|
180
|
+
{isDir && (
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
className={styles.action}
|
|
184
|
+
title={t('action.refresh')}
|
|
185
|
+
onClick={(e) => {
|
|
186
|
+
e.stopPropagation()
|
|
187
|
+
onRefreshDir(entry.path)
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
⟳
|
|
191
|
+
</button>
|
|
192
|
+
)}
|
|
193
|
+
</span>
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
export function FileExplorer({
|
|
199
|
+
width,
|
|
200
|
+
useSessions,
|
|
201
|
+
t,
|
|
202
|
+
listDir,
|
|
203
|
+
openPath,
|
|
204
|
+
createFile,
|
|
205
|
+
createDir,
|
|
206
|
+
renameFile,
|
|
207
|
+
removePath,
|
|
208
|
+
}: FileExplorerProps) {
|
|
79
209
|
const sessionList = useSessions((s) => s)
|
|
80
210
|
const currentId = sessionList.current
|
|
81
211
|
const cwd = currentId !== undefined ? sessionList.byId[currentId]?.cwd : undefined
|
|
@@ -95,9 +225,64 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
95
225
|
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set())
|
|
96
226
|
const [dirErrors, setDirErrors] = useState<Record<string, string>>({})
|
|
97
227
|
|
|
228
|
+
// ---- context menu ----
|
|
229
|
+
interface MenuState {
|
|
230
|
+
kind: 'file' | 'dir' | 'root'
|
|
231
|
+
path: string
|
|
232
|
+
x: number
|
|
233
|
+
y: number
|
|
234
|
+
}
|
|
235
|
+
const [menu, setMenu] = useState<MenuState | undefined>(undefined)
|
|
236
|
+
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | undefined>(undefined)
|
|
237
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
238
|
+
|
|
239
|
+
// Measure and clamp the menu into the viewport before the browser paints
|
|
240
|
+
// (layout effects run pre-paint, so the raw position never shows).
|
|
241
|
+
useLayoutEffect(() => {
|
|
242
|
+
if (menu === undefined) {
|
|
243
|
+
setMenuPos(undefined)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
const el = menuRef.current
|
|
247
|
+
if (el === null) return
|
|
248
|
+
const rect = el.getBoundingClientRect()
|
|
249
|
+
setMenuPos({
|
|
250
|
+
x: Math.max(4, Math.min(menu.x, window.innerWidth - rect.width - 4)),
|
|
251
|
+
y: Math.max(4, Math.min(menu.y, window.innerHeight - rect.height - 4)),
|
|
252
|
+
})
|
|
253
|
+
}, [menu])
|
|
254
|
+
|
|
255
|
+
// Close the menu on outside click / Escape / window blur.
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (menu === undefined) return undefined
|
|
258
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
259
|
+
if (e.key === 'Escape') setMenu(undefined)
|
|
260
|
+
}
|
|
261
|
+
const onMouseDown = (e: MouseEvent) => {
|
|
262
|
+
const el = menuRef.current
|
|
263
|
+
if (el !== null && e.target instanceof Node && el.contains(e.target)) return
|
|
264
|
+
setMenu(undefined)
|
|
265
|
+
}
|
|
266
|
+
const onBlur = () => setMenu(undefined)
|
|
267
|
+
document.addEventListener('keydown', onKeyDown)
|
|
268
|
+
// mousedown (not click): closing before a menu item's click still lets the
|
|
269
|
+
// click dispatch on the item, and closes when clicking anywhere else.
|
|
270
|
+
document.addEventListener('mousedown', onMouseDown)
|
|
271
|
+
window.addEventListener('blur', onBlur)
|
|
272
|
+
return () => {
|
|
273
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
274
|
+
document.removeEventListener('mousedown', onMouseDown)
|
|
275
|
+
window.removeEventListener('blur', onBlur)
|
|
276
|
+
}
|
|
277
|
+
}, [menu])
|
|
278
|
+
|
|
98
279
|
// Latest tree snapshot for the polling tick (avoids stale closures).
|
|
99
280
|
const treeRef = useRef({ root, children, expanded, rootLoading })
|
|
100
281
|
treeRef.current = { root, children, expanded, rootLoading }
|
|
282
|
+
// Stable view of the loaded listings so toggleDir doesn't depend on the
|
|
283
|
+
// children state (keeps memoized rows from re-rendering on listing updates).
|
|
284
|
+
const childrenRef = useRef(children)
|
|
285
|
+
childrenRef.current = children
|
|
101
286
|
|
|
102
287
|
// Expose the explorer width so the maid-atelier fixed chrome (top/bottom
|
|
103
288
|
// trim) can shift past this column instead of covering it.
|
|
@@ -129,15 +314,22 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
129
314
|
const controller = new AbortController()
|
|
130
315
|
rootAbortRef.current = controller
|
|
131
316
|
listDir(cwd, controller.signal)
|
|
132
|
-
.then((result) => {
|
|
317
|
+
.then(async (result) => {
|
|
133
318
|
if (controller.signal.aborted) return
|
|
134
319
|
setRoot(result.root)
|
|
135
320
|
setChildren({ [result.root]: result.entries })
|
|
136
321
|
setRootError(undefined)
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
322
|
+
// Restore previously-expanded folders in small batches so a workspace
|
|
323
|
+
// with many expanded folders doesn't flood the tree (and the page)
|
|
324
|
+
// all at once; the auto-refresh tick fills any remainder shortly after.
|
|
325
|
+
const dirs = [...expanded].filter((dirPath) => dirPath !== result.root)
|
|
326
|
+
for (let i = 0; i < dirs.length; i += DIR_LOAD_BATCH) {
|
|
327
|
+
if (controller.signal.aborted) return
|
|
328
|
+
const batch = dirs.slice(i, i + DIR_LOAD_BATCH)
|
|
329
|
+
await Promise.all(batch.map((dirPath) => loadDir(dirPath)))
|
|
330
|
+
if (i + DIR_LOAD_BATCH < dirs.length) {
|
|
331
|
+
await new Promise((resolve) => setTimeout(resolve, DIR_LOAD_GAP_MS))
|
|
332
|
+
}
|
|
141
333
|
}
|
|
142
334
|
})
|
|
143
335
|
.catch((error) => {
|
|
@@ -240,8 +432,8 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
240
432
|
else next.add(dirPath)
|
|
241
433
|
return { ...prev, [cwdKey]: next }
|
|
242
434
|
})
|
|
243
|
-
if (!
|
|
244
|
-
}, [cwdKey,
|
|
435
|
+
if (!childrenRef.current[dirPath]) void loadDir(dirPath)
|
|
436
|
+
}, [cwdKey, loadDir])
|
|
245
437
|
|
|
246
438
|
const refreshDir = useCallback((dirPath: string) => {
|
|
247
439
|
setChildren((prev) => {
|
|
@@ -252,6 +444,81 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
252
444
|
void loadDir(dirPath)
|
|
253
445
|
}, [loadDir])
|
|
254
446
|
|
|
447
|
+
// ---- context-menu actions ----
|
|
448
|
+
|
|
449
|
+
const openContextMenu = useCallback((e: ReactMouseEvent, entry: FsListEntry) => {
|
|
450
|
+
e.preventDefault()
|
|
451
|
+
e.stopPropagation()
|
|
452
|
+
setMenu({ kind: entry.kind === 'dir' ? 'dir' : 'file', path: entry.path, x: e.clientX, y: e.clientY })
|
|
453
|
+
}, [])
|
|
454
|
+
|
|
455
|
+
const runAction = useCallback((action: () => void) => {
|
|
456
|
+
setMenu(undefined)
|
|
457
|
+
action()
|
|
458
|
+
}, [])
|
|
459
|
+
|
|
460
|
+
// One mutation pipeline shared by New File / New Folder / Rename / Delete:
|
|
461
|
+
// run the op, refresh the touched directory, and surface failures inline in
|
|
462
|
+
// the tree (under the directory row, or at the column top for the root).
|
|
463
|
+
const applyMutation = useCallback(async (op: () => Promise<unknown>, refreshPath: string) => {
|
|
464
|
+
try {
|
|
465
|
+
await op()
|
|
466
|
+
refreshDir(refreshPath)
|
|
467
|
+
} catch (error) {
|
|
468
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
469
|
+
if (refreshPath === root) setRootError(message)
|
|
470
|
+
else setDirErrors((prev) => ({ ...prev, [refreshPath]: message }))
|
|
471
|
+
}
|
|
472
|
+
}, [refreshDir, root])
|
|
473
|
+
|
|
474
|
+
const onNewFile = useCallback((dirPath: string) => {
|
|
475
|
+
const name = window.prompt(`${t('prompt.newFileName')}:`, '')
|
|
476
|
+
if (name === null) return
|
|
477
|
+
const trimmed = name.trim()
|
|
478
|
+
if (trimmed === '') return
|
|
479
|
+
void applyMutation(() => createFile(joinPath(dirPath, trimmed)), dirPath)
|
|
480
|
+
}, [applyMutation, createFile, t])
|
|
481
|
+
|
|
482
|
+
const onNewFolder = useCallback((dirPath: string) => {
|
|
483
|
+
const name = window.prompt(`${t('prompt.newFolderName')}:`, '')
|
|
484
|
+
if (name === null) return
|
|
485
|
+
const trimmed = name.trim()
|
|
486
|
+
if (trimmed === '') return
|
|
487
|
+
void applyMutation(() => createDir(joinPath(dirPath, trimmed)), dirPath)
|
|
488
|
+
}, [applyMutation, createDir, t])
|
|
489
|
+
|
|
490
|
+
const onRename = useCallback((path: string) => {
|
|
491
|
+
const current = basenameOf(path)
|
|
492
|
+
const name = window.prompt(`${t('prompt.renameTo')}:`, current)
|
|
493
|
+
if (name === null) return
|
|
494
|
+
const trimmed = name.trim()
|
|
495
|
+
if (trimmed === '' || trimmed === current) return
|
|
496
|
+
const parent = parentOf(path)
|
|
497
|
+
const to = joinPath(parent, trimmed)
|
|
498
|
+
void applyMutation(async () => {
|
|
499
|
+
await renameFile(path, to)
|
|
500
|
+
// Keep any open tab pointing at the moved file.
|
|
501
|
+
retargetFile(path, to)
|
|
502
|
+
}, parent)
|
|
503
|
+
}, [applyMutation, renameFile, t])
|
|
504
|
+
|
|
505
|
+
const onDelete = useCallback((path: string, kind: 'file' | 'dir') => {
|
|
506
|
+
const name = basenameOf(path)
|
|
507
|
+
const message = kind === 'dir' ? t('confirm.deleteDir', { name }) : t('confirm.deleteFile', { name })
|
|
508
|
+
if (!window.confirm(message)) return
|
|
509
|
+
void applyMutation(async () => {
|
|
510
|
+
await removePath(path)
|
|
511
|
+
// Drop tabs for the deleted file (or everything under a deleted folder).
|
|
512
|
+
closeFilesUnder(path)
|
|
513
|
+
}, parentOf(path))
|
|
514
|
+
}, [applyMutation, removePath, t])
|
|
515
|
+
|
|
516
|
+
const onCopyPath = useCallback((path: string) => {
|
|
517
|
+
void navigator.clipboard.writeText(path).catch(() => {
|
|
518
|
+
// clipboard unavailable (permissions) — nothing else to do
|
|
519
|
+
})
|
|
520
|
+
}, [])
|
|
521
|
+
|
|
255
522
|
const onRowClick = useCallback((entry: FsListEntry) => {
|
|
256
523
|
if (entry.kind === 'dir') {
|
|
257
524
|
toggleDir(entry.path)
|
|
@@ -285,59 +552,19 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
285
552
|
|
|
286
553
|
return (
|
|
287
554
|
<div key={entry.path}>
|
|
288
|
-
<
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
e.stopPropagation()
|
|
302
|
-
if (isDir) toggleDir(entry.path)
|
|
303
|
-
}}
|
|
304
|
-
>
|
|
305
|
-
{isDir ? (isExpanded ? '▾' : '▸') : ''}
|
|
306
|
-
</span>
|
|
307
|
-
<span className={styles.icon}>
|
|
308
|
-
{isDir ? (isExpanded ? '📂' : '📁') : entry.kind === 'file' ? <FileIcon name={entry.name} /> : '·'}
|
|
309
|
-
</span>
|
|
310
|
-
<span className={styles.name}>{entry.name}</span>
|
|
311
|
-
{entry.kind === 'file' && entry.size !== undefined && (
|
|
312
|
-
<span className={styles.size}>{formatSize(entry.size)}</span>
|
|
313
|
-
)}
|
|
314
|
-
<span className={styles.actions}>
|
|
315
|
-
<button
|
|
316
|
-
type="button"
|
|
317
|
-
className={styles.action}
|
|
318
|
-
title={t('action.open')}
|
|
319
|
-
onClick={(e) => {
|
|
320
|
-
e.stopPropagation()
|
|
321
|
-
void openPath(entry.path)
|
|
322
|
-
}}
|
|
323
|
-
>
|
|
324
|
-
↗
|
|
325
|
-
</button>
|
|
326
|
-
{isDir && (
|
|
327
|
-
<button
|
|
328
|
-
type="button"
|
|
329
|
-
className={styles.action}
|
|
330
|
-
title={t('action.refresh')}
|
|
331
|
-
onClick={(e) => {
|
|
332
|
-
e.stopPropagation()
|
|
333
|
-
refreshDir(entry.path)
|
|
334
|
-
}}
|
|
335
|
-
>
|
|
336
|
-
⟳
|
|
337
|
-
</button>
|
|
338
|
-
)}
|
|
339
|
-
</span>
|
|
340
|
-
</div>
|
|
555
|
+
<TreeRow
|
|
556
|
+
entry={entry}
|
|
557
|
+
depth={depth}
|
|
558
|
+
isActive={entry.kind === 'file' && activePath === entry.path}
|
|
559
|
+
isExpanded={isExpanded}
|
|
560
|
+
onToggleDir={toggleDir}
|
|
561
|
+
onRefreshDir={refreshDir}
|
|
562
|
+
onSelect={onRowClick}
|
|
563
|
+
onDoubleClick={onRowDoubleClick}
|
|
564
|
+
onOpenExternal={openPath}
|
|
565
|
+
onContextMenu={openContextMenu}
|
|
566
|
+
t={t}
|
|
567
|
+
/>
|
|
341
568
|
{isDir && isExpanded && (
|
|
342
569
|
<div>
|
|
343
570
|
{isLoading && <div className={styles.rowHint} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{t('preview.loading')}</div>}
|
|
@@ -352,6 +579,41 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
352
579
|
)
|
|
353
580
|
})
|
|
354
581
|
|
|
582
|
+
const menuItems = (m: MenuState): Array<{ label: string; danger?: boolean; onClick: () => void } | 'divider'> => {
|
|
583
|
+
if (m.kind === 'file') {
|
|
584
|
+
return [
|
|
585
|
+
{ label: t('menu.open'), onClick: () => openFile(m.path) },
|
|
586
|
+
'divider',
|
|
587
|
+
{ label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
|
|
588
|
+
{ label: t('menu.openSystem'), onClick: () => void openPath(m.path) },
|
|
589
|
+
'divider',
|
|
590
|
+
{ label: t('menu.rename'), onClick: () => onRename(m.path) },
|
|
591
|
+
{ label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, 'file') },
|
|
592
|
+
]
|
|
593
|
+
}
|
|
594
|
+
if (m.kind === 'dir') {
|
|
595
|
+
return [
|
|
596
|
+
{ label: t('menu.newFile'), onClick: () => onNewFile(m.path) },
|
|
597
|
+
{ label: t('menu.newFolder'), onClick: () => onNewFolder(m.path) },
|
|
598
|
+
'divider',
|
|
599
|
+
{ label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
|
|
600
|
+
{ label: t('menu.openSystem'), onClick: () => void openPath(m.path) },
|
|
601
|
+
{ label: t('menu.refresh'), onClick: () => refreshDir(m.path) },
|
|
602
|
+
'divider',
|
|
603
|
+
{ label: t('menu.rename'), onClick: () => onRename(m.path) },
|
|
604
|
+
{ label: t('menu.delete'), danger: true, onClick: () => onDelete(m.path, 'dir') },
|
|
605
|
+
]
|
|
606
|
+
}
|
|
607
|
+
// Empty tree area (the workspace root).
|
|
608
|
+
return [
|
|
609
|
+
{ label: t('menu.newFile'), onClick: () => onNewFile(m.path) },
|
|
610
|
+
{ label: t('menu.newFolder'), onClick: () => onNewFolder(m.path) },
|
|
611
|
+
'divider',
|
|
612
|
+
{ label: t('menu.copyPath'), onClick: () => onCopyPath(m.path) },
|
|
613
|
+
{ label: t('menu.refresh'), onClick: () => refreshDir(m.path) },
|
|
614
|
+
]
|
|
615
|
+
}
|
|
616
|
+
|
|
355
617
|
return (
|
|
356
618
|
<div className={styles.column} style={{ width: width > 0 ? width : undefined }} data-pane="explorer" data-fe-theme={theme}>
|
|
357
619
|
<div className={styles.header}>
|
|
@@ -364,7 +626,15 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
364
626
|
<button type="button" className={styles.action} title={t('tab.expand')} onClick={expandPreview}>{'>'}</button>
|
|
365
627
|
</span>
|
|
366
628
|
</div>
|
|
367
|
-
<div
|
|
629
|
+
<div
|
|
630
|
+
className={styles.treeArea}
|
|
631
|
+
onContextMenu={(e) => {
|
|
632
|
+
e.preventDefault()
|
|
633
|
+
const target = root ?? cwd
|
|
634
|
+
if (target === undefined) return
|
|
635
|
+
setMenu({ kind: 'root', path: target, x: e.clientX, y: e.clientY })
|
|
636
|
+
}}
|
|
637
|
+
>
|
|
368
638
|
{rootLoading && <div className={styles.rowHint}>{t('preview.loading')}</div>}
|
|
369
639
|
{rootError !== undefined && <div className={styles.rowError}>{rootError}</div>}
|
|
370
640
|
{!rootLoading && rootError === undefined && root !== undefined && children[root] !== undefined && children[root].length === 0 && (
|
|
@@ -372,6 +642,29 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
372
642
|
)}
|
|
373
643
|
{root !== undefined && children[root] !== undefined && renderEntries(children[root], 0)}
|
|
374
644
|
</div>
|
|
645
|
+
{menu !== undefined && (
|
|
646
|
+
<div
|
|
647
|
+
ref={menuRef}
|
|
648
|
+
className={styles.contextMenu}
|
|
649
|
+
role="menu"
|
|
650
|
+
style={menuPos !== undefined ? { left: menuPos.x, top: menuPos.y } : { left: menu.x, top: menu.y, visibility: 'hidden' }}
|
|
651
|
+
>
|
|
652
|
+
{menuItems(menu).map((item, index) =>
|
|
653
|
+
item === 'divider' ? (
|
|
654
|
+
<div key={index} className={styles.contextMenuDivider} />
|
|
655
|
+
) : (
|
|
656
|
+
<div
|
|
657
|
+
key={index}
|
|
658
|
+
role="menuitem"
|
|
659
|
+
className={`${styles.contextMenuItem}${item.danger ? ` ${styles.contextMenuItemDanger}` : ''}`}
|
|
660
|
+
onClick={() => runAction(item.onClick)}
|
|
661
|
+
>
|
|
662
|
+
{item.label}
|
|
663
|
+
</div>
|
|
664
|
+
),
|
|
665
|
+
)}
|
|
666
|
+
</div>
|
|
667
|
+
)}
|
|
375
668
|
</div>
|
|
376
669
|
)
|
|
377
670
|
}
|