From f0cd3942cc1ef661cbe618d8b69ac035e71c850d Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 26 Sep 2026 15:08:16 +0800 Subject: [PATCH] =?UTF-8?q?=E2=8F=AA=20revert(vscode):=20=E8=BF=98?= =?UTF-8?q?=E5=8E=9F=E6=82=AC=E5=81=9C=E5=8D=B3=E6=98=BE=E7=A4=BA=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=EF=BC=8C=E6=81=A2=E5=A4=8D=E6=8C=89=E4=BD=8F=20?= =?UTF-8?q?=E2=8C=98=20/=20Ctrl=20=E7=82=B9=E5=87=BB=E8=B7=B3=E5=88=B0?= =?UTF-8?q?=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 15 +- .../src/codeReader/editor/definitionAction.ts | 2 +- vscode/src/codeReader/editor/messages.ts | 4 +- vscode/src/codeReader/editor/previewEditor.ts | 2 +- .../webview-ui/src/codeReader/codeReader.css | 2 +- .../src/codeReader/definitionLinks.test.ts | 180 ++++-------------- .../src/codeReader/definitionLinks.ts | 54 ++---- vscode/webview-ui/src/codeReader/types.ts | 6 +- .../src/codeReader/useDefinitionLinks.ts | 67 ++++--- 9 files changed, 95 insertions(+), 237 deletions(-) diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 2444019..a3bcb60 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -23,7 +23,7 @@ | 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)和类块默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 | | 函数、类的说明 | TypeScript 紧贴在函数、类前面的 JSDoc,Python 函数、类主体开头的 docstring 是它自己的说明:进属性表「说明」一行,不再单独成一块注释;它的源码行算在函数、类块的头部里,「编辑」时和签名一起改 | | 注释按 Markdown 显示 | 注释块和函数、类的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 | -| 跳到定义 | 鼠标停在名字上、查得到定义就显示成链接,单击跳到定义(不用按 ⌘ / Ctrl),定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | +| 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | | 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释;不做新建 / 重命名 / 删除 | | 文件注释 | 读代码时给目录 / 文件随手记一句:存在各工作区文件夹的 `.spx/code-notes.json`,跟着仓库走;在 VS Code 里改名、挪位置、删除时注释跟着变 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | @@ -134,8 +134,8 @@ ### 跳到定义 - 能点的:预览里(属性表的名称、类型、来源、导入项、默认值、值、字典 / 对象各层子行的键和值、类型参数、父类、实现的接口,以及一行译文)照抄自源码的名字——标识符、属性名、类型名、点分模块名的每一节(`luffy_agent.core.db` 三节各自能点,和 VS Code 一样)、字符串(TypeScript 的模块路径 `'./x'` 就是字符串)。翻出来的关键字、运算符、说明词不能点。 -- 链接不用按修饰键:鼠标停在**查得到定义**的名字上时,名字加下划线、换成链接色(`--vscode-editorLink-activeForeground`)、光标变手形;移开就恢复。有没有定义问扩展端(`vscode.executeDefinitionProvider`,文档 uri + 名字在源码里的位置);鼠标在一个名字上停稳 100ms 才问,停稳之前换了名字或移开就不问,划过预览时路过的名字不打扰语言扩展。和编辑器一样,每回停到一个名字上(或换了文档版本)都现问,回答不留到下次用——Pylance、tsserver 刚启动那几秒会答「没有」;只认最近一次询问的回答,迟到的丢掉。 -- 单击链接: +- 链接和编辑器里一样:只有按住修饰键(macOS ⌘,其余平台 Ctrl)、鼠标停在**查得到定义**的名字上时,名字加下划线、换成链接色(`--vscode-editorLink-activeForeground`)、光标变手形;松开修饰键或移开就恢复。有没有定义问扩展端(`vscode.executeDefinitionProvider`,文档 uri + 名字在源码里的位置);和编辑器一样,每回指到一个名字上(或指着名字按下修饰键、换了文档版本)都现问,回答不留到下次用——Pylance、tsserver 刚启动那几秒会答「没有」;只认最近一次询问的回答,迟到的丢掉。 +- ⌘ / Ctrl + 点击: - 一处定义,在当前文件:原地滚过去; - 一处定义,另一个文件,语言有代码阅读:在同一个编辑器组用预览打开(`vscode.openWith`),滚过去;这个文件的预览已经在这一组开着就切过去、发给它,不重新打开; - 一处定义,语言没有代码阅读:在同一个编辑器组用普通编辑器打开,选中定义处的名字; @@ -148,8 +148,7 @@ 3. 其余(定义在一行译文里,例如循环变量;翻译插件没填 `defines`)→ 显示着定义处那个名字的块,名字在属性行的子行里时展开那一行(在嵌套的子行里时连它上面各层一起展开);页面上找不到这个名字就取包含定义那一行的块。 - 同一文件原地跳、跳进另一个文件的预览,规则一样。 - 滚过去:目标所在块的祖先块折叠着就展开,目标在折叠的属性行子行里(参数、导入项、字典 / 对象的项)就展开那一行,在嵌套的子行里就连它上面折叠着的各层子行一起展开。目标已在视口里不滚,否则滚到视口正中(比视口高就顶部对齐)。然后目标的边框和底色往聚焦色(`--vscode-focusBorder`)闪三下,共约 1.5 秒后复原;同一处再跳一次从头闪;系统开了「减少动态效果」时改成静止高亮 1.5 秒。目标是属性行 / 子行但块正在编辑(页面上没有属性表)时闪整块。 -- 选文字照旧:单击链接后等 250ms 才跳,双击的第二下在这之内按下就不跳(双击选词);拖选文字时松手落在链接上不跳(点击时有这一下拖出来的选区)。 -- 点链接只管跳,不触发块的编辑切换、折叠(字典子行的键本身也是折叠开关,键链上时点它跳,折叠点箭头);没链上的名字(还没问完、查不到定义)、折叠箭头、属性名、「编辑 / 预览」切换、筛选栏点了照旧。「编辑」状态里是源码,名字不成链接。名字不能用键盘聚焦,没有键盘跳转。 +- 不按修饰键的点击照旧;⌘ / Ctrl + 点击名字不会触发块的编辑切换、折叠。 ## 通用契约 @@ -488,8 +487,8 @@ type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: P - `{ type: 'definitionProbed', id, found }`:回答第 `id` 次 `probeDefinition`。 - `{ type: 'filter', excluded: Record<属性 id, 值 id[]> }`:整个工作区共用的块筛选。webview 发 `ready` 后先于第一份文档发一次(第一份文档按筛选画),之后任何一个预览改了都发给所有开着的预览(包括改的那个)。 - webview → 扩展: - - `{ type: 'probeDefinition', id, version, offset }`:鼠标在带 `offset` 的名字上停稳时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。 - - `{ type: 'goToDefinition', version, offset }`:单击链接,按「跳到定义」一节跳。 + - `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。 + - `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。 - `{ type: 'ready' }` - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 @@ -557,7 +556,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。 - 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / 相对路径的链接和图片不渲染;跳到定义不认 Markdown 里的名字。 - 行替换:多行块替换后缩进正确。 -- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置;悬停不用修饰键、停稳才问、划过的名字不问,单击链接等过双击间隔才跳,拖选、双击不跳,没链上的名字点击不拦。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。 +- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。 - 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。 - 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。 diff --git a/vscode/src/codeReader/editor/definitionAction.ts b/vscode/src/codeReader/editor/definitionAction.ts index 7cb8b45..b7f2f98 100644 --- a/vscode/src/codeReader/editor/definitionAction.ts +++ b/vscode/src/codeReader/editor/definitionAction.ts @@ -7,7 +7,7 @@ export interface DefinitionTarget { } /** - * 单击链接查到定义之后怎么做: + * ⌘ / Ctrl + 点击查到定义之后怎么做: * none 不动;pick 让用户从几处里挑一处,挑好的那处再交给 definitionAction 决定; * here 定义就在当前文件,预览原地滚过去;preview 用代码阅读预览打开目标文件;text 目标语言没有代码阅读,用普通文本编辑器打开。 */ diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index f052c49..ddcfade 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -48,9 +48,9 @@ export type ExtensionToWebview /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) * visibleLine:预览视口顶部那块的起始行,切回文本编辑器时把这行放到顶部 - * probeDefinition:鼠标停在带下标的名字上,问源码 offset 处有没有定义;id 标出是哪一次问,回答带回去; + * probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问源码 offset 处有没有定义;id 标出是哪一次问,回答带回去; * version 是这份下标所属的文档版本 - * goToDefinition:单击链接,跳到 offset 处名字的定义 + * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 * setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览 */ export type WebviewToExtension diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index a139a2d..a4346d9 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -210,7 +210,7 @@ class Preview { this.post({ type: 'definitionProbed', id, found }) } - /** 单击链接:跳到 offset 处名字的定义,几处时先让用户挑。 */ + /** ⌘ / Ctrl + 点击:跳到 offset 处名字的定义,几处时先让用户挑。 */ private async goToDefinition(version: number, offset: number): Promise { const { document } = this if (version !== document.version) diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 485eec2..3eb59ec 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -242,7 +242,7 @@ body.vscode-high-contrast-light { border-top: 1px solid color-mix(in srgb, currentColor 30%, transparent); } -/* 鼠标停在查得到定义的名字上:和编辑器里的链接一样 */ +/* 按住 ⌘ / Ctrl 停在查得到定义的名字上:和编辑器里的链接一样 */ .cr-code [data-link] { color: var(--vscode-editorLink-activeForeground); text-decoration: underline; diff --git a/vscode/webview-ui/src/codeReader/definitionLinks.test.ts b/vscode/webview-ui/src/codeReader/definitionLinks.test.ts index 57305f7..c59f0b2 100644 --- a/vscode/webview-ui/src/codeReader/definitionLinks.test.ts +++ b/vscode/webview-ui/src/codeReader/definitionLinks.test.ts @@ -1,115 +1,76 @@ import type { WebviewToExtension } from './types' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { DefinitionLinks, DOUBLE_CLICK_WAIT_MS, HOVER_DELAY_MS } from './definitionLinks' +import { describe, expect, it } from 'vitest' +import { DefinitionLinks } from './definitionLinks' -interface Probe { id: number, version: number, offset: number } - -function setup(version: number | undefined): { links: DefinitionLinks, probes: () => Probe[], jumps: () => WebviewToExtension[] } { +function setup(version: number | undefined): { links: DefinitionLinks, sent: WebviewToExtension[], probes: () => { id: number, version: number, offset: number }[] } { const sent: WebviewToExtension[] = [] const links = new DefinitionLinks(msg => sent.push(msg)) links.setVersion(version) - const probes = (): Probe[] => sent.flatMap(msg => msg.type === 'probeDefinition' ? [{ id: msg.id, version: msg.version, offset: msg.offset }] : []) - const jumps = (): WebviewToExtension[] => sent.filter(msg => msg.type === 'goToDefinition') - return { links, probes, jumps } + const probes = (): { id: number, version: number, offset: number }[] => sent.flatMap(msg => msg.type === 'probeDefinition' ? [{ id: msg.id, version: msg.version, offset: msg.offset }] : []) + return { links, sent, probes } } -/** 停在 offset 处的名字上直到问出去,扩展端答 found */ -function hoverAnswered(links: DefinitionLinks, probes: () => Probe[], offset: number, found: boolean): void { - links.point(offset) - vi.advanceTimersByTime(HOVER_DELAY_MS) - links.answer(probes().at(-1)!.id, found) -} - -beforeEach(() => { - vi.useFakeTimers() -}) - -afterEach(() => { - vi.useRealTimers() -}) - -describe('definitionLinks 悬停', () => { - it('不用按修饰键:鼠标在名字上停稳就问,查得到就链上', () => { - const { links, probes } = setup(1) - links.point(54) - vi.advanceTimersByTime(HOVER_DELAY_MS - 1) - expect(probes()).toEqual([]) - - vi.advanceTimersByTime(1) - const [probe] = probes() - expect(probe).toEqual({ id: probe.id, version: 1, offset: 54 }) - expect(links.linked).toBeUndefined() - - links.answer(probe.id, true) - expect(links.linked).toBe(54) - }) - - it('划过的名字不问:停稳之前换了名字或移开都不问,只问最后停下的那个', () => { - const { links, probes } = setup(1) - links.point(10) - vi.advanceTimersByTime(HOVER_DELAY_MS / 2) - links.point(20) - vi.advanceTimersByTime(HOVER_DELAY_MS / 2) - links.point(undefined) - vi.advanceTimersByTime(HOVER_DELAY_MS) - expect(probes()).toEqual([]) - - links.point(30) - vi.advanceTimersByTime(HOVER_DELAY_MS) - expect(probes().map(probe => probe.offset)).toEqual([30]) - }) - +describe('definitionLinks', () => { it('问到「没有」后移开再指回来会重新问,语言扩展启动好了就能链上', () => { const { links, probes } = setup(1) - hoverAnswered(links, probes, 54, false) + links.point(54) + links.answer(probes()[0].id, false) expect(links.linked).toBeUndefined() links.point(undefined) - hoverAnswered(links, probes, 54, true) + links.point(54) const [first, second] = probes() expect(second).toEqual({ id: second.id, version: 1, offset: 54 }) expect(second.id).not.toBe(first.id) + + links.answer(second.id, true) expect(links.linked).toBe(54) }) + it('修饰键 + 点击名字总交给扩展端去跳,问到过「没有」也一样', () => { + const { links, sent, probes } = setup(1) + links.point(54) + links.answer(probes()[0].id, false) + + expect(links.click(54)).toBe(true) + expect(sent.at(-1)).toEqual({ type: 'goToDefinition', version: 1, offset: 54 }) + }) + it('还指着同一个名字不重问', () => { const { links, probes } = setup(1) links.point(54) - vi.advanceTimersByTime(HOVER_DELAY_MS) links.point(54) - vi.advanceTimersByTime(HOVER_DELAY_MS) expect(probes()).toHaveLength(1) }) it('迟到的旧回答丢掉,只认最近一次询问的回答', () => { const { links, probes } = setup(1) - for (const offset of [54, 79, 54]) { - links.point(offset) - vi.advanceTimersByTime(HOVER_DELAY_MS) - } + links.point(54) + links.point(79) + links.point(54) const [stale, , latest] = probes() links.answer(latest.id, true) links.answer(stale.id, false) expect(links.linked).toBe(54) }) - it('移开就不链接,之后到的回答也不再挂上', () => { + it('松开修饰键或移开就不链接,之后到的回答也不再挂上', () => { const { links, probes } = setup(1) - hoverAnswered(links, probes, 54, true) + links.point(54) + const [probe] = probes() + links.answer(probe.id, true) expect(links.linked).toBe(54) links.point(undefined) expect(links.linked).toBeUndefined() - links.answer(probes()[0].id, true) + links.answer(probe.id, true) expect(links.linked).toBeUndefined() }) it('换了文档版本:正指着的名字按新版本重问,旧版本的回答丢掉', () => { const { links, probes } = setup(1) links.point(54) - vi.advanceTimersByTime(HOVER_DELAY_MS) links.setVersion(2) - vi.advanceTimersByTime(HOVER_DELAY_MS) const [old, renewed] = probes() expect(renewed).toEqual({ id: renewed.id, version: 2, offset: 54 }) @@ -119,86 +80,15 @@ describe('definitionLinks 悬停', () => { expect(links.linked).toBe(54) }) - it('还没收到文档时不问', () => { - const { links, probes } = setup(undefined) + it('还没收到文档时不问,点击也不拦', () => { + const { links, sent } = setup(undefined) links.point(54) - vi.advanceTimersByTime(HOVER_DELAY_MS) - expect(probes()).toEqual([]) + expect(links.click(54)).toBe(false) + expect(sent).toEqual([]) }) - it('不再听鼠标后,等着发的询问不发', () => { - const { links, probes } = setup(1) - links.point(54) - links.dispose() - vi.advanceTimersByTime(HOVER_DELAY_MS) - expect(probes()).toEqual([]) - }) -}) - -describe('definitionLinks 点击', () => { - it('单击链接:拦下这次点击,等过双击间隔再跳', () => { - const { links, probes, jumps } = setup(1) - hoverAnswered(links, probes, 54, true) - - expect(links.click(54, 1, false)).toBe(true) - vi.advanceTimersByTime(DOUBLE_CLICK_WAIT_MS - 1) - expect(jumps()).toEqual([]) - vi.advanceTimersByTime(1) - expect(jumps()).toEqual([{ type: 'goToDefinition', version: 1, offset: 54 }]) - }) - - it('双击不跳:第二下按下时取消还没发出的跳转,第二下的点击也拦下', () => { - const { links, probes, jumps } = setup(1) - hoverAnswered(links, probes, 54, true) - - expect(links.click(54, 1, false)).toBe(true) - links.press(2) - expect(links.click(54, 2, true)).toBe(true) - vi.advanceTimersByTime(DOUBLE_CLICK_WAIT_MS) - expect(jumps()).toEqual([]) - }) - - it('单击后又单独按下一次(不算双击)不取消跳转', () => { - const { links, probes, jumps } = setup(1) - hoverAnswered(links, probes, 54, true) - - links.click(54, 1, false) - links.press(1) - vi.advanceTimersByTime(DOUBLE_CLICK_WAIT_MS) - expect(jumps()).toHaveLength(1) - }) - - it('拖选文字停在链接上松手不跳', () => { - const { links, probes, jumps } = setup(1) - hoverAnswered(links, probes, 54, true) - - expect(links.click(54, 1, true)).toBe(true) - vi.advanceTimersByTime(DOUBLE_CLICK_WAIT_MS) - expect(jumps()).toEqual([]) - }) - - it('没链上的名字点了不拦也不跳:还没问完、问到「没有」、点的不是指着的那个', () => { - const { links, probes, jumps } = setup(1) - links.point(54) - expect(links.click(54, 1, false)).toBe(false) - - vi.advanceTimersByTime(HOVER_DELAY_MS) - links.answer(probes()[0].id, false) - expect(links.click(54, 1, false)).toBe(false) - - hoverAnswered(links, probes, 79, true) - expect(links.click(54, 1, false)).toBe(false) - expect(links.click(undefined, 1, false)).toBe(false) - vi.advanceTimersByTime(DOUBLE_CLICK_WAIT_MS) - expect(jumps()).toEqual([]) - }) - - it('不再听鼠标后,等着发的跳转不发', () => { - const { links, probes, jumps } = setup(1) - hoverAnswered(links, probes, 54, true) - links.click(54, 1, false) - links.dispose() - vi.advanceTimersByTime(DOUBLE_CLICK_WAIT_MS) - expect(jumps()).toEqual([]) + it('没指着名字的点击不拦', () => { + const { links } = setup(1) + expect(links.click(undefined)).toBe(false) }) }) diff --git a/vscode/webview-ui/src/codeReader/definitionLinks.ts b/vscode/webview-ui/src/codeReader/definitionLinks.ts index 322edf6..cb169fa 100644 --- a/vscode/webview-ui/src/codeReader/definitionLinks.ts +++ b/vscode/webview-ui/src/codeReader/definitionLinks.ts @@ -1,25 +1,17 @@ import type { WebviewToExtension } from './types' -/** 鼠标在名字上停这么久才问定义:划过预览时路过的名字不问,免得一路打扰语言扩展 */ -export const HOVER_DELAY_MS = 100 - -/** 单击链接后等这么久才跳:双击的第二下在这之内按下就不跳,留给双击选词 */ -export const DOUBLE_CLICK_WAIT_MS = 250 - /** - * 鼠标指着名字时问不问定义、链接显示在哪、点击跳不跳。和编辑器一样,每回停到一个名字上都现问: + * 按住 ⌘ / Ctrl 指着名字时问不问定义、链接显示在哪:和编辑器一样,每回指到一个名字上都现问。 * 语言扩展(Pylance、tsserver)刚启动那几秒会答「没有」,所以回答不留到下次指过来时用。 - * 只管下标,不碰 DOM;指着的名字下标、点击拖没拖出选区由调用方给。 + * 只管下标,不碰 DOM;按住修饰键时指着的名字下标由调用方给,不按就给 undefined。 */ export class DefinitionLinks { private version: number | undefined - /** 鼠标指着的名字的下标 */ + /** 按住修饰键时指着的名字的下标 */ private target: number | undefined - /** 为 target 发出(或等着发)的最近一次询问;它的回答之外的迟到回答都丢掉 */ + /** 为 target 发出的最近一次询问;它的回答之外的迟到回答都丢掉 */ private request = 0 private found = false - private probeTimer: ReturnType | undefined - private jumpTimer: ReturnType | undefined constructor(private readonly post: (msg: WebviewToExtension) => void) {} @@ -36,7 +28,7 @@ export class DefinitionLinks { this.ask(this.target) } - /** 指着的名字变了;还指着同一个名字就不重问 */ + /** 指着的名字(或修饰键)变了;还指着同一个名字就不重问 */ point(offset: number | undefined): void { if (offset !== this.target) this.ask(offset) @@ -47,41 +39,19 @@ export class DefinitionLinks { this.found = found } - /** - * 点击落在 offset 处的名字上。count 是连击数(同 MouseEvent.detail),selected 表示这一下拖出了选区。 - * 返回 true 表示点的是链接,这次点击归跳转,不再给块上的按钮;只有没拖出选区的单击才跳, - * 而且等 DOUBLE_CLICK_WAIT_MS 再发——双击的第二下在这之内按下(press)就不跳。 - */ - click(offset: number | undefined, count: number, selected: boolean): boolean { - const { version } = this - if (offset === undefined || offset !== this.linked || version === undefined) + /** 修饰键 + 点击名字:交给扩展端去跳,返回 true 表示这次点击归跳转,不再给块上的按钮 */ + click(offset: number | undefined): boolean { + if (offset === undefined || this.version === undefined) return false - if (count === 1 && !selected) { - clearTimeout(this.jumpTimer) - this.jumpTimer = setTimeout(() => this.post({ type: 'goToDefinition', version, offset }), DOUBLE_CLICK_WAIT_MS) - } + this.post({ type: 'goToDefinition', version: this.version, offset }) return true } - /** 按下鼠标,count 同 MouseEvent.detail:是双击的第二下就取消还没发出的跳转 */ - press(count: number): void { - if (count > 1) - clearTimeout(this.jumpTimer) - } - - /** 不再听鼠标:等着发的询问、跳转都不发了 */ - dispose(): void { - this.point(undefined) - clearTimeout(this.jumpTimer) - } - private ask(offset: number | undefined): void { - clearTimeout(this.probeTimer) this.target = offset this.found = false - const id = ++this.request - const { version } = this - if (offset !== undefined && version !== undefined) - this.probeTimer = setTimeout(() => this.post({ type: 'probeDefinition', id, version, offset }), HOVER_DELAY_MS) + this.request++ + if (offset !== undefined && this.version !== undefined) + this.post({ type: 'probeDefinition', id: this.request, version: this.version, offset }) } } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 66543ef..815984f 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -14,7 +14,7 @@ export type TokenRole | 'hint' // 属性表里的说明词:未标注 无 N 个 默认导出 … | 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain' -/** offset:照抄自源码里的名字、字符串时它在源码里的起始下标,悬停、点击时拿它查定义;译词、提示词没有 */ +/** offset:照抄自源码里的名字、字符串时它在源码里的起始下标,⌘ / Ctrl 悬停、点击时拿它查定义;译词、提示词没有 */ export interface Segment { text: string, role: TokenRole, offset?: number } /** 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,按 Markdown 渲染成注释色的段落、列表、代码块 */ @@ -123,9 +123,9 @@ export type ExtensionToWebview /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进) * visibleLine:视口顶部那块的起始行,切回文本编辑器时扩展端把这行放到顶部 - * probeDefinition:鼠标停在带下标的名字上,问 offset 处有没有定义;id 标出是哪一次问,回答带回来; + * probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问 offset 处有没有定义;id 标出是哪一次问,回答带回来; * version 是这份下标所属的文档版本 - * goToDefinition:单击链接,跳到 offset 处名字的定义 + * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 * setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示 */ export type WebviewToExtension diff --git a/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts b/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts index ce67a71..b1fa7dd 100644 --- a/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts +++ b/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts @@ -2,6 +2,13 @@ import { useEffect, useState } from 'react' import { DefinitionLinks } from './definitionLinks' import { onMessage, postMessage } from './vscode' +/** 跳到定义用的修饰键,按平台习惯:macOS 是 ⌘,其余是 Ctrl。 */ +const IS_MAC = /Mac|iPhone|iPad/.test(navigator.userAgent) + +function modifierHeld(event: MouseEvent | KeyboardEvent): boolean { + return IS_MAC ? event.metaKey : event.ctrlKey +} + /** 事件落在的那个带源码下标的名字(SegmentText 渲染的 data-offset)。 */ function nameAt(target: EventTarget | null): HTMLElement | null { return target instanceof Element ? target.closest('[data-offset]') : null @@ -11,32 +18,29 @@ function offsetOf(el: HTMLElement | null): number | undefined { return el?.dataset.offset === undefined ? undefined : Number(el.dataset.offset) } -/** 眼下选中的那段;没选中文字是 null */ -function selectedRange(): Range | null { - const selection = document.getSelection() - return selection && !selection.isCollapsed && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null -} - -function sameRange(a: Range, b: Range): boolean { - return a.compareBoundaryPoints(Range.START_TO_START, b) === 0 && a.compareBoundaryPoints(Range.END_TO_END, b) === 0 -} - -/** 指针下的名字;查得到定义时把链接样式(data-link)挂到它上面。 */ +/** 指针下的名字、修饰键按没按;把链接样式(data-link)挂到指着的那个名字上。 */ class Pointer { private hovered: HTMLElement | null = null + private held = false private linked: HTMLElement | null = null constructor(private readonly links: DefinitionLinks) {} - move(target: EventTarget | null): void { + move(target: EventTarget | null, held: boolean): void { this.hovered = nameAt(target) + this.held = held this.sync() } - /** 把指着的名字告诉 links,再按它的回答挂上或摘掉链接样式 */ + hold(held: boolean): void { + this.held = held + this.sync() + } + + /** 把按住修饰键时指着的名字告诉 links,再按它的回答挂上或摘掉链接样式 */ sync(): void { const offset = offsetOf(this.hovered) - this.links.point(offset) + this.links.point(this.held ? offset : undefined) const link = offset !== undefined && offset === this.links.linked ? this.hovered : null if (link === this.linked) return @@ -47,7 +51,7 @@ class Pointer { } /** - * 鼠标停在查得到定义的名字上时,名字显示成链接(不用按修饰键),单击跳到定义;拖选文字、双击选词不跳。 + * 和 VS Code 编辑器里一样:按住 ⌘ / Ctrl、鼠标停在查得到定义的名字上时,名字显示成链接,点一下跳到定义。 * version 是当前显示的文档版本,名字的下标属于它。 */ export function useDefinitionLinks(version: number | undefined): void { @@ -60,23 +64,16 @@ export function useDefinitionLinks(version: number | undefined): void { }, [links, pointer, version]) useEffect(() => { - // mouseover 只在指针下换了元素时来,滚动后指针下换了名字也来 - const onOver = (event: MouseEvent): void => pointer.move(event.target) + const onMove = (event: MouseEvent): void => pointer.move(event.target, modifierHeld(event)) const onOut = (event: MouseEvent): void => { if (event.relatedTarget === null) - pointer.move(null) + pointer.move(null, false) } - // 按下时就在的选区:按在它里面时浏览器要等点击之后才清掉,不能当成这一下拖出来的 - let pressedOn: Range | null = null - const onDown = (event: MouseEvent): void => { - pressedOn = selectedRange() - links.press(event.detail) - } - // 点链接归跳转:拦下这次点击,不让它再触发块上的编辑切换、折叠 + const onKey = (event: KeyboardEvent): void => pointer.hold(modifierHeld(event)) + const onBlur = (): void => pointer.hold(false) + // ⌘ / Ctrl + 点击名字归跳转:拦下这次点击,不让它再触发块上的编辑切换、折叠 const onClick = (event: MouseEvent): void => { - const range = selectedRange() - const selected = range !== null && !(pressedOn !== null && sameRange(pressedOn, range)) - if (!links.click(offsetOf(nameAt(event.target)), event.detail, selected)) + if (!links.click(modifierHeld(event) ? offsetOf(nameAt(event.target)) : undefined)) return event.preventDefault() event.stopPropagation() @@ -87,19 +84,21 @@ export function useDefinitionLinks(version: number | undefined): void { links.answer(msg.id, msg.found) pointer.sync() }) - document.addEventListener('mouseover', onOver) + document.addEventListener('mousemove', onMove) document.addEventListener('mouseout', onOut) // 捕获阶段:先于块里的按钮、折叠箭头拿到点击 - document.addEventListener('mousedown', onDown, true) document.addEventListener('click', onClick, true) + window.addEventListener('keydown', onKey) + window.addEventListener('keyup', onKey) + window.addEventListener('blur', onBlur) return () => { off() - document.removeEventListener('mouseover', onOver) + document.removeEventListener('mousemove', onMove) document.removeEventListener('mouseout', onOut) - document.removeEventListener('mousedown', onDown, true) document.removeEventListener('click', onClick, true) - pointer.move(null) - links.dispose() + window.removeEventListener('keydown', onKey) + window.removeEventListener('keyup', onKey) + window.removeEventListener('blur', onBlur) } }, [links, pointer]) }