fix(explorer): 文件名搜索跳过 node_modules 等噪声目录

右侧文件窗口的全局文件名搜索会深度优先遍历整个工作区,原先只跳过
.git。含大型 node_modules 的仓库会先把 10 万条访问预算耗尽,导致
docs/ 等后序目录里的真实文件搜不到。

搜索改为跳过依赖、包管理存储和常见构建缓存目录(不匹配也不下降),
并补回归测试钉住 node_modules / dist 内的命中不会泄漏。
This commit is contained in:
leokuang
2026-08-23 02:50:37 +08:00
parent d9b8f15d9e
commit d0f194b67a
3 changed files with 61 additions and 12 deletions
+1 -1
View File
@@ -712,7 +712,7 @@ better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据)
- **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护
- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git`/symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`)
- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git` / `node_modules` / 构建缓存 / symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`)
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录)
调试时直接读这些文件即可看到所有 API 的真实用法。
+35 -8
View File
@@ -3,14 +3,14 @@
* 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 `.git` directories are
* skipped outright (VCS internals are never useful results) and symlink
* directories are NOT descended (cycle safety).
* (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,
* a node_modules forest — must not stall the host). Exceeding either stops
* early with `truncated: true`.
* 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'
@@ -33,6 +33,33 @@ export interface FsSearchOptions {
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).
@@ -63,8 +90,8 @@ export async function searchFiles(root: string, query: string, opts: FsSearchOpt
truncated = true
return
}
// .git is VCS-internal noise: never matched, never descended.
if (dirent.isDirectory() && dirent.name === '.git') continue
// 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) {
+25 -3
View File
@@ -1,9 +1,10 @@
/**
* fs-search: the host's recursive file-name search behind the editor side
* panel's search box. Matches are case-insensitive name substrings, reported
* RELATIVE to the root ('/'-separated); `.git` directories are skipped,
* symlinked directories are never descended (cycle safety), and the
* maxMatches/maxVisited budgets stop a runaway walk with `truncated: true`.
* RELATIVE to the root ('/'-separated); noise directories (`.git`,
* `node_modules`, build caches) are skipped, symlinked directories are
* never descended (cycle safety), and the maxMatches/maxVisited budgets
* stop a runaway walk with `truncated: true`.
*/
import { describe, expect, it } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
@@ -86,6 +87,27 @@ describe('fs-search', () => {
}
})
it('never descends into node_modules or other noise directories', async () => {
const dir = makeFixture()
try {
mkdirSync(join(dir, 'node_modules', 'left-pad'), { recursive: true })
mkdirSync(join(dir, 'web', 'dist'), { recursive: true })
writeFileSync(join(dir, 'node_modules', 'left-pad', 'guide.md'), 'dep')
writeFileSync(join(dir, 'web', 'dist', 'bundle.js'), 'build')
writeFileSync(join(dir, 'web', 'app.ts'), 'src')
// A match hidden behind node_modules / dist must not appear; project
// files after those forests must still be reachable within budget.
expect((await searchFiles(dir, 'guide')).matches).toEqual(['docs/guide.md'])
expect((await searchFiles(dir, 'left-pad')).matches).toEqual([])
expect((await searchFiles(dir, 'bundle')).matches).toEqual([])
expect((await searchFiles(dir, 'app.ts')).matches).toEqual(['web/app.ts'])
expect((await searchFiles(dir, 'node_modules')).matches).toEqual([])
expect((await searchFiles(dir, 'dist')).matches).toEqual([])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('an empty (or whitespace) query matches nothing without walking', async () => {
const dir = makeFixture()
try {