✨ feat(vscode): 代码阅读标出项目写明的入口文件和入口函数
This commit is contained in:
1 parent
78d5748b81
commit
718bbb7f6c
19 files changed
+761
-83
No files matched your search
@@ -31,6 +31,7 @@
|
||||
| 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释、按审查工单只看 PR 改过的文件;不做新建 / 重命名 / 删除 |
|
||||
| 审查工单 | 标题栏第一个按钮「选工单」列出看板「审查」列的工单和「进行中」列里已经有 PR 的工单(按列分组),选中后树里只剩这张工单的 PR 改过的文件(目录层级照留、全部展开,按 M / A / D 着色,删掉的列出来但打不开);点开的是本机这张工单的工作副本里 PR 最新的代码,不标差异、不开 diff;本机没有工作副本就拉 PR 分支建一个。弹框列的是看板上现在摆着的工单(不再加载一遍),选中后重读那张工单和它的 PR。见「审查工单」 |
|
||||
| 文件注释 | 读代码时给目录 / 文件随手记一句:存在各工作区文件夹的 `.spx/code-notes.json`,跟着仓库走;在 VS Code 里改名、挪位置、删除时注释跟着变 |
|
||||
| 入口 | 只认项目自己写明的启动项:pyproject.toml 的 `[project.scripts]` / `[project.gui-scripts]`、Cargo.toml 的 `src/main.rs` 和 `[[bin]]` 的 `path`、go.mod 模块里 `package main` 加 `func main` 的文件、package.json 的 `bin`;不按文件名、函数名猜。侧栏灰字「入口」(有注释时「注释 · 入口」),预览里名字对上的块名称后面加浅色「入口」。见「入口」 |
|
||||
| 问 AI | 块右上角「问 AI」,在块下面问 Claude Code(`claude -p`,只读,用输入框旁边选的 profile,记住上次选的,没选过用插件默认的)这块代码,回答逐字出来按 Markdown 显示、能追问(接着这块最近一次的会话,收起再展开、重启 VS Code 也接得上)、能停;输入框里能粘贴 / 拖进图片随问题发(存在 `.spx/code-qa/`,跟着问答删);问答存进各工作区文件夹的 `.spx/code-qa.json`,灰字挂在块下面免得重复问,理解了就删。问答按块的位置记号挂,记号只由和语言无关的块数据(种类、facts 里的名字、源码哈希)算出,文件别处改了照样认得;认不回来的不显示,留在文件里,代码改回来又挂回去。见「问 AI」 |
|
||||
| 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) |
|
||||
| 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 |
|
||||
@@ -84,7 +85,24 @@
|
||||
- 在 VS Code 里改名、挪位置(资源管理器拖动、`WorkspaceEdit` 等,走 `workspace.onDidRenameFiles`):注释跟着走,目录连带里面各层的注释一起走;挪到另一个工作区文件夹就搬到那边的文件里,挪出工作区就不要了。删除(`onDidDeleteFiles`):连带目录里各层的注释一起删。在 VS Code 外面(终端、git)改名删除不跟。
|
||||
- 外部改了 `code-notes.json`(git pull、手改):文件监听重读,树上跟着变。
|
||||
- 刷新:只重画注释变了的那几行(文件树按 Uri 记住交给 VS Code 的节点对象,VS Code 按对象认行);还没展开过的行不用管。
|
||||
- 注释和代码阅读的解析、翻译无关,单独放在 `notes/`;文件树只留一个「给行添信息」的接口(`FileTreeDecorator`),由 `index.ts` 接上。
|
||||
- 注释和代码阅读的解析、翻译无关,单独放在 `notes/`;文件树只留一个「给行添信息」的接口(`FileTreeDecorator`),由 `explorer/rowDecorator.ts` 的 `RowDecorator` 把注释和项目入口(见下节)合成一行的灰字和提示(纯函数 `explorer/rowDecoration.ts`),`index.ts` 接上。
|
||||
|
||||
### 入口
|
||||
|
||||
读一个陌生项目先找它从哪儿启动:侧栏标出项目的入口文件,点进预览再标出入口函数。只认项目自己写明的启动项,不靠文件名、函数名猜——叫 `main.py`、里面有个 `main` 函数、写了 `if __name__ == "__main__"`、`src/lib.rs`、`index.js` 都不算入口。
|
||||
|
||||
- 认这四种,每种只有一条(`entries/entryPoints.ts` 的 `findEntryPoints`,纯函数:传入找到的文件路径,读内容、问「这个路径在不在」都经传进来的 `FolderReader`):
|
||||
| 项目 | 读哪儿 | 入口文件 | 预览里标的名字 |
|
||||
| --- | --- | --- | --- |
|
||||
| Python | `pyproject.toml` 的 `[project.scripts]`、`[project.gui-scripts]`,值 `模块.路径:名字` | 相对 pyproject.toml 所在目录按顺序找第一个存在的:`src/<模块路径>.py`、`<模块路径>.py`、`src/<模块路径>/__init__.py`、`<模块路径>/__init__.py`(点换成 `/`);都没有就不标 | `:` 后面的名字(带点的取第一节,`[extra]` 不管);只写了模块的只标文件 |
|
||||
| Rust | `Cargo.toml` | 所在目录的 `src/main.rs`;`[[bin]]` 写了 `path` 的,那个路径(相对 Cargo.toml 所在目录,不存在的不标) | `main` |
|
||||
| Go | `go.mod` | 这个模块自己的目录里(递归,但别的 go.mod 的子目录归那个模块)同时有 `package main` 和最外层 `func main(` 的 `.go` 文件;注释、字符串里的,方法 `func (T) main()` 不算 | `main` |
|
||||
| JavaScript / TypeScript | `package.json` 的 `"bin"`:字符串,或 `{ "命令名": "文件" }` | 那些文件(相对 package.json 所在目录,不存在的不标) | 不标,只标文件 |
|
||||
- 不认的:`[tool.poetry.scripts]` 等别的表、`[[bin]]` 没写 `path` 的、`src/bin/*.rs` 自动发现、`src/lib.rs`、package.json 的 `main` / `exports` / `scripts`。
|
||||
- 一个工作区文件夹里可以有很多个项目(子目录各有自己的清单),各算各的;同一个文件被几条指到,名字合在一起。不进 `node_modules`、`.venv`、`vendor`、`dist`、`__pycache__`(还有 `.git`)。
|
||||
- 什么时候算(`entries/workspaceEntries.ts` 的 `WorkspaceEntries`):打开时每个工作区文件夹扫一遍(`findFiles` 找清单和 `.go`,排除上面那几个目录);清单、`.go` 改了,或文件增删了(入口文件出现、消失),过 500ms 重扫那个工作区文件夹;入口变了的文件报给侧栏重画那几行、开着的预览重新发文档。
|
||||
- 侧栏:入口文件那一行名字后面的灰字,没注释时是「入口」,有注释时是「注释 · 入口」,不盖掉注释;悬停提示在注释下面再写一行「入口,从 `app` 开始读」(没有名字的写「入口」)。contextValue 不变,菜单照旧只按有没有注释换。目录、工作区外面的行(审查的工作副本)不标。
|
||||
- 预览:入口文件里,文件最外层、名字对上的那一块(函数、常量、变量、库规则的卡片,谁对上就是谁;Python 的 `app = typer.Typer()` 是变量块),属性表里写着这个名字的那一行、名字后面加一个浅色的「入口」——就是属性表里的说明词(`hint`),不另做标记。宿主在翻译好的结果上加(`entries/entryMarks.ts` 的 `markEntryNames`,`documentMessage` 里调),各语言的翻译插件不用管:块的名字按定义处(`defines`)在源码里的那一段认,属性表里按词的源码下标找到写名字的那个词;对不上就什么都不加。标差异时旧版本按同样的入口标,「入口」不会被当成改动。
|
||||
|
||||
### 审查工单
|
||||
|
||||
@@ -2003,9 +2021,13 @@ vscode/src/codeReader/
|
||||
folderStore.ts FolderStore:每个工作区文件夹一份表,内存里的一份、文件监听、跟着改名 / 删除走、读写排队、改表后删掉不再有条目带着的文件
|
||||
notes/noteMap.ts 保存一条注释(纯函数)
|
||||
notes/store.ts .spx/code-notes.json 叫什么、每条怎么认
|
||||
notes/noteItem.ts 有注释的行怎么显示:描述、悬停提示、contextValue(纯函数)
|
||||
notes/fileNotes.ts FileNotes:注释的 FolderStore、给树画行
|
||||
notes/fileNotes.ts FileNotes:注释的 FolderStore、一行的注释
|
||||
notes/commands.ts 「添加注释 / 编辑注释」输入框
|
||||
explorer/rowDecoration.ts 一行名字后面的灰字、悬停提示、contextValue:注释,后面接入口(纯函数)
|
||||
explorer/rowDecorator.ts RowDecorator:把注释和入口交给 rowDecoration 画行(FileTreeDecorator)
|
||||
entries/entryPoints.ts 项目写明的入口:四种清单 / Go 源码 → 入口文件和名字(纯函数,读文件经 FolderReader)
|
||||
entries/entryMarks.ts 预览里名字对上的块,名称后面加「入口」(纯函数)
|
||||
entries/workspaceEntries.ts WorkspaceEntries:各工作区文件夹扫一遍、文件变了重扫、报出入口变了的文件
|
||||
qa/anchor.ts 块的位置记号(BlockAnchors)(不碰 VS Code)
|
||||
qa/qaMap.ts .spx/code-qa.json 的形状、每条怎么认、记一次 / 删一次、再问时接着哪个会话(纯函数)
|
||||
qa/prompt.ts 问 AI 的话:找到那块、外层块的开头、带行号的代码、追问
|
||||
|
||||
+2
-1
@@ -476,7 +476,8 @@
|
||||
"diff": "^9.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"ignore": "^7.0.10",
|
||||
"minimatch": "^10.2.6"
|
||||
"minimatch": "^10.2.6",
|
||||
"toml": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^6.7.3",
|
||||
|
||||
Generated
+9
@@ -28,6 +28,9 @@ importers:
|
||||
minimatch:
|
||||
specifier: ^10.2.6
|
||||
version: 10.2.6
|
||||
toml:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
devDependencies:
|
||||
'@antfu/eslint-config':
|
||||
specifier: ^6.7.3
|
||||
@@ -3393,6 +3396,10 @@ packages:
|
||||
resolution: {integrity: sha512-9mjy3frhioGIVGcwamlVlUyJ9x+WHw/TXiz9R4YOlmsIuBN43r9Dp8HZ35SF9EKjHrn3BUZj04CF+YqZ2oJ+7w==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
toml@5.0.0:
|
||||
resolution: {integrity: sha512-bsqEd+g7fzlrw7LEynQ6W6aOK/lTOlepz5x1sJyIV9fTZVJS2Q76nuju6FK+gZhW5bUpvB7mCEqdR0u4WziMFw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tree-kill@1.2.2:
|
||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||
hasBin: true
|
||||
@@ -7209,6 +7216,8 @@ snapshots:
|
||||
dependencies:
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
toml@5.0.0: {}
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
||||
tree-sitter-wasm@2.0.2: {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Memento } from 'vscode'
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { LibraryRule } from '../contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { packageCommandRules } from '../commands/packageCommands'
|
||||
@@ -6,14 +7,16 @@ import { shellCommandRules } from '../commands/shellCommands'
|
||||
import { createBashAdapter } from '../languages/bash'
|
||||
import { createDockerfileAdapter } from '../languages/dockerfile'
|
||||
import { createPythonAdapter } from '../languages/python'
|
||||
import { createRustAdapter } from '../languages/rust'
|
||||
import { createTypeScriptAdapter } from '../languages/typescript'
|
||||
import { CodeReaderRegistry } from '../registry'
|
||||
import { fastapiRule } from '../rules/fastapi'
|
||||
import { sqlalchemyRule } from '../rules/sqlalchemy'
|
||||
import { DOCKERFILE_WASM_DIR, runtime, WASM_DIR } from '../testSupport'
|
||||
import { DOCKERFILE_WASM_DIR, propertyLines, runtime, WASM_DIR } from '../testSupport'
|
||||
import { bashZhTranslator } from '../translators/bashZh'
|
||||
import { dockerfileZhTranslator } from '../translators/dockerfileZh'
|
||||
import { pythonZhTranslator } from '../translators/pythonZh'
|
||||
import { rustZhTranslator } from '../translators/rustZh'
|
||||
import { typescriptZhTranslator } from '../translators/typescriptZh'
|
||||
import { documentMessage } from './documentMessage'
|
||||
|
||||
@@ -196,3 +199,44 @@ describe('预览的 document 消息', () => {
|
||||
expect(message.error).toBe('语法包加载失败')
|
||||
})
|
||||
})
|
||||
|
||||
describe('入口文件的预览', () => {
|
||||
const CLI = [
|
||||
'import typer',
|
||||
'',
|
||||
'app = typer.Typer()',
|
||||
'',
|
||||
'',
|
||||
'def main():',
|
||||
' pass',
|
||||
'',
|
||||
'',
|
||||
'def helper():',
|
||||
' pass',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const nameRow = (block: BlockView): string => 'properties' in block ? propertyLines(block.properties)[0] : ''
|
||||
|
||||
it('名字对上的块(变量、函数都算),名称那一行名字后面加浅色的「入口」', async () => {
|
||||
const { message } = await documentMessage({ ...doc('python', CLI), entryNames: ['app', 'main'] }, registryWithPython(), runtime, ignore)
|
||||
expect(message.blocks.slice(1).map(nameRow)).toEqual(['名称: app 入口', '名称: main 入口', '名称: helper'])
|
||||
const [, app] = message.blocks
|
||||
expect('properties' in app && app.properties[0].value).toContainEqual({ text: ' 入口', role: 'hint' })
|
||||
})
|
||||
|
||||
it('对不上名字、不是入口:什么都不加', async () => {
|
||||
const plain = await documentMessage(doc('python', CLI), registryWithPython(), runtime, ignore)
|
||||
const unmatched = await documentMessage({ ...doc('python', CLI), entryNames: ['run'] }, registryWithPython(), runtime, ignore)
|
||||
expect(unmatched.message.blocks).toEqual(plain.message.blocks)
|
||||
expect(plain.message.blocks.map(nameRow).join('\n')).not.toContain('入口')
|
||||
})
|
||||
|
||||
it('rust:fn main 的名称后面加「入口」', async () => {
|
||||
const registry = new CodeReaderRegistry({ get: () => undefined, update: async () => {} } as Pick<Memento, 'get' | 'update'>)
|
||||
registry.registerLanguage(createRustAdapter(WASM_DIR))
|
||||
registry.registerTranslator(rustZhTranslator)
|
||||
const { message } = await documentMessage({ ...doc('rust', 'fn main() {\n run();\n}\n\nfn run() {}\n'), entryNames: ['main'] }, registry, runtime, ignore)
|
||||
expect(message.blocks.map(nameRow)).toEqual(['名称: main 入口', '名称: run'])
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import type { DocumentMessage } from './messages'
|
||||
import { fileAttributes, mergeAttributeNames } from '../attributes'
|
||||
import { readBlockViews } from '../blockView'
|
||||
import { commandRuleLookup } from '../commandRules'
|
||||
import { markEntryNames } from '../entries/entryMarks'
|
||||
import { ruleAttributeNames } from '../libraryRules'
|
||||
|
||||
/** 生成预览数据要读的那点文档信息(TextDocument 的子集)。 */
|
||||
@@ -12,6 +13,8 @@ export interface DocumentSnapshot {
|
||||
languageId: string
|
||||
version: number
|
||||
getText: () => string
|
||||
/** 这份文件是项目写明的入口时,预览里要标「入口」的名字(见 entries/entryPoints.ts) */
|
||||
entryNames?: readonly string[]
|
||||
}
|
||||
|
||||
/** 发给预览的文档,和这份源码里起了中文名的定义(扩展端接着找用到它们的各处)、语言插件自己认得出的名字(LocalSymbol)。 */
|
||||
@@ -25,6 +28,7 @@ export interface DocumentRead {
|
||||
* 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下;
|
||||
* 规则查名字的定义时问 lookup(RuleContext.lookup),翻译插件问调用的名字定义成哪种块时问 calleeKind(SourceDocument.calleeKind)。
|
||||
* 筛选属性和值的名字先取翻译插件的,没给的再取库规则的(LibraryRule.attributeNames)。
|
||||
* 入口文件里名字对上的块,名称那一行在翻译好的结果上加「入口」(markEntryNames)。
|
||||
*/
|
||||
export async function documentMessage(
|
||||
document: DocumentSnapshot,
|
||||
@@ -66,7 +70,7 @@ export async function documentMessage(
|
||||
},
|
||||
})
|
||||
const names = mergeAttributeNames([translator?.attributeNames, ...ruleAttributeNames(rules, translator?.displayLanguage, report)])
|
||||
return { message: { ...message, blocks, attributes: fileAttributes(blocks, names) }, displayNames, symbols }
|
||||
return { message: { ...message, blocks: markEntryNames(blocks, source, document.entryNames ?? []), attributes: fileAttributes(blocks, names) }, displayNames, symbols }
|
||||
}
|
||||
catch (err) {
|
||||
return { message: { ...message, error: err instanceof Error ? err.message : String(err) }, displayNames: [], symbols: [] }
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ClaudeImage } from '../../cc/spawnClaude'
|
||||
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, WorkspaceIndex } from '../contract'
|
||||
import type { DiffBase } from '../diff/diffBase'
|
||||
import type { OldDocument, SentDocument } from '../diff/previewDiff'
|
||||
import type { WorkspaceEntries } from '../entries/workspaceEntries'
|
||||
import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution'
|
||||
import type { CodeQa } from '../qa/codeQa'
|
||||
import type { CodeReaderRegistry } from '../registry'
|
||||
@@ -76,6 +77,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
readonly indexed: (index: WorkspaceIndex, key: string) => Promise<IndexedEntry[]>,
|
||||
/** 预览标差异时对比什么 */
|
||||
readonly diffBases: DiffBases,
|
||||
/** 哪些文件是项目写明的入口、预览里标哪些名字 */
|
||||
readonly entries: Pick<WorkspaceEntries, 'namesOf' | 'onDidChange'>,
|
||||
) {}
|
||||
|
||||
/** 侧栏「问 AI」打开预览,并把光标放进顶部输入框。已经开着的就揭到前面再聚焦。 */
|
||||
@@ -253,6 +256,11 @@ class Preview {
|
||||
if (this.ready)
|
||||
this.post({ type: 'filter', ...state })
|
||||
}),
|
||||
// 项目的入口变了:重新发文档,名称那一行的「入口」跟着变
|
||||
owner.entries.onDidChange((uris) => {
|
||||
if (uris.some(uri => uri.toString() === document.uri.toString()))
|
||||
this.schedulePush()
|
||||
}),
|
||||
owner.qa.onDidChange((uris) => {
|
||||
if (uris.some(uri => uri.toString() === this.subject().uri.toString()))
|
||||
this.postQa()
|
||||
@@ -320,7 +328,7 @@ class Preview {
|
||||
const source = this.document.getText()
|
||||
const session = this.lookups.session()
|
||||
const callees = this.callees.session()
|
||||
const { message, displayNames, symbols } = await documentMessage({ languageId, version, getText: () => source }, this.registry, this.runtime, (text) => {
|
||||
const { message, displayNames, symbols } = await documentMessage({ languageId, version, getText: () => source, entryNames: this.owner.entries.namesOf(this.document.uri) }, this.registry, this.runtime, (text) => {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` })
|
||||
}, session.lookup, callees.calleeKind)
|
||||
if (request !== this.latest || !this.ready)
|
||||
@@ -344,11 +352,11 @@ class Preview {
|
||||
|
||||
/**
|
||||
* 对比基准里的旧版本排成视图数据:和这份文档同一种语言、同一个翻译插件和库规则;规则查定义、调用的种类用这份文档眼下查到的答案,
|
||||
* 两边同样的写法翻得一样,不会因为答案晚到被当成改了。旧版本里规则出的错不记(记的是现在这份的)。
|
||||
* 两边同样的写法翻得一样,不会因为答案晚到被当成改了;项目入口也按眼下的标,「入口」两边都有。旧版本里规则出的错不记(记的是现在这份的)。
|
||||
*/
|
||||
private async readOld(source: string): Promise<OldDocument | undefined> {
|
||||
const { message } = await documentMessage(
|
||||
{ languageId: this.document.languageId, version: this.document.version, getText: () => source },
|
||||
{ languageId: this.document.languageId, version: this.document.version, getText: () => source, entryNames: this.owner.entries.namesOf(this.document.uri) },
|
||||
this.registry,
|
||||
this.runtime,
|
||||
() => {},
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { Segment } from '../contract'
|
||||
|
||||
/** 名称那一行、名字后面的浅色说明词。 */
|
||||
const ENTRY_HINT: Segment = { text: ' 入口', role: 'hint' }
|
||||
|
||||
/**
|
||||
* 入口文件的预览:文件最外层、名字是入口名字之一的那一块(函数、常量、变量、库规则的卡片,谁对上就是谁),
|
||||
* 属性表里写着这个名字的那一行、名字后面加一个浅色的「入口」。和语言、翻译插件无关:
|
||||
* 块的名字按它定义处(defines)在源码里的那一段认,属性表里按词的源码下标(offset)找写名字的那个词;对不上就什么都不加。
|
||||
*/
|
||||
export function markEntryNames(blocks: BlockView[], source: string, names: readonly string[]): BlockView[] {
|
||||
if (names.length === 0)
|
||||
return blocks
|
||||
return blocks.map((block) => {
|
||||
const at = block.defines
|
||||
if (!at || !('properties' in block) || !names.includes(source.slice(at.startIndex, at.endIndex)))
|
||||
return block
|
||||
let marked = false
|
||||
const properties = block.properties.map((row) => {
|
||||
if (marked || 'markdown' in row.value)
|
||||
return row
|
||||
const index = row.value.findIndex(piece => 'role' in piece && piece.offset === at.startIndex)
|
||||
if (index < 0)
|
||||
return row
|
||||
marked = true
|
||||
return { ...row, value: [...row.value.slice(0, index + 1), ENTRY_HINT, ...row.value.slice(index + 1)] }
|
||||
})
|
||||
return marked ? { ...block, properties } : block
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { FolderReader } from '../workspaceScope'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { findEntryPoints } from './entryPoints'
|
||||
|
||||
/** 一个假的工作区文件夹:路径 → 内容;不读盘。 */
|
||||
function folder(files: Record<string, string>): { paths: string[], reader: FolderReader } {
|
||||
return {
|
||||
paths: Object.keys(files),
|
||||
reader: {
|
||||
exists: async path => path in files,
|
||||
read: async path => files[path],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function entriesOf(files: Record<string, string>): Promise<Record<string, readonly string[]>> {
|
||||
const { paths, reader } = folder(files)
|
||||
return Object.fromEntries(await findEntryPoints(paths, reader))
|
||||
}
|
||||
|
||||
describe('python:pyproject.toml 的 [project.scripts]、[project.gui-scripts]', () => {
|
||||
it('src 布局:模块找到 src 下的文件,带上名字;一个文件可以有几个名字', async () => {
|
||||
expect(await entriesOf({
|
||||
'tools/agent/pyproject.toml': [
|
||||
'[project]',
|
||||
'name = "agent-cli"',
|
||||
'[project.scripts]',
|
||||
'agent = "agent_cli.main:app"',
|
||||
'agent-dev = "agent_cli.main:dev [extra]"',
|
||||
'[project.gui-scripts]',
|
||||
'agent-gui = "agent_cli.gui:Window.run"',
|
||||
].join('\n'),
|
||||
'tools/agent/src/agent_cli/main.py': '',
|
||||
'tools/agent/src/agent_cli/gui.py': '',
|
||||
'tools/agent/agent_cli/main.py': '',
|
||||
})).toEqual({
|
||||
'tools/agent/src/agent_cli/main.py': ['app', 'dev'],
|
||||
'tools/agent/src/agent_cli/gui.py': ['Window'],
|
||||
})
|
||||
})
|
||||
|
||||
it('按顺序找第一个存在的:src/模块.py、模块.py、src/模块/__init__.py、模块/__init__.py', async () => {
|
||||
const scripts = (target: string): string => `[project.scripts]\ncli = "${target}"`
|
||||
expect(await entriesOf({ 'pyproject.toml': scripts('pkg:main'), 'pkg.py': '', 'pkg/__init__.py': '' })).toEqual({ 'pkg.py': ['main'] })
|
||||
expect(await entriesOf({ 'pyproject.toml': scripts('pkg:main'), 'src/pkg/__init__.py': '', 'pkg/__init__.py': '' })).toEqual({ 'src/pkg/__init__.py': ['main'] })
|
||||
expect(await entriesOf({ 'pyproject.toml': scripts('pkg.cli'), 'pkg/cli/__init__.py': '' })).toEqual({ 'pkg/cli/__init__.py': [] })
|
||||
})
|
||||
|
||||
it('模块文件不存在就不标;别的表、写坏的 toml 也不标', async () => {
|
||||
expect(await entriesOf({ 'pyproject.toml': '[project.scripts]\ncli = "missing.module:main"', 'main.py': '' })).toEqual({})
|
||||
expect(await entriesOf({ 'pyproject.toml': '[tool.poetry.scripts]\ncli = "app:main"', 'app.py': '' })).toEqual({})
|
||||
expect(await entriesOf({ 'pyproject.toml': '[project.scripts\ncli = "app:main"', 'app.py': '' })).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rust:Cargo.toml', () => {
|
||||
it('src/main.rs 标 main,src/lib.rs 不标', async () => {
|
||||
expect(await entriesOf({
|
||||
'crates/server/Cargo.toml': '[package]\nname = "server"',
|
||||
'crates/server/src/main.rs': 'fn main() {}',
|
||||
'crates/server/src/lib.rs': '',
|
||||
'crates/core/Cargo.toml': '[package]\nname = "core"',
|
||||
'crates/core/src/lib.rs': '',
|
||||
})).toEqual({ 'crates/server/src/main.rs': ['main'] })
|
||||
})
|
||||
|
||||
it('[[bin]] 写了 path 的标那个路径(相对 Cargo.toml 所在目录),不存在的不标', async () => {
|
||||
expect(await entriesOf({
|
||||
'app/Cargo.toml': [
|
||||
'[package]',
|
||||
'name = "app"',
|
||||
'[[bin]]',
|
||||
'name = "migrate"',
|
||||
'path = "tools/migrate.rs"',
|
||||
'[[bin]]',
|
||||
'name = "gone"',
|
||||
'path = "tools/gone.rs"',
|
||||
'[[bin]]',
|
||||
'name = "auto"',
|
||||
].join('\n'),
|
||||
'app/tools/migrate.rs': '',
|
||||
})).toEqual({ 'app/tools/migrate.rs': ['main'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('go:go.mod', () => {
|
||||
const main = 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("hi")\n}\n'
|
||||
|
||||
it('package main 又有 func main 的标 main,普通包不标', async () => {
|
||||
expect(await entriesOf({
|
||||
'go.mod': 'module example.com/app',
|
||||
'cmd/server/main.go': main,
|
||||
'internal/run/run.go': 'package run\n\nfunc main() {}\n',
|
||||
'cmd/server/flags.go': 'package main\n\nvar port = 8080\n',
|
||||
})).toEqual({ 'cmd/server/main.go': ['main'] })
|
||||
})
|
||||
|
||||
it('方法、注释和字符串里的 func main 不算;包名前面可以有注释', async () => {
|
||||
expect(await entriesOf({
|
||||
'go.mod': 'module example.com/app',
|
||||
'a.go': '// 生成代码的模板\npackage main\n\nconst tpl = `\nfunc main() {}\n`\n\ntype T struct{}\n\nfunc (T) main() {}\n// func main()\n',
|
||||
'b.go': '/* 说明 */\n// +build tools\npackage main\n\nfunc init() { s := "{"; _ = s }\n\nfunc main() {}\n',
|
||||
})).toEqual({ 'b.go': ['main'] })
|
||||
})
|
||||
|
||||
it('只看自己模块的目录:别的 go.mod 的子目录归那个模块,vendor 不进,没有 go.mod 的不看', async () => {
|
||||
expect(await entriesOf({
|
||||
'svc/go.mod': 'module example.com/svc',
|
||||
'svc/main.go': main,
|
||||
'svc/tools/go.mod': 'module example.com/tools',
|
||||
'svc/tools/gen/main.go': main,
|
||||
'svc/vendor/example.com/dep/main.go': main,
|
||||
'scripts/main.go': main,
|
||||
})).toEqual({ 'svc/main.go': ['main'], 'svc/tools/gen/main.go': ['main'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('javascript / typescript:package.json 的 bin', () => {
|
||||
it('对象:每个命令的文件,只标文件不带名字', async () => {
|
||||
expect(await entriesOf({
|
||||
'packages/cli/package.json': JSON.stringify({ name: 'cli', main: 'index.js', bin: { 'spx': './bin/spx.js', 'spx-dev': 'bin/dev.js' } }),
|
||||
'packages/cli/bin/spx.js': '',
|
||||
'packages/cli/bin/dev.js': '',
|
||||
'packages/cli/index.js': '',
|
||||
})).toEqual({ 'packages/cli/bin/spx.js': [], 'packages/cli/bin/dev.js': [] })
|
||||
})
|
||||
|
||||
it('字符串:就是那个文件;main、exports、scripts 不看', async () => {
|
||||
expect(await entriesOf({
|
||||
'package.json': JSON.stringify({ bin: 'cli.mjs', main: 'lib.js', exports: './lib.js', scripts: { start: 'node server.js' } }),
|
||||
'cli.mjs': '',
|
||||
'lib.js': '',
|
||||
'server.js': '',
|
||||
})).toEqual({ 'cli.mjs': [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('不进的目录', () => {
|
||||
it('node_modules、.venv、vendor、dist、__pycache__ 里的清单不看', async () => {
|
||||
expect(await entriesOf({
|
||||
'node_modules/tool/package.json': JSON.stringify({ bin: 'cli.js' }),
|
||||
'node_modules/tool/cli.js': '',
|
||||
'.venv/lib/pkg/pyproject.toml': '[project.scripts]\ncli = "pkg:main"',
|
||||
'.venv/lib/pkg/pkg.py': '',
|
||||
'dist/package.json': JSON.stringify({ bin: 'cli.js' }),
|
||||
'dist/cli.js': '',
|
||||
})).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { FolderReader } from '../workspaceScope'
|
||||
import * as path from 'node:path'
|
||||
import { parse as parseToml } from 'toml'
|
||||
import { normalizeKey } from '../pathMap'
|
||||
|
||||
/**
|
||||
* 一个工作区文件夹里各项目自己写明的入口:入口文件(相对工作区文件夹,`/` 分隔)→ 预览里要标的名字。
|
||||
* 名字是空的只标文件(package.json 的 `bin` 写的是文件,没有函数名)。
|
||||
*/
|
||||
export type EntryPoints = ReadonlyMap<string, readonly string[]>
|
||||
|
||||
/** 不进的目录:依赖、虚拟环境、构建产物、字节码缓存、git 自己的目录。 */
|
||||
export const SKIPPED_DIRS: readonly string[] = ['node_modules', '.venv', 'vendor', 'dist', '__pycache__', '.git']
|
||||
|
||||
/** 交给 findFiles 的排除 glob,和 SKIPPED_DIRS 是同一组目录。 */
|
||||
export const SKIPPED_GLOB = `**/{${SKIPPED_DIRS.join(',')}}/**`
|
||||
|
||||
/** 要交给 findEntryPoints 的文件:四种项目的清单,加上 Go 源码(看 `package main` 和 `func main`)。 */
|
||||
export const ENTRY_SOURCES = '**/{pyproject.toml,Cargo.toml,go.mod,package.json,*.go}'
|
||||
|
||||
interface Entry { path: string, names: readonly string[] }
|
||||
|
||||
type Table = Record<string, unknown>
|
||||
|
||||
function isTable(value: unknown): value is Table {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** path 是否在不进的目录里(见 SKIPPED_DIRS)。 */
|
||||
export function isSkipped(file: string): boolean {
|
||||
return file.split('/').slice(0, -1).some(dir => SKIPPED_DIRS.includes(dir))
|
||||
}
|
||||
|
||||
/** 清单所在目录下的 file,换成相对工作区文件夹的路径;跑出工作区文件夹的不要。 */
|
||||
function inDir(manifest: string, file: string): string | undefined {
|
||||
return normalizeKey(path.posix.join(path.posix.dirname(manifest), file))
|
||||
}
|
||||
|
||||
/** 按顺序第一个存在的。 */
|
||||
async function firstExisting(candidates: readonly (string | undefined)[], reader: FolderReader): Promise<string | undefined> {
|
||||
for (const candidate of candidates) {
|
||||
if (candidate !== undefined && await reader.exists(candidate))
|
||||
return candidate
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 存在的那几个,各标 names。 */
|
||||
async function existing(candidates: readonly (string | undefined)[], names: readonly string[], reader: FolderReader): Promise<Entry[]> {
|
||||
const files = [...new Set(candidates.filter((file): file is string => file !== undefined))]
|
||||
const present = await Promise.all(files.map(file => reader.exists(file)))
|
||||
return files.filter((_, i) => present[i]).map(file => ({ path: file, names }))
|
||||
}
|
||||
|
||||
function tomlOf(text: string | undefined): Table {
|
||||
if (text === undefined)
|
||||
return {}
|
||||
try {
|
||||
const parsed: unknown = parseToml(text)
|
||||
return isTable(parsed) ? parsed : {}
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function tableIn(table: Table, key: string): Table {
|
||||
const value = table[key]
|
||||
return isTable(value) ? value : {}
|
||||
}
|
||||
|
||||
/** 点分的名字:`agent_cli.main`、`Cli.run`。 */
|
||||
const DOTTED_NAME = /^[\p{L}_][\p{L}\p{N}_]*(?:\.[\p{L}_][\p{L}\p{N}_]*)*$/u
|
||||
|
||||
/**
|
||||
* 入口写法 `模块.路径:名字`(`agent_cli.main:app`)。名字可以带点(`Cli.run`),预览里标的是文件最外层的那一块,取第一节;
|
||||
* 后面跟着的 `[extra]` 不管。只写了模块、没有名字的只标文件。
|
||||
*/
|
||||
function objectReference(value: unknown): { module: string, name?: string } | undefined {
|
||||
if (typeof value !== 'string')
|
||||
return undefined
|
||||
const [module, object, ...rest] = value.replace(/\[[^\]]*\]\s*$/, '').split(':').map(part => part.trim())
|
||||
if (rest.length > 0 || !DOTTED_NAME.test(module) || (object !== undefined && !DOTTED_NAME.test(object)))
|
||||
return undefined
|
||||
return object === undefined ? { module } : { module, name: object.split('.')[0] }
|
||||
}
|
||||
|
||||
/** 模块对应的文件,按这个顺序找第一个存在的:src 布局的模块文件、平铺的模块文件、src 布局的包、平铺的包。 */
|
||||
function moduleFiles(module: string): string[] {
|
||||
const at = module.replaceAll('.', '/')
|
||||
return [`src/${at}.py`, `${at}.py`, `src/${at}/__init__.py`, `${at}/__init__.py`]
|
||||
}
|
||||
|
||||
/** pyproject.toml 的 `[project.scripts]`、`[project.gui-scripts]`:每条指向的模块文件,标它的名字。 */
|
||||
async function pythonEntries(manifest: string, reader: FolderReader): Promise<Entry[]> {
|
||||
const project = tableIn(tomlOf(await reader.read(manifest)), 'project')
|
||||
const targets = [tableIn(project, 'scripts'), tableIn(project, 'gui-scripts')].flatMap(Object.values).map(objectReference)
|
||||
const found = await Promise.all(targets.map(async (target) => {
|
||||
const file = target && await firstExisting(moduleFiles(target.module).map(candidate => inDir(manifest, candidate)), reader)
|
||||
return file ? [{ path: file, names: target.name === undefined ? [] : [target.name] }] : []
|
||||
}))
|
||||
return found.flat()
|
||||
}
|
||||
|
||||
/** Cargo.toml:`src/main.rs`,和 `[[bin]]` 里写了 `path` 的;都从 `main` 读起。`src/lib.rs` 是库,不算。 */
|
||||
async function cargoEntries(manifest: string, reader: FolderReader): Promise<Entry[]> {
|
||||
const bins = tomlOf(await reader.read(manifest)).bin
|
||||
const paths = Array.isArray(bins) ? bins.flatMap(bin => isTable(bin) && typeof bin.path === 'string' ? [bin.path] : []) : []
|
||||
return existing(['src/main.rs', ...paths].map(file => inDir(manifest, file)), ['main'], reader)
|
||||
}
|
||||
|
||||
/** package.json 的 `bin`:一个文件,或 `{ 命令名: 文件 }`。只标文件。 */
|
||||
async function packageEntries(manifest: string, reader: FolderReader): Promise<Entry[]> {
|
||||
const text = await reader.read(manifest)
|
||||
let bin: unknown
|
||||
try {
|
||||
bin = text === undefined ? undefined : (JSON.parse(text) as Table).bin
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
const files = typeof bin === 'string' ? [bin] : isTable(bin) ? Object.values(bin).filter((file): file is string => typeof file === 'string') : []
|
||||
return existing(files.map(file => inDir(manifest, file)), [], reader)
|
||||
}
|
||||
|
||||
const GO_WORD = /[\p{L}_][\p{L}\p{N}_]*/uy
|
||||
|
||||
/** 引号括起来的字符串、字符从 start 起到哪儿结束(反斜杠转义;没收尾的到行末)。 */
|
||||
function quotedEnd(text: string, start: number): number {
|
||||
const quote = text[start]
|
||||
for (let i = start + 1; i < text.length; i++) {
|
||||
if (text[i] === '\\')
|
||||
i++
|
||||
else if (text[i] === quote)
|
||||
return i + 1
|
||||
else if (text[i] === '\n')
|
||||
return i
|
||||
}
|
||||
return text.length
|
||||
}
|
||||
|
||||
/** Go 源码里花括号外面的词和左括号,跳过注释、字符串(含反引号的原样字符串)、字符。 */
|
||||
function goTopLevelTokens(text: string): string[] {
|
||||
const tokens: string[] = []
|
||||
let depth = 0
|
||||
let i = 0
|
||||
while (i < text.length) {
|
||||
if (text.startsWith('//', i)) {
|
||||
const end = text.indexOf('\n', i)
|
||||
i = end < 0 ? text.length : end
|
||||
continue
|
||||
}
|
||||
if (text.startsWith('/*', i)) {
|
||||
const end = text.indexOf('*/', i + 2)
|
||||
i = end < 0 ? text.length : end + 2
|
||||
continue
|
||||
}
|
||||
const char = text[i]
|
||||
if (char === '`') {
|
||||
const end = text.indexOf('`', i + 1)
|
||||
i = end < 0 ? text.length : end + 1
|
||||
continue
|
||||
}
|
||||
if (char === '"' || char === '\'') {
|
||||
i = quotedEnd(text, i)
|
||||
continue
|
||||
}
|
||||
GO_WORD.lastIndex = i
|
||||
const word = GO_WORD.exec(text)
|
||||
if (word) {
|
||||
if (depth === 0)
|
||||
tokens.push(word[0])
|
||||
i += word[0].length
|
||||
continue
|
||||
}
|
||||
if (char === '{')
|
||||
depth++
|
||||
else if (char === '}')
|
||||
depth = Math.max(0, depth - 1)
|
||||
else if (char === '(' && depth === 0)
|
||||
tokens.push(char)
|
||||
i++
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
/** 文件的包是 `package main`,最外层有 `func main(`(方法、写在字符串和注释里的都不算)。 */
|
||||
function isGoMain(text: string): boolean {
|
||||
const tokens = goTopLevelTokens(text)
|
||||
return tokens[0] === 'package' && tokens[1] === 'main'
|
||||
&& tokens.some((token, i) => token === 'func' && tokens[i + 1] === 'main' && tokens[i + 2] === '(')
|
||||
}
|
||||
|
||||
/** go.mod 所在各目录里的 Go 源码:每个文件归最近的那个 go.mod(别的 go.mod 的子目录归别的模块),`package main` 加 `func main` 的从 `main` 读起。 */
|
||||
async function goEntries(modules: ReadonlySet<string>, sources: readonly string[], reader: FolderReader): Promise<Entry[]> {
|
||||
const ownerOf = (file: string): string | undefined => {
|
||||
for (let dir = path.posix.dirname(file); ; dir = path.posix.dirname(dir)) {
|
||||
if (modules.has(dir))
|
||||
return dir
|
||||
if (dir === '.')
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
const owned = sources.filter(file => ownerOf(file) !== undefined)
|
||||
const texts = await Promise.all(owned.map(file => reader.read(file)))
|
||||
return owned.filter((_, i) => isGoMain(texts[i] ?? '')).map(file => ({ path: file, names: ['main'] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 各项目自己写明的启动项,不靠文件名、函数名猜:
|
||||
* - pyproject.toml 的 `[project.scripts]`、`[project.gui-scripts]`;
|
||||
* - Cargo.toml 所在目录的 `src/main.rs`,`[[bin]]` 的 `path`;
|
||||
* - go.mod 的模块里 `package main` 又有 `func main` 的文件;
|
||||
* - package.json 的 `bin`。
|
||||
* 每个清单各管各的项目(相对它所在的目录),一个工作区文件夹里可以有很多个。指向的文件不存在的不标。
|
||||
*
|
||||
* @param paths 工作区文件夹里匹配 ENTRY_SOURCES 的文件(相对工作区文件夹,`/` 分隔);在 SKIPPED_DIRS 里的不看
|
||||
* @param reader 读清单、Go 源码,问入口文件在不在
|
||||
*/
|
||||
export async function findEntryPoints(paths: readonly string[], reader: FolderReader): Promise<EntryPoints> {
|
||||
const kept = paths.filter(file => !isSkipped(file))
|
||||
const named = (name: string): string[] => kept.filter(file => path.posix.basename(file) === name)
|
||||
const modules = new Set(named('go.mod').map(file => path.posix.dirname(file)))
|
||||
const found = await Promise.all([
|
||||
...named('pyproject.toml').map(file => pythonEntries(file, reader)),
|
||||
...named('Cargo.toml').map(file => cargoEntries(file, reader)),
|
||||
...named('package.json').map(file => packageEntries(file, reader)),
|
||||
goEntries(modules, kept.filter(file => file.endsWith('.go')), reader),
|
||||
])
|
||||
const entries = new Map<string, string[]>()
|
||||
for (const { path: file, names } of found.flat()) {
|
||||
const known = entries.get(file) ?? []
|
||||
entries.set(file, [...known, ...names.filter(name => !known.includes(name))])
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Disposable, WorkspaceFolder } from 'vscode'
|
||||
import type { RowEntries } from '../explorer/rowDecorator'
|
||||
import type { EntryPoints } from './entryPoints'
|
||||
import { EventEmitter, RelativePattern, Uri, workspace } from 'vscode'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { locate } from '../folderStore'
|
||||
import { toPathKey } from '../pathMap'
|
||||
import { folderReader } from '../workspaceFiles'
|
||||
import { ENTRY_SOURCES, findEntryPoints, isSkipped, SKIPPED_GLOB } from './entryPoints'
|
||||
|
||||
/** 连着的文件增删(装依赖、切分支)合并成一次重扫。 */
|
||||
const RESCAN_DELAY_MS = 500
|
||||
|
||||
/**
|
||||
* 各工作区文件夹里项目写明的入口(见 findEntryPoints)。打开时扫一遍;之后清单、Go 源码改了,或文件增删了(入口文件出现、消失),
|
||||
* 等一会儿重扫那个工作区文件夹。入口变了的文件报出去:侧栏重画那几行,开着的预览重新标名字。
|
||||
*/
|
||||
export class WorkspaceEntries implements RowEntries, Disposable {
|
||||
/** 工作区文件夹 → 它里面的入口 */
|
||||
private readonly found = new Map<string, EntryPoints>()
|
||||
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
/** 每个工作区文件夹最近一次扫描的序号:先发出的扫描后回来时不盖掉新的 */
|
||||
private readonly scans = new Map<string, number>()
|
||||
private readonly changed = new EventEmitter<readonly Uri[]>()
|
||||
/** 入口变了的那些文件 */
|
||||
readonly onDidChange = this.changed.event
|
||||
private readonly disposables: Disposable[]
|
||||
|
||||
constructor() {
|
||||
const sources = workspace.createFileSystemWatcher(ENTRY_SOURCES, true, false, true)
|
||||
const files = workspace.createFileSystemWatcher('**/*', false, true, false)
|
||||
this.disposables = [
|
||||
this.changed,
|
||||
sources,
|
||||
files,
|
||||
sources.onDidChange(uri => this.schedule(uri)),
|
||||
files.onDidCreate(uri => this.schedule(uri)),
|
||||
files.onDidDelete(uri => this.schedule(uri)),
|
||||
workspace.onDidChangeWorkspaceFolders((e) => {
|
||||
for (const folder of e.removed)
|
||||
this.forget(folder)
|
||||
for (const folder of e.added)
|
||||
void this.scan(folder)
|
||||
}),
|
||||
]
|
||||
for (const folder of workspace.workspaceFolders ?? [])
|
||||
void this.scan(folder)
|
||||
}
|
||||
|
||||
/** 是项目入口时,预览里要标的名字(可能是空的:只标文件);不是入口时 undefined。 */
|
||||
namesOf(uri: Uri): readonly string[] | undefined {
|
||||
const at = locate(uri)
|
||||
return at && this.found.get(at.folder.uri.toString())?.get(at.key)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const timer of this.timers.values())
|
||||
clearTimeout(timer)
|
||||
for (const disposable of this.disposables)
|
||||
disposable.dispose()
|
||||
}
|
||||
|
||||
private schedule(uri: Uri): void {
|
||||
const at = locate(uri)
|
||||
if (!at || isSkipped(at.key))
|
||||
return
|
||||
const key = at.folder.uri.toString()
|
||||
clearTimeout(this.timers.get(key))
|
||||
this.timers.set(key, setTimeout(() => {
|
||||
this.timers.delete(key)
|
||||
void this.scan(at.folder)
|
||||
}, RESCAN_DELAY_MS))
|
||||
}
|
||||
|
||||
private async scan(folder: WorkspaceFolder): Promise<void> {
|
||||
const key = folder.uri.toString()
|
||||
const scan = (this.scans.get(key) ?? 0) + 1
|
||||
this.scans.set(key, scan)
|
||||
try {
|
||||
const uris = await workspace.findFiles(new RelativePattern(folder, ENTRY_SOURCES), SKIPPED_GLOB)
|
||||
const paths = uris.flatMap((uri) => {
|
||||
const path = toPathKey(folder.uri.path, uri.path)
|
||||
return path === undefined ? [] : [path]
|
||||
})
|
||||
const entries = await findEntryPoints(paths, folderReader(folder))
|
||||
if (this.scans.get(key) === scan)
|
||||
this.publish(folder, entries)
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `没能找出「${folder.name}」里的项目入口:${err instanceof Error ? err.message : String(err)}` })
|
||||
}
|
||||
}
|
||||
|
||||
private forget(folder: WorkspaceFolder): void {
|
||||
const key = folder.uri.toString()
|
||||
clearTimeout(this.timers.get(key))
|
||||
this.timers.delete(key)
|
||||
this.scans.delete(key)
|
||||
this.publish(folder, new Map())
|
||||
this.found.delete(key)
|
||||
}
|
||||
|
||||
/** 换上新扫出来的入口,报出入口、名字变了的文件。 */
|
||||
private publish(folder: WorkspaceFolder, entries: EntryPoints): void {
|
||||
const key = folder.uri.toString()
|
||||
const before: EntryPoints = this.found.get(key) ?? new Map()
|
||||
this.found.set(key, entries)
|
||||
const changed = [...new Set([...before.keys(), ...entries.keys()])]
|
||||
.filter(path => before.get(path)?.join('\n') !== entries.get(path)?.join('\n'))
|
||||
if (changed.length > 0)
|
||||
this.changed.fire(changed.map(path => Uri.joinPath(folder.uri, path)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { escapeMarkdown, rowDecoration } from './rowDecoration'
|
||||
|
||||
describe('树上的一行', () => {
|
||||
it('没有注释、不是入口的行保持原样', () => {
|
||||
expect(rowDecoration('file', 'src/a.ts', undefined, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('有注释:名字后面灰字是注释,contextValue 加 noted,提示里粗体路径下一行注释全文', () => {
|
||||
expect(rowDecoration('file', 'campaign/order_bonus.py', '下单返利:订单完成后按活动规则发积分', undefined)).toEqual({
|
||||
description: '下单返利:订单完成后按活动规则发积分',
|
||||
tooltip: '**campaign\\/order\\_bonus\\.py** \n下单返利:订单完成后按活动规则发积分',
|
||||
contextValue: 'file noted',
|
||||
})
|
||||
expect(rowDecoration('folder', 'campaign', '营销活动', undefined)?.contextValue).toBe('folder noted')
|
||||
})
|
||||
|
||||
it('入口、没注释:灰字是「入口」,提示里写从哪个名字开始读,contextValue 不变', () => {
|
||||
expect(rowDecoration('file', 'src/agent_cli/main.py', undefined, ['app', 'dev'])).toEqual({
|
||||
description: '入口',
|
||||
tooltip: '**src\\/agent\\_cli\\/main\\.py** \n入口,从 `app`、`dev` 开始读',
|
||||
contextValue: 'file',
|
||||
})
|
||||
expect(rowDecoration('file', 'bin/cli.js', undefined, [])?.tooltip).toBe('**bin\\/cli\\.js** \n入口')
|
||||
})
|
||||
|
||||
it('入口、有注释:灰字是「注释 · 入口」,注释不被盖掉,提示里注释下面再写入口', () => {
|
||||
expect(rowDecoration('file', 'src/main.rs', '服务启动', ['main'])).toEqual({
|
||||
description: '服务启动 · 入口',
|
||||
tooltip: '**src\\/main\\.rs** \n服务启动 \n入口,从 `main` 开始读',
|
||||
contextValue: 'file noted',
|
||||
})
|
||||
})
|
||||
|
||||
it('注释里的 Markdown 符号原样显示', () => {
|
||||
expect(escapeMarkdown('**别加粗** <b>x</b> [链接](command:x) `代码` # 标题 $(icon)'))
|
||||
.toBe('\\*\\*别加粗\\*\\* \\<b\\>x\\<\\/b\\> \\[链接\\]\\(command\\:x\\) \\`代码\\` \\# 标题 \\$\\(icon\\)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
/** 侧栏一行名字后面的灰字和悬停提示:文件注释、项目入口。 */
|
||||
export interface RowDecoration {
|
||||
/** 名字后面的灰字:注释,是入口时后面接「 · 入口」,没注释时就是「入口」;VS Code 放不下时自己截断 */
|
||||
description: string
|
||||
/** 悬停提示(Markdown):第一行粗体的相对路径,下面是注释全文,是入口时再一行写明 */
|
||||
tooltip: string
|
||||
/** 有注释时末尾加上 ` noted`:菜单的 when 子句靠它在「添加注释」和「编辑注释」之间换;是不是入口不影响 */
|
||||
contextValue: string
|
||||
}
|
||||
|
||||
/** 每个 ASCII 标点前加反斜杠(CommonMark 允许任何 ASCII 标点这样转义),注释里的 `*`、`_`、`<`、`[` 等原样显示。 */
|
||||
export function escapeMarkdown(text: string): string {
|
||||
return text.replace(/[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param contextValue 这一行原本的 contextValue(`file` / `folder`)
|
||||
* @param key 相对工作区文件夹的路径
|
||||
* @param note 文件注释
|
||||
* @param entry 是项目写明的入口时,预览里要标的名字(package.json 的 `bin` 没有名字,是空的);不是入口时 undefined
|
||||
* @returns 没有注释、也不是入口时 undefined,这一行保持原样
|
||||
*/
|
||||
export function rowDecoration(contextValue: string, key: string, note: string | undefined, entry: readonly string[] | undefined): RowDecoration | undefined {
|
||||
if (note === undefined && entry === undefined)
|
||||
return undefined
|
||||
const lines = [`**${escapeMarkdown(key)}**`]
|
||||
if (note !== undefined)
|
||||
lines.push(escapeMarkdown(note))
|
||||
if (entry !== undefined)
|
||||
lines.push(entry.length > 0 ? `入口,从 ${entry.map(name => `\`${name}\``).join('、')} 开始读` : '入口')
|
||||
return {
|
||||
description: note === undefined ? '入口' : entry === undefined ? note : `${note} · 入口`,
|
||||
tooltip: lines.join(' \n'),
|
||||
contextValue: note === undefined ? contextValue : `${contextValue} noted`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Event, TreeItem, Uri } from 'vscode'
|
||||
import type { FileNode, FileTreeDecorator } from './fileTree'
|
||||
import { Disposable, MarkdownString } from 'vscode'
|
||||
import { locate } from '../folderStore'
|
||||
import { rowDecoration } from './rowDecoration'
|
||||
|
||||
/** 各行的文件注释(见 notes/)。 */
|
||||
export interface RowNotes {
|
||||
readonly onDidChange: Event<readonly Uri[]>
|
||||
noteOf: (node: FileNode) => string | undefined
|
||||
}
|
||||
|
||||
/** 哪些文件是项目写明的入口(见 entries/)。 */
|
||||
export interface RowEntries {
|
||||
readonly onDidChange: Event<readonly Uri[]>
|
||||
namesOf: (uri: Uri) => readonly string[] | undefined
|
||||
}
|
||||
|
||||
/** 侧栏一行名字后面的灰字和悬停提示:文件注释,后面接项目入口(见 rowDecoration);工作区外面的行(审查的工作副本)不加。 */
|
||||
export class RowDecorator implements FileTreeDecorator {
|
||||
constructor(private readonly notes: RowNotes, private readonly entries: RowEntries) {}
|
||||
|
||||
/** 注释变了、入口变了的那些行 */
|
||||
readonly onDidChange: Event<readonly Uri[]> = (listener, thisArgs, disposables) => {
|
||||
const both = Disposable.from(this.notes.onDidChange(listener, thisArgs), this.entries.onDidChange(listener, thisArgs))
|
||||
disposables?.push(both)
|
||||
return both
|
||||
}
|
||||
|
||||
decorate(item: TreeItem, node: FileNode): void {
|
||||
const at = locate(node.uri)
|
||||
const entry = node.isDirectory ? undefined : this.entries.namesOf(node.uri)
|
||||
const decoration = at && rowDecoration(item.contextValue ?? '', at.key, this.notes.noteOf(node), entry)
|
||||
if (!decoration)
|
||||
return
|
||||
item.description = decoration.description
|
||||
item.tooltip = new MarkdownString(decoration.tooltip)
|
||||
item.contextValue = decoration.contextValue
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { noteDecoration } from '../notes/noteItem'
|
||||
import { UNSTAGED_MARKER } from '../uncommitted/uncommittedFiles'
|
||||
import { COPY_COMMANDS } from './copyText'
|
||||
import { entryContext, GROUP_CONTEXT } from './fileEntries'
|
||||
import { rowDecoration } from './rowDecoration'
|
||||
|
||||
interface MenuItem { command?: string, submenu?: string, when: string, group?: string }
|
||||
interface Manifest {
|
||||
@@ -28,7 +28,7 @@ const root = entryContext({ isDirectory: true, isRoot: true })
|
||||
const unstaged = entryContext({ isDirectory: false, isRoot: false }, UNSTAGED_MARKER)
|
||||
const STAGE = 'superpowers.codeReader.stage'
|
||||
const ASK = 'superpowers.codeReader.askPath'
|
||||
const noted = (context: string): string => noteDecoration(context, 'x', '注释')!.contextValue
|
||||
const noted = (context: string): string => rowDecoration(context, 'x', '注释', undefined)!.contextValue
|
||||
|
||||
const orderOf = (item: MenuItem): number => Number(item.group?.split('@')[1] ?? 0)
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigat
|
||||
import { PATH_PREVIEW_SCHEME } from './editor/pathPreview'
|
||||
import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor'
|
||||
import { showPreview, showSource } from './editor/switchEditor'
|
||||
import { WorkspaceEntries } from './entries/workspaceEntries'
|
||||
import { registerCopyCommands } from './explorer/copyCommands'
|
||||
import { FileTreeProvider } from './explorer/fileTree'
|
||||
import { registerPathAsk } from './explorer/pathAskPanel'
|
||||
import { RowDecorator } from './explorer/rowDecorator'
|
||||
import { TreeFilters } from './explorer/treeFilters'
|
||||
import { createBashAdapter } from './languages/bash'
|
||||
import { createDockerfileAdapter } from './languages/dockerfile'
|
||||
@@ -97,17 +99,19 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
return adapter ? indexedNames((index, key) => indexes.find(index, key), adapter, name) : []
|
||||
}
|
||||
const notes = new FileNotes()
|
||||
const files = new FileTreeProvider(OPEN_COMMAND, notes)
|
||||
const entries = new WorkspaceEntries()
|
||||
const files = new FileTreeProvider(OPEN_COMMAND, new RowDecorator(notes, entries))
|
||||
const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true })
|
||||
const filters = new TreeFilters(files, view, context.workspaceState)
|
||||
const review = new ReviewMode(context, FILES_VIEW, view, files, filters)
|
||||
const uncommitted = new UncommittedMode(context, FILES_VIEW, view, files, filters)
|
||||
// 侧栏开着的筛选定预览对比什么:未提交的更改对比上一次提交,审查工单对比 PR 的目标分支
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), filters)
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), filters, entries)
|
||||
|
||||
context.subscriptions.push(
|
||||
qa,
|
||||
notes,
|
||||
entries,
|
||||
indexes,
|
||||
registerNoteCommands(notes),
|
||||
registerCopyCommands(),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Disposable, TreeItem } from 'vscode'
|
||||
import type { FileNode, FileTreeDecorator } from '../explorer/fileTree'
|
||||
import { MarkdownString } from 'vscode'
|
||||
import type { Disposable } from 'vscode'
|
||||
import type { FileNode } from '../explorer/fileTree'
|
||||
import type { RowNotes } from '../explorer/rowDecorator'
|
||||
import { FolderStore, locate } from '../folderStore'
|
||||
import { noteDecoration } from './noteItem'
|
||||
import { withNote } from './noteMap'
|
||||
import { NOTES_FILE } from './store'
|
||||
|
||||
@@ -12,7 +11,7 @@ import { NOTES_FILE } from './store'
|
||||
* 注释记在文件所在的工作区文件夹里,和树从哪个根摆下来(工作区文件夹、仓库根)无关;工作区外面的文件(审查的工作副本)不记。
|
||||
* 在 VS Code 里改名、挪位置,注释跟着走;删掉就一起删。外部改了文件(git pull、手改)按文件重读(见 FolderStore)。
|
||||
*/
|
||||
export class FileNotes implements FileTreeDecorator, Disposable {
|
||||
export class FileNotes implements RowNotes, Disposable {
|
||||
private readonly store = new FolderStore(NOTES_FILE)
|
||||
/** 注释变了的那些行 */
|
||||
readonly onDidChange = this.store.onDidChange
|
||||
@@ -23,16 +22,6 @@ export class FileNotes implements FileTreeDecorator, Disposable {
|
||||
return at && this.store.get(at.folder, at.key)
|
||||
}
|
||||
|
||||
decorate(item: TreeItem, node: FileNode): void {
|
||||
const at = locate(node.uri)
|
||||
const decoration = at && noteDecoration(item.contextValue ?? '', at.key, this.store.get(at.folder, at.key))
|
||||
if (!decoration)
|
||||
return
|
||||
item.description = decoration.description
|
||||
item.tooltip = new MarkdownString(decoration.tooltip)
|
||||
item.contextValue = decoration.contextValue
|
||||
}
|
||||
|
||||
/** 保存注释:去掉首尾空白,空的就删掉这条。 */
|
||||
async save(node: FileNode, text: string): Promise<void> {
|
||||
const at = locate(node.uri)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { escapeMarkdown, noteDecoration } from './noteItem'
|
||||
|
||||
describe('树上的一行', () => {
|
||||
it('没有注释的行保持原样', () => {
|
||||
expect(noteDecoration('file', 'src/a.ts', undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('有注释:名字后面灰字是注释,contextValue 加 noted,提示里粗体路径下一行注释全文', () => {
|
||||
expect(noteDecoration('file', 'campaign/order_bonus.py', '下单返利:订单完成后按活动规则发积分')).toEqual({
|
||||
description: '下单返利:订单完成后按活动规则发积分',
|
||||
tooltip: '**campaign\\/order\\_bonus\\.py** \n下单返利:订单完成后按活动规则发积分',
|
||||
contextValue: 'file noted',
|
||||
})
|
||||
expect(noteDecoration('folder', 'campaign', '营销活动')?.contextValue).toBe('folder noted')
|
||||
})
|
||||
|
||||
it('注释里的 Markdown 符号原样显示', () => {
|
||||
expect(escapeMarkdown('**别加粗** <b>x</b> [链接](command:x) `代码` # 标题 $(icon)'))
|
||||
.toBe('\\*\\*别加粗\\*\\* \\<b\\>x\\<\\/b\\> \\[链接\\]\\(command\\:x\\) \\`代码\\` \\# 标题 \\$\\(icon\\)')
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
/** 树上有注释的一行怎么显示。 */
|
||||
export interface NoteDecoration {
|
||||
/** 名字后面的灰字,VS Code 放不下时自己截断 */
|
||||
description: string
|
||||
/** 悬停提示(Markdown):第一行粗体的相对路径,下一行注释全文 */
|
||||
tooltip: string
|
||||
/** 末尾加上 ` noted`:菜单的 when 子句靠它在「添加注释」和「编辑注释」之间换 */
|
||||
contextValue: string
|
||||
}
|
||||
|
||||
/** 每个 ASCII 标点前加反斜杠(CommonMark 允许任何 ASCII 标点这样转义),注释里的 `*`、`_`、`<`、`[` 等原样显示。 */
|
||||
export function escapeMarkdown(text: string): string {
|
||||
return text.replace(/[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param contextValue 这一行原本的 contextValue(`file` / `folder`)
|
||||
* @param key 相对工作区文件夹的路径
|
||||
* @returns 没有注释时 undefined,这一行保持原样
|
||||
*/
|
||||
export function noteDecoration(contextValue: string, key: string, note: string | undefined): NoteDecoration | undefined {
|
||||
if (note === undefined)
|
||||
return undefined
|
||||
return {
|
||||
description: note,
|
||||
tooltip: `**${escapeMarkdown(key)}** \n${escapeMarkdown(note)}`,
|
||||
contextValue: `${contextValue} noted`,
|
||||
}
|
||||
}
|
||||
@@ -38,14 +38,18 @@ 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) } }
|
||||
}
|
||||
|
||||
/** 这个工作区文件夹里还要跳过的(嵌套的 checkout、忽略文件忽略的),见 folderScope;忽略文件按 search.useIgnoreFiles。 */
|
||||
function scopeOf(folder: WorkspaceFolder): ReturnType<typeof folderScope> {
|
||||
/** 读这个工作区文件夹里的文件(路径相对它、`/` 分隔)。 */
|
||||
export function folderReader(folder: WorkspaceFolder): FolderReader {
|
||||
const at = (path: string): Uri => Uri.joinPath(folder.uri, path)
|
||||
const reader: FolderReader = {
|
||||
return {
|
||||
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))
|
||||
}
|
||||
|
||||
/** 这个工作区文件夹里还要跳过的(嵌套的 checkout、忽略文件忽略的),见 folderScope;忽略文件按 search.useIgnoreFiles。 */
|
||||
function scopeOf(folder: WorkspaceFolder): ReturnType<typeof folderScope> {
|
||||
return folderScope(folderReader(folder), workspace.getConfiguration('search', folder.uri).get<boolean>('useIgnoreFiles', true))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user