✨ feat(vscode): 代码阅读鼠标停在名字和注释链接上弹出定义所在那块的卡片,别的文件的也行
This commit is contained in:
1 parent
bc7380c716
commit
a48b016d73
20 files changed
+1145
-89
No files matched your search
@@ -27,6 +27,7 @@
|
||||
| 变量、字段的说明 | Python 模块、类主体里紧跟在一条赋值后面的字符串(属性 docstring,PEP 257 / Sphinx 的写法)是这个变量的说明:常量、变量块进属性表「说明」一行(紧跟名称),字段块排在一行译文下面;不再单独成块,它的源码行算在这一块里,「编辑」时和赋值一起改 |
|
||||
| 注释按 Markdown 显示 | 注释块和函数、类、变量、字段的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段;仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」 |
|
||||
| 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 |
|
||||
| 悬停看定义 | 和普通编辑器里的悬停一样:鼠标停在名字、注释里指到符号的链接上一会儿(VS Code 的 `editor.hover.delay`),弹出定义所在那块的卡片(别的文件的也行,和那个文件的预览画的是同一张),点卡片标题跳过去 |
|
||||
| 高亮名字的各处出现 | 和普通编辑器一样单击名字就高亮它在这个文件里的各处出现,由语言扩展按语义认(同名的别的东西不算),颜色用 VS Code 的单词高亮色,再用 focusBorder 色补一笔(当前那一处描框、别的画细线);看不见的(折叠、筛掉的)点的时候不自动展开,筛选栏上写「名字 · 2/5」;⌘↓ / ⌘↑(可改绑的命令)在各处之间跳,跳到折叠着的地方会展开 |
|
||||
| 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释、按审查工单只看 PR 改过的文件;不做新建 / 重命名 / 删除 |
|
||||
| 审查工单 | 标题栏第一个按钮「选工单」列出看板「审查」列的工单和「进行中」列里已经有 PR 的工单(按列分组),选中后树里只剩这张工单的 PR 改过的文件(目录层级照留、全部展开,按 M / A / D 着色,删掉的列出来但打不开);点开的是本机这张工单的工作副本里 PR 最新的代码,不标差异、不开 diff;本机没有工作副本就拉 PR 分支建一个。弹框列的是看板上现在摆着的工单(不再加载一遍),选中后重读那张工单和它的 PR。见「审查工单」 |
|
||||
@@ -479,6 +480,7 @@
|
||||
- 问答同仓库路径链接(`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 细线。
|
||||
- 「编辑」状态照旧是源码,编辑写回不变;Markdown 里的名字(反引号里的代码)不能点,不参与跳到定义,能点的只有上面的链接和引用。
|
||||
- 定义卡片浮窗里的注释(别的文件的)按 `peek` 渲染(`TextPreview` 读 `MarkdownKindContext`):不认不带路径的引用,别的同注释。
|
||||
|
||||
### 跳到定义
|
||||
|
||||
@@ -501,6 +503,17 @@
|
||||
- VS Code 里没有这种语言的定义提供者(shell):语言扩展答不上时,同一文件里的名字由语言插件认(`LanguageAdapter.symbols`,见「Shell 脚本」),跳到用到的那处之前最近的定义。
|
||||
- 不按修饰键单击名字是高亮它的各处出现(见下一节);⌘ / Ctrl + 点击名字不会触发块的编辑切换、折叠,也不动高亮。
|
||||
|
||||
### 悬停看定义卡片
|
||||
|
||||
- 能弹的:带源码下标的名字(同「跳到定义」能点的那些),和注释里指到符号的链接(带 `#类名` / `#类名.成员` 的仓库路径链接、不带路径的引用 `[Name]`、`[Class.member]`);只指到文件、目录的链接不弹。不用按修饰键。
|
||||
- 什么时候弹(同 VS Code 编辑器里的悬停):鼠标停在上面 `editor.hover.delay` 毫秒后问扩展端要卡片(扩展端按这份文件读 VS Code 的设置,语言自己的设置优先;`ready` 后发给页面,设置改了再发,页面不写死),没到时间就移开的作罢;回答带卡片才弹,没有就什么都不显示。同一时刻只有一个浮窗:指针在浮窗里时不弹别的,移到另一个名字、它有卡片时换成它的。只认最近一次询问的回答,迟到的丢掉。
|
||||
- 什么时候收:移开名字和浮窗两者 `editor.hover.hidingDelay` 毫秒后收(期间移进浮窗、移回名字就不收;从名字移向浮窗时扫过别的名字不把时间往后推);Esc、滚动(页面或页面里能滚的框,浮窗自己里面滚不算)、在浮窗外按下鼠标、换了文档版本、跳到某处(`reveal`)立刻收;收了之后指针不离开这一处不再弹。
|
||||
- 卡片是哪块(扩展端,`editor/definitionPeek.ts` 的 `peekBlock`):名字的定义同 ⌘ / Ctrl + 点击查的(规则标了引用的问规则,其余问语言扩展),几处定义取第一处;链接的符号同点这个链接时找的(`symbolTarget`:不带路径的引用按这份文件找,仓库链接在那个文件的文档符号里找)。定义处是哪块自己的名字(`defines`)就取那块,否则取编辑范围盖住定义那一行的最里层那块(参数、导入项、字段落在它们所在的块里),连同子块;挂在父卡片子行上的子块去掉 `slot`,在浮窗里是最外层那张。定义在这份文件里用最近发出去的那份文档的块(版本对不上就没有);在别的文件就打开它,按它的语言、选中的翻译插件、库规则、项目入口排视图数据(同 `documentMessage`;规则查名字的定义、调用的种类不等,规则出的错不记,由那个文件自己的预览记)。没有卡片:查不到定义、目标语言没有代码阅读、解析失败、没有块盖住定义处、指着的名字就是定义本身(同一文件、定义处下标就是这个名字的)。
|
||||
- 中文名:同一文件的带上这个版本眼下已知的;别的文件只认起了中文名的定义本身(定义处标 `definition`,后面淡淡写原名),用到它们的各处要问语言扩展,浮窗不等。都只带卡片上画出来的名字(`namesOnCard`)。
|
||||
- 样子(`DefinitionPeekPopup.tsx`):portal 到页面根上的浮窗,外框用 VS Code 悬停框的边框、底色、阴影(`--vscode-editorHoverWidget-*`);标题一行「相对路径:行号」(路径从工作区文件夹算起,同挑选框),垫悬停框状态栏的底色、链接色,点了跳到定义(名字的同 ⌘ / Ctrl + 点击,发 `goToDefinition`;链接的同点链接,发 `openRepoLink`),浮窗跟着收。下面是卡片,卡片区最宽 640px、最高 420px,再大在里面滚。默认放在名字下方、左边对齐名字;下方放不下、上方空间更大就放上方,底边贴着名字往上长(卡片展开时不盖住名字);宽高再限到那边剩下的空间,钳在视口内。层级低于悬停提示,浮窗里名字的提示照样盖在它上面。
|
||||
- 卡片只读:同页面上那张卡片(类、函数默认收起子块,能在浮窗里折叠展开,只记在浮窗里),块树不带编辑那部分(`BlockTreeState.edit`),没有编辑、问 AI、重写注释的按钮,不标差异、不筛选。浮窗里的名字下标是那个文件的,页面上管名字的几处都不认它们(浮窗根上标 `data-peek`):不按 ⌘ / Ctrl 变链接、不能跳,单击不高亮也不清页面上的高亮,不画页面上的高亮,不再弹卡片;注释里不带路径的引用不成链接(引用按注释所在的文件找,页面只替自己这份文件问),仓库路径链接照样能点。
|
||||
- 按住 ⌘ / Ctrl 变链接、⌘ / Ctrl + 点击跳转、单击高亮各处出现都照旧;在浮窗外按下鼠标(单击名字、⌘ + 点击)先收起浮窗。
|
||||
|
||||
### 高亮名字的各处出现
|
||||
|
||||
- 能点的名字和「跳到定义」一样:带源码下标的段(属性表、一行译文、字典 / 对象各层子行的键和值,点分模块名每一节各算各的)。「编辑」状态的块里的源码、Markdown 里的名字不算,既不能点也不被高亮。
|
||||
@@ -1187,7 +1200,7 @@ export interface CommandRule {
|
||||
有些名字语言扩展查不到定义,只有懂这个库的规则知道它指向哪儿(Celery 任务表里的 `"commerce.cancel_timeout_pending_orders"` 是个字符串,指向工作区里某个 `@celery_app.task(name=…)` 装饰的函数)。规则可以把自己给的这种名字标成引用,自己查。别的库(路由名 → 处理函数、配置键 → 定义处……)照同样的办法用。
|
||||
|
||||
- 标:规则在属性表的段上填 `reference: { rule: 自己的 id, key: 规则自己认的键 }`,段照常带 `offset`(名字在源码里的位置,高亮各处出现、webview 认名字都用它);规则要给 `resolve(key, workspace)`,异步返回各处定义(`RuleLocation`:文件 uri 和定义处那个名字的起止行列)。不合这几条时宿主当替身不合约定(见上面「出错」)。
|
||||
- 查:webview 不认引用,⌘ / Ctrl 悬停、点击照旧只报名字的下标(`probeDefinition` / `goToDefinition`)。扩展端记着最近发出去的那份文档里哪些下标是引用(只对那个文档版本有效),是引用的交给标它的规则的 `resolve`,其余照旧问语言扩展(`vscode.executeDefinitionProvider`);两边查到的位置走同一条路:链接样子、一处原地滚 / 在预览里打开 / 普通编辑器打开、几处弹挑选框、闪「就是这处定义」的那块或那一行,都和「跳到定义」一节一样。规则注销了、`resolve` 出错时记日志,当作没有定义。
|
||||
- 查:webview 不认引用,⌘ / Ctrl 悬停、点击、悬停看定义卡片照旧只报名字的下标(`probeDefinition` / `goToDefinition` / `peekDefinition`)。扩展端记着最近发出去的那份文档里哪些下标是引用(只对那个文档版本有效),是引用的交给标它的规则的 `resolve`,其余照旧问语言扩展(`vscode.executeDefinitionProvider`);两边查到的位置走同一条路:链接样子、一处原地滚 / 在预览里打开 / 普通编辑器打开、几处弹挑选框、闪「就是这处定义」的那块或那一行,都和「跳到定义」一节一样。规则注销了、`resolve` 出错时记日志,当作没有定义。
|
||||
- 在整个工作区里找:规则不能碰 VS Code,只描述一个 `WorkspaceIndex`(看哪些文件、便宜的文字预筛、从一份语法树里认出「键 → 定义处名字」),`resolve` 里调 `workspace.lookup(索引, 键)`。宿主(`workspaceIndex.ts`,VS Code 那一层在 `workspaceFiles.ts`):
|
||||
- 第一次查时才建,同一个 id 只建一份,建的时候不挡预览(查的那一下等它建好);
|
||||
- 按 `include` 在各工作区文件夹里找文件,`files.exclude`、`search.exclude` 里开着的和 `.venv`、`node_modules`、`__pycache__` 不看;自己是一份 checkout 的子目录(带 `.git` 文件或目录:子仓库、git worktree,例如 `.claude/worktrees/…` 里的副本,不然同一个任务会查到两处)整个不看,工作区文件夹自己的 `.git` 不算;`search.useIgnoreFiles` 开着时(默认开,和 VS Code 搜索一样)路上各层目录的 `.gitignore`、`.ignore` 忽略的不看——每层的规则相对那一层判断,哪一层忽略了就不看,下层不能把上层忽略的加回来;工作区文件夹上面的、全局的忽略文件和 `.git/info/exclude` 不读(VS Code 的 `search.useParentIgnoreFiles`、`search.useGlobalIgnoreFiles` 默认也不读)。插件声明的引擎版本(^1.97)的正式 API 里 `findFiles` 不能按忽略文件筛,所以忽略文件由宿主自己读(`workspaceScope.ts`);文件改动时新建、改了的文件照同样的规则筛,改了忽略文件不会重扫已经建好的索引。每次读 32 份,预筛不过的不解析,用 `languageId` 的语言插件解析,语法树用完释放;
|
||||
@@ -1988,6 +2001,8 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTab
|
||||
- `{ type: 'document', version, languageId, blocks: BlockView[], attributes: FileAttribute[], translators: { id, label }[], translatorId, error?: string, reveal?: Reveal, names: ShownName[] }`,文档每次变化(防抖 150ms)重发。`names` 是这个版本眼下已知的中文名(`{ offset, length, name, definition? }`:源码下标 [offset, offset + length) 的名字显示成 name,definition 是定义处本身),见「中文名」。`attributes` 是这份文件里出现过的筛选属性,按筛选栏的先后排:`{ id, name, values: { id, name, count }[] }`,名字取翻译插件的 `attributeNames`,没有的用 id。`reveal = { line, definition? }` 只随新预览的第一份文档走:从文本编辑器切过来时是它顶部那一行(滚到包含这行的块、顶到顶部,折叠的不展开);跳到定义时还带 `definition`(定义处名字在源码里的下标),按「跳到定义」一节展开、滚动、闪。
|
||||
- `{ type: 'reveal', line, definition? }`:同上,发给已经开着的预览。页面正在重新加载(标签页切回来)时收不到,扩展端改成随它 `ready` 后的第一份文档带过去。
|
||||
- `{ type: 'definitionProbed', id, found }`:回答第 `id` 次 `probeDefinition`。
|
||||
- `{ type: 'definitionPeeked', id, peek? }`:回答第 `id` 次 `peekDefinition` / `peekLink`。`peek` 是 `{ path, line, block: BlockView, names: ShownName[] }`:定义所在的文件(从工作区文件夹算起)、定义处那一行(0 起)、那块卡片连同子块、卡片上显示成中文名的名字(下标是那个文件的);没有卡片时不带。见「悬停看定义卡片」。
|
||||
- `{ type: 'hoverDelay', delay, hidingDelay }`:VS Code 的 `editor.hover.delay` / `editor.hover.hidingDelay`(毫秒,按这份文件读),`ready` 后发一次,设置改了再发。
|
||||
- `{ type: 'occurrencesFound', id, occurrences: { start, end, kind: 'text' | 'read' | 'write' }[] }`:回答第 `id` 次 `findOccurrences`,`[start, end)` 是源码下标,`kind` 同 `DocumentHighlightKind`;语言扩展没答就是空数组。
|
||||
- `{ type: 'moveOccurrence', step: 1 | -1 }`:两条命令发来,跳到下一处 / 上一处出现。
|
||||
- `{ type: 'displayNames', version, names: ShownName[] }`:version 这份文档里显示成中文名的名字,查到一步发一步;页面只收自己眼下这个版本的。
|
||||
@@ -2001,6 +2016,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTab
|
||||
- webview → 扩展:
|
||||
- `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。
|
||||
- `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。这两条都只带下标:扩展端按发出去的文档认出规则标了引用的下标交给规则查(见「规则自己查定义」),其余问语言扩展。
|
||||
- `{ type: 'peekDefinition', id, version, offset }`:鼠标停在带 `offset` 的名字上,要它的定义卡片;`id` 每问一次加一,`version` 不是当前文档版本就不答。`{ type: 'peekLink', id, path?, symbol }`:鼠标停在注释里指到符号的链接上,要那个符号的定义卡片(`path`、`symbol` 同 `openRepoLink`)。见「悬停看定义卡片」。
|
||||
- `{ type: 'findOccurrences', id, version, offset }`:单击带 `offset` 的名字,问它在这份文档里的各处出现;`id` 每问一次加一,`version` 不是当前文档版本就不答。
|
||||
- `{ type: 'occurrenceNavigation', available }`:眼下能不能用键盘在各处出现之间跳,页面加载后报一次、之后变了才报;扩展端按它设 `superpowers.codeReader.hasOccurrences`。
|
||||
- `{ type: 'checkRepoLinks', id, paths, symbols }`:页面上的仓库路径问哪些没有,不带路径的引用问哪些找不到(`id` 每问一次加一)。`{ type: 'openRepoLink', path?, symbol? }`:点了注释里的仓库链接(带 `path`)或不带路径的引用(只带 `symbol`)。见「注释的 Markdown 排版」。
|
||||
@@ -2147,11 +2163,12 @@ vscode/src/codeReader/
|
||||
editor/definitions.ts 查定义(vscode.executeDefinitionProvider)、规则查到的位置换成 VS Code 的、几处定义的挑选框
|
||||
editor/definitionSources.ts 名字的定义去哪儿查:文档里规则标的引用问规则,其余问语言扩展
|
||||
editor/definitionAction.ts 查到的定义 → 怎么跳(纯函数)
|
||||
editor/definitionPeek.ts 定义卡片取哪块(peekBlock)、卡片上带哪些中文名(namesOnCard、definitionSiteNames)
|
||||
editor/openTarget.ts 打开别的文件用哪种编辑器:语言有代码阅读的用预览(滚到定义处),其余用默认的打开方式(选中名字);侧栏点文件、跳到定义、注释里的链接共用
|
||||
editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights)
|
||||
editor/occurrenceNavigation.ts 跳到下一处 / 上一处高亮:命令、上下文键
|
||||
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(不带路径的引用的行内规则也在 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)
|
||||
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,悬停看定义卡片什么时候弹、收、放在哪在 definitionPeek.ts、接到页面上在 useDefinitionPeek.ts、浮窗和只读的卡片在 DefinitionPeekPopup.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)
|
||||
```
|
||||
|
||||
## 测试
|
||||
@@ -2170,7 +2187,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
|
||||
- 块标签:拆法(`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 里的名字。不带路径的引用:行内规则(`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 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 别的文件 / 挑选框,别的文件用哪种编辑器(`editor/openTarget.test.ts`:有代码阅读的用预览、带位置时滚到那一行闪定义处,没有的用默认的打开方式选中名字,打不开成文本的用默认的打开方式);webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。
|
||||
- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 别的文件 / 挑选框,别的文件用哪种编辑器(`editor/openTarget.test.ts`:有代码阅读的用预览、带位置时滚到那一行闪定义处,没有的用默认的打开方式选中名字,打不开成文本的用默认的打开方式);webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。悬停看定义卡片:取哪块(`editor/definitionPeek.test.ts`:Python 源码走完整的语言插件 + 翻译插件,类名 → 类那块连同子块,参数 → 所在的函数,变量 → 变量那块,块外面 → 没有,挂在子行上的子块去掉 `slot`;卡片上的中文名只带画出来的、长度对得上的,别的文件只认定义本身),什么时候弹、收(`definitionPeek.test.ts`:停够延迟才问、没到时间移开不问、没有卡片不弹、迟到的回答丢掉、链接问 `peekLink` 点标题发 `openRepoLink`、没收到延迟不弹、移开过 `hidingDelay` 才收、移进浮窗不收、扫过别的名字不往后推、浮窗里不弹别的、换成新名字的卡片、Esc / 滚动后不再弹、换文档版本收起;指到符号的链接才弹;放在下方 / 上方 / 空间大的那边、钳在视口内),只读的卡片(`DefinitionPeekPopup.test.tsx`:类收起子块、没有编辑 / 问 AI / 重写按钮、中文名用卡片带的、引用不成链接;`markdown.test.ts` 的 `peek`)。闪哪一处(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` 或又按键、点鼠标不守。
|
||||
- 中文名:
|
||||
- 语言插件认写法(`languages/pythonNames.test.ts`、`languages/typescriptNames.test.ts`,真实解析):Python 注解 `Annotated`(`typing.Annotated`、`t.Comment`,f-string、别的调用不算)、装饰器(函数、类)、行尾记号(多行参数表、`*args`、`self.x`、变量、字段、枚举成员,记号前面有别的字,记号的范围)、单独成行 / 那一行没有定义的不算、函数那一行给参数、docstring 的谷歌写法和 Sphinx 写法(带类型、星号、续行、别的段落不算)、第一小句和 12 个字的限制、明写的记号盖过说明文字、没取的记号照样去掉;TypeScript 装饰器(类、`@d export class`、字段、构造函数和方法的参数、方法隔着别的装饰器、名字最后一节)、行尾 `//` 记号(参数、变量、枚举成员、类字段、接口属性,块注释不算)、JSDoc `@param`(类型、短横、可选、属性不算、export 函数、方法、箭头函数变量、类字段)、JavaScript 默认值参数;预筛和各方言的文件。
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Memento } from 'vscode'
|
||||
import type { BlockView } from '../blockView'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createPythonAdapter } from '../languages/python'
|
||||
import { CodeReaderRegistry } from '../registry'
|
||||
import { runtime, WASM_DIR } from '../testSupport'
|
||||
import { pythonZhTranslator } from '../translators/pythonZh'
|
||||
import { definitionSiteNames, namesOnCard, peekBlock } from './definitionPeek'
|
||||
import { documentMessage } from './documentMessage'
|
||||
|
||||
const SOURCE = [
|
||||
'from typing import Annotated',
|
||||
'',
|
||||
'class PairedDeviceRepository:',
|
||||
' """配对设备的仓库"""',
|
||||
'',
|
||||
' owner: Annotated[str, Comment("所有者")] = ""',
|
||||
'',
|
||||
' def find(self, device_id: str):',
|
||||
' return device_id',
|
||||
'',
|
||||
'',
|
||||
'repo = PairedDeviceRepository()',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
async function read(): Promise<Awaited<ReturnType<typeof documentMessage>>> {
|
||||
const registry = new CodeReaderRegistry({ get: () => undefined, update: async () => {} } as Pick<Memento, 'get' | 'update'>)
|
||||
registry.registerLanguage(createPythonAdapter(WASM_DIR))
|
||||
registry.registerTranslator(pythonZhTranslator)
|
||||
return documentMessage({ languageId: 'python', version: 1, getText: () => SOURCE }, registry, runtime, () => {})
|
||||
}
|
||||
|
||||
function at(name: string, from = 0): { offset: number, line: number } {
|
||||
const offset = SOURCE.indexOf(name, from)
|
||||
return { offset, line: SOURCE.slice(0, offset).split('\n').length - 1 }
|
||||
}
|
||||
|
||||
describe('定义卡片取哪块', () => {
|
||||
it('定义是块自己的名字:取那块,连同子块', async () => {
|
||||
const { message } = await read()
|
||||
const { offset, line } = at('PairedDeviceRepository')
|
||||
const block = peekBlock(message.blocks, offset, line)
|
||||
expect(block?.kind).toBe('class')
|
||||
expect(block?.children.map(child => child.kind)).toContain('function')
|
||||
})
|
||||
|
||||
it('定义是参数:取参数所在的函数那块', async () => {
|
||||
const { message } = await read()
|
||||
const { offset, line } = at('device_id')
|
||||
const block = peekBlock(message.blocks, offset, line)
|
||||
expect(block?.kind).toBe('function')
|
||||
expect(block?.defines?.startIndex).toBe(at('find').offset)
|
||||
})
|
||||
|
||||
it('定义是变量:取变量那块;定义在块外面:没有', async () => {
|
||||
const { message } = await read()
|
||||
const repo = at('repo =')
|
||||
expect(peekBlock(message.blocks, repo.offset, repo.line)?.kind).toBe('variable')
|
||||
expect(peekBlock(message.blocks, SOURCE.length, 99)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('挂在父卡片子行上的子块:浮窗里是最外层的卡片,不带 slot', () => {
|
||||
const child = { id: '0.0', startLine: 2, endLine: 3, defines: { startIndex: 30, endIndex: 34 }, slot: { row: 1, item: 0 }, children: [] } as unknown as BlockView
|
||||
const parent = { id: '0', startLine: 0, endLine: 5, children: [child] } as unknown as BlockView
|
||||
const block = peekBlock([parent], 31, 2)
|
||||
expect(block?.id).toBe('0.0')
|
||||
expect(block && 'slot' in block).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('卡片上的中文名', () => {
|
||||
it('别的文件:起了中文名的定义本身,标成定义处;只带卡片上画出来的', async () => {
|
||||
const { message, displayNames } = await read()
|
||||
const names = definitionSiteNames(displayNames)
|
||||
const { offset, line } = at('PairedDeviceRepository')
|
||||
const owner = { offset: at('owner').offset, length: 'owner'.length, name: '所有者', definition: true }
|
||||
expect(namesOnCard(peekBlock(message.blocks, offset, line)!, names)).toEqual([owner])
|
||||
const repo = at('repo =')
|
||||
expect(namesOnCard(peekBlock(message.blocks, repo.offset, repo.line)!, names)).toEqual([])
|
||||
})
|
||||
|
||||
it('下标对上、长度对不上的不带', async () => {
|
||||
const { message } = await read()
|
||||
const { offset, line } = at('PairedDeviceRepository')
|
||||
const owner = at('owner').offset
|
||||
expect(namesOnCard(peekBlock(message.blocks, offset, line)!, [{ offset: owner, length: 3, name: '所有者' }])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { DisplayName } from '../contract'
|
||||
import type { ShownName } from '../nameResolution'
|
||||
import { renderedNames } from '../nameResolution'
|
||||
|
||||
function covers(block: BlockView, offset: number): boolean {
|
||||
return block.defines !== undefined && block.defines.startIndex <= offset && offset < block.defines.endIndex
|
||||
}
|
||||
|
||||
/** 自己的名字(defines)盖住 offset 的最里层那块。 */
|
||||
function namedAt(blocks: readonly BlockView[], offset: number): BlockView | undefined {
|
||||
for (const block of blocks) {
|
||||
const inner = namedAt(block.children, offset)
|
||||
if (inner)
|
||||
return inner
|
||||
if (covers(block, offset))
|
||||
return block
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 编辑范围盖住 line 的最里层那块;同一行上并排的兄弟块取第一块。 */
|
||||
function innermostAt(blocks: readonly BlockView[], line: number): BlockView | undefined {
|
||||
const outer = blocks.find(block => block.startLine <= line && line <= block.endLine)
|
||||
return outer && (innermostAt(outer.children, line) ?? outer)
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义卡片取哪块:定义处(下标 offset,在第 line 行)是哪块自己的名字(函数名、类名、单个变量名)就取那块,
|
||||
* 否则取编辑范围盖住定义那一行的最里层那块(参数、导入项、字段落在它们所在的块里)。都没有(定义在块外面)就没有。
|
||||
* 挂在父卡片子行上的子块(slot)在浮窗里是最外层那张卡片,去掉 slot 照常画。
|
||||
*/
|
||||
export function peekBlock(blocks: readonly BlockView[], offset: number, line: number): BlockView | undefined {
|
||||
const found = namedAt(blocks, offset) ?? innermostAt(blocks, line)
|
||||
if (!found?.slot)
|
||||
return found
|
||||
const { slot: _slot, ...card } = found
|
||||
return card
|
||||
}
|
||||
|
||||
/** 卡片上画出来的名字(照抄自源码,下标和长度都对得上)显示成什么中文名;别的不带过去。 */
|
||||
export function namesOnCard(block: BlockView, names: readonly ShownName[]): ShownName[] {
|
||||
const rendered = renderedNames([block])
|
||||
return names.filter(name => rendered.get(name.offset)?.length === name.length)
|
||||
}
|
||||
|
||||
/** 别的文件里起了中文名的定义本身显示成中文名(定义处标 definition,同预览);用到它们的各处要问语言扩展,浮窗不等。 */
|
||||
export function definitionSiteNames(names: readonly DisplayName[]): ShownName[] {
|
||||
return names.map(({ name, text }) => ({ offset: name.startIndex, length: name.endIndex - name.startIndex, name: text, definition: true as const }))
|
||||
}
|
||||
@@ -34,6 +34,17 @@ export interface Occurrence {
|
||||
kind: 'text' | 'read' | 'write'
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬停名字、注释里的链接时浮窗里的定义卡片:定义所在的文件(从工作区文件夹算起的相对路径,同挑选框)、定义处那一行(0 起),
|
||||
* 盖住定义处的那块(连同子块,和那个文件的预览画的是同一张卡片),显示成中文名的名字(下标是那个文件里的)。
|
||||
*/
|
||||
export interface DefinitionPeek {
|
||||
path: string
|
||||
line: number
|
||||
block: BlockView
|
||||
names: ShownName[]
|
||||
}
|
||||
|
||||
export interface DocumentMessage {
|
||||
type: 'document'
|
||||
/** TextDocument.version:webview 靠它判断是不是新一份文档 */
|
||||
@@ -89,6 +100,9 @@ export interface QaMessage {
|
||||
/**
|
||||
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
|
||||
* definitionProbed:id 那次 probeDefinition 的回答,found 表示 VS Code 查得到定义
|
||||
* definitionPeeked:id 那次 peekDefinition / peekLink 的回答。查不到定义、定义所在的语言没有代码阅读、没有块盖住定义处、
|
||||
* 指着的名字就是定义本身时没有 peek,页面不弹浮窗
|
||||
* hoverDelay:VS Code 的 editor.hover.delay / editor.hover.hidingDelay(毫秒),鼠标停多久弹定义卡片、移开后多久收;ready 后发一次,设置改了再发
|
||||
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
|
||||
* filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览
|
||||
* qa:这份文件的问答;ready 后发一次,问答、正在答的问题变了再发
|
||||
@@ -110,6 +124,8 @@ export type ExtensionToWebview
|
||||
= | DocumentMessage
|
||||
| ({ type: 'reveal' } & Reveal)
|
||||
| { type: 'definitionProbed', id: number, found: boolean }
|
||||
| { type: 'definitionPeeked', id: number, peek?: DefinitionPeek }
|
||||
| { type: 'hoverDelay', delay: number, hidingDelay: number }
|
||||
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
|
||||
| { type: 'moveOccurrence', step: 1 | -1 }
|
||||
| { type: 'displayNames', version: number, names: ShownName[] }
|
||||
@@ -133,6 +149,8 @@ export type ExtensionToWebview
|
||||
* probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问源码 offset 处有没有定义;id 标出是哪一次问,回答带回去;
|
||||
* version 是这份下标所属的文档版本
|
||||
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
|
||||
* peekDefinition:鼠标停在带下标的名字上,要 offset 处名字的定义卡片(几处定义取第一处);id、version 同 probeDefinition
|
||||
* peekLink:鼠标停在注释里指到符号的链接上(带 #符号 的仓库链接、不带路径的引用),要那个符号的定义卡片;path、symbol 同 openRepoLink
|
||||
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
|
||||
* setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览
|
||||
* setOnlyChanges:打开 / 关掉「只看改动」,扩展端记下后发 filter 给所有预览
|
||||
@@ -161,6 +179,8 @@ export type WebviewToExtension
|
||||
| { type: 'visibleLine', line: number }
|
||||
| { type: 'probeDefinition', id: number, version: number, offset: number }
|
||||
| { type: 'goToDefinition', version: number, offset: number }
|
||||
| { type: 'peekDefinition', id: number, version: number, offset: number }
|
||||
| { type: 'peekLink', id: number, path?: string, symbol: string }
|
||||
| { type: 'findOccurrences', id: number, version: number, offset: number }
|
||||
| { type: 'ask', askId: string, anchor: string, question: string, images: ClaudeImage[] }
|
||||
| { type: 'stopAsk', askId: string }
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { ImportedName, LinkTarget, SymbolNode } from '../symbolLinks'
|
||||
import type { IndexedEntry } from '../workspaceIndex'
|
||||
import type { BlockFilter } from './blockFilter'
|
||||
import type { FoundLocation } from './definitions'
|
||||
import type { ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
|
||||
import type { DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
|
||||
import type { TargetSpot } from './openTarget'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
@@ -33,6 +33,7 @@ import { askPrompt, followUpPrompt } from '../qa/prompt'
|
||||
import { DocumentLookups } from '../ruleLookups'
|
||||
import { resolveReference } from '../symbolLinks'
|
||||
import { definitionAction } from './definitionAction'
|
||||
import { definitionSiteNames, namesOnCard, peekBlock } from './definitionPeek'
|
||||
import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
|
||||
import { DefinitionSources } from './definitionSources'
|
||||
import { documentMessage } from './documentMessage'
|
||||
@@ -259,6 +260,10 @@ class Preview {
|
||||
this.names.edited(document.version, e.contentChanges.map(change => ({ offset: change.rangeOffset, length: change.rangeLength, text: change.text })))
|
||||
this.schedulePush()
|
||||
}),
|
||||
workspace.onDidChangeConfiguration((e) => {
|
||||
if (this.ready && e.affectsConfiguration('editor.hover', document))
|
||||
this.postHoverDelay()
|
||||
}),
|
||||
// 页面没 ready 时收不到,等它 ready 再随第一份文档之前补发
|
||||
filter.subscribe((state) => {
|
||||
if (this.ready)
|
||||
@@ -533,6 +538,7 @@ class Preview {
|
||||
this.ready = true
|
||||
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
|
||||
this.post({ type: 'filter', ...this.filter.current })
|
||||
this.postHoverDelay()
|
||||
this.postQa()
|
||||
void this.postProfiles()
|
||||
await this.push()
|
||||
@@ -579,6 +585,12 @@ class Preview {
|
||||
case 'goToDefinition':
|
||||
await this.goToDefinition(msg.version, msg.offset)
|
||||
return
|
||||
case 'peekDefinition':
|
||||
await this.peekDefinition(msg.id, msg.version, msg.offset)
|
||||
return
|
||||
case 'peekLink':
|
||||
await this.peekLink(msg.id, msg.path, msg.symbol)
|
||||
return
|
||||
case 'findOccurrences':
|
||||
await this.findOccurrences(msg.id, msg.version, msg.offset)
|
||||
return
|
||||
@@ -644,6 +656,73 @@ class Preview {
|
||||
}
|
||||
}
|
||||
|
||||
/** VS Code 的悬停延迟(按这份文件取,语言自己的设置优先):页面按它弹、收定义卡片。 */
|
||||
private postHoverDelay(): void {
|
||||
const hover = workspace.getConfiguration('editor.hover', this.document)
|
||||
this.post({ type: 'hoverDelay', delay: hover.get<number>('delay', 300), hidingDelay: hover.get<number>('hidingDelay', 300) })
|
||||
}
|
||||
|
||||
/**
|
||||
* 回答 webview 第 id 次要定义卡片:offset 处名字的第一处定义所在那块(见 peekAt);指着的名字就是定义本身时没有。
|
||||
* 下标属于别的文档版本时不答,webview 换了文档版本会收起浮窗
|
||||
*/
|
||||
private async peekDefinition(id: number, version: number, offset: number): Promise<void> {
|
||||
const { document } = this
|
||||
if (version !== document.version)
|
||||
return
|
||||
const [definition] = await findDefinitions(await this.definitions.locate(version, offset))
|
||||
const own = definition?.uri === document.uri.toString() && definition.offset === offset
|
||||
const peek = definition && !own ? await this.peekAt(definition.location.uri, definition.offset) : undefined
|
||||
if (version === document.version && this.ready)
|
||||
this.post({ type: 'definitionPeeked', id, ...(peek && { peek }) })
|
||||
}
|
||||
|
||||
/** 回答 webview 第 id 次要注释里的链接指的符号的卡片:符号同点这个链接时找的那个(symbolTarget),找不到没有。 */
|
||||
private async peekLink(id: number, rel: string | undefined, symbol: string): Promise<void> {
|
||||
let peek: DefinitionPeek | undefined
|
||||
try {
|
||||
const target = await this.symbolTarget(rel, symbol)
|
||||
peek = target && await this.peekAt(Uri.parse(target.uri), target.selection.startIndex)
|
||||
}
|
||||
catch (err) {
|
||||
const link = rel === undefined ? `[${symbol}]` : `${rel}#${symbol}`
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `没能找 ${link} 指的符号:${err instanceof Error ? err.message : String(err)}` })
|
||||
}
|
||||
if (this.ready)
|
||||
this.post({ type: 'definitionPeeked', id, ...(peek && { peek }) })
|
||||
}
|
||||
|
||||
/**
|
||||
* uri 里 offset 处的定义的卡片:盖住它的那块(peekBlock)。同一份文件用最近发出去的那份文档的块和中文名;
|
||||
* 别的文件打开它、按它的语言排视图数据(同它自己的预览:翻译插件、库规则、项目入口;规则查名字的定义、调用的种类不等),
|
||||
* 中文名只认起了中文名的定义本身。语言没有代码阅读、解析失败、没有块盖住定义处时没有。
|
||||
*/
|
||||
private async peekAt(uri: Uri, offset: number): Promise<DefinitionPeek | undefined> {
|
||||
try {
|
||||
const target = await workspace.openTextDocument(uri)
|
||||
const line = target.positionAt(offset).line
|
||||
const path = workspace.asRelativePath(uri)
|
||||
if (uri.toString() === this.document.uri.toString()) {
|
||||
const { sent } = this
|
||||
const block = sent?.version === target.version ? peekBlock(sent.blocks, offset, line) : undefined
|
||||
return block && { path, line, block, names: namesOnCard(block, this.names.known(target.version)) }
|
||||
}
|
||||
const { message, displayNames } = await documentMessage(
|
||||
{ languageId: target.languageId, version: target.version, getText: () => target.getText(), fileName: target.fileName, entryNames: this.owner.entries.namesOf(uri) },
|
||||
this.registry,
|
||||
this.runtime,
|
||||
// 那个文件里规则出的错由它自己的预览记
|
||||
() => {},
|
||||
)
|
||||
const block = message.error === undefined ? peekBlock(message.blocks, offset, line) : undefined
|
||||
return block && { path, line, block, names: namesOnCard(block, definitionSiteNames(displayNames)) }
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `没能读出 ${uri.toString()} 里定义的那块:${err instanceof Error ? err.message : String(err)}` })
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在预览所在的编辑器组打开别的文件,和普通编辑器里 ⌘ + 点击一样:语言有代码阅读的用预览打开,给了 spot 就滚过去、闪这处;
|
||||
* 其余用 VS Code 默认的打开方式,给了 spot 就选中它(见 openingFor)。目标是否按「预览标签」打开听 VS Code 的设置,
|
||||
@@ -732,45 +811,33 @@ class Preview {
|
||||
private async openRepoLink(rel: string | undefined, symbol?: string): Promise<void> {
|
||||
const link = rel ?? `[${symbol}]`
|
||||
try {
|
||||
if (rel === undefined) {
|
||||
const target = symbol === undefined ? undefined : await this.resolveSymbolLink(symbol)
|
||||
if (symbol !== undefined) {
|
||||
const target = await this.symbolTarget(rel, symbol)
|
||||
if (!target) {
|
||||
this.markRepoLinkBroken(undefined, symbol)
|
||||
this.markRepoLinkBroken(rel, symbol)
|
||||
return
|
||||
}
|
||||
await this.showLinkTarget(target)
|
||||
this.markRepoLinkOpened(undefined, symbol)
|
||||
this.markRepoLinkOpened(rel, symbol)
|
||||
return
|
||||
}
|
||||
if (rel === undefined)
|
||||
return
|
||||
const root = await repoRootOf(this.document.uri.fsPath)
|
||||
const entry = root === undefined ? undefined : await resolveRepoEntry(root, rel)
|
||||
if (!entry) {
|
||||
this.markRepoLinkBroken(rel, symbol)
|
||||
this.markRepoLinkBroken(rel)
|
||||
return
|
||||
}
|
||||
const uri = Uri.file(entry.fsPath)
|
||||
if (entry.isDirectory) {
|
||||
if (symbol !== undefined) {
|
||||
this.markRepoLinkBroken(rel, symbol)
|
||||
return
|
||||
}
|
||||
if (!await this.owner.revealInTree(uri))
|
||||
void window.showInformationMessage(`代码阅读:侧栏的树上没有 ${rel}`)
|
||||
this.markRepoLinkOpened(rel)
|
||||
return
|
||||
}
|
||||
if (symbol === undefined) {
|
||||
await this.openElsewhere(uri)
|
||||
this.markRepoLinkOpened(rel)
|
||||
return
|
||||
}
|
||||
const found = await this.locateRepoSymbol(uri, symbol)
|
||||
if (!found) {
|
||||
this.markRepoLinkBroken(rel, symbol)
|
||||
return
|
||||
}
|
||||
await this.showLinkTarget(found)
|
||||
this.markRepoLinkOpened(rel, symbol)
|
||||
await this.openElsewhere(uri)
|
||||
this.markRepoLinkOpened(rel)
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
@@ -779,6 +846,18 @@ class Preview {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 链接指的符号在哪:没有 path 的是不带路径的引用,按这份文件找(resolveSymbolLink);有 path 的在那个文件的文档符号里找(locateRepoSymbol)。
|
||||
* 文件没有、是目录、符号没有时没有。点链接、悬停链接看定义卡片都按它找。
|
||||
*/
|
||||
private async symbolTarget(rel: string | undefined, symbol: string): Promise<LinkTarget | undefined> {
|
||||
if (rel === undefined)
|
||||
return this.resolveSymbolLink(symbol)
|
||||
const root = await repoRootOf(this.document.uri.fsPath)
|
||||
const entry = root === undefined ? undefined : await resolveRepoEntry(root, rel)
|
||||
return entry && !entry.isDirectory ? this.locateRepoSymbol(Uri.file(entry.fsPath), symbol) : undefined
|
||||
}
|
||||
|
||||
/** 用文档符号按名字找(`Agent`、`Agent.owner_id`);没有这个名字就没有,不打开到文件顶上。 */
|
||||
private async locateRepoSymbol(uri: Uri, symbol: string): Promise<LinkTarget | undefined> {
|
||||
const found = findRepoSymbol(await this.symbolNodes(uri.toString()), symbol)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TriangleAlert } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AskThread } from './AskThread'
|
||||
import { BlockLevel } from './BlockTree'
|
||||
import { DefinitionPeekPopup } from './DefinitionPeekPopup'
|
||||
import { DiffBar, NewFileBanner } from './DiffBar'
|
||||
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
|
||||
import { acceptNames, DisplayNameContext, documentNames } from './displayNames'
|
||||
@@ -18,6 +19,7 @@ import { OverviewRuler } from './OverviewRuler'
|
||||
import { asksOf, historyOf, PATH_ASK_ANCHOR } from './qa'
|
||||
import { useChangeNav } from './useChangeNav'
|
||||
import { useDefinitionLinks } from './useDefinitionLinks'
|
||||
import { useDefinitionPeek } from './useDefinitionPeek'
|
||||
import { HighlightContext, useOccurrences } from './useOccurrences'
|
||||
import { useQa } from './useQa'
|
||||
import { useRepoImages } from './useRepoImages'
|
||||
@@ -88,6 +90,7 @@ export function App(): ReactElement | null {
|
||||
useScrollPosition(doc)
|
||||
useReveal(doc, reveal, collapsed, unshown, covered, show, expand, revealed)
|
||||
useDefinitionLinks(doc?.version)
|
||||
const peek = useDefinitionPeek(doc?.version)
|
||||
useRepoLinks()
|
||||
useRepoImages()
|
||||
const nav = useChangeNav(stops, collapsed, expand)
|
||||
@@ -143,27 +146,29 @@ export function App(): ReactElement | null {
|
||||
const kindNames = useMemo(() => new Map(doc?.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])), [doc])
|
||||
const kindName = useCallback((kind: string) => kindNames.get(kind) ?? kind, [kindNames])
|
||||
const tree = useMemo((): BlockTreeState | null => doc && {
|
||||
version: doc.version,
|
||||
editing: new Set(view.editing),
|
||||
edit: {
|
||||
version: doc.version,
|
||||
editing: new Set(view.editing),
|
||||
focusId,
|
||||
onFocused,
|
||||
onToggleEdit: (id) => {
|
||||
setFocusId(view.editing.includes(id) ? null : id)
|
||||
setView(prev => ({
|
||||
...prev,
|
||||
editing: prev.editing.includes(id) ? prev.editing.filter(other => other !== id) : startEditing(doc.blocks, prev.editing, id),
|
||||
}))
|
||||
},
|
||||
rewrites: rewrites.runs,
|
||||
onRewriteComment: rewrites.onRewrite,
|
||||
onStopRewrite: rewrites.onStop,
|
||||
},
|
||||
collapsed,
|
||||
hidden: filter.hidden,
|
||||
temporary: filter.temporary,
|
||||
covered,
|
||||
kindName,
|
||||
focusId,
|
||||
onFocused,
|
||||
onToggleEdit: (id) => {
|
||||
setFocusId(view.editing.includes(id) ? null : id)
|
||||
setView(prev => ({
|
||||
...prev,
|
||||
editing: prev.editing.includes(id) ? prev.editing.filter(other => other !== id) : startEditing(doc.blocks, prev.editing, id),
|
||||
}))
|
||||
},
|
||||
onToggleFold: id => setFolded([id], !collapsed.has(id)),
|
||||
onSetFolds: folds => setView(prev => ({ ...prev, folds: { ...prev.folds, ...folds } })),
|
||||
rewrites: rewrites.runs,
|
||||
onRewriteComment: rewrites.onRewrite,
|
||||
onStopRewrite: rewrites.onStop,
|
||||
...(qa.state.available && { qa }),
|
||||
...(changes && { diff: changes }),
|
||||
...(folded && { folded }),
|
||||
@@ -236,6 +241,7 @@ export function App(): ReactElement | null {
|
||||
? <BlockLevel blocks={doc.blocks} tree={tree} />
|
||||
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
|
||||
</div>
|
||||
<DefinitionPeekPopup shown={peek.shown} onJump={peek.jump} />
|
||||
</DisplayNameContext>
|
||||
</HighlightContext>
|
||||
)
|
||||
|
||||
@@ -53,21 +53,23 @@ const CARD: BlockView = {
|
||||
function render(folds: Record<string, boolean>): string {
|
||||
const collapsed = collapsedIds([CARD], folds)
|
||||
const tree: BlockTreeState = {
|
||||
version: 1,
|
||||
editing: new Set(),
|
||||
edit: {
|
||||
version: 1,
|
||||
editing: new Set(),
|
||||
focusId: null,
|
||||
onFocused: () => {},
|
||||
onToggleEdit: () => {},
|
||||
rewrites: new Map(),
|
||||
onRewriteComment: () => {},
|
||||
onStopRewrite: () => {},
|
||||
},
|
||||
collapsed,
|
||||
hidden: new Set(),
|
||||
temporary: new Set(),
|
||||
covered: new Map(),
|
||||
kindName: kind => kind,
|
||||
focusId: null,
|
||||
onFocused: () => {},
|
||||
onToggleEdit: () => {},
|
||||
onToggleFold: () => {},
|
||||
onSetFolds: () => {},
|
||||
rewrites: new Map(),
|
||||
onRewriteComment: () => {},
|
||||
onStopRewrite: () => {},
|
||||
onOpenRun: () => {},
|
||||
}
|
||||
return renderToStaticMarkup(<BlockLevel blocks={[CARD]} tree={tree} />)
|
||||
|
||||
@@ -18,9 +18,27 @@ import { asksOf, historyOf } from './qa'
|
||||
import { TextPreview } from './TextPreview'
|
||||
import { tableId } from './viewState'
|
||||
|
||||
export interface BlockTreeState {
|
||||
/**
|
||||
* 块树里改代码的那部分:哪些块在编辑、点「编辑」、按注释规范重写。页面上的块树才有;定义卡片浮窗里的块树没有,
|
||||
* 卡片只读,不画右上角的按钮
|
||||
*/
|
||||
export interface EditTree {
|
||||
version: number
|
||||
editing: ReadonlySet<string>
|
||||
/** 刚点了「编辑」、要把光标放进去的块 */
|
||||
focusId: string | null
|
||||
onFocused: () => void
|
||||
onToggleEdit: (id: string) => void
|
||||
/** 按注释规范重写的过程,按块的记号。只在这一页上 */
|
||||
rewrites: ReadonlyMap<string, RewriteRun>
|
||||
onRewriteComment: (anchor: string) => void
|
||||
/** 叫停这块正在进行的重写,不写文件 */
|
||||
onStopRewrite: (anchor: string) => void
|
||||
}
|
||||
|
||||
export interface BlockTreeState {
|
||||
/** 能编辑(页面上的块树);定义卡片浮窗里没有 */
|
||||
edit?: EditTree
|
||||
/** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */
|
||||
collapsed: ReadonlySet<string>
|
||||
/** 按筛选不画的块(连同子孙),见 applyFilter */
|
||||
@@ -31,18 +49,9 @@ export interface BlockTreeState {
|
||||
covered: ReadonlyMap<string, string>
|
||||
/** 种类叫什么(种类图标的悬停提示和 aria-label):翻译插件给种类起的名字(库规则的种类是规则给的 badge.label),没给就是 id */
|
||||
kindName: (kind: string) => string
|
||||
/** 刚点了「编辑」、要把光标放进去的块 */
|
||||
focusId: string | null
|
||||
onFocused: () => void
|
||||
onToggleEdit: (id: string) => void
|
||||
onToggleFold: (id: string) => void
|
||||
/** 一次记下几块、几行折叠还是展开(点子行展开一块、同一行里别的收起) */
|
||||
onSetFolds: (folds: Record<string, boolean>) => void
|
||||
/** 按注释规范重写的过程,按块的记号。只在这一页上 */
|
||||
rewrites: ReadonlyMap<string, RewriteRun>
|
||||
onRewriteComment: (anchor: string) => void
|
||||
/** 叫停这块正在进行的重写,不写文件 */
|
||||
onStopRewrite: (anchor: string) => void
|
||||
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */
|
||||
qa?: QaTree
|
||||
/** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */
|
||||
@@ -152,7 +161,7 @@ export function BlockLevel({ blocks, owner, tree, slotted = false }: { blocks: B
|
||||
<BlockCard block={block} tree={tree} />
|
||||
</ChangeRow>
|
||||
{tree.qa && <BlockQa key={block.anchor} anchor={block.anchor} qa={tree.qa} />}
|
||||
<BlockRewrite anchor={block.anchor} tree={tree} />
|
||||
{tree.edit && <BlockRewrite anchor={block.anchor} edit={tree.edit} />}
|
||||
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
|
||||
<BlockLevel blocks={block.children} owner={block} tree={tree} />
|
||||
)}
|
||||
@@ -184,11 +193,11 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
function BlockRewrite({ anchor, tree }: { anchor: string, tree: BlockTreeState }): ReactElement | null {
|
||||
const run = tree.rewrites.get(anchor)
|
||||
function BlockRewrite({ anchor, edit }: { anchor: string, edit: EditTree }): ReactElement | null {
|
||||
const run = edit.rewrites.get(anchor)
|
||||
if (!run)
|
||||
return null
|
||||
return <RewriteThread run={run} onStop={() => tree.onStopRewrite(anchor)} />
|
||||
return <RewriteThread run={run} onStop={() => edit.onStopRewrite(anchor)} />
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,9 +242,10 @@ function cardSlots(block: BlockView, tree: BlockTreeState): GridSlots | undefine
|
||||
}
|
||||
}
|
||||
|
||||
/** 一块:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools)。 */
|
||||
/** 一块:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools,只读的块树没有)。 */
|
||||
function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
|
||||
const editing = tree.editing.has(block.id)
|
||||
const { edit } = tree
|
||||
const editing = edit?.editing.has(block.id) ?? false
|
||||
const collapsed = tree.collapsed.has(block.id)
|
||||
// 点父卡片子行才展开的子块:画出来就是展开着的,箭头收起整张
|
||||
const toggled = block.slot !== undefined
|
||||
@@ -267,14 +277,14 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
||||
: <span className="w-3.5 shrink-0" />}
|
||||
<KindIcon concepts={block.concepts} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{editing
|
||||
{edit && editing
|
||||
? (
|
||||
<CodeEditor
|
||||
block={block}
|
||||
version={tree.version}
|
||||
autoFocus={tree.focusId === block.id}
|
||||
onAutoFocused={tree.onFocused}
|
||||
onExit={() => tree.onToggleEdit(block.id)}
|
||||
version={edit.version}
|
||||
autoFocus={edit.focusId === block.id}
|
||||
onAutoFocused={edit.onFocused}
|
||||
onExit={() => edit.onToggleEdit(block.id)}
|
||||
/>
|
||||
)
|
||||
: <BlockPreview block={block} tree={tree} marks={tree.diff?.current ? change?.marks : undefined} />}
|
||||
@@ -285,14 +295,16 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
||||
已被筛选隐藏,临时显示
|
||||
</span>
|
||||
)}
|
||||
<BlockTools
|
||||
editing={editing}
|
||||
onToggleEdit={() => tree.onToggleEdit(block.id)}
|
||||
asking={tree.qa?.state.open.includes(block.anchor)}
|
||||
onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))}
|
||||
rewriting={tree.rewrites.get(block.anchor)?.status === 'running'}
|
||||
onRewrite={block.commentStartLine !== undefined ? () => tree.onRewriteComment(block.anchor) : undefined}
|
||||
/>
|
||||
{edit && (
|
||||
<BlockTools
|
||||
editing={editing}
|
||||
onToggleEdit={() => edit.onToggleEdit(block.id)}
|
||||
asking={tree.qa?.state.open.includes(block.anchor)}
|
||||
onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))}
|
||||
rewriting={edit.rewrites.get(block.anchor)?.status === 'running'}
|
||||
onRewrite={block.commentStartLine !== undefined ? () => edit.onRewriteComment(block.anchor) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { BlockView, DefinitionPeek } from './types'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PeekTree } from './DefinitionPeekPopup'
|
||||
|
||||
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
|
||||
|
||||
function block(id: string, kind: string, extra: Partial<BlockView>): BlockView {
|
||||
return {
|
||||
id,
|
||||
anchor: id,
|
||||
kind,
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
code: '',
|
||||
codeTokens: [],
|
||||
attributes: { kind },
|
||||
concepts: [{ id: kind, label: kind, icon: 'box' }],
|
||||
children: [],
|
||||
translation: [],
|
||||
...extra,
|
||||
} as BlockView
|
||||
}
|
||||
|
||||
const NAME = 'PairedDeviceRepository'
|
||||
|
||||
const PEEK: DefinitionPeek = {
|
||||
path: 'app/devices.py',
|
||||
line: 41,
|
||||
block: block('3', 'class', {
|
||||
commentStartLine: 42,
|
||||
commentEndLine: 42,
|
||||
properties: [
|
||||
{ label: '名称', value: [{ text: NAME, role: 'type', offset: 1200 }] },
|
||||
{ label: '说明', value: { markdown: '只在桌面端用,见 [DesktopOnly]' } },
|
||||
],
|
||||
children: [block('3.0', 'function', { translation: [{ text: '查找设备', role: 'plain' }] })],
|
||||
} as Partial<BlockView>),
|
||||
names: [{ offset: 1200, length: NAME.length, name: '配对设备仓库', definition: true }],
|
||||
}
|
||||
|
||||
describe('定义卡片浮窗里的卡片', () => {
|
||||
const html = renderToStaticMarkup(<PeekTree peek={PEEK} />)
|
||||
|
||||
it('同页面上那张卡片:类默认收起子块', () => {
|
||||
expect(html).toContain('cr-block')
|
||||
expect(html).toContain('aria-label="展开子块"')
|
||||
expect(html).not.toContain('查找设备')
|
||||
})
|
||||
|
||||
it('只读:没有编辑、问 AI、重写注释的按钮', () => {
|
||||
expect(html).not.toContain('aria-label="编辑"')
|
||||
expect(html).not.toContain('aria-label="问 AI"')
|
||||
expect(html).not.toContain('aria-label="按注释规范重写"')
|
||||
})
|
||||
|
||||
it('中文名用卡片带来的(下标是那个文件的)', () => {
|
||||
expect(html).toContain('data-name="配对设备仓库"')
|
||||
expect(html).toContain(`<span class="cr-original">${NAME}</span>`)
|
||||
})
|
||||
|
||||
it('注释里不带路径的引用不成链接:它按注释所在的文件找,页面只替自己这份文件问', () => {
|
||||
expect(html).not.toContain('symbol:DesktopOnly')
|
||||
expect(html).toContain('[DesktopOnly]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { BlockTreeState } from './BlockTree'
|
||||
import type { ShownPeek } from './definitionPeek'
|
||||
import type { DefinitionPeek } from './types'
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { BlockLevel } from './BlockTree'
|
||||
import { PEEK_ATTRIBUTE, placePeek } from './definitionPeek'
|
||||
import { DisplayNameContext, nameMap } from './displayNames'
|
||||
import { MarkdownKindContext } from './TextPreview'
|
||||
import { HighlightContext } from './useOccurrences'
|
||||
import { collapsedIds } from './viewState'
|
||||
|
||||
const NONE: ReadonlySet<string> = new Set()
|
||||
const NOT_COVERED: ReadonlyMap<string, string> = new Map()
|
||||
|
||||
/**
|
||||
* 浮窗里的那张卡片:只读的块树(不编辑、不问 AI、不重写注释、不标差异、不筛选),折叠展开只记在浮窗里,默认同页面(类、函数收起子块)。
|
||||
* 名字的下标是那个文件的:中文名换成卡片带来的,不画页面上的高亮;Markdown 不认不带路径的引用。
|
||||
*/
|
||||
export function PeekTree({ peek }: { peek: DefinitionPeek }): ReactElement {
|
||||
const [folds, setFolds] = useState<Record<string, boolean>>({})
|
||||
const collapsed = useMemo(() => collapsedIds([peek.block], folds), [peek, folds])
|
||||
const names = useMemo(() => nameMap(peek.names), [peek])
|
||||
const tree: BlockTreeState = {
|
||||
collapsed,
|
||||
hidden: NONE,
|
||||
temporary: NONE,
|
||||
covered: NOT_COVERED,
|
||||
kindName: kind => kind,
|
||||
onToggleFold: id => setFolds(prev => ({ ...prev, [id]: !collapsed.has(id) })),
|
||||
onSetFolds: next => setFolds(prev => ({ ...prev, ...next })),
|
||||
// 不标差异就没有「只看改动」折起来的块
|
||||
onOpenRun: () => {},
|
||||
}
|
||||
return (
|
||||
<HighlightContext value={undefined}>
|
||||
<DisplayNameContext value={names}>
|
||||
<MarkdownKindContext value="peek">
|
||||
<BlockLevel blocks={[peek.block]} tree={tree} />
|
||||
</MarkdownKindContext>
|
||||
</DisplayNameContext>
|
||||
</HighlightContext>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义卡片浮窗:标题一行「相对路径:行号」,点了跳到定义(同 ⌘ / Ctrl + 点击);下面是卡片,太大时在浮窗里滚。
|
||||
* 外框用 VS Code 悬停框的颜色;portal 到 body,先在左上角量出大小,再落到名字下方或上方(placePeek)。
|
||||
*/
|
||||
export function DefinitionPeekPopup({ shown, onJump }: { shown: ShownPeek<HTMLElement> | undefined, onJump: () => void }): ReactElement | null {
|
||||
const popupRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const popup = popupRef.current
|
||||
if (!shown || !popup)
|
||||
return
|
||||
Object.assign(popup.style, { top: '0px', left: '0px', bottom: '', maxWidth: '', maxHeight: '', visibility: 'hidden' })
|
||||
const place = placePeek(shown.anchor.getBoundingClientRect(), popup.getBoundingClientRect(), { width: window.innerWidth, height: window.innerHeight })
|
||||
Object.assign(popup.style, {
|
||||
top: 'top' in place ? `${place.top}px` : '',
|
||||
bottom: 'bottom' in place ? `${place.bottom}px` : '',
|
||||
left: `${place.left}px`,
|
||||
maxWidth: `${place.maxWidth}px`,
|
||||
maxHeight: `${place.maxHeight}px`,
|
||||
visibility: 'visible',
|
||||
})
|
||||
}, [shown])
|
||||
|
||||
if (!shown)
|
||||
return null
|
||||
const { peek } = shown
|
||||
const where = `${peek.path}:${peek.line + 1}`
|
||||
return createPortal(
|
||||
<div
|
||||
ref={popupRef}
|
||||
{...{ [PEEK_ATTRIBUTE]: '' }}
|
||||
role="dialog"
|
||||
aria-label={`定义 ${where}`}
|
||||
className="cr-peek"
|
||||
style={{ top: 0, left: 0, visibility: 'hidden' }}
|
||||
>
|
||||
<button type="button" className="cr-peek-head" data-tooltip="跳到定义" onClick={onJump}>{where}</button>
|
||||
<div className="cr-peek-body">
|
||||
<PeekTree key={`${where}#${peek.block.id}`} peek={peek} />
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { MarkdownKind } from './markdown'
|
||||
import type { PreviewText } from './types'
|
||||
import { createContext, useContext } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import { SegmentText } from './SegmentText'
|
||||
|
||||
/** 卡片里的 Markdown 说明按哪种渲染:页面上是注释;定义卡片浮窗里是 peek(别的文件的注释,不认不带路径的引用)。 */
|
||||
export const MarkdownKindContext = createContext<Exclude<MarkdownKind, 'answer'>>('comment')
|
||||
|
||||
/** 预览里的一段文字:着色的词按源码排版;Markdown 说明渲染成段落、列表、代码块。 */
|
||||
export function TextPreview({ text, className }: { text: PreviewText, className?: string }): ReactElement {
|
||||
const kind = useContext(MarkdownKindContext)
|
||||
if ('markdown' in text)
|
||||
return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={{ __html: renderMarkdown(text.markdown, 'comment') }} />
|
||||
return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={{ __html: renderMarkdown(text.markdown, kind) }} />
|
||||
return (
|
||||
<p className={cn('cr-code', className)}>
|
||||
<SegmentText segments={text} />
|
||||
|
||||
@@ -330,6 +330,54 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/*
|
||||
* 悬停名字、链接时的定义卡片浮窗:外框同 VS Code 编辑器的悬停框(边框、底色、阴影),标题一行垫悬停框状态栏的底色。
|
||||
* 落位时按剩下的空间限宽高(行内样式),卡片再大就在下面那块里滚;那块自己也有个上限,大文件的类不至于占满半屏。
|
||||
* 层级低于悬停提示(z-50),浮窗里名字的提示照样盖在它上面
|
||||
*/
|
||||
.cr-peek {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vscode-editorHoverWidget-border, var(--vscode-editorWidget-border));
|
||||
border-radius: 6px;
|
||||
background: var(--vscode-editorHoverWidget-background, var(--vscode-editorWidget-background));
|
||||
color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground));
|
||||
box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgb(0 0 0 / 36%));
|
||||
}
|
||||
.cr-peek-head {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-bottom: 1px solid var(--vscode-editorHoverWidget-border, var(--vscode-editorWidget-border));
|
||||
background: var(--vscode-editorHoverWidget-statusBarBackground, transparent);
|
||||
color: var(--vscode-textLink-foreground);
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cr-peek-head:hover {
|
||||
color: var(--vscode-textLink-activeForeground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cr-peek-head:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.cr-peek-body {
|
||||
min-height: 0;
|
||||
max-width: 640px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 6px 8px 6px 0;
|
||||
}
|
||||
|
||||
/* 按住 ⌘ / Ctrl 停在查得到定义的名字上:和编辑器里的链接一样 */
|
||||
.cr-code [data-link] {
|
||||
color: var(--vscode-editorLink-activeForeground);
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { PeekTarget, ShownPeek } from './definitionPeek'
|
||||
import type { DefinitionPeek, WebviewToExtension } from './types'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DefinitionPeeks, linkPeekSource, placePeek } from './definitionPeek'
|
||||
|
||||
const DELAY = 300
|
||||
const HIDING = 500
|
||||
|
||||
const PEEK: DefinitionPeek = {
|
||||
path: 'app/devices.py',
|
||||
line: 41,
|
||||
block: { id: '3', anchor: '3', kind: 'class', startLine: 41, endLine: 60, code: '', codeTokens: [], attributes: { kind: 'class' }, concepts: [], children: [], translation: [] },
|
||||
names: [],
|
||||
}
|
||||
|
||||
interface Anchor { name: string }
|
||||
|
||||
function setup(version: number | undefined = 7): {
|
||||
peeks: DefinitionPeeks<Anchor>
|
||||
sent: WebviewToExtension[]
|
||||
shown: () => ShownPeek<Anchor> | undefined
|
||||
asks: () => { id: number }[]
|
||||
} {
|
||||
const sent: WebviewToExtension[] = []
|
||||
let current: ShownPeek<Anchor> | undefined
|
||||
const peeks = new DefinitionPeeks<Anchor>(msg => sent.push(msg), (next) => {
|
||||
current = next
|
||||
})
|
||||
peeks.setDelays(DELAY, HIDING)
|
||||
peeks.setVersion(version)
|
||||
const asks = (): { id: number }[] => sent.flatMap(msg => msg.type === 'peekDefinition' || msg.type === 'peekLink' ? [{ id: msg.id }] : [])
|
||||
return { peeks, sent, shown: () => current, asks }
|
||||
}
|
||||
|
||||
function name(offset: number): PeekTarget<Anchor> {
|
||||
return { anchor: { name: `n${offset}` }, source: { offset } }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('定义卡片什么时候弹', () => {
|
||||
it('停够 editor.hover.delay 才问,问的是这份文档版本里这个下标;回答带卡片才弹', () => {
|
||||
const { peeks, sent, shown } = setup()
|
||||
const target = name(120)
|
||||
peeks.point(target)
|
||||
vi.advanceTimersByTime(DELAY - 1)
|
||||
expect(sent).toEqual([])
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(sent).toEqual([{ type: 'peekDefinition', id: 1, version: 7, offset: 120 }])
|
||||
peeks.answer(1, PEEK)
|
||||
expect(shown()).toEqual({ anchor: target.anchor, peek: PEEK, jump: { type: 'goToDefinition', version: 7, offset: 120 } })
|
||||
})
|
||||
|
||||
it('没到时间就移开:不问', () => {
|
||||
const { peeks, sent } = setup()
|
||||
peeks.point(name(120))
|
||||
vi.advanceTimersByTime(DELAY / 2)
|
||||
peeks.point(null)
|
||||
vi.advanceTimersByTime(DELAY * 2)
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('回答没有卡片:不弹', () => {
|
||||
const { peeks, shown } = setup()
|
||||
peeks.point(name(120))
|
||||
vi.advanceTimersByTime(DELAY)
|
||||
peeks.answer(1, undefined)
|
||||
expect(shown()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('问了之后移开、或换了名字:迟到的回答丢掉', () => {
|
||||
const { peeks, shown, asks } = setup()
|
||||
peeks.point(name(120))
|
||||
vi.advanceTimersByTime(DELAY)
|
||||
peeks.point(name(200))
|
||||
vi.advanceTimersByTime(DELAY)
|
||||
const [first, second] = asks()
|
||||
peeks.answer(first.id, PEEK)
|
||||
expect(shown()).toBeUndefined()
|
||||
peeks.answer(second.id, PEEK)
|
||||
expect(shown()?.anchor).toEqual({ name: 'n200' })
|
||||
})
|
||||
|
||||
it('注释里指到符号的链接:问 peekLink,点标题发的是点这个链接时发的 openRepoLink', () => {
|
||||
const { peeks, sent, shown } = setup()
|
||||
const anchor = { name: 'link' }
|
||||
peeks.point({ anchor, source: { path: 'app/devices.py', symbol: 'Repo.find' } })
|
||||
vi.advanceTimersByTime(DELAY)
|
||||
expect(sent).toEqual([{ type: 'peekLink', id: 1, path: 'app/devices.py', symbol: 'Repo.find' }])
|
||||
peeks.answer(1, PEEK)
|
||||
peeks.jump()
|
||||
expect(sent.at(-1)).toEqual({ type: 'openRepoLink', path: 'app/devices.py', symbol: 'Repo.find' })
|
||||
expect(shown()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('还没收到 VS Code 的悬停延迟:不弹', () => {
|
||||
const sent: WebviewToExtension[] = []
|
||||
const peeks = new DefinitionPeeks<Anchor>(msg => sent.push(msg), () => {})
|
||||
peeks.setVersion(1)
|
||||
peeks.point(name(5))
|
||||
vi.advanceTimersByTime(10_000)
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('定义卡片什么时候收', () => {
|
||||
function showing(): ReturnType<typeof setup> & { target: PeekTarget<Anchor> } {
|
||||
const context = setup()
|
||||
const target = name(120)
|
||||
context.peeks.point(target)
|
||||
vi.advanceTimersByTime(DELAY)
|
||||
context.peeks.answer(1, PEEK)
|
||||
return { ...context, target }
|
||||
}
|
||||
|
||||
it('移开名字:过 editor.hover.hidingDelay 才收,期间移进浮窗就不收', () => {
|
||||
const { peeks, shown } = showing()
|
||||
peeks.point(null)
|
||||
vi.advanceTimersByTime(HIDING - 1)
|
||||
expect(shown()).toBeDefined()
|
||||
peeks.enterPopup()
|
||||
vi.advanceTimersByTime(HIDING * 3)
|
||||
expect(shown()).toBeDefined()
|
||||
})
|
||||
|
||||
it('移出浮窗到空白处:过一会儿收;移回名字就不收', () => {
|
||||
const { peeks, shown, target } = showing()
|
||||
peeks.enterPopup()
|
||||
peeks.point(null)
|
||||
vi.advanceTimersByTime(HIDING / 2)
|
||||
peeks.point(target)
|
||||
vi.advanceTimersByTime(HIDING * 3)
|
||||
expect(shown()).toBeDefined()
|
||||
peeks.point(null)
|
||||
vi.advanceTimersByTime(HIDING)
|
||||
expect(shown()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('从名字移向浮窗时扫过别的名字:不把收的时间往后推', () => {
|
||||
const { peeks, shown } = showing()
|
||||
peeks.point(name(300))
|
||||
vi.advanceTimersByTime(HIDING / 2)
|
||||
peeks.point(name(400))
|
||||
vi.advanceTimersByTime(HIDING / 2)
|
||||
expect(shown()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('扫过别的名字后进了浮窗:不收,也不弹那个名字的卡片', () => {
|
||||
const { peeks, shown, asks } = showing()
|
||||
peeks.point(name(300))
|
||||
vi.advanceTimersByTime(DELAY / 2)
|
||||
peeks.enterPopup()
|
||||
vi.advanceTimersByTime(HIDING * 3)
|
||||
expect(shown()?.anchor).toEqual({ name: 'n120' })
|
||||
expect(asks()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('指着浮窗里的东西不弹别的卡片', () => {
|
||||
const { peeks, asks } = showing()
|
||||
peeks.enterPopup()
|
||||
vi.advanceTimersByTime(DELAY * 3)
|
||||
expect(asks()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('移到另一个名字、它有卡片:换成它的', () => {
|
||||
const { peeks, shown, asks } = showing()
|
||||
peeks.point(name(300))
|
||||
vi.advanceTimersByTime(DELAY)
|
||||
peeks.answer(asks()[1].id, PEEK)
|
||||
expect(shown()?.anchor).toEqual({ name: 'n300' })
|
||||
vi.advanceTimersByTime(HIDING * 3)
|
||||
expect(shown()?.anchor).toEqual({ name: 'n300' })
|
||||
})
|
||||
|
||||
it('移到另一个名字、它没有卡片:原来的照常过一会儿收', () => {
|
||||
const { peeks, shown, asks } = showing()
|
||||
peeks.point(name(300))
|
||||
vi.advanceTimersByTime(DELAY)
|
||||
peeks.answer(asks()[1].id, undefined)
|
||||
expect(shown()?.anchor).toEqual({ name: 'n120' })
|
||||
vi.advanceTimersByTime(HIDING - DELAY)
|
||||
expect(shown()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('按 Esc、滚动、按下鼠标(dismiss)立刻收;指针不离开这个名字不再弹', () => {
|
||||
const { peeks, shown, asks, target } = showing()
|
||||
peeks.dismiss()
|
||||
expect(shown()).toBeUndefined()
|
||||
peeks.point(target)
|
||||
vi.advanceTimersByTime(DELAY * 3)
|
||||
expect(asks()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('换了文档版本:收起,作罢没问完的', () => {
|
||||
const { peeks, shown, asks } = showing()
|
||||
peeks.point(name(300))
|
||||
peeks.setVersion(8)
|
||||
expect(shown()).toBeUndefined()
|
||||
vi.advanceTimersByTime(DELAY * 3)
|
||||
expect(asks()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('链接能不能弹定义卡片', () => {
|
||||
it('指到符号的才能:带 #符号 的仓库链接、不带路径的引用', () => {
|
||||
expect(linkPeekSource('app/devices.py#Repo.find')).toEqual({ path: 'app/devices.py', symbol: 'Repo.find' })
|
||||
expect(linkPeekSource('symbol:DesktopOnly')).toEqual({ symbol: 'DesktopOnly' })
|
||||
})
|
||||
|
||||
it('只指到文件、目录的,网址:不能', () => {
|
||||
expect(linkPeekSource('app/devices.py')).toBeUndefined()
|
||||
expect(linkPeekSource('app/')).toBeUndefined()
|
||||
expect(linkPeekSource('https://example.com#x')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('浮窗放在哪', () => {
|
||||
const viewport = { width: 1000, height: 800 }
|
||||
|
||||
it('下方放得下:贴着名字下边往下长,左边对齐名字', () => {
|
||||
expect(placePeek({ top: 100, bottom: 120, left: 300 }, { width: 400, height: 300 }, viewport))
|
||||
.toEqual({ top: 124, left: 300, maxWidth: 692, maxHeight: 668 })
|
||||
})
|
||||
|
||||
it('下方放不下、上方空间更大:底边贴着名字上边往上长', () => {
|
||||
expect(placePeek({ top: 600, bottom: 620, left: 300 }, { width: 400, height: 300 }, viewport))
|
||||
.toEqual({ bottom: 204, left: 300, maxWidth: 692, maxHeight: 588 })
|
||||
})
|
||||
|
||||
it('两边都放不下:放空间大的那边;右边放不下往左挪,钳在视口内', () => {
|
||||
expect(placePeek({ top: 300, bottom: 320, left: 900 }, { width: 400, height: 900 }, viewport))
|
||||
.toEqual({ top: 324, left: 592, maxWidth: 400, maxHeight: 468 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { DefinitionPeek, WebviewToExtension } from './types'
|
||||
import { parseCommentLink } from './repoLink'
|
||||
|
||||
/** 定义卡片浮窗的根上的属性:浮窗里的名字、链接是别的文件的,页面上管名字的那几处(跳到定义、高亮各处出现、再弹卡片)都不认。 */
|
||||
export const PEEK_ATTRIBUTE = 'data-peek'
|
||||
|
||||
/** 能弹定义卡片的东西:带源码下标的名字,或注释里指到符号的链接(带 #符号 的仓库链接、不带路径的引用)。 */
|
||||
export type PeekSource = { offset: number } | { path?: string, symbol: string }
|
||||
|
||||
/** 指针下能弹定义卡片的一处:页面上的哪个元素(浮窗挂在它旁边),它是什么。 */
|
||||
export interface PeekTarget<A> {
|
||||
anchor: A
|
||||
source: PeekSource
|
||||
}
|
||||
|
||||
/** 弹着的定义卡片:挂在哪个元素旁边、卡片,点标题时发什么(同 ⌘ / Ctrl + 点击这个名字、点这个链接)。 */
|
||||
export interface ShownPeek<A> {
|
||||
anchor: A
|
||||
peek: DefinitionPeek
|
||||
jump: WebviewToExtension
|
||||
}
|
||||
|
||||
/** 元素在不在定义卡片浮窗里。 */
|
||||
export function inPeek(target: Element): boolean {
|
||||
return target.closest(`[${PEEK_ATTRIBUTE}]`) !== null
|
||||
}
|
||||
|
||||
/** 事件落在的那个页面上这份文档的名字(SegmentText 渲染的 data-offset);浮窗里的名字下标是别的文件的,不算。 */
|
||||
export function documentNameAt(target: EventTarget | null): HTMLElement | null {
|
||||
const name = target instanceof Element ? target.closest<HTMLElement>('[data-offset]') : null
|
||||
return name && !inPeek(name) ? name : null
|
||||
}
|
||||
|
||||
/** 注释里一个链接地址能不能弹定义卡片:指到符号的才能(带 #符号 的仓库链接、不带路径的引用),只指到文件、目录的不能。 */
|
||||
export function linkPeekSource(href: string): PeekSource | undefined {
|
||||
const link = parseCommentLink(href)
|
||||
if (link?.symbol === undefined)
|
||||
return undefined
|
||||
return link.path === undefined ? { symbol: link.symbol } : { path: link.path, symbol: link.symbol }
|
||||
}
|
||||
|
||||
/** 浮窗与名字之间的间距。 */
|
||||
const GAP_PX = 4
|
||||
/** 贴边留白,浮窗不顶到 webview 边缘。 */
|
||||
const GUTTER_PX = 8
|
||||
|
||||
/**
|
||||
* 浮窗落在哪:top 是在名字下方、从这儿往下长,bottom 是在名字上方、底边离视口底这么远、往上长(卡片展开时不盖住名字);
|
||||
* maxWidth、maxHeight 是那边还剩的空间,卡片再大就在浮窗里滚。
|
||||
*/
|
||||
export type PeekPlacement = { left: number, maxWidth: number, maxHeight: number } & ({ top: number } | { bottom: number })
|
||||
|
||||
/**
|
||||
* 浮窗放在哪(同 VS Code 编辑器里的悬停):优先在名字下方,下方放不下、上方空间更大就放上方。
|
||||
* 左边对齐名字,钳在视口内。size 是浮窗不受限时的大小。
|
||||
*/
|
||||
export function placePeek(
|
||||
anchor: Pick<DOMRect, 'top' | 'bottom' | 'left'>,
|
||||
size: Pick<DOMRect, 'width' | 'height'>,
|
||||
viewport: { width: number, height: number },
|
||||
): PeekPlacement {
|
||||
const below = viewport.height - GUTTER_PX - (anchor.bottom + GAP_PX)
|
||||
const above = anchor.top - GAP_PX - GUTTER_PX
|
||||
const left = Math.max(GUTTER_PX, Math.min(anchor.left, viewport.width - GUTTER_PX - size.width))
|
||||
const maxWidth = viewport.width - GUTTER_PX - left
|
||||
if (size.height <= below || below >= above)
|
||||
return { top: anchor.bottom + GAP_PX, left, maxWidth, maxHeight: below }
|
||||
return { bottom: viewport.height - (anchor.top - GAP_PX), left, maxWidth, maxHeight: above }
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义卡片的浮窗什么时候弹、换成谁、什么时候收(同 VS Code 编辑器里的悬停;不碰 DOM,页面把指针、按键、滚动喂进来,anchor 是页面上的元素):
|
||||
* 鼠标停在能弹的名字、链接上 delay 毫秒后才问扩展端要卡片,没到时间就移开的作罢;回答带卡片才弹,同一时刻只有一个浮窗。
|
||||
* 移开名字、浮窗两者 hidingDelay 毫秒后收,期间移进浮窗或移回名字就不收;指针在浮窗里时不弹别的。
|
||||
* Esc、滚动、按下鼠标、换了文档版本立刻收。只认最近一次询问的回答,迟到的丢掉。两个延迟都取 VS Code 的设置(hoverDelay),没收到之前不弹。
|
||||
*/
|
||||
export class DefinitionPeeks<A> {
|
||||
private version: number | undefined
|
||||
private delays: { delay: number, hidingDelay: number } | undefined
|
||||
/** 指针下能弹卡片的那一处(浮窗外);没指着的、指针在浮窗里时是 null */
|
||||
private hovered: A | null = null
|
||||
private inPopup = false
|
||||
private askTimer: ReturnType<typeof setTimeout> | undefined
|
||||
private hideTimer: ReturnType<typeof setTimeout> | undefined
|
||||
private request = 0
|
||||
/** 最近一次发出去、还没答的询问 */
|
||||
private asked: { id: number, anchor: A, jump: WebviewToExtension } | undefined
|
||||
private shown: ShownPeek<A> | undefined
|
||||
|
||||
constructor(
|
||||
private readonly post: (msg: WebviewToExtension) => void,
|
||||
private readonly onChange: (shown: ShownPeek<A> | undefined) => void,
|
||||
) {}
|
||||
|
||||
/** VS Code 的 editor.hover.delay / editor.hover.hidingDelay */
|
||||
setDelays(delay: number, hidingDelay: number): void {
|
||||
this.delays = { delay, hidingDelay }
|
||||
}
|
||||
|
||||
/** 换了一份文档:名字的下标换了意思,收起浮窗、作罢没问完的;指针下的名字等它再动时重新认 */
|
||||
setVersion(version: number | undefined): void {
|
||||
if (version === this.version)
|
||||
return
|
||||
this.version = version
|
||||
this.hovered = null
|
||||
this.dismiss()
|
||||
}
|
||||
|
||||
/** 指针在浮窗外:指着能弹卡片的一处,或没指着(null) */
|
||||
point(target: PeekTarget<A> | null): void {
|
||||
const anchor = target?.anchor ?? null
|
||||
if (!this.inPopup && anchor === this.hovered)
|
||||
return
|
||||
this.inPopup = false
|
||||
this.hovered = anchor
|
||||
this.cancelAsk()
|
||||
if (this.shown) {
|
||||
if (anchor === this.shown.anchor)
|
||||
this.cancelHide()
|
||||
else
|
||||
this.scheduleHide()
|
||||
}
|
||||
if (target && anchor !== this.shown?.anchor)
|
||||
this.scheduleAsk(target)
|
||||
}
|
||||
|
||||
/** 指针移进浮窗:不收,也不弹别的 */
|
||||
enterPopup(): void {
|
||||
if (this.inPopup)
|
||||
return
|
||||
this.inPopup = true
|
||||
this.hovered = null
|
||||
this.cancelAsk()
|
||||
this.cancelHide()
|
||||
}
|
||||
|
||||
/** id 那次询问的回答;不是最近一次的、指针已经移开的丢掉 */
|
||||
answer(id: number, peek: DefinitionPeek | undefined): void {
|
||||
if (this.asked?.id !== id)
|
||||
return
|
||||
const { anchor, jump } = this.asked
|
||||
this.asked = undefined
|
||||
if (!peek)
|
||||
return
|
||||
this.cancelHide()
|
||||
this.setShown({ anchor, peek, jump })
|
||||
}
|
||||
|
||||
/** 点了浮窗的标题:跳到定义(同 ⌘ / Ctrl + 点击名字、点链接),收起浮窗 */
|
||||
jump(): void {
|
||||
const jump = this.shown?.jump
|
||||
this.dismiss()
|
||||
if (jump)
|
||||
this.post(jump)
|
||||
}
|
||||
|
||||
/** 立刻收,作罢没问完的;指针不离开眼下这一处就不再弹 */
|
||||
dismiss(): void {
|
||||
this.inPopup = false
|
||||
this.cancelAsk()
|
||||
this.cancelHide()
|
||||
this.setShown(undefined)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.askTimer)
|
||||
clearTimeout(this.hideTimer)
|
||||
}
|
||||
|
||||
private scheduleAsk(target: PeekTarget<A>): void {
|
||||
if (!this.delays)
|
||||
return
|
||||
this.askTimer = setTimeout(() => {
|
||||
this.askTimer = undefined
|
||||
this.ask(target)
|
||||
}, this.delays.delay)
|
||||
}
|
||||
|
||||
private ask({ anchor, source }: PeekTarget<A>): void {
|
||||
const id = ++this.request
|
||||
if ('offset' in source) {
|
||||
if (this.version === undefined)
|
||||
return
|
||||
const { version } = this
|
||||
this.asked = { id, anchor, jump: { type: 'goToDefinition', version, offset: source.offset } }
|
||||
this.post({ type: 'peekDefinition', id, version, offset: source.offset })
|
||||
return
|
||||
}
|
||||
const link = source.path === undefined ? { symbol: source.symbol } : { path: source.path, symbol: source.symbol }
|
||||
this.asked = { id, anchor, jump: { type: 'openRepoLink', ...link } }
|
||||
this.post({ type: 'peekLink', id, ...link })
|
||||
}
|
||||
|
||||
private cancelAsk(): void {
|
||||
clearTimeout(this.askTimer)
|
||||
this.askTimer = undefined
|
||||
this.asked = undefined
|
||||
}
|
||||
|
||||
/** 已经在等着收的不重新计时:从名字移向浮窗时扫过别的名字,不把收的时间往后推 */
|
||||
private scheduleHide(): void {
|
||||
if (this.hideTimer !== undefined || !this.delays)
|
||||
return
|
||||
this.hideTimer = setTimeout(() => {
|
||||
this.hideTimer = undefined
|
||||
this.setShown(undefined)
|
||||
}, this.delays.hidingDelay)
|
||||
}
|
||||
|
||||
private cancelHide(): void {
|
||||
clearTimeout(this.hideTimer)
|
||||
this.hideTimer = undefined
|
||||
}
|
||||
|
||||
private setShown(shown: ShownPeek<A> | undefined): void {
|
||||
if (shown === this.shown)
|
||||
return
|
||||
this.shown = shown
|
||||
this.onChange(shown)
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,11 @@ describe('不带路径的引用', () => {
|
||||
expect(renderMarkdown('见 [Agent]', 'answer')).toBe('<p>见 [Agent]</p>\n')
|
||||
expect(renderMarkdownDiff([{ text: '见 ' }, { text: '[Agent]', change: 'add' }])).toContain('href="symbol:Agent"')
|
||||
})
|
||||
|
||||
it('定义卡片浮窗里别的文件的注释不认(引用按注释所在的文件找);仓库路径链接照样是链接', () => {
|
||||
expect(renderMarkdown('见 [Agent]', 'peek')).toBe('<p>见 [Agent]</p>\n')
|
||||
expect(renderMarkdown('见 [Agent](app/models.py#Agent)', 'peek')).toContain('<a href="app/models.py#Agent">')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderMarkdownDiff', () => {
|
||||
|
||||
@@ -82,8 +82,11 @@ md.core.ruler.push('link_title_tooltip', (state) => {
|
||||
const CACHE_SIZE = 500
|
||||
const cache = new Map<string, string>()
|
||||
|
||||
/** 渲染的是什么:注释(说明、注释块,认不带路径的引用),还是问 AI 的回答。 */
|
||||
export type MarkdownKind = 'comment' | 'answer'
|
||||
/**
|
||||
* 渲染的是什么:注释(说明、注释块,认不带路径的引用),定义卡片浮窗里的注释(不认不带路径的引用:引用按注释所在的文件找,
|
||||
* 页面只替自己这份文件问),还是问 AI 的回答。
|
||||
*/
|
||||
export type MarkdownKind = 'comment' | 'peek' | 'answer'
|
||||
|
||||
function envOf(kind: MarkdownKind): RenderEnv {
|
||||
return { symbolLinks: kind === 'comment' }
|
||||
|
||||
@@ -206,6 +206,17 @@ export interface Occurrence {
|
||||
kind: 'text' | 'read' | 'write'
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬停名字、注释里的链接时浮窗里的定义卡片:定义所在的文件(从工作区文件夹算起的相对路径)、定义处那一行(0 起),
|
||||
* 盖住定义处的那块(连同子块,和那个文件的预览画的是同一张卡片),显示成中文名的名字(下标是那个文件里的,不是页面上这份文档的)
|
||||
*/
|
||||
export interface DefinitionPeek {
|
||||
path: string
|
||||
line: number
|
||||
block: BlockView
|
||||
names: ShownName[]
|
||||
}
|
||||
|
||||
export interface DocumentMessage {
|
||||
type: 'document'
|
||||
version: number
|
||||
@@ -400,6 +411,8 @@ export interface DocumentDiff {
|
||||
/**
|
||||
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
|
||||
* definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义
|
||||
* definitionPeeked:id 那次 peekDefinition / peekLink 的回答。没有 peek(查不到定义、语言没有代码阅读、没有块盖住定义处、指着的就是定义本身)就不弹浮窗
|
||||
* hoverDelay:VS Code 的 editor.hover.delay / editor.hover.hidingDelay(毫秒),鼠标停多久弹定义卡片、移开后多久收;ready 后来一次,设置改了再来
|
||||
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
|
||||
* filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
|
||||
* qa:这份文件的问答;ready 后来一次,问答、正在答的问题变了再来
|
||||
@@ -420,6 +433,8 @@ export type ExtensionToWebview
|
||||
= | DocumentMessage
|
||||
| ({ type: 'reveal' } & Reveal)
|
||||
| { type: 'definitionProbed', id: number, found: boolean }
|
||||
| { type: 'definitionPeeked', id: number, peek?: DefinitionPeek }
|
||||
| { type: 'hoverDelay', delay: number, hidingDelay: number }
|
||||
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
|
||||
| { type: 'moveOccurrence', step: 1 | -1 }
|
||||
| { type: 'displayNames', version: number, names: ShownName[] }
|
||||
@@ -443,6 +458,8 @@ export type ExtensionToWebview
|
||||
* probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问 offset 处有没有定义;id 标出是哪一次问,回答带回来;
|
||||
* version 是这份下标所属的文档版本
|
||||
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
|
||||
* peekDefinition:鼠标停在带下标的名字上,要 offset 处名字的定义卡片(几处定义取第一处);id、version 同 probeDefinition
|
||||
* peekLink:鼠标停在注释里指到符号的链接上(带 #符号 的仓库链接、不带路径的引用),要那个符号的定义卡片;path、symbol 同 openRepoLink
|
||||
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
|
||||
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
|
||||
* setOnlyChanges:打开 / 关掉「只看改动」;同样等扩展端发回 filter 再按它显示
|
||||
@@ -470,6 +487,8 @@ export type WebviewToExtension
|
||||
| { type: 'visibleLine', line: number }
|
||||
| { type: 'probeDefinition', id: number, version: number, offset: number }
|
||||
| { type: 'goToDefinition', version: number, offset: number }
|
||||
| { type: 'peekDefinition', id: number, version: number, offset: number }
|
||||
| { type: 'peekLink', id: number, path?: string, symbol: string }
|
||||
| { type: 'findOccurrences', id: number, version: number, offset: number }
|
||||
| { type: 'ask', askId: string, anchor: string, question: string, images: AskImage[] }
|
||||
| { type: 'stopAsk', askId: string }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { DefinitionLinks } from './definitionLinks'
|
||||
import { documentNameAt } from './definitionPeek'
|
||||
import { onMessage, postMessage } from './vscode'
|
||||
|
||||
/** 跳到定义用的修饰键,按平台习惯:macOS 是 ⌘,其余是 Ctrl。 */
|
||||
@@ -9,11 +10,6 @@ function modifierHeld(event: MouseEvent | KeyboardEvent): boolean {
|
||||
return IS_MAC ? event.metaKey : event.ctrlKey
|
||||
}
|
||||
|
||||
/** 事件落在的那个带源码下标的名字(SegmentText 渲染的 data-offset)。 */
|
||||
function nameAt(target: EventTarget | null): HTMLElement | null {
|
||||
return target instanceof Element ? target.closest<HTMLElement>('[data-offset]') : null
|
||||
}
|
||||
|
||||
function offsetOf(el: HTMLElement | null): number | undefined {
|
||||
return el?.dataset.offset === undefined ? undefined : Number(el.dataset.offset)
|
||||
}
|
||||
@@ -27,7 +23,7 @@ class Pointer {
|
||||
constructor(private readonly links: DefinitionLinks) {}
|
||||
|
||||
move(target: EventTarget | null, held: boolean): void {
|
||||
this.hovered = nameAt(target)
|
||||
this.hovered = documentNameAt(target)
|
||||
this.held = held
|
||||
this.sync()
|
||||
}
|
||||
@@ -73,7 +69,7 @@ export function useDefinitionLinks(version: number | undefined): void {
|
||||
const onBlur = (): void => pointer.hold(false)
|
||||
// ⌘ / Ctrl + 点击名字归跳转:拦下这次点击,不让它再触发块上的编辑切换、折叠
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
if (!links.click(modifierHeld(event) ? offsetOf(nameAt(event.target)) : undefined))
|
||||
if (!links.click(modifierHeld(event) ? offsetOf(documentNameAt(event.target)) : undefined))
|
||||
return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { PeekTarget, ShownPeek } from './definitionPeek'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { DefinitionPeeks, documentNameAt, inPeek, linkPeekSource } from './definitionPeek'
|
||||
import { onMessage, postMessage } from './vscode'
|
||||
|
||||
/** 指针下能弹定义卡片的一处:注释里指到符号的链接,或页面上这份文档带下标的名字;浮窗里的不算。 */
|
||||
function peekTargetAt(target: EventTarget | null): PeekTarget<HTMLElement> | null {
|
||||
if (!(target instanceof Element) || inPeek(target))
|
||||
return null
|
||||
const link = target.closest<HTMLAnchorElement>('.cr-markdown a')
|
||||
if (link) {
|
||||
const source = linkPeekSource(link.getAttribute('href') ?? '')
|
||||
return source ? { anchor: link, source } : null
|
||||
}
|
||||
const name = documentNameAt(target)
|
||||
return name?.dataset.offset === undefined ? null : { anchor: name, source: { offset: Number(name.dataset.offset) } }
|
||||
}
|
||||
|
||||
function isInPeek(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && inPeek(target)
|
||||
}
|
||||
|
||||
/**
|
||||
* 和 VS Code 编辑器里的悬停一样:鼠标停在带下标的名字、注释里指到符号的链接上,过一会儿(editor.hover.delay)弹出定义所在那块的卡片,
|
||||
* 移开名字和浮窗过一会儿(editor.hover.hidingDelay)收起,Esc、滚动、按下鼠标立刻收(见 DefinitionPeeks)。
|
||||
* version 是当前显示的文档版本,名字的下标属于它。返回弹着的卡片和点标题时的跳转。
|
||||
*/
|
||||
export function useDefinitionPeek(version: number | undefined): { shown: ShownPeek<HTMLElement> | undefined, jump: () => void } {
|
||||
const [shown, setShown] = useState<ShownPeek<HTMLElement> | undefined>()
|
||||
const [peeks] = useState(() => new DefinitionPeeks<HTMLElement>(postMessage, setShown))
|
||||
|
||||
useEffect(() => {
|
||||
peeks.setVersion(version)
|
||||
}, [peeks, version])
|
||||
|
||||
useEffect(() => {
|
||||
const onOver = (event: PointerEvent): void => {
|
||||
if (isInPeek(event.target))
|
||||
peeks.enterPopup()
|
||||
else
|
||||
peeks.point(peekTargetAt(event.target))
|
||||
}
|
||||
const onOut = (event: PointerEvent): void => {
|
||||
if (event.relatedTarget === null)
|
||||
peeks.point(null)
|
||||
}
|
||||
const onDown = (event: PointerEvent): void => {
|
||||
if (!isInPeek(event.target))
|
||||
peeks.dismiss()
|
||||
}
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape')
|
||||
peeks.dismiss()
|
||||
}
|
||||
// 捕获阶段:页面和页面里能滚的框滚动都收;浮窗自己里面滚不收
|
||||
const onScroll = (event: Event): void => {
|
||||
if (!isInPeek(event.target))
|
||||
peeks.dismiss()
|
||||
}
|
||||
// hoverDelay 在页面 ready 后、第一份文档之前就来,挂在页面根上的 App 一开始就收
|
||||
const off = onMessage((msg) => {
|
||||
if (msg.type === 'hoverDelay')
|
||||
peeks.setDelays(msg.delay, msg.hidingDelay)
|
||||
else if (msg.type === 'definitionPeeked')
|
||||
peeks.answer(msg.id, msg.peek)
|
||||
else if (msg.type === 'reveal')
|
||||
peeks.dismiss()
|
||||
})
|
||||
document.addEventListener('pointerover', onOver)
|
||||
document.addEventListener('pointerout', onOut)
|
||||
document.addEventListener('pointerdown', onDown, true)
|
||||
window.addEventListener('keydown', onKey)
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
return () => {
|
||||
off()
|
||||
document.removeEventListener('pointerover', onOver)
|
||||
document.removeEventListener('pointerout', onOut)
|
||||
document.removeEventListener('pointerdown', onDown, true)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
peeks.dispose()
|
||||
}
|
||||
}, [peeks])
|
||||
|
||||
return { shown, jump: () => peeks.jump() }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Click, Highlight, ScrollPosition } from './occurrences'
|
||||
import type { BlockView, Occurrence } from './types'
|
||||
import { createContext, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { documentNameAt, PEEK_ATTRIBUTE } from './definitionPeek'
|
||||
import { clickAction, KeyScroll, OccurrenceHighlight, occurrenceJumpKey, OccurrenceNavigation } from './occurrences'
|
||||
import { occurrencePlan } from './reveal'
|
||||
import { showInViewport } from './useReveal'
|
||||
@@ -9,8 +10,8 @@ import { onMessage, postMessage } from './vscode'
|
||||
/** 眼下高亮着的名字,SegmentText 按它给各处出现上底色、给当前那一处描边。 */
|
||||
export const HighlightContext = createContext<Highlight | undefined>(undefined)
|
||||
|
||||
/** 自己管点击的东西:点在上面(名字以外的地方)高亮不动 */
|
||||
const CONTROLS = 'a, button, input, select, textarea, [role="listbox"], [role="dialog"], .cr-filter-bar, .cr-prop-label'
|
||||
/** 自己管点击的东西:点在上面(名字以外的地方)高亮不动。定义卡片浮窗里的名字是别的文件的,点了也不动 */
|
||||
const CONTROLS = `a, button, input, select, textarea, [role="listbox"], [role="dialog"], .cr-filter-bar, .cr-prop-label, [${PEEK_ATTRIBUTE}]`
|
||||
|
||||
/** 不收文字的输入框:焦点在它们上面时方向键不归它们移光标 */
|
||||
const NON_TEXT_INPUTS = new Set(['checkbox', 'radio', 'button', 'submit', 'reset'])
|
||||
@@ -28,7 +29,7 @@ export interface OccurrencePage {
|
||||
|
||||
function clickOf(event: MouseEvent): Click {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
const name = target?.closest<HTMLElement>('[data-offset]')
|
||||
const name = documentNameAt(target)
|
||||
const selection = window.getSelection()
|
||||
return {
|
||||
modified: event.metaKey || event.ctrlKey || event.altKey || event.shiftKey,
|
||||
|
||||
Reference in new issue
Block a user