diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 63a01b1..b28b766 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -77,13 +77,13 @@ ### 跳到定义 - 能点的:预览里(属性表的名称、类型、来源、导入项、默认值、值,以及一行译文)照抄自源码的名字——标识符、属性名、类型名、点分模块名的每一节(`luffy_agent.core.db` 三节各自能点,和 VS Code 一样)、字符串(TypeScript 的模块路径 `'./x'` 就是字符串)。翻出来的关键字、运算符、说明词不能点。 -- 链接和编辑器里一样:只有按住修饰键(macOS ⌘,其余平台 Ctrl)、鼠标停在**查得到定义**的名字上时,名字加下划线、换成链接色(`--vscode-editorLink-activeForeground`)、光标变手形;松开修饰键或移开就恢复。有没有定义问扩展端(`vscode.executeDefinitionProvider`,文档 uri + 名字在源码里的位置),回答按文档版本缓存,同一版本里每个名字只问一次,旧版本的迟到回答丢掉。 +- 链接和编辑器里一样:只有按住修饰键(macOS ⌘,其余平台 Ctrl)、鼠标停在**查得到定义**的名字上时,名字加下划线、换成链接色(`--vscode-editorLink-activeForeground`)、光标变手形;松开修饰键或移开就恢复。有没有定义问扩展端(`vscode.executeDefinitionProvider`,文档 uri + 名字在源码里的位置);和编辑器一样,每回指到一个名字上(或指着名字按下修饰键、换了文档版本)都现问,回答不留到下次用——Pylance、tsserver 刚启动那几秒会答「没有」;只认最近一次询问的回答,迟到的丢掉。 - ⌘ / Ctrl + 点击: - 一处定义,在当前文件:原地滚过去; - 一处定义,另一个文件,语言有代码阅读:在同一个编辑器组用预览打开(`vscode.openWith`),滚过去;这个文件的预览已经在这一组开着就切过去、发给它,不重新打开; - 一处定义,语言没有代码阅读:在同一个编辑器组用普通编辑器打开,选中定义处的名字; - 几处定义:先弹挑选框(每项「相对路径:行号」,下面一行是那行源码),挑一处再按上面跳; - - 没有定义:不动(本来也不显示链接)。 + - 没有定义:不动。 - 和普通编辑器里 ⌘ + 点击一样:目标是否按「预览标签」打开听 `workbench.editor.enablePreview`;`workbench.editor.enablePreviewFromCodeNavigation` 为假(默认)时先把当前预览标签固定住,免得被目标替掉。 - 滚过去:目标块是显示着定义处那个名字的块(找不到就取包含定义那一行的块);它的祖先块折叠着就展开,名字在折叠的属性行子行里(参数、导入项)就展开那一行。整块已在视口里不滚,否则滚到视口正中(比视口高就顶部对齐)。然后目标块的边框和底色往聚焦色(`--vscode-focusBorder`)闪三下,共约 1.5 秒后复原;同一块再跳一次从头闪;系统开了「减少动态效果」时改成静止高亮 1.5 秒。 - 不按修饰键的点击照旧;⌘ / Ctrl + 点击名字不会触发块的编辑切换、折叠。 @@ -343,9 +343,9 @@ type BlockView = BlockViewBase & ({ translation: Segment[] } | { properties: Pro - 扩展 → webview: - `{ type: 'document', version, languageId, blocks: BlockView[], translators: { id, label }[], translatorId, error?: string, reveal?: Reveal }`,文档每次变化(防抖 150ms)重发。`reveal = { line, definition? }` 只随新预览的第一份文档走:从文本编辑器切过来时是它顶部那一行(滚到包含这行的块、顶到顶部,折叠的不展开);跳到定义时还带 `definition`(定义处名字在源码里的下标),按「跳到定义」一节展开、滚动、闪。 - `{ type: 'reveal', line, definition? }`:同上,发给已经开着的预览。页面正在重新加载(标签页切回来)时收不到,扩展端改成随它 `ready` 后的第一份文档带过去。 - - `{ type: 'definitionProbed', version, offset, found }`:回答 `probeDefinition`。 + - `{ type: 'definitionProbed', id, found }`:回答第 `id` 次 `probeDefinition`。 - webview → 扩展: - - `{ type: 'probeDefinition', version, offset }`:按住修饰键悬停到带 `offset` 的名字上时问有没有定义;`version` 不是当前文档版本就不答。 + - `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。 - `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。 - `{ type: 'ready' }` - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 99ed837..8a6d5e1 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -30,17 +30,18 @@ export interface DocumentMessage { /** * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) - * definitionProbed:probeDefinition 的回答,found 表示 VS Code 查得到定义 + * definitionProbed:id 那次 probeDefinition 的回答,found 表示 VS Code 查得到定义 */ export type ExtensionToWebview = | DocumentMessage | ({ type: 'reveal' } & Reveal) - | { type: 'definitionProbed', version: number, offset: number, found: boolean } + | { type: 'definitionProbed', id: number, found: boolean } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) * visibleLine:预览视口顶部那块的起始行,切回文本编辑器时把这行放到顶部 - * probeDefinition:按住 ⌘ / Ctrl 悬停到带下标的名字上,问源码 offset 处有没有定义;version 是这份下标所属的文档版本 + * probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问源码 offset 处有没有定义;id 标出是哪一次问,回答带回去; + * version 是这份下标所属的文档版本 * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 */ export type WebviewToExtension @@ -48,5 +49,5 @@ export type WebviewToExtension | { type: 'edit', startLine: number, endLine: number, text: string } | { type: 'selectTranslator', id: string } | { type: 'visibleLine', line: number } - | { type: 'probeDefinition', version: number, offset: number } + | { type: 'probeDefinition', id: number, version: number, offset: number } | { type: 'goToDefinition', version: number, offset: number } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index ec05bcf..5aa6431 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -180,21 +180,21 @@ class Preview { this.visibleLine = msg.line return case 'probeDefinition': - await this.probeDefinition(msg.version, msg.offset) + await this.probeDefinition(msg.id, msg.version, msg.offset) return case 'goToDefinition': await this.goToDefinition(msg.version, msg.offset) } } - /** 回答 webview:offset 处的名字有没有定义。下标属于别的文档版本时不答,webview 收到新文档后会重新问 */ - private async probeDefinition(version: number, offset: number): Promise { + /** 回答 webview 第 id 次询问:offset 处的名字有没有定义。下标属于别的文档版本时不答,webview 收到新文档后会重新问 */ + private async probeDefinition(id: number, version: number, offset: number): Promise { const { document } = this if (version !== document.version) return const found = await hasDefinition(document.uri, document.positionAt(offset)) if (version === document.version && this.ready) - this.post({ type: 'definitionProbed', version, offset, found }) + this.post({ type: 'definitionProbed', id, found }) } /** ⌘ / Ctrl + 点击:跳到 offset 处名字的定义,几处时先让用户挑。 */ diff --git a/vscode/webview-ui/src/codeReader/definitionLinks.test.ts b/vscode/webview-ui/src/codeReader/definitionLinks.test.ts new file mode 100644 index 0000000..c59f0b2 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/definitionLinks.test.ts @@ -0,0 +1,94 @@ +import type { WebviewToExtension } from './types' +import { describe, expect, it } from 'vitest' +import { DefinitionLinks } from './definitionLinks' + +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 = (): { 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 } +} + +describe('definitionLinks', () => { + it('问到「没有」后移开再指回来会重新问,语言扩展启动好了就能链上', () => { + const { links, probes } = setup(1) + links.point(54) + links.answer(probes()[0].id, false) + expect(links.linked).toBeUndefined() + + links.point(undefined) + 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) + links.point(54) + expect(probes()).toHaveLength(1) + }) + + it('迟到的旧回答丢掉,只认最近一次询问的回答', () => { + const { links, probes } = setup(1) + 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('松开修饰键或移开就不链接,之后到的回答也不再挂上', () => { + const { links, probes } = setup(1) + 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(probe.id, true) + expect(links.linked).toBeUndefined() + }) + + it('换了文档版本:正指着的名字按新版本重问,旧版本的回答丢掉', () => { + const { links, probes } = setup(1) + links.point(54) + links.setVersion(2) + const [old, renewed] = probes() + expect(renewed).toEqual({ id: renewed.id, version: 2, offset: 54 }) + + links.answer(old.id, true) + expect(links.linked).toBeUndefined() + links.answer(renewed.id, true) + expect(links.linked).toBe(54) + }) + + it('还没收到文档时不问,点击也不拦', () => { + const { links, sent } = setup(undefined) + links.point(54) + expect(links.click(54)).toBe(false) + expect(sent).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 new file mode 100644 index 0000000..cb169fa --- /dev/null +++ b/vscode/webview-ui/src/codeReader/definitionLinks.ts @@ -0,0 +1,57 @@ +import type { WebviewToExtension } from './types' + +/** + * 按住 ⌘ / Ctrl 指着名字时问不问定义、链接显示在哪:和编辑器一样,每回指到一个名字上都现问。 + * 语言扩展(Pylance、tsserver)刚启动那几秒会答「没有」,所以回答不留到下次指过来时用。 + * 只管下标,不碰 DOM;按住修饰键时指着的名字下标由调用方给,不按就给 undefined。 + */ +export class DefinitionLinks { + private version: number | undefined + /** 按住修饰键时指着的名字的下标 */ + private target: number | undefined + /** 为 target 发出的最近一次询问;它的回答之外的迟到回答都丢掉 */ + private request = 0 + private found = false + + constructor(private readonly post: (msg: WebviewToExtension) => void) {} + + /** 链接该显示在哪个下标的名字上;没有就是 undefined */ + get linked(): number | undefined { + return this.found ? this.target : undefined + } + + /** 换了一份文档:下标换了意思,正指着的名字按新版本重问 */ + setVersion(version: number | undefined): void { + if (version === this.version) + return + this.version = version + this.ask(this.target) + } + + /** 指着的名字(或修饰键)变了;还指着同一个名字就不重问 */ + point(offset: number | undefined): void { + if (offset !== this.target) + this.ask(offset) + } + + answer(id: number, found: boolean): void { + if (id === this.request && this.target !== undefined) + this.found = found + } + + /** 修饰键 + 点击名字:交给扩展端去跳,返回 true 表示这次点击归跳转,不再给块上的按钮 */ + click(offset: number | undefined): boolean { + if (offset === undefined || this.version === undefined) + return false + this.post({ type: 'goToDefinition', version: this.version, offset }) + return true + } + + private ask(offset: number | undefined): void { + this.target = offset + this.found = false + 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 6464b7a..8b1eea3 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -69,17 +69,18 @@ export interface DocumentMessage { /** * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) - * definitionProbed:probeDefinition 的回答,found 表示查得到定义 + * definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义 */ export type ExtensionToWebview = | DocumentMessage | ({ type: 'reveal' } & Reveal) - | { type: 'definitionProbed', version: number, offset: number, found: boolean } + | { type: 'definitionProbed', id: number, found: boolean } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进) * visibleLine:视口顶部那块的起始行,切回文本编辑器时扩展端把这行放到顶部 - * probeDefinition:按住 ⌘ / Ctrl 悬停到带下标的名字上,问 offset 处有没有定义;version 是这份下标所属的文档版本 + * probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问 offset 处有没有定义;id 标出是哪一次问,回答带回来; + * version 是这份下标所属的文档版本 * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 */ export type WebviewToExtension @@ -87,5 +88,5 @@ export type WebviewToExtension | { type: 'edit', startLine: number, endLine: number, text: string } | { type: 'selectTranslator', id: string } | { type: 'visibleLine', line: number } - | { type: 'probeDefinition', version: number, offset: number } + | { type: 'probeDefinition', id: number, version: number, offset: number } | { type: 'goToDefinition', version: number, offset: number } diff --git a/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts b/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts index 4692b9f..b1fa7dd 100644 --- a/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts +++ b/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts @@ -1,5 +1,5 @@ -import type { WebviewToExtension } from './types' import { useEffect, useState } from 'react' +import { DefinitionLinks } from './definitionLinks' import { onMessage, postMessage } from './vscode' /** 跳到定义用的修饰键,按平台习惯:macOS 是 ⌘,其余是 Ctrl。 */ @@ -18,63 +18,30 @@ function offsetOf(el: HTMLElement | null): number | undefined { return el?.dataset.offset === undefined ? undefined : Number(el.dataset.offset) } -/** - * 和 VS Code 编辑器里一样:按住 ⌘ / Ctrl、鼠标停在查得到定义的名字上时,名字显示成链接(data-link),点一下跳到定义。 - * 有没有定义问扩展端,回答按文档版本缓存:同一版本里每个名字只问一次,换了版本清空,迟到的旧版本回答丢掉。 - */ -class DefinitionLinks { - private version: number | undefined - /** 这个版本里问过的下标:true / false 是回答,undefined 是问了还没回 */ - private readonly answers = new Map() +/** 指针下的名字、修饰键按没按;把链接样式(data-link)挂到指着的那个名字上。 */ +class Pointer { private hovered: HTMLElement | null = null private held = false private linked: HTMLElement | null = null - constructor(private readonly post: (msg: WebviewToExtension) => void) {} + constructor(private readonly links: DefinitionLinks) {} - setVersion(version: number | undefined): void { - if (version === this.version) - return - this.version = version - this.answers.clear() - this.update() - } - - point(target: EventTarget | null, held: boolean): void { + move(target: EventTarget | null, held: boolean): void { this.hovered = nameAt(target) this.held = held - this.update() + this.sync() } hold(held: boolean): void { this.held = held - this.update() + this.sync() } - answer(version: number, offset: number, found: boolean): void { - if (version !== this.version) - return - this.answers.set(offset, found) - this.update() - } - - /** ⌘ / Ctrl + 点击名字:跳到定义,并拦下这次点击,不让它再触发块上的切换。已知没有定义的名字照常点。 */ - click(event: MouseEvent): void { - const offset = modifierHeld(event) ? offsetOf(nameAt(event.target)) : undefined - if (offset === undefined || this.version === undefined || this.answers.get(offset) === false) - return - event.preventDefault() - event.stopPropagation() - this.post({ type: 'goToDefinition', version: this.version, offset }) - } - - private update(): void { - const offset = this.held ? offsetOf(this.hovered) : undefined - if (offset !== undefined && this.version !== undefined && !this.answers.has(offset)) { - this.answers.set(offset, undefined) - this.post({ type: 'probeDefinition', version: this.version, offset }) - } - const link = offset !== undefined && this.answers.get(offset) === true ? this.hovered : null + /** 把按住修饰键时指着的名字告诉 links,再按它的回答挂上或摘掉链接样式 */ + sync(): void { + const offset = offsetOf(this.hovered) + this.links.point(this.held ? offset : undefined) + const link = offset !== undefined && offset === this.links.linked ? this.hovered : null if (link === this.linked) return this.linked?.removeAttribute('data-link') @@ -83,26 +50,39 @@ class DefinitionLinks { } } -/** 预览里的名字能 ⌘ / Ctrl + 点击跳到定义;version 是当前显示的文档版本,名字的下标属于它。 */ +/** + * 和 VS Code 编辑器里一样:按住 ⌘ / Ctrl、鼠标停在查得到定义的名字上时,名字显示成链接,点一下跳到定义。 + * version 是当前显示的文档版本,名字的下标属于它。 + */ export function useDefinitionLinks(version: number | undefined): void { const [links] = useState(() => new DefinitionLinks(postMessage)) + const [pointer] = useState(() => new Pointer(links)) useEffect(() => { links.setVersion(version) - }, [links, version]) + pointer.sync() + }, [links, pointer, version]) useEffect(() => { - const onMove = (event: MouseEvent): void => links.point(event.target, modifierHeld(event)) + const onMove = (event: MouseEvent): void => pointer.move(event.target, modifierHeld(event)) const onOut = (event: MouseEvent): void => { if (event.relatedTarget === null) - links.point(null, false) + pointer.move(null, false) + } + const onKey = (event: KeyboardEvent): void => pointer.hold(modifierHeld(event)) + const onBlur = (): void => pointer.hold(false) + // ⌘ / Ctrl + 点击名字归跳转:拦下这次点击,不让它再触发块上的编辑切换、折叠 + const onClick = (event: MouseEvent): void => { + if (!links.click(modifierHeld(event) ? offsetOf(nameAt(event.target)) : undefined)) + return + event.preventDefault() + event.stopPropagation() } - const onKey = (event: KeyboardEvent): void => links.hold(modifierHeld(event)) - const onBlur = (): void => links.hold(false) - const onClick = (event: MouseEvent): void => links.click(event) const off = onMessage((msg) => { - if (msg.type === 'definitionProbed') - links.answer(msg.version, msg.offset, msg.found) + if (msg.type !== 'definitionProbed') + return + links.answer(msg.id, msg.found) + pointer.sync() }) document.addEventListener('mousemove', onMove) document.addEventListener('mouseout', onOut) @@ -120,5 +100,5 @@ export function useDefinitionLinks(version: number | undefined): void { window.removeEventListener('keyup', onKey) window.removeEventListener('blur', onBlur) } - }, [links]) + }, [links, pointer]) }