dsh-explorer 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +817 -2772
- package/lib/index.js +124 -17
- package/package.json +1 -2
- package/src/client/ExplorerView.tsx +494 -652
- package/src/client/{ExplorerContextMenu.tsx → components/ExplorerContextMenu.tsx} +2 -4
- package/src/client/components/ExplorerDeleteModal.tsx +124 -0
- package/src/client/components/ExplorerEmptyContextMenu.tsx +125 -0
- package/src/client/{ExplorerHeader.tsx → components/ExplorerHeader.tsx} +18 -12
- package/src/client/{ExplorerOptionsModal.tsx → components/ExplorerOptionsModal.tsx} +2 -15
- package/src/client/{ExplorerRow.tsx → components/ExplorerRow.tsx} +45 -27
- package/src/client/{ExplorerSearch.tsx → components/ExplorerSearch.tsx} +18 -5
- package/src/client/components/TreeNode.tsx +384 -0
- package/src/client/explorer.module.css +215 -10
- package/src/client/hooks/useKeyboardNav.ts +89 -0
- package/src/client/hooks/useLiveSync.ts +81 -0
- package/src/client/index.tsx +82 -4
- package/src/client/{api.ts → utils/api.ts} +133 -3
- package/src/client/{conversation-draft.ts → utils/conversation-draft.ts} +38 -29
- package/src/client/utils/open-tab.ts +69 -0
- package/src/fs-search.ts +116 -0
- package/src/git-status.ts +19 -13
- package/src/index.ts +12 -0
- /package/src/client/{drag-drop.ts → utils/drag-drop.ts} +0 -0
- /package/src/client/{options.ts → utils/options.ts} +0 -0
- /package/src/client/{vscode-theme.tsx → utils/vscode-theme.tsx} +0 -0
|
@@ -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
|
+
}
|
package/src/client/index.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { Component, type ReactNode, type ErrorInfo, memo } from 'react'
|
|
2
2
|
import { ExplorerView } from './ExplorerView.tsx'
|
|
3
|
-
import { attachGlobalConversationDropListener } from './drag-drop.ts'
|
|
4
|
-
import { setupCaretTracker } from './conversation-draft.ts'
|
|
3
|
+
import { attachGlobalConversationDropListener } from './utils/drag-drop.ts'
|
|
4
|
+
import { setupCaretTracker } from './utils/conversation-draft.ts'
|
|
5
5
|
|
|
6
6
|
export const inject = [
|
|
7
7
|
'slots',
|
|
@@ -9,6 +9,80 @@ export const inject = [
|
|
|
9
9
|
'locale',
|
|
10
10
|
]
|
|
11
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
|
+
|
|
12
86
|
const ExplorerTabIcon = () => (
|
|
13
87
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
14
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"/>
|
|
@@ -25,7 +99,11 @@ const ExplorerTabTitle = memo(function ExplorerTabTitle() {
|
|
|
25
99
|
})
|
|
26
100
|
|
|
27
101
|
const ExplorerPaneBody = memo(function ExplorerPaneBody(props: { ctx?: any; sessionId?: string }) {
|
|
28
|
-
return
|
|
102
|
+
return (
|
|
103
|
+
<ExplorerErrorBoundary>
|
|
104
|
+
<ExplorerView ctx={props.ctx} sessionId={props.sessionId} />
|
|
105
|
+
</ExplorerErrorBoundary>
|
|
106
|
+
)
|
|
29
107
|
})
|
|
30
108
|
|
|
31
109
|
const ExplorerHeaderButton = ({ ctx, sessionId }: { ctx: any; sessionId?: string }) => {
|
|
@@ -58,16 +58,146 @@ export async function fetchBatchTree(paths: string[]): Promise<Record<string, Fs
|
|
|
58
58
|
return json.results
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
export async function fetchGitStatus(
|
|
61
|
+
export async function fetchGitStatus(
|
|
62
|
+
dirOrOpts?: string | { dir?: string; sessionId?: string },
|
|
63
|
+
sessionId?: string
|
|
64
|
+
): Promise<GitStatusResult> {
|
|
62
65
|
const params = new URLSearchParams()
|
|
63
|
-
if (
|
|
64
|
-
|
|
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
|
+
}
|
|
65
73
|
const res = await fetch(`/dsh-explorer/api/git-status?${params.toString()}`)
|
|
74
|
+
if (!res.ok) return { isRepo: false, entries: [] }
|
|
66
75
|
const json = await res.json()
|
|
67
76
|
if (!json.ok) return { isRepo: false, entries: [] }
|
|
68
77
|
return json.git
|
|
69
78
|
}
|
|
70
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
|
+
|
|
71
201
|
export async function fetchFileContent(path: string): Promise<string> {
|
|
72
202
|
const params = new URLSearchParams({ path })
|
|
73
203
|
const res = await fetch(`/dsh-explorer/api/read-file?${params.toString()}`)
|
|
@@ -107,39 +107,48 @@ export function findComposerElement(): HTMLElement | null {
|
|
|
107
107
|
* Continuously track user selection/caret in composer.
|
|
108
108
|
*/
|
|
109
109
|
export function recordLiveCaret(): void {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
try {
|
|
111
|
+
if (typeof document === 'undefined') return
|
|
112
|
+
const el = findComposerElement()
|
|
113
|
+
if (!el) return
|
|
113
114
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
115
|
+
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
|
|
116
|
+
const ta = el as HTMLTextAreaElement
|
|
117
|
+
const start = ta.selectionStart
|
|
118
|
+
const end = ta.selectionEnd
|
|
119
|
+
if (typeof start === 'number' && typeof end === 'number' && Number.isFinite(start) && Number.isFinite(end)) {
|
|
120
|
+
trackedCaret = {
|
|
121
|
+
start: Math.max(0, Math.min(start, ta.value.length)),
|
|
122
|
+
end: Math.max(start, Math.min(end, ta.value.length)),
|
|
123
|
+
draft: ta.value,
|
|
124
|
+
timestamp: Date.now(),
|
|
125
|
+
}
|
|
124
126
|
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
127
|
+
} else if (el.isContentEditable) {
|
|
128
|
+
const sel = window.getSelection()
|
|
129
|
+
if (sel && sel.rangeCount > 0 && sel.anchorNode && el.contains(sel.anchorNode)) {
|
|
130
|
+
const range = sel.getRangeAt(0)
|
|
131
|
+
if (range && range.startContainer && el.contains(range.startContainer)) {
|
|
132
|
+
const maxOffset =
|
|
133
|
+
range.startContainer.nodeType === Node.TEXT_NODE
|
|
134
|
+
? (range.startContainer.nodeValue?.length ?? 0)
|
|
135
|
+
: range.startContainer.childNodes.length
|
|
136
|
+
const safeOffset = Math.max(0, Math.min(range.startOffset, maxOffset))
|
|
137
|
+
const preRange = range.cloneRange()
|
|
138
|
+
preRange.selectNodeContents(el)
|
|
139
|
+
preRange.setEnd(range.startContainer, safeOffset)
|
|
140
|
+
const start = preRange.toString().length
|
|
141
|
+
const length = range.toString().length
|
|
142
|
+
trackedCaret = {
|
|
143
|
+
start,
|
|
144
|
+
end: start + length,
|
|
145
|
+
draft: el.innerText ?? el.textContent ?? '',
|
|
146
|
+
timestamp: Date.now(),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
140
149
|
}
|
|
141
150
|
}
|
|
142
|
-
}
|
|
151
|
+
} catch {}
|
|
143
152
|
}
|
|
144
153
|
|
|
145
154
|
let caretTrackerAttached = false
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { baseName } from './conversation-draft.ts'
|
|
2
|
+
|
|
3
|
+
export function safeService<T = any>(ctx: any, name: string): T | undefined {
|
|
4
|
+
if (!ctx) return undefined
|
|
5
|
+
try {
|
|
6
|
+
if (typeof ctx.get === 'function') {
|
|
7
|
+
const val = ctx.get(name)
|
|
8
|
+
if (val !== undefined) return val
|
|
9
|
+
}
|
|
10
|
+
return ctx[name]
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function openFileInTab(ctx: any, sessionId: string, filePath: string, relPath: string): void {
|
|
17
|
+
const normPath = filePath.replace(/\\/g, '/')
|
|
18
|
+
const fileName = baseName(filePath)
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const betterSidebar = safeService(ctx, 'betterSidebar')
|
|
22
|
+
if (betterSidebar && typeof betterSidebar.openTab === 'function') {
|
|
23
|
+
betterSidebar.openTab({
|
|
24
|
+
type: 'editor',
|
|
25
|
+
title: fileName,
|
|
26
|
+
path: filePath,
|
|
27
|
+
id: `editor:${filePath}`,
|
|
28
|
+
})
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
} catch {}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const sidebarRight = safeService(ctx, 'sidebarRight')
|
|
35
|
+
if (sidebarRight && typeof sidebarRight.openResource === 'function') {
|
|
36
|
+
const normRel = relPath.replace(/\\/g, '/')
|
|
37
|
+
const encodePath = (p: string) =>
|
|
38
|
+
p
|
|
39
|
+
.split('/')
|
|
40
|
+
.map((seg) => encodeURIComponent(seg).replace(/%3A/gi, ':'))
|
|
41
|
+
.join('/')
|
|
42
|
+
const address = sessionId
|
|
43
|
+
? `dsh-resource://file/session/${encodeURIComponent(sessionId)}/${encodePath(normRel)}`
|
|
44
|
+
: `dsh-resource://file/absolute/${encodePath(normPath.replace(/^\/+/, ''))}`
|
|
45
|
+
sidebarRight.openResource(address)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const tabs = safeService(ctx, 'tabs')
|
|
52
|
+
if (tabs && typeof tabs.open === 'function') {
|
|
53
|
+
tabs.open({ path: filePath, title: fileName })
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
} catch {}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const remote = ctx?.remote?.session
|
|
60
|
+
if (remote && typeof remote.openWorkspacePath === 'function') {
|
|
61
|
+
remote.openWorkspacePath({ path: filePath })
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
window.open(`/dsh-explorer/api/read-file?path=${encodeURIComponent(filePath)}`, '_blank')
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
package/src/fs-search.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface SearchMatch {
|
|
5
|
+
name: string
|
|
6
|
+
path: string
|
|
7
|
+
relativePath: string
|
|
8
|
+
isDir: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SearchResult {
|
|
12
|
+
query: string
|
|
13
|
+
matches: SearchMatch[]
|
|
14
|
+
totalMatches: number
|
|
15
|
+
truncated: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const IGNORED_SEARCH_DIRS = new Set([
|
|
19
|
+
'node_modules',
|
|
20
|
+
'.agent',
|
|
21
|
+
'.git',
|
|
22
|
+
'dist',
|
|
23
|
+
'target',
|
|
24
|
+
'build',
|
|
25
|
+
'.system_generated',
|
|
26
|
+
'.cache',
|
|
27
|
+
'.dsh',
|
|
28
|
+
'.gemini',
|
|
29
|
+
'.vscode',
|
|
30
|
+
'.idea',
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
export async function searchDirectory(
|
|
34
|
+
rootDir: string,
|
|
35
|
+
query: string,
|
|
36
|
+
maxResults = 250,
|
|
37
|
+
includeHidden = false
|
|
38
|
+
): Promise<SearchResult> {
|
|
39
|
+
const cleanQuery = query.trim().toLowerCase()
|
|
40
|
+
if (!cleanQuery) {
|
|
41
|
+
return { query, matches: [], totalMatches: 0, truncated: false }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isExtensionFilter = cleanQuery.startsWith('*.') && cleanQuery.length > 2
|
|
45
|
+
const extensionTarget = isExtensionFilter ? cleanQuery.slice(1) : ''
|
|
46
|
+
|
|
47
|
+
const matches: SearchMatch[] = []
|
|
48
|
+
let truncated = false
|
|
49
|
+
let scannedCount = 0
|
|
50
|
+
const maxScanned = 30000
|
|
51
|
+
|
|
52
|
+
const queue: string[] = [rootDir]
|
|
53
|
+
|
|
54
|
+
while (queue.length > 0 && matches.length < maxResults && scannedCount < maxScanned) {
|
|
55
|
+
const currentDir = queue.shift()!
|
|
56
|
+
let entries
|
|
57
|
+
try {
|
|
58
|
+
entries = await readdir(currentDir, { withFileTypes: true })
|
|
59
|
+
} catch {
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const ent of entries) {
|
|
64
|
+
scannedCount++
|
|
65
|
+
if (scannedCount >= maxScanned) {
|
|
66
|
+
truncated = true
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!includeHidden && ent.name.startsWith('.')) {
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isDirectory = ent.isDirectory()
|
|
75
|
+
if (isDirectory && IGNORED_SEARCH_DIRS.has(ent.name)) {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fullPath = join(currentDir, ent.name)
|
|
80
|
+
const relPath = relative(rootDir, fullPath).replace(/\\/g, '/')
|
|
81
|
+
const nameLower = ent.name.toLowerCase()
|
|
82
|
+
const relLower = relPath.toLowerCase()
|
|
83
|
+
|
|
84
|
+
let isMatch = false
|
|
85
|
+
if (isExtensionFilter) {
|
|
86
|
+
isMatch = !isDirectory && nameLower.endsWith(extensionTarget)
|
|
87
|
+
} else {
|
|
88
|
+
isMatch = nameLower.includes(cleanQuery) || relLower.includes(cleanQuery)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isMatch) {
|
|
92
|
+
matches.push({
|
|
93
|
+
name: ent.name,
|
|
94
|
+
path: fullPath,
|
|
95
|
+
relativePath: relPath,
|
|
96
|
+
isDir: isDirectory,
|
|
97
|
+
})
|
|
98
|
+
if (matches.length >= maxResults) {
|
|
99
|
+
truncated = true
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isDirectory) {
|
|
105
|
+
queue.push(fullPath)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
query,
|
|
112
|
+
matches,
|
|
113
|
+
totalMatches: matches.length,
|
|
114
|
+
truncated,
|
|
115
|
+
}
|
|
116
|
+
}
|