dsh-explorer 0.0.0-stage → 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.
Files changed (37) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +106 -2
  3. package/cordis.patch.yml +5 -0
  4. package/icon.svg +7 -0
  5. package/lib/client.js +859 -0
  6. package/lib/index.js +854 -0
  7. package/package.json +91 -4
  8. package/src/client/ExplorerView.tsx +981 -0
  9. package/src/client/components/ExplorerContextMenu.tsx +157 -0
  10. package/src/client/components/ExplorerDeleteModal.tsx +124 -0
  11. package/src/client/components/ExplorerEmptyContextMenu.tsx +125 -0
  12. package/src/client/components/ExplorerHeader.tsx +81 -0
  13. package/src/client/components/ExplorerOptionsModal.tsx +230 -0
  14. package/src/client/components/ExplorerRow.tsx +258 -0
  15. package/src/client/components/ExplorerSearch.tsx +55 -0
  16. package/src/client/components/TreeNode.tsx +384 -0
  17. package/src/client/css-modules.d.ts +5 -0
  18. package/src/client/explorer.module.css +833 -0
  19. package/src/client/hooks/useKeyboardNav.ts +89 -0
  20. package/src/client/hooks/useLiveSync.ts +81 -0
  21. package/src/client/index.tsx +238 -0
  22. package/src/client/utils/api.ts +251 -0
  23. package/src/client/utils/conversation-draft.ts +644 -0
  24. package/src/client/utils/drag-drop.ts +209 -0
  25. package/src/client/utils/open-tab.ts +69 -0
  26. package/src/client/utils/options.ts +59 -0
  27. package/src/client/utils/vscode-theme.tsx +250 -0
  28. package/src/config.ts +11 -0
  29. package/src/fs-operations.ts +116 -0
  30. package/src/fs-search.ts +116 -0
  31. package/src/fs-tree.ts +155 -0
  32. package/src/fs-watch.ts +179 -0
  33. package/src/git-status.ts +167 -0
  34. package/src/index.ts +177 -0
  35. package/src/path-security.ts +22 -0
  36. package/src/session-path.ts +35 -0
  37. package/src/wire.ts +63 -0
