From 718bbb7f6c5f56674d6dc06c9713e7943acae6c0 Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 5 Oct 2026 04:49:00 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E6=A0=87=E5=87=BA=E9=A1=B9=E7=9B=AE=E5=86=99?= =?UTF-8?q?=E6=98=8E=E7=9A=84=E5=85=A5=E5=8F=A3=E6=96=87=E4=BB=B6=E5=92=8C?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 28 ++- vscode/package.json | 3 +- vscode/pnpm-lock.yaml | 9 + .../codeReader/editor/documentMessage.test.ts | 46 +++- .../src/codeReader/editor/documentMessage.ts | 6 +- vscode/src/codeReader/editor/previewEditor.ts | 14 +- vscode/src/codeReader/entries/entryMarks.ts | 31 +++ .../codeReader/entries/entryPoints.test.ts | 149 +++++++++++ vscode/src/codeReader/entries/entryPoints.ts | 236 ++++++++++++++++++ .../codeReader/entries/workspaceEntries.ts | 113 +++++++++ .../codeReader/explorer/rowDecoration.test.ts | 39 +++ .../src/codeReader/explorer/rowDecoration.ts | 36 +++ .../src/codeReader/explorer/rowDecorator.ts | 40 +++ .../src/codeReader/explorer/rowMenus.test.ts | 4 +- vscode/src/codeReader/index.ts | 8 +- vscode/src/codeReader/notes/fileNotes.ts | 19 +- vscode/src/codeReader/notes/noteItem.test.ts | 22 -- vscode/src/codeReader/notes/noteItem.ts | 29 --- vscode/src/codeReader/workspaceFiles.ts | 12 +- 19 files changed, 761 insertions(+), 83 deletions(-) create mode 100644 vscode/src/codeReader/entries/entryMarks.ts create mode 100644 vscode/src/codeReader/entries/entryPoints.test.ts create mode 100644 vscode/src/codeReader/entries/entryPoints.ts create mode 100644 vscode/src/codeReader/entries/workspaceEntries.ts create mode 100644 vscode/src/codeReader/explorer/rowDecoration.test.ts create mode 100644 vscode/src/codeReader/explorer/rowDecoration.ts create mode 100644 vscode/src/codeReader/explorer/rowDecorator.ts delete mode 100644 vscode/src/codeReader/notes/noteItem.test.ts delete mode 100644 vscode/src/codeReader/notes/noteItem.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index b6f3522..b9c21fd 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -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 的话:找到那块、外层块的开头、带行号的代码、追问 diff --git a/vscode/package.json b/vscode/package.json index 9200365..7830e46 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -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", diff --git a/vscode/pnpm-lock.yaml b/vscode/pnpm-lock.yaml index 497ce63..a73f558 100644 --- a/vscode/pnpm-lock.yaml +++ b/vscode/pnpm-lock.yaml @@ -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: {} diff --git a/vscode/src/codeReader/editor/documentMessage.test.ts b/vscode/src/codeReader/editor/documentMessage.test.ts index cf1ffe2..ce948d3 100644 --- a/vscode/src/codeReader/editor/documentMessage.test.ts +++ b/vscode/src/codeReader/editor/documentMessage.test.ts @@ -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) + 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']) + }) +}) diff --git a/vscode/src/codeReader/editor/documentMessage.ts b/vscode/src/codeReader/editor/documentMessage.ts index aeaa63b..474ee52 100644 --- a/vscode/src/codeReader/editor/documentMessage.ts +++ b/vscode/src/codeReader/editor/documentMessage.ts @@ -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: [] } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 9186987..acd7c2d 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -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, /** 预览标差异时对比什么 */ readonly diffBases: DiffBases, + /** 哪些文件是项目写明的入口、预览里标哪些名字 */ + readonly entries: Pick, ) {} /** 侧栏「问 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 { 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, () => {}, diff --git a/vscode/src/codeReader/entries/entryMarks.ts b/vscode/src/codeReader/entries/entryMarks.ts new file mode 100644 index 0000000..cf6184d --- /dev/null +++ b/vscode/src/codeReader/entries/entryMarks.ts @@ -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 + }) +} diff --git a/vscode/src/codeReader/entries/entryPoints.test.ts b/vscode/src/codeReader/entries/entryPoints.test.ts new file mode 100644 index 0000000..2408b9a --- /dev/null +++ b/vscode/src/codeReader/entries/entryPoints.test.ts @@ -0,0 +1,149 @@ +import type { FolderReader } from '../workspaceScope' +import { describe, expect, it } from 'vitest' +import { findEntryPoints } from './entryPoints' + +/** 一个假的工作区文件夹:路径 → 内容;不读盘。 */ +function folder(files: Record): { 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): Promise> { + 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({}) + }) +}) diff --git a/vscode/src/codeReader/entries/entryPoints.ts b/vscode/src/codeReader/entries/entryPoints.ts new file mode 100644 index 0000000..aea37b8 --- /dev/null +++ b/vscode/src/codeReader/entries/entryPoints.ts @@ -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 + +/** 不进的目录:依赖、虚拟环境、构建产物、字节码缓存、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 + +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 { + 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 { + 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 { + 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 { + 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 { + 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, sources: readonly string[], reader: FolderReader): Promise { + 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 { + 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() + 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 +} diff --git a/vscode/src/codeReader/entries/workspaceEntries.ts b/vscode/src/codeReader/entries/workspaceEntries.ts new file mode 100644 index 0000000..4736f7a --- /dev/null +++ b/vscode/src/codeReader/entries/workspaceEntries.ts @@ -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() + private readonly timers = new Map>() + /** 每个工作区文件夹最近一次扫描的序号:先发出的扫描后回来时不盖掉新的 */ + private readonly scans = new Map() + private readonly changed = new EventEmitter() + /** 入口变了的那些文件 */ + 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 { + 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))) + } +} diff --git a/vscode/src/codeReader/explorer/rowDecoration.test.ts b/vscode/src/codeReader/explorer/rowDecoration.test.ts new file mode 100644 index 0000000..0b66aaf --- /dev/null +++ b/vscode/src/codeReader/explorer/rowDecoration.test.ts @@ -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('**别加粗** x [链接](command:x) `代码` # 标题 $(icon)')) + .toBe('\\*\\*别加粗\\*\\* \\x\\<\\/b\\> \\[链接\\]\\(command\\:x\\) \\`代码\\` \\# 标题 \\$\\(icon\\)') + }) +}) diff --git a/vscode/src/codeReader/explorer/rowDecoration.ts b/vscode/src/codeReader/explorer/rowDecoration.ts new file mode 100644 index 0000000..ae00e36 --- /dev/null +++ b/vscode/src/codeReader/explorer/rowDecoration.ts @@ -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`, + } +} diff --git a/vscode/src/codeReader/explorer/rowDecorator.ts b/vscode/src/codeReader/explorer/rowDecorator.ts new file mode 100644 index 0000000..f23cdec --- /dev/null +++ b/vscode/src/codeReader/explorer/rowDecorator.ts @@ -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 + noteOf: (node: FileNode) => string | undefined +} + +/** 哪些文件是项目写明的入口(见 entries/)。 */ +export interface RowEntries { + readonly onDidChange: Event + namesOf: (uri: Uri) => readonly string[] | undefined +} + +/** 侧栏一行名字后面的灰字和悬停提示:文件注释,后面接项目入口(见 rowDecoration);工作区外面的行(审查的工作副本)不加。 */ +export class RowDecorator implements FileTreeDecorator { + constructor(private readonly notes: RowNotes, private readonly entries: RowEntries) {} + + /** 注释变了、入口变了的那些行 */ + readonly onDidChange: Event = (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 + } +} diff --git a/vscode/src/codeReader/explorer/rowMenus.test.ts b/vscode/src/codeReader/explorer/rowMenus.test.ts index 4c1ff74..4959f24 100644 --- a/vscode/src/codeReader/explorer/rowMenus.test.ts +++ b/vscode/src/codeReader/explorer/rowMenus.test.ts @@ -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) diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 9aeb044..c58255c 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -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(), diff --git a/vscode/src/codeReader/notes/fileNotes.ts b/vscode/src/codeReader/notes/fileNotes.ts index 27d4cdc..092d00c 100644 --- a/vscode/src/codeReader/notes/fileNotes.ts +++ b/vscode/src/codeReader/notes/fileNotes.ts @@ -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 { const at = locate(node.uri) diff --git a/vscode/src/codeReader/notes/noteItem.test.ts b/vscode/src/codeReader/notes/noteItem.test.ts deleted file mode 100644 index 132c25f..0000000 --- a/vscode/src/codeReader/notes/noteItem.test.ts +++ /dev/null @@ -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('**别加粗** x [链接](command:x) `代码` # 标题 $(icon)')) - .toBe('\\*\\*别加粗\\*\\* \\x\\<\\/b\\> \\[链接\\]\\(command\\:x\\) \\`代码\\` \\# 标题 \\$\\(icon\\)') - }) -}) diff --git a/vscode/src/codeReader/notes/noteItem.ts b/vscode/src/codeReader/notes/noteItem.ts deleted file mode 100644 index 5b0360d..0000000 --- a/vscode/src/codeReader/notes/noteItem.ts +++ /dev/null @@ -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`, - } -} diff --git a/vscode/src/codeReader/workspaceFiles.ts b/vscode/src/codeReader/workspaceFiles.ts index a1ba545..4dc83c8 100644 --- a/vscode/src/codeReader/workspaceFiles.ts +++ b/vscode/src/codeReader/workspaceFiles.ts @@ -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 { +/** 读这个工作区文件夹里的文件(路径相对它、`/` 分隔)。 */ +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('useIgnoreFiles', true)) +} + +/** 这个工作区文件夹里还要跳过的(嵌套的 checkout、忽略文件忽略的),见 folderScope;忽略文件按 search.useIgnoreFiles。 */ +function scopeOf(folder: WorkspaceFolder): ReturnType { + return folderScope(folderReader(folder), workspace.getConfiguration('search', folder.uri).get('useIgnoreFiles', true)) } /**