diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index f3f9ee5..c830e18 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -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 默认值参数;预筛和各方言的文件。 diff --git a/vscode/src/codeReader/editor/definitionPeek.test.ts b/vscode/src/codeReader/editor/definitionPeek.test.ts new file mode 100644 index 0000000..c26fc13 --- /dev/null +++ b/vscode/src/codeReader/editor/definitionPeek.test.ts @@ -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>> { + const registry = new CodeReaderRegistry({ get: () => undefined, update: async () => {} } as Pick) + 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([]) + }) +}) diff --git a/vscode/src/codeReader/editor/definitionPeek.ts b/vscode/src/codeReader/editor/definitionPeek.ts new file mode 100644 index 0000000..fa029ed --- /dev/null +++ b/vscode/src/codeReader/editor/definitionPeek.ts @@ -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 })) +} diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 32400d6..f768a9d 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -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 } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index b641138..1dafa91 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -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('delay', 300), hidingDelay: hover.get('hidingDelay', 300) }) + } + + /** + * 回答 webview 第 id 次要定义卡片:offset 处名字的第一处定义所在那块(见 peekAt);指着的名字就是定义本身时没有。 + * 下标属于别的文档版本时不答,webview 换了文档版本会收起浮窗 + */ + private async peekDefinition(id: number, version: number, offset: number): Promise { + 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 { + 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 { + 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 { 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 { + 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 { const found = findRepoSymbol(await this.symbolNodes(uri.toString()), symbol) diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index d07ab33..968b7f0 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -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 { ? :
所有块都被筛选隐藏了。
} + ) diff --git a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx index 2c5b9bc..7a53360 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx @@ -53,21 +53,23 @@ const CARD: BlockView = { function render(folds: Record): 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() diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index d33d111..f01c4f7 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -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 + /** 刚点了「编辑」、要把光标放进去的块 */ + focusId: string | null + onFocused: () => void + onToggleEdit: (id: string) => void + /** 按注释规范重写的过程,按块的记号。只在这一页上 */ + rewrites: ReadonlyMap + onRewriteComment: (anchor: string) => void + /** 叫停这块正在进行的重写,不写文件 */ + onStopRewrite: (anchor: string) => void +} + +export interface BlockTreeState { + /** 能编辑(页面上的块树);定义卡片浮窗里没有 */ + edit?: EditTree /** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */ collapsed: ReadonlySet /** 按筛选不画的块(连同子孙),见 applyFilter */ @@ -31,18 +49,9 @@ export interface BlockTreeState { covered: ReadonlyMap /** 种类叫什么(种类图标的悬停提示和 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) => void - /** 按注释规范重写的过程,按块的记号。只在这一页上 */ - rewrites: ReadonlyMap - 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 {tree.qa && } - + {tree.edit && } {hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && ( )} @@ -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 tree.onStopRewrite(anchor)} /> + return 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 }): : }
- {editing + {edit && editing ? ( tree.onToggleEdit(block.id)} + version={edit.version} + autoFocus={edit.focusId === block.id} + onAutoFocused={edit.onFocused} + onExit={() => edit.onToggleEdit(block.id)} /> ) : } @@ -285,14 +295,16 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): 已被筛选隐藏,临时显示 )} - 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 && ( + 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} + /> + )}
) } diff --git a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx new file mode 100644 index 0000000..ed74592 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.test.tsx @@ -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 { + 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), + names: [{ offset: 1200, length: NAME.length, name: '配对设备仓库', definition: true }], +} + +describe('定义卡片浮窗里的卡片', () => { + const html = renderToStaticMarkup() + + 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(`${NAME}`) + }) + + it('注释里不带路径的引用不成链接:它按注释所在的文件找,页面只替自己这份文件问', () => { + expect(html).not.toContain('symbol:DesktopOnly') + expect(html).toContain('[DesktopOnly]') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx new file mode 100644 index 0000000..e3b1472 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx @@ -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 = new Set() +const NOT_COVERED: ReadonlyMap = new Map() + +/** + * 浮窗里的那张卡片:只读的块树(不编辑、不问 AI、不重写注释、不标差异、不筛选),折叠展开只记在浮窗里,默认同页面(类、函数收起子块)。 + * 名字的下标是那个文件的:中文名换成卡片带来的,不画页面上的高亮;Markdown 不认不带路径的引用。 + */ +export function PeekTree({ peek }: { peek: DefinitionPeek }): ReactElement { + const [folds, setFolds] = useState>({}) + 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 ( + + + + + + + + ) +} + +/** + * 定义卡片浮窗:标题一行「相对路径:行号」,点了跳到定义(同 ⌘ / Ctrl + 点击);下面是卡片,太大时在浮窗里滚。 + * 外框用 VS Code 悬停框的颜色;portal 到 body,先在左上角量出大小,再落到名字下方或上方(placePeek)。 + */ +export function DefinitionPeekPopup({ shown, onJump }: { shown: ShownPeek | undefined, onJump: () => void }): ReactElement | null { + const popupRef = useRef(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( +
+ +
+ +
+
, + document.body, + ) +} diff --git a/vscode/webview-ui/src/codeReader/TextPreview.tsx b/vscode/webview-ui/src/codeReader/TextPreview.tsx index cdbbb7e..9fda593 100644 --- a/vscode/webview-ui/src/codeReader/TextPreview.tsx +++ b/vscode/webview-ui/src/codeReader/TextPreview.tsx @@ -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>('comment') + /** 预览里的一段文字:着色的词按源码排版;Markdown 说明渲染成段落、列表、代码块。 */ export function TextPreview({ text, className }: { text: PreviewText, className?: string }): ReactElement { + const kind = useContext(MarkdownKindContext) if ('markdown' in text) - return
+ return
return (

diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 16b1430..64a67cf 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -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); diff --git a/vscode/webview-ui/src/codeReader/definitionPeek.test.ts b/vscode/webview-ui/src/codeReader/definitionPeek.test.ts new file mode 100644 index 0000000..ea43c21 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/definitionPeek.test.ts @@ -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 + sent: WebviewToExtension[] + shown: () => ShownPeek | undefined + asks: () => { id: number }[] +} { + const sent: WebviewToExtension[] = [] + let current: ShownPeek | undefined + const peeks = new DefinitionPeeks(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 { + 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(msg => sent.push(msg), () => {}) + peeks.setVersion(1) + peeks.point(name(5)) + vi.advanceTimersByTime(10_000) + expect(sent).toEqual([]) + }) +}) + +describe('定义卡片什么时候收', () => { + function showing(): ReturnType & { target: PeekTarget } { + 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 }) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/definitionPeek.ts b/vscode/webview-ui/src/codeReader/definitionPeek.ts new file mode 100644 index 0000000..760c8a9 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/definitionPeek.ts @@ -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 { + anchor: A + source: PeekSource +} + +/** 弹着的定义卡片:挂在哪个元素旁边、卡片,点标题时发什么(同 ⌘ / Ctrl + 点击这个名字、点这个链接)。 */ +export interface ShownPeek { + 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('[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, + size: Pick, + 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 { + private version: number | undefined + private delays: { delay: number, hidingDelay: number } | undefined + /** 指针下能弹卡片的那一处(浮窗外);没指着的、指针在浮窗里时是 null */ + private hovered: A | null = null + private inPopup = false + private askTimer: ReturnType | undefined + private hideTimer: ReturnType | undefined + private request = 0 + /** 最近一次发出去、还没答的询问 */ + private asked: { id: number, anchor: A, jump: WebviewToExtension } | undefined + private shown: ShownPeek | undefined + + constructor( + private readonly post: (msg: WebviewToExtension) => void, + private readonly onChange: (shown: ShownPeek | 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 | 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): void { + if (!this.delays) + return + this.askTimer = setTimeout(() => { + this.askTimer = undefined + this.ask(target) + }, this.delays.delay) + } + + private ask({ anchor, source }: PeekTarget): 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 | undefined): void { + if (shown === this.shown) + return + this.shown = shown + this.onChange(shown) + } +} diff --git a/vscode/webview-ui/src/codeReader/markdown.test.ts b/vscode/webview-ui/src/codeReader/markdown.test.ts index d847cc0..3bd3641 100644 --- a/vscode/webview-ui/src/codeReader/markdown.test.ts +++ b/vscode/webview-ui/src/codeReader/markdown.test.ts @@ -102,6 +102,11 @@ describe('不带路径的引用', () => { expect(renderMarkdown('见 [Agent]', 'answer')).toBe('

见 [Agent]

\n') expect(renderMarkdownDiff([{ text: '见 ' }, { text: '[Agent]', change: 'add' }])).toContain('href="symbol:Agent"') }) + + it('定义卡片浮窗里别的文件的注释不认(引用按注释所在的文件找);仓库路径链接照样是链接', () => { + expect(renderMarkdown('见 [Agent]', 'peek')).toBe('

见 [Agent]

\n') + expect(renderMarkdown('见 [Agent](app/models.py#Agent)', 'peek')).toContain('
') + }) }) describe('renderMarkdownDiff', () => { diff --git a/vscode/webview-ui/src/codeReader/markdown.ts b/vscode/webview-ui/src/codeReader/markdown.ts index 38644ad..58cc01d 100644 --- a/vscode/webview-ui/src/codeReader/markdown.ts +++ b/vscode/webview-ui/src/codeReader/markdown.ts @@ -82,8 +82,11 @@ md.core.ruler.push('link_title_tooltip', (state) => { const CACHE_SIZE = 500 const cache = new Map() -/** 渲染的是什么:注释(说明、注释块,认不带路径的引用),还是问 AI 的回答。 */ -export type MarkdownKind = 'comment' | 'answer' +/** + * 渲染的是什么:注释(说明、注释块,认不带路径的引用),定义卡片浮窗里的注释(不认不带路径的引用:引用按注释所在的文件找, + * 页面只替自己这份文件问),还是问 AI 的回答。 + */ +export type MarkdownKind = 'comment' | 'peek' | 'answer' function envOf(kind: MarkdownKind): RenderEnv { return { symbolLinks: kind === 'comment' } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 243add2..9eb26f9 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -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 } diff --git a/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts b/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts index b1fa7dd..d0a83cf 100644 --- a/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts +++ b/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts @@ -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('[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() diff --git a/vscode/webview-ui/src/codeReader/useDefinitionPeek.ts b/vscode/webview-ui/src/codeReader/useDefinitionPeek.ts new file mode 100644 index 0000000..85ce681 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/useDefinitionPeek.ts @@ -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 | null { + if (!(target instanceof Element) || inPeek(target)) + return null + const link = target.closest('.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 | undefined, jump: () => void } { + const [shown, setShown] = useState | undefined>() + const [peeks] = useState(() => new DefinitionPeeks(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() } +} diff --git a/vscode/webview-ui/src/codeReader/useOccurrences.ts b/vscode/webview-ui/src/codeReader/useOccurrences.ts index 9804ffe..2ca1a52 100644 --- a/vscode/webview-ui/src/codeReader/useOccurrences.ts +++ b/vscode/webview-ui/src/codeReader/useOccurrences.ts @@ -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(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('[data-offset]') + const name = documentNameAt(target) const selection = window.getSelection() return { modified: event.metaKey || event.ctrlKey || event.altKey || event.shiftKey,