From 113b02657c0760e80d9d84343be2429bbd6d0f63 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 26 Sep 2026 20:14:30 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E9=98=85=E8=AF=BB=E5=B7=A5=E4=BD=9C=E5=8C=BA=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E8=B7=B3=E8=BF=87=E5=B5=8C=E5=A5=97=E7=9A=84=20checko?= =?UTF-8?q?ut=20=E5=92=8C=E5=BF=BD=E7=95=A5=E6=96=87=E4=BB=B6=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=E7=9A=84=EF=BC=8C=E4=BB=BB=E5=8A=A1=E5=90=8D=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=9F=A5=E5=88=B0=E4=B8=A4=E5=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 5 +- vscode/package.json | 1 + vscode/pnpm-lock.yaml | 13 +-- vscode/src/codeReader/contract.ts | 5 +- vscode/src/codeReader/workspaceFiles.ts | 29 ++++++- vscode/src/codeReader/workspaceScope.test.ts | 84 ++++++++++++++++++++ vscode/src/codeReader/workspaceScope.ts | 77 ++++++++++++++++++ 7 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 vscode/src/codeReader/workspaceScope.test.ts create mode 100644 vscode/src/codeReader/workspaceScope.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 8e41506..18153a2 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -552,7 +552,7 @@ export interface CodeReaderApi { - 查:webview 不认引用,⌘ / Ctrl 悬停、点击照旧只报名字的下标(`probeDefinition` / `goToDefinition`)。扩展端记着最近发出去的那份文档里哪些下标是引用(只对那个文档版本有效),是引用的交给标它的规则的 `resolve`,其余照旧问语言扩展(`vscode.executeDefinitionProvider`);两边查到的位置走同一条路:链接样子、一处原地滚 / 在预览里打开 / 普通编辑器打开、几处弹挑选框、闪「就是这处定义」的那块或那一行,都和「跳到定义」一节一样。规则注销了、`resolve` 出错时记日志,当作没有定义。 - 在整个工作区里找:规则不能碰 VS Code,只描述一个 `WorkspaceIndex`(看哪些文件、便宜的文字预筛、从一份语法树里认出「键 → 定义处名字」),`resolve` 里调 `workspace.lookup(索引, 键)`。宿主(`workspaceIndex.ts`,VS Code 那一层在 `workspaceFiles.ts`): - 第一次查时才建,同一个 id 只建一份,建的时候不挡预览(查的那一下等它建好); - - 按 `include` 在各工作区文件夹里找文件,`files.exclude`、`search.exclude` 里开着的和 `.venv`、`node_modules`、`__pycache__` 不看;每次读 32 份,预筛不过的不解析,用 `languageId` 的语言插件解析,语法树用完释放; + - 按 `include` 在各工作区文件夹里找文件,`files.exclude`、`search.exclude` 里开着的和 `.venv`、`node_modules`、`__pycache__` 不看;自己是一份 checkout 的子目录(带 `.git` 文件或目录:子仓库、git worktree,例如 `.claude/worktrees/…` 里的副本,不然同一个任务会查到两处)整个不看,工作区文件夹自己的 `.git` 不算;`search.useIgnoreFiles` 开着时(默认开,和 VS Code 搜索一样)路上各层目录的 `.gitignore`、`.ignore` 忽略的不看——每层的规则相对那一层判断,哪一层忽略了就不看,下层不能把上层忽略的加回来;工作区文件夹上面的、全局的忽略文件和 `.git/info/exclude` 不读(VS Code 的 `search.useParentIgnoreFiles`、`search.useGlobalIgnoreFiles` 默认也不读)。插件声明的引擎版本(^1.97)的正式 API 里 `findFiles` 不能按忽略文件筛,所以忽略文件由宿主自己读(`workspaceScope.ts`);文件改动时新建、改了的文件照同样的规则筛,改了忽略文件不会重扫已经建好的索引。每次读 32 份,预筛不过的不解析,用 `languageId` 的语言插件解析,语法树用完释放; - 盯着文件改动:改了只重看那一份;新建的加进来;删掉的(目录连里面的)去掉;规则认的时候问过在不在(`IndexedFile.exists`)的文件新建、删掉时,问过它的那几份跟着重看(Python 包路径看 `__init__.py`);建索引期间的改动建好后补上; - 读不了、规则认的时候出错的文件记日志、跳过,别的照常。 @@ -931,6 +931,7 @@ vscode/src/codeReader/ libraryRules.ts 库规则的宿主部分:按导入启用、按注册顺序认领、出错退回、编辑范围和留下的子块、查规则标的引用 workspaceIndex.ts 库规则的工作区索引(WorkspaceIndex)的宿主部分:建、缓存、跟着文件改动更新(不碰 VS Code) workspaceFiles.ts 工作区索引在 VS Code 里怎么找文件、读文件、盯改动(排除规则) + workspaceScope.ts 工作区索引还要跳过的文件:嵌套的 checkout、忽略文件忽略的(不碰 VS Code) rules/sqlalchemy.ts 内置 SQLAlchemy 库规则(只 import contract 和同目录的共用代码) rules/celery.ts 内置 Celery 库规则:两张卡片、频率换算、任务名索引 rules/enum.ts 内置 enum 库规则(Python):枚举类认成枚举块,成员照 Python 的规则认 @@ -980,7 +981,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 同一行不在两处能改(webview `editing.test.ts`):卡片编辑时盖住它留下的子块(字段中间的方法连同主体),跳到被盖住的块改闪卡片;只编辑头部的块不盖住子块,一行写完的块盖住同一行的子块;点「编辑」时重叠的块退出编辑;任意点法下正在编辑、画出来的块两两不重叠。 - SQLAlchemy 规则:luffy `referral/models.py` 的节选(`fixtures/sqlalchemyModels.py`)里 `ReferralRelation` 的属性表逐字同上面的样例,其余模型覆盖 `Mapped[bool]`、`int | None` 可空、`default=None`、`server_default`、别名的 `PgUUID`、`_enum` 辅助函数、关系、唯一约束、删除时置空 / 级联删除;枚举类、函数照常显示;卡片的编辑范围是整个类、每行只在一块里;两个字段中间写着的方法照常是子块、卡片「编辑」写回整个类、字段前面的注释并进卡片、讲方法的注释留下;类名、字段名的定义范围和带下标的词都指回源码原文;webview 里跳到类名闪整块、跳到字段名只闪字段表里那一行并先展开「字段」那一行,卡片默认折叠;`import sqlalchemy as sa` 的老写法 `Column`、类型全名、只写注解的字段、不认得的类型照写法、docstring 当说明、方法留作子块。 - 库规则的引用:引用标的不是自己、规则没给 `resolve`、段没带下标时当替身不合约定;查引用交给标它的规则,规则不在、没给 `resolve`、出错时记下来、当作没有定义;视图数据里的引用(下标 → 引用)只从规则认领的块的属性表里取,查定义时是引用的问规则、其余问语言扩展,只对记下它的那个文档版本有效(`editor/definitionSources.test.ts`)。 -- 工作区索引(内存里的工作区,`MemoryIndexHost`):第一次查才建、同一个 id 只建一份;查到的是名字的行列(列按 UTF-16)、照文件和行排好;预筛不过的不解析;改了只重看那一份、新建加进来、删目录连里面的去掉;新建 / 删掉问过在不在的文件(`__init__.py`)时问过它的文件重看;建索引期间的改动建好后补上;读不了、规则出错的记下来、别的照常;dispose 后不再盯着。 +- 工作区索引(内存里的工作区,`MemoryIndexHost`):第一次查才建、同一个 id 只建一份;查到的是名字的行列(列按 UTF-16)、照文件和行排好;预筛不过的不解析;改了只重看那一份、新建加进来、删目录连里面的去掉;新建 / 删掉问过在不在的文件(`__init__.py`)时问过它的文件重看;建索引期间的改动建好后补上;读不了、规则出错的记下来、别的照常;dispose 后不再盯着。还要跳过的文件(`workspaceScope.test.ts`,内存里的文件夹):子目录里 `.git` 是文件的 git worktree、`.git` 是目录的子仓库整个跳过,工作区自己的 `.git` 不算;`.gitignore` 忽略的目录跳过,每层规则相对那一层、取反照 gitignore 写法,`.ignore` 也读;`search.useIgnoreFiles` 关着时不读忽略文件、嵌套的 checkout 照样跳过;同一个目录只查一次。 - Celery 规则:`fixtures/celeryApp.py` 的应用卡片和定时任务卡片逐字同上面的样例(列名、「12 条,另有展开 1 处」、行尾注释、条目里单独成行的注释、跨行的 `10⏎ * 60`、`每 get_settings().… 秒`、展开一行);任务名是引用、下标指回源码原文;应用卡片的位置参数按参数顺序认、别的参数各一行、展开、属性 docstring、别名;没导入 celery、不是 `Celery(...)` 的不认;频率换算(秒数算式、`timedelta`、`crontab` 各种写法、`schedule`、换算不了的「每 X 秒」和照写法);说明取哪些注释;条目名和任务名不同、没写任务名、任务名不是字符串、值不是字典、选项一列有才有;`conf.update(beat_schedule=…)`、不带 `beat_schedule` 的 `conf.update` 和 `autodiscover_tasks` 照常。任务名索引:显式 `name=`、默认「模块路径.函数名」(包、`src` 下的命名空间包、没有包)、`@shared_task` / `@shared_task()` / `@app.task` 不带括号 / 老写法 `@task`、`async def`、函数里的只认显式名字、`name=` 不是字符串的不认、同名几处都给、文件改了和新建 `__init__.py` 后跟着变、没有任务装饰器的文件不解析。 - 枚举(`rules/enum.test.ts`、`languages/typescript.test.ts`):`fixtures/enumModels.py` 的 `CommissionStatus` 逐字同上面的样例,`SecretScope` 的 docstring 当说明、没有说明列;种类 enum、叫「枚举」、访问级别同原来的类、没有标签(badge),编辑范围是整个类;Python 成员规则(dunder、sunder、私有名、`_ignore_`、lambda、`property`、`nonmember`、只写注解的不是成员,`auto()` 写「自动」,`member(x)` 取 x,说明取上面的注释块、行尾注释、属性 docstring,装饰器);方法、嵌套类、不是成员的属性照源码顺序是子块,成员前面的注释并进来、最后面的留下,「编辑」写回整个类;类名、成员名的定义范围和带下标的词指回源码原文;没导入 enum、SQLAlchemy 的 `Enum`、不是枚举类型的父类不认,`import enum as e` 的 `e.IntEnum` 认。TypeScript:`enums.ts` 里 export / const / declare / 函数里的 enum 都是枚举块、JSDoc 进头部,写的值、自增算出的值(接不上数字就没有)、说明(上面的注释和 JSDoc、行尾注释,同一行的 JSDoc 讲下一个),访问级别;自增的值和「自动」按 hint。TypeScript 和 Python 同样的成员得到一样的种类、属性表行、列名和格子着色(除了 Python 多的「继承」)。宿主:规则认成现成属性表块时照语言插件报的这种块显示、并进来的不是子块时照常显示;webview 枚举块默认折叠。 - 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。 diff --git a/vscode/package.json b/vscode/package.json index a777889..df2f646 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -233,6 +233,7 @@ "@vscode/tree-sitter-wasm": "^0.3.1", "diff": "^9.0.0", "handlebars": "^4.7.9", + "ignore": "^7.0.10", "minimatch": "^10.2.6" }, "devDependencies": { diff --git a/vscode/pnpm-lock.yaml b/vscode/pnpm-lock.yaml index dd595bc..0d95c6c 100644 --- a/vscode/pnpm-lock.yaml +++ b/vscode/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: handlebars: specifier: ^4.7.9 version: 4.7.9 + ignore: + specifier: ^7.0.10 + version: 7.0.10 minimatch: specifier: ^10.2.6 version: 10.2.6 @@ -2350,8 +2353,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.10: + resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} engines: {node: '>= 4'} import-fresh@3.3.0: @@ -4730,7 +4733,7 @@ snapshots: '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.52.0 eslint: 9.39.2(jiti@2.6.1) - ignore: 7.0.5 + ignore: 7.0.10 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -5893,7 +5896,7 @@ snapshots: dependencies: '@sindresorhus/merge-streams': 2.3.0 fast-glob: 3.3.3 - ignore: 7.0.5 + ignore: 7.0.10 path-type: 6.0.0 slash: 5.1.0 unicorn-magic: 0.3.0 @@ -5969,7 +5972,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.5: {} + ignore@7.0.10: {} import-fresh@3.3.0: dependencies: diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 98cb54d..564f1d2 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -431,7 +431,10 @@ export interface WorkspaceIndex { id: string /** 按哪种语言解析:宿主用这个 languageId 的语言插件。 */ languageId: string - /** 看哪些文件:相对各工作区文件夹的 glob('**\/*.py');files.exclude、search.exclude 排除的,和 .venv、node_modules、__pycache__ 里的不看。 */ + /** + * 看哪些文件:相对各工作区文件夹的 glob('**\/*.py');files.exclude、search.exclude 排除的,.venv、node_modules、__pycache__ 里的, + * 嵌套的 checkout(子仓库、git worktree)里的,和忽略文件(.gitignore、.ignore,按 search.useIgnoreFiles)忽略的不看。 + */ include: string /** 便宜的文字预筛:源码里不可能有要找的东西就不解析。 */ mentions: (source: string) => boolean diff --git a/vscode/src/codeReader/workspaceFiles.ts b/vscode/src/codeReader/workspaceFiles.ts index d99fcda..a1ba545 100644 --- a/vscode/src/codeReader/workspaceFiles.ts +++ b/vscode/src/codeReader/workspaceFiles.ts @@ -1,8 +1,10 @@ import type { WorkspaceFolder } from 'vscode' import type { IndexHost, WorkspaceFile } from './workspaceIndex' +import type { FolderReader } from './workspaceScope' import * as path from 'node:path' import { minimatch } from 'minimatch' import { RelativePattern, Uri, workspace } from 'vscode' +import { folderScope } from './workspaceScope' /** 建索引时总不看的目录:虚拟环境、依赖、Python 字节码缓存。 */ const ALWAYS_EXCLUDED = ['**/.venv', '**/node_modules', '**/__pycache__'] @@ -36,17 +38,34 @@ function workspaceFile(uri: Uri): { file: WorkspaceFile, folder: WorkspaceFolder return { folder, file: { uri: uri.toString(), folder: folder.uri.toString(), path: path.posix.relative(folder.uri.path, uri.path) } } } -/** 库规则的工作区索引在 VS Code 里怎么找文件、读文件、盯改动;解析交给调用方(语言插件 + 解析运行时)。 */ +/** 这个工作区文件夹里还要跳过的(嵌套的 checkout、忽略文件忽略的),见 folderScope;忽略文件按 search.useIgnoreFiles。 */ +function scopeOf(folder: WorkspaceFolder): ReturnType { + const at = (path: string): Uri => Uri.joinPath(folder.uri, path) + const reader: FolderReader = { + exists: path => Promise.resolve(workspace.fs.stat(at(path))).then(() => true, () => false), + read: path => Promise.resolve(workspace.fs.readFile(at(path))).then(bytes => new TextDecoder('utf-8').decode(bytes), () => undefined), + } + return folderScope(reader, workspace.getConfiguration('search', folder.uri).get('useIgnoreFiles', true)) +} + +/** + * 库规则的工作区索引在 VS Code 里怎么找文件、读文件、盯改动;解析交给调用方(语言插件 + 解析运行时)。 + * 不看的:files.exclude、search.exclude 排除的、总不看的目录,加上嵌套的 checkout 和忽略文件忽略的(见 folderScope)。 + * 插件声明的引擎版本(^1.97)的正式 API 里 findFiles 不能按忽略文件筛,所以忽略文件自己读。 + */ export function workspaceIndexHost(parse: IndexHost['parse'], report: IndexHost['report']): IndexHost { return { async list(include) { const found = await Promise.all((workspace.workspaceFolders ?? []).map(async (folder) => { const patterns = excludedPatterns(folder) + const scope = scopeOf(folder) const uris = await workspace.findFiles(new RelativePattern(folder, include), excludeGlob(patterns)) - return uris.flatMap((uri) => { + const files = uris.flatMap((uri) => { const file = workspaceFile(uri)?.file return file && !isExcluded(file.path, patterns) ? [file] : [] }) + const skipped = await Promise.all(files.map(file => scope.skips(file.path))) + return files.filter((_, i) => !skipped[i]) })) return found.flat() }, @@ -54,9 +73,11 @@ export function workspaceIndexHost(parse: IndexHost['parse'], report: IndexHost[ return new TextDecoder('utf-8').decode(await workspace.fs.readFile(Uri.parse(file.uri))) }, watch(include, listener) { - const forward = (kind: 'change' | 'create') => (uri: Uri): void => { + const forward = (kind: 'change' | 'create') => async (uri: Uri): Promise => { const found = workspaceFile(uri) - if (found && minimatch(found.file.path, include, { dot: true }) && !isExcluded(found.file.path, excludedPatterns(found.folder))) + if (!found || !minimatch(found.file.path, include, { dot: true }) || isExcluded(found.file.path, excludedPatterns(found.folder))) + return + if (!await scopeOf(found.folder).skips(found.file.path)) listener({ kind, file: found.file }) } // 删目录时 VS Code 只报目录本身,匹配不上 include,所以删除另用一个什么都盯的 diff --git a/vscode/src/codeReader/workspaceScope.test.ts b/vscode/src/codeReader/workspaceScope.test.ts new file mode 100644 index 0000000..ba2a03d --- /dev/null +++ b/vscode/src/codeReader/workspaceScope.test.ts @@ -0,0 +1,84 @@ +import type { FolderReader } from './workspaceScope' +import { describe, expect, it } from 'vitest' +import { folderScope } from './workspaceScope' + +/** 内存里的工作区文件夹:路径 → 内容(目录不用列,列出来的文件所在的目录都算有);记下读过哪些。 */ +function memoryFolder(files: Record): FolderReader & { asked: string[] } { + const asked: string[] = [] + const directories = new Set(Object.keys(files).flatMap((file) => { + const parts = file.split('/') + return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join('/')) + })) + return { + asked, + exists: async (path) => { + asked.push(path) + return path in files || directories.has(path) + }, + read: async (path) => { + asked.push(path) + return files[path] + }, + } +} + +async function kept(files: Record, paths: string[], useIgnoreFiles = true): Promise { + const scope = folderScope(memoryFolder(files), useIgnoreFiles) + const skipped = await Promise.all(paths.map(path => scope.skips(path))) + return paths.filter((_, i) => !skipped[i]) +} + +const TASKS = 'backend/src/luffy_agent/commerce/tasks.py' +const WORKTREE = '.claude/worktrees/agent-aa35211c29ea9e068' + +describe('工作区扫描还要跳过的文件', () => { + it('嵌套的 git worktree(子目录里的 .git 是个文件)整个跳过,工作区自己的 .git 不算', async () => { + const files = { + '.git/HEAD': 'ref: refs/heads/main', + [TASKS]: '', + [`${WORKTREE}/.git`]: 'gitdir: /repo/.git/worktrees/agent-aa35211c29ea9e068', + [`${WORKTREE}/${TASKS}`]: '', + } + expect(await kept(files, [TASKS, `${WORKTREE}/${TASKS}`], false)).toEqual([TASKS]) + }) + + it('嵌套的仓库(子目录里的 .git 是个目录)也跳过', async () => { + expect(await kept({ 'vendor/lib/.git/HEAD': '', 'vendor/lib/a.py': '', 'vendor/b.py': '' }, ['vendor/lib/a.py', 'vendor/b.py'])).toEqual(['vendor/b.py']) + }) + + it('.gitignore 忽略的目录跳过;每层的规则相对那一层,取反照 gitignore 写法;.ignore 也读', async () => { + const files = { + '.gitignore': '.claude/worktrees/\nbuild/\n*.log\n!keep.log\n', + 'pkg/.gitignore': 'generated/\n', + 'tools/.ignore': 'scratch.py\n', + [`${WORKTREE}/${TASKS}`]: '', + } + const paths = [ + TASKS, + `${WORKTREE}/${TASKS}`, + 'build/out/a.py', + 'src/build.py', + 'pkg/generated/a.py', + 'generated/a.py', + 'logs/x.log', + 'logs/keep.log', + 'tools/scratch.py', + 'tools/run.py', + ] + expect(await kept(files, paths)).toEqual([TASKS, 'src/build.py', 'generated/a.py', 'logs/keep.log', 'tools/run.py']) + }) + + it('search.useIgnoreFiles 关着时不读忽略文件,嵌套的 checkout 照样跳过', async () => { + const files = { '.gitignore': 'build/\n', 'sub/.git': 'gitdir: x', 'sub/a.py': '' } + expect(await kept(files, ['build/a.py', 'sub/a.py'], false)).toEqual(['build/a.py']) + }) + + it('同一个目录只查一次', async () => { + const folder = memoryFolder({ 'a/b/c.py': '', 'a/b/d.py': '' }) + const scope = folderScope(folder, true) + await scope.skips('a/b/c.py') + await scope.skips('a/b/d.py') + expect(folder.asked.filter(path => path === 'a/b/.git')).toEqual(['a/b/.git']) + expect(folder.asked.filter(path => path === 'a/.gitignore')).toEqual(['a/.gitignore']) + }) +}) diff --git a/vscode/src/codeReader/workspaceScope.ts b/vscode/src/codeReader/workspaceScope.ts new file mode 100644 index 0000000..1e7fef2 --- /dev/null +++ b/vscode/src/codeReader/workspaceScope.ts @@ -0,0 +1,77 @@ +import type { Ignore } from 'ignore' +import ignore from 'ignore' + +/** 读一个工作区文件夹里的东西,路径相对这个文件夹、用 / 分隔。 */ +export interface FolderReader { + /** 有没有这个文件或目录。 */ + exists: (path: string) => Promise + /** 文本文件的内容;没有、读不了时没有。 */ + read: (path: string) => Promise +} + +/** 和 VS Code 搜索一样读的忽略文件(search.useIgnoreFiles)。 */ +const IGNORE_FILES = ['.gitignore', '.ignore'] + +/** path 所在的各层目录,从工作区文件夹('')往下,不含 path 自己。 */ +function directoriesOf(path: string): string[] { + const parts = path.split('/').slice(0, -1) + return ['', ...parts.map((_, i) => parts.slice(0, i + 1).join('/'))] +} + +function joined(directory: string, name: string): string { + return directory ? `${directory}/${name}` : name +} + +/** + * 工作区文件夹里建索引时还要跳过的文件(files.exclude、search.exclude 之外): + * ① 在自己是一份 checkout 的子目录里(带 `.git` 文件或目录:子仓库、git worktree,例如 `.claude/worktrees/…`)—— + * 那是另一份代码的副本,算进来同一个定义会出现两次;工作区文件夹自己的 `.git` 不算。 + * ② useIgnoreFiles 开着时(同 VS Code 的 search.useIgnoreFiles,默认开),被路上各层目录的 `.gitignore`、`.ignore` 忽略的: + * 每层的规则按 gitignore 写法、相对那一层目录判断;哪一层忽略了就跳过,下层不能再把上层忽略的加回来。 + * 工作区文件夹上面的目录、全局的忽略文件、`.git/info/exclude` 不读(VS Code 的 search.useParentIgnoreFiles、 + * search.useGlobalIgnoreFiles 默认也不读)。 + * 一个实例记着查过的目录,一次扫描、一个文件改动各用一个。 + */ +export function folderScope(reader: FolderReader, useIgnoreFiles: boolean): { skips: (path: string) => Promise } { + const checkouts = new Map>() + const rules = new Map>() + + const isCheckout = (directory: string): Promise => { + let found = checkouts.get(directory) + if (!found) { + found = reader.exists(joined(directory, '.git')) + checkouts.set(directory, found) + } + return found + } + + const rulesIn = (directory: string): Promise => { + let found = rules.get(directory) + if (!found) { + found = Promise.all(IGNORE_FILES.map(name => reader.read(joined(directory, name)))).then((texts) => { + const written = texts.filter((text): text is string => text !== undefined) + return written.length > 0 ? ignore().add(written.join('\n')) : undefined + }) + rules.set(directory, found) + } + return found + } + + return { + async skips(path) { + const directories = directoriesOf(path) + for (const directory of directories.slice(1)) { + if (await isCheckout(directory)) + return true + } + if (!useIgnoreFiles) + return false + for (const directory of directories) { + const rule = await rulesIn(directory) + if (rule?.ignores(directory ? path.slice(directory.length + 1) : path)) + return true + } + return false + }, + } +}