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.
- package/lib/client.js +817 -2772
- package/lib/index.js +124 -17
- package/package.json +1 -2
- package/src/client/ExplorerView.tsx +494 -652
- package/src/client/{ExplorerContextMenu.tsx → components/ExplorerContextMenu.tsx} +2 -4
- package/src/client/components/ExplorerDeleteModal.tsx +124 -0
- package/src/client/components/ExplorerEmptyContextMenu.tsx +125 -0
- package/src/client/{ExplorerHeader.tsx → components/ExplorerHeader.tsx} +18 -12
- package/src/client/{ExplorerOptionsModal.tsx → components/ExplorerOptionsModal.tsx} +2 -15
- package/src/client/{ExplorerRow.tsx → components/ExplorerRow.tsx} +45 -27
- package/src/client/{ExplorerSearch.tsx → components/ExplorerSearch.tsx} +18 -5
- package/src/client/components/TreeNode.tsx +384 -0
- package/src/client/explorer.module.css +215 -10
- package/src/client/hooks/useKeyboardNav.ts +89 -0
- package/src/client/hooks/useLiveSync.ts +81 -0
- package/src/client/index.tsx +82 -4
- package/src/client/{api.ts → utils/api.ts} +133 -3
- package/src/client/{conversation-draft.ts → utils/conversation-draft.ts} +38 -29
- package/src/client/utils/open-tab.ts +69 -0
- package/src/fs-search.ts +116 -0
- package/src/git-status.ts +19 -13
- package/src/index.ts +12 -0
- /package/src/client/{drag-drop.ts → utils/drag-drop.ts} +0 -0
- /package/src/client/{options.ts → utils/options.ts} +0 -0
- /package/src/client/{vscode-theme.tsx → utils/vscode-theme.tsx} +0 -0
|
@@ -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 {
|
|
19
|
-
import {
|
|
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-
|
|
456
|
-
} as React.CSSProperties
|
|
457
|
-
[rowHeight, fontSize, iconSize,
|
|
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)
|
|
469
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
[
|
|
532
|
-
[
|
|
533
|
-
|
|
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
|
|
167
|
+
[sessionId]
|
|
544
168
|
)
|
|
545
169
|
|
|
546
170
|
const reloadGit = useCallback(
|
|
547
|
-
async (dir
|
|
171
|
+
async (dir?: string) => {
|
|
172
|
+
const target = dir || rootPath
|
|
173
|
+
if (!target && !sessionId) return
|
|
548
174
|
try {
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
const git = await fetchGitStatus(activeId, dir)
|
|
552
|
-
setGitData(git)
|
|
175
|
+
const res = await fetchGitStatus(target, sessionId)
|
|
176
|
+
setGitData(res)
|
|
553
177
|
} catch {
|
|
554
|
-
|
|
178
|
+
setGitData({ isRepo: false, entries: [] })
|
|
555
179
|
}
|
|
556
180
|
},
|
|
557
|
-
[
|
|
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
|
-
|
|
568
|
-
const dirsToRefresh = Array.from(new Set([rootPath, ...currentExpanded]))
|
|
186
|
+
reloadGit(rootPath)
|
|
569
187
|
try {
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const mergedBatch: Record<string, FsListing> = {
|
|
575
|
-
|
|
576
|
-
mergedBatch[
|
|
577
|
-
|
|
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
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
if (!mergedBatch[expPath] && !mergedBatch[
|
|
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,
|
|
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
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
739
|
-
|
|
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
|
-
|
|
763
|
-
const
|
|
764
|
-
|
|
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 =
|
|
770
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
if (
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
948
|
-
const rootListing =
|
|
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={
|
|
736
|
+
title={rootPath || 'EXPLORER'}
|
|
957
737
|
searchOpen={searchOpen}
|
|
958
738
|
optionsOpen={optionsOpen}
|
|
959
|
-
onToggleSearch={() =>
|
|
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={
|
|
989
|
-
|
|
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
|
-
{
|
|
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
|
-
? '
|
|
888
|
+
{loading || searchLoading
|
|
889
|
+
? 'Searching workspace files...'
|
|
1085
890
|
: errorMessage
|
|
1086
891
|
? `Error: ${errorMessage}`
|
|
1087
892
|
: filterText
|
|
1088
|
-
?
|
|
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
|
-
|
|
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>
|