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.
@@ -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
- export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileExplorerProps) {
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
- // Reload the contents of this workspace's previously-expanded folders
138
- // right away instead of waiting for the auto-refresh tick.
139
- for (const dirPath of expanded) {
140
- if (dirPath !== result.root) void loadDir(dirPath)
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 (!children[dirPath]) void loadDir(dirPath)
244
- }, [cwdKey, children, loadDir])
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
- <div
289
- className={`${styles.row} ${entry.kind === 'file' && activePath === entry.path ? styles.rowSelected : ''}`}
290
- style={{ paddingLeft: 8 + depth * 14 }}
291
- onClick={() => onRowClick(entry)}
292
- onDoubleClick={() => onRowDoubleClick(entry)}
293
- role="treeitem"
294
- aria-selected={entry.kind === 'file' && activePath === entry.path}
295
- aria-expanded={isDir ? isExpanded : undefined}
296
- title={entry.name}
297
- >
298
- <span
299
- className={styles.chevron}
300
- onClick={(e) => {
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 className={styles.treeArea}>
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
  }