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