@@ -0,0 +1,981 @@
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
+ fetchSearchWorkspace,
8
+ apiCreateNewFile,
9
+ apiCreateDirectory,
10
+ apiRename,
11
+ apiDelete,
12
+ fetchFileContent,
13
+ type FsEntry,
14
+ type FsListing,
15
+ type GitStatusResult,
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'
26
+ import {
27
+ loadExplorerOptions,
28
+ saveExplorerOptions,
29
+ DEFAULT_OPTIONS,
30
+ type ExplorerOptions,
31
+ } from './utils/options.ts'
32
+ import {
33
+ insertMentionToComposer,
34
+ insertSnippetToComposer,
35
+ setLastKnownSessionId,
36
+ baseName,
37
+ } from './utils/conversation-draft.ts'
38
+ import { openFileInTab, safeService } from './utils/open-tab.ts'
39
+
40
+ export { openFileInTab }
41
+
42
+ interface ExplorerViewProps {
43
+ ctx: any
44
+ sessionId?: string
45
+ initialCwd?: string
46
+ }
47
+
48
+ export const ExplorerView: React.FC<ExplorerViewProps> = ({ ctx, sessionId, initialCwd }) => {
49
+ const [rootPath, setRootPath] = useState<string>(initialCwd ?? '')
50
+ const [levels, setLevels] = useState<Record<string, FsListing>>({})
51
+ const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
52
+ const [selected, setSelected] = useState<Set<string>>(() => new Set())
53
+ const [focusedPath, setFocusedPath] = useState<string | null>(null)
54
+ const [searchInput, setSearchInput] = useState('')
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)
61
+ const [searchOpen, setSearchOpen] = useState(false)
62
+ const [optionsOpen, setOptionsOpen] = useState(false)
63
+ const [options, setOptions] = useState<ExplorerOptions>(() => loadExplorerOptions())
64
+ const [loading, setLoading] = useState(false)
65
+ const [errorMessage, setErrorMessage] = useState<string | null>(null)
66
+ const [gitData, setGitData] = useState<GitStatusResult>({ isRepo: false, entries: [] })
67
+ const [creating, setCreating] = useState<{ parentDir: string; isDir: boolean } | null>(null)
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)
74
+ const [contextMenu, setContextMenu] = useState<{
75
+ x: number
76
+ y: number
77
+ entry: FsEntry
78
+ relativePath: string
79
+ } | null>(null)
80
+ const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null)
81
+
82
+ const treeBodyRef = useRef<HTMLDivElement>(null)
83
+ const createInputRef = useRef<HTMLInputElement>(null)
84
+
85
+ const handleUpdateOptions = useCallback((patch: Partial<ExplorerOptions>) => {
86
+ setOptions((prev) => {
87
+ const next = { ...prev, ...patch }
88
+ saveExplorerOptions(next)
89
+ return next
90
+ })
91
+ }, [])
92
+
93
+ const handleResetOptions = useCallback(() => {
94
+ setOptions({ ...DEFAULT_OPTIONS })
95
+ saveExplorerOptions({ ...DEFAULT_OPTIONS })
96
+ }, [])
97
+
98
+ const baseRowHeight = options.density === 'compact' ? 20 : options.density === 'relaxed' ? 28 : 24
99
+ const baseFontSize = options.density === 'compact' ? 11.5 : options.density === 'relaxed' ? 14 : 13
100
+ const baseIconSize = options.density === 'compact' ? 14 : options.density === 'relaxed' ? 18 : 16
101
+ const baseIndent = options.density === 'compact' ? 12 : options.density === 'relaxed' ? 20 : 16
102
+
103
+ const rowHeight = Math.round(baseRowHeight * options.scale)
104
+ const fontSize = Math.round(baseFontSize * options.scale)
105
+ const iconSize = Math.round(baseIconSize * options.scale)
106
+ const indentStep = Math.round(baseIndent * options.scale)
107
+
108
+ const containerStyles = useMemo(
109
+ () =>
110
+ ({
111
+ '--dsh-row-height': `${rowHeight}px`,
112
+ '--dsh-font-size': `${fontSize}px`,
113
+ '--dsh-icon-size': `${iconSize}px`,
114
+ '--dsh-indent-step': `${indentStep}px`,
115
+ }) as React.CSSProperties,
116
+ [rowHeight, fontSize, iconSize, indentStep]
117
+ )
118
+
119
+ useEffect(() => {
120
+ if (sessionId) setLastKnownSessionId(sessionId)
121
+ }, [sessionId])
122
+
123
+ useEffect(() => {
124
+ if (initialCwd && !rootPath) {
125
+ setRootPath(initialCwd)
126
+ return
127
+ }
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
144
+ }
145
+ }
146
+ }, [sessionId, initialCwd, rootPath])
147
+
148
+ const reloadDirectory = useCallback(
149
+ async (dir: string): Promise<void> => {
150
+ try {
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 {
157
+ ...prev,
158
+ [dir]: listing,
159
+ [norm]: listing,
160
+ [back]: listing,
161
+ }
162
+ })
163
+ } catch (err: any) {
164
+ setErrorMessage(err?.message || 'Failed to load directory')
165
+ }
166
+ },
167
+ [sessionId]
168
+ )
169
+
170
+ const reloadGit = useCallback(
171
+ async (dir?: string) => {
172
+ const target = dir || rootPath
173
+ if (!target && !sessionId) return
174
+ try {
175
+ const res = await fetchGitStatus(target, sessionId)
176
+ setGitData(res)
177
+ } catch {
178
+ setGitData({ isRepo: false, entries: [] })
179
+ }
180
+ },
181
+ [rootPath, sessionId]
182
+ )
183
+
184
+ const refreshAll = useCallback(async () => {
185
+ if (!rootPath) return
186
+ reloadGit(rootPath)
187
+ try {
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
196
+ }
197
+
198
+ const normRoot = rootPath.replace(/\\/g, '/').toLowerCase()
199
+
200
+ setLevels((prev) => {
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]
207
+ }
208
+ }
209
+ return next
210
+ })
211
+
212
+ setExpanded((prev) => {
213
+ let changed = false
214
+ const next = new Set(prev)
215
+ for (const expPath of prev) {
216
+ const normExp = expPath.replace(/\\/g, '/').toLowerCase()
217
+ if (normExp === normRoot) continue
218
+ if (!mergedBatch[expPath] && !mergedBatch[expPath.replace(/\\/g, '/')] && !mergedBatch[expPath.replace(/\//g, '\\')]) {
219
+ next.delete(expPath)
220
+ changed = true
221
+ }
222
+ }
223
+ return changed ? next : prev
224
+ })
225
+ } catch (err) {
226
+ console.warn('[dsh-explorer] refreshAll failed:', err)
227
+ }
228
+ }, [rootPath, expanded, reloadGit])
229
+
230
+ useEffect(() => {
231
+ if (!rootPath) return
232
+ refreshAll()
233
+ setExpanded((prev) => new Set(prev).add(rootPath))
234
+ }, [rootPath])
235
+
236
+ useLiveSync({
237
+ rootPath,
238
+ onRefresh: refreshAll,
239
+ })
240
+
241
+ const toggleExpand = useCallback(
242
+ async (path: string) => {
243
+ setExpanded((prev) => {
244
+ const next = new Set(prev)
245
+ if (next.has(path)) {
246
+ next.delete(path)
247
+ } else {
248
+ next.add(path)
249
+ const norm = path.replace(/\\/g, '/')
250
+ if (!levels[path] && !levels[norm]) {
251
+ reloadDirectory(path)
252
+ }
253
+ }
254
+ return next
255
+ })
256
+ },
257
+ [levels, reloadDirectory]
258
+ )
259
+
260
+ const getRelativePath = useCallback(
261
+ (fullPath: string): string => {
262
+ if (!rootPath) return fullPath
263
+ const normRoot = rootPath.replace(/\\/g, '/').replace(/\/+$/, '')
264
+ const normTarget = fullPath.replace(/\\/g, '/')
265
+ if (normTarget.toLowerCase().startsWith(`${normRoot.toLowerCase()}/`)) {
266
+ return normTarget.slice(normRoot.length + 1)
267
+ }
268
+ return fullPath
269
+ },
270
+ [rootPath]
271
+ )
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
+
482
+ const gitStatusMap = useMemo(() => {
483
+ const map = new Map<string, string>()
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()
488
+ map.set(p, entry.xy)
489
+ if (normRoot) {
490
+ map.set(`${normRoot}/${p}`, entry.xy)
491
+ }
492
+
493
+ let parent = p
494
+ while (true) {
495
+ const lastSlash = parent.lastIndexOf('/')
496
+ if (lastSlash === -1) break
497
+ parent = parent.slice(0, lastSlash)
498
+ if (!parent) break
499
+ const existing = map.get(parent)
500
+ if (!existing) {
501
+ map.set(parent, entry.xy)
502
+ if (normRoot) map.set(`${normRoot}/${parent}`, entry.xy)
503
+ } else if (entry.xy.includes('M') && !existing.includes('M')) {
504
+ map.set(parent, ' M')
505
+ if (normRoot) map.set(`${normRoot}/${parent}`, ' M')
506
+ }
507
+ }
508
+ }
509
+ return map
510
+ }, [gitData, rootPath])
511
+
512
+ const visibleFlatRows = useMemo(() => {
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 []
521
+ const rows: { entry: FsEntry; relativePath: string }[] = []
522
+
523
+ const isSearchActive = Boolean(filterText && matchingPaths)
524
+
525
+ const walk = (dir: string) => {
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
533
+
534
+ for (const entry of listing.entries) {
535
+ if (!entry) continue
536
+ if (!options.showHiddenFiles && (entry.hidden || entry.name.startsWith('.'))) {
537
+ continue
538
+ }
539
+ const rel = getRelativePath(entry.path)
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
+ }
559
+ }
560
+
561
+ if (options.compactFolders && entry.isDir) {
562
+ const chain = [entry]
563
+ let curr = entry
564
+ let currL = levels[curr.path] ?? levels[curr.path.replace(/\\/g, '/')] ?? levels[curr.path.replace(/\//g, '\\')]
565
+ while (currL && Array.isArray(currL.entries)) {
566
+ const valid = options.showHiddenFiles
567
+ ? currL.entries
568
+ : currL.entries.filter((c) => c && !c.hidden && !c.name.startsWith('.'))
569
+ if (valid.length === 1 && valid[0]!.isDir) {
570
+ const next = valid[0]!
571
+ chain.push(next)
572
+ curr = next
573
+ currL = levels[curr.path] ?? levels[curr.path.replace(/\\/g, '/')] ?? levels[curr.path.replace(/\//g, '\\')]
574
+ } else {
575
+ break
576
+ }
577
+ }
578
+ if (chain.length > 1) {
579
+ const compactRel = getRelativePath(curr.path)
580
+ rows.push({ entry: curr, relativePath: compactRel })
581
+ const isExp = isSearchActive || expanded.has(entry.path) || expanded.has(curr.path)
582
+ if (isExp) {
583
+ walk(curr.path)
584
+ }
585
+ continue
586
+ }
587
+ }
588
+
589
+ rows.push({ entry, relativePath: rel })
590
+
591
+ const isFolderExpanded = isSearchActive || expanded.has(entry.path)
592
+ if (entry.isDir && isFolderExpanded) {
593
+ walk(entry.path)
594
+ }
595
+ }
596
+ }
597
+
598
+ walk(rootPath)
599
+ return rows
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
+ }
612
+ }
613
+ }
614
+ }
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
+ })
654
+
655
+ const handleRowSelect = (entryPath: string, e: React.MouseEvent) => {
656
+ setFocusedPath(entryPath)
657
+ if (e.ctrlKey || e.metaKey) {
658
+ setSelected((prev) => {
659
+ const next = new Set(prev)
660
+ if (next.has(entryPath)) next.delete(entryPath)
661
+ else next.add(entryPath)
662
+ return next
663
+ })
664
+ } else {
665
+ setSelected(new Set([entryPath]))
666
+ }
667
+ }
668
+
669
+ const handleCreateSubmit = async () => {
670
+ if (!creating || !creatingName.trim()) {
671
+ setCreating(null)
672
+ return
673
+ }
674
+ const name = creatingName.trim()
675
+ const parent = creating.parentDir
676
+ try {
677
+ if (creating.isDir) {
678
+ await apiCreateDirectory(parent, name)
679
+ } else {
680
+ await apiCreateNewFile(parent, name)
681
+ }
682
+ refreshAll()
683
+ setExpanded((prev) => new Set(prev).add(parent))
684
+ } catch (err: any) {
685
+ console.error('[dsh-explorer] Operation failed:', err)
686
+ } finally {
687
+ setCreating(null)
688
+ setCreatingName('')
689
+ }
690
+ }
691
+
692
+ const handleRename = async (entryPath: string, newName: string) => {
693
+ setRenamingPath(null)
694
+ try {
695
+ await apiRename(entryPath, newName)
696
+ refreshAll()
697
+ } catch (err: any) {
698
+ console.error('[dsh-explorer] Rename failed:', err)
699
+ }
700
+ }
701
+
702
+ const handleExplorerDrop = async (e: React.DragEvent) => {
703
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
704
+ e.preventDefault()
705
+ e.stopPropagation()
706
+ const targetDir = rootPath
707
+ for (let i = 0; i < e.dataTransfer.files.length; i++) {
708
+ const file = e.dataTransfer.files[i]!
709
+ const uploadUrl = `/dsh-explorer/api/upload?dir=${encodeURIComponent(targetDir)}&relativePath=${encodeURIComponent(file.name)}`
710
+ await fetch(uploadUrl, { method: 'POST', body: file })
711
+ }
712
+ refreshAll()
713
+ }
714
+ }
715
+
716
+ const handleOpenFile = useCallback(
717
+ (filePath: string, relPath: string) => {
718
+ openFileInTab(ctx, sessionId ?? '', filePath, relPath)
719
+ },
720
+ [ctx, sessionId]
721
+ )
722
+
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)
729
+
730
+ return (
731
+ <div
732
+ className={`${css.container} ${!options.enableMotion ? css.noMotion : ''}`}
733
+ style={containerStyles}
734
+ >
735
+ <ExplorerHeader
736
+ title={rootPath || 'EXPLORER'}
737
+ searchOpen={searchOpen}
738
+ optionsOpen={optionsOpen}
739
+ onToggleSearch={() => {
740
+ if (searchOpen) {
741
+ setSearchInput('')
742
+ setFilterText('')
743
+ setSearchOpen(false)
744
+ } else {
745
+ setSearchOpen(true)
746
+ }
747
+ }}
748
+ onToggleOptions={() => setOptionsOpen(!optionsOpen)}
749
+ onNewFile={() => {
750
+ setCreating({ parentDir: rootPath, isDir: false })
751
+ setCreatingName('')
752
+ }}
753
+ onNewFolder={() => {
754
+ setCreating({ parentDir: rootPath, isDir: true })
755
+ setCreatingName('')
756
+ }}
757
+ onRefresh={() => {
758
+ refreshAll()
759
+ }}
760
+ onExpandAll={handleExpandAll}
761
+ onCollapseAll={() => {
762
+ setExpanded(new Set())
763
+ }}
764
+ />
765
+
766
+ {optionsOpen && (
767
+ <ExplorerOptionsModal
768
+ options={options}
769
+ onChange={handleUpdateOptions}
770
+ onReset={handleResetOptions}
771
+ onClose={() => setOptionsOpen(false)}
772
+ />
773
+ )}
774
+
775
+ {searchOpen && (
776
+ <ExplorerSearch
777
+ value={searchInput}
778
+ loading={searchLoading}
779
+ matchCount={matchCount}
780
+ onChange={setSearchInput}
781
+ onClose={() => {
782
+ setSearchInput('')
783
+ setFilterText('')
784
+ setSearchOpen(false)
785
+ }}
786
+ />
787
+ )}
788
+
789
+ <div
790
+ ref={treeBodyRef}
791
+ className={css.treeBody}
792
+ tabIndex={0}
793
+ onKeyDown={handleKeyDown}
794
+ onContextMenu={(e) => {
795
+ e.preventDefault()
796
+ setContextMenu(null)
797
+ setEmptyContextMenu({
798
+ x: e.clientX,
799
+ y: e.clientY,
800
+ })
801
+ }}
802
+ onDragOver={(e) => {
803
+ e.preventDefault()
804
+ e.dataTransfer.dropEffect = 'copy'
805
+ }}
806
+ onDrop={handleExplorerDrop}
807
+ >
808
+ {creating && creating.parentDir === rootPath && (
809
+ <div className={css.row} style={{ paddingLeft: `${indentStep + 8}px` }}>
810
+ <span className={css.iconWrap}>{creating.isDir ? '📁' : '📄'}</span>
811
+ <input
812
+ ref={createInputRef}
813
+ autoFocus
814
+ type="text"
815
+ className={css.inlineInput}
816
+ placeholder={creating.isDir ? 'Folder name...' : 'File name...'}
817
+ value={creatingName}
818
+ onChange={(e) => setCreatingName(e.target.value)}
819
+ onKeyDown={(e) => {
820
+ if (e.key === 'Enter') handleCreateSubmit()
821
+ else if (e.key === 'Escape') setCreating(null)
822
+ }}
823
+ onBlur={handleCreateSubmit}
824
+ />
825
+ </div>
826
+ )}
827
+
828
+ {(rootListing?.entries ?? []).map((entry) => (
829
+ <TreeNode
830
+ key={entry.path}
831
+ entry={entry}
832
+ depth={0}
833
+ parentDir={rootPath}
834
+ rootPath={rootPath}
835
+ levels={levels}
836
+ expanded={expanded}
837
+ selected={selected}
838
+ focusedPath={focusedPath}
839
+ renamingPath={renamingPath}
840
+ gitStatusMap={gitStatusMap}
841
+ filterText={filterText}
842
+ matchingPaths={matchingPaths}
843
+ ancestorPaths={ancestorPaths}
844
+ creating={creating}
845
+ creatingName={creatingName}
846
+ createInputRef={createInputRef}
847
+ options={options}
848
+ indentStep={indentStep}
849
+ iconSize={iconSize}
850
+ reloadDirectory={reloadDirectory}
851
+ onToggle={toggleExpand}
852
+ onSelect={handleRowSelect}
853
+ onOpenFile={handleOpenFile}
854
+ onContextMenu={(ent, rel, e) => {
855
+ e.preventDefault()
856
+ e.stopPropagation()
857
+ setEmptyContextMenu(null)
858
+ setContextMenu({
859
+ x: e.clientX,
860
+ y: e.clientY,
861
+ entry: ent,
862
+ relativePath: rel,
863
+ })
864
+ }}
865
+ onInsertReference={(rel, isDir) => {
866
+ const activeId = sessionId ?? safeService(ctx, 'sessions')?.active?.id ?? ''
867
+ insertMentionToComposer(ctx, activeId, rel, isDir)
868
+ }}
869
+ onCopyRelativePath={(rel) => {
870
+ navigator.clipboard.writeText(rel)
871
+ }}
872
+ onStartRename={(path) => setRenamingPath(path)}
873
+ onCancelRename={() => setRenamingPath(null)}
874
+ onRenameSubmit={(path, newName) => handleRename(path, newName)}
875
+ onDelete={handleDelete}
876
+ onCreatingNameChange={setCreatingName}
877
+ onCreatingKeyDown={(e) => {
878
+ if (e.key === 'Enter') handleCreateSubmit()
879
+ else if (e.key === 'Escape') setCreating(null)
880
+ }}
881
+ onCreatingBlur={handleCreateSubmit}
882
+ getRelativePath={getRelativePath}
883
+ />
884
+ ))}
885
+
886
+ {visibleFlatRows.length === 0 && !creating && (
887
+ <div className={css.emptyState}>
888
+ {loading || searchLoading
889
+ ? 'Searching workspace files...'
890
+ : errorMessage
891
+ ? `Error: ${errorMessage}`
892
+ : filterText
893
+ ? `No files matching "${filterText}"`
894
+ : !options.showHiddenFiles
895
+ ? 'Only hidden files present (enable in options)'
896
+ : 'Empty directory'}
897
+ </div>
898
+ )}
899
+ </div>
900
+
901
+ {contextMenu && (
902
+ <ExplorerContextMenu
903
+ x={contextMenu.x}
904
+ y={contextMenu.y}
905
+ entry={contextMenu.entry}
906
+ relativePath={contextMenu.relativePath}
907
+ onClose={() => setContextMenu(null)}
908
+ onOpenFile={() => handleOpenFile(contextMenu.entry.path, contextMenu.relativePath)}
909
+ onInsertReference={() => {
910
+ const activeId = sessionId ?? safeService(ctx, 'sessions')?.active?.id ?? ''
911
+ insertMentionToComposer(ctx, activeId, contextMenu.relativePath, contextMenu.entry.isDir)
912
+ }}
913
+ onInsertSnippet={async () => {
914
+ const activeId = sessionId ?? safeService(ctx, 'sessions')?.active?.id ?? ''
915
+ try {
916
+ const content = await fetchFileContent(contextMenu.entry.path)
917
+ insertSnippetToComposer(ctx, activeId, contextMenu.relativePath, content)
918
+ } catch (err: any) {
919
+ console.error('[dsh-explorer] Failed to read file:', err)
920
+ }
921
+ }}
922
+ onCopyPath={() => {
923
+ navigator.clipboard.writeText(contextMenu.entry.path)
924
+ }}
925
+ onCopyRelativePath={() => {
926
+ navigator.clipboard.writeText(contextMenu.relativePath)
927
+ }}
928
+ onNewFile={() => {
929
+ setCreating({ parentDir: contextMenu.entry.path, isDir: false })
930
+ setCreatingName('')
931
+ }}
932
+ onNewFolder={() => {
933
+ setCreating({ parentDir: contextMenu.entry.path, isDir: true })
934
+ setCreatingName('')
935
+ }}
936
+ onRename={() => {
937
+ setFocusedPath(contextMenu.entry.path)
938
+ setRenamingPath(contextMenu.entry.path)
939
+ }}
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)}
977
+ />
978
+ )}
979
+ </div>
980
+ )
981
+ }