dsh-explorer 0.1.0 → 0.1.1

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.
@@ -4,6 +4,7 @@ import {
4
4
  fetchTree,
5
5
  fetchBatchTree,
6
6
  fetchGitStatus,
7
+ fetchSearchWorkspace,
7
8
  apiCreateNewFile,
8
9
  apiCreateDirectory,
9
10
  apiRename,
@@ -12,24 +13,31 @@ import {
12
13
  type FsEntry,
13
14
  type FsListing,
14
15
  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'
16
+ } from './utils/api.ts'
17
+ import { ExplorerHeader } from './components/ExplorerHeader.tsx'
18
+ import { ExplorerSearch } from './components/ExplorerSearch.tsx'
19
+ import { ExplorerContextMenu } from './components/ExplorerContextMenu.tsx'
20
+ import { ExplorerEmptyContextMenu } from './components/ExplorerEmptyContextMenu.tsx'
21
+ import { ExplorerOptionsModal } from './components/ExplorerOptionsModal.tsx'
22
+ import { ExplorerDeleteModal } from './components/ExplorerDeleteModal.tsx'
23
+ import { TreeNode } from './components/TreeNode.tsx'
24
+ import { useLiveSync } from './hooks/useLiveSync.ts'
25
+ import { useKeyboardNav } from './hooks/useKeyboardNav.ts'
21
26
  import {
22
27
  loadExplorerOptions,
23
28
  saveExplorerOptions,
24
29
  DEFAULT_OPTIONS,
25
30
  type ExplorerOptions,
26
- } from './options.ts'
31
+ } from './utils/options.ts'
27
32
  import {
28
33
  insertMentionToComposer,
29
34
  insertSnippetToComposer,
30
35
  setLastKnownSessionId,
31
36
  baseName,
32
- } from './conversation-draft.ts'
37
+ } from './utils/conversation-draft.ts'
38
+ import { openFileInTab, safeService } from './utils/open-tab.ts'
39
+
40
+ export { openFileInTab }
33
41
 
34
42
  interface ExplorerViewProps {
35
43
  ctx: any
@@ -37,373 +45,19 @@ interface ExplorerViewProps {
37
45
  initialCwd?: string
38
46
  }
39
47
 
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
48
  export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, initialCwd }) => {
401
49
  const [rootPath, setRootPath] = useState<string>(initialCwd ?? '')
402
50
  const [levels, setLevels] = useState<Record<string, FsListing>>({})
403
51
  const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
404
52
  const [selected, setSelected] = useState<Set<string>>(() => new Set())
405
53
  const [focusedPath, setFocusedPath] = useState<string | null>(null)
54
+ const [searchInput, setSearchInput] = useState('')
406
55
  const [filterText, setFilterText] = useState('')
56
+ const [searchLoading, setSearchLoading] = useState(false)
57
+ const [matchCount, setMatchCount] = useState<number | null>(null)
58
+ const [matchingPaths, setMatchingPaths] = useState<Set<string> | null>(null)
59
+ const [ancestorPaths, setAncestorPaths] = useState<Set<string> | null>(null)
60
+ const savedExpandedRef = useRef<Set<string> | null>(null)
407
61
  const [searchOpen, setSearchOpen] = useState(false)
408
62
  const [optionsOpen, setOptionsOpen] = useState(false)
409
63
  const [options, setOptions] = useState<ExplorerOptions>(() => loadExplorerOptions())
@@ -412,12 +66,18 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
412
66
  const [gitData, setGitData] = useState<GitStatusResult>({ isRepo: false, entries: [] })
413
67
  const [creating, setCreating] = useState<{ parentDir: string; isDir: boolean } | null>(null)
414
68
  const [creatingName, setCreatingName] = useState('')
69
+ const [renamingPath, setRenamingPath] = useState<string | null>(null)
70
+ const [deletingTarget, setDeletingTarget] = useState<{
71
+ entry: FsEntry
72
+ relativePath: string
73
+ } | null>(null)
415
74
  const [contextMenu, setContextMenu] = useState<{
416
75
  x: number
417
76
  y: number
418
77
  entry: FsEntry
419
78
  relativePath: string
420
79
  } | null>(null)
80
+ const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null)
421
81
 
422
82
  const treeBodyRef = useRef<HTMLDivElement>(null)
423
83
  const createInputRef = useRef<HTMLInputElement>(null)
@@ -435,7 +95,6 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
435
95
  saveExplorerOptions({ ...DEFAULT_OPTIONS })
436
96
  }, [])
437
97
 
438
- // Calculate dynamic scale metrics
439
98
  const baseRowHeight = options.density === 'compact' ? 20 : options.density === 'relaxed' ? 28 : 24
440
99
  const baseFontSize = options.density === 'compact' ? 11.5 : options.density === 'relaxed' ? 14 : 13
