dsh-explorer 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +106 -2
- package/cordis.patch.yml +5 -0
- package/icon.svg +7 -0
- package/lib/client.js +859 -0
- package/lib/index.js +854 -0
- package/package.json +91 -4
- package/src/client/ExplorerView.tsx +981 -0
- package/src/client/components/ExplorerContextMenu.tsx +157 -0
- package/src/client/components/ExplorerDeleteModal.tsx +124 -0
- package/src/client/components/ExplorerEmptyContextMenu.tsx +125 -0
- package/src/client/components/ExplorerHeader.tsx +81 -0
- package/src/client/components/ExplorerOptionsModal.tsx +230 -0
- package/src/client/components/ExplorerRow.tsx +258 -0
- package/src/client/components/ExplorerSearch.tsx +55 -0
- package/src/client/components/TreeNode.tsx +384 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/explorer.module.css +833 -0
- package/src/client/hooks/useKeyboardNav.ts +89 -0
- package/src/client/hooks/useLiveSync.ts +81 -0
- package/src/client/index.tsx +238 -0
- package/src/client/utils/api.ts +251 -0
- package/src/client/utils/conversation-draft.ts +644 -0
- package/src/client/utils/drag-drop.ts +209 -0
- package/src/client/utils/open-tab.ts +69 -0
- package/src/client/utils/options.ts +59 -0
- package/src/client/utils/vscode-theme.tsx +250 -0
- package/src/config.ts +11 -0
- package/src/fs-operations.ts +116 -0
- package/src/fs-search.ts +116 -0
- package/src/fs-tree.ts +155 -0
- package/src/fs-watch.ts +179 -0
- package/src/git-status.ts +167 -0
- package/src/index.ts +177 -0
- package/src/path-security.ts +22 -0
- package/src/session-path.ts +35 -0
- package/src/wire.ts +63 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { FsEntry } from '../utils/api.ts'
|
|
3
|
+
|
|
4
|
+
interface FlatRowItem {
|
|
5
|
+
entry: FsEntry
|
|
6
|
+
relativePath: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface UseKeyboardNavOptions {
|
|
10
|
+
visibleFlatRows: FlatRowItem[]
|
|
11
|
+
focusedPath: string | null
|
|
12
|
+
expanded: Set<string>
|
|
13
|
+
setFocusedPath: (path: string) => void
|
|
14
|
+
setSelected: React.Dispatch<React.SetStateAction<Set<string>>>
|
|
15
|
+
toggleExpand: (path: string) => void
|
|
16
|
+
onMention: (relativePath: string) => void
|
|
17
|
+
onStartRename: (path: string) => void
|
|
18
|
+
onDelete: (entry: FsEntry, relativePath: string) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useKeyboardNav({
|
|
22
|
+
visibleFlatRows,
|
|
23
|
+
focusedPath,
|
|
24
|
+
expanded,
|
|
25
|
+
setFocusedPath,
|
|
26
|
+
setSelected,
|
|
27
|
+
toggleExpand,
|
|
28
|
+
onMention,
|
|
29
|
+
onStartRename,
|
|
30
|
+
onDelete,
|
|
31
|
+
}: UseKeyboardNavOptions) {
|
|
32
|
+
return (e: React.KeyboardEvent) => {
|
|
33
|
+
if (visibleFlatRows.length === 0) return
|
|
34
|
+
const currentIndex = visibleFlatRows.findIndex((r) => r.entry.path === focusedPath)
|
|
35
|
+
|
|
36
|
+
if (e.key === 'ArrowDown') {
|
|
37
|
+
e.preventDefault()
|
|
38
|
+
const nextIndex = currentIndex < visibleFlatRows.length - 1 ? currentIndex + 1 : 0
|
|
39
|
+
const nextRow = visibleFlatRows[nextIndex]
|
|
40
|
+
if (nextRow) {
|
|
41
|
+
setFocusedPath(nextRow.entry.path)
|
|
42
|
+
setSelected(new Set([nextRow.entry.path]))
|
|
43
|
+
}
|
|
44
|
+
} else if (e.key === 'ArrowUp') {
|
|
45
|
+
e.preventDefault()
|
|
46
|
+
const prevIndex = currentIndex > 0 ? currentIndex - 1 : visibleFlatRows.length - 1
|
|
47
|
+
const prevRow = visibleFlatRows[prevIndex]
|
|
48
|
+
if (prevRow) {
|
|
49
|
+
setFocusedPath(prevRow.entry.path)
|
|
50
|
+
setSelected(new Set([prevRow.entry.path]))
|
|
51
|
+
}
|
|
52
|
+
} else if (e.key === 'ArrowRight') {
|
|
53
|
+
const currentRow = visibleFlatRows[currentIndex]
|
|
54
|
+
if (currentRow?.entry.isDir && !expanded.has(currentRow.entry.path)) {
|
|
55
|
+
e.preventDefault()
|
|
56
|
+
toggleExpand(currentRow.entry.path)
|
|
57
|
+
}
|
|
58
|
+
} else if (e.key === 'ArrowLeft') {
|
|
59
|
+
const currentRow = visibleFlatRows[currentIndex]
|
|
60
|
+
if (currentRow?.entry.isDir && expanded.has(currentRow.entry.path)) {
|
|
61
|
+
e.preventDefault()
|
|
62
|
+
toggleExpand(currentRow.entry.path)
|
|
63
|
+
}
|
|
64
|
+
} else if (e.key === 'Enter') {
|
|
65
|
+
const currentRow = visibleFlatRows[currentIndex]
|
|
66
|
+
if (currentRow) {
|
|
67
|
+
e.preventDefault()
|
|
68
|
+
if (currentRow.entry.isDir) {
|
|
69
|
+
toggleExpand(currentRow.entry.path)
|
|
70
|
+
} else {
|
|
71
|
+
onMention(currentRow.relativePath)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} else if (e.key === 'F2') {
|
|
75
|
+
if (focusedPath) {
|
|
76
|
+
e.preventDefault()
|
|
77
|
+
onStartRename(focusedPath)
|
|
78
|
+
}
|
|
79
|
+
} else if (e.key === 'Delete') {
|
|
80
|
+
if (focusedPath) {
|
|
81
|
+
e.preventDefault()
|
|
82
|
+
const targetRow = visibleFlatRows.find((r) => r.entry.path === focusedPath)
|
|
83
|
+
if (targetRow) {
|
|
84
|
+
onDelete(targetRow.entry, targetRow.relativePath)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
interface UseLiveSyncOptions {
|
|
4
|
+
rootPath: string
|
|
5
|
+
onRefresh: () => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function useLiveSync({ rootPath, onRefresh }: UseLiveSyncOptions): void {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!rootPath) return
|
|
11
|
+
|
|
12
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
13
|
+
const scheduleRefresh = () => {
|
|
14
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
15
|
+
debounceTimer = setTimeout(() => {
|
|
16
|
+
onRefresh()
|
|
17
|
+
}, 100)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let es: EventSource | null = null
|
|
21
|
+
try {
|
|
22
|
+
const sseUrl = `/dsh-explorer/api/events?dir=${encodeURIComponent(rootPath)}`
|
|
23
|
+
es = new EventSource(sseUrl)
|
|
24
|
+
es.onmessage = (e) => {
|
|
25
|
+
try {
|
|
26
|
+
const data = JSON.parse(e.data)
|
|
27
|
+
if (data.type === 'connected') return
|
|
28
|
+
} catch {}
|
|
29
|
+
scheduleRefresh()
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.warn('[dsh-explorer] SSE init error:', err)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let ws: WebSocket | null = null
|
|
36
|
+
try {
|
|
37
|
+
const loc = window.location
|
|
38
|
+
const host = loc.host || (loc.port ? `127.0.0.1:${loc.port}` : '127.0.0.1')
|
|
39
|
+
const wsProtocol = loc.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
40
|
+
const wsUrl = `${wsProtocol}//${host}/dsh-explorer/api/watch?dir=${encodeURIComponent(rootPath)}`
|
|
41
|
+
ws = new WebSocket(wsUrl)
|
|
42
|
+
ws.onmessage = () => {
|
|
43
|
+
scheduleRefresh()
|
|
44
|
+
}
|
|
45
|
+
} catch {}
|
|
46
|
+
|
|
47
|
+
const onFocus = () => {
|
|
48
|
+
scheduleRefresh()
|
|
49
|
+
}
|
|
50
|
+
const onVisibilityChange = () => {
|
|
51
|
+
if (document.visibilityState === 'visible') {
|
|
52
|
+
scheduleRefresh()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
window.addEventListener('focus', onFocus)
|
|
56
|
+
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
57
|
+
|
|
58
|
+
const interval = setInterval(() => {
|
|
59
|
+
if (document.visibilityState === 'visible') {
|
|
60
|
+
scheduleRefresh()
|
|
61
|
+
}
|
|
62
|
+
}, 3000)
|
|
63
|
+
|
|
64
|
+
return () => {
|
|
65
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
66
|
+
clearInterval(interval)
|
|
67
|
+
window.removeEventListener('focus', onFocus)
|
|
68
|
+
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
69
|
+
if (es) {
|
|
70
|
+
try {
|
|
71
|
+
es.close()
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
if (ws) {
|
|
75
|
+
try {
|
|
76
|
+
ws.close()
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}, [rootPath, onRefresh])
|
|
81
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import React, { Component, type ReactNode, type ErrorInfo, memo } from 'react'
|
|
2
|
+
import { ExplorerView } from './ExplorerView.tsx'
|
|
3
|
+
import { attachGlobalConversationDropListener } from './utils/drag-drop.ts'
|
|
4
|
+
import { setupCaretTracker } from './utils/conversation-draft.ts'
|
|
5
|
+
|
|
6
|
+
export const inject = [
|
|
7
|
+
'slots',
|
|
8
|
+
'sessions',
|
|
9
|
+
'locale',
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
interface ErrorBoundaryProps {
|
|
13
|
+
children: ReactNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ErrorBoundaryState {
|
|
17
|
+
hasError: boolean
|
|
18
|
+
error: Error | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class ExplorerErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
22
|
+
state: ErrorBoundaryState = { hasError: false, error: null }
|
|
23
|
+
|
|
24
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
25
|
+
return { hasError: true, error }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
|
29
|
+
console.error('[dsh-explorer] Render error:', error, errorInfo)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
render(): ReactNode {
|
|
33
|
+
if (this.state.hasError) {
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
style={{
|
|
37
|
+
padding: '16px',
|
|
38
|
+
color: 'var(--dsw-alias-label-primary, #ffffff)',
|
|
39
|
+
fontSize: '12px',
|
|
40
|
+
fontFamily: 'var(--dsw-font-code, monospace)',
|
|
41
|
+
display: 'flex',
|
|
42
|
+
flexDirection: 'column',
|
|
43
|
+
gap: '12px',
|
|
44
|
+
height: '100%',
|
|
45
|
+
boxSizing: 'border-box',
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
<div style={{ fontWeight: 600, color: 'var(--dsw-alias-danger, #f85149)' }}>
|
|
49
|
+
Explorer encountered an error
|
|
50
|
+
</div>
|
|
51
|
+
<div
|
|
52
|
+
style={{
|
|
53
|
+
fontSize: '11px',
|
|
54
|
+
opacity: 0.85,
|
|
55
|
+
wordBreak: 'break-word',
|
|
56
|
+
background: 'var(--dsw-alias-bg-hover, rgba(255, 255, 255, 0.05))',
|
|
57
|
+
padding: '8px',
|
|
58
|
+
borderRadius: '4px',
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{this.state.error?.message || 'Unknown render exception'}
|
|
62
|
+
</div>
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={() => this.setState({ hasError: false, error: null })}
|
|
66
|
+
style={{
|
|
67
|
+
padding: '6px 12px',
|
|
68
|
+
borderRadius: '6px',
|
|
69
|
+
border: '1px solid var(--dsw-alias-hairline, rgba(255, 255, 255, 0.15))',
|
|
70
|
+
background: 'var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08))',
|
|
71
|
+
color: 'var(--dsw-alias-label-primary, #ffffff)',
|
|
72
|
+
cursor: 'pointer',
|
|
73
|
+
alignSelf: 'flex-start',
|
|
74
|
+
fontSize: '12px',
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
Reload Explorer
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
return this.props.children
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const ExplorerTabIcon = () => (
|
|
87
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
88
|
+
<path d="M14 4.5V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h6.5L14 4.5zm-1 0H9.5a.5.5 0 0 1-.5-.5V2H3v12h10V4.5z"/>
|
|
89
|
+
</svg>
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const ExplorerTabTitle = memo(function ExplorerTabTitle() {
|
|
93
|
+
return (
|
|
94
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
|
|
95
|
+
<ExplorerTabIcon />
|
|
96
|
+
<span>Explorer</span>
|
|
97
|
+
</span>
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const ExplorerPaneBody = memo(function ExplorerPaneBody(props: { ctx?: any; sessionId?: string }) {
|
|
102
|
+
return (
|
|
103
|
+
<ExplorerErrorBoundary>
|
|
104
|
+
<ExplorerView ctx={props.ctx} sessionId={props.sessionId} />
|
|
105
|
+
</ExplorerErrorBoundary>
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const ExplorerHeaderButton = ({ ctx, sessionId }: { ctx: any; sessionId?: string }) => {
|
|
110
|
+
return (
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
title="Explorer (File Tree)"
|
|
114
|
+
aria-label="Explorer"
|
|
115
|
+
onClick={() => {
|
|
116
|
+
try {
|
|
117
|
+
const sr = ctx.get ? ctx.get('sidebarRight') : ctx.sidebarRight
|
|
118
|
+
if (sessionId && sr?.openTabIn) {
|
|
119
|
+
sr.openTabIn(sessionId, 'explorer')
|
|
120
|
+
} else if (sr?.openTab) {
|
|
121
|
+
sr.openTab('explorer')
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.warn('[dsh-explorer] failed to open sidebar tab:', err)
|
|
125
|
+
}
|
|
126
|
+
}}
|
|
127
|
+
style={{
|
|
128
|
+
display: 'inline-flex',
|
|
129
|
+
alignItems: 'center',
|
|
130
|
+
justifyContent: 'center',
|
|
131
|
+
background: 'transparent',
|
|
132
|
+
border: 'none',
|
|
133
|
+
cursor: 'pointer',
|
|
134
|
+
padding: '3px 6px',
|
|
135
|
+
borderRadius: '6px',
|
|
136
|
+
color: 'var(--dsw-alias-label-secondary, #8b949e)',
|
|
137
|
+
transition: 'color 0.15s, background-color 0.15s',
|
|
138
|
+
}}
|
|
139
|
+
onMouseEnter={(e) => {
|
|
140
|
+
e.currentTarget.style.color = 'var(--dsw-alias-label-primary, #ffffff)'
|
|
141
|
+
e.currentTarget.style.backgroundColor = 'var(--dsw-alias-bg-hover, rgba(255, 255, 255, 0.08))'
|
|
142
|
+
}}
|
|
143
|
+
onMouseLeave={(e) => {
|
|
144
|
+
e.currentTarget.style.color = 'var(--dsw-alias-label-secondary, #8b949e)'
|
|
145
|
+
e.currentTarget.style.backgroundColor = 'transparent'
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
<ExplorerTabIcon />
|
|
149
|
+
</button>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function apply(ctx: any): void {
|
|
154
|
+
// 1. Live composer caret tracker (for precise insertion point)
|
|
155
|
+
ctx.effect(() => {
|
|
156
|
+
try {
|
|
157
|
+
return setupCaretTracker()
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.warn('[dsh-explorer] failed to setup caret tracker:', err)
|
|
160
|
+
return () => {}
|
|
161
|
+
}
|
|
162
|
+
}, 'dsh-explorer: caret tracker')
|
|
163
|
+
|
|
164
|
+
// 2. Global drag & drop listener onto DSH chat conversation
|
|
165
|
+
ctx.effect(() => {
|
|
166
|
+
try {
|
|
167
|
+
return attachGlobalConversationDropListener(ctx)
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.warn('[dsh-explorer] failed to attach conversation drop listener:', err)
|
|
170
|
+
return () => {}
|
|
171
|
+
}
|
|
172
|
+
}, 'dsh-explorer: conversation drop listener')
|
|
173
|
+
|
|
174
|
+
// 2. Direct icon button in conversation session header utilities
|
|
175
|
+
ctx.slots.inject('conversation.session.header.utilities', () =>
|
|
176
|
+
ctx.slots.register(
|
|
177
|
+
{
|
|
178
|
+
name: 'conversation.session.header.utilities',
|
|
179
|
+
id: 'dsh-explorer:header-button',
|
|
180
|
+
order: 15,
|
|
181
|
+
inject: (sessionId: string) => ({ sessionId }),
|
|
182
|
+
},
|
|
183
|
+
(props: { sessionId?: string }) => <ExplorerHeaderButton ctx={ctx} sessionId={props.sessionId} />
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
// 3. Register native tab into DSH right sidebar
|
|
188
|
+
ctx.inject(['sidebarRightTabs'], (injected: any) => {
|
|
189
|
+
const tabs = injected.get ? injected.get('sidebarRightTabs') : injected.sidebarRightTabs
|
|
190
|
+
if (!tabs?.register) return
|
|
191
|
+
|
|
192
|
+
// Register 'explorer' kind
|
|
193
|
+
const unregisterExplorer = tabs.register({
|
|
194
|
+
id: 'dsh-explorer',
|
|
195
|
+
kind: 'explorer',
|
|
196
|
+
keepMounted: true,
|
|
197
|
+
priority: 'extension',
|
|
198
|
+
title: () => 'Explorer',
|
|
199
|
+
guide: [
|
|
200
|
+
{
|
|
201
|
+
id: 'dsh-explorer',
|
|
202
|
+
order: 5,
|
|
203
|
+
title: () => 'Explorer',
|
|
204
|
+
description: () => 'VS Code style file explorer with drag & drop to chat',
|
|
205
|
+
icon: ExplorerTabIcon,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// Register pane slots for 'dsh-explorer'
|
|
211
|
+
const offBody = ctx.slots.inject('sidebar.right.pane.tab', () =>
|
|
212
|
+
ctx.slots.register(
|
|
213
|
+
{
|
|
214
|
+
name: 'sidebar.right.pane.tab',
|
|
215
|
+
key: 'dsh-explorer',
|
|
216
|
+
inject: (sessionId: string) => ({ ctx, sessionId }),
|
|
217
|
+
},
|
|
218
|
+
ExplorerPaneBody
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
const offTitle = ctx.slots.inject('sidebar.right.pane.tab.title', () =>
|
|
222
|
+
ctx.slots.register(
|
|
223
|
+
{
|
|
224
|
+
name: 'sidebar.right.pane.tab.title',
|
|
225
|
+
key: 'dsh-explorer',
|
|
226
|
+
inject: (sessionId: string) => ({ sessionId }),
|
|
227
|
+
},
|
|
228
|
+
ExplorerTabTitle
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return () => {
|
|
233
|
+
try { offTitle?.() } catch {}
|
|
234
|
+
try { offBody?.() } catch {}
|
|
235
|
+
try { unregisterExplorer?.() } catch {}
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
export interface CompactSegment {
|
|
2
|
+
name: string
|
|
3
|
+
path: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface FsEntry {
|
|
7
|
+
name: string
|
|
8
|
+
path: string
|
|
9
|
+
isDir: boolean
|
|
10
|
+
hidden: boolean
|
|
11
|
+
isSymlink: boolean
|
|
12
|
+
broken: boolean
|
|
13
|
+
compact?: boolean
|
|
14
|
+
compactName?: string
|
|
15
|
+
compactPath?: string
|
|
16
|
+
compactChain?: CompactSegment[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FsListing {
|
|
20
|
+
path: string
|
|
21
|
+
entries: FsEntry[]
|
|
22
|
+
truncated: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GitStatusEntry {
|
|
26
|
+
path: string
|
|
27
|
+
xy: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GitStatusResult {
|
|
31
|
+
isRepo: boolean
|
|
32
|
+
branch?: string
|
|
33
|
+
entries: GitStatusEntry[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function fetchTree(sessionId?: string, dir?: string): Promise<FsListing> {
|
|
37
|
+
const params = new URLSearchParams()
|
|
38
|
+
if (sessionId) params.set('sessionId', sessionId)
|
|
39
|
+
if (dir) params.set('dir', dir)
|
|
40
|
+
const res = await fetch(`/dsh-explorer/api/tree?${params.toString()}`)
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
const text = await res.text().catch(() => '')
|
|
43
|
+
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`)
|
|
44
|
+
}
|
|
45
|
+
const json = await res.json()
|
|
46
|
+
if (!json.ok) throw new Error(json.message || 'Failed to fetch directory')
|
|
47
|
+
return json.listing
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function fetchBatchTree(paths: string[]): Promise<Record<string, FsListing>> {
|
|
51
|
+
const res = await fetch('/dsh-explorer/api/batch-tree', {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({ paths }),
|
|
55
|
+
})
|
|
56
|
+
const json = await res.json()
|
|
57
|
+
if (!json.ok) throw new Error(json.message || 'Failed to fetch batch trees')
|
|
58
|
+
return json.results
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function fetchGitStatus(
|
|
62
|
+
dirOrOpts?: string | { dir?: string; sessionId?: string },
|
|
63
|
+
sessionId?: string
|
|
64
|
+
): Promise<GitStatusResult> {
|
|
65
|
+
const params = new URLSearchParams()
|
|
66
|
+
if (typeof dirOrOpts === 'object' && dirOrOpts !== null) {
|
|
67
|
+
if (dirOrOpts.dir) params.set('dir', dirOrOpts.dir)
|
|
68
|
+
if (dirOrOpts.sessionId) params.set('sessionId', dirOrOpts.sessionId)
|
|
69
|
+
} else {
|
|
70
|
+
if (dirOrOpts) params.set('dir', dirOrOpts)
|
|
71
|
+
if (sessionId) params.set('sessionId', sessionId)
|
|
72
|
+
}
|
|
73
|
+
const res = await fetch(`/dsh-explorer/api/git-status?${params.toString()}`)
|
|
74
|
+
if (!res.ok) return { isRepo: false, entries: [] }
|
|
75
|
+
const json = await res.json()
|
|
76
|
+
if (!json.ok) return { isRepo: false, entries: [] }
|
|
77
|
+
return json.git
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SearchMatch {
|
|
81
|
+
name: string
|
|
82
|
+
path: string
|
|
83
|
+
relativePath: string
|
|
84
|
+
isDir: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface SearchResult {
|
|
88
|
+
query: string
|
|
89
|
+
matches: SearchMatch[]
|
|
90
|
+
totalMatches: number
|
|
91
|
+
truncated: boolean
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function crawlClientSearch(
|
|
95
|
+
rootDir: string,
|
|
96
|
+
query: string,
|
|
97
|
+
sessionId?: string,
|
|
98
|
+
signal?: AbortSignal,
|
|
99
|
+
maxResults = 250
|
|
100
|
+
): Promise<SearchResult> {
|
|
101
|
+
const cleanQ = query.trim().toLowerCase()
|
|
102
|
+
if (!cleanQ) return { query, matches: [], totalMatches: 0, truncated: false }
|
|
103
|
+
|
|
104
|
+
const matches: SearchMatch[] = []
|
|
105
|
+
const queue: string[] = [rootDir]
|
|
106
|
+
const visited = new Set<string>()
|
|
107
|
+
const ignored = new Set([
|
|
108
|
+
'node_modules',
|
|
109
|
+
'.agent',
|
|
110
|
+
'.git',
|
|
111
|
+
'dist',
|
|
112
|
+
'target',
|
|
113
|
+
'build',
|
|
114
|
+
'.system_generated',
|
|
115
|
+
'.cache',
|
|
116
|
+
'.dsh',
|
|
117
|
+
'.gemini',
|
|
118
|
+
'.vscode',
|
|
119
|
+
'.idea',
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
let scanned = 0
|
|
123
|
+
const maxScanned = 1500
|
|
124
|
+
|
|
125
|
+
while (queue.length > 0 && matches.length < maxResults && scanned < maxScanned) {
|
|
126
|
+
if (signal?.aborted) break
|
|
127
|
+
const current = queue.shift()!
|
|
128
|
+
if (visited.has(current)) continue
|
|
129
|
+
visited.add(current)
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const listing = await fetchTree(sessionId, current)
|
|
133
|
+
if (!listing?.entries) continue
|
|
134
|
+
|
|
135
|
+
for (const ent of listing.entries) {
|
|
136
|
+
if (!ent || ent.name.startsWith('.')) continue
|
|
137
|
+
scanned++
|
|
138
|
+
const normRoot = rootDir.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
139
|
+
const normTarget = ent.path.replace(/\\/g, '/')
|
|
140
|
+
const relPath = normTarget.toLowerCase().startsWith(`${normRoot.toLowerCase()}/`)
|
|
141
|
+
? normTarget.slice(normRoot.length + 1)
|
|
142
|
+
: ent.path
|
|
143
|
+
|
|
144
|
+
const nameLower = ent.name.toLowerCase()
|
|
145
|
+
const relLower = relPath.toLowerCase()
|
|
146
|
+
|
|
147
|
+
if (nameLower.includes(cleanQ) || relLower.includes(cleanQ)) {
|
|
148
|
+
matches.push({
|
|
149
|
+
name: ent.name,
|
|
150
|
+
path: ent.path,
|
|
151
|
+
relativePath: relPath,
|
|
152
|
+
isDir: ent.isDir,
|
|
153
|
+
})
|
|
154
|
+
if (matches.length >= maxResults) break
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (ent.isDir && !ignored.has(ent.name)) {
|
|
158
|
+
queue.push(ent.path)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
query,
|
|
168
|
+
matches,
|
|
169
|
+
totalMatches: matches.length,
|
|
170
|
+
truncated: scanned >= maxScanned,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function fetchSearchWorkspace(
|
|
175
|
+
dir: string,
|
|
176
|
+
query: string,
|
|
177
|
+
sessionId?: string,
|
|
178
|
+
signal?: AbortSignal
|
|
179
|
+
): Promise<SearchResult> {
|
|
180
|
+
const params = new URLSearchParams({ dir, q: query })
|
|
181
|
+
if (sessionId) params.set('sessionId', sessionId)
|
|
182
|
+
try {
|
|
183
|
+
const res = await fetch(`/dsh-explorer/api/search?${params.toString()}`, { signal })
|
|
184
|
+
if (res.status === 404) {
|
|
185
|
+
return crawlClientSearch(dir, query, sessionId, signal)
|
|
186
|
+
}
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
return crawlClientSearch(dir, query, sessionId, signal)
|
|
189
|
+
}
|
|
190
|
+
const json = await res.json()
|
|
191
|
+
if (!json.ok || !json.result) {
|
|
192
|
+
return crawlClientSearch(dir, query, sessionId, signal)
|
|
193
|
+
}
|
|
194
|
+
return json.result
|
|
195
|
+
} catch (err: any) {
|
|
196
|
+
if (err?.name === 'AbortError') throw err
|
|
197
|
+
return crawlClientSearch(dir, query, sessionId, signal)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function fetchFileContent(path: string): Promise<string> {
|
|
202
|
+
const params = new URLSearchParams({ path })
|
|
203
|
+
const res = await fetch(`/dsh-explorer/api/read-file?${params.toString()}`)
|
|
204
|
+
const json = await res.json()
|
|
205
|
+
if (!json.ok) throw new Error(json.message || 'Failed to read file')
|
|
206
|
+
return json.text
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function apiCreateNewFile(dir: string, name: string): Promise<{ path: string }> {
|
|
210
|
+
const res = await fetch('/dsh-explorer/api/new-file', {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: { 'Content-Type': 'application/json' },
|
|
213
|
+
body: JSON.stringify({ dir, name }),
|
|
214
|
+
})
|
|
215
|
+
const json = await res.json()
|
|
216
|
+
if (!json.ok) throw new Error(json.message || 'Failed to create file')
|
|
217
|
+
return json
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function apiCreateDirectory(dir: string, name: string): Promise<{ path: string }> {
|
|
221
|
+
const res = await fetch('/dsh-explorer/api/mkdir', {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { 'Content-Type': 'application/json' },
|
|
224
|
+
body: JSON.stringify({ dir, name }),
|
|
225
|
+
})
|
|
226
|
+
const json = await res.json()
|
|
227
|
+
if (!json.ok) throw new Error(json.message || 'Failed to create folder')
|
|
228
|
+
return json
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function apiRename(path: string, newName: string): Promise<{ oldPath: string; newPath: string }> {
|
|
232
|
+
const res = await fetch('/dsh-explorer/api/rename', {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: { 'Content-Type': 'application/json' },
|
|
235
|
+
body: JSON.stringify({ path, newName }),
|
|
236
|
+
})
|
|
237
|
+
const json = await res.json()
|
|
238
|
+
if (!json.ok) throw new Error(json.message || 'Failed to rename')
|
|
239
|
+
return json
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function apiDelete(path: string): Promise<void> {
|
|
243
|
+
const res = await fetch('/dsh-explorer/api/delete', {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers: { 'Content-Type': 'application/json' },
|
|
246
|
+
body: JSON.stringify({ path }),
|
|
247
|
+
})
|
|
248
|
+
const json = await res.json()
|
|
249
|
+
if (!json.ok) throw new Error(json.message || 'Failed to delete')
|
|
250
|
+
}
|
|
251
|
+
|