From 49f253b80fb2bb3aea0dd26b7b9bfe3498e8759d Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 26 Sep 2026 23:47:43 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E7=9A=84=E5=90=8D=E5=AD=97=E9=AB=98=E4=BA=AE?= =?UTF-8?q?=E6=9B=B4=E6=98=BE=E7=9C=BC=EF=BC=88=E5=BD=93=E5=89=8D=E9=82=A3?= =?UTF-8?q?=E5=A4=84=E6=8F=8F=E6=A1=86=E3=80=81=E5=88=AB=E5=A4=84=E7=94=BB?= =?UTF-8?q?=E7=BB=86=E7=BA=BF=EF=BC=89=EF=BC=8C=E2=8C=98=E2=86=93=20/=20?= =?UTF-8?q?=E2=8C=98=E2=86=91=20=E5=9C=A8=E5=90=84=E5=A4=84=E4=B9=8B?= =?UTF-8?q?=E9=97=B4=E8=B7=B3=EF=BC=8C=E8=B7=B3=E5=88=B0=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E5=A4=84=E8=87=AA=E5=8A=A8=E5=B1=95=E5=BC=80=EF=BC=8C=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E6=A0=8F=E5=86=99=E7=AC=AC=E5=87=A0=E5=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 16 +- vscode/package.json | 32 ++++ vscode/src/codeReader/editor/messages.ts | 20 +++ .../editor/occurrenceNavigation.test.ts | 92 +++++++++++ .../codeReader/editor/occurrenceNavigation.ts | 85 ++++++++++ vscode/src/codeReader/editor/previewEditor.ts | 30 +++- vscode/src/codeReader/index.ts | 5 +- vscode/webview-ui/src/codeReader/App.tsx | 11 +- .../webview-ui/src/codeReader/FilterBar.tsx | 7 +- .../webview-ui/src/codeReader/SegmentText.tsx | 6 +- .../webview-ui/src/codeReader/codeReader.css | 9 +- .../src/codeReader/occurrences.test.ts | 114 +++++++++++++- .../webview-ui/src/codeReader/occurrences.ts | 146 ++++++++++++++++-- .../webview-ui/src/codeReader/reveal.test.ts | 42 ++++- vscode/webview-ui/src/codeReader/reveal.ts | 41 ++++- vscode/webview-ui/src/codeReader/types.ts | 19 +++ .../src/codeReader/useOccurrences.ts | 94 ++++++++++- vscode/webview-ui/src/codeReader/useReveal.ts | 15 +- 18 files changed, 731 insertions(+), 53 deletions(-) create mode 100644 vscode/src/codeReader/editor/occurrenceNavigation.test.ts create mode 100644 vscode/src/codeReader/editor/occurrenceNavigation.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index d5e2576..9ac2f5d 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -25,7 +25,7 @@ | 变量、字段的说明 | Python 模块、类主体里紧跟在一条赋值后面的字符串(属性 docstring,PEP 257 / Sphinx 的写法)是这个变量的说明:常量、变量块进属性表「说明」一行(紧跟名称),字段块排在一行译文下面;不再单独成块,它的源码行算在这一块里,「编辑」时和赋值一起改 | | 注释按 Markdown 显示 | 注释块和函数、类、变量、字段的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 | | 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | -| 高亮名字的各处出现 | 和普通编辑器一样单击名字就高亮它在这个文件里的各处出现,由语言扩展按语义认(同名的别的东西不算),颜色用 VS Code 的单词高亮色;看不见的(折叠、筛掉的)不自动展开,筛选栏上写「名字 · N 处」 | +| 高亮名字的各处出现 | 和普通编辑器一样单击名字就高亮它在这个文件里的各处出现,由语言扩展按语义认(同名的别的东西不算),颜色用 VS Code 的单词高亮色,再用 focusBorder 色补一笔(当前那一处描框、别的画细线);看不见的(折叠、筛掉的)点的时候不自动展开,筛选栏上写「名字 · 2/5」;⌘↓ / ⌘↑(可改绑的命令)在各处之间跳,跳到折叠着的地方会展开 | | 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释;不做新建 / 重命名 / 删除 | | 文件注释 | 读代码时给目录 / 文件随手记一句:存在各工作区文件夹的 `.spx/code-notes.json`,跟着仓库走;在 VS Code 里改名、挪位置、删除时注释跟着变 | | 问 AI | 块右上角「问 AI」,在块下面问 Claude Code(`claude -p`,只读,用输入框旁边选的 profile,记住上次选的,没选过用插件默认的)这块代码,回答逐字出来按 Markdown 显示、能追问(接着这块最近一次的会话,收起再展开、重启 VS Code 也接得上)、能停;输入框里能粘贴 / 拖进图片随问题发(存在 `.spx/code-qa/`,跟着问答删);问答存进各工作区文件夹的 `.spx/code-qa.json`,灰字挂在块下面免得重复问,理解了就删。问答按块的位置记号挂,记号只由和语言无关的块数据(种类、facts 里的名字、源码哈希)算出,文件别处改了照样认得;认不回来的不显示,留在文件里,代码改回来又挂回去。见「问 AI」 | @@ -201,7 +201,13 @@ - 能点的名字和「跳到定义」一样:带源码下标的段(属性表、一行译文、字典 / 对象各层子行的键和值,点分模块名每一节各算各的)。「编辑」状态的块里的源码、Markdown 里的名字不算,既不能点也不被高亮。 - 单击名字:问扩展端(`vscode.executeDocumentHighlights`,文档 uri + 名字在源码里的位置),由语言扩展(Pylance、TypeScript 语言服务……)按语义答,同名的别的东西(例如同名的参数)不算。页面上源码范围和回答里任何一处重叠的段都高亮,一个名字被拆成几段(`luffy_agent.core.db`)时每段各按自己的范围认。 - 颜色和编辑器里一样:写入(定义、赋值,`DocumentHighlightKind.Write`)用 `--vscode-editor-wordHighlightStrongBackground`,读取和按文字认出的用 `--vscode-editor-wordHighlightBackground`;主题给了 `wordHighlightStrongBorder` / `wordHighlightBorder`(高对比主题)才描边,描边画在字框里面不挤动排版。同一段碰上两种按写入画。 -- 看不见的出现(在折叠着的块、属性行、子行里,或块被筛掉)不自动展开;筛选栏右边、块数前面写「名字 · N 处」,名字取点中的那一段的文字,N 是回答里的处数(看不见的也算)。之后自己展开,里面的出现照样亮着。 +- 很多主题没配单词高亮色(例如 Gerry Oceanic),默认的灰底很淡,所以再用主题的 `--vscode-focusBorder` 补一笔(换什么主题都跟着主题走):当前那一处(点中的、用键盘跳到的)在底色之外描一圈 1px 框;别的各处在底色下面画一条 1px 细线(内阴影,不占 `text-decoration`,⌘ 指着名字时的链接下划线照旧)。写入的那处照样是强高亮底色。框和线都画在字框里面,不挤动排版。 +- 看不见的出现(在折叠着的块、属性行、子行里,或块被筛掉)点的时候不自动展开;筛选栏右边、块数前面写「名字 · 2/5」(当前那一处是照源码先后排的第几处 / 回答里共几处,看不见的也算),名字取点中的那一段的文字;点中的名字不在回答里(没有当前那一处)时写「名字 · N 处」。之后自己展开,里面的出现照样亮着。 +- 键盘跳:两条命令「代码阅读:跳到下一处高亮」(`superpowers.codeReader.nextOccurrence`)、「代码阅读:跳到上一处高亮」(`superpowers.codeReader.previousOccurrence`),默认键 mac 上 ⌘↓ / ⌘↑、别的系统 Ctrl+↓ / Ctrl+↑,`when` 是 `activeCustomEditorId == superpowers.codeReader.preview && superpowers.codeReader.hasOccurrences`;用户可以在「键盘快捷方式」里改绑,webview 里不写死处理哪个键。命令面板里只在代码阅读预览是活动编辑器时出现。 + - 上下文键 `superpowers.codeReader.hasOccurrences`:webview 报「眼下能不能用键盘跳」(有高亮、页面有焦点、焦点不在输入框 / 块的「编辑」里),变了才报;扩展端有一个预览能跳就设成真(同一时刻只有一个页面有焦点,几个预览的消息先后不定也对),页面失去焦点、高亮清掉、页面被销毁(标签页切走)、预览关掉都变假。 + - 命令发给报了能跳的那个预览;从命令面板运行时页面没焦点,发给活动的预览。 + - 跳:照源码先后,从当前那一处往后 / 往前找下一处页面上找得到的出现(显示着和它重叠的段),到头了从另一头接着找;在被筛掉的、正在编辑的、被正在编辑的祖先盖住的块里的跳过,Markdown 里的不算,筛选栏上的总数照旧。点中的名字不在回答里时从点中的地方数起。当前那一处换成它,展开把它折叠住的块、属性行和子行(同一处在页面上显示几次取页面上的第一处),再把它滚进视口(已经整个看得见就不滚,否则放到视口正中,同「跳到定义」)。 + - VS Code 把 webview 里按的每个键都原样转给工作台的快捷键系统(webview 外壳 `pre/index.html` 的 `handleInnerKeydown` 发 `did-keydown`,`webviewElement` 的 `handleKeyEvent` 在 webview 元素上重新派发键盘事件),所以焦点在预览里时快捷键照常生效;但它不回头告诉页面有没有命令接了这个键,而浏览器对这个键的默认动作(⌘↓ / ⌘↑ 滚到底 / 顶)在按下的当下就发生,事后拦不住,扩展也没有查快捷键的接口。所以页面只能事先知道命令绑的是哪些键:扩展端从 `package.json` 读这两条命令默认绑的键(各平台的都算),`ready` 后发给页面;页面只在「能跳」时拦下这些键的默认动作,没有高亮、焦点在输入框里时照常滚动 / 移光标。用户改绑成别的键时,新键照常生效,原来的 ⌘↓ / ⌘↑ 在有高亮时仍被拦着(不再滚动)。 - 和编辑器一样每回点都现问,回答不留到下次用(语言扩展刚启动时答的「没有」或按文字认的结果不会一直留着);只认最近一次询问的回答,迟到的丢掉。点另一个名字:回答到之前旧高亮留着,到了就换成新的;回答是空的(没装语言扩展、没答)就什么都不亮,不按文字自己找。 - 清掉(连同筛选栏上那句):点空白处或不是名字的字、按 Esc(输入框、下拉列表自己用掉的 Esc 不算)、文档换了版本、换了翻译。 - 不抢别的点击:⌘ / Ctrl(以及 Alt、Shift)+ 点击、双击的第二下、拖着选字都不动高亮(双击的第一下照样算一次单击,和编辑器一样);点在按钮、链接、输入框、下拉列表、筛选栏、属性名上照旧,高亮不动——折叠箭头、「编辑」、Markdown 里的链接都照旧;名字本身在按钮里(带下一层的子行的键)时照旧折叠,同时高亮。 @@ -989,6 +995,8 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | - `{ type: 'reveal', line, definition? }`:同上,发给已经开着的预览。页面正在重新加载(标签页切回来)时收不到,扩展端改成随它 `ready` 后的第一份文档带过去。 - `{ type: 'definitionProbed', id, found }`:回答第 `id` 次 `probeDefinition`。 - `{ type: 'occurrencesFound', id, occurrences: { start, end, kind: 'text' | 'read' | 'write' }[] }`:回答第 `id` 次 `findOccurrences`,`[start, end)` 是源码下标,`kind` 同 `DocumentHighlightKind`;语言扩展没答就是空数组。 + - `{ type: 'occurrenceKeys', keys: { key, ctrl, shift, alt, meta }[] }`:「跳到下一处 / 上一处高亮」默认绑的键(`key` 按 `KeyboardEvent.key`),`ready` 后发一次;页面能跳时拦下这些键在浏览器里的默认动作。 + - `{ type: 'moveOccurrence', step: 1 | -1 }`:两条命令发来,跳到下一处 / 上一处出现。 - `{ type: 'filter', excluded: Record<属性 id, 值 id[]> }`:整个工作区共用的块筛选。webview 发 `ready` 后先于第一份文档发一次(第一份文档按筛选画),之后任何一个预览改了都发给所有开着的预览(包括改的那个)。 - `{ type: 'qa', available, threads: { anchor, entries: { id, question, answer, createdAt, profile?, images? }[] }[], running: { askId, anchor, question, text, activity? }[] }`:这份文件的问答(见「问 AI」)。`ready` 后发一次,存的问答、正在答的问题变了再发给开着这份文件的所有预览;`available` 为假时文件不在工作区文件夹里,不给「问 AI」;`images` 是问题带的图片在预览里能直接用的地址(`webview.asWebviewUri`);`running` 让重新加载的页面接上还在答的问题(不带图片,接上的这一问不显示缩略图,答完回到历史里照常有)。 - `{ type: 'askProfiles', options: { id, label }[], selected }`:「问 AI」的 profile 下拉(`id` 是 profile 名,「默认」的 `id` 是 `''`、`label` 是「默认」)。`ready` 后发一次(每次现读 profile 目录),任何一个预览选了都发给所有开着的预览(包括选的那个)。 @@ -998,6 +1006,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | - `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。 - `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。这两条都只带下标:扩展端按发出去的文档认出规则标了引用的下标交给规则查(见「规则自己查定义」),其余问语言扩展。 - `{ type: 'findOccurrences', id, version, offset }`:单击带 `offset` 的名字,问它在这份文档里的各处出现;`id` 每问一次加一,`version` 不是当前文档版本就不答。 + - `{ type: 'occurrenceNavigation', available }`:眼下能不能用键盘在各处出现之间跳,页面加载后报一次、之后变了才报;扩展端按它设 `superpowers.codeReader.hasOccurrences`。 - `{ type: 'ready' }` - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 @@ -1081,6 +1090,7 @@ vscode/src/codeReader/ editor/definitionSources.ts 名字的定义去哪儿查:文档里规则标的引用问规则,其余问语言扩展 editor/definitionAction.ts 查到的定义 → 怎么跳(纯函数) editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights) + editor/occurrenceNavigation.ts 跳到下一处 / 上一处高亮:命令、上下文键、从 package.json 读默认绑的键 index.ts registerCodeReader(context): CodeReaderApi vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts) ``` @@ -1100,7 +1110,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / 相对路径的链接和图片不渲染;跳到定义不认 Markdown 里的名字。 - 行替换:多行块替换后缩进正确。 - 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。 -- 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。 +- 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。键盘跳(`occurrences.test.ts`、`reveal.test.ts`、`editor/occurrenceNavigation.test.ts`):回答照源码先后排、点中的那处是当前那一处、「2/5」和「N 处」;往后 / 往前、到头接着跳,找不到的跳过、总数照旧、一处都找不到不动,点中的不在回答里从点中的地方数起;只有当前那一处描框;能不能跳(高亮、焦点、输入框)第一次必报、之后变了才报,只在能跳时拦命令绑的键、修饰键要全对上;页面上找第一处显示着它的地方、要展开的块 / 属性行 / 子行、跳过筛掉 / 编辑 / 盖住的块、Markdown 不算;`package.json` 的两条命令、默认键、`when`、命令面板,默认键读成按键组合(各平台、去重、两下按的不认),上下文键有一个预览能跳就真、变了才设,命令发给能跳的预览、没有就发给活动的。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中;库规则按语言过滤、保持注册顺序,注销后不再给。 - 库规则(宿主):导入了库(`from sqlalchemy.orm import …`、`import sqlalchemy as sa`、`import os, sqlalchemy`、函数里的导入)才启用,`sqlalchemy_utils`、相对导入 `.sqlalchemy` 不算;先注册的规则优先;规则抛错、替身不合约定时记下来、接着问下一个、都没认照常显示,document 消息不带 error;编辑范围是整个类、没并进来的子块(夹在中间的注释、两个字段中间的方法)照源码顺序留下,卡片「编辑」写回整个类,能编辑的范围只在块和它的子孙之间重叠;筛选的种类计数、名字取规则的标签;`rules/` 下的非测试文件只 import contract。 - 同一行不在两处能改(webview `editing.test.ts`):卡片编辑时盖住它留下的子块(字段中间的方法连同主体),跳到被盖住的块改闪卡片;只编辑头部的块不盖住子块,一行写完的块盖住同一行的子块;点「编辑」时重叠的块退出编辑;任意点法下正在编辑、画出来的块两两不重叠。 diff --git a/vscode/package.json b/vscode/package.json index 0f2d3df..59c1dc0 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -160,6 +160,30 @@ "title": "编辑注释", "category": "代码阅读", "icon": "$(edit)" + }, + { + "command": "superpowers.codeReader.nextOccurrence", + "title": "跳到下一处高亮", + "category": "代码阅读" + }, + { + "command": "superpowers.codeReader.previousOccurrence", + "title": "跳到上一处高亮", + "category": "代码阅读" + } + ], + "keybindings": [ + { + "command": "superpowers.codeReader.nextOccurrence", + "key": "ctrl+down", + "mac": "cmd+down", + "when": "activeCustomEditorId == superpowers.codeReader.preview && superpowers.codeReader.hasOccurrences" + }, + { + "command": "superpowers.codeReader.previousOccurrence", + "key": "ctrl+up", + "mac": "cmd+up", + "when": "activeCustomEditorId == superpowers.codeReader.preview && superpowers.codeReader.hasOccurrences" } ], "submenus": [ @@ -296,6 +320,14 @@ "command": "superpowers.codeReader.showSource", "when": "activeCustomEditorId == superpowers.codeReader.preview" }, + { + "command": "superpowers.codeReader.nextOccurrence", + "when": "activeCustomEditorId == superpowers.codeReader.preview" + }, + { + "command": "superpowers.codeReader.previousOccurrence", + "when": "activeCustomEditorId == superpowers.codeReader.preview" + }, { "command": "superpowers.codeReader.copyFileName", "when": "false" diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 434304d..a5fad69 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -32,6 +32,19 @@ export interface Occurrence { kind: 'text' | 'read' | 'write' } +/** + * 一个按键组合,按 KeyboardEvent 的写法:key 是 KeyboardEvent.key('ArrowDown'),四个修饰键都要对上。 + * webview 收到的每个按键 VS Code 都会转给自己的快捷键系统,但不告诉 webview 有没有命令接了它; + * 浏览器对这个键的默认动作(⌘↓ 滚到底)又只能在按下的当下拦,所以 webview 要事先知道命令绑的是哪些键。 + */ +export interface KeyCombo { + key: string + ctrl: boolean + shift: boolean + alt: boolean + meta: boolean +} + export interface DocumentMessage { type: 'document' /** TextDocument.version:webview 靠它判断是不是新一份文档 */ @@ -85,12 +98,16 @@ export interface QaMessage { * askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览 * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 * answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停 + * occurrenceKeys:「跳到下一处 / 上一处高亮」这两条命令在 package.json 里默认绑的键;ready 后发一次,见 KeyCombo + * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览 */ export type ExtensionToWebview = | DocumentMessage | ({ type: 'reveal' } & Reveal) | { type: 'definitionProbed', id: number, found: boolean } | { type: 'occurrencesFound', id: number, occurrences: Occurrence[] } + | { type: 'occurrenceKeys', keys: KeyCombo[] } + | { type: 'moveOccurrence', step: 1 | -1 } | { type: 'filter', excluded: Excluded } | QaMessage | ({ type: 'askProfiles' } & ProfileChoice) @@ -110,6 +127,8 @@ export type ExtensionToWebview * stopAsk:叫停 askId 那个问题,这次不存 * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) * selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览 + * occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发; + * 扩展端按它设 when 子句用的上下文键 */ export type WebviewToExtension = | { type: 'ready' } @@ -124,3 +143,4 @@ export type WebviewToExtension | { type: 'stopAsk', askId: string } | { type: 'deleteQa', anchor: string, entryId: string } | { type: 'selectAskProfile', id: string } + | { type: 'occurrenceNavigation', available: boolean } diff --git a/vscode/src/codeReader/editor/occurrenceNavigation.test.ts b/vscode/src/codeReader/editor/occurrenceNavigation.test.ts new file mode 100644 index 0000000..55ac143 --- /dev/null +++ b/vscode/src/codeReader/editor/occurrenceNavigation.test.ts @@ -0,0 +1,92 @@ +import { readFileSync } from 'node:fs' +import * as path from 'node:path' +import { describe, expect, it } from 'vitest' +import { NavigationContext, navigationKeys, NEXT_OCCURRENCE, OCCURRENCES_CONTEXT, parseKey, PREVIOUS_OCCURRENCE } from './occurrenceNavigation' + +interface Manifest { + contributes: { + commands: { command: string, title: string, category?: string }[] + keybindings: { command: string, key: string, mac?: string, when?: string }[] + customEditors: { viewType: string }[] + menus: { commandPalette: { command: string, when: string }[] } + } +} + +const manifest = JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as Manifest +const { commands, keybindings, customEditors, menus } = manifest.contributes +const PREVIEW = customEditors[0].viewType + +describe('跳到下一处 / 上一处高亮:package.json 里的命令和快捷键', () => { + it('两条命令叫「代码阅读:跳到下一处高亮 / 跳到上一处高亮」', () => { + expect(commands.filter(c => c.command === NEXT_OCCURRENCE || c.command === PREVIOUS_OCCURRENCE).map(c => `${c.category}:${c.title}`)) + .toEqual(['代码阅读:跳到下一处高亮', '代码阅读:跳到上一处高亮']) + }) + + it('默认键:mac 上 ⌘↓ / ⌘↑,别的系统 Ctrl+↓ / Ctrl+↑;只在代码阅读预览是活动编辑器、里面有高亮时生效', () => { + const when = `activeCustomEditorId == ${PREVIEW} && ${OCCURRENCES_CONTEXT}` + expect(keybindings.filter(k => k.command === NEXT_OCCURRENCE || k.command === PREVIOUS_OCCURRENCE)).toEqual([ + { command: NEXT_OCCURRENCE, key: 'ctrl+down', mac: 'cmd+down', when }, + { command: PREVIOUS_OCCURRENCE, key: 'ctrl+up', mac: 'cmd+up', when }, + ]) + }) + + it('命令面板里只在代码阅读预览是活动编辑器时出现', () => { + expect(menus.commandPalette.filter(item => item.command === NEXT_OCCURRENCE || item.command === PREVIOUS_OCCURRENCE)).toEqual([ + { command: NEXT_OCCURRENCE, when: `activeCustomEditorId == ${PREVIEW}` }, + { command: PREVIOUS_OCCURRENCE, when: `activeCustomEditorId == ${PREVIEW}` }, + ]) + }) + + it('页面要拦的键取自这两条命令默认绑的键,各平台的都算、重复的去掉', () => { + expect(navigationKeys(manifest)).toEqual([ + { key: 'ArrowDown', ctrl: true, shift: false, alt: false, meta: false }, + { key: 'ArrowDown', ctrl: false, shift: false, alt: false, meta: true }, + { key: 'ArrowUp', ctrl: true, shift: false, alt: false, meta: false }, + { key: 'ArrowUp', ctrl: false, shift: false, alt: false, meta: true }, + ]) + expect(navigationKeys({ contributes: { keybindings: [ + { command: NEXT_OCCURRENCE, key: 'alt+j', linux: 'alt+j', win: 'ctrl+k ctrl+j' }, + { command: 'other', key: 'cmd+down' }, + ] } })).toEqual([{ key: 'j', ctrl: false, shift: false, alt: true, meta: false }]) + expect(navigationKeys(undefined)).toEqual([]) + }) +}) + +describe('快捷键写法 → 按键组合', () => { + it('修饰键、方向键、字母;cmd / meta / win 都是 meta', () => { + expect(parseKey('cmd+down')).toEqual({ key: 'ArrowDown', ctrl: false, shift: false, alt: false, meta: true }) + expect(parseKey('Ctrl+Shift+PageUp')).toEqual({ key: 'PageUp', ctrl: true, shift: true, alt: false, meta: false }) + expect(parseKey('win+k')).toEqual({ key: 'k', ctrl: false, shift: false, alt: false, meta: true }) + }) + + it('分两下按的、认不得的键名、认不得的修饰键没有', () => { + expect(parseKey('ctrl+k ctrl+x')).toBeUndefined() + expect(parseKey('ctrl+f5')).toBeUndefined() + expect(parseKey('hyper+down')).toBeUndefined() + expect(parseKey('ctrl+')).toBeUndefined() + }) +}) + +describe('上下文键跟着各预览报来的状态走', () => { + it('有一个预览能跳就是真,变了才设;预览关掉(报不能跳)时跟着变', () => { + const set: boolean[] = [] + const context = new NavigationContext(value => set.push(value)) + context.update('a', false) + context.update('a', true) + context.update('a', true) + // 焦点从 a 挪到 b:两边的消息先后不定 + context.update('b', true) + context.update('a', false) + context.update('b', false) + expect(set).toEqual([true, false]) + }) + + it('命令发给报了能跳的预览;都没报(从命令面板运行)时发给活动的预览', () => { + const context = new NavigationContext(() => {}) + const previews = ['a', 'b', 'c'] + expect(context.target(previews, id => id === 'c')).toBe('c') + context.update('b', true) + expect(context.target(previews, id => id === 'c')).toBe('b') + expect(context.target([], () => true)).toBeUndefined() + }) +}) diff --git a/vscode/src/codeReader/editor/occurrenceNavigation.ts b/vscode/src/codeReader/editor/occurrenceNavigation.ts new file mode 100644 index 0000000..c9e6004 --- /dev/null +++ b/vscode/src/codeReader/editor/occurrenceNavigation.ts @@ -0,0 +1,85 @@ +import type { KeyCombo } from './messages' + +/** 在高亮着的名字的各处出现之间跳:两条命令、快捷键的 when 子句用的上下文键。 */ + +export const NEXT_OCCURRENCE = 'superpowers.codeReader.nextOccurrence' +export const PREVIOUS_OCCURRENCE = 'superpowers.codeReader.previousOccurrence' +/** 活动的预览里能用键盘跳(有高亮、页面有焦点、焦点不在输入框里)。 */ +export const OCCURRENCES_CONTEXT = 'superpowers.codeReader.hasOccurrences' + +/** VS Code 快捷键写法里的键名 → KeyboardEvent.key;单个字母、数字照写。 */ +const KEY_NAMES: Record = { + up: 'ArrowUp', + down: 'ArrowDown', + left: 'ArrowLeft', + right: 'ArrowRight', + home: 'Home', + end: 'End', + pageup: 'PageUp', + pagedown: 'PageDown', +} + +/** `cmd+down` 这种一下按完的组合 → KeyCombo;`ctrl+k ctrl+x` 这种分两下按的、认不得的键名没有。 */ +export function parseKey(text: string): KeyCombo | undefined { + const parts = text.trim().toLowerCase().split('+') + if (parts.some(part => part === '' || /\s/.test(part))) + return undefined + const name = parts.pop() ?? '' + const key = KEY_NAMES[name] ?? (/^[a-z0-9]$/.test(name) ? name : undefined) + const modifiers = new Set(parts) + if (!key || [...modifiers].some(modifier => !['ctrl', 'shift', 'alt', 'cmd', 'meta', 'win'].includes(modifier))) + return undefined + return { + key, + ctrl: modifiers.has('ctrl'), + shift: modifiers.has('shift'), + alt: modifiers.has('alt'), + meta: modifiers.has('cmd') || modifiers.has('meta') || modifiers.has('win'), + } +} + +interface Keybinding { command?: unknown, key?: unknown, mac?: unknown, linux?: unknown, win?: unknown } + +/** + * package.json 给这两条命令默认绑的键,各平台的都算(扩展可能跑在远程机器上,看不出界面在哪个系统)。 + * 用户在「键盘快捷方式」里改绑的读不到:VS Code 没有给扩展查快捷键的接口。 + */ +export function navigationKeys(manifest: unknown): KeyCombo[] { + const bindings = (manifest as { contributes?: { keybindings?: Keybinding[] } } | undefined)?.contributes?.keybindings ?? [] + const combos = bindings + .filter(binding => binding.command === NEXT_OCCURRENCE || binding.command === PREVIOUS_OCCURRENCE) + .flatMap(binding => [binding.key, binding.mac, binding.linux, binding.win]) + .filter((text): text is string => typeof text === 'string') + .map(parseKey) + .filter((combo): combo is KeyCombo => combo !== undefined) + return combos.filter((combo, i) => combos.findIndex(other => JSON.stringify(other) === JSON.stringify(combo)) === i) +} + +/** + * 上下文键跟着各预览报来的状态走:有一个预览能跳就是真(同一时刻只有一个页面有焦点),变了才设。 + * 预览关掉、页面被销毁(标签页切走)时当它不能跳。 + */ +export class NavigationContext { + private readonly available = new Set() + private value = false + + constructor(private readonly set: (value: boolean) => void) {} + + update(owner: Owner, available: boolean): void { + if (available) + this.available.add(owner) + else + this.available.delete(owner) + const value = this.available.size > 0 + if (value === this.value) + return + this.value = value + this.set(value) + } + + /** 命令发给哪个预览:报了能跳的那个;从命令面板运行时页面没焦点,就发给活动的那个。 */ + target(owners: Iterable, active: (owner: Owner) => boolean): Owner | undefined { + const list = [...owners] + return list.find(owner => this.available.has(owner)) ?? list.find(active) + } +} diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 135e483..1f025d6 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -7,7 +7,7 @@ import type { CodeReaderRegistry } from '../registry' import type { TreeSitterRuntime } from '../runtime' import type { BlockFilter } from './blockFilter' import type { Definition, FoundLocation } from './definitions' -import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages' +import type { ExtensionToWebview, KeyCombo, Reveal, WebviewToExtension } from './messages' import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode' import { logger } from '../../logging/logger' import { webviewHtml, webviewUiRoot } from '../../webview/html' @@ -18,6 +18,7 @@ import { definitionAction } from './definitionAction' import { findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions' import { DefinitionSources } from './definitionSources' import { documentMessage } from './documentMessage' +import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation' import { occurrencesOf } from './occurrences' export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview' @@ -33,6 +34,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { private readonly previews = new Set() /** 按文档 uri:等着交给马上要打开的新预览的位置(从文本编辑器切过来时它顶部那一行,或跳到的定义) */ private readonly reveals = new Map() + /** 哪个预览眼下能用键盘在高亮的各处出现之间跳,跟着它设快捷键 when 子句用的上下文键 */ + readonly navigation = new NavigationContext(value => void commands.executeCommand('setContext', OCCURRENCES_CONTEXT, value)) constructor( private readonly extensionUri: Uri, @@ -43,6 +46,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { readonly resolveReference: (reference: RuleReference) => Promise, /** 问 AI:各预览共用,同一份文件开在几处时问答、正在答的问题都一样 */ readonly qa: CodeQa, + /** 「跳到下一处 / 上一处高亮」默认绑的键:页面在这些键上拦下浏览器自己的滚动 */ + readonly occurrenceKeys: readonly KeyCombo[], ) {} resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { @@ -56,10 +61,16 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { this.previews.add(preview) panel.onDidDispose(() => { this.previews.delete(preview) + this.navigation.update(preview, false) preview.dispose() }) } + /** 「跳到下一处 / 上一处高亮」:发给能跳的那个预览,从命令面板运行时发给活动的预览。 */ + moveOccurrence(step: 1 | -1): void { + this.navigation.target(this.previews, preview => preview.active)?.moveOccurrence(step) + } + /** 让已打开的预览按当前注册表重发;给了 languageId 就只刷这种语言的。 */ refresh(languageId?: string): void { for (const preview of this.previews) { @@ -130,8 +141,11 @@ class Preview { this.disposables = [ panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)), panel.onDidChangeViewState(() => { - if (!panel.visible) - this.ready = false + if (panel.visible) + return + // 页面被销毁了,高亮跟着没了 + this.ready = false + owner.navigation.update(this, false) }), workspace.onDidChangeTextDocument((e) => { if (e.document === document && e.contentChanges.length > 0) @@ -202,6 +216,12 @@ class Preview { this.post(reveal === undefined ? message : { ...message, reveal }) } + /** 跳到高亮着的名字的下一处 / 上一处出现;页面不在就算了(页面重新加载时高亮已经没了)。 */ + moveOccurrence(step: 1 | -1): void { + if (this.ready) + this.post({ type: 'moveOccurrence', step }) + } + /** 滚到 reveal:页面在就直接发,正在重新加载就等它发 ready 后随文档带过去。 */ reveal(reveal: Reveal): void { if (this.ready) @@ -262,6 +282,7 @@ class Preview { this.ready = true // 先于文档发:第一份文档按筛选画,不会先整份露出来再藏 this.post({ type: 'filter', excluded: this.filter.excluded }) + this.post({ type: 'occurrenceKeys', keys: [...this.owner.occurrenceKeys] }) this.postQa() void this.postProfiles() await this.push() @@ -299,6 +320,9 @@ class Preview { return case 'findOccurrences': await this.findOccurrences(msg.id, msg.version, msg.offset) + return + case 'occurrenceNavigation': + this.owner.navigation.update(this, msg.available) } } diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index acf260b..9194b24 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -3,6 +3,7 @@ import type { CodeReaderApi, RuleLocation, RuleReference } from './contract' import { commands, window, workspace } from 'vscode' import { logger } from '../logging/logger' import { BlockFilter } from './editor/blockFilter' +import { navigationKeys, NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation' import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor' import { showPreview, showSource } from './editor/switchEditor' import { registerCopyCommands } from './explorer/copyCommands' @@ -65,7 +66,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { }, report)) const resolve = (reference: RuleReference): Promise => resolveReference(reference, registry.libraryRules(), indexes, report) const qa = new CodeQa(context.globalState) - const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa) + const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, navigationKeys(context.extension.packageJSON)) const notes = new FileNotes() const files = new FileTreeProvider(OPEN_COMMAND, notes) @@ -87,6 +88,8 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { // 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签 commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)), commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => showSource(previews, args)), + commands.registerCommand(NEXT_OCCURRENCE, () => previews.moveOccurrence(1)), + commands.registerCommand(PREVIOUS_OCCURRENCE, () => previews.moveOccurrence(-1)), ) const api: CodeReaderApi = registry diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 4b191a8..4e03980 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react' import type { BlockTreeState } from './BlockTree' import type { DocumentMessage, Excluded, Reveal } from './types' +import type { OccurrencePage } from './useOccurrences' import type { ViewState } from './viewState' import { TriangleAlert } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -53,7 +54,15 @@ export function App(): ReactElement | null { useScrollPosition(doc) useReveal(doc, reveal, collapsed, filter.hidden, covered, show, expand, revealed) useDefinitionLinks(doc?.version) - const highlight = useOccurrences(doc?.version, doc?.translatorId) + const occurrencePage = useMemo((): OccurrencePage | undefined => doc + ? { + blocks: doc.blocks, + skip: id => filter.hidden.has(id) || covered.has(id) || view.editing.includes(id), + collapsed, + expand, + } + : undefined, [doc, filter, covered, view.editing, collapsed, expand]) + const highlight = useOccurrences(doc?.version, doc?.translatorId, occurrencePage) useEffect(() => { const off = onMessage((msg) => { diff --git a/vscode/webview-ui/src/codeReader/FilterBar.tsx b/vscode/webview-ui/src/codeReader/FilterBar.tsx index 94d142d..ed466f3 100644 --- a/vscode/webview-ui/src/codeReader/FilterBar.tsx +++ b/vscode/webview-ui/src/codeReader/FilterBar.tsx @@ -6,12 +6,13 @@ import { Check, ChevronDown, ListFilter } from 'lucide-react' import { useId, useLayoutEffect, useRef, useState } from 'react' import { SelectMenu } from '@/components/ui/select-menu' import { facetLabel, facetsOf, setChecked, toggleValue } from './filter' +import { occurrenceCount } from './occurrences' export interface FilterBarProps { attributes: FileAttribute[] excluded: Excluded view: FilterView - /** 眼下高亮着的名字:汇总前面写「名字 · N 处」,折叠、筛掉的块里的出现也算在 N 里 */ + /** 眼下高亮着的名字:汇总前面写「名字 · 2/5」(当前是第几处 / 共几处,见 occurrenceCount),折叠、筛掉的块里的出现也算在总数里 */ highlight: Highlight | undefined onChange: (excluded: Excluded) => void translators: TranslatorOption[] @@ -20,7 +21,7 @@ export interface FilterBarProps { } /** - * 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(高亮着名字时先是「名字 · N 处」,再是共 N 块 / 已隐藏 N 块 · 清除), + * 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(高亮着名字时先是「名字 · 2/5」,再是共 N 块 / 已隐藏 N 块 · 清除), * 多于一种翻译时最右边是翻译下拉框。内容和代码块同宽对齐,滚动时块从它下面过去。 */ export function FilterBar({ attributes, excluded, view, highlight, onChange, translators, translatorId, onSelectTranslator }: FilterBarProps): ReactElement { @@ -44,7 +45,7 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra <> {highlight.name} · - {`${highlight.occurrences.length} 处`} + {occurrenceCount(highlight)} )} diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index bff5077..04becfe 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -3,12 +3,13 @@ import type { ReactElement } from 'react' import type { GroupStyle, Inline, SegmentGroup } from './types' import { Repeat } from 'lucide-react' import { useContext } from 'react' -import { segmentOccurrence } from './occurrences' +import { isCurrent, segmentOccurrence } from './occurrences' import { HighlightContext } from './useOccurrences' /** * 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上, - * 跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色。框起来的一组词画成小框,框里的词同样处理。 + * 跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色,是当前那一处(点中的、用键盘跳到的)时 + * 再标 data-current。框起来的一组词画成小框,框里的词同样处理。 */ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement { const highlight = useContext(HighlightContext) @@ -21,6 +22,7 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement className={`cr-role-${piece.role}`} data-offset={piece.offset} data-occurrence={highlight && segmentOccurrence(piece, highlight.occurrences)} + data-current={highlight && isCurrent(piece, highlight) ? '' : undefined} > {piece.text} diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index a60913a..415e75b 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -292,10 +292,13 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(- /* * 单击名字高亮它在文件里的各处出现:颜色取 VS Code 编辑器的单词高亮,写入(定义、赋值)用强高亮色,读取和按文字认出的用普通高亮色; - * 主题给了边框色(高对比主题)才描边。筛选栏上「名字 · N 处」的名字也按普通高亮画。描边画在字框里面,不挤动排版 + * 主题给了边框色(高对比主题)才描边。很多主题没配单词高亮色,默认的灰底很淡,所以再用 focusBorder 色补一笔: + * 各处底下一条细线,当前那一处(点中的、用键盘跳到的)描一圈框。筛选栏上「名字 · 2/5」的名字也按普通高亮画。 + * 线和框都画在字框里面,不挤动排版;细线用内阴影画,不占 text-decoration(⌘ 指着名字时的链接下划线用它) */ [data-occurrence] { outline-offset: -1px; + box-shadow: inset 0 -1px 0 var(--vscode-focusBorder); } [data-occurrence="normal"] { background: var(--vscode-editor-wordHighlightBackground); @@ -305,6 +308,10 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(- background: var(--vscode-editor-wordHighlightStrongBackground); outline: 1px solid var(--vscode-editor-wordHighlightStrongBorder, transparent); } +[data-occurrence][data-current] { + outline: 1px solid var(--vscode-focusBorder); + box-shadow: none; +} .cr-occurrence-name { padding: 0 3px; color: var(--vscode-editor-foreground); diff --git a/vscode/webview-ui/src/codeReader/occurrences.test.ts b/vscode/webview-ui/src/codeReader/occurrences.test.ts index d7e2610..711122c 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.test.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.test.ts @@ -1,12 +1,12 @@ import type { Click, Highlight } from './occurrences' -import type { BlockView, Inline, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types' +import type { BlockView, Inline, KeyCombo, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types' import { describe, expect, it } from 'vitest' import { toBlockViews } from '../../../src/codeReader/blockView' import { createPythonAdapter } from '../../../src/codeReader/languages/python' import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh' import { DefinitionLinks } from './definitionLinks' -import { clickAction, OccurrenceHighlight, occurrenceStyle, segmentOccurrence } from './occurrences' +import { clickAction, isCurrent, matchesKey, occurrenceCount, OccurrenceHighlight, OccurrenceNavigation, occurrenceStyle, segmentOccurrence, stepIndex } from './occurrences' function name(text: string, offset: number): Segment { return { text, role: 'variable', offset } @@ -121,8 +121,8 @@ describe('occurrences:高亮的状态', () => { expect(asked()).toEqual([{ id: asked()[0].id, version: 1, offset: 20 }]) const occurrences = [at(20, 28, 'write'), at(60, 68)] occ.answer(asked()[0].id, occurrences) - expect(occ.highlight).toEqual({ name: 'ALPHABET', occurrences }) - expect(changes).toEqual([{ name: 'ALPHABET', occurrences }]) + expect(occ.highlight).toEqual({ name: 'ALPHABET', occurrences, current: 0 }) + expect(changes).toEqual([{ name: 'ALPHABET', occurrences, current: 0 }]) find(occ, 5, 'secrets') occ.answer(asked()[1].id, []) @@ -136,7 +136,7 @@ describe('occurrences:高亮的状态', () => { find(occ, 5, 'secrets') expect(occ.highlight?.name).toBe('ALPHABET') occ.answer(asked()[1].id, [at(5, 12), at(40, 47)]) - expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12), at(40, 47)] }) + expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12), at(40, 47)], current: 0 }) }) it('只认最近一次询问的回答,迟到的丢掉', () => { @@ -146,9 +146,9 @@ describe('occurrences:高亮的状态', () => { const [first, second] = asked() occ.answer(second.id, [at(5, 12)]) occ.answer(first.id, [at(20, 28)]) - expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12)] }) + expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12)], current: 0 }) occ.answer(second.id, [at(99, 100)]) - expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12)] }) + expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12)], current: 0 }) }) it('回答不留到下次用:答过「没有」后再点同一个名字照样再问', () => { @@ -227,3 +227,103 @@ function allSegments(views: BlockView[]): Segment[] { ...allSegments(view.children), ]) } + +const ANYWHERE = (): boolean => true + +describe('occurrences:用键盘在各处出现之间跳', () => { + it('回答照源码先后排好,点中的那处是当前那一处;筛选栏写「第几处/共几处」,点中的不在回答里时写「N 处」', () => { + const { occ, asked } = setup() + find(occ, 40, 'ALPHABET') + occ.answer(asked()[0].id, [at(90, 98), at(40, 48), at(10, 18, 'write')]) + expect(occ.highlight).toEqual({ name: 'ALPHABET', occurrences: [at(10, 18, 'write'), at(40, 48), at(90, 98)], current: 1 }) + expect(occurrenceCount(occ.highlight!)).toBe('2/3') + + find(occ, 60, 'ALPHABET') + occ.answer(asked()[1].id, [at(10, 18), at(40, 48), at(90, 98)]) + expect(occ.highlight?.current).toBeUndefined() + expect(occurrenceCount(occ.highlight!)).toBe('3 处') + }) + + it('往后、往前按源码先后跳,到头了从另一头接着跳;当前那一处跟着换', () => { + const { occ, asked, changes } = setup() + find(occ, 40, 'ALPHABET') + occ.answer(asked()[0].id, [at(10, 18), at(40, 48), at(90, 98)]) + expect(occ.move(1, ANYWHERE)).toEqual(at(90, 98)) + expect(occ.highlight?.current).toBe(2) + expect(occ.move(1, ANYWHERE)).toEqual(at(10, 18)) + expect(occ.highlight?.current).toBe(0) + expect(occ.move(-1, ANYWHERE)).toEqual(at(90, 98)) + expect(occ.move(-1, ANYWHERE)).toEqual(at(40, 48)) + expect(occurrenceCount(occ.highlight!)).toBe('2/3') + expect(changes.at(-1)).toEqual({ name: 'ALPHABET', occurrences: [at(10, 18), at(40, 48), at(90, 98)], current: 1 }) + }) + + it('页面上找不到的(在被筛掉的块里)跳过,总数照旧;一处都找不到、没有高亮时不动', () => { + const { occ, asked, changes } = setup() + expect(occ.move(1, ANYWHERE)).toBeUndefined() + find(occ, 10, 'ALPHABET') + occ.answer(asked()[0].id, [at(10, 18), at(40, 48), at(90, 98)]) + const shown = (occurrence: Occurrence): boolean => occurrence.start !== 40 + expect(occ.move(1, shown)).toEqual(at(90, 98)) + expect(occ.move(1, shown)).toEqual(at(10, 18)) + expect(occ.move(-1, shown)).toEqual(at(90, 98)) + expect(occurrenceCount(occ.highlight!)).toBe('3/3') + const before = changes.length + expect(occ.move(1, () => false)).toBeUndefined() + expect(changes).toHaveLength(before) + expect(occ.highlight?.current).toBe(2) + }) + + it('点中的名字不在回答里:从点中的地方往后是它后面第一处,往前是它前面最后一处', () => { + const occurrences = [at(10, 18), at(40, 48), at(90, 98)] + expect(stepIndex(occurrences, undefined, 60, 1, ANYWHERE)).toBe(2) + expect(stepIndex(occurrences, undefined, 60, -1, ANYWHERE)).toBe(1) + expect(stepIndex(occurrences, undefined, 5, -1, ANYWHERE)).toBe(2) + expect(stepIndex(occurrences, undefined, 95, 1, ANYWHERE)).toBe(0) + expect(stepIndex(occurrences, 0, 0, -1, ANYWHERE)).toBe(2) + expect(stepIndex(occurrences, 1, 0, 1, occurrence => occurrence.start === 40)).toBe(1) + expect(stepIndex([], undefined, 0, 1, ANYWHERE)).toBeUndefined() + }) + + it('当前那一处:和它重叠的段描边,别的出现、没下标的段不描', () => { + const highlight = { name: 'ALPHABET', occurrences: [at(10, 18), at(40, 48)], current: 1 } + expect(isCurrent(name('ALPHABET', 40), highlight)).toBe(true) + expect(isCurrent(name('ALPHABET', 10), highlight)).toBe(false) + expect(isCurrent({ text: 'ALPHABET', role: 'plain' }, highlight)).toBe(false) + expect(isCurrent(name('ALPHABET', 40), { ...highlight, current: undefined })).toBe(false) + }) + + it('能不能用键盘跳:有高亮、页面有焦点、焦点不在输入框里;第一次都告诉扩展端,之后变了才告诉', () => { + const sent: WebviewToExtension[] = [] + const navigation = new OccurrenceNavigation(msg => sent.push(msg), { focused: true, typing: false }) + const reports = (): boolean[] => sent.flatMap(msg => msg.type === 'occurrenceNavigation' ? [msg.available] : []) + navigation.update({}) + expect(reports()).toEqual([false]) + navigation.update({ highlighted: true }) + expect(navigation.available).toBe(true) + navigation.update({ highlighted: true }) + navigation.update({ typing: true }) + navigation.update({ typing: false }) + navigation.update({ focused: false }) + navigation.update({ highlighted: false }) + navigation.update({ focused: true }) + expect(reports()).toEqual([false, true, false, true, false]) + }) + + it('命令绑的键:能跳时拦下浏览器的默认动作(⌘↓ 滚到底),不能跳、别的键、修饰键对不上都不拦', () => { + const keys: KeyCombo[] = [{ key: 'ArrowDown', ctrl: false, shift: false, alt: false, meta: true }, { key: 'j', ctrl: true, shift: false, alt: false, meta: false }] + const press = (key: string, modifiers: Partial> = {}): Pick => + ({ key, ctrlKey: false, shiftKey: false, altKey: false, metaKey: false, ...modifiers }) + const navigation = new OccurrenceNavigation(() => {}, { focused: true, typing: false }) + expect(navigation.blocks(press('ArrowDown', { metaKey: true }), keys)).toBe(false) + navigation.update({ highlighted: true }) + expect(navigation.blocks(press('ArrowDown', { metaKey: true }), keys)).toBe(true) + expect(navigation.blocks(press('J', { ctrlKey: true }), keys)).toBe(true) + expect(navigation.blocks(press('ArrowDown', { metaKey: true, shiftKey: true }), keys)).toBe(false) + expect(navigation.blocks(press('ArrowDown'), keys)).toBe(false) + expect(navigation.blocks(press('ArrowUp', { metaKey: true }), keys)).toBe(false) + navigation.update({ typing: true }) + expect(navigation.blocks(press('ArrowDown', { metaKey: true }), keys)).toBe(false) + expect(matchesKey(press('ArrowDown', { metaKey: true }), keys[0])).toBe(true) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/occurrences.ts b/vscode/webview-ui/src/codeReader/occurrences.ts index 92e52f1..baa724c 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.ts @@ -1,4 +1,4 @@ -import type { Occurrence, Segment, WebviewToExtension } from './types' +import type { Inline, KeyCombo, Occurrence, Segment, WebviewToExtension } from './types' /** 高亮画成哪种底色,和 VS Code 编辑器一样:写入(定义、赋值)用强高亮色,读取和按文字认出的用普通高亮色。 */ export type OccurrenceStyle = 'strong' | 'normal' @@ -12,13 +12,9 @@ export function occurrenceStyle(kind: Occurrence['kind']): OccurrenceStyle { * 同时碰上写入和读取的按写入画。没有下标的(译词、提示词、编辑状态的源码)不画。 */ export function segmentOccurrence(segment: Segment, occurrences: readonly Occurrence[]): OccurrenceStyle | undefined { - const { offset } = segment - if (offset === undefined) - return undefined - const end = offset + segment.text.length let style: OccurrenceStyle | undefined for (const occurrence of occurrences) { - if (occurrence.start < end && offset < occurrence.end) { + if (overlaps(segment, occurrence)) { style = occurrenceStyle(occurrence.kind) if (style === 'strong') return style @@ -27,6 +23,17 @@ export function segmentOccurrence(segment: Segment, occurrences: readonly Occurr return style } +/** 段照抄自源码、在源码里占的 [offset, offset + 字数) 和这处出现重叠;没有下标的段不算。 */ +export function overlaps(segment: Segment, occurrence: Occurrence): boolean { + const { offset } = segment + return offset !== undefined && occurrence.start < offset + segment.text.length && offset < occurrence.end +} + +/** 文字里(连框里)有段和这处出现重叠。 */ +export function showsOccurrence(pieces: readonly Inline[], occurrence: Occurrence): boolean { + return pieces.some(piece => 'role' in piece ? overlaps(piece, occurrence) : showsOccurrence(piece.segments, occurrence)) +} + /** 一次点击的情况,useOccurrences 从事件和页面上读出来。 */ export interface Click { /** 按着 ⌘ / Ctrl / Alt / Shift:不是单纯的单击(⌘ / Ctrl + 点击归跳到定义) */ @@ -62,20 +69,68 @@ export function clickAction(click: Click): ClickAction { /** 眼下高亮着的名字:点中时它的文字(筛选栏上显示)和它在文档里的各处出现。 */ export interface Highlight { name: string + /** 照源码先后排 */ occurrences: readonly Occurrence[] + /** 当前那一处(点中的、用键盘跳到的)在 occurrences 里的序号;点中的名字不在回答里时没有 */ + current?: number +} + +/** 段是不是当前那一处出现(点中的、用键盘跳到的)。 */ +export function isCurrent(segment: Segment, highlight: Highlight): boolean { + const current = highlight.current === undefined ? undefined : highlight.occurrences[highlight.current] + return current !== undefined && overlaps(segment, current) +} + +/** 筛选栏上名字后面写的:有当前那一处时「第几处/共几处」(2/5),没有时「N 处」;看不见的也算在总数里。 */ +export function occurrenceCount(highlight: Highlight): string { + const total = highlight.occurrences.length + return highlight.current === undefined ? `${total} 处` : `${highlight.current + 1}/${total}` +} + +/** + * 往后(step 1)/ 往前(step -1)数,下一处能跳到的出现的序号,到头了从另一头接着数;一处都跳不到时没有。 + * 从当前那一处(current)数起;没有当前那一处时从源码下标 from(点中的名字)数起。 + */ +export function stepIndex( + occurrences: readonly Occurrence[], + current: number | undefined, + from: number, + step: 1 | -1, + reachable: (occurrence: Occurrence) => boolean, +): number | undefined { + const count = occurrences.length + // 没有当前那一处时,起点是 from 前面最后一处(往后数)/ 后面第一处(往前数),数的时候跳过它本身所在的位置 + const start = current ?? (step === 1 + ? occurrences.filter(occurrence => occurrence.start <= from).length - 1 + : count - occurrences.filter(occurrence => occurrence.start >= from).length) + for (let k = 1; k <= count; k++) { + const index = (((start + step * k) % count) + count) % count + if (reachable(occurrences[index])) + return index + } + return undefined +} + +/** 按下的键是不是这个组合:键名(字母不分大小写)和四个修饰键都对上。 */ +export function matchesKey(event: Pick, combo: KeyCombo): boolean { + return event.key.toLowerCase() === combo.key.toLowerCase() + && event.ctrlKey === combo.ctrl && event.shiftKey === combo.shift && event.altKey === combo.alt && event.metaKey === combo.meta } /** * 单击名字高亮它在文档里的各处出现,和编辑器里一样每回点都现问扩展端,回答不留到下次用; * 只认最近一次询问的回答。回答到之前旧高亮留着(点同一个名字的另一处不闪),回答是空的就不高亮。 + * 高亮着时能用键盘在各处出现之间跳(move),跳到的那处是当前那一处。 */ export class OccurrenceHighlight { private version: number | undefined private translatorId: string | undefined /** 最近一次询问的 id;别的回答(点了别处、清掉之后才到的)都丢掉 */ private request = 0 - /** 最近一次询问点中的名字;答过或清掉后没有 */ - private asked: string | undefined + /** 最近一次询问点中的那段名字;答过或清掉后没有 */ + private asked: Segment | undefined + /** 高亮着的名字没有当前那一处时,从这个源码下标(点中的名字)数起跳到哪一处 */ + private from = 0 private current: Highlight | undefined constructor( @@ -103,12 +158,29 @@ export class OccurrenceHighlight { this.find(action.offset, action.name) } + /** 回答照源码先后排好;点中的那处就是当前那一处。 */ answer(id: number, occurrences: Occurrence[]): void { - if (id !== this.request || this.asked === undefined) + const asked = this.asked + if (id !== this.request || asked === undefined) return - const name = this.asked this.asked = undefined - this.show(occurrences.length > 0 ? { name, occurrences } : undefined) + const sorted = [...occurrences].sort((a, b) => a.start - b.start || a.end - b.end) + const current = sorted.findIndex(occurrence => overlaps(asked, occurrence)) + this.from = asked.offset ?? 0 + this.show(sorted.length > 0 ? { name: asked.text, occurrences: sorted, ...(current >= 0 && { current }) } : undefined) + } + + /** + * 当前那一处换成往后(step 1)/ 往前(step -1)的下一处能跳到的出现(见 stepIndex),返回它; + * 没有高亮、一处都跳不到时不动,返回 undefined。 + */ + move(step: 1 | -1, reachable: (occurrence: Occurrence) => boolean): Occurrence | undefined { + const highlight = this.current + const index = highlight && stepIndex(highlight.occurrences, highlight.current, this.from, step, reachable) + if (!highlight || index === undefined) + return undefined + this.show({ ...highlight, current: index }) + return highlight.occurrences[index] } clear(): void { @@ -120,7 +192,7 @@ export class OccurrenceHighlight { private find(offset: number, name: string): void { if (this.version === undefined) return - this.asked = name + this.asked = { text: name, role: 'plain', offset } this.post({ type: 'findOccurrences', id: ++this.request, version: this.version, offset }) } @@ -131,3 +203,53 @@ export class OccurrenceHighlight { this.changed(highlight) } } + +/** 能不能用键盘跳要看的几件事。 */ +export interface NavigationFacts { + /** 有名字高亮着 */ + highlighted: boolean + /** 页面有焦点:按的键才会到这个页面 */ + focused: boolean + /** 焦点在输入框里:⌘↑ / ⌘↓ 这些键归输入框移光标 */ + typing: boolean +} + +/** + * 眼下能不能用键盘在高亮着的名字的各处出现之间跳:有高亮、页面有焦点、焦点不在输入框里。变了才告诉扩展端, + * 扩展端跟着设快捷键 when 子句用的上下文键;页面也按它决定要不要拦下这些键在浏览器里的默认动作(滚到顶、滚到底)。 + */ +export class OccurrenceNavigation { + private facts: NavigationFacts + private reported: boolean | undefined + + constructor(private readonly post: (msg: WebviewToExtension) => void, initial: Omit) { + this.facts = { highlighted: false, ...initial } + } + + get available(): boolean { + const { highlighted, focused, typing } = this.facts + return highlighted && focused && !typing + } + + /** 改了哪几件事;第一次调用时不管变没变都告诉扩展端(页面重新加载后扩展端记的可能是旧页面的) */ + update(change: Partial): void { + this.facts = { ...this.facts, ...change } + this.report() + } + + /** + * 这个键要不要拦下浏览器的默认动作:能跳、又是那两条命令绑的键。VS Code 把页面上按的每个键都转给自己的快捷键系统, + * 但不回头告诉页面有没有命令接了它,浏览器的默认动作又只能在按下的当下拦,所以只能照命令绑的键(keys)当场判断。 + */ + blocks(event: Pick, keys: readonly KeyCombo[]): boolean { + return this.available && keys.some(combo => matchesKey(event, combo)) + } + + private report(): void { + const { available } = this + if (available === this.reported) + return + this.reported = available + this.post({ type: 'occurrenceNavigation', available }) + } +} diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index 8a968cd..dcb0f7c 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -1,6 +1,6 @@ import type { LanguageAdapter, Translator } from '../../../src/codeReader/contract' import type { RevealPlan } from './reveal' -import type { BlockKind, BlockView, Inline, PropertyItem, PropertyRow, Segment } from './types' +import type { BlockKind, BlockView, Inline, Occurrence, PropertyItem, PropertyRow, Segment } from './types' import { describe, expect, it } from 'vitest' import { toBlockViews } from '../../../src/codeReader/blockView' import { createPythonAdapter } from '../../../src/codeReader/languages/python' @@ -9,7 +9,7 @@ import { sqlalchemyRule } from '../../../src/codeReader/rules/sqlalchemy' import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh' import { typescriptZhTranslator } from '../../../src/codeReader/translators/typescriptZh' -import { revealPlan, scrollTopToShow } from './reveal' +import { occurrencePlan, revealPlan, scrollTopToShow } from './reveal' import { collapsedIds, propertyItemId, propertyRowId } from './viewState' function name(text: string, offset: number): Segment { @@ -52,6 +52,44 @@ const BLOCKS: BlockView[] = [ factBlock('2', 'const', 8, [{ label: '名称', value: [name('LIMIT', 130)] }]), ] +describe('用键盘跳到高亮的一处出现:滚到哪块、展开哪些', () => { + const nowhere = (): boolean => false + const at = (start: number, end: number): Occurrence => ({ start, end, kind: 'read' }) + + it('在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => { + expect(occurrencePlan(BLOCKS, at(102, 108), nowhere)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) + }) + + it('在属性行展开的子行里(导入项、参数):连那一行一起展开;在属性行的值里不用展开', () => { + expect(occurrencePlan(BLOCKS, at(34, 40), nowhere)).toEqual({ id: '0', expand: ['0/p1'] }) + expect(occurrencePlan(BLOCKS, at(51, 55), nowhere)).toEqual({ id: '1', expand: ['1/p2'] }) + expect(occurrencePlan(BLOCKS, at(130, 135), nowhere)).toEqual({ id: '2', expand: [] }) + }) + + it('和段重叠就算(点分模块名的一节、比名字长的段);页面上哪儿都没有时没有', () => { + expect(occurrencePlan(BLOCKS, at(18, 20), nowhere)).toEqual({ id: '0', expand: [] }) + expect(occurrencePlan(BLOCKS, at(62, 70), nowhere)).toBeUndefined() + }) + + it('被筛掉的、正在编辑的、被盖住的块里的不算;只跳过那一块,它下面画出来的子块照常算', () => { + const hidden = new Set(['1.0', '1.0.0']) + expect(occurrencePlan(BLOCKS, at(102, 108), id => hidden.has(id))).toBeUndefined() + expect(occurrencePlan(BLOCKS, at(51, 55), id => id === '1')).toBeUndefined() + expect(occurrencePlan(BLOCKS, at(102, 108), id => id === '1')).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) + }) + + it('同一处在页面上显示几次:取页面上的第一处;Markdown 里的不算', () => { + const blocks: BlockView[] = [ + factBlock('0', 'function', 0, [{ label: '说明', value: { markdown: '见 `get_db`' } }], [ + textBlock('0.0', 'return', 1, [word('返回'), name('get_db', 30)]), + ]), + factBlock('1', 'class', 2, [{ label: '字段', value: [{ text: '1 个', role: 'hint' }], columns: ['字段'], items: [{ cells: [[name('get_db', 30)]] }] }]), + ] + expect(occurrencePlan(blocks, at(30, 36), nowhere)).toEqual({ id: '0.0', expand: ['0'] }) + expect(occurrencePlan(blocks, at(30, 36), id => id === '0.0')).toEqual({ id: '1', expand: ['1/p0'] }) + }) +}) + describe('跳到定义:滚到哪块、展开哪些', () => { it('名字在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => { expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts index 759dd3b..5729525 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -1,5 +1,6 @@ -import type { BlockView, PreviewText, PropertyItem, SourceRange } from './types' +import type { BlockView, Occurrence, PreviewText, PropertyItem, SourceRange } from './types' import { blockPath } from './filter' +import { showsOccurrence } from './occurrences' import { showsOffset } from './segments' import { propertyItemId, propertyRowId } from './viewState' @@ -61,7 +62,7 @@ function locate(block: BlockView, offset: number): Spot | undefined { for (const { row, id } of rows) { if (shows(row.value, offset)) return { expand: [] } - const expand = showingItem(row.items, id, offset) + const expand = showingItem(row.items, id, cell => shows(cell, offset)) if (expand) return { expand } } @@ -81,12 +82,12 @@ function definingItem(items: PropertyItem[] | undefined, parentId: string, offse return undefined } -/** parentId 下面一层层的子行里,哪个显示着 offset 处的名字:要展开的各层(parentId 到那个子行所在的那一层)。 */ -function showingItem(items: PropertyItem[] | undefined, parentId: string, offset: number): string[] | undefined { +/** parentId 下面一层层的子行里,哪个的格子满足 test(显示着要找的名字):要展开的各层(parentId 到那个子行所在的那一层)。 */ +function showingItem(items: PropertyItem[] | undefined, parentId: string, test: (cell: PreviewText) => boolean): string[] | undefined { for (const [index, item] of (items ?? []).entries()) { - if (item.cells.some(cell => shows(cell, offset))) + if (item.cells.some(test)) return [parentId] - const inner = showingItem(item.items, propertyItemId(parentId, index), offset) + const inner = showingItem(item.items, propertyItemId(parentId, index), test) if (inner) return [parentId, ...inner] } @@ -119,6 +120,34 @@ export function uncoveredPlan(plan: RevealPlan, covered: ReadonlyMap boolean): string[] | undefined { + if ('translation' in block) + return test(block.translation) ? [] : undefined + for (const [index, row] of block.properties.entries()) { + if (test(row.value)) + return [] + const expand = showingItem(row.items, propertyRowId(block.id, index), test) + if (expand) + return expand + } + return undefined +} + +/** + * 用键盘跳到高亮着的名字的一处出现:页面上按先后第一处显示着它(有段和它重叠)的块,以及要展开的块、属性行和子行。 + * skip 的块(被筛掉的、正在编辑的、被正在编辑的祖先盖住的)里的不算;Markdown 说明里的名字不带下标,也不算。页面上哪儿都没有时是 undefined。 + */ +export function occurrencePlan(blocks: BlockView[], occurrence: Occurrence, skip: (id: string) => boolean): RevealPlan | undefined { + const test = (text: PreviewText): boolean => !('markdown' in text) && showsOccurrence(text, occurrence) + for (const { block, ancestors } of placed(blocks)) { + const expand = skip(block.id) ? undefined : showingSpot(block, test) + if (expand) + return { id: block.id, expand: [...ancestors, ...expand] } + } + return undefined +} + /** * 让一块露出来时视口顶部要在的位置,和 VS Code 跳到定义一样:整块已经在视口里就不滚;否则放到视口正中,比视口还高就顶部对齐。 * viewTop / viewHeight 是视口里露出块的那部分(钉住的筛选栏下面)。 diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 9fdb943..75dd853 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -142,6 +142,19 @@ export interface Occurrence { kind: 'text' | 'read' | 'write' } +/** + * 一个按键组合,按 KeyboardEvent 的写法:key 是 KeyboardEvent.key('ArrowDown'),四个修饰键都要对上。 + * 页面上按的每个键 VS Code 都会转给自己的快捷键系统,但不告诉页面有没有命令接了它;浏览器对这个键的默认动作 + * (⌘↓ 滚到底)又只能在按下的当下拦,所以页面要事先知道命令绑的是哪些键 + */ +export interface KeyCombo { + key: string + ctrl: boolean + shift: boolean + alt: boolean + meta: boolean +} + export interface DocumentMessage { type: 'document' version: number @@ -234,12 +247,16 @@ export interface AnswerEnd { * askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来 * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 * answerEnd:askId 那个问题答完了,见 AnswerEnd + * occurrenceKeys:「跳到下一处 / 上一处高亮」这两条命令默认绑的键;ready 后来一次,见 KeyCombo + * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 */ export type ExtensionToWebview = | DocumentMessage | ({ type: 'reveal' } & Reveal) | { type: 'definitionProbed', id: number, found: boolean } | { type: 'occurrencesFound', id: number, occurrences: Occurrence[] } + | { type: 'occurrenceKeys', keys: KeyCombo[] } + | { type: 'moveOccurrence', step: 1 | -1 } | { type: 'filter', excluded: Excluded } | QaMessage | ({ type: 'askProfiles' } & ProfileChoice) @@ -259,6 +276,7 @@ export type ExtensionToWebview * stopAsk:叫停 askId 那个问题,这次不存 * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) * selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示 + * occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发 */ export type WebviewToExtension = | { type: 'ready' } @@ -273,3 +291,4 @@ export type WebviewToExtension | { type: 'stopAsk', askId: string } | { type: 'deleteQa', anchor: string, entryId: string } | { type: 'selectAskProfile', id: string } + | { type: 'occurrenceNavigation', available: boolean } diff --git a/vscode/webview-ui/src/codeReader/useOccurrences.ts b/vscode/webview-ui/src/codeReader/useOccurrences.ts index 713c28e..47eebce 100644 --- a/vscode/webview-ui/src/codeReader/useOccurrences.ts +++ b/vscode/webview-ui/src/codeReader/useOccurrences.ts @@ -1,14 +1,31 @@ import type { Click, Highlight } from './occurrences' -import { createContext, useEffect, useLayoutEffect, useState } from 'react' -import { clickAction, OccurrenceHighlight } from './occurrences' +import type { BlockView, KeyCombo, Occurrence } from './types' +import { createContext, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { clickAction, OccurrenceHighlight, OccurrenceNavigation } from './occurrences' +import { occurrencePlan } from './reveal' +import { showInViewport } from './useReveal' import { onMessage, postMessage } from './vscode' -/** 眼下高亮着的名字,SegmentText 按它给各处出现上底色。 */ +/** 眼下高亮着的名字,SegmentText 按它给各处出现上底色、给当前那一处描边。 */ export const HighlightContext = createContext(undefined) /** 自己管点击的东西:点在上面(名字以外的地方)高亮不动 */ const CONTROLS = 'a, button, input, select, textarea, [role="listbox"], [role="dialog"], .cr-filter-bar, .cr-prop-label' +/** 不收文字的输入框:焦点在它们上面时方向键不归它们移光标 */ +const NON_TEXT_INPUTS = new Set(['checkbox', 'radio', 'button', 'submit', 'reset']) + +/** 用键盘跳到一处出现时要用的页面状态。 */ +export interface OccurrencePage { + blocks: BlockView[] + /** 这块里的出现跳不到:被筛掉的、正在编辑的、被正在编辑的祖先盖住的 */ + skip: (id: string) => boolean + /** 眼下折叠着的块、属性行和子行 */ + collapsed: ReadonlySet + /** 展开这几块、属性行和子行 */ + expand: (ids: string[]) => void +} + function clickOf(event: MouseEvent): Click { const target = event.target instanceof Element ? event.target : null const name = target?.closest('[data-offset]') @@ -22,34 +39,97 @@ function clickOf(event: MouseEvent): Click { } } -/** 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;返回眼下高亮着的名字。 */ -export function useOccurrences(version: number | undefined, translatorId: string | undefined): Highlight | undefined { +/** 焦点在能打字的地方:文字输入框、多行输入框、下拉列表、可编辑的元素(块的「编辑」) */ +function isTyping(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) + return false + return target.isContentEditable || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement + || (target instanceof HTMLInputElement && !NON_TEXT_INPUTS.has(target.type)) +} + +/** + * 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;返回眼下高亮着的名字。 + * 高亮着时扩展端的「跳到下一处 / 上一处高亮」命令发来 moveOccurrence:当前那一处换成下一处页面上找得到的出现, + * 展开把它折叠住的块、属性行和子行,再把它滚进视口。 + */ +export function useOccurrences(version: number | undefined, translatorId: string | undefined, page: OccurrencePage | undefined): Highlight | undefined { const [highlight, setHighlight] = useState() const [occurrences] = useState(() => new OccurrenceHighlight(postMessage, setHighlight)) + const [navigation] = useState(() => new OccurrenceNavigation(postMessage, { focused: document.hasFocus(), typing: isTyping(document.activeElement) })) + /** 用键盘跳过几次:每跳一次,画完后把当前那一处滚进视口 */ + const [moves, setMoves] = useState(0) + const latestPage = useRef(page) + + useLayoutEffect(() => { + latestPage.current = page + }) // 赶在绘制前清掉:新文档的下标换了意思,旧高亮不能落在新页面的别的名字上 useLayoutEffect(() => occurrences.setDocument(version, translatorId), [occurrences, version, translatorId]) + useEffect(() => navigation.update({ highlighted: highlight !== undefined }), [navigation, highlight]) + + // 要展开的已经展开、当前那一处已经画出来(data-current),页面上按先后第一个就是它 + useLayoutEffect(() => { + const el = moves > 0 ? document.querySelector('[data-current]') : null + if (el) + showInViewport(el) + }, [moves]) + useEffect(() => { + let keys: KeyCombo[] = [] + const move = (step: 1 | -1): void => { + const current = latestPage.current + if (!current) + return + const planOf = (occurrence: Occurrence): ReturnType => occurrencePlan(current.blocks, occurrence, current.skip) + const target = occurrences.move(step, occurrence => planOf(occurrence) !== undefined) + const plan = target && planOf(target) + if (!plan) + return + const folded = plan.expand.filter(id => current.collapsed.has(id)) + if (folded.length > 0) + current.expand(folded) + setMoves(count => count + 1) + } // 捕获阶段:赶在块里的按钮处理点击、折叠箭头换图标之前读出点在哪 const onClick = (event: MouseEvent): void => occurrences.click(clickAction(clickOf(event))) - // 输入框、下拉列表自己用掉的 Esc 不算 const onKey = (event: KeyboardEvent): void => { + // 输入框、下拉列表自己用掉的 Esc 不算 if (event.key === 'Escape' && !event.defaultPrevented) occurrences.clear() + else if (navigation.blocks(event, keys)) + event.preventDefault() } + const onFocus = (): void => navigation.update({ focused: true }) + const onBlur = (): void => navigation.update({ focused: false }) + const onFocusIn = (event: FocusEvent): void => navigation.update({ typing: isTyping(event.target) }) + const onFocusOut = (): void => navigation.update({ typing: false }) const off = onMessage((msg) => { if (msg.type === 'occurrencesFound') occurrences.answer(msg.id, msg.occurrences) + else if (msg.type === 'occurrenceKeys') + keys = msg.keys + else if (msg.type === 'moveOccurrence') + move(msg.step) }) + navigation.update({}) document.addEventListener('click', onClick, true) window.addEventListener('keydown', onKey) + window.addEventListener('focus', onFocus) + window.addEventListener('blur', onBlur) + document.addEventListener('focusin', onFocusIn) + document.addEventListener('focusout', onFocusOut) return () => { off() document.removeEventListener('click', onClick, true) window.removeEventListener('keydown', onKey) + window.removeEventListener('focus', onFocus) + window.removeEventListener('blur', onBlur) + document.removeEventListener('focusin', onFocusIn) + document.removeEventListener('focusout', onFocusOut) } - }, [occurrences]) + }, [occurrences, navigation]) return highlight } diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index 73f9867..90cfa03 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -23,6 +23,15 @@ function flash(el: HTMLElement): void { el.setAttribute('data-flash', '') } +/** 把 el 滚进钉住的筛选栏下面的视口,规则见 scrollTopToShow:已经整个看得见就不滚。 */ +export function showInViewport(el: HTMLElement): void { + const rect = el.getBoundingClientRect() + const pinned = pinnedHeight() + const top = scrollTopToShow({ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY }, window.scrollY + pinned, window.innerHeight - pinned) + if (top !== undefined) + window.scrollTo(0, top - pinned) +} + /** * 滚到 reveal 指的位置,处理完调 done: * ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部(筛选栏下面),折叠着的、被筛掉的不展开; @@ -66,11 +75,7 @@ export function useReveal( const row = plan?.row === undefined ? null : document.querySelector(`:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-id="${plan.row}"]`) const el = row ?? (plan && document.querySelector(`.cr-block[data-id="${plan.id}"]`)) if (el) { - const rect = el.getBoundingClientRect() - const pinned = pinnedHeight() - const top = scrollTopToShow({ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY }, window.scrollY + pinned, window.innerHeight - pinned) - if (top !== undefined) - window.scrollTo(0, top - pinned) + showInViewport(el) flash(el) } done()