dsh-explorer 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +106 -2
  3. package/cordis.patch.yml +5 -0
  4. package/icon.svg +7 -0
  5. package/lib/client.js +859 -0
  6. package/lib/index.js +854 -0
  7. package/package.json +91 -4
  8. package/src/client/ExplorerView.tsx +981 -0
  9. package/src/client/components/ExplorerContextMenu.tsx +157 -0
  10. package/src/client/components/ExplorerDeleteModal.tsx +124 -0
  11. package/src/client/components/ExplorerEmptyContextMenu.tsx +125 -0
  12. package/src/client/components/ExplorerHeader.tsx +81 -0
  13. package/src/client/components/ExplorerOptionsModal.tsx +230 -0
  14. package/src/client/components/ExplorerRow.tsx +258 -0
  15. package/src/client/components/ExplorerSearch.tsx +55 -0
  16. package/src/client/components/TreeNode.tsx +384 -0
  17. package/src/client/css-modules.d.ts +5 -0
  18. package/src/client/explorer.module.css +833 -0
  19. package/src/client/hooks/useKeyboardNav.ts +89 -0
  20. package/src/client/hooks/useLiveSync.ts +81 -0
  21. package/src/client/index.tsx +238 -0
  22. package/src/client/utils/api.ts +251 -0
  23. package/src/client/utils/conversation-draft.ts +644 -0
  24. package/src/client/utils/drag-drop.ts +209 -0
  25. package/src/client/utils/open-tab.ts +69 -0
  26. package/src/client/utils/options.ts +59 -0
  27. package/src/client/utils/vscode-theme.tsx +250 -0
  28. package/src/config.ts +11 -0
  29. package/src/fs-operations.ts +116 -0
  30. package/src/fs-search.ts +116 -0
  31. package/src/fs-tree.ts +155 -0
  32. package/src/fs-watch.ts +179 -0
  33. package/src/git-status.ts +167 -0
  34. package/src/index.ts +177 -0
  35. package/src/path-security.ts +22 -0
  36. package/src/session-path.ts +35 -0
  37. package/src/wire.ts +63 -0
@@ -0,0 +1,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
+