441
100
  const baseIconSize = options.density === 'compact' ? 14 : options.density === 'relaxed' ? 18 : 16
@@ -452,148 +111,99 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
452
111
  '--dsh-row-height': `${rowHeight}px`,
453
112
  '--dsh-font-size': `${fontSize}px`,
454
113
  '--dsh-icon-size': `${iconSize}px`,
455
- '--dsh-scale': `${options.scale}`,
456
- } as React.CSSProperties),
457
- [rowHeight, fontSize, iconSize, options.scale]
114
+ '--dsh-indent-step': `${indentStep}px`,
115
+ }) as React.CSSProperties,
116
+ [rowHeight, fontSize, iconSize, indentStep]
458
117
  )
459
118
 
460
119
  useEffect(() => {
461
- if (sessionId) {
462
- setLastKnownSessionId(sessionId)
463
- }
120
+ if (sessionId) setLastKnownSessionId(sessionId)
464
121
  }, [sessionId])
465
122
 
466
- // Resolve active session root if not provided
467
123
  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)
124
+ if (initialCwd && !rootPath) {
125
+ setRootPath(initialCwd)
475
126
  return
476
127
  }
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
128
+ if (!rootPath) {
129
+ let isMounted = true
130
+ fetchTree(sessionId)
131
+ .then((listing) => {
132
+ if (!isMounted) return
133
+ if (listing && listing.path) {
134
+ setRootPath(listing.path)
135
+ setLevels({ [listing.path]: listing })
136
+ }
137
+ })
138
+ .catch((err) => {
139
+ if (!isMounted) return
140
+ setErrorMessage(err?.message || 'Failed to connect to file service')
141
+ })
142
+ return () => {
143
+ isMounted = false
483
144
  }
484
145
  }
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])
146
+ }, [sessionId, initialCwd, rootPath])
514
147
 
515
148
  const reloadDirectory = useCallback(
516
- async (dir: string) => {
517
- setLoading(true)
518
- setErrorMessage(null)
519
- const normDir = dir.replace(/\\/g, '/')
149
+ async (dir: string): Promise<void> => {
520
150
  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) => ({
151
+ setErrorMessage(null)
152
+ const listing = await fetchTree(sessionId, dir)
153
+ setLevels((prev) => {
154
+ const norm = dir.replace(/\\/g, '/')
155
+ const back = dir.replace(/\//g, '\\')
156
+ return {
529
157
  ...prev,
530
158
  [dir]: listing,
531
- [normDir]: listing,
532
- [listing.path]: listing,
533
- [normListingPath]: listing,
534
- }))
535
- }
159
+ [norm]: listing,
160
+ [back]: listing,
161
+ }
162
+ })
536
163
  } catch (err: any) {
537
- console.warn('[dsh-explorer] failed to load dir:', dir, err)
538
164
  setErrorMessage(err?.message || 'Failed to load directory')
539
- } finally {
540
- setLoading(false)
541
165
  }
542
166
  },
543
- [sessionId, ctx]
167
+ [sessionId]
544
168
  )
545
169
 
546
170
  const reloadGit = useCallback(
547
- async (dir: string) => {
171
+ async (dir?: string) => {
172
+ const target = dir || rootPath
173
+ if (!target && !sessionId) return
548
174
  try {
549
- const sessions = safeService(ctx, 'sessions')
550
- const activeId = sessionId ?? sessions?.active?.id
551
- const git = await fetchGitStatus(activeId, dir)
552
- setGitData(git)
175
+ const res = await fetchGitStatus(target, sessionId)
176
+ setGitData(res)
553
177
  } catch {
554
- // Ignore
178
+ setGitData({ isRepo: false, entries: [] })
555
179
  }
556
180
  },
557
- [sessionId, ctx]
181
+ [rootPath, sessionId]
558
182
  )
559
183
 
560
- const expandedRef = useRef(expanded)
561
- useEffect(() => {
562
- expandedRef.current = expanded
563
- }, [expanded])
564
-
565
184
  const refreshAll = useCallback(async () => {
566
185
  if (!rootPath) return
567
- const currentExpanded = Array.from(expandedRef.current)
568
- const dirsToRefresh = Array.from(new Set([rootPath, ...currentExpanded]))
186
+ reloadGit(rootPath)
569
187
  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
188
+ const currentExpanded = Array.from(expanded)
189
+ const dirsToFetch = Array.from(new Set([rootPath, ...currentExpanded]))
190
+ const batchResult = await fetchBatchTree(dirsToFetch)
191
+
192
+ const mergedBatch: Record<string, FsListing> = {}
193
+ for (const [k, v] of Object.entries(batchResult)) {
194
+ mergedBatch[k] = v
195
+ mergedBatch[k.replace(/\\/g, '/')] = v
578
196
  }
579
197
 
198
+ const normRoot = rootPath.replace(/\\/g, '/').toLowerCase()
199
+
580
200
  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]
201
+ const next: Record<string, FsListing> = { ...prev, ...mergedBatch }
202
+ for (const key of Object.keys(prev)) {
203
+ const normKey = key.replace(/\\/g, '/').toLowerCase()
204
+ if (normKey === normRoot) continue
205
+ if (!mergedBatch[key] && !mergedBatch[key.replace(/\\/g, '/')] && !mergedBatch[key.replace(/\//g, '\\')]) {
206
+ delete next[key]
597
207
  }
598
208
  }
599
209
  return next
@@ -603,103 +213,30 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
603
213
  let changed = false
604
214
  const next = new Set(prev)
605
215
  for (const expPath of prev) {
606
- if (expPath === rootPath) continue
607
- const normExp = expPath.replace(/\\/g, '/')
608
- if (!mergedBatch[expPath] && !mergedBatch[normExp]) {
216
+ const normExp = expPath.replace(/\\/g, '/').toLowerCase()
217
+ if (normExp === normRoot) continue
218
+ if (!mergedBatch[expPath] && !mergedBatch[expPath.replace(/\\/g, '/')] && !mergedBatch[expPath.replace(/\//g, '\\')]) {
609
219
  next.delete(expPath)
610
220
  changed = true
611
221
  }
612
222
  }
613
223
  return changed ? next : prev
614
224
  })
615
-
616
- reloadGit(rootPath)
617
225
  } catch (err) {
618
226
  console.warn('[dsh-explorer] refreshAll failed:', err)
619
227
  }
620
- }, [rootPath, sessionId, reloadGit])
228
+ }, [rootPath, expanded, reloadGit])
621
229
 
