From 1b1299dc1f0b7922f0ca33aaef88f51c68bc8f99 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sun, 27 Sep 2026 00:33:18 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(vscode):=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=98=85=E8=AF=BB=E4=B8=8D=E5=86=8D=E5=9C=A8?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E9=87=8C=E6=8B=A6=20=E2=8C=98=E2=86=93=20/?= =?UTF-8?q?=20=E2=8C=98=E2=86=91=EF=BC=8C=E5=BF=AB=E6=8D=B7=E9=94=AE?= =?UTF-8?q?=E5=8F=AA=E5=9C=A8=E3=80=8C=E9=94=AE=E7=9B=98=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E3=80=8D=E9=87=8C=E7=BB=91=EF=BC=9B=E8=B7=B3?= =?UTF-8?q?=E5=88=B0=E4=B8=8B=E4=B8=80=E5=A4=84=E6=97=B6=E6=8A=8A=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E8=87=AA=E5=B7=B1=E7=9A=84=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=8B=89=E5=9B=9E=E6=9D=A5=EF=BC=8C=E9=A1=B5=E9=9D=A2=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=85=88=E6=BB=9A=E5=88=B0=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 9 +-- vscode/src/codeReader/editor/messages.ts | 15 ---- .../editor/occurrenceNavigation.test.ts | 34 ++-------- .../codeReader/editor/occurrenceNavigation.ts | 50 -------------- vscode/src/codeReader/editor/previewEditor.ts | 5 +- vscode/src/codeReader/index.ts | 4 +- .../src/codeReader/occurrences.test.ts | 57 +++++++++++----- .../webview-ui/src/codeReader/occurrences.ts | 68 +++++++++++++++---- vscode/webview-ui/src/codeReader/types.ts | 15 ---- .../src/codeReader/useOccurrences.ts | 65 +++++++++++++----- 10 files changed, 157 insertions(+), 165 deletions(-) diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 9ac2f5d..28a9dfe 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -207,7 +207,9 @@ - 上下文键 `superpowers.codeReader.hasOccurrences`:webview 报「眼下能不能用键盘跳」(有高亮、页面有焦点、焦点不在输入框 / 块的「编辑」里),变了才报;扩展端有一个预览能跳就设成真(同一时刻只有一个页面有焦点,几个预览的消息先后不定也对),页面失去焦点、高亮清掉、页面被销毁(标签页切走)、预览关掉都变假。 - 命令发给报了能跳的那个预览;从命令面板运行时页面没焦点,发给活动的预览。 - 跳:照源码先后,从当前那一处往后 / 往前找下一处页面上找得到的出现(显示着和它重叠的段),到头了从另一头接着找;在被筛掉的、正在编辑的、被正在编辑的祖先盖住的块里的跳过,Markdown 里的不算,筛选栏上的总数照旧。点中的名字不在回答里时从点中的地方数起。当前那一处换成它,展开把它折叠住的块、属性行和子行(同一处在页面上显示几次取页面上的第一处),再把它滚进视口(已经整个看得见就不滚,否则放到视口正中,同「跳到定义」)。 - - VS Code 把 webview 里按的每个键都原样转给工作台的快捷键系统(webview 外壳 `pre/index.html` 的 `handleInnerKeydown` 发 `did-keydown`,`webviewElement` 的 `handleKeyEvent` 在 webview 元素上重新派发键盘事件),所以焦点在预览里时快捷键照常生效;但它不回头告诉页面有没有命令接了这个键,而浏览器对这个键的默认动作(⌘↓ / ⌘↑ 滚到底 / 顶)在按下的当下就发生,事后拦不住,扩展也没有查快捷键的接口。所以页面只能事先知道命令绑的是哪些键:扩展端从 `package.json` 读这两条命令默认绑的键(各平台的都算),`ready` 后发给页面;页面只在「能跳」时拦下这些键的默认动作,没有高亮、焦点在输入框里时照常滚动 / 移光标。用户改绑成别的键时,新键照常生效,原来的 ⌘↓ / ⌘↑ 在有高亮时仍被拦着(不再滚动)。 + - 快捷键只写在 `package.json` 里,扩展、页面的代码里都不写死哪个键,页面不拦任何键:VS Code 把 webview 里按的每个键都原样转给工作台的快捷键系统(webview 外壳 `pre/index.html` 的 `handleInnerKeydown` 发 `did-keydown`),焦点在预览里时快捷键照常生效;用户改绑后原来的 ⌘↓ / ⌘↑ 照常是浏览器的滚到底 / 顶。 + - 浏览器对触发命令的那个键自己的动作(⌘↓ 滚到底)照常发生,由跳转盖掉(`occurrences.ts` 的 `KeyScroll`):页面在按下任何键时记下滚在哪;命令发来时,按键后 1 秒内的(`KEY_TO_COMMAND_MS`)先回到按键那一刻的位置,再把目标滚进视口;按键后 0.5 秒内(`KEY_SCROLL_MS`)浏览器又滚走了就拉回跳到的位置。按键后点了鼠标、滚了滚轮、又按了键,或从命令面板运行(没有刚按的键),都不回、不守。 + - 实测(隔离的真 VS Code,逐帧录屏):命令十几毫秒就到页面,浏览器的滚到底动画再过一帧才开始,而且在合成线程上播、页面忙着重画时照样播;不管的话页面最后停在底部(目标看不见)。只在滚动时拉回,重画期间仍有约 100 毫秒来回跳的画面;滚到原地浏览器当没滚、不打断已排上的动画,所以回位时先挪一个像素再回来(同一段脚本里连着两下,中间不画),动画在开始前就被打断,录屏里一帧都没有被滚走。 - 和编辑器一样每回点都现问,回答不留到下次用(语言扩展刚启动时答的「没有」或按文字认的结果不会一直留着);只认最近一次询问的回答,迟到的丢掉。点另一个名字:回答到之前旧高亮留着,到了就换成新的;回答是空的(没装语言扩展、没答)就什么都不亮,不按文字自己找。 - 清掉(连同筛选栏上那句):点空白处或不是名字的字、按 Esc(输入框、下拉列表自己用掉的 Esc 不算)、文档换了版本、换了翻译。 - 不抢别的点击:⌘ / Ctrl(以及 Alt、Shift)+ 点击、双击的第二下、拖着选字都不动高亮(双击的第一下照样算一次单击,和编辑器一样);点在按钮、链接、输入框、下拉列表、筛选栏、属性名上照旧,高亮不动——折叠箭头、「编辑」、Markdown 里的链接都照旧;名字本身在按钮里(带下一层的子行的键)时照旧折叠,同时高亮。 @@ -995,7 +997,6 @@ 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` 让重新加载的页面接上还在答的问题(不带图片,接上的这一问不显示缩略图,答完回到历史里照常有)。 @@ -1090,7 +1091,7 @@ vscode/src/codeReader/ editor/definitionSources.ts 名字的定义去哪儿查:文档里规则标的引用问规则,其余问语言扩展 editor/definitionAction.ts 查到的定义 → 怎么跳(纯函数) editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights) - editor/occurrenceNavigation.ts 跳到下一处 / 上一处高亮:命令、上下文键、从 package.json 读默认绑的键 + editor/occurrenceNavigation.ts 跳到下一处 / 上一处高亮:命令、上下文键 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) ``` @@ -1110,7 +1111,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 + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。键盘跳(`occurrences.test.ts`、`reveal.test.ts`、`editor/occurrenceNavigation.test.ts`):回答照源码先后排、点中的那处是当前那一处、「2/5」和「N 处」;往后 / 往前、到头接着跳,找不到的跳过、总数照旧、一处都找不到不动,点中的不在回答里从点中的地方数起;只有当前那一处描框;能不能跳(高亮、焦点、输入框)第一次必报、之后变了才报,只在能跳时拦命令绑的键、修饰键要全对上;页面上找第一处显示着它的地方、要展开的块 / 属性行 / 子行、跳过筛掉 / 编辑 / 盖住的块、Markdown 不算;`package.json` 的两条命令、默认键、`when`、命令面板,默认键读成按键组合(各平台、去重、两下按的不认),上下文键有一个预览能跳就真、变了才设,命令发给能跳的预览、没有就发给活动的。 +- 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。键盘跳(`occurrences.test.ts`、`reveal.test.ts`、`editor/occurrenceNavigation.test.ts`):回答照源码先后排、点中的那处是当前那一处、「2/5」和「N 处」;往后 / 往前、到头接着跳,找不到的跳过、总数照旧、一处都找不到不动,点中的不在回答里从点中的地方数起;只有当前那一处描框;能不能跳(高亮、焦点、输入框)第一次必报、之后变了才报;页面上找第一处显示着它的地方、要展开的块 / 属性行 / 子行、跳过筛掉 / 编辑 / 盖住的块、Markdown 不算;`package.json` 的两条命令、默认键、`when`、命令面板,扩展和页面的代码里不写键、不拦键,上下文键有一个预览能跳就真、变了才设,命令发给能跳的预览、没有就发给活动的;按键那一刻的位置跳转时回一次、隔太久 / 点过鼠标 / 没按过键不回,跳完守住跳到的位置、过了 `KEY_SCROLL_MS` 或又按键、点鼠标不守。 - 注册表:注册 / 注销后 `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/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index a5fad69..2b2d71b 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -32,19 +32,6 @@ 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 靠它判断是不是新一份文档 */ @@ -98,7 +85,6 @@ 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 @@ -106,7 +92,6 @@ export type ExtensionToWebview | ({ 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 diff --git a/vscode/src/codeReader/editor/occurrenceNavigation.test.ts b/vscode/src/codeReader/editor/occurrenceNavigation.test.ts index 55ac143..94ff994 100644 --- a/vscode/src/codeReader/editor/occurrenceNavigation.test.ts +++ b/vscode/src/codeReader/editor/occurrenceNavigation.test.ts @@ -1,7 +1,7 @@ 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' +import { NavigationContext, NEXT_OCCURRENCE, OCCURRENCES_CONTEXT, PREVIOUS_OCCURRENCE } from './occurrenceNavigation' interface Manifest { contributes: { @@ -37,33 +37,11 @@ describe('跳到下一处 / 上一处高亮:package.json 里的命令和快捷 ]) }) - 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() + it('键只在 package.json 里绑,扩展、页面的代码里都不写死键:用户改绑后原来的键照常', () => { + const sources = ['occurrenceNavigation.ts', 'previewEditor.ts', 'messages.ts', '../index.ts'].map(file => readFileSync(path.resolve(__dirname, file), 'utf8')) + const webview = ['occurrences.ts', 'useOccurrences.ts', 'types.ts'].map(file => readFileSync(path.resolve(__dirname, '../../../webview-ui/src/codeReader', file), 'utf8')) + for (const text of [...sources, ...webview]) + expect(text).not.toMatch(/ArrowDown|ArrowUp|cmd\+|ctrl\+|KeyCombo|keybindings|preventDefault/) }) }) diff --git a/vscode/src/codeReader/editor/occurrenceNavigation.ts b/vscode/src/codeReader/editor/occurrenceNavigation.ts index c9e6004..66b0437 100644 --- a/vscode/src/codeReader/editor/occurrenceNavigation.ts +++ b/vscode/src/codeReader/editor/occurrenceNavigation.ts @@ -1,5 +1,3 @@ -import type { KeyCombo } from './messages' - /** 在高亮着的名字的各处出现之间跳:两条命令、快捷键的 when 子句用的上下文键。 */ export const NEXT_OCCURRENCE = 'superpowers.codeReader.nextOccurrence' @@ -7,54 +5,6 @@ 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) -} - /** * 上下文键跟着各预览报来的状态走:有一个预览能跳就是真(同一时刻只有一个页面有焦点),变了才设。 * 预览关掉、页面被销毁(标签页切走)时当它不能跳。 diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 1f025d6..2907f5c 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, KeyCombo, Reveal, WebviewToExtension } from './messages' +import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages' import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode' import { logger } from '../../logging/logger' import { webviewHtml, webviewUiRoot } from '../../webview/html' @@ -46,8 +46,6 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { readonly resolveReference: (reference: RuleReference) => Promise, /** 问 AI:各预览共用,同一份文件开在几处时问答、正在答的问题都一样 */ readonly qa: CodeQa, - /** 「跳到下一处 / 上一处高亮」默认绑的键:页面在这些键上拦下浏览器自己的滚动 */ - readonly occurrenceKeys: readonly KeyCombo[], ) {} resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { @@ -282,7 +280,6 @@ 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() diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 9194b24..24b9d10 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -3,7 +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 { 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' @@ -66,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, navigationKeys(context.extension.packageJSON)) + const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa) const notes = new FileNotes() const files = new FileTreeProvider(OPEN_COMMAND, notes) diff --git a/vscode/webview-ui/src/codeReader/occurrences.test.ts b/vscode/webview-ui/src/codeReader/occurrences.test.ts index 711122c..2fc7f86 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, KeyCombo, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types' +import type { BlockView, Inline, 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, isCurrent, matchesKey, occurrenceCount, OccurrenceHighlight, OccurrenceNavigation, occurrenceStyle, segmentOccurrence, stepIndex } from './occurrences' +import { clickAction, isCurrent, KEY_SCROLL_MS, KEY_TO_COMMAND_MS, KeyScroll, occurrenceCount, OccurrenceHighlight, OccurrenceNavigation, occurrenceStyle, segmentOccurrence, stepIndex } from './occurrences' function name(text: string, offset: number): Segment { return { text, role: 'variable', offset } @@ -310,20 +310,43 @@ describe('occurrences:用键盘在各处出现之间跳', () => { 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) + it('不拦任何键:跳转发来时回到刚按键那一刻的位置(只回一次),隔太久、点过鼠标、没按过键(命令面板)不回', () => { + const scroll = new KeyScroll() + expect(scroll.take(100)).toBeUndefined() + scroll.press({ x: 0, y: 40 }, 1000) + expect(scroll.take(1012)).toEqual({ x: 0, y: 40 }) + expect(scroll.take(1013)).toBeUndefined() + scroll.press({ x: 0, y: 40 }, 2000) + expect(scroll.take(2000 + KEY_TO_COMMAND_MS + 1)).toBeUndefined() + scroll.press({ x: 0, y: 40 }, 3000) + scroll.forget() + expect(scroll.take(3010)).toBeUndefined() + }) + + it('跳完守住跳到的位置:浏览器对那个键自己的滚动晚于命令才开始,按键后 KEY_SCROLL_MS 内滚走了就拉回来', () => { + const scroll = new KeyScroll() + scroll.press({ x: 0, y: 40 }, 1000) + scroll.take(1012) + // 刚回到按键那一刻、还没跳完:守住按键那一刻的位置 + expect(scroll.scrolled({ x: 0, y: 300 }, 1020)).toEqual({ x: 0, y: 40 }) + scroll.hold({ x: 0, y: 900 }) + expect(scroll.scrolled({ x: 0, y: 900 }, 1030)).toBeUndefined() + expect(scroll.scrolled({ x: 0, y: 1200 }, 1040)).toEqual({ x: 0, y: 900 }) + expect(scroll.scrolled({ x: 0, y: 1200 }, 1000 + KEY_SCROLL_MS + 1)).toBeUndefined() + expect(scroll.scrolled({ x: 0, y: 1300 }, 1000 + KEY_SCROLL_MS + 2)).toBeUndefined() + }) + + it('不是按键触发的跳转、又按了键、点了鼠标:不守', () => { + const scroll = new KeyScroll() + scroll.take(10) + scroll.hold({ x: 0, y: 900 }) + expect(scroll.scrolled({ x: 0, y: 0 }, 20)).toBeUndefined() + scroll.press({ x: 0, y: 40 }, 1000) + scroll.take(1010) + scroll.press({ x: 0, y: 40 }, 1100) + expect(scroll.scrolled({ x: 0, y: 300 }, 1110)).toBeUndefined() + scroll.take(1120) + scroll.forget() + expect(scroll.scrolled({ x: 0, y: 300 }, 1130)).toBeUndefined() }) }) diff --git a/vscode/webview-ui/src/codeReader/occurrences.ts b/vscode/webview-ui/src/codeReader/occurrences.ts index baa724c..819b620 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.ts @@ -1,4 +1,4 @@ -import type { Inline, KeyCombo, Occurrence, Segment, WebviewToExtension } from './types' +import type { Inline, Occurrence, Segment, WebviewToExtension } from './types' /** 高亮画成哪种底色,和 VS Code 编辑器一样:写入(定义、赋值)用强高亮色,读取和按文字认出的用普通高亮色。 */ export type OccurrenceStyle = 'strong' | 'normal' @@ -111,10 +111,58 @@ export function stepIndex( 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 +/** 按键之后多久内发来的跳转算这个键触发的:命令从 VS Code 绕一圈回到页面通常只要几十毫秒 */ +export const KEY_TO_COMMAND_MS = 1000 +/** 浏览器对一个键自己的滚动(⌘↓ 滚到底)按下后多久内会开始:它可能在命令发来之后才开始,这段时间里跳到的位置要守住 */ +export const KEY_SCROLL_MS = 500 + +export interface ScrollPosition { x: number, y: number } + +/** + * 跳到下一处 / 上一处的命令由 VS Code 的快捷键系统触发,页面不知道绑的是哪个键,浏览器对这个键自己的动作(⌘↓ 滚到底)拦不住; + * 它还可能早于命令开始,也可能晚于命令(实测命令十几毫秒就到,滚动动画再过一帧才开始)。所以不管哪个键: + * 按键那一刻记下页面滚在哪(press);命令发来时先回到那里(take)再滚到目标;跳完守住跳到的位置(hold), + * 按键后 KEY_SCROLL_MS 内浏览器自己滚走了就拉回来(scrolled)。 + * 按键后点了鼠标、滚了滚轮、又按了键,或跳转隔了 KEY_TO_COMMAND_MS 以上才来(从命令面板运行),都不算这个键触发的。 + */ +export class KeyScroll { + private pressed: { position: ScrollPosition, at: number } | undefined + private held: { position: ScrollPosition, until: number } | undefined + + /** 按下任何键(照常交给浏览器和 VS Code 处理)时记下页面滚在哪。 */ + press(position: ScrollPosition, at: number): void { + this.pressed = { position, at } + this.held = undefined + } + + forget(): void { + this.pressed = undefined + this.held = undefined + } + + /** 跳转发来时:刚按过键就交出按键那一刻的位置(只交一次),从这时起守住它,直到跳完换成跳到的位置。 */ + take(now: number): ScrollPosition | undefined { + const pressed = this.pressed + this.pressed = undefined + this.held = pressed && now - pressed.at <= KEY_TO_COMMAND_MS ? { position: pressed.position, until: pressed.at + KEY_SCROLL_MS } : undefined + return this.held?.position + } + + /** 跳完了(滚到了目标,或没处可跳留在原地):按键触发的跳转改守这个位置。 */ + hold(position: ScrollPosition): void { + if (this.held) + this.held = { ...this.held, position } + } + + /** 页面滚动了:还在守着、又被浏览器滚走了,给出要拉回的位置。 */ + scrolled(position: ScrollPosition, now: number): ScrollPosition | undefined { + const held = this.held + if (!held || now > held.until) { + this.held = undefined + return undefined + } + return held.position.x === position.x && held.position.y === position.y ? undefined : held.position + } } /** @@ -210,7 +258,7 @@ export interface NavigationFacts { highlighted: boolean /** 页面有焦点:按的键才会到这个页面 */ focused: boolean - /** 焦点在输入框里:⌘↑ / ⌘↓ 这些键归输入框移光标 */ + /** 焦点在输入框里:按的键归输入框(打字、移光标),不拿来跳 */ typing: boolean } @@ -237,14 +285,6 @@ export class OccurrenceNavigation { 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) diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 75dd853..3150bed 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -142,19 +142,6 @@ 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 @@ -247,7 +234,6 @@ export interface AnswerEnd { * askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来 * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 * answerEnd:askId 那个问题答完了,见 AnswerEnd - * occurrenceKeys:「跳到下一处 / 上一处高亮」这两条命令默认绑的键;ready 后来一次,见 KeyCombo * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 */ export type ExtensionToWebview @@ -255,7 +241,6 @@ export type ExtensionToWebview | ({ 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 diff --git a/vscode/webview-ui/src/codeReader/useOccurrences.ts b/vscode/webview-ui/src/codeReader/useOccurrences.ts index 47eebce..fca2670 100644 --- a/vscode/webview-ui/src/codeReader/useOccurrences.ts +++ b/vscode/webview-ui/src/codeReader/useOccurrences.ts @@ -1,7 +1,7 @@ -import type { Click, Highlight } from './occurrences' -import type { BlockView, KeyCombo, Occurrence } from './types' +import type { Click, Highlight, ScrollPosition } from './occurrences' +import type { BlockView, Occurrence } from './types' import { createContext, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { clickAction, OccurrenceHighlight, OccurrenceNavigation } from './occurrences' +import { clickAction, KeyScroll, OccurrenceHighlight, OccurrenceNavigation } from './occurrences' import { occurrencePlan } from './reveal' import { showInViewport } from './useReveal' import { onMessage, postMessage } from './vscode' @@ -47,15 +47,30 @@ function isTyping(target: EventTarget | null): boolean { || (target instanceof HTMLInputElement && !NON_TEXT_INPUTS.has(target.type)) } +function scrollPosition(): ScrollPosition { + return { x: window.scrollX, y: window.scrollY } +} + +/** + * 立刻滚到这里,顺带打断浏览器正在播(或刚排上、还没开始播)的滚动动画。滚到原地浏览器当没滚、不打断动画, + * 所以先挪一个像素再回来:同一段脚本里连着滚两下,中间不画,画面上看不出。 + */ +function scrollBack({ x, y }: ScrollPosition): void { + window.scrollTo({ left: x, top: y > 0 ? y - 1 : y + 1, behavior: 'instant' }) + window.scrollTo({ left: x, top: y, behavior: 'instant' }) +} + /** * 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;返回眼下高亮着的名字。 * 高亮着时扩展端的「跳到下一处 / 上一处高亮」命令发来 moveOccurrence:当前那一处换成下一处页面上找得到的出现, * 展开把它折叠住的块、属性行和子行,再把它滚进视口。 + * 触发命令的那个键浏览器自己的滚动不拦(页面不知道是哪个键),跳的时候盖掉它,见 KeyScroll。 */ 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 [keyScroll] = useState(() => new KeyScroll()) /** 用键盘跳过几次:每跳一次,画完后把当前那一处滚进视口 */ const [moves, setMoves] = useState(0) const latestPage = useRef(page) @@ -71,22 +86,28 @@ export function useOccurrences(version: number | undefined, translatorId: string // 要展开的已经展开、当前那一处已经画出来(data-current),页面上按先后第一个就是它 useLayoutEffect(() => { - const el = moves > 0 ? document.querySelector('[data-current]') : null + if (moves === 0) + return + const el = document.querySelector('[data-current]') if (el) showInViewport(el) - }, [moves]) + keyScroll.hold(scrollPosition()) + }, [moves, keyScroll]) useEffect(() => { - let keys: KeyCombo[] = [] const move = (step: 1 | -1): void => { + // 触发命令的那个键浏览器可能已经照自己的意思滚了(⌘↓ 滚到底),先回到按键那一刻 + const before = keyScroll.take(performance.now()) + if (before) + scrollBack(before) 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 planOf = (occurrence: Occurrence): ReturnType => current && occurrencePlan(current.blocks, occurrence, current.skip) + const target = current && occurrences.move(step, occurrence => planOf(occurrence) !== undefined) const plan = target && planOf(target) - if (!plan) + if (!current || !plan) { + keyScroll.hold(scrollPosition()) return + } const folded = plan.expand.filter(id => current.collapsed.has(id)) if (folded.length > 0) current.expand(folded) @@ -94,12 +115,18 @@ export function useOccurrences(version: number | undefined, translatorId: string } // 捕获阶段:赶在块里的按钮处理点击、折叠箭头换图标之前读出点在哪 const onClick = (event: MouseEvent): void => occurrences.click(clickAction(clickOf(event))) + // 任何键都照常交给浏览器和 VS Code,这里只记下按键那一刻页面滚在哪 + const onKeyDown = (event: KeyboardEvent): void => keyScroll.press(scrollPosition(), event.timeStamp) + const onPointer = (): void => keyScroll.forget() + const onScroll = (event: Event): void => { + const back = keyScroll.scrolled(scrollPosition(), event.timeStamp) + if (back) + scrollBack(back) + } 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 }) @@ -108,13 +135,15 @@ export function useOccurrences(version: number | undefined, translatorId: string 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', onKeyDown, true) + window.addEventListener('pointerdown', onPointer, true) + window.addEventListener('wheel', onPointer, { capture: true, passive: true }) + window.addEventListener('scroll', onScroll, { passive: true }) window.addEventListener('keydown', onKey) window.addEventListener('focus', onFocus) window.addEventListener('blur', onBlur) @@ -123,13 +152,17 @@ export function useOccurrences(version: number | undefined, translatorId: string return () => { off() document.removeEventListener('click', onClick, true) + window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('pointerdown', onPointer, true) + window.removeEventListener('wheel', onPointer, { capture: true }) + window.removeEventListener('scroll', onScroll) window.removeEventListener('keydown', onKey) window.removeEventListener('focus', onFocus) window.removeEventListener('blur', onBlur) document.removeEventListener('focusin', onFocusIn) document.removeEventListener('focusout', onFocusOut) } - }, [occurrences, navigation]) + }, [occurrences, navigation, keyScroll]) return highlight }