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.
@@ -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
+ }
@@ -1,7 +1,7 @@
1
- import React, { createElement, memo } from '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 <ExplorerView ctx={props.ctx} sessionId={props.sessionId} />
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(sessionId?: string, dir?: string): Promise<GitStatusResult> {
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 (sessionId) params.set('sessionId', sessionId)
64
- if (dir) params.set('dir', dir)
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
- if (typeof document === 'undefined') return
111
- const el = findComposerElement()
112
- if (!el) return
110
+ try {
111
+ if (typeof document === 'undefined') return
112
+ const el = findComposerElement()
113
+ if (!el) return
113
114
 
114
- if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
115
- const ta = el as HTMLTextAreaElement
116
- const start = ta.selectionStart
117
- const end = ta.selectionEnd
118
- if (typeof start === 'number' && typeof end === 'number' && Number.isFinite(start) && Number.isFinite(end)) {
119
- trackedCaret = {
120
- start: Math.max(0, Math.min(start, ta.value.length)),
121
- end: Math.max(start, Math.min(end, ta.value.length)),
122
- draft: ta.value,
123
- timestamp: Date.now(),
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
- } else if (el.isContentEditable) {
127
- const sel = window.getSelection()
128
- if (sel && sel.rangeCount > 0 && el.contains(sel.anchorNode)) {
129
- const range = sel.getRangeAt(0)
130
- const preRange = range.cloneRange()
131
- preRange.selectNodeContents(el)
132
- preRange.setEnd(range.startContainer, range.startOffset)
133
- const start = preRange.toString().length
134
- const length = range.toString().length
135
- trackedCaret = {
136
- start,
137
- end: start + length,
138
- draft: el.innerText ?? el.textContent ?? '',
139
- timestamp: Date.now(),
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
+ }
@@ -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
+ }