diff --git a/AGENTS.md b/AGENTS.md index 24b7a9f..4803df2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 的真实用法。 diff --git a/src/fs-search.ts b/src/fs-search.ts index 597bbb0..c78d98f 100644 --- a/src/fs-search.ts +++ b/src/fs-search.ts @@ -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) { diff --git a/tests/fs-search.spec.ts b/tests/fs-search.spec.ts index 9bb0c6b..ca10763 100644 --- a/tests/fs-search.spec.ts +++ b/tests/fs-search.spec.ts @@ -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 {