右侧文件窗口的全局文件名搜索会深度优先遍历整个工作区,原先只跳过 .git。含大型 node_modules 的仓库会先把 10 万条访问预算耗尽,导致 docs/ 等后序目录里的真实文件搜不到。 搜索改为跳过依赖、包管理存储和常见构建缓存目录(不匹配也不下降), 并补回归测试钉住 node_modules / dist 内的命中不会泄漏。
114 lines
3.9 KiB
TypeScript
114 lines
3.9 KiB
TypeScript
/**
|
|
* Recursive file-name search for the editor's merged-mode side panel.
|
|
* Streams the tree with opendir and matches the query as a case-insensitive
|
|
* substring of each entry's NAME (paths stay relative to the search root —
|
|
* the client resolves them against the session cwd). No .gitignore semantics
|
|
* (this is a name lookup, not a code search), but known noise directories
|
|
* (`.git`, `node_modules`, package-manager stores, build caches) are
|
|
* skipped outright and symlink directories are NOT descended (cycle safety).
|
|
*
|
|
* Two performance budgets bound the walk: `maxMatches` (the client renders
|
|
* the flat list) and `maxVisited` (a runaway tree — a home directory root
|
|
* — must not stall the host). Exceeding either stops early with
|
|
* `truncated: true`.
|
|
*/
|
|
import { opendir } from 'node:fs/promises'
|
|
import { join, relative, sep } from 'node:path'
|
|
|
|
/** One search: the relative paths of the matching entries (dirs included so
|
|
* the client can hint where matches live) plus the truncation flag. */
|
|
export interface FsSearchResult {
|
|
matches: string[]
|
|
truncated: boolean
|
|
}
|
|
|
|
/** Search budgets (both injectable for tests). */
|
|
export interface FsSearchOptions {
|
|
/** Row cap of the result list (default 200). */
|
|
maxMatches?: number
|
|
/** Total entries visited before the walk gives up (default 100_000). */
|
|
maxVisited?: number
|
|
}
|
|
|
|
const DEFAULT_MAX_MATCHES = 200
|
|
const DEFAULT_MAX_VISITED = 100_000
|
|
|
|
/**
|
|
* Directory names that are never useful filename-search results and would
|
|
* burn the visit budget before the walk reaches project files. Compared
|
|
* case-insensitively so `Node_Modules` / `.GIT` stay skipped on every
|
|
* platform. The directory itself is neither matched nor descended.
|
|
*/
|
|
const SEARCH_SKIP_DIRS = new Set([
|
|
'.git',
|
|
'node_modules',
|
|
'.pnpm-store',
|
|
'.yarn',
|
|
'.turbo',
|
|
'.turbopack',
|
|
'.next',
|
|
'.nuxt',
|
|
'.output',
|
|
'.cache',
|
|
'.parcel-cache',
|
|
'coverage',
|
|
'dist',
|
|
'build',
|
|
'out',
|
|
'.umi',
|
|
'.umi-production',
|
|
'.dumi',
|
|
])
|
|
|
|
/**
|
|
* Search `root` recursively for entries whose name contains `query`
|
|
* (case-insensitive).
|
|
* @param root - absolute search root.
|
|
* @param query - the name substring; empty matches nothing.
|
|
* @param opts - budget overrides (tests).
|
|
* @returns the matching paths RELATIVE to `root` ('/'-separated), sorted,
|
|
* plus whether a budget cut the walk short. An unreadable level is skipped
|
|
* (permission errors never fail the whole search).
|
|
*/
|
|
export async function searchFiles(root: string, query: string, opts: FsSearchOptions = {}): Promise<FsSearchResult> {
|
|
const needle = query.trim().toLowerCase()
|
|
if (needle === '') return { matches: [], truncated: false }
|
|
const maxMatches = opts.maxMatches ?? DEFAULT_MAX_MATCHES
|
|
const maxVisited = opts.maxVisited ?? DEFAULT_MAX_VISITED
|
|
|
|
const matches: string[] = []
|
|
let visited = 0
|
|
let truncated = false
|
|
|
|
const walk = async (dir: string): Promise<void> => {
|
|
if (truncated) return
|
|
const level = await opendir(dir).catch(() => undefined)
|
|
if (level === undefined) return
|
|
for await (const dirent of level) {
|
|
visited += 1
|
|
if (visited > maxVisited) {
|
|
truncated = true
|
|
return
|
|
}
|
|
// Dependency / VCS / build-output forests: never matched, never descended.
|
|
if (dirent.isDirectory() && SEARCH_SKIP_DIRS.has(dirent.name.toLowerCase())) continue
|
|
if (dirent.name.toLowerCase().includes(needle)) {
|
|
matches.push(join(relative(root, dir), dirent.name))
|
|
if (matches.length >= maxMatches) {
|
|
truncated = true
|
|
return
|
|
}
|
|
}
|
|
// Descend real directories only: a symlinked directory may point back
|
|
// up the tree (cycle).
|
|
if (dirent.isDirectory() && !dirent.isSymbolicLink()) {
|
|
await walk(join(dir, dirent.name))
|
|
if (truncated) return
|
|
}
|
|
}
|
|
}
|
|
await walk(root)
|
|
// '/' separators on every platform: the client joins onto the cwd itself.
|
|
return { matches: matches.sort().map(path => path.split(sep).join('/')), truncated }
|
|
}
|