✨ feat(vscode): 代码阅读注释里不带路径的引用 [Name] 能点,本文件定义的优先、再找导入的

This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent 0958506681
commit 300d8a07b9
16 files changed
+600 -124

No files matched your search

+11 -4
View File
@@ -472,9 +472,13 @@
- 中文名的兜底(`languages/typescriptNames.ts`、`pythonNames.ts` 从 `@param` / `Args:` 取参数的中文名)是另一回事,照旧读源码。 - 中文名的兜底(`languages/typescriptNames.ts`、`pythonNames.ts` 从 `@param` / `Args:` 取参数的中文名)是另一回事,照旧读源码。
- 渲染:webview 用 markdown-it(`html: false`、`linkify: true`),CommonMark 加 GFM 的表格、删除线:段落、强调、行内代码(含双反引号)、代码块(围栏、缩进四格)、列表、链接、标题、引用、表格。段内单个换行是软换行,空行分段。同一段文字只渲染一次(缓存最近 500 段)。 - 渲染:webview 用 markdown-it(`html: false`、`linkify: true`),CommonMark 加 GFM 的表格、删除线:段落、强调、行内代码(含双反引号)、代码块(围栏、缩进四格)、列表、链接、标题、引用、表格。段内单个换行是软换行,空行分段。同一段文字只渲染一次(缓存最近 500 段)。
- 安全:源码里的 HTML 当文字显示;链接认 http / https / mailto,带协议的网址和邮箱自动成链接(`sample.py` 这种不带协议的不算),别的写法(`javascript:`、`command:`、`./`、`..`、绝对路径、盘符)照原文显示。http / https / mailto 由 VS Code 的 webview 自己拦下、交给宿主在外部打开,不用另发消息。http / https 图片不渲染(webview 的 CSP 不放行外部图片),`![alt](url)` 显示成 `!` 加链接。仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」和路径。 - 安全:源码里的 HTML 当文字显示;链接认 http / https / mailto,带协议的网址和邮箱自动成链接(`sample.py` 这种不带协议的不算),别的写法(`javascript:`、`command:`、`./`、`..`、绝对路径、盘符)照原文显示。http / https / mailto 由 VS Code 的 webview 自己拦下、交给宿主在外部打开,不用另发消息。http / https 图片不渲染(webview 的 CSP 不放行外部图片),`![alt](url)` 显示成 `!` 加链接。仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」和路径。
- 仓库路径链接(`[名字](从仓库根起的路径)`,可带 `#类名` 或 `#类名.成员`)能点,点了打开文件并跳到符号。指到目录的在代码阅读侧栏的树上定位(展开并选中,和侧栏跟着当前文件走是同一个定位;侧栏没露着时亮出来),不切到 VS Code 资源管理器;树上没有(开着筛选时没摆它)时提示「侧栏的树上没有 …」,不退出筛选。 - 仓库路径链接(`[名字](从仓库根起的路径)`,可带 `#类名` 或 `#类名.成员`)能点,点了打开文件并跳到符号(指到这份文件里的符号时在本预览里滚过去、闪一下,同下面的引用)。指到目录的在代码阅读侧栏的树上定位(展开并选中,和侧栏跟着当前文件走是同一个定位;侧栏没露着时亮出来),不切到 VS Code 资源管理器;树上没有(开着筛选时没摆它)时提示「侧栏的树上没有 …」,不退出筛选。
- 不带路径的引用(KDoc 的 `[Name]`、`[Class.member]`)能点:
- 认哪些:只在注释的 Markdown 里认(说明正文、参数说明、`@return`、「异常」表、单独成块的注释,连带增删的;问 AI 的回答不认)。方括号里是标识符(ASCII 字母、数字、下划线、`$`,点分开的几段);中文这类不是标识符的(`[会话进展]`)照原样是文字。反引号里的是代码;紧跟在名字、`]`、`)` 后面的方括号是下标(`items[i]`、`a[0][1]`、`f()[k]`);后面紧跟 `(…)` 的是完整链接、紧跟 `[` 的是参考式链接,都不算。webview 在 `markdown.ts` 加一条行内规则(排在 markdown-it 的 link 后面,渲染时 `env.symbolLinks` 为真才认),写成地址为 `symbol:名字` 的链接(作者写不出这种地址,`validateLink` 不放行)。
- 指到哪(扩展端,`symbolLinks.ts` 的 `resolveReference`,按 KDoc 的规矩):① 本文件定义的:文档符号(`vscode.executeDocumentSymbolProvider`,同仓库链接找符号那一套)顶层名字对上的(类、函数、常量、变量;落在导入语句里的名字不算),`Class.member` 在它底下找成员;② 本文件导入进来的:名字(`Class.member` 的第一段)是某条导入引进来的(预览已有的导入块 facts,`ImportFacts.items`:起了别名的按别名认,问原来那个名字;整模块导入按第一节认,起了别名的问最后一节),在导入语句里这个名字的位置上问语言扩展定义在哪(`vscode.executeDefinitionProvider`,同跳到定义,几处取第一处),`Class.member` 再在目标文件的文档符号里找:定义落在哪个符号里就在它底下找,落在文件开头(导入的是模块)就从顶层找;③ 都没有就找不到。本文件定义了这个名字、却没有这个成员时也算找不到,不再去导入里找。
- 问答同仓库路径链接(`useRepoLinks`、`RepoLinks`):画出来后把页面上的仓库路径和引用一起发去问(`checkRepoLinks { paths, symbols }` → `repoLinksChecked { missing, unresolved }`),问到之前是普通链接,找不到的画成坏链接(红色波浪线,悬停「找不到」);文档换了版本(收到 `document`)再问一次,引用跟着源码变。点了发 `openRepoLink`(不带 `path`,只带 `symbol`):找到了在预览所在的编辑器组打开目标文件、选中符号的名字(同完整链接的后半段),目标在这份文件里时在本预览里滚过去、闪一下(同跳到定义的闪);找不到就标坏。
- 样子:正文用界面字体(`--vscode-font-family`、`--vscode-font-size`)、comment 色、行高 20px,一行注释和一行译文一样高;行内代码用编辑器字体、`--vscode-textCodeBlock-background` 底;围栏代码块用编辑器字体和字号、同样的底,长行折行;段落、列表、代码块、表格之间 6px;标题只比正文稍大(h1 1.15em、h2 1.08em,其余同正文,加粗);链接 `--vscode-textLink-foreground`;引用左边 3px `--vscode-textBlockQuote-border` 竖线、`--vscode-textBlockQuote-background` 底;表格 1px 细线。 - 样子:正文用界面字体(`--vscode-font-family`、`--vscode-font-size`)、comment 色、行高 20px,一行注释和一行译文一样高;行内代码用编辑器字体、`--vscode-textCodeBlock-background` 底;围栏代码块用编辑器字体和字号、同样的底,长行折行;段落、列表、代码块、表格之间 6px;标题只比正文稍大(h1 1.15em、h2 1.08em,其余同正文,加粗);链接 `--vscode-textLink-foreground`;引用左边 3px `--vscode-textBlockQuote-border` 竖线、`--vscode-textBlockQuote-background` 底;表格 1px 细线。
- 「编辑」状态照旧是源码,编辑写回不变;Markdown 里的名字不能点,不参与跳到定义。 - 「编辑」状态照旧是源码,编辑写回不变;Markdown 里的名字(反引号里的代码)不能点,不参与跳到定义,能点的只有上面的链接和引用。
### 跳到定义 ### 跳到定义
@@ -1992,11 +1996,13 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTab
- `{ type: 'askProfiles', options: { id, label }[], selected }`:「问 AI」的 profile 下拉(`id` 是 profile 名,「默认」的 `id` 是 `''`、`label` 是「默认」)。`ready` 后发一次(每次现读 profile 目录),任何一个预览选了都发给所有开着的预览(包括选的那个)。 - `{ type: 'askProfiles', options: { id, label }[], selected }`:「问 AI」的 profile 下拉(`id` 是 profile 名,「默认」的 `id` 是 `''`、`label` 是「默认」)。`ready` 后发一次(每次现读 profile 目录),任何一个预览选了都发给所有开着的预览(包括选的那个)。
- `{ type: 'answer', askId, text, activity? }`:`askId` 那个问题的回答长了(`text` 是到现在为止的全文)或换了在做的事。webview 攒着、最多每 80ms 画一次。 - `{ type: 'answer', askId, text, activity? }`:`askId` 那个问题的回答长了(`text` 是到现在为止的全文)或换了在做的事。webview 攒着、最多每 80ms 画一次。
- `{ type: 'answerEnd', askId, status: 'done' | 'failed' | 'stopped', text?, error?, entryId? }`:答完了。`done` 带最终回答和存下的那条的 id(历史里不再重复显示它),`failed` 带给人看的原因,`stopped` 是被叫停、没存。 - `{ type: 'answerEnd', askId, status: 'done' | 'failed' | 'stopped', text?, error?, entryId? }`:答完了。`done` 带最终回答和存下的那条的 id(历史里不再重复显示它),`failed` 带给人看的原因,`stopped` 是被叫停、没存。
- `{ type: 'repoLinksChecked', id, missing, unresolved }`:回答第 `id` 次 `checkRepoLinks`,`missing` 是仓库里没有的路径,`unresolved` 是找不到的不带路径的引用。`{ type: 'repoLinkBroken' | 'repoLinkOpened', path?, symbol? }`:点了以后打不开 / 打开了;没有 `path` 的是不带路径的引用。见「注释的 Markdown 排版」。
- webview → 扩展: - webview → 扩展:
- `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。 - `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。
- `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。这两条都只带下标:扩展端按发出去的文档认出规则标了引用的下标交给规则查(见「规则自己查定义」),其余问语言扩展。 - `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。这两条都只带下标:扩展端按发出去的文档认出规则标了引用的下标交给规则查(见「规则自己查定义」),其余问语言扩展。
- `{ type: 'findOccurrences', id, version, offset }`:单击带 `offset` 的名字,问它在这份文档里的各处出现;`id` 每问一次加一,`version` 不是当前文档版本就不答。 - `{ type: 'findOccurrences', id, version, offset }`:单击带 `offset` 的名字,问它在这份文档里的各处出现;`id` 每问一次加一,`version` 不是当前文档版本就不答。
- `{ type: 'occurrenceNavigation', available }`:眼下能不能用键盘在各处出现之间跳,页面加载后报一次、之后变了才报;扩展端按它设 `superpowers.codeReader.hasOccurrences`。 - `{ type: 'occurrenceNavigation', available }`:眼下能不能用键盘在各处出现之间跳,页面加载后报一次、之后变了才报;扩展端按它设 `superpowers.codeReader.hasOccurrences`。
- `{ type: 'checkRepoLinks', id, paths, symbols }`:页面上的仓库路径问哪些没有,不带路径的引用问哪些找不到(`id` 每问一次加一)。`{ type: 'openRepoLink', path?, symbol? }`:点了注释里的仓库链接(带 `path`)或不带路径的引用(只带 `symbol`)。见「注释的 Markdown 排版」。
- `{ type: 'ready' }` - `{ type: 'ready' }`
- `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。
- `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。
@@ -2068,6 +2074,7 @@ vscode/src/codeReader/
rules/children.ts 库规则共用的挑子块的办法(成员连同紧挨在它前面的注释块、成员的说明节点) rules/children.ts 库规则共用的挑子块的办法(成员连同紧挨在它前面的注释块、成员的说明节点)
docTags.ts 说明的原文按 KDoc 拆成正文和块标签(Doc:@param / @return / @throws),翻译插件的 doc 用它(纯函数) docTags.ts 说明的原文按 KDoc 拆成正文和块标签(Doc:@param / @return / @throws),翻译插件的 doc 用它(纯函数)
rules/docRows.ts 讲说明的几行,中文直译和库规则共用:正文、对不上的参数、参数按名字对说明(paramDocs)、输出后面的 @return、异常表 rules/docRows.ts 讲说明的几行,中文直译和库规则共用:正文、对不上的参数、参数按名字对说明(paramDocs)、输出后面的 @return、异常表
symbolLinks.ts 注释里不带路径的引用指到哪:导入引进来的名字(importedNames,readBlockViews 交出)、本文件定义的优先再是导入的(resolveReference,符号和定义由预览去问)(不碰 VS Code)
languages/shared.ts 语言插件共用的建树工具;异常类(withExceptions:按整份文件认异常类,类块换成异常块,并类属性、取构造参数) languages/shared.ts 语言插件共用的建树工具;异常类(withExceptions:按整份文件认异常类,类块换成异常块,并类属性、取构造参数)
languages/typescript.ts (call:调用的结构,new 是新建;TYPESCRIPT_EXCEPTIONS:认异常类的父类名、类字段、constructor) languages/typescript.ts (call:调用的结构,new 是新建;TYPESCRIPT_EXCEPTIONS:认异常类的父类名、类字段、constructor)
languages/python.ts (iteration:推导式的结构;call:调用的结构;PYTHON_EXCEPTIONS:自带的异常类、类属性、__init__) languages/python.ts (iteration:推导式的结构;call:调用的结构;PYTHON_EXCEPTIONS:自带的异常类、类属性、__init__)
@@ -2142,7 +2149,7 @@ vscode/src/codeReader/
editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights) editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights)
editor/occurrenceNavigation.ts 跳到下一处 / 上一处高亮:命令、上下文键 editor/occurrenceNavigation.ts 跳到下一处 / 上一处高亮:命令、上下文键
index.ts registerCodeReader(context): CodeReaderApi index.ts registerCodeReader(context): CodeReaderApi
vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,悬停提示在 src/components/ui/tooltip.tsx(和看板共用,页面根上挂一次),块的概念图标(图标名 → 图标组件只在 kindIcons.tsx;内置种类 → 图标名在 contract.ts 的 KIND_ICONS,宿主写进概念列表)在 kindIcons.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,中文名换字在 displayNames.ts,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts;代码差异:页面上怎么标(块 id、删掉的块挂哪、只看改动折哪几段、各处改动、上一处 / 下一处跳到哪)在 diffView.ts,筛选栏第二行和新文件横幅在 DiffBar.tsx,卡片里的增删在 DiffText.tsx(Markdown 的增删在 markdown.ts 的 renderMarkdownDiff),总览条在 OverviewRuler.tsx,跳到一处改动在 useChangeNav.ts) vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,悬停提示在 src/components/ui/tooltip.tsx(和看板共用,页面根上挂一次),块的概念图标(图标名 → 图标组件只在 kindIcons.tsx;内置种类 → 图标名在 contract.ts 的 KIND_ICONS,宿主写进概念列表)在 kindIcons.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,中文名换字在 displayNames.ts,Markdown 渲染在 markdown.ts / TextPreview.tsx(不带路径的引用的行内规则也在 markdown.ts),仓库链接和引用的点击、问坏没坏在 repoLink.ts / useRepoLinks.ts,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts;代码差异:页面上怎么标(块 id、删掉的块挂哪、只看改动折哪几段、各处改动、上一处 / 下一处跳到哪)在 diffView.ts,筛选栏第二行和新文件横幅在 DiffBar.tsx,卡片里的增删在 DiffText.tsx(Markdown 的增删在 markdown.ts 的 renderMarkdownDiff),总览条在 OverviewRuler.tsx,跳到一处改动在 useChangeNav.ts)
``` ```
## 测试 ## 测试
@@ -2159,7 +2166,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
- 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。
- `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。 - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。
- 块标签:拆法(`docTags.test.ts`:多行续写、空行结束、`@returns` / `@exception` 同义、JSDoc 的 `{类型}`、`[名字=默认值]`、横杠、标签前后的正文、没有标签时原样、别的标签和围栏代码块里的行留在正文、异常名写成引用);卡片(`translators/zhTemplate.test.ts` 的「说明里的块标签」):`fixtures/conversationBrief.py`(luffy 的 `render_conversation_brief`)说明里没有 `@param` / `@return`,输入参数表「说明」列四个参数的原文,输出参数后面跟 `@return`;`fixtures/localRuntimeGate.py`(luffy 的 `gate.py`)「异常」表两行,第一行是完整链接、第二行是不带路径的引用;对不上的 `@param` 排在说明下面、提示色、悬停「没有这个参数」;类的 `@param` 照写成表、异常类的对到构造参数(`*args` 去掉星号对)。React 组件(字段没注释时用 `@param`、`props.x`、讲整个参数的跟在构造参数后面、对不上的、异常表)和事件处理函数(`rules/react.test.ts`)、FastAPI 请求表的说明列和响应后面的 `@return`(`rules/fastapi.test.ts`)、Rust 函数和结构体(`languages/rust.test.ts`)、数据类字段(`rules/dataclass.test.ts`)。 - 块标签:拆法(`docTags.test.ts`:多行续写、空行结束、`@returns` / `@exception` 同义、JSDoc 的 `{类型}`、`[名字=默认值]`、横杠、标签前后的正文、没有标签时原样、别的标签和围栏代码块里的行留在正文、异常名写成引用);卡片(`translators/zhTemplate.test.ts` 的「说明里的块标签」):`fixtures/conversationBrief.py`(luffy 的 `render_conversation_brief`)说明里没有 `@param` / `@return`,输入参数表「说明」列四个参数的原文,输出参数后面跟 `@return`;`fixtures/localRuntimeGate.py`(luffy 的 `gate.py`)「异常」表两行,第一行是完整链接、第二行是不带路径的引用;对不上的 `@param` 排在说明下面、提示色、悬停「没有这个参数」;类的 `@param` 照写成表、异常类的对到构造参数(`*args` 去掉星号对)。React 组件(字段没注释时用 `@param`、`props.x`、讲整个参数的跟在构造参数后面、对不上的、异常表)和事件处理函数(`rules/react.test.ts`)、FastAPI 请求表的说明列和响应后面的 `@return`(`rules/fastapi.test.ts`)、Rust 函数和结构体(`languages/rust.test.ts`)、数据类字段(`rules/dataclass.test.ts`)。
- 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、`{@link}`、HTML 都不转换,块标签拆出去),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / `./` / `..` / 绝对路径的链接和 http 图片不渲染,仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」,仓库路径链接能点、点了打开文件并跳到符号;跳到定义不认 Markdown 里的名字。 - 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、`{@link}`、HTML 都不转换,块标签拆出去),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / `./` / `..` / 绝对路径的链接和 http 图片不渲染,仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」,仓库路径链接能点、点了打开文件并跳到符号;跳到定义不认 Markdown 里的名字。不带路径的引用:行内规则(`webview-ui/.../markdown.test.ts`:`[Name]`、`[A.b]` 是 `symbol:` 链接,`` `[x]` ``、`items[i]`、`[会话进展]`、`[名字](路径)`、`[Agent](javascript:x)` 各自的结果,问 AI 的回答不认、带增删的注释照样认);问答(`repoLink.test.ts`:`symbol:` 地址只认标识符、点了发不带 `path` 的 `openRepoLink`、和仓库路径一起问、找不到的标坏、打开成功不再标、同样的链接不再问、文档换了版本再问);扩展端(`symbolLinks.test.ts`:本文件优先于导入且不问语言扩展、本文件的 `Class.member`、类有成员没有就是找不到、导入的名字问定义再在那个类底下找成员、整模块导入从顶层找、找不到;Python / TypeScript 各种导入写法引进来的名字和问定义的位置,`gate.py` 的导入)。
- 行替换:多行块替换后缩进正确。 - 行替换:多行块替换后缩进正确。
- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。 - 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。
- 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,讲别处名字的段(`refers`)按那个名字算、自己照抄的位置不算(`occurrences.test.ts` 的 `sourceOf`),点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。键盘跳(`occurrences.test.ts`、`reveal.test.ts`、`editor/occurrenceNavigation.test.ts`):回答照源码先后排、点中的那处是当前那一处、「2/5」和「N 处」;往后 / 往前、到头接着跳,找不到的跳过、总数照旧、一处都找不到不动,点中的不在回答里从点中的地方数起;只有当前那一处描框;能不能跳(高亮、焦点、输入框)第一次必报、之后变了才报;页面上找第一处显示着它的地方、要展开的块 / 属性行 / 子行、跳过筛掉 / 编辑 / 盖住的块、Markdown 不算;`package.json` 的两条命令、默认键、`when`、命令面板,扩展和页面的代码里不写键、不拦键,上下文键有一个预览能跳就真、变了才设,命令发给能跳的预览、没有就发给活动的;按键那一刻的位置跳转时回一次、隔太久 / 点过鼠标 / 没按过键不回,跳完守住跳到的位置、过了 `KEY_SCROLL_MS` 或又按键、点鼠标不守。 - 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,讲别处名字的段(`refers`)按那个名字算、自己照抄的位置不算(`occurrences.test.ts` 的 `sourceOf`),点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。键盘跳(`occurrences.test.ts`、`reveal.test.ts`、`editor/occurrenceNavigation.test.ts`):回答照源码先后排、点中的那处是当前那一处、「2/5」和「N 处」;往后 / 往前、到头接着跳,找不到的跳过、总数照旧、一处都找不到不动,点中的不在回答里从点中的地方数起;只有当前那一处描框;能不能跳(高亮、焦点、输入框)第一次必报、之后变了才报;页面上找第一处显示着它的地方、要展开的块 / 属性行 / 子行、跳过筛掉 / 编辑 / 盖住的块、Markdown 不算;`package.json` 的两条命令、默认键、`when`、命令面板,扩展和页面的代码里不写键、不拦键,上下文键有一个预览能跳就真、变了才设,命令发给能跳的预览、没有就发给活动的;按键那一刻的位置跳转时回一次、隔太久 / 点过鼠标 / 没按过键不回,跳完守住跳到的位置、过了 `KEY_SCROLL_MS` 或又按键、点鼠标不守。
+8 -3
View File
@@ -1,12 +1,14 @@
import type { Tree } from '@vscode/tree-sitter-wasm' import type { Tree } from '@vscode/tree-sitter-wasm'
import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleCard, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract' import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleCard, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract'
import type { TreeSitterRuntime } from './runtime' import type { TreeSitterRuntime } from './runtime'
import type { ImportedName } from './symbolLinks'
import { blockAttributes } from './attributes' import { blockAttributes } from './attributes'
import { KIND_ICONS } from './contract' import { KIND_ICONS } from './contract'
import { commonIndent, splitLines } from './editText' import { commonIndent, splitLines } from './editText'
import { commentSpan } from './languages/shared' import { commentSpan } from './languages/shared'
import { activeRules, claimBlock, importsIn } from './libraryRules' import { activeRules, claimBlock, importsIn } from './libraryRules'
import { BlockAnchors } from './qa/anchor' import { BlockAnchors } from './qa/anchor'
import { importedNames } from './symbolLinks'
interface BlockViewBase { interface BlockViewBase {
/** 在树里的位置,例如 '6.1.0'。 */ /** 在树里的位置,例如 '6.1.0'。 */
@@ -415,12 +417,14 @@ export function toBlockViews(
/** /**
* 一份源码的视图数据,和它里面起了中文名的定义(预览找用到它们的各处要用,见 displayNames.ts)、 * 一份源码的视图数据,和它里面起了中文名的定义(预览找用到它们的各处要用,见 displayNames.ts)、
* 语言插件自己认得出的名字(LanguageAdapter.symbols,语言扩展查不到定义、各处出现时用)。 * 语言插件自己认得出的名字(LanguageAdapter.symbols,语言扩展查不到定义、各处出现时用)、
* 各条导入引进来的名字(注释里不带路径的引用按它找导入的定义,见 symbolLinks.ts)。
*/ */
export interface DocumentViews { export interface DocumentViews {
blocks: BlockView[] blocks: BlockView[]
displayNames: DisplayName[] displayNames: DisplayName[]
symbols: LocalSymbol[] symbols: LocalSymbol[]
imports: ImportedName[]
} }
/** /**
@@ -487,12 +491,13 @@ export async function readBlockViews(
...(commandRule && { commandRule }), ...(commandRule && { commandRule }),
...(parts.size > 0 && { embedded }), ...(parts.size > 0 && { embedded }),
} }
const blocks = toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), document, translator, extras.rules, parts) const codeBlocks = adapter.toBlocks(tree.rootNode, source, languageId)
const blocks = toBlockViews(codeBlocks, document, translator, extras.rules, parts)
const symbols = [ const symbols = [
...adapter.symbols?.(tree.rootNode, source, languageId) ?? [], ...adapter.symbols?.(tree.rootNode, source, languageId) ?? [],
...[...parts.values()].flatMap(part => part.document.language.symbols?.(part.root, source, part.code.languageId) ?? []), ...[...parts.values()].flatMap(part => part.document.language.symbols?.(part.root, source, part.code.languageId) ?? []),
] ]
return { blocks, displayNames: names, symbols } return { blocks, displayNames: names, symbols, imports: importedNames(importsIn(codeBlocks)) }
} }
finally { finally {
for (const parsed of trees) for (const parsed of trees)
+2 -2
View File
@@ -11,8 +11,8 @@ const TAG_LINE = /^\s*@(\w+)(?:\s(.*))?$/
/** 围栏代码块的开头(三个以上的反引号或波浪线,前面最多三个空格):里面的行照原样留在正文里。 */ /** 围栏代码块的开头(三个以上的反引号或波浪线,前面最多三个空格):里面的行照原样留在正文里。 */
const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})/ const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})/
/** 一个名字或点分的几个名字(`ValueError`、`errors.NotFound`):写成不带路径的引用。 */ /** 一个标识符或点分的几个(`ValueError`、`errors.NotFound`),同预览认引用的写法:写成不带路径的引用。 */
const NAME_PATH = /^[\p{L}_$][\p{L}\p{N}_$]*(?:\.[\p{L}_$][\p{L}\p{N}_$]*)*$/u const NAME_PATH = /^[A-Z_$][\w$]*(?:\.[A-Z_$][\w$]*)*$/i
interface Tag { interface Tag {
kind: TagKind kind: TagKind
@@ -1,6 +1,7 @@
import type { DisplayName, LocalSymbol, RuleContext, SourceDocument } from '../contract' import type { DisplayName, LocalSymbol, RuleContext, SourceDocument } from '../contract'
import type { CodeReaderRegistry } from '../registry' import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime' import type { TreeSitterRuntime } from '../runtime'
import type { ImportedName } from '../symbolLinks'
import type { DocumentMessage } from './messages' import type { DocumentMessage } from './messages'
import { fileAttributes, mergeAttributeNames } from '../attributes' import { fileAttributes, mergeAttributeNames } from '../attributes'
import { readBlockViews } from '../blockView' import { readBlockViews } from '../blockView'
@@ -19,11 +20,15 @@ export interface DocumentSnapshot {
entryNames?: readonly string[] entryNames?: readonly string[]
} }
/** 发给预览的文档,和这份源码里起了中文名的定义(扩展端接着找用到它们的各处)、语言插件自己认得出的名字(LocalSymbol)。 */ /**
* 发给预览的文档,和这份源码里起了中文名的定义(扩展端接着找用到它们的各处)、语言插件自己认得出的名字(LocalSymbol)、
* 各条导入引进来的名字(注释里不带路径的引用按它找导入的定义)。
*/
export interface DocumentRead { export interface DocumentRead {
message: DocumentMessage message: DocumentMessage
displayNames: DisplayName[] displayNames: DisplayName[]
symbols: LocalSymbol[] symbols: LocalSymbol[]
imports: ImportedName[]
} }
/** /**
@@ -55,10 +60,10 @@ export async function documentMessage(
} }
const adapter = registry.languageFor(languageId) const adapter = registry.languageFor(languageId)
if (!adapter) if (!adapter)
return { message: { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }, displayNames: [], symbols: [] } return { message: { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }, displayNames: [], symbols: [], imports: [] }
try { try {
const rules = registry.rulesFor(languageId) const rules = registry.rulesFor(languageId)
const { blocks, displayNames, symbols } = await readBlockViews(runtime, adapter, translator, languageId, source, { const { blocks, displayNames, symbols, imports } = await readBlockViews(runtime, adapter, translator, languageId, source, {
...(document.fileName && { fileName: document.fileName }), ...(document.fileName && { fileName: document.fileName }),
rules: { rules, report, lookup }, rules: { rules, report, lookup },
calleeKind, calleeKind,
@@ -73,9 +78,9 @@ export async function documentMessage(
}, },
}) })
const names = mergeAttributeNames([translator?.attributeNames, ...ruleAttributeNames(rules, translator?.displayLanguage, report)]) const names = mergeAttributeNames([translator?.attributeNames, ...ruleAttributeNames(rules, translator?.displayLanguage, report)])
return { message: { ...message, blocks: markEntryBlocks(blocks, source, document.entryNames ?? []), attributes: fileAttributes(blocks, names) }, displayNames, symbols } return { message: { ...message, blocks: markEntryBlocks(blocks, source, document.entryNames ?? []), attributes: fileAttributes(blocks, names) }, displayNames, symbols, imports }
} }
catch (err) { catch (err) {
return { message: { ...message, error: err instanceof Error ? err.message : String(err) }, displayNames: [], symbols: [] } return { message: { ...message, error: err instanceof Error ? err.message : String(err) }, displayNames: [], symbols: [], imports: [] }
} }
} }
+11 -10
View File
@@ -101,9 +101,9 @@ export interface QaMessage {
* displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理 * displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理
* diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有,见 DiffBase):每发一份文档之后算好了补发, * diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有,见 DiffBase):每发一份文档之后算好了补发,
* 换了筛选、基准挪了(提交后 HEAD 动了)再发;null 是不比了(回到全部文件) * 换了筛选、基准挪了(提交后 HEAD 动了)再发;null 是不比了(回到全部文件)
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径 * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有 * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在 * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了
* repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在 * repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在
*/ */
export type ExtensionToWebview export type ExtensionToWebview
@@ -121,9 +121,9 @@ export type ExtensionToWebview
| { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string } | { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string }
| { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string } | { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string }
| { type: 'diff', diff: DocumentDiff | null } | { type: 'diff', diff: DocumentDiff | null }
| { type: 'repoLinksChecked', id: number, missing: string[] } | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
| { type: 'repoLinkBroken', path: string, symbol?: string } | { type: 'repoLinkBroken', path?: string, symbol?: string }
| { type: 'repoLinkOpened', path: string, symbol?: string } | { type: 'repoLinkOpened', path?: string, symbol?: string }
| { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] } | { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] }
| { type: 'focusPathAsk' } | { type: 'focusPathAsk' }
@@ -145,8 +145,9 @@ export type ExtensionToWebview
* selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览 * selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发; * occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发;
* 扩展端按它设 when 子句用的上下文键 * 扩展端按它设 when 子句用的上下文键
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录 * openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录;
* checkRepoLinks:预览里这批仓库路径,问哪些文件或目录没有。id 标出是哪一次问 * 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),按这份文件找 symbol(本文件定义的,再是导入的,见 symbolLinks.ts)
* checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(symbols)问哪些找不到。id 标出是哪一次问
* checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问 * checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问
*/ */
export type WebviewToExtension export type WebviewToExtension
@@ -166,6 +167,6 @@ export type WebviewToExtension
| { type: 'deleteQa', anchor: string, entryId: string } | { type: 'deleteQa', anchor: string, entryId: string }
| { type: 'selectAskProfile', id: string } | { type: 'selectAskProfile', id: string }
| { type: 'occurrenceNavigation', available: boolean } | { type: 'occurrenceNavigation', available: boolean }
| { type: 'openRepoLink', path: string, symbol?: string } | { type: 'openRepoLink', path?: string, symbol?: string }
| { type: 'checkRepoLinks', id: number, paths: string[] } | { type: 'checkRepoLinks', id: number, paths: string[], symbols: string[] }
| { type: 'checkRepoImages', id: number, paths: string[] } | { type: 'checkRepoImages', id: number, paths: string[] }
+105 -32
View File
@@ -1,6 +1,6 @@
import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode' import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
import type { ClaudeImage } from '../../cc/spawnClaude' import type { ClaudeImage } from '../../cc/spawnClaude'
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, WorkspaceIndex } from '../contract' import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange, WorkspaceIndex } from '../contract'
import type { DiffBase } from '../diff/diffBase' import type { DiffBase } from '../diff/diffBase'
import type { OldDocument, SentDocument } from '../diff/previewDiff' import type { OldDocument, SentDocument } from '../diff/previewDiff'
import type { WorkspaceEntries } from '../entries/workspaceEntries' import type { WorkspaceEntries } from '../entries/workspaceEntries'
@@ -9,6 +9,7 @@ import type { CodeQa } from '../qa/codeQa'
import type { CodeReaderRegistry } from '../registry' import type { CodeReaderRegistry } from '../registry'
import type { LookupSources } from '../ruleLookups' import type { LookupSources } from '../ruleLookups'
import type { TreeSitterRuntime } from '../runtime' import type { TreeSitterRuntime } from '../runtime'
import type { ImportedName, LinkTarget, SymbolNode } from '../symbolLinks'
import type { IndexedEntry } from '../workspaceIndex' import type { IndexedEntry } from '../workspaceIndex'
import type { BlockFilter } from './blockFilter' import type { BlockFilter } from './blockFilter'
import type { Definition, FoundLocation } from './definitions' import type { Definition, FoundLocation } from './definitions'
@@ -29,6 +30,7 @@ import { askClaude, askFailure } from '../qa/claudeAsk'
import { PATH_ASK_ANCHOR } from '../qa/pathQa' import { PATH_ASK_ANCHOR } from '../qa/pathQa'
import { askPrompt, followUpPrompt } from '../qa/prompt' import { askPrompt, followUpPrompt } from '../qa/prompt'
import { DocumentLookups } from '../ruleLookups' import { DocumentLookups } from '../ruleLookups'
import { resolveReference } from '../symbolLinks'
import { definitionAction } from './definitionAction' import { definitionAction } from './definitionAction'
import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions' import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
import { DefinitionSources } from './definitionSources' import { DefinitionSources } from './definitionSources'
@@ -188,6 +190,8 @@ class Preview {
private sent: SentDocument | undefined private sent: SentDocument | undefined
/** 最近一次发出去的文档里语言插件自己认得出的名字(LanguageAdapter.symbols):语言扩展查不到定义、各处出现时用 */ /** 最近一次发出去的文档里语言插件自己认得出的名字(LanguageAdapter.symbols):语言扩展查不到定义、各处出现时用 */
private symbols: { version: number, list: LocalSymbol[] } | undefined private symbols: { version: number, list: LocalSymbol[] } | undefined
/** 最近一次发出去的文档里各条导入引进来的名字:注释里不带路径的引用按它找导入的定义 */
private imports: ImportedName[] = []
/** 这份文档各版本显示成中文名的名字 */ /** 这份文档各版本显示成中文名的名字 */
private readonly names: DocumentNames private readonly names: DocumentNames
/** 库规则问的名字的定义查到了什么:查到的变了就重发文档 */ /** 库规则问的名字的定义查到了什么:查到的变了就重发文档 */
@@ -330,7 +334,7 @@ class Preview {
const source = this.document.getText() const source = this.document.getText()
const session = this.lookups.session() const session = this.lookups.session()
const callees = this.callees.session() const callees = this.callees.session()
const { message, displayNames, symbols } = await documentMessage({ languageId, version, getText: () => source, fileName: this.document.fileName, entryNames: this.owner.entries.namesOf(this.document.uri) }, this.registry, this.runtime, (text) => { const { message, displayNames, symbols, imports } = await documentMessage({ languageId, version, getText: () => source, fileName: this.document.fileName, 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}` }) logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` })
}, session.lookup, callees.calleeKind) }, session.lookup, callees.calleeKind)
if (request !== this.latest || !this.ready) if (request !== this.latest || !this.ready)
@@ -339,6 +343,7 @@ class Preview {
// 解析失败的文档没有块:问 AI 找不到块,也不拿它比差异 // 解析失败的文档没有块:问 AI 找不到块,也不拿它比差异
this.sent = message.error === undefined ? { version: message.version, blocks: message.blocks, source } : undefined this.sent = message.error === undefined ? { version: message.version, blocks: message.blocks, source } : undefined
this.symbols = { version, list: symbols } this.symbols = { version, list: symbols }
this.imports = imports
const reveal = this.pendingReveal const reveal = this.pendingReveal
this.pendingReveal = undefined this.pendingReveal = undefined
this.post({ ...message, names: this.names.known(version), ...(reveal && { reveal }) }) this.post({ ...message, names: this.names.known(version), ...(reveal && { reveal }) })
@@ -579,7 +584,7 @@ class Preview {
await this.openRepoLink(msg.path, msg.symbol) await this.openRepoLink(msg.path, msg.symbol)
return return
case 'checkRepoLinks': case 'checkRepoLinks':
await this.checkRepoLinks(msg.id, msg.paths) await this.checkRepoLinks(msg.id, msg.paths, msg.symbols)
return return
case 'checkRepoImages': case 'checkRepoImages':
await this.checkRepoImages(msg.id, msg.paths) await this.checkRepoImages(msg.id, msg.paths)
@@ -654,16 +659,25 @@ class Preview {
await this.owner.openAt(uri, reveal, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options)) await this.owner.openAt(uri, reveal, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options))
} }
/** 预览里这批仓库路径,哪些文件或目录不在。 */ /** 预览里这批仓库路径,哪些文件或目录不在;这批不带路径的引用,哪些找不到。 */
private async checkRepoLinks(id: number, paths: string[]): Promise<void> { private async checkRepoLinks(id: number, paths: string[], symbols: string[]): Promise<void> {
const root = await repoRootOf(this.document.uri.fsPath) const root = await repoRootOf(this.document.uri.fsPath)
const missing = (await Promise.all(paths.map(async (rel) => { const asked = new Map<string, Promise<SymbolNode[]>>()
if (root === undefined || await resolveRepoEntry(root, rel) === undefined) const symbolsOf = (uri: string): Promise<SymbolNode[]> => {
return rel const known = asked.get(uri) ?? this.symbolNodes(uri)
return undefined asked.set(uri, known)
}))).filter(rel => rel !== undefined) return known
}
const [missing, unresolved] = await Promise.all([
Promise.all(paths.map(async (rel) => {
if (root === undefined || await resolveRepoEntry(root, rel) === undefined)
return rel
return undefined
})),
Promise.all(symbols.map(async symbol => await this.resolveSymbolLink(symbol, symbolsOf) ? undefined : symbol)),
])
if (this.ready) if (this.ready)
this.post({ type: 'repoLinksChecked', id, missing }) this.post({ type: 'repoLinksChecked', id, missing: missing.filter(rel => rel !== undefined), unresolved: unresolved.filter(symbol => symbol !== undefined) })
} }
/** 仓库根放进这个预览能读的本地目录,图片地址才能加载。真实位置和 git 给的路径不一样时两个都放。 */ /** 仓库根放进这个预览能读的本地目录,图片地址才能加载。真实位置和 git 给的路径不一样时两个都放。 */
@@ -707,18 +721,29 @@ class Preview {
/** /**
* 打开注释里的仓库链接:目录在代码阅读侧栏的树上定位,文件用编辑器打开。 * 打开注释里的仓库链接:目录在代码阅读侧栏的树上定位,文件用编辑器打开。
* 带了符号就按文档符号的名字跳过去;文件或符号没有就不打开。 * 带了符号就按文档符号的名字跳过去;文件或符号没有就不打开。没有路径的是不带路径的引用,按这份文件找(见 resolveSymbolLink)。
*/ */
private async openRepoLink(rel: string, symbol?: string): Promise<void> { private async openRepoLink(rel: string | undefined, symbol?: string): Promise<void> {
const root = await repoRootOf(this.document.uri.fsPath) const link = rel ?? `[${symbol}]`
const target = root === undefined ? undefined : await resolveRepoEntry(root, rel)
if (!target) {
this.markRepoLinkBroken(rel, symbol)
return
}
const uri = Uri.file(target.fsPath)
try { try {
if (target.isDirectory) { if (rel === undefined) {
const target = symbol === undefined ? undefined : await this.resolveSymbolLink(symbol)
if (!target) {
this.markRepoLinkBroken(undefined, symbol)
return
}
await this.showLinkTarget(target)
this.markRepoLinkOpened(undefined, symbol)
return
}
const root = await repoRootOf(this.document.uri.fsPath)
const entry = root === undefined ? undefined : await resolveRepoEntry(root, rel)
if (!entry) {
this.markRepoLinkBroken(rel, symbol)
return
}
const uri = Uri.file(entry.fsPath)
if (entry.isDirectory) {
if (symbol !== undefined) { if (symbol !== undefined) {
this.markRepoLinkBroken(rel, symbol) this.markRepoLinkBroken(rel, symbol)
return return
@@ -738,21 +763,69 @@ class Preview {
this.markRepoLinkBroken(rel, symbol) this.markRepoLinkBroken(rel, symbol)
return return
} }
await this.showRepoFile(uri, found.selectionRange) await this.showLinkTarget(found)
this.markRepoLinkOpened(rel, symbol) this.markRepoLinkOpened(rel, symbol)
} }
catch (err) { catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'warn', source: 'codeReader', message: `没能打开仓库链接 ${rel}:${message}` }) logger.add({ level: 'warn', source: 'codeReader', message: `没能打开仓库链接 ${link}:${message}` })
void window.showErrorMessage(`代码阅读:没能打开 ${rel}`) void window.showErrorMessage(`代码阅读:没能打开 ${link}`)
} }
} }
/** 用文档符号按名字找。先把文件读进来(不显示),没有这个名字就没有,不打开到文件顶上。 */ /** 用文档符号按名字找(`Agent`、`Agent.owner_id`);没有这个名字就没有,不打开到文件顶上。 */
private async locateRepoSymbol(uri: Uri, symbol: string): Promise<DocumentSymbol | undefined> { private async locateRepoSymbol(uri: Uri, symbol: string): Promise<LinkTarget | undefined> {
const doc = await workspace.openTextDocument(uri) const found = findRepoSymbol(await this.symbolNodes(uri.toString()), symbol)
return found && { uri: uri.toString(), selection: found.selection }
}
/** 一个文件的文档符号(vscode.executeDocumentSymbolProvider):先把文件读进来(不显示),范围换成那个文件里的下标。 */
private async symbolNodes(uri: string): Promise<SymbolNode[]> {
const doc = await workspace.openTextDocument(Uri.parse(uri))
const symbols = await commands.executeCommand<DocumentSymbol[] | undefined>('vscode.executeDocumentSymbolProvider', doc.uri) const symbols = await commands.executeCommand<DocumentSymbol[] | undefined>('vscode.executeDocumentSymbolProvider', doc.uri)
return Array.isArray(symbols) ? findRepoSymbol(symbols, symbol) : undefined const span = (range: Range): SourceRange => ({ startIndex: doc.offsetAt(range.start), endIndex: doc.offsetAt(range.end) })
const node = (symbol: DocumentSymbol): SymbolNode => ({
name: symbol.name,
range: span(symbol.range),
selection: span(symbol.selectionRange),
children: (symbol.children ?? []).map(node),
})
return Array.isArray(symbols) ? symbols.map(node) : []
}
/**
* 注释里不带路径的引用(`[Name]`、`[Class.member]`)指到哪:本文件定义的优先,再是导入进来的(见 symbolLinks.ts)。
* 文档符号同仓库链接找符号那一套(symbols,一批引用一起问时共用一份),导入的名字问语言扩展它的定义(同跳到定义),定义几处取第一处。
*/
private async resolveSymbolLink(symbol: string, symbols: (uri: string) => Promise<SymbolNode[]> = uri => this.symbolNodes(uri)): Promise<LinkTarget | undefined> {
const { document } = this
try {
return await resolveReference(symbol, this.imports, {
uri: document.uri.toString(),
symbols,
definition: async (offset) => {
const [found] = await languageDefinitions(document.uri, document.positionAt(offset))
if (!found)
return undefined
const doc = await workspace.openTextDocument(found.uri)
return { uri: found.uri.toString(), selection: { startIndex: doc.offsetAt(found.range.start), endIndex: doc.offsetAt(found.range.end) } }
},
})
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `没能找注释里的引用 [${symbol}]:${err instanceof Error ? err.message : String(err)}` })
return undefined
}
}
/** 跳到链接指的符号:在这份文件里就在本预览里滚过去、闪一下;别的文件在预览所在的编辑器组打开、选中符号的名字。 */
private async showLinkTarget({ uri, selection }: LinkTarget): Promise<void> {
if (uri === this.document.uri.toString()) {
this.reveal({ line: this.document.positionAt(selection.startIndex).line, definition: selection.startIndex })
return
}
const doc = await workspace.openTextDocument(Uri.parse(uri))
await this.showRepoFile(doc.uri, new Range(doc.positionAt(selection.startIndex), doc.positionAt(selection.endIndex)))
} }
/** 在预览所在的编辑器组打开文件,并把预览标签固定住,免得被它替掉。图片用默认打开方式,才看得到图。 */ /** 在预览所在的编辑器组打开文件,并把预览标签固定住,免得被它替掉。图片用默认打开方式,才看得到图。 */
@@ -766,14 +839,14 @@ class Preview {
await window.showTextDocument(uri, { viewColumn: this.panel.viewColumn, ...(selection && { selection }) }) await window.showTextDocument(uri, { viewColumn: this.panel.viewColumn, ...(selection && { selection }) })
} }
private markRepoLinkBroken(path: string, symbol?: string): void { private markRepoLinkBroken(path: string | undefined, symbol?: string): void {
if (this.ready) if (this.ready)
this.post({ type: 'repoLinkBroken', path, ...(symbol !== undefined && { symbol }) }) this.post({ type: 'repoLinkBroken', ...(path !== undefined && { path }), ...(symbol !== undefined && { symbol }) })
} }
private markRepoLinkOpened(path: string, symbol?: string): void { private markRepoLinkOpened(path: string | undefined, symbol?: string): void {
if (this.ready) if (this.ready)
this.post({ type: 'repoLinkOpened', path, ...(symbol !== undefined && { symbol }) }) this.post({ type: 'repoLinkOpened', ...(path !== undefined && { path }), ...(symbol !== undefined && { symbol }) })
} }
/** 用写注释的技能重写这一段,只把正文套回原来的定界符,再走块编辑那条写回。 */ /** 用写注释的技能重写这一段,只把正文套回原来的定界符,再走块编辑那条写回。 */
+100
View File
@@ -0,0 +1,100 @@
import type { ReferenceSources, SymbolNode } from './symbolLinks'
import { describe, expect, it } from 'vitest'
import { createPythonAdapter } from './languages/python'
import { createTypeScriptAdapter } from './languages/typescript'
import { importsIn } from './libraryRules'
import { importedNames, resolveReference, splitReference } from './symbolLinks'
import { readFixture, WASM_DIR, withBlocks } from './testSupport'
const py = createPythonAdapter(WASM_DIR)
const ts = createTypeScriptAdapter(WASM_DIR)
function symbol(name: string, start: number, end: number, children: SymbolNode[] = []): SymbolNode {
return { name, range: { startIndex: start, endIndex: end }, selection: { startIndex: start, endIndex: start + name.length }, children }
}
/** 假的语言扩展:几个文件的文档符号,本文件几处名字的定义;记下问过哪些。 */
function sources(files: Record<string, SymbolNode[]>, definitions: Record<number, { uri: string, at: number, length: number }> = {}): ReferenceSources & { asked: number[] } {
const asked: number[] = []
return {
uri: 'file:///here.py',
asked,
symbols: async uri => files[uri] ?? [],
definition: async (offset) => {
asked.push(offset)
const found = definitions[offset]
return found && { uri: found.uri, selection: { startIndex: found.at, endIndex: found.at + found.length } }
},
}
}
describe('不带路径的引用:拆名字', () => {
it('拆开 Class.member:名字和成员,单个名字没有成员', () => {
expect(splitReference('Agent.owner_id')).toEqual({ head: 'Agent', members: ['owner_id'] })
expect(splitReference('USER')).toEqual({ head: 'USER', members: [] })
})
})
describe('不带路径的引用:指到哪', () => {
const imports = [{ name: 'Agent', offset: 20, range: { startIndex: 20, endIndex: 25 } }]
it('本文件定义的优先于导入的;不问语言扩展', async () => {
const found = sources({ 'file:///here.py': [symbol('Agent', 100, 200)] }, { 20: { uri: 'file:///models.py', at: 5, length: 5 } })
expect(await resolveReference('Agent', imports, found)).toEqual({ uri: 'file:///here.py', selection: { startIndex: 100, endIndex: 105 } })
expect(found.asked).toEqual([])
})
it('本文件的 Class.member 在类底下找成员;类有、成员没有就是找不到,不再去导入里找', async () => {
const here = sources({ 'file:///here.py': [symbol('Agent', 100, 200, [symbol('owner_id', 120, 140)])] }, { 20: { uri: 'file:///models.py', at: 5, length: 5 } })
expect(await resolveReference('Agent.owner_id', imports, here)).toEqual({ uri: 'file:///here.py', selection: { startIndex: 120, endIndex: 128 } })
expect(await resolveReference('Agent.missing', imports, here)).toBeUndefined()
expect(here.asked).toEqual([])
})
it('导入语句里的名字不算本文件定义的:问那个名字的定义,Class.member 在定义所在文件的那个类底下找', async () => {
const files = {
'file:///here.py': [symbol('Agent', 20, 25)],
'file:///models.py': [symbol('Other', 0, 4, [symbol('owner_id', 1, 3)]), symbol('Agent', 10, 90, [symbol('owner_id', 30, 50)])],
}
const found = sources(files, { 20: { uri: 'file:///models.py', at: 10, length: 5 } })
expect(await resolveReference('Agent', imports, found)).toEqual({ uri: 'file:///models.py', selection: { startIndex: 10, endIndex: 15 } })
expect(await resolveReference('Agent.owner_id', imports, found)).toEqual({ uri: 'file:///models.py', selection: { startIndex: 30, endIndex: 38 } })
expect(found.asked).toEqual([20, 20])
})
it('导入的是整个模块(定义落在文件开头)时成员从顶层找', async () => {
const files = { 'file:///errors.py': [symbol('NotFound', 40, 80)] }
const found = sources(files, { 7: { uri: 'file:///errors.py', at: 0, length: 0 } })
expect(await resolveReference('errors.NotFound', [{ name: 'errors', offset: 7, range: { startIndex: 7, endIndex: 13 } }], found))
.toEqual({ uri: 'file:///errors.py', selection: { startIndex: 40, endIndex: 48 } })
})
it('本文件没定义、也没导入,或语言扩展答不上:找不到', async () => {
expect(await resolveReference('Nowhere', imports, sources({}))).toBeUndefined()
expect(await resolveReference('Agent', imports, sources({}))).toBeUndefined()
})
})
describe('导入引进来的名字(取自预览已经读出的导入 facts)', () => {
it('python:from 导入问原名的位置、别名按别名认,整个模块按第一节认、起了别名问最后一节,import * 没有', async () => {
const source = 'from a.b import C, D as E\nimport os.path\nimport x.y as z\nfrom m import *\n'
const names = await withBlocks(py, 'python', source, blocks => importedNames(importsIn(blocks)))
expect(names.map(item => [item.name, source.slice(item.offset, item.offset + 1), source.slice(item.range.startIndex, item.range.endIndex)])).toEqual([
['C', 'C', 'C'],
['E', 'D', 'D as E'],
['os', 'o', 'os.path'],
['z', 'y', 'x.y as z'],
])
})
it('typeScript:具名、默认、命名空间导入', async () => {
const source = 'import d, { b as c } from \'y\'\nimport * as ns from \'z\'\n'
const names = await withBlocks(ts, 'typescript', source, blocks => importedNames(importsIn(blocks)))
expect(names.map(item => [item.name, source.slice(item.offset, item.offset + 1)])).toEqual([['d', 'd'], ['c', 'b'], ['ns', 'n']])
})
it('gate.py 导入的 require_desktop_client、AppException 都在,本文件定义的 LocalRuntimeDisabled 不在', async () => {
const names = await withBlocks(py, 'python', readFixture('localRuntimeGate.py'), blocks => importedNames(importsIn(blocks)))
expect(names.map(item => item.name)).toEqual(['annotations', 'Request', 'get_local_runtime_enabled', 'AppException', 'require_desktop_client'])
})
})
+118
View File
@@ -0,0 +1,118 @@
import type { ImportFacts, ImportItem, Node, SourceRange } from './contract'
/**
* 注释里不带路径的引用(KDoc 的 `[Name]`、`[Class.member]`)指到哪:先找本文件定义的,再找本文件导入进来的,都没有就找不到。
* 符号、定义问语言扩展,交给调用方(预览)去问;这里只管按什么顺序找、怎么拆名字(不碰 VS Code)。
*/
/** 本文件某条导入引进来的一个名字:名字、问定义时问的位置(导入语句里那个名字的下标)、这一项在源码里的范围。 */
export interface ImportedName {
name: string
offset: number
range: SourceRange
}
/** 一个文件里的一个文档符号:名字、整个定义的范围(看定义落在哪个符号里)、名字的范围、子符号。下标都是那个文件里的。 */
export interface SymbolNode {
name: string
range: SourceRange
selection: SourceRange
children: readonly SymbolNode[]
}
/** 引用指到的地方:文件(uri 字符串)和定义处那个名字的范围。 */
export interface LinkTarget {
uri: string
selection: SourceRange
}
/** 解析一个引用要问的:本文件是哪个、它的文档符号,本文件某处名字的定义(几处取第一处),别的文件的文档符号。 */
export interface ReferenceSources {
uri: string
symbols: (uri: string) => Promise<readonly SymbolNode[]>
definition: (offset: number) => Promise<LinkTarget | undefined>
}
function spanOf(nodes: readonly (Node | undefined)[]): SourceRange {
const present = nodes.filter((node): node is Node => node !== undefined)
return { startIndex: Math.min(...present.map(node => node.startIndex)), endIndex: Math.max(...present.map(node => node.endIndex)) }
}
/** 一项导入引进来的名字:起了别名的是别名;问定义时问原来那个名字(模块问最后一节,那才是这个模块)。整模块导入(`from m import *`)没有。 */
function importedName(item: ImportItem): ImportedName | undefined {
switch (item.kind) {
case 'named':
return { name: (item.alias ?? item.name).text, offset: item.name.startIndex, range: spanOf([item.name, item.alias]) }
case 'default':
return { name: item.alias.text, offset: item.alias.startIndex, range: spanOf([item.alias]) }
case 'namespace':
return item.alias && { name: item.alias.text, offset: item.alias.startIndex, range: spanOf([item.alias]) }
case 'module': {
const last = item.module.namedChildren.at(-1) ?? item.module
const name = item.alias?.text ?? item.module.text.split('.')[0]
return { name, offset: item.alias ? last.startIndex : item.module.startIndex, range: spanOf([item.module, item.alias]) }
}
}
}
/** 本文件各条导入(预览已经读出的 ImportFacts)引进来的名字,照源码顺序。 */
export function importedNames(imports: readonly ImportFacts[]): ImportedName[] {
return imports.flatMap(facts => facts.items.flatMap(item => importedName(item) ?? []))
}
/** 引用拆成名字和成员:`Agent.owner_id` 是 Agent 和 [owner_id]。 */
export function splitReference(reference: string): { head: string, members: string[] } {
const [head = '', ...members] = reference.split('.')
return { head, members }
}
function covers(range: SourceRange, offset: number): boolean {
return range.startIndex <= offset && offset < range.endIndex
}
/** 符号底下按名字一层层找成员:先找直接的子符号,没有再往深处找。 */
function memberOf(symbols: readonly SymbolNode[], members: readonly string[]): SymbolNode | undefined {
const [first, ...rest] = members
const found = symbols.find(symbol => symbol.name === first) ?? deepFind(symbols, first)
return found && (rest.length > 0 ? memberOf(found.children, rest) : found)
}
function deepFind(symbols: readonly SymbolNode[], name: string | undefined): SymbolNode | undefined {
for (const symbol of symbols) {
const found = symbol.children.find(child => child.name === name) ?? deepFind(symbol.children, name)
if (found)
return found
}
return undefined
}
/** 盖住 offset 的最里层的符号;定义落在文件开头(导入的是整个模块)时没有。 */
function innermost(symbols: readonly SymbolNode[], offset: number): SymbolNode | undefined {
const outer = symbols.find(symbol => covers(symbol.range, offset))
return outer && (innermost(outer.children, offset) ?? outer)
}
/**
* 引用指到哪,按 KDoc 的规矩:
* 1. 本文件定义的:顶层的符号(类、函数、常量、变量)名字对上,`Class.member` 再在它底下找成员;导入语句里的名字不算本文件定义的。
* 2. 本文件导入进来的:名字(`Class.member` 的第一段)是某条导入引进来的,问导入语句里那个名字的定义;`Class.member` 再在定义所在文件的
* 符号里找:定义落在哪个符号里就在它底下找,落在文件开头(导入的是模块)就从顶层找。
* 3. 都没有:找不到。本文件定义了这个名字、却没有这个成员时也是找不到,不再去导入里找。
*/
export async function resolveReference(reference: string, imports: readonly ImportedName[], sources: ReferenceSources): Promise<LinkTarget | undefined> {
const { head, members } = splitReference(reference)
const local = await sources.symbols(sources.uri)
const own = local.find(symbol => symbol.name === head && !imports.some(item => covers(item.range, symbol.selection.startIndex)))
if (own) {
const found = members.length > 0 ? memberOf(own.children, members) : own
return found && { uri: sources.uri, selection: found.selection }
}
const imported = imports.find(item => item.name === head)
const target = imported && await sources.definition(imported.offset)
if (!target || members.length === 0)
return target
const symbols = await sources.symbols(target.uri)
const holder = innermost(symbols, target.selection.startIndex)
const found = memberOf(holder ? holder.children : symbols, members)
return found && { uri: target.uri, selection: found.selection }
}
@@ -87,7 +87,7 @@ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView
</div> </div>
<Thumbs urls={entry.images ?? []} onOpen={onOpenImage} /> <Thumbs urls={entry.images ?? []} onOpen={onOpenImage} />
{expanded {expanded
? <div className="cr-markdown cr-qa-answer" dangerouslySetInnerHTML={{ __html: renderMarkdown(entry.answer) }} /> ? <div className="cr-markdown cr-qa-answer" dangerouslySetInnerHTML={{ __html: renderMarkdown(entry.answer, 'answer') }} />
: ( : (
<button type="button" className="cr-qa-preview" onClick={toggle}> <button type="button" className="cr-qa-preview" onClick={toggle}>
{answerPreview(entry.answer)} {answerPreview(entry.answer)}
@@ -112,7 +112,7 @@ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url
<div <div
className="cr-markdown cr-qa-answer" className="cr-markdown cr-qa-answer"
data-streaming={running ? '' : undefined} data-streaming={running ? '' : undefined}
dangerouslySetInnerHTML={{ __html: running ? renderMarkdownOnce(ask.text) : renderMarkdown(ask.text) }} dangerouslySetInnerHTML={{ __html: running ? renderMarkdownOnce(ask.text) : renderMarkdown(ask.text, 'answer') }}
/> />
)} )}
{running && ( {running && (
@@ -7,7 +7,7 @@ import { SegmentText } from './SegmentText'
/** 预览里的一段文字:着色的词按源码排版;Markdown 说明渲染成段落、列表、代码块。 */ /** 预览里的一段文字:着色的词按源码排版;Markdown 说明渲染成段落、列表、代码块。 */
export function TextPreview({ text, className }: { text: PreviewText, className?: string }): ReactElement { export function TextPreview({ text, className }: { text: PreviewText, className?: string }): ReactElement {
if ('markdown' in text) if ('markdown' in text)
return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={{ __html: renderMarkdown(text.markdown) }} /> return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={{ __html: renderMarkdown(text.markdown, 'comment') }} />
return ( return (
<p className={cn('cr-code', className)}> <p className={cn('cr-code', className)}>
<SegmentText segments={text} /> <SegmentText segments={text} />
@@ -81,6 +81,29 @@ describe('renderMarkdown', () => {
}) })
}) })
describe('不带路径的引用', () => {
it('注释里的 [Name]、[A.b] 是链接,地址 symbol:名字', () => {
expect(renderMarkdown('见 [LocalRuntimeDisabled]。')).toBe('<p>见 <a href="symbol:LocalRuntimeDisabled">LocalRuntimeDisabled</a>。</p>\n')
expect(renderMarkdown('[Agent.owner_id]')).toBe('<p><a href="symbol:Agent.owner_id">Agent.owner_id</a></p>\n')
expect(renderMarkdown('转写谁说的话: [USER] / [ASSISTANT]')).toBe('<p>转写谁说的话: <a href="symbol:USER">USER</a> / <a href="symbol:ASSISTANT">ASSISTANT</a></p>\n')
expect(renderMarkdown('见[Name]')).toContain('<a href="symbol:Name">')
})
it('反引号里的、下标、中文、后面跟 (…) 的完整链接都不算', () => {
expect(renderMarkdown('`[x]`')).toBe('<p><code>[x]</code></p>\n')
expect(renderMarkdown('items[i] 和 f()[k]、a[0][1]')).toBe('<p>items[i] 和 f()[k]、a[0][1]</p>\n')
expect(renderMarkdown('以 [会话进展] 开头')).toBe('<p>以 [会话进展] 开头</p>\n')
expect(renderMarkdown('[名字](backend/a.py#Agent)')).toBe('<p><a href="backend/a.py#Agent">名字</a></p>\n')
expect(renderMarkdown('[Agent](backend/a.py#Agent)')).toBe('<p><a href="backend/a.py#Agent">Agent</a></p>\n')
expect(renderMarkdown('[Agent](javascript:x)')).not.toContain('<a')
})
it('问 AI 的回答不认;带增删的注释照样认', () => {
expect(renderMarkdown('见 [Agent]', 'answer')).toBe('<p>见 [Agent]</p>\n')
expect(renderMarkdownDiff([{ text: '见 ' }, { text: '[Agent]', change: 'add' }])).toContain('href="symbol:Agent"')
})
})
describe('renderMarkdownDiff', () => { describe('renderMarkdownDiff', () => {
it('照常排版,新加的字包进 <ins>、删掉的包进 <del>', () => { it('照常排版,新加的字包进 <ins>、删掉的包进 <del>', () => {
expect(renderMarkdownDiff([{ text: '放锁' }, { text: '前', change: 'del' }, { text: '都', change: 'add' }, { text: '必须比对 `token`' }])) expect(renderMarkdownDiff([{ text: '放锁' }, { text: '前', change: 'del' }, { text: '都', change: 'add' }, { text: '必须比对 `token`' }]))
+59 -11
View File
@@ -1,16 +1,56 @@
import type { StateInline } from 'markdown-it'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import { isRepoImageFile, repoImagePlaceholder } from './repoImage' import { isRepoImageFile, repoImagePlaceholder } from './repoImage'
import { parseRepoLink } from './repoLink' import { parseRepoLink, symbolHref } from './repoLink'
/** /**
* 注释、函数和类说明、问 AI 的回答的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。 * 注释、函数和类说明、问 AI 的回答的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。
* 源码里的 HTML 当文字显示。链接认 http / https / mailto,以及从仓库根起的路径(可带 #类名 或 #类名.成员); * 源码里的 HTML 当文字显示。链接认 http / https / mailto,以及从仓库根起的路径(可带 #类名 或 #类名.成员);
* 别的写法照原文显示。webview 会拦下 http 链接的点击交给 VS Code 在外部打开;仓库路径链接由页面另发给扩展端,不走外部打开。 * 别的写法照原文显示。webview 会拦下 http 链接的点击交给 VS Code 在外部打开;仓库路径链接由页面另发给扩展端,不走外部打开。
* 图片只认仓库里的图片文件:先显示路径,问到文件在了再按原图比例显示。http / https 图片不显示。 * 图片只认仓库里的图片文件:先显示路径,问到文件在了再按原图比例显示。http / https 图片不显示。
* 注释(不是问 AI 的回答)里 KDoc 写法的不带路径的引用 `[Name]`、`[Class.member]` 也是链接,见 symbolReference。
*/ */
const md = new MarkdownIt({ html: false, linkify: true }) const md = new MarkdownIt({ html: false, linkify: true })
md.validateLink = url => /^(?:https?|mailto):/i.test(url) || parseRepoLink(url) !== undefined md.validateLink = url => /^(?:https?|mailto):/i.test(url) || parseRepoLink(url) !== undefined
/**
* 渲染时带的环境:symbolLinks 为真时认不带路径的引用(注释的 Markdown 才认,问 AI 的回答不认)。
* markdown-it 自己用 env.references 记参考式链接的定义,不能占这个名字。
*/
type RenderEnv = Record<string, unknown> & { symbolLinks?: boolean }
/** 方括号里的标识符(字母、数字、下划线),点分开的几段:`Name`、`Class.member`。 */
const REFERENCE = /^\[([A-Z_$][\w$]*(?:\.[A-Z_$][\w$]*)*)\]/i
/** 紧跟在这些后面的方括号是下标(`items[i]`、`a[0][1]`、`f()[k]`),不是引用。 */
const SUBSCRIPTED = /[\w$\])]/
/**
* 不带路径的引用:`[Name]`、`[Class.member]` 写成链接,地址是 `symbol:Name`(作者写不出这种地址,validateLink 不放行),
* 点了、坏没坏和仓库路径链接走同一套(RepoLinks)。方括号里得是标识符(中文这类照原样是文字),后面紧跟 `(` 是完整链接、
* 紧跟 `[` 是参考式链接,都不算;紧跟在名字、`]`、`)` 后面的是下标,不算。反引号里的是代码,到不了这里。
*/
function symbolReference(state: StateInline, silent: boolean): boolean {
if (!(state.env as RenderEnv | undefined)?.symbolLinks || state.src.charCodeAt(state.pos) !== 0x5B)
return false
if (state.pos > 0 && SUBSCRIPTED.test(state.src[state.pos - 1]))
return false
const match = REFERENCE.exec(state.src.slice(state.pos))
if (!match)
return false
const next = state.src[state.pos + match[0].length]
if (next === '(' || next === '[')
return false
if (!silent) {
const open = state.push('link_open', 'a', 1)
open.attrSet('href', symbolHref(match[1]))
state.push('text', '', 0).content = match[1]
state.push('link_close', 'a', -1)
}
state.pos += match[0].length
return true
}
md.inline.ruler.after('link', 'symbol_reference', symbolReference)
/** 仓库图片先占位。http / https 和不是图片的仓库路径仍是「!」加链接,不画出图片。 */ /** 仓库图片先占位。http / https 和不是图片的仓库路径仍是「!」加链接,不画出图片。 */
md.renderer.rules.image = (tokens, idx) => { md.renderer.rules.image = (tokens, idx) => {
const token = tokens[idx] const token = tokens[idx]
@@ -42,16 +82,24 @@ md.core.ruler.push('link_title_tooltip', (state) => {
const CACHE_SIZE = 500 const CACHE_SIZE = 500
const cache = new Map<string, string>() const cache = new Map<string, string>()
/** 还在变的文字(逐字长出来的回答)→ HTML:每次都现渲染,不进缓存,免得半截的文字把缓存挤满。 */ /** 渲染的是什么:注释(说明、注释块,认不带路径的引用),还是问 AI 的回答。 */
export function renderMarkdownOnce(text: string): string { export type MarkdownKind = 'comment' | 'answer'
return md.render(text)
function envOf(kind: MarkdownKind): RenderEnv {
return { symbolLinks: kind === 'comment' }
} }
/** Markdown 原文 → HTML;同一段文字只渲染一次。 */ /** 还在变的文字(逐字长出来的回答)→ HTML:每次都现渲染,不进缓存,免得半截的文字把缓存挤满。 */
export function renderMarkdown(text: string): string { export function renderMarkdownOnce(text: string): string {
const html = cache.get(text) ?? md.render(text) return md.render(text, envOf('answer'))
cache.delete(text) }
cache.set(text, html)
/** Markdown 原文 → HTML;同一段文字(同一种)只渲染一次。 */
export function renderMarkdown(text: string, kind: MarkdownKind = 'comment'): string {
const key = `${kind}\u0000${text}`
const html = cache.get(key) ?? md.render(text, envOf(kind))
cache.delete(key)
cache.set(key, html)
if (cache.size > CACHE_SIZE) if (cache.size > CACHE_SIZE)
cache.delete(cache.keys().next().value!) cache.delete(cache.keys().next().value!)
return html return html
@@ -67,7 +115,7 @@ const MARKS = {
const BLOCK_LEAD = /^\s*(?:(?:[\-*+>]|\d+[.)]|#{1,6})\s+)*/ const BLOCK_LEAD = /^\s*(?:(?:[\-*+>]|\d+[.)]|#{1,6})\s+)*/
/** /**
* 带增删的 Markdown 说明 → HTML:照常排版,新加的字包进 <ins>、删掉的包进 <del>(样式见 .cr-diff-word)。 * 带增删的 Markdown 说明 → HTML:照常排版(同注释,认不带路径的引用),新加的字包进 <ins>、删掉的包进 <del>(样式见 .cr-diff-word)。
* 一截跨了几行时逐行包,记号不跨段落、列表项。 * 一截跨了几行时逐行包,记号不跨段落、列表项。
*/ */
export function renderMarkdownDiff(parts: readonly { text: string, change?: 'add' | 'del' }[]): string { export function renderMarkdownDiff(parts: readonly { text: string, change?: 'add' | 'del' }[]): string {
@@ -85,7 +133,7 @@ export function renderMarkdownDiff(parts: readonly { text: string, change?: 'add
return rest ? `${lead}${open}${rest}${close}` : line return rest ? `${lead}${open}${rest}${close}` : line
}).join('\n') }).join('\n')
} }
let html = md.render(text) let html = md.render(text, envOf('comment'))
for (const [open, close, openTag, closeTag] of Object.values(MARKS)) for (const [open, close, openTag, closeTag] of Object.values(MARKS))
html = html.replaceAll(open, openTag).replaceAll(close, closeTag) html = html.replaceAll(open, openTag).replaceAll(close, closeTag)
return html return html
@@ -1,6 +1,6 @@
import type { WebviewToExtension } from './types' import type { WebviewToExtension } from './types'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { parseRepoLink, RepoLinks } from './repoLink' import { parseCommentLink, parseRepoLink, RepoLinks, symbolHref } from './repoLink'
describe('parseRepoLink', () => { describe('parseRepoLink', () => {
it('合法路径', () => { it('合法路径', () => {
@@ -47,12 +47,12 @@ describe('parseRepoLink', () => {
}) })
}) })
describe('repoLinks', () => { function setup(): { links: RepoLinks, sent: WebviewToExtension[] } {
function setup(): { links: RepoLinks, sent: WebviewToExtension[] } { const sent: WebviewToExtension[] = []
const sent: WebviewToExtension[] = [] return { links: new RepoLinks(msg => sent.push(msg)), sent }
return { links: new RepoLinks(msg => sent.push(msg)), sent } }
}
describe('repoLinks', () => {
it('点仓库链接发给扩展端,http 不管', () => { it('点仓库链接发给扩展端,http 不管', () => {
const { links, sent } = setup() const { links, sent } = setup()
expect(links.click('backend/src/luffy_agent/agent/models.py#Agent.owner_id')).toBe(true) expect(links.click('backend/src/luffy_agent/agent/models.py#Agent.owner_id')).toBe(true)
@@ -69,11 +69,11 @@ describe('repoLinks', () => {
const { links, sent } = setup() const { links, sent } = setup()
links.scan(['vendor/agents', 'https://example.com', 'backend/a.py#Agent']) links.scan(['vendor/agents', 'https://example.com', 'backend/a.py#Agent'])
links.scan(['backend/a.py#Agent', 'vendor/agents']) links.scan(['backend/a.py#Agent', 'vendor/agents'])
expect(sent).toEqual([{ type: 'checkRepoLinks', id: 1, paths: ['backend/a.py', 'vendor/agents'] }]) expect(sent).toEqual([{ type: 'checkRepoLinks', id: 1, paths: ['backend/a.py', 'vendor/agents'], symbols: [] }])
expect(links.checked(0, ['vendor/agents'])).toBe(false) expect(links.checked(0, ['vendor/agents'], [])).toBe(false)
expect(links.isBroken('vendor/agents')).toBe(false) expect(links.isBroken('vendor/agents')).toBe(false)
expect(links.checked(1, ['vendor/agents'])).toBe(true) expect(links.checked(1, ['vendor/agents'], [])).toBe(true)
expect(links.isBroken('vendor/agents')).toBe(true) expect(links.isBroken('vendor/agents')).toBe(true)
expect(links.isBroken('backend/a.py#Agent')).toBe(false) expect(links.isBroken('backend/a.py#Agent')).toBe(false)
}) })
@@ -87,3 +87,43 @@ describe('repoLinks', () => {
expect(links.isBroken('backend/a.py#Agent')).toBe(false) expect(links.isBroken('backend/a.py#Agent')).toBe(false)
}) })
}) })
describe('不带路径的引用', () => {
it('地址是 symbol:名字,只认标识符和点分的几段;别的按仓库路径链接认', () => {
expect(symbolHref('Agent.owner_id')).toBe('symbol:Agent.owner_id')
expect(parseCommentLink('symbol:LocalRuntimeDisabled')).toEqual({ symbol: 'LocalRuntimeDisabled' })
expect(parseCommentLink('symbol:Agent.owner_id')).toEqual({ symbol: 'Agent.owner_id' })
expect(parseCommentLink('symbol:会话进展')).toBeUndefined()
expect(parseCommentLink('symbol:a b')).toBeUndefined()
expect(parseCommentLink('backend/a.py#Agent')).toEqual({ path: 'backend/a.py', symbol: 'Agent' })
})
it('点了发 openRepoLink(没有 path);和仓库路径一起问,找不到的标坏,打开成功就不再标', () => {
const { links, sent } = setup()
expect(links.click('symbol:LocalRuntimeDisabled')).toBe(true)
expect(sent).toEqual([{ type: 'openRepoLink', symbol: 'LocalRuntimeDisabled' }])
links.scan(['symbol:USER', 'backend/a.py', 'symbol:Missing', 'symbol:USER'])
expect(sent.at(-1)).toEqual({ type: 'checkRepoLinks', id: 1, paths: ['backend/a.py'], symbols: ['Missing', 'USER'] })
expect(links.checked(1, [], ['Missing'])).toBe(true)
expect(links.isBroken('symbol:Missing')).toBe(true)
expect(links.isBroken('symbol:USER')).toBe(false)
links.opened(undefined, 'Missing')
expect(links.isBroken('symbol:Missing')).toBe(false)
links.broken(undefined, 'USER')
expect(links.isBroken('symbol:USER')).toBe(true)
})
it('同样的链接不再问;文档换了版本(stale)再问一次', () => {
const { links, sent } = setup()
links.scan(['symbol:USER'])
links.scan(['symbol:USER'])
expect(sent).toHaveLength(1)
links.stale()
links.scan(['symbol:USER'])
expect(sent).toEqual([
{ type: 'checkRepoLinks', id: 1, paths: [], symbols: ['USER'] },
{ type: 'checkRepoLinks', id: 2, paths: [], symbols: ['USER'] },
])
})
})
+71 -21
View File
@@ -8,8 +8,36 @@ export interface RepoLink {
symbol?: string symbol?: string
} }
/**
* 注释里不带路径的引用(`[Name]`、`[Class.member]`):没有路径,扩展端按当前文件找这个符号(本文件定义的,再是导入的)。
* 页面上它是地址为 `symbol:Name` 的链接(见 symbolHref)。
*/
export interface SymbolLink {
path?: undefined
symbol: string
}
/** 注释里能点的链接:仓库路径链接,或不带路径的引用。 */
export type CommentLink = RepoLink | SymbolLink
const SCHEME = /^[a-z][a-z0-9+.-]*:/i const SCHEME = /^[a-z][a-z0-9+.-]*:/i
const IDENT = /^[\p{L}_$][\p{L}\p{N}_$]*$/u const IDENT = /^[\p{L}_$][\p{L}\p{N}_$]*$/u
/** 不带路径的引用在页面上的地址前缀。作者在注释里写不出这种地址(markdown.ts 的 validateLink 不放行),只有引用规则会生成。 */
const SYMBOL_SCHEME = 'symbol:'
const REFERENCE = /^[A-Z_$][\w$]*(?:\.[A-Z_$][\w$]*)*$/i
/** 不带路径的引用写成的链接地址:`symbol:Class.member`。 */
export function symbolHref(symbol: string): string {
return `${SYMBOL_SCHEME}${symbol}`
}
/** 页面上一个链接地址是哪种能点的链接:`symbol:` 开头的是不带路径的引用,别的按仓库路径链接认(见 parseRepoLink)。都不是就没有。 */
export function parseCommentLink(href: string): CommentLink | undefined {
if (!href.startsWith(SYMBOL_SCHEME))
return parseRepoLink(href)
const symbol = href.slice(SYMBOL_SCHEME.length)
return REFERENCE.test(symbol) ? { symbol } : undefined
}
/** /**
* 从链接地址拆出仓库路径和可选符号。 * 从链接地址拆出仓库路径和可选符号。
@@ -62,71 +90,93 @@ function symbolKey(path: string, symbol: string): string {
} }
/** /**
* 页面上的仓库链接:点了发给扩展端打开;预览出来后把路径发去问哪些文件没有; * 页面上的仓库链接和不带路径的引用:点了发给扩展端打开;预览出来后把路径、引用发去问哪些文件没有、哪些引用找不到;
* 扩展端说没有的,标成坏的。符号在不在要点了才知道。 * 扩展端说没有的,标成坏的。仓库链接的符号在不在要点了才知道。
*/ */
export class RepoLinks { export class RepoLinks {
private request = 0 private request = 0
/** 最近一次问过的路径,没变就不再问 */ /** 最近一次问过的路径和引用,没变就不再问 */
private sent = '' private sent = ''
private missingPaths = new Set<string>() private missingPaths = new Set<string>()
private missingSymbols = new Set<string>() private missingSymbols = new Set<string>()
/** 找不到的不带路径的引用 */
private unresolved = new Set<string>()
constructor(private readonly post: (msg: WebviewToExtension) => void) {} constructor(private readonly post: (msg: WebviewToExtension) => void) {}
/** 点在仓库链接上:发给扩展端打开,返回 true。不是仓库链接就不管。 */ /** 点在仓库链接或不带路径的引用上:发给扩展端打开,返回 true。别的链接不管。 */
click(href: string | null): boolean { click(href: string | null): boolean {
const link = href === null ? undefined : parseRepoLink(href) const link = href === null ? undefined : parseCommentLink(href)
if (!link) if (!link)
return false return false
this.post({ type: 'openRepoLink', path: link.path, ...(link.symbol !== undefined && { symbol: link.symbol }) }) this.post({ type: 'openRepoLink', ...(link.path !== undefined && { path: link.path }), ...(link.symbol !== undefined && { symbol: link.symbol }) })
return true return true
} }
/** 页面上现有的仓库链接。路径变了才问扩展端哪些没有。 */ /** 页面上现有的链接。路径、引用变了(或文档换了版本,见 stale)才问扩展端哪些路径没有、哪些引用找不到。 */
scan(hrefs: readonly string[]): void { scan(hrefs: readonly string[]): void {
const paths = [...new Set(hrefs.flatMap((href) => { const links = hrefs.flatMap((href) => {
const link = parseRepoLink(href) const link = parseCommentLink(href)
return link ? [link.path] : [] return link ? [link] : []
}))].sort() })
const key = paths.join('\n') const paths = [...new Set(links.flatMap(link => link.path === undefined ? [] : [link.path]))].sort()
const symbols = [...new Set(links.flatMap(link => link.path === undefined ? [link.symbol] : []))].sort()
const key = `${paths.join('\n')}\u0000${symbols.join('\n')}`
if (key === this.sent) if (key === this.sent)
return return
this.sent = key this.sent = key
if (paths.length === 0) if (paths.length === 0 && symbols.length === 0)
return return
this.request += 1 this.request += 1
this.post({ type: 'checkRepoLinks', id: this.request, paths }) this.post({ type: 'checkRepoLinks', id: this.request, paths, symbols })
}
/** 文档换了版本:引用指到哪跟着源码变(加了定义、改了导入),下次 scan 照样再问。 */
stale(): void {
this.sent = ''
} }
/** id 那次询问的回答。不是最近一次的丢掉,返回这次有没有用上。 */ /** id 那次询问的回答。不是最近一次的丢掉,返回这次有没有用上。 */
checked(id: number, missing: readonly string[]): boolean { checked(id: number, missing: readonly string[], unresolved: readonly string[]): boolean {
if (id !== this.request) if (id !== this.request)
return false return false
this.missingPaths = new Set(missing) this.missingPaths = new Set(missing)
this.unresolved = new Set(unresolved)
return true return true
} }
/** 扩展端打开时发现文件或符号没有。 */ /** 扩展端打开时发现文件或符号没有;没有路径的是不带路径的引用找不到。 */
broken(path: string, symbol?: string): void { broken(path: string | undefined, symbol?: string): void {
if (path === undefined) {
if (symbol !== undefined)
this.unresolved.add(symbol)
return
}
if (symbol !== undefined) if (symbol !== undefined)
this.missingSymbols.add(symbolKey(path, symbol)) this.missingSymbols.add(symbolKey(path, symbol))
else else
this.missingPaths.add(path) this.missingPaths.add(path)
} }
/** 打开成功:这个路径在,这个符号也在。 */ /** 打开成功:这个路径在,这个符号也在;没有路径的是不带路径的引用找到了。 */
opened(path: string, symbol?: string): void { opened(path: string | undefined, symbol?: string): void {
if (path === undefined) {
if (symbol !== undefined)
this.unresolved.delete(symbol)
return
}
this.missingPaths.delete(path) this.missingPaths.delete(path)
if (symbol !== undefined) if (symbol !== undefined)
this.missingSymbols.delete(symbolKey(path, symbol)) this.missingSymbols.delete(symbolKey(path, symbol))
} }
/** 这个地址现在是不是坏的(文件没有,或这个符号没有)。还没问到的不算。 */ /** 这个地址现在是不是坏的(文件没有,这个符号没有,或引用找不到)。还没问到的不算。 */
isBroken(href: string): boolean { isBroken(href: string): boolean {
const link = parseRepoLink(href) const link = parseCommentLink(href)
if (!link) if (!link)
return false return false
if (link.path === undefined)
return this.unresolved.has(link.symbol)
if (this.missingPaths.has(link.path)) if (this.missingPaths.has(link.path))
return true return true
return link.symbol !== undefined && this.missingSymbols.has(symbolKey(link.path, link.symbol)) return link.symbol !== undefined && this.missingSymbols.has(symbolKey(link.path, link.symbol))
+11 -10
View File
@@ -411,9 +411,9 @@ export interface DocumentDiff {
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
* displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理
* diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有);每来一份文档之后补来,基准变了再来;null 是不比了 * diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有);每来一份文档之后补来,基准变了再来;null 是不比了
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径 * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有 * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在 * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了
* repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在 * repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在
*/ */
export type ExtensionToWebview export type ExtensionToWebview
@@ -431,9 +431,9 @@ export type ExtensionToWebview
| { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string } | { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string }
| { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string } | { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string }
| { type: 'diff', diff: DocumentDiff | null } | { type: 'diff', diff: DocumentDiff | null }
| { type: 'repoLinksChecked', id: number, missing: string[] } | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
| { type: 'repoLinkBroken', path: string, symbol?: string } | { type: 'repoLinkBroken', path?: string, symbol?: string }
| { type: 'repoLinkOpened', path: string, symbol?: string } | { type: 'repoLinkOpened', path?: string, symbol?: string }
| { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] } | { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] }
| { type: 'focusPathAsk' } | { type: 'focusPathAsk' }
@@ -454,8 +454,9 @@ export type ExtensionToWebview
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示 * selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发 * occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录 * openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录;
* checkRepoLinks:预览里这批仓库路径,问哪些文件或目录没有。id 标出是哪一次问 * 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),扩展端按这份文件找 symbol
* checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(symbols)问哪些找不到。id 标出是哪一次问
* checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问 * checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问
*/ */
export type WebviewToExtension export type WebviewToExtension
@@ -475,6 +476,6 @@ export type WebviewToExtension
| { type: 'deleteQa', anchor: string, entryId: string } | { type: 'deleteQa', anchor: string, entryId: string }
| { type: 'selectAskProfile', id: string } | { type: 'selectAskProfile', id: string }
| { type: 'occurrenceNavigation', available: boolean } | { type: 'occurrenceNavigation', available: boolean }
| { type: 'openRepoLink', path: string, symbol?: string } | { type: 'openRepoLink', path?: string, symbol?: string }
| { type: 'checkRepoLinks', id: number, paths: string[] } | { type: 'checkRepoLinks', id: number, paths: string[], symbols: string[] }
| { type: 'checkRepoImages', id: number, paths: string[] } | { type: 'checkRepoImages', id: number, paths: string[] }
@@ -5,7 +5,7 @@ import { onMessage, postMessage } from './vscode'
/** 坏链接悬停上的字,走页面上统一的 data-tooltip,不用原生 title。 */ /** 坏链接悬停上的字,走页面上统一的 data-tooltip,不用原生 title。 */
const MISSING = '找不到' const MISSING = '找不到'
/** 文件或符号没有的仓库链接:红色波浪线,悬停「找不到」。问到之前先是普通链接。 */ /** 文件或符号没有的仓库链接、找不到的不带路径的引用:红色波浪线,悬停「找不到」。问到之前先是普通链接。 */
function paintLink(anchor: HTMLAnchorElement, broken: boolean): void { function paintLink(anchor: HTMLAnchorElement, broken: boolean): void {
const tip = anchor.getAttribute('data-tooltip') const tip = anchor.getAttribute('data-tooltip')
if (broken) { if (broken) {
@@ -36,16 +36,31 @@ function paint(links: RepoLinks): void {
} }
/** /**
* 注释、说明、问 AI 的回答里的仓库路径链接:点了发给扩展端打开(不走 webview 把 http 链接交给外部打开的那条), * 注释、说明、问 AI 的回答里的仓库路径链接,和注释里不带路径的引用:点了发给扩展端打开(不走 webview 把 http 链接交给外部打开的那条),
* 画出来之后把路径发去问哪些文件没有。 * 画出来之后把路径、引用发去问哪些文件没有、哪些引用找不到;文档换了版本再问一次(引用跟着源码变)。
*/ */
export function useRepoLinks(): void { export function useRepoLinks(): void {
const [links] = useState(() => new RepoLinks(postMessage)) const [links] = useState(() => new RepoLinks(postMessage))
useEffect(() => { useEffect(() => {
let timer = 0
const scan = (): void => {
window.clearTimeout(timer)
timer = window.setTimeout(() => {
const anchors = [...document.querySelectorAll<HTMLAnchorElement>('.cr-markdown a')]
for (const anchor of anchors)
paintLink(anchor, links.isBroken(anchor.getAttribute('href') ?? ''))
links.scan(anchors.map(anchor => anchor.getAttribute('href') ?? ''))
}, 50)
}
const off = onMessage((msg) => { const off = onMessage((msg) => {
if (msg.type === 'document') {
links.stale()
scan()
return
}
if (msg.type === 'repoLinksChecked') { if (msg.type === 'repoLinksChecked') {
if (links.checked(msg.id, msg.missing)) if (links.checked(msg.id, msg.missing, msg.unresolved))
paint(links) paint(links)
return return
} }
@@ -67,16 +82,6 @@ export function useRepoLinks(): void {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
} }
let timer = 0
const scan = (): void => {
window.clearTimeout(timer)
timer = window.setTimeout(() => {
const anchors = [...document.querySelectorAll<HTMLAnchorElement>('.cr-markdown a')]
for (const anchor of anchors)
paintLink(anchor, links.isBroken(anchor.getAttribute('href') ?? ''))
links.scan(anchors.map(anchor => anchor.getAttribute('href') ?? ''))
}, 50)
}
const observer = new MutationObserver(scan) const observer = new MutationObserver(scan)
observer.observe(document.body, { childList: true, subtree: true }) observer.observe(document.body, { childList: true, subtree: true })
document.addEventListener('click', onClick, true) document.addEventListener('click', onClick, true)