🐛 fix(vscode): 代码阅读工作区索引跳过嵌套的 checkout 和忽略文件忽略的,任务名不再查到两处
This commit is contained in:
1 parent
de355661f0
commit
113b02657c
7 files changed
+202
-12
No files matched your search
@@ -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`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+8
-5
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof folderScope> {
|
||||
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<boolean>('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<void> => {
|
||||
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,所以删除另用一个什么都盯的
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { FolderReader } from './workspaceScope'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { folderScope } from './workspaceScope'
|
||||
|
||||
/** 内存里的工作区文件夹:路径 → 内容(目录不用列,列出来的文件所在的目录都算有);记下读过哪些。 */
|
||||
function memoryFolder(files: Record<string, string>): 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<string, string>, paths: string[], useIgnoreFiles = true): Promise<string[]> {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Ignore } from 'ignore'
|
||||
import ignore from 'ignore'
|
||||
|
||||
/** 读一个工作区文件夹里的东西,路径相对这个文件夹、用 / 分隔。 */
|
||||
export interface FolderReader {
|
||||
/** 有没有这个文件或目录。 */
|
||||
exists: (path: string) => Promise<boolean>
|
||||
/** 文本文件的内容;没有、读不了时没有。 */
|
||||
read: (path: string) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
/** 和 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<boolean> } {
|
||||
const checkouts = new Map<string, Promise<boolean>>()
|
||||
const rules = new Map<string, Promise<Ignore | undefined>>()
|
||||
|
||||
const isCheckout = (directory: string): Promise<boolean> => {
|
||||
let found = checkouts.get(directory)
|
||||
if (!found) {
|
||||
found = reader.exists(joined(directory, '.git'))
|
||||
checkouts.set(directory, found)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
const rulesIn = (directory: string): Promise<Ignore | undefined> => {
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user