622
230
  useEffect(() => {
623
231
  if (!rootPath) return
624
232
  refreshAll()
625
233
  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
- }
234
+ }, [rootPath])
639
235
 
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])
236
+ useLiveSync({
237
+ rootPath,
238
+ onRefresh: refreshAll,
239
+ })
703
240
 
704
241
  const toggleExpand = useCallback(
705
242
  async (path: string) => {
@@ -733,13 +270,226 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
733
270
  [rootPath]
734
271
  )
735
272
 
273
+ const levelsRef = useRef(levels)
274
+ useEffect(() => {
275
+ levelsRef.current = levels
276
+ }, [levels])
277
+
278
+ useEffect(() => {
279
+ const timer = setTimeout(() => {
280
+ setFilterText(searchInput)
281
+ }, 200)
282
+ return () => clearTimeout(timer)
283
+ }, [searchInput])
284
+
285
+ useEffect(() => {
286
+ const query = filterText.trim()
287
+ if (!query || !rootPath) {
288
+ setMatchingPaths(null)
289
+ setAncestorPaths(null)
290
+ setMatchCount(null)
291
+ setSearchLoading(false)
292
+ if (savedExpandedRef.current) {
293
+ setExpanded(savedExpandedRef.current)
294
+ savedExpandedRef.current = null
295
+ }
296
+ return
297
+ }
298
+
299
+ if (!savedExpandedRef.current) {
300
+ savedExpandedRef.current = new Set(expanded)
301
+ }
302
+
303
+ let isCancelled = false
304
+ const abortCtrl = new AbortController()
305
+
306
+ setSearchLoading(true)
307
+
308
+ const runSearch = async () => {
309
+ try {
310
+ const backendResult = await fetchSearchWorkspace(rootPath, query, sessionId, abortCtrl.signal)
311
+ if (isCancelled) return
312
+
313
+ const currentLevels = levelsRef.current
314
+ const matchSet = new Set<string>()
315
+ const ancSet = new Set<string>()
316
+ const missingDirs = new Set<string>()
317
+
318
+ const normRoot = rootPath.replace(/\\/g, '/').replace(/\/+$/, '')
319
+ const nativeRoot = rootPath.replace(/\//g, '\\').replace(/\\+$/, '')
320
+
321
+ for (const m of backendResult.matches) {
322
+ const normP = m.path.replace(/\\/g, '/').toLowerCase()
323
+ const nativeP = m.path.replace(/\//g, '\\').toLowerCase()
324
+ const normRel = m.relativePath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').toLowerCase()
325
+ const nativeRel = m.relativePath.replace(/\//g, '\\').toLowerCase()
326
+
327
+ matchSet.add(normP)
328
+ matchSet.add(nativeP)
329
+ matchSet.add(normRel)
330
+ matchSet.add(nativeRel)
331
+ matchSet.add(m.path)
332
+ matchSet.add(m.path.toLowerCase())
333
+ matchSet.add(m.relativePath)
334
+ matchSet.add(m.relativePath.toLowerCase())
335
+
336
+ if (m.isDir) {
337
+ const dirNorm = m.path.replace(/\\/g, '/')
338
+ const dirNative = m.path.replace(/\//g, '\\')
339
+ if (!currentLevels[m.path] && !currentLevels[dirNorm] && !currentLevels[dirNative]) {
340
+ missingDirs.add(dirNative)
341
+ }
342
+ }
343
+
344
+ const segments = normRel.split('/')
345
+ const depth = segments.length - 1
346
+ let currentRel = ''
347
+ for (let i = 0; i < depth; i++) {
348
+ currentRel = currentRel ? `${currentRel}/${segments[i]}` : segments[i]!
349
+ ancSet.add(currentRel.toLowerCase())
350
+
351
+ const fullDirNorm = `${normRoot}/${currentRel}`
352
+ const fullDirNative = `${nativeRoot}\\${currentRel.replace(/\//g, '\\')}`
353
+
354
+ ancSet.add(fullDirNorm.toLowerCase())
355
+ ancSet.add(fullDirNative.toLowerCase())
356
+ ancSet.add(fullDirNorm)
357
+ ancSet.add(fullDirNative)
358
+
359
+ if (!currentLevels[fullDirNorm] && !currentLevels[fullDirNative] && !currentLevels[fullDirNorm.toLowerCase()] && !currentLevels[fullDirNative.toLowerCase()]) {
360
+ missingDirs.add(fullDirNative)
361
+ }
362
+ }
363
+ }
364
+
365
+ for (const [, listing] of Object.entries(currentLevels)) {
366
+ if (!listing?.entries) continue
367
+ for (const ent of listing.entries) {
368
+ const entRel = getRelativePath(ent.path)
369
+ const normP = ent.path.replace(/\\/g, '/').toLowerCase()
370
+ const nativeP = ent.path.replace(/\//g, '\\').toLowerCase()
371
+ const normRel = entRel.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').toLowerCase()
372
+ const nativeRel = entRel.replace(/\//g, '\\').toLowerCase()
373
+ if (ent.name.toLowerCase().includes(query.toLowerCase()) || normRel.includes(query.toLowerCase())) {
374
+ matchSet.add(normP)
375
+ matchSet.add(nativeP)
376
+ matchSet.add(normRel)
377
+ matchSet.add(nativeRel)
378
+ matchSet.add(ent.path)
379
+ matchSet.add(ent.path.toLowerCase())
380
+ matchSet.add(entRel)
381
+ matchSet.add(entRel.toLowerCase())
382
+
383
+ const segs = normRel.split('/')
384
+ const d = segs.length - 1
385
+ let cur = ''
386
+ for (let i = 0; i < d; i++) {
387
+ cur = cur ? `${cur}/${segs[i]}` : segs[i]!
388
+ ancSet.add(cur.toLowerCase())
389
+ const fullCurNorm = `${normRoot}/${cur}`
390
+ const fullCurNative = `${nativeRoot}\\${cur.replace(/\//g, '\\')}`
391
+ ancSet.add(fullCurNorm.toLowerCase())
392
+ ancSet.add(fullCurNative.toLowerCase())
393
+ ancSet.add(fullCurNorm)
394
+ ancSet.add(fullCurNative)
395
+ if (!currentLevels[fullCurNorm] && !currentLevels[fullCurNative]) {
396
+ missingDirs.add(fullCurNative)
397
+ }
398
+ }
399
+ }
400
+ }
401
+ }
402
+
403
+ if (missingDirs.size > 0 && !isCancelled) {
404
+ const dirsList = Array.from(new Set(Array.from(missingDirs).map((d) => d.replace(/\//g, '\\'))))
405
+ let fetchedMap: Record<string, FsListing> = {}
406
+
407
+ try {
408
+ const batch = await fetchBatchTree(dirsList.slice(0, 100))
409
+ if (batch && typeof batch === 'object') {
410
+ fetchedMap = batch
411
+ }
412
+ } catch {
413
+ fetchedMap = {}
414
+ }
415
+
416
+ const unreturned = dirsList.filter((d) => !fetchedMap[d] && !fetchedMap[d.replace(/\\/g, '/')] && !fetchedMap[d.replace(/\//g, '\\')])
417
+ if (unreturned.length > 0 && !isCancelled) {
418
+ const fallbacks = await Promise.all(
419
+ unreturned.slice(0, 60).map(async (dir) => {
420
+ try {
421
+ const listing = await fetchTree(sessionId, dir)
422
+ return { dir, listing }
423
+ } catch {
424
+ return null
425
+ }
426
+ })
427
+ )
428
+ for (const r of fallbacks) {
429
+ if (r && r.listing) {
430
+ fetchedMap[r.dir] = r.listing
431
+ }
432
+ }
433
+ }
434
+
435
+ if (!isCancelled && Object.keys(fetchedMap).length > 0) {
436
+ setLevels((prev) => {
437
+ const next = { ...prev }
438
+ for (const [k, v] of Object.entries(fetchedMap)) {
439
+ if (!v) continue
440
+ const norm = k.replace(/\\/g, '/')
441
+ const back = k.replace(/\//g, '\\')
442
+ next[k] = v
443
+ next[norm] = v
444
+ next[back] = v
445
+ next[norm.toLowerCase()] = v
446
+ next[back.toLowerCase()] = v
447
+ }
448
+ return next
449
+ })
450
+ }
451
+ }
452
+
453
+ if (!isCancelled) {
454
+ setMatchingPaths(matchSet)
455
+ setAncestorPaths(ancSet)
456
+ setMatchCount(backendResult.matches.length)
457
+ setSearchLoading(false)
458
+
459
+ setExpanded((prev) => {
460
+ const next = new Set(prev)
461
+ for (const anc of ancSet) {
462
+ next.add(anc)
463
+ }
464
+ return next
465
+ })
466
+ }
467
+ } catch (err: any) {
468
+ if (!isCancelled && err?.name !== 'AbortError') {
469
+ setSearchLoading(false)
470
+ }
471
+ }
472
+ }
473
+
474
+ runSearch()
475
+
476
+ return () => {
477
+ isCancelled = true
478
+ abortCtrl.abort()
479
+ }
480
+ }, [filterText, rootPath, sessionId, getRelativePath])
481
+
736
482
  const gitStatusMap = useMemo(() => {
737
483
  const map = new Map<string, string>()
738
- for (const entry of gitData.entries) {
739
- const p = entry.path.replace(/\\/g, '/').toLowerCase()
484
+ const normRoot = rootPath ? rootPath.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase() : ''
485
+ for (const entry of gitData?.entries ?? []) {
486
+ if (!entry || !entry.path) continue
487
+ const p = entry.path.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').toLowerCase()
740
488
  map.set(p, entry.xy)
489
+ if (normRoot) {
490
+ map.set(`${normRoot}/${p}`, entry.xy)
491
+ }
741
492
 
742
- // Bubble up to ancestor folders so parent directories reflect modified/untracked state
743
493
  let parent = p
744
494
  while (true) {
745
495
  const lastSlash = parent.lastIndexOf('/')
@@ -749,51 +499,78 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
749
499
  const existing = map.get(parent)
750
500
  if (!existing) {
751
501
  map.set(parent, entry.xy)
502
+ if (normRoot) map.set(`${normRoot}/${parent}`, entry.xy)
752
503
  } else if (entry.xy.includes('M') && !existing.includes('M')) {
753
504
  map.set(parent, ' M')
505
+ if (normRoot) map.set(`${normRoot}/${parent}`, ' M')
754
506
  }
755
507
  }
756
508
  }
757
509
  return map
758
- }, [gitData])
510
+ }, [gitData, rootPath])
759
511
 
760
- // Flattened array of currently visible rows for keyboard navigation
761
512
  const visibleFlatRows = useMemo(() => {
762
- const normRoot = rootPath.replace(/\\/g, '/')
763
- const rootListing = levels[rootPath] ?? levels[normRoot]
764
- if (!rootPath || !rootListing) return []
513
+ if (!rootPath) return []
514
+ const normRootLower = rootPath.replace(/\\/g, '/').toLowerCase()
515
+ const rootListing =
516
+ levels[rootPath] ??
517
+ levels[rootPath.replace(/\\/g, '/')] ??
518
+ levels[rootPath.replace(/\//g, '\\')] ??
519
+ Object.entries(levels).find(([k]) => k.replace(/\\/g, '/').toLowerCase() === normRootLower)?.[1]
520
+ if (!rootListing || !Array.isArray(rootListing.entries)) return []
765
521
  const rows: { entry: FsEntry; relativePath: string }[] = []
766
522
 
523
+ const isSearchActive = Boolean(filterText && matchingPaths)
524
+
767
525
  const walk = (dir: string) => {
768
- const normD = dir.replace(/\\/g, '/')
769
- const listing = levels[dir] ?? levels[normD]
770
- if (!listing) return
526
+ const normD = dir.replace(/\\/g, '/').toLowerCase()
527
+ const listing =
528
+ levels[dir] ??
529
+ levels[dir.replace(/\\/g, '/')] ??
530
+ levels[dir.replace(/\//g, '\\')] ??
531
+ Object.entries(levels).find(([k]) => k.replace(/\\/g, '/').toLowerCase() === normD)?.[1]
532
+ if (!listing || !Array.isArray(listing.entries)) return
771
533
 
772
534
  for (const entry of listing.entries) {
535
+ if (!entry) continue
773
536
  if (!options.showHiddenFiles && (entry.hidden || entry.name.startsWith('.'))) {
774
537
  continue
775
538
  }
776
539
  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
540
+ const normRel = rel.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').toLowerCase()
541
+ const normEntry = entry.path.replace(/\\/g, '/').toLowerCase()
542
+
543
+ if (isSearchActive && matchingPaths && ancestorPaths) {
544
+ const isDirect =
545
+ matchingPaths.has(normRel) ||
546
+ matchingPaths.has(normEntry) ||
547
+ matchingPaths.has(entry.path) ||
548
+ matchingPaths.has(entry.path.toLowerCase())
549
+ if (entry.isDir) {
550
+ const isAnc =
551
+ ancestorPaths.has(normRel) ||
552
+ ancestorPaths.has(normEntry) ||
553
+ ancestorPaths.has(entry.path) ||
554
+ ancestorPaths.has(entry.path.toLowerCase())
555
+ if (!isDirect && !isAnc) continue
556
+ } else {
557
+ if (!isDirect) continue
558
+ }
782
559
  }
783
560
 
784
561
  if (options.compactFolders && entry.isDir) {
785
562
  const chain = [entry]
786
563
  let curr = entry
787
- let currL = levels[curr.path] ?? levels[curr.path.replace(/\\/g, '/')]
788
- while (currL && currL.entries) {
564
+ let currL = levels[curr.path] ?? levels[curr.path.replace(/\\/g, '/')] ?? levels[curr.path.replace(/\//g, '\\')]
565
+ while (currL && Array.isArray(currL.entries)) {
789
566
  const valid = options.showHiddenFiles
790
567
  ? currL.entries
791
- : currL.entries.filter((c) => !c.hidden && !c.name.startsWith('.'))
568
+ : currL.entries.filter((c) => c && !c.hidden && !c.name.startsWith('.'))
792
569
  if (valid.length === 1 && valid[0]!.isDir) {
793
570
  const next = valid[0]!
794
571
  chain.push(next)
795
572
  curr = next
796
- currL = levels[curr.path] ?? levels[curr.path.replace(/\\/g, '/')]
573
+ currL = levels[curr.path] ?? levels[curr.path.replace(/\\/g, '/')] ?? levels[curr.path.replace(/\//g, '\\')]
797
574
  } else {
798
575
  break
799
576
  }
@@ -801,7 +578,8 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
801
578
  if (chain.length > 1) {
802
579
  const compactRel = getRelativePath(curr.path)
803
580
  rows.push({ entry: curr, relativePath: compactRel })
804
- if (expanded.has(entry.path) || expanded.has(curr.path) || Boolean(filterText)) {
581
+ const isExp = isSearchActive || expanded.has(entry.path) || expanded.has(curr.path)
582
+ if (isExp) {
805
583
  walk(curr.path)
806
584
  }
807
585
  continue
@@ -810,7 +588,8 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
810
588
 
811
589
  rows.push({ entry, relativePath: rel })
812
590
 
813
- if (entry.isDir && (expanded.has(entry.path) || Boolean(filterText))) {
591
+ const isFolderExpanded = isSearchActive || expanded.has(entry.path)
592
+ if (entry.isDir && isFolderExpanded) {
814
593
  walk(entry.path)
815
594
  }
816
595
  }
@@ -818,54 +597,60 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
818
597
 
819
598
  walk(rootPath)
820
599
  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)
600
+ }, [rootPath, levels, expanded, filterText, matchingPaths, ancestorPaths, getRelativePath, options.showHiddenFiles, options.compactFolders])
601
+
602
+ const handleExpandAll = useCallback(async () => {
603
+ const allDirs = new Set<string>()
604
+ if (rootPath) allDirs.add(rootPath)
605
+ for (const [dirPath, listing] of Object.entries(levels)) {
606
+ allDirs.add(dirPath)
607
+ if (listing?.entries) {
608
+ for (const entry of listing.entries) {
609
+ if (entry.isDir) {
610
+ allDirs.add(entry.path)
611
+ }
865
612
  }
866
613
  }
867
614
  }
868
- }
615
+ setExpanded(allDirs)
616
+
617
+ const unloadedDirs = Array.from(allDirs).filter((p) => {
618
+ const norm = p.replace(/\\/g, '/')
619
+ return !levels[p] && !levels[norm]
620
+ })
621
+ if (unloadedDirs.length > 0) {
622
+ try {
623
+ const batch = await fetchBatchTree(unloadedDirs)
624
+ setLevels((prev) => ({ ...prev, ...batch }))
625
+ } catch {}
626
+ }
627
+ }, [rootPath, levels])
628
+
629
+ const handleDelete = useCallback((entry: FsEntry, relativePath?: string) => {
630
+ const rel = relativePath ?? getRelativePath(entry.path)
631
+ setDeletingTarget({ entry, relativePath: rel })
632
+ }, [getRelativePath])
633
+
634
+ const handleConfirmDelete = useCallback(async () => {
635
+ if (!deletingTarget) return
636
+ await apiDelete(deletingTarget.entry.path)
637
+ refreshAll()
638
+ }, [deletingTarget, refreshAll])
639
+
640
+ const handleKeyDown = useKeyboardNav({
641
+ visibleFlatRows,
642
+ focusedPath,
643
+ expanded,
644
+ setFocusedPath,
645
+ setSelected,
646
+ toggleExpand,
647
+ onMention: (rel) => {
648
+ const activeId = sessionId ?? safeService(ctx, 'sessions')?.active?.id ?? ''
649
+ insertMentionToComposer(ctx, activeId, rel, false)
650
+ },
651
+ onStartRename: (path) => setRenamingPath(path),
652
+ onDelete: (entry, rel) => handleDelete(entry, rel),
653
+ })
869
654
 
870
655
  const handleRowSelect = (entryPath: string, e: React.MouseEvent) => {
871
656
  setFocusedPath(entryPath)
@@ -897,7 +682,7 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
897
682
  refreshAll()
898
683
  setExpanded((prev) => new Set(prev).add(parent))
899
684
  } catch (err: any) {
900
- alert(err.message || 'Operation failed')
685
+ console.error('[dsh-explorer] Operation failed:', err)
901
686
  } finally {
902
687
  setCreating(null)
903
688
  setCreatingName('')
@@ -905,21 +690,12 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
905
690
  }
906
691
 
907
692
  const handleRename = async (entryPath: string, newName: string) => {
693
+ setRenamingPath(null)
908
694
  try {
909
695
  await apiRename(entryPath, newName)
910
696
  refreshAll()
911
697
  } 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')
698
+ console.error('[dsh-explorer] Rename failed:', err)
923
699
  }
924
700
  }
925
701
 
@@ -944,8 +720,12 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
944
720
  [ctx, sessionId]
945
721
  )
946
722
 
947
- const title = rootPath ? baseName(rootPath) : 'EXPLORER'
948
- const rootListing = levels[rootPath] ?? levels[rootPath.replace(/\\/g, '/')]
723
+ const normRootLower = rootPath ? rootPath.replace(/\\/g, '/').toLowerCase() : ''
724
+ const rootListing =
725
+ levels[rootPath] ??
726
+ levels[rootPath.replace(/\\/g, '/')] ??
727
+ levels[rootPath.replace(/\//g, '\\')] ??
728
+ (normRootLower ? Object.entries(levels).find(([k]) => k.replace(/\\/g, '/').toLowerCase() === normRootLower)?.[1] : undefined)
949
729
 
950
730
  return (
951
731
  <div
@@ -953,10 +733,18 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
953
733
  style={containerStyles}
954
734
  >
955
735
  <ExplorerHeader
956
- title={`EXPLORER: ${title}`}
736
+ title={rootPath || 'EXPLORER'}
957
737
  searchOpen={searchOpen}
958
738
  optionsOpen={optionsOpen}
959
- onToggleSearch={() => setSearchOpen(!searchOpen)}
739
+ onToggleSearch={() => {
740
+ if (searchOpen) {
741
+ setSearchInput('')
742
+ setFilterText('')
743
+ setSearchOpen(false)
744
+ } else {
745
+ setSearchOpen(true)
746
+ }
747
+ }}
960
748
  onToggleOptions={() => setOptionsOpen(!optionsOpen)}
961
749
  onNewFile={() => {
962
750
  setCreating({ parentDir: rootPath, isDir: false })
@@ -969,6 +757,7 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
969
757
  onRefresh={() => {
970
758
  refreshAll()
971
759
  }}
760
+ onExpandAll={handleExpandAll}
972
761
  onCollapseAll={() => {
973
762
  setExpanded(new Set())
974
763
  }}
@@ -985,9 +774,12 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
985
774
 
986
775
  {searchOpen && (
987
776
  <ExplorerSearch
988
- value={filterText}
989
- onChange={setFilterText}
777
+ value={searchInput}
778
+ loading={searchLoading}
779
+ matchCount={matchCount}
780
+ onChange={setSearchInput}
990
781
  onClose={() => {
782
+ setSearchInput('')
991
783
  setFilterText('')
992
784
  setSearchOpen(false)
993
785
  }}
@@ -999,13 +791,20 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
999
791
  className={css.treeBody}
1000
792
  tabIndex={0}
1001
793
  onKeyDown={handleKeyDown}
794
+ onContextMenu={(e) => {
795
+ e.preventDefault()
796
+ setContextMenu(null)
797
+ setEmptyContextMenu({
798
+ x: e.clientX,
799
+ y: e.clientY,
800
+ })
801
+ }}
1002
802
  onDragOver={(e) => {
1003
803
  e.preventDefault()
1004
804
  e.dataTransfer.dropEffect = 'copy'
1005
805
  }}
1006
806
  onDrop={handleExplorerDrop}
1007
807
  >
1008
- {/* Root inline creation */}
1009
808
  {creating && creating.parentDir === rootPath && (
1010
809
  <div className={css.row} style={{ paddingLeft: `${indentStep + 8}px` }}>
1011
810
  <span className={css.iconWrap}>{creating.isDir ? '📁' : '📄'}</span>
@@ -1026,8 +825,7 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
1026
825
  </div>
1027
826
  )}
1028
827
 
1029
- {/* Tree Nodes with Motion Collapse */}
1030
- {rootListing?.entries.map((entry) => (
828
+ {(rootListing?.entries ?? []).map((entry) => (
1031
829
  <TreeNode
1032
830
  key={entry.path}
1033
831
  entry={entry}
@@ -1038,8 +836,11 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
1038
836
  expanded={expanded}
1039
837
  selected={selected}
1040
838
  focusedPath={focusedPath}
839
+ renamingPath={renamingPath}
1041
840
  gitStatusMap={gitStatusMap}
1042
841
  filterText={filterText}
842
+ matchingPaths={matchingPaths}
843
+ ancestorPaths={ancestorPaths}
1043
844
  creating={creating}
1044
845
  creatingName={creatingName}
1045
846
  createInputRef={createInputRef}
@@ -1052,6 +853,8 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
1052
853
  onOpenFile={handleOpenFile}
1053
854
  onContextMenu={(ent, rel, e) => {
1054
855
  e.preventDefault()
856
+ e.stopPropagation()
857
+ setEmptyContextMenu(null)
1055
858
  setContextMenu({
1056
859
  x: e.clientX,
1057
860
  y: e.clientY,
@@ -1066,6 +869,8 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
1066
869
  onCopyRelativePath={(rel) => {
1067
870
  navigator.clipboard.writeText(rel)
1068
871
  }}
872
+ onStartRename={(path) => setRenamingPath(path)}
873
+ onCancelRename={() => setRenamingPath(null)}
1069
874
  onRenameSubmit={(path, newName) => handleRename(path, newName)}
1070
875
  onDelete={handleDelete}
1071
876
  onCreatingNameChange={setCreatingName}
@@ -1080,12 +885,12 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
1080
885
 
1081
886
  {visibleFlatRows.length === 0 && !creating && (
1082
887
  <div className={css.emptyState}>
1083
- {loading
1084
- ? 'Loading workspace files...'
888
+ {loading || searchLoading
889
+ ? 'Searching workspace files...'
1085
890
  : errorMessage
1086
891
  ? `Error: ${errorMessage}`
1087
892
  : filterText
1088
- ? 'No matching files found'
893
+ ? `No files matching "${filterText}"`
1089
894
  : !options.showHiddenFiles
1090
895
  ? 'Only hidden files present (enable in options)'
1091
896
  : 'Empty directory'}
@@ -1111,7 +916,7 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
1111
916
  const content = await fetchFileContent(contextMenu.entry.path)
1112
917
  insertSnippetToComposer(ctx, activeId, contextMenu.relativePath, content)
1113
918
  } catch (err: any) {
1114
- alert(err.message || 'Failed to read file')
919
+ console.error('[dsh-explorer] Failed to read file:', err)
1115
920
  }
1116
921
  }}
1117
922
  onCopyPath={() => {
@@ -1130,8 +935,45 @@ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, init
1130
935
  }}
1131
936
  onRename={() => {
1132
937
  setFocusedPath(contextMenu.entry.path)
938
+ setRenamingPath(contextMenu.entry.path)
1133
939
  }}
1134
- onDelete={() => handleDelete(contextMenu.entry)}
940
+ onDelete={() => handleDelete(contextMenu.entry, contextMenu.relativePath)}
941
+ />
942
+ )}
943
+
944
+ {emptyContextMenu && (
945
+ <ExplorerEmptyContextMenu
946
+ x={emptyContextMenu.x}
947
+ y={emptyContextMenu.y}
948
+ onClose={() => setEmptyContextMenu(null)}
949
+ onNewFile={() => {
950
+ setCreating({ parentDir: rootPath, isDir: false })
951
+ setCreatingName('')
952
+ }}
953
+ onNewFolder={() => {
954
+ setCreating({ parentDir: rootPath, isDir: true })
955
+ setCreatingName('')
956
+ }}
957
+ onToggleSearch={() => {
958
+ setSearchOpen((prev) => !prev)
959
+ }}
960
+ onExpandAll={handleExpandAll}
961
+ onCollapseAll={() => {
962
+ setExpanded(new Set())
963
+ }}
964
+ onRefresh={refreshAll}
965
+ onOpenOptions={() => {
966
+ setOptionsOpen(true)
967
+ }}
968
+ />
969
+ )}
970
+
971
+ {deletingTarget && (
972
+ <ExplorerDeleteModal
973
+ entry={deletingTarget.entry}
974
+ relativePath={deletingTarget.relativePath}
975
+ onConfirm={handleConfirmDelete}
976
+ onClose={() => setDeletingTarget(null)}
1135
977
  />
1136
978
  )}
1137
979
  </div>