dsh-explorer 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,1139 @@
1
+ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
2
+ import css from './explorer.module.css'
3
+ import {
4
+ fetchTree,
5
+ fetchBatchTree,
6
+ fetchGitStatus,
7
+ apiCreateNewFile,
8
+ apiCreateDirectory,
9
+ apiRename,
10
+ apiDelete,
11
+ fetchFileContent,
12
+ type FsEntry,
13
+ type FsListing,
14
+ type GitStatusResult,
15
+ } from './api.ts'
16
+ import { ExplorerHeader } from './ExplorerHeader.tsx'
17
+ import { ExplorerSearch } from './ExplorerSearch.tsx'
18
+ import { ExplorerRow } from './ExplorerRow.tsx'
19
+ import { ExplorerContextMenu } from './ExplorerContextMenu.tsx'
20
+ import { ExplorerOptionsModal } from './ExplorerOptionsModal.tsx'
21
+ import {
22
+ loadExplorerOptions,
23
+ saveExplorerOptions,
24
+ DEFAULT_OPTIONS,
25
+ type ExplorerOptions,
26
+ } from './options.ts'
27
+ import {
28
+ insertMentionToComposer,
29
+ insertSnippetToComposer,
30
+ setLastKnownSessionId,
31
+ baseName,
32
+ } from './conversation-draft.ts'
33
+
34
+ interface ExplorerViewProps {
35
+ ctx: any
36
+ sessionId?: string
37
+ initialCwd?: string
38
+ }
39
+
40
+ function safeService<T = any>(ctx: any, name: string): T | undefined {
41
+ if (!ctx) return undefined
42
+ try {
43
+ if (typeof ctx.get === 'function') {
44
+ const val = ctx.get(name)
45
+ if (val !== undefined) return val
46
+ }
47
+ return ctx[name]
48
+ } catch {
49
+ return undefined
50
+ }
51
+ }
52
+
53
+ export function openFileInTab(ctx: any, sessionId: string, filePath: string, relPath: string): void {
54
+ const normPath = filePath.replace(/\\/g, '/')
55
+ const fileName = baseName(filePath)
56
+
57
+ // 1. Try DSH betterSidebar editor tab
58
+ try {
59
+ const betterSidebar = safeService(ctx, 'betterSidebar')
60
+ if (betterSidebar && typeof betterSidebar.openTab === 'function') {
61
+ betterSidebar.openTab({
62
+ type: 'editor',
63
+ title: fileName,
64
+ path: filePath,
65
+ id: `editor:${filePath}`,
66
+ })
67
+ return
68
+ }
69
+ } catch {}
70
+
71
+ // 2. Try native DSH sidebarRight tab opener
72
+ try {
73
+ const sidebarRight = safeService(ctx, 'sidebarRight')
74
+ if (sidebarRight && typeof sidebarRight.openResource === 'function') {
75
+ const normRel = relPath.replace(/\\/g, '/')
76
+ const encodePath = (p: string) =>
77
+ p
78
+ .split('/')
79
+ .map((seg) => encodeURIComponent(seg).replace(/%3A/gi, ':'))
80
+ .join('/')
81
+ const address = sessionId
82
+ ? `dsh-resource://file/session/${encodeURIComponent(sessionId)}/${encodePath(normRel)}`
83
+ : `dsh-resource://file/absolute/${encodePath(normPath.replace(/^\/+/, ''))}`
84
+ sidebarRight.openResource(address)
85
+ return
86
+ }
87
+ } catch {}
88
+
89
+ // 3. Try tabs / editor service if registered
90
+ try {
91
+ const tabs = safeService(ctx, 'tabs')
92
+ if (tabs && typeof tabs.open === 'function') {
93
+ tabs.open({ path: filePath, title: fileName })
94
+ return
95
+ }
96
+ } catch {}
97
+
98
+ // 4. Try native host openWorkspacePath if native desktop
99
+ try {
100
+ const remote = ctx?.remote?.session
101
+ if (remote && typeof remote.openWorkspacePath === 'function') {
102
+ remote.openWorkspacePath({ path: filePath })
103
+ return
104
+ }
105
+ } catch {}
106
+
107
+ // 5. Fallback: open file in new browser window/tab
108
+ try {
109
+ window.open(`/dsh-explorer/api/read-file?path=${encodeURIComponent(filePath)}`, '_blank')
110
+ } catch {}
111
+ }
112
+
113
+ interface TreeNodeProps {
114
+ entry: FsEntry
115
+ depth: number
116
+ parentDir: string
117
+ rootPath: string
118
+ levels: Record<string, FsListing>
119
+ expanded: Set<string>
120
+ selected: Set<string>
121
+ focusedPath: string | null
122
+ gitStatusMap: Map<string, string>
123
+ filterText: string
124
+ creating: { parentDir: string; isDir: boolean } | null
125
+ creatingName: string
126
+ createInputRef: React.RefObject<HTMLInputElement>
127
+ options: ExplorerOptions
128
+ indentStep: number
129
+ iconSize: number
130
+ reloadDirectory: (dir: string) => Promise<void>
131
+ onToggle: (path: string) => void
132
+ onSelect: (path: string, e: React.MouseEvent) => void
133
+ onOpenFile: (filePath: string, relPath: string) => void
134
+ onContextMenu: (entry: FsEntry, relPath: string, e: React.MouseEvent) => void
135
+ onInsertReference: (relPath: string, isDir: boolean) => void
136
+ onCopyRelativePath: (relPath: string) => void
137
+ onRenameSubmit: (path: string, newName: string) => void
138
+ onDelete: (entry: FsEntry) => void
139
+ onCreatingNameChange: (val: string) => void
140
+ onCreatingKeyDown: (e: React.KeyboardEvent) => void
141
+ onCreatingBlur: () => void
142
+ getRelativePath: (path: string) => string
143
+ }
144
+
145
+ const TreeNode: React.FC<TreeNodeProps> = React.memo(({
146
+ entry,
147
+ depth,
148
+ rootPath,
149
+ levels,
150
+ expanded,
151
+ selected,
152
+ focusedPath,
153
+ gitStatusMap,
154
+ filterText,
155
+ creating,
156
+ creatingName,
157
+ createInputRef,
158
+ options,
159
+ indentStep,
160
+ iconSize,
161
+ reloadDirectory,
162
+ onToggle,
163
+ onSelect,
164
+ onOpenFile,
165
+ onContextMenu,
166
+ onInsertReference,
167
+ onCopyRelativePath,
168
+ onRenameSubmit,
169
+ onDelete,
170
+ onCreatingNameChange,
171
+ onCreatingKeyDown,
172
+ onCreatingBlur,
173
+ getRelativePath,
174
+ }) => {
175
+ const relPath = getRelativePath(entry.path)
176
+ const normPath = entry.path.replace(/\\/g, '/')
177
+ const listing = levels[entry.path] ?? levels[normPath]
178
+ const hasLoaded = listing !== undefined
179
+
180
+ // Hidden files filter
181
+ if (!options.showHiddenFiles && (entry.hidden || entry.name.startsWith('.'))) {
182
+ return null
183
+ }
184
+
185
+ // Filter check
186
+ if (filterText) {
187
+ const selfMatches =
188
+ entry.name.toLowerCase().includes(filterText.toLowerCase()) ||
189
+ relPath.toLowerCase().includes(filterText.toLowerCase())
190
+ if (!selfMatches && !entry.isDir) {
191
+ return null
192
+ }
193
+ }
194
+
195
+ // Dynamically resolve compact chain from loaded levels (and entry.compactChain if present)
196
+ const compactChain = useMemo(() => {
197
+ if (!options.compactFolders || !entry.isDir) return undefined
198
+ const chain: FsEntry[] = [entry]
199
+ let curr = entry
200
+ let currListing = levels[curr.path] ?? levels[curr.path.replace(/\\/g, '/')]
201
+
202
+ while (currListing && currListing.entries) {
203
+ const valid = options.showHiddenFiles
204
+ ? currListing.entries
205
+ : currListing.entries.filter((c) => !c.hidden && !c.name.startsWith('.'))
206
+ if (valid.length === 1 && valid[0]!.isDir) {
207
+ const next = valid[0]!
208
+ chain.push(next)
209
+ curr = next
210
+ currListing = levels[curr.path] ?? levels[curr.path.replace(/\\/g, '/')]
211
+ } else {
212
+ break
213
+ }
214
+ }
215
+
216
+ return chain.length > 1 ? chain : undefined
217
+ }, [options.compactFolders, options.showHiddenFiles, entry, levels])
218
+
219
+ const isCompacted = compactChain !== undefined && compactChain.length > 1
220
+ const effectiveEntry = isCompacted ? compactChain[compactChain.length - 1]! : entry
221
+ const targetPath = effectiveEntry.path
222
+ const targetRelPath = getRelativePath(targetPath)
223
+ const isExpanded = entry.isDir && (expanded.has(entry.path) || expanded.has(targetPath) || Boolean(filterText))
224
+ const isSel = selected.has(entry.path) || selected.has(targetPath)
225
+ const isFoc = focusedPath === entry.path || focusedPath === targetPath
226
+ const normRel = targetRelPath.replace(/\\/g, '/').toLowerCase()
227
+ const status =
228
+ gitStatusMap.get(normRel) ??
229
+ gitStatusMap.get(targetPath.replace(/\\/g, '/').toLowerCase()) ??
230
+ gitStatusMap.get(relPath.replace(/\\/g, '/').toLowerCase())
231
+
232
+ // Target directory listing for compact folders
233
+ const targetListing = levels[targetPath] ?? levels[targetPath.replace(/\\/g, '/')]
234
+ const hasTargetLoaded = targetListing !== undefined
235
+
236
+ // Pre-load innermost folder if compact and expanded, or single-child probe
237
+ useEffect(() => {
238
+ if (isExpanded && targetPath !== entry.path && !hasTargetLoaded) {
239
+ reloadDirectory(targetPath)
240
+ }
241
+ }, [isExpanded, targetPath, entry.path, hasTargetLoaded, reloadDirectory])
242
+
243
+ // Pre-load single-child directories when compactFolders is enabled
244
+ useEffect(() => {
245
+ if (options.compactFolders && entry.isDir && entry.compact && !hasLoaded) {
246
+ reloadDirectory(entry.path)
247
+ }
248
+ }, [options.compactFolders, entry.isDir, entry.compact, hasLoaded, entry.path, reloadDirectory])
249
+
250
+ const handleToggle = () => {
251
+ if (isCompacted && targetPath !== entry.path) {
252
+ const isCurrentlyExpanded = expanded.has(entry.path) || expanded.has(targetPath)
253
+ if (isCurrentlyExpanded) {
254
+ if (expanded.has(entry.path)) onToggle(entry.path)
255
+ if (expanded.has(targetPath)) onToggle(targetPath)
256
+ } else {
257
+ onToggle(entry.path)
258
+ onToggle(targetPath)
259
+ }
260
+ } else {
261
+ onToggle(entry.path)
262
+ }
263
+ }
264
+
265
+ if (!entry.isDir) {
266
+ return (
267
+ <ExplorerRow
268
+ entry={entry}
269
+ depth={depth}
270
+ isExpanded={false}
271
+ isSelected={isSel}
272
+ isFocused={isFoc}
273
+ gitStatus={status}
274
+ relativePath={relPath}
275
+ indentStep={indentStep}
276
+ iconSize={iconSize}
277
+ enableMotion={options.enableMotion}
278
+ showGitBadges={options.showGitBadges}
279
+ compactFolders={options.compactFolders}
280
+ singleClickAction={options.singleClickAction}
281
+ onToggle={() => {}}
282
+ onSelect={(e) => onSelect(entry.path, e)}
283
+ onDoubleClick={() => onOpenFile(entry.path, relPath)}
284
+ onContextMenu={(e) => onContextMenu(entry, relPath, e)}
285
+ onInsertReference={() => onInsertReference(relPath, false)}
286
+ onCopyRelativePath={() => onCopyRelativePath(relPath)}
287
+ onRenameSubmit={(newName) => onRenameSubmit(entry.path, newName)}
288
+ onDelete={() => onDelete(entry)}
289
+ />
290
+ )
291
+ }
292
+
293
+ const visibleChildren = hasTargetLoaded && targetListing.entries
294
+ ? options.showHiddenFiles
295
+ ? targetListing.entries
296
+ : targetListing.entries.filter((c) => !c.hidden && !c.name.startsWith('.'))
297
+ : []
298
+
299
+ return (
300
+ <div className={css.folderGroup}>
301
+ <ExplorerRow
302
+ entry={entry}
303
+ depth={depth}
304
+ isExpanded={isExpanded}
305
+ isSelected={isSel}
306
+ isFocused={isFoc}
307
+ gitStatus={status}
308
+ relativePath={targetRelPath}
309
+ compactChain={compactChain}
310
+ indentStep={indentStep}
311
+ iconSize={iconSize}
312
+ enableMotion={options.enableMotion}
313
+ showGitBadges={options.showGitBadges}
314
+ compactFolders={options.compactFolders}
315
+ singleClickAction={options.singleClickAction}
316
+ onToggle={handleToggle}
317
+ onSelect={(e) => onSelect(targetPath, e)}
318
+ onContextMenu={(e) => onContextMenu(entry, targetRelPath, e)}
319
+ onInsertReference={() => onInsertReference(targetRelPath, true)}
320
+ onCopyRelativePath={() => onCopyRelativePath(targetRelPath)}
321
+ onRenameSubmit={(newName) => onRenameSubmit(entry.path, newName)}
322
+ onDelete={() => onDelete(entry)}
323
+ />
324
+
325
+ <div
326
+ className={`${css.folderChildren} ${isExpanded ? css.folderChildrenOpen : css.folderChildrenClosed}`}
327
+ style={!options.enableMotion ? { transition: 'none' } : undefined}
328
+ >
329
+ <div className={css.folderChildrenInner}>
330
+ {creating && (creating.parentDir === entry.path || creating.parentDir === targetPath) && (
331
+ <div className={css.row} style={{ paddingLeft: `${(depth + 1) * indentStep + 8}px` }}>
332
+ <span className={css.iconWrap}>{creating.isDir ? '📁' : '📄'}</span>
333
+ <input
334
+ ref={createInputRef}
335
+ autoFocus
336
+ type="text"
337
+ className={css.inlineInput}
338
+ placeholder={creating.isDir ? 'Folder name...' : 'File name...'}
339
+ value={creatingName}
340
+ onChange={(e) => onCreatingNameChange(e.target.value)}
341
+ onKeyDown={onCreatingKeyDown}
342
+ onBlur={onCreatingBlur}
343
+ />
344
+ </div>
345
+ )}
346
+ {visibleChildren.length > 0 ? (
347
+ visibleChildren.map((child) => (
348
+ <TreeNode
349
+ key={child.path}
350
+ entry={child}
351
+ depth={depth + 1}
352
+ parentDir={targetPath}
353
+ rootPath={rootPath}
354
+ levels={levels}
355
+ expanded={expanded}
356
+ selected={selected}
357
+ focusedPath={focusedPath}
358
+ gitStatusMap={gitStatusMap}
359
+ filterText={filterText}
360
+ creating={creating}
361
+ creatingName={creatingName}
362
+ createInputRef={createInputRef}
363
+ options={options}
364
+ indentStep={indentStep}
365
+ iconSize={iconSize}
366
+ reloadDirectory={reloadDirectory}
367
+ onToggle={onToggle}
368
+ onSelect={onSelect}
369
+ onOpenFile={onOpenFile}
370
+ onContextMenu={onContextMenu}
371
+ onInsertReference={onInsertReference}
372
+ onCopyRelativePath={onCopyRelativePath}
373
+ onRenameSubmit={onRenameSubmit}
374
+ onDelete={onDelete}
375
+ onCreatingNameChange={onCreatingNameChange}
376
+ onCreatingKeyDown={onCreatingKeyDown}
377
+ onCreatingBlur={onCreatingBlur}
378
+ getRelativePath={getRelativePath}
379
+ />
380
+ ))
381
+ ) : hasTargetLoaded && targetListing.entries && targetListing.entries.length === 0 ? (
382
+ <div
383
+ className={css.row}
384
+ style={{
385
+ paddingLeft: `${(depth + 1) * indentStep + 8}px`,
386
+ opacity: 0.45,
387
+ fontStyle: 'italic',
388
+ fontSize: '12px',
389
+ }}
390
+ >
391
+ Empty folder
392
+ </div>
393
+ ) : null}
394
+ </div>
395
+ </div>
396
+ </div>
397
+ )
398
+ })
399
+
400
+ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, initialCwd }) => {
401
+ const [rootPath, setRootPath] = useState<string>(initialCwd ?? '')
402
+ const [levels, setLevels] = useState<Record<string, FsListing>>({})
403
+ const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
404
+ const [selected, setSelected] = useState<Set<string>>(() => new Set())
405
+ const [focusedPath, setFocusedPath] = useState<string | null>(null)
406
+ const [filterText, setFilterText] = useState('')
407
+ const [searchOpen, setSearchOpen] = useState(false)
408
+ const [optionsOpen, setOptionsOpen] = useState(false)
409
+ const [options, setOptions] = useState<ExplorerOptions>(() => loadExplorerOptions())
410
+ const [loading, setLoading] = useState(false)
411
+ const [errorMessage, setErrorMessage] = useState<string | null>(null)
412
+ const [gitData, setGitData] = useState<GitStatusResult>({ isRepo: false, entries: [] })
413
+ const [creating, setCreating] = useState<{ parentDir: string; isDir: boolean } | null>(null)
414
+ const [creatingName, setCreatingName] = useState('')
415
+ const [contextMenu, setContextMenu] = useState<{
416
+ x: number
417
+ y: number
418
+ entry: FsEntry
419
+ relativePath: string
420
+ } | null>(null)
421
+
422
+ const treeBodyRef = useRef<HTMLDivElement>(null)
423
+ const createInputRef = useRef<HTMLInputElement>(null)
424
+
425
+ const handleUpdateOptions = useCallback((patch: Partial<ExplorerOptions>) => {
426
+ setOptions((prev) => {
427
+ const next = { ...prev, ...patch }
428
+ saveExplorerOptions(next)
429
+ return next
430
+ })
431
+ }, [])
432
+
433
+ const handleResetOptions = useCallback(() => {
434
+ setOptions({ ...DEFAULT_OPTIONS })
435
+ saveExplorerOptions({ ...DEFAULT_OPTIONS })
436
+ }, [])
437
+
438
+ // Calculate dynamic scale metrics
439
+ const baseRowHeight = options.density === 'compact' ? 20 : options.density === 'relaxed' ? 28 : 24
440
+ const baseFontSize = options.density === 'compact' ? 11.5 : options.density === 'relaxed' ? 14 : 13
441
+ const baseIconSize = options.density === 'compact' ? 14 : options.density === 'relaxed' ? 18 : 16
442
+ const baseIndent = options.density === 'compact' ? 12 : options.density === 'relaxed' ? 20 : 16
443
+
444
+ const rowHeight = Math.round(baseRowHeight * options.scale)
445
+ const fontSize = Math.round(baseFontSize * options.scale)
446
+ const iconSize = Math.round(baseIconSize * options.scale)
447
+ const indentStep = Math.round(baseIndent * options.scale)
448
+
449
+ const containerStyles = useMemo(
450
+ () =>
451
+ ({
452
+ '--dsh-row-height': `${rowHeight}px`,
453
+ '--dsh-font-size': `${fontSize}px`,
454
+ '--dsh-icon-size': `${iconSize}px`,
455
+ '--dsh-scale': `${options.scale}`,
456
+ } as React.CSSProperties),
457
+ [rowHeight, fontSize, iconSize, options.scale]
458
+ )
459
+
460
+ useEffect(() => {
461
+ if (sessionId) {
462
+ setLastKnownSessionId(sessionId)
463
+ }
464
+ }, [sessionId])
465
+
466
+ // Resolve active session root if not provided
467
+ useEffect(() => {
468
+ if (rootPath) return
469
+ const sessions = safeService(ctx, 'sessions')
470
+ const snapshot = sessions?.list?.getSnapshot?.()
471
+ const activeId = sessionId ?? sessions?.active?.id ?? sessions?.current?.id ?? snapshot?.ids?.[0]
472
+
473
+ if (activeId && snapshot?.byId?.[activeId]?.cwd) {
474
+ setRootPath(snapshot.byId[activeId].cwd)
475
+ return
476
+ }
477
+
478
+ if (snapshot?.byId) {
479
+ const first = Object.values(snapshot.byId).find((s: any) => s?.cwd) as any
480
+ if (first?.cwd) {
481
+ setRootPath(first.cwd)
482
+ return
483
+ }
484
+ }
485
+
486
+ const workspaces = safeService(ctx, 'workspaces')
487
+ const ws = workspaces?.list?.getSnapshot?.()
488
+ const item = ws?.items?.find((w: any) => w.sessionIds?.includes(activeId)) ?? ws?.items?.[0]
489
+ if (item?.path) {
490
+ setRootPath(item.path)
491
+ return
492
+ }
493
+
494
+ setLoading(true)
495
+ fetchTree(activeId)
496
+ .then((listing) => {
497
+ const normPath = listing.path.replace(/\\/g, '/')
498
+ setRootPath(listing.path)
499
+ setLevels((prev) => ({
500
+ ...prev,
501
+ [listing.path]: listing,
502
+ [normPath]: listing,
503
+ }))
504
+ setExpanded((prev) => new Set(prev).add(listing.path))
505
+ })
506
+ .catch((err) => {
507
+ console.warn('[dsh-explorer] root fetchTree error:', err)
508
+ setErrorMessage(err?.message || 'Failed to connect to file service')
509
+ })
510
+ .finally(() => {
511
+ setLoading(false)
512
+ })
513
+ }, [rootPath, sessionId, ctx])
514
+
515
+ const reloadDirectory = useCallback(
516
+ async (dir: string) => {
517
+ setLoading(true)
518
+ setErrorMessage(null)
519
+ const normDir = dir.replace(/\\/g, '/')
520
+ try {
521
+ const sessions = safeService(ctx, 'sessions')
522
+ const snapshot = sessions?.list?.getSnapshot?.()
523
+ const activeId = sessionId ?? sessions?.active?.id ?? snapshot?.ids?.[0]
524
+ const listing = await fetchTree(activeId, dir)
525
+
526
+ if (listing) {
527
+ const normListingPath = listing.path.replace(/\\/g, '/')
528
+ setLevels((prev) => ({
529
+ ...prev,
530
+ [dir]: listing,
531
+ [normDir]: listing,
532
+ [listing.path]: listing,
533
+ [normListingPath]: listing,
534
+ }))
535
+ }
536
+ } catch (err: any) {
537
+ console.warn('[dsh-explorer] failed to load dir:', dir, err)
538
+ setErrorMessage(err?.message || 'Failed to load directory')
539
+ } finally {
540
+ setLoading(false)
541
+ }
542
+ },
543
+ [sessionId, ctx]
544
+ )
545
+
546
+ const reloadGit = useCallback(
547
+ async (dir: string) => {
548
+ try {
549
+ const sessions = safeService(ctx, 'sessions')
550
+ const activeId = sessionId ?? sessions?.active?.id
551
+ const git = await fetchGitStatus(activeId, dir)
552
+ setGitData(git)
553
+ } catch {
554
+ // Ignore
555
+ }
556
+ },
557
+ [sessionId, ctx]
558
+ )
559
+
560
+ const expandedRef = useRef(expanded)
561
+ useEffect(() => {
562
+ expandedRef.current = expanded
563
+ }, [expanded])
564
+
565
+ const refreshAll = useCallback(async () => {
566
+ if (!rootPath) return
567
+ const currentExpanded = Array.from(expandedRef.current)
568
+ const dirsToRefresh = Array.from(new Set([rootPath, ...currentExpanded]))
569
+ try {
570
+ const [batch, rootListing] = await Promise.all([
571
+ fetchBatchTree(dirsToRefresh).catch(() => ({} as Record<string, FsListing>)),
572
+ fetchTree(sessionId, rootPath).catch(() => null),
573
+ ])
574
+ const mergedBatch: Record<string, FsListing> = { ...batch }
575
+ if (rootListing) {
576
+ mergedBatch[rootPath] = rootListing
577
+ if (rootListing.path) mergedBatch[rootListing.path] = rootListing
578
+ }
579
+
580
+ setLevels((prev) => {
581
+ const next = { ...prev }
582
+ for (const [dirKey, listing] of Object.entries(mergedBatch)) {
583
+ if (!listing) continue
584
+ const norm = dirKey.replace(/\\/g, '/')
585
+ next[dirKey] = listing
586
+ next[norm] = listing
587
+ if (listing.path) {
588
+ next[listing.path] = listing
589
+ next[listing.path.replace(/\\/g, '/')] = listing
590
+ }
591
+ }
592
+ for (const path of dirsToRefresh) {
593
+ const norm = path.replace(/\\/g, '/')
594
+ if (!mergedBatch[path] && !mergedBatch[norm] && path !== rootPath) {
595
+ delete next[path]
596
+ delete next[norm]
597
+ }
598
+ }
599
+ return next
600
+ })
601
+
602
+ setExpanded((prev) => {
603
+ let changed = false
604
+ const next = new Set(prev)
605
+ for (const expPath of prev) {
606
+ if (expPath === rootPath) continue
607
+ const normExp = expPath.replace(/\\/g, '/')
608
+ if (!mergedBatch[expPath] && !mergedBatch[normExp]) {
609
+ next.delete(expPath)
610
+ changed = true
611
+ }
612
+ }
613
+ return changed ? next : prev
614
+ })
615
+
616
+ reloadGit(rootPath)
617
+ } catch (err) {
618
+ console.warn('[dsh-explorer] refreshAll failed:', err)
619
+ }
620
+ }, [rootPath, sessionId, reloadGit])
621
+
622
+ useEffect(() => {
623
+ if (!rootPath) return
624
+ refreshAll()
625
+ setExpanded((prev) => new Set(prev).add(rootPath))
626
+ }, [rootPath, refreshAll])
627
+
628
+ // Live real-time synchronizer (SSE stream + WebSocket + Window focus + Heartbeat)
629
+ useEffect(() => {
630
+ if (!rootPath) return
631
+
632
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
633
+ const scheduleRefresh = () => {
634
+ if (debounceTimer) clearTimeout(debounceTimer)
635
+ debounceTimer = setTimeout(() => {
636
+ refreshAll()
637
+ }, 100)
638
+ }
639
+
640
+ // 1. Primary: Server-Sent Events (SSE) native stream
641
+ let es: EventSource | null = null
642
+ try {
643
+ const sseUrl = `/dsh-explorer/api/events?dir=${encodeURIComponent(rootPath)}`
644
+ es = new EventSource(sseUrl)
645
+ es.onmessage = (e) => {
646
+ try {
647
+ const data = JSON.parse(e.data)
648
+ if (data.type === 'connected') return
649
+ } catch {}
650
+ scheduleRefresh()
651
+ }
652
+ } catch (err) {
653
+ console.warn('[dsh-explorer] SSE init error:', err)
654
+ }
655
+
656
+ // 2. Secondary: WebSocket fallback / complement
657
+ let ws: WebSocket | null = null
658
+ try {
659
+ const loc = window.location
660
+ const host = loc.host || (loc.port ? `127.0.0.1:${loc.port}` : '127.0.0.1')
661
+ const wsProtocol = loc.protocol === 'https:' ? 'wss:' : 'ws:'
662
+ const wsUrl = `${wsProtocol}//${host}/dsh-explorer/api/watch?dir=${encodeURIComponent(rootPath)}`
663
+ ws = new WebSocket(wsUrl)
664
+ ws.onmessage = () => {
665
+ scheduleRefresh()
666
+ }
667
+ } catch {
668
+ // Ignore ws errors
669
+ }
670
+
671
+ // 3. Window focus and visibility listeners (immediate sync on returning to DSH)
672
+ const onFocus = () => {
673
+ scheduleRefresh()
674
+ }
675
+ const onVisibilityChange = () => {
676
+ if (document.visibilityState === 'visible') {
677
+ scheduleRefresh()
678
+ }
679
+ }
680
+ window.addEventListener('focus', onFocus)
681
+ document.addEventListener('visibilitychange', onVisibilityChange)
682
+
683
+ // 4. Fallback periodic sync (every 3 seconds when document is visible)
684
+ const interval = setInterval(() => {
685
+ if (document.visibilityState === 'visible') {
686
+ scheduleRefresh()
687
+ }
688
+ }, 3000)
689
+
690
+ return () => {
691
+ if (debounceTimer) clearTimeout(debounceTimer)
692
+ clearInterval(interval)
693
+ window.removeEventListener('focus', onFocus)
694
+ document.removeEventListener('visibilitychange', onVisibilityChange)
695
+ if (es) {
696
+ try { es.close() } catch {}
697
+ }
698
+ if (ws) {
699
+ try { ws.close() } catch {}
700
+ }
701
+ }
702
+ }, [rootPath, refreshAll])
703
+
704
+ const toggleExpand = useCallback(
705
+ async (path: string) => {
706
+ setExpanded((prev) => {
707
+ const next = new Set(prev)
708
+ if (next.has(path)) {
709
+ next.delete(path)
710
+ } else {
711
+ next.add(path)
712
+ const norm = path.replace(/\\/g, '/')
713
+ if (!levels[path] && !levels[norm]) {
714
+ reloadDirectory(path)
715
+ }
716
+ }
717
+ return next
718
+ })
719
+ },
720
+ [levels, reloadDirectory]
721
+ )
722
+
723
+ const getRelativePath = useCallback(
724
+ (fullPath: string): string => {
725
+ if (!rootPath) return fullPath
726
+ const normRoot = rootPath.replace(/\\/g, '/').replace(/\/+$/, '')
727
+ const normTarget = fullPath.replace(/\\/g, '/')
728
+ if (normTarget.toLowerCase().startsWith(`${normRoot.toLowerCase()}/`)) {
729
+ return normTarget.slice(normRoot.length + 1)
730
+ }
731
+ return fullPath
732
+ },
733
+ [rootPath]
734
+ )
735
+
736
+ const gitStatusMap = useMemo(() => {
737
+ const map = new Map<string, string>()
738
+ for (const entry of gitData.entries) {
739
+ const p = entry.path.replace(/\\/g, '/').toLowerCase()
740
+ map.set(p, entry.xy)
741
+
742
+ // Bubble up to ancestor folders so parent directories reflect modified/untracked state
743
+ let parent = p
744
+ while (true) {
745
+ const lastSlash = parent.lastIndexOf('/')
746
+ if (lastSlash === -1) break
747
+ parent = parent.slice(0, lastSlash)
748
+ if (!parent) break
749
+ const existing = map.get(parent)
750
+ if (!existing) {
751
+ map.set(parent, entry.xy)
752
+ } else if (entry.xy.includes('M') && !existing.includes('M')) {
753
+ map.set(parent, ' M')
754
+ }
755
+ }
756
+ }
757
+ return map
758
+ }, [gitData])
759
+
760
+ // Flattened array of currently visible rows for keyboard navigation
761
+ const visibleFlatRows = useMemo(() => {
762
+ const normRoot = rootPath.replace(/\\/g, '/')
763
+ const rootListing = levels[rootPath] ?? levels[normRoot]
764
+ if (!rootPath || !rootListing) return []
765
+ const rows: { entry: FsEntry; relativePath: string }[] = []
766
+
767
+ const walk = (dir: string) => {
768
+ const normD = dir.replace(/\\/g, '/')
769
+ const listing = levels[dir] ?? levels[normD]
770
+ if (!listing) return
771
+
772
+ for (const entry of listing.entries) {
773
+ if (!options.showHiddenFiles && (entry.hidden || entry.name.startsWith('.'))) {
774
+ continue
775
+ }
776
+ const rel = getRelativePath(entry.path)
777
+ if (filterText) {
778
+ const match =
779
+ entry.name.toLowerCase().includes(filterText.toLowerCase()) ||
780
+ rel.toLowerCase().includes(filterText.toLowerCase())
781
+ if (!match && !entry.isDir) continue
782
+ }
783
+
784
+ if (options.compactFolders && entry.isDir) {
785
+ const chain = [entry]
786
+ let curr = entry
787
+ let currL = levels[curr.path] ?? levels[curr.path.replace(/\\/g, '/')]
788
+ while (currL && currL.entries) {
789
+ const valid = options.showHiddenFiles
790
+ ? currL.entries
791
+ : currL.entries.filter((c) => !c.hidden && !c.name.startsWith('.'))
792
+ if (valid.length === 1 && valid[0]!.isDir) {
793
+ const next = valid[0]!
794
+ chain.push(next)
795
+ curr = next
796
+ currL = levels[curr.path] ?? levels[curr.path.replace(/\\/g, '/')]
797
+ } else {
798
+ break
799
+ }
800
+ }
801
+ if (chain.length > 1) {
802
+ const compactRel = getRelativePath(curr.path)
803
+ rows.push({ entry: curr, relativePath: compactRel })
804
+ if (expanded.has(entry.path) || expanded.has(curr.path) || Boolean(filterText)) {
805
+ walk(curr.path)
806
+ }
807
+ continue
808
+ }
809
+ }
810
+
811
+ rows.push({ entry, relativePath: rel })
812
+
813
+ if (entry.isDir && (expanded.has(entry.path) || Boolean(filterText))) {
814
+ walk(entry.path)
815
+ }
816
+ }
817
+ }
818
+
819
+ walk(rootPath)
820
+ return rows
821
+ }, [rootPath, levels, expanded, filterText, getRelativePath, options.showHiddenFiles, options.compactFolders])
822
+
823
+ // Keyboard navigation
824
+ const handleKeyDown = (e: React.KeyboardEvent) => {
825
+ if (visibleFlatRows.length === 0) return
826
+ const currentIndex = visibleFlatRows.findIndex((r) => r.entry.path === focusedPath)
827
+
828
+ if (e.key === 'ArrowDown') {
829
+ e.preventDefault()
830
+ const nextIndex = currentIndex < visibleFlatRows.length - 1 ? currentIndex + 1 : 0
831
+ const nextRow = visibleFlatRows[nextIndex]
832
+ if (nextRow) {
833
+ setFocusedPath(nextRow.entry.path)
834
+ setSelected(new Set([nextRow.entry.path]))
835
+ }
836
+ } else if (e.key === 'ArrowUp') {
837
+ e.preventDefault()
838
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : visibleFlatRows.length - 1
839
+ const prevRow = visibleFlatRows[prevIndex]
840
+ if (prevRow) {
841
+ setFocusedPath(prevRow.entry.path)
842
+ setSelected(new Set([prevRow.entry.path]))
843
+ }
844
+ } else if (e.key === 'ArrowRight') {
845
+ const currentRow = visibleFlatRows[currentIndex]
846
+ if (currentRow?.entry.isDir && !expanded.has(currentRow.entry.path)) {
847
+ e.preventDefault()
848
+ toggleExpand(currentRow.entry.path)
849
+ }
850
+ } else if (e.key === 'ArrowLeft') {
851
+ const currentRow = visibleFlatRows[currentIndex]
852
+ if (currentRow?.entry.isDir && expanded.has(currentRow.entry.path)) {
853
+ e.preventDefault()
854
+ toggleExpand(currentRow.entry.path)
855
+ }
856
+ } else if (e.key === 'Enter') {
857
+ const currentRow = visibleFlatRows[currentIndex]
858
+ if (currentRow) {
859
+ e.preventDefault()
860
+ if (currentRow.entry.isDir) {
861
+ toggleExpand(currentRow.entry.path)
862
+ } else {
863
+ const activeId = sessionId ?? safeService(ctx, 'sessions')?.active?.id ?? ''
864
+ insertMentionToComposer(ctx, activeId, currentRow.relativePath, false)
865
+ }
866
+ }
867
+ }
868
+ }
869
+
870
+ const handleRowSelect = (entryPath: string, e: React.MouseEvent) => {
871
+ setFocusedPath(entryPath)
872
+ if (e.ctrlKey || e.metaKey) {
873
+ setSelected((prev) => {
874
+ const next = new Set(prev)
875
+ if (next.has(entryPath)) next.delete(entryPath)
876
+ else next.add(entryPath)
877
+ return next
878
+ })
879
+ } else {
880
+ setSelected(new Set([entryPath]))
881
+ }
882
+ }
883
+
884
+ const handleCreateSubmit = async () => {
885
+ if (!creating || !creatingName.trim()) {
886
+ setCreating(null)
887
+ return
888
+ }
889
+ const name = creatingName.trim()
890
+ const parent = creating.parentDir
891
+ try {
892
+ if (creating.isDir) {
893
+ await apiCreateDirectory(parent, name)
894
+ } else {
895
+ await apiCreateNewFile(parent, name)
896
+ }
897
+ refreshAll()
898
+ setExpanded((prev) => new Set(prev).add(parent))
899
+ } catch (err: any) {
900
+ alert(err.message || 'Operation failed')
901
+ } finally {
902
+ setCreating(null)
903
+ setCreatingName('')
904
+ }
905
+ }
906
+
907
+ const handleRename = async (entryPath: string, newName: string) => {
908
+ try {
909
+ await apiRename(entryPath, newName)
910
+ refreshAll()
911
+ } catch (err: any) {
912
+ alert(err.message || 'Rename failed')
913
+ }
914
+ }
915
+
916
+ const handleDelete = async (entry: FsEntry) => {
917
+ if (!confirm(`Are you sure you want to delete "${entry.name}"?`)) return
918
+ try {
919
+ await apiDelete(entry.path)
920
+ refreshAll()
921
+ } catch (err: any) {
922
+ alert(err.message || 'Delete failed')
923
+ }
924
+ }
925
+
926
+ const handleExplorerDrop = async (e: React.DragEvent) => {
927
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
928
+ e.preventDefault()
929
+ e.stopPropagation()
930
+ const targetDir = rootPath
931
+ for (let i = 0; i < e.dataTransfer.files.length; i++) {
932
+ const file = e.dataTransfer.files[i]!
933
+ const uploadUrl = `/dsh-explorer/api/upload?dir=${encodeURIComponent(targetDir)}&relativePath=${encodeURIComponent(file.name)}`
934
+ await fetch(uploadUrl, { method: 'POST', body: file })
935
+ }
936
+ refreshAll()
937
+ }
938
+ }
939
+
940
+ const handleOpenFile = useCallback(
941
+ (filePath: string, relPath: string) => {
942
+ openFileInTab(ctx, sessionId ?? '', filePath, relPath)
943
+ },
944
+ [ctx, sessionId]
945
+ )
946
+
947
+ const title = rootPath ? baseName(rootPath) : 'EXPLORER'
948
+ const rootListing = levels[rootPath] ?? levels[rootPath.replace(/\\/g, '/')]
949
+
950
+ return (
951
+ <div
952
+ className={`${css.container} ${!options.enableMotion ? css.noMotion : ''}`}
953
+ style={containerStyles}
954
+ >
955
+ <ExplorerHeader
956
+ title={`EXPLORER: ${title}`}
957
+ searchOpen={searchOpen}
958
+ optionsOpen={optionsOpen}
959
+ onToggleSearch={() => setSearchOpen(!searchOpen)}
960
+ onToggleOptions={() => setOptionsOpen(!optionsOpen)}
961
+ onNewFile={() => {
962
+ setCreating({ parentDir: rootPath, isDir: false })
963
+ setCreatingName('')
964
+ }}
965
+ onNewFolder={() => {
966
+ setCreating({ parentDir: rootPath, isDir: true })
967
+ setCreatingName('')
968
+ }}
969
+ onRefresh={() => {
970
+ refreshAll()
971
+ }}
972
+ onCollapseAll={() => {
973
+ setExpanded(new Set())
974
+ }}
975
+ />
976
+
977
+ {optionsOpen && (
978
+ <ExplorerOptionsModal
979
+ options={options}
980
+ onChange={handleUpdateOptions}
981
+ onReset={handleResetOptions}
982
+ onClose={() => setOptionsOpen(false)}
983
+ />
984
+ )}
985
+
986
+ {searchOpen && (
987
+ <ExplorerSearch
988
+ value={filterText}
989
+ onChange={setFilterText}
990
+ onClose={() => {
991
+ setFilterText('')
992
+ setSearchOpen(false)
993
+ }}
994
+ />
995
+ )}
996
+
997
+ <div
998
+ ref={treeBodyRef}
999
+ className={css.treeBody}
1000
+ tabIndex={0}
1001
+ onKeyDown={handleKeyDown}
1002
+ onDragOver={(e) => {
1003
+ e.preventDefault()
1004
+ e.dataTransfer.dropEffect = 'copy'
1005
+ }}
1006
+ onDrop={handleExplorerDrop}
1007
+ >
1008
+ {/* Root inline creation */}
1009
+ {creating && creating.parentDir === rootPath && (
1010
+ <div className={css.row} style={{ paddingLeft: `${indentStep + 8}px` }}>
1011
+ <span className={css.iconWrap}>{creating.isDir ? '📁' : '📄'}</span>
1012
+ <input
1013
+ ref={createInputRef}
1014
+ autoFocus
1015
+ type="text"
1016
+ className={css.inlineInput}
1017
+ placeholder={creating.isDir ? 'Folder name...' : 'File name...'}
1018
+ value={creatingName}
1019
+ onChange={(e) => setCreatingName(e.target.value)}
1020
+ onKeyDown={(e) => {
1021
+ if (e.key === 'Enter') handleCreateSubmit()
1022
+ else if (e.key === 'Escape') setCreating(null)
1023
+ }}
1024
+ onBlur={handleCreateSubmit}
1025
+ />
1026
+ </div>
1027
+ )}
1028
+
1029
+ {/* Tree Nodes with Motion Collapse */}
1030
+ {rootListing?.entries.map((entry) => (
1031
+ <TreeNode
1032
+ key={entry.path}
1033
+ entry={entry}
1034
+ depth={0}
1035
+ parentDir={rootPath}
1036
+ rootPath={rootPath}
1037
+ levels={levels}
1038
+ expanded={expanded}
1039
+ selected={selected}
1040
+ focusedPath={focusedPath}
1041
+ gitStatusMap={gitStatusMap}
1042
+ filterText={filterText}
1043
+ creating={creating}
1044
+ creatingName={creatingName}
1045
+ createInputRef={createInputRef}
1046
+ options={options}
1047
+ indentStep={indentStep}
1048
+ iconSize={iconSize}
1049
+ reloadDirectory={reloadDirectory}
1050
+ onToggle={toggleExpand}
1051
+ onSelect={handleRowSelect}
1052
+ onOpenFile={handleOpenFile}
1053
+ onContextMenu={(ent, rel, e) => {
1054
+ e.preventDefault()
1055
+ setContextMenu({
1056
+ x: e.clientX,
1057
+ y: e.clientY,
1058
+ entry: ent,
1059
+ relativePath: rel,
1060
+ })
1061
+ }}
1062
+ onInsertReference={(rel, isDir) => {
1063
+ const activeId = sessionId ?? safeService(ctx, 'sessions')?.active?.id ?? ''
1064
+ insertMentionToComposer(ctx, activeId, rel, isDir)
1065
+ }}
1066
+ onCopyRelativePath={(rel) => {
1067
+ navigator.clipboard.writeText(rel)
1068
+ }}
1069
+ onRenameSubmit={(path, newName) => handleRename(path, newName)}
1070
+ onDelete={handleDelete}
1071
+ onCreatingNameChange={setCreatingName}
1072
+ onCreatingKeyDown={(e) => {
1073
+ if (e.key === 'Enter') handleCreateSubmit()
1074
+ else if (e.key === 'Escape') setCreating(null)
1075
+ }}
1076
+ onCreatingBlur={handleCreateSubmit}
1077
+ getRelativePath={getRelativePath}
1078
+ />
1079
+ ))}
1080
+
1081
+ {visibleFlatRows.length === 0 && !creating && (
1082
+ <div className={css.emptyState}>
1083
+ {loading
1084
+ ? 'Loading workspace files...'
1085
+ : errorMessage
1086
+ ? `Error: ${errorMessage}`
1087
+ : filterText
1088
+ ? 'No matching files found'
1089
+ : !options.showHiddenFiles
1090
+ ? 'Only hidden files present (enable in options)'
1091
+ : 'Empty directory'}
1092
+ </div>
1093
+ )}
1094
+ </div>
1095
+
1096
+ {contextMenu && (
1097
+ <ExplorerContextMenu
1098
+ x={contextMenu.x}
1099
+ y={contextMenu.y}
1100
+ entry={contextMenu.entry}
1101
+ relativePath={contextMenu.relativePath}
1102
+ onClose={() => setContextMenu(null)}
1103
+ onOpenFile={() => handleOpenFile(contextMenu.entry.path, contextMenu.relativePath)}
1104
+ onInsertReference={() => {
1105
+ const activeId = sessionId ?? safeService(ctx, 'sessions')?.active?.id ?? ''
1106
+ insertMentionToComposer(ctx, activeId, contextMenu.relativePath, contextMenu.entry.isDir)
1107
+ }}
1108
+ onInsertSnippet={async () => {
1109
+ const activeId = sessionId ?? safeService(ctx, 'sessions')?.active?.id ?? ''
1110
+ try {
1111
+ const content = await fetchFileContent(contextMenu.entry.path)
1112
+ insertSnippetToComposer(ctx, activeId, contextMenu.relativePath, content)
1113
+ } catch (err: any) {
1114
+ alert(err.message || 'Failed to read file')
1115
+ }
1116
+ }}
1117
+ onCopyPath={() => {
1118
+ navigator.clipboard.writeText(contextMenu.entry.path)
1119
+ }}
1120
+ onCopyRelativePath={() => {
1121
+ navigator.clipboard.writeText(contextMenu.relativePath)
1122
+ }}
1123
+ onNewFile={() => {
1124
+ setCreating({ parentDir: contextMenu.entry.path, isDir: false })
1125
+ setCreatingName('')
1126
+ }}
1127
+ onNewFolder={() => {
1128
+ setCreating({ parentDir: contextMenu.entry.path, isDir: true })
1129
+ setCreatingName('')
1130
+ }}
1131
+ onRename={() => {
1132
+ setFocusedPath(contextMenu.entry.path)
1133
+ }}
1134
+ onDelete={() => handleDelete(contextMenu.entry)}
1135
+ />
1136
+ )}
1137
+ </div>
1138
+ )
1139
+ }