From 4e146eb48f79abaf3013048a666a58e6f1ad87b7 Mon Sep 17 00:00:00 2001 From: cruldra Date: Fri, 25 Sep 2026 12:54:45 +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=E9=A2=84=E8=A7=88=E6=8C=89=E4=BD=8F=20?= =?UTF-8?q?=E2=8C=98=20/=20Ctrl=20=E7=82=B9=E5=90=8D=E5=AD=97=E8=B7=B3?= =?UTF-8?q?=E5=88=B0=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 | 32 ++++- vscode/src/codeReader/contract.ts | 6 +- .../editor/definitionAction.test.ts | 44 ++++++ .../src/codeReader/editor/definitionAction.ts | 31 ++++ vscode/src/codeReader/editor/definitions.ts | 68 +++++++++ vscode/src/codeReader/editor/messages.ts | 26 +++- vscode/src/codeReader/editor/previewEditor.ts | 132 +++++++++++++++--- vscode/src/codeReader/editor/switchEditor.ts | 2 +- .../codeReader/translators/zhTemplate.test.ts | 118 +++++++++++++++- .../src/codeReader/translators/zhTemplate.ts | 36 +++-- vscode/webview-ui/src/codeReader/App.tsx | 21 ++- .../webview-ui/src/codeReader/BlockTree.tsx | 1 + .../webview-ui/src/codeReader/SegmentText.tsx | 4 +- .../webview-ui/src/codeReader/codeReader.css | 27 ++++ .../webview-ui/src/codeReader/reveal.test.ts | 88 ++++++++++++ vscode/webview-ui/src/codeReader/reveal.ts | 65 +++++++++ vscode/webview-ui/src/codeReader/types.ts | 29 +++- .../src/codeReader/useDefinitionLinks.ts | 124 ++++++++++++++++ vscode/webview-ui/src/codeReader/useReveal.ts | 63 +++++++++ .../src/codeReader/useScrollPosition.ts | 13 +- 20 files changed, 870 insertions(+), 60 deletions(-) create mode 100644 vscode/src/codeReader/editor/definitionAction.test.ts create mode 100644 vscode/src/codeReader/editor/definitionAction.ts create mode 100644 vscode/src/codeReader/editor/definitions.ts create mode 100644 vscode/webview-ui/src/codeReader/reveal.test.ts create mode 100644 vscode/webview-ui/src/codeReader/reveal.ts create mode 100644 vscode/webview-ui/src/codeReader/useDefinitionLinks.ts create mode 100644 vscode/webview-ui/src/codeReader/useReveal.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index a73ed59..69cc663 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -20,6 +20,7 @@ | 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 | | 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 | | 折叠 | 有子块的块、属性表里带子行的行能折叠,默认全部展开 | +| 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | | 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠;不做新建 / 重命名 / 删除 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | | 函数、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | @@ -71,6 +72,20 @@ 值里的表达式只翻落在块头部里的那一段:`const v = useMemo(() => { … })` 这种值带语句块主体回调的块,回调主体是子块,值只翻到 `useMemo(() =>`,和块头部一致。 +### 跳到定义 + +- 能点的:预览里(属性表的名称、类型、来源、导入项、默认值、值,以及一行译文)照抄自源码的名字——标识符、属性名、类型名、点分模块名的每一节(`luffy_agent.core.db` 三节各自能点,和 VS Code 一样)、字符串(TypeScript 的模块路径 `'./x'` 就是字符串)。翻出来的关键字、运算符、说明词不能点。 +- 链接和编辑器里一样:只有按住修饰键(macOS ⌘,其余平台 Ctrl)、鼠标停在**查得到定义**的名字上时,名字加下划线、换成链接色(`--vscode-editorLink-activeForeground`)、光标变手形;松开修饰键或移开就恢复。有没有定义问扩展端(`vscode.executeDefinitionProvider`,文档 uri + 名字在源码里的位置),回答按文档版本缓存,同一版本里每个名字只问一次,旧版本的迟到回答丢掉。 +- ⌘ / Ctrl + 点击: + - 一处定义,在当前文件:原地滚过去; + - 一处定义,另一个文件,语言有代码阅读:在同一个编辑器组用预览打开(`vscode.openWith`),滚过去;这个文件的预览已经在这一组开着就切过去、发给它,不重新打开; + - 一处定义,语言没有代码阅读:在同一个编辑器组用普通编辑器打开,选中定义处的名字; + - 几处定义:先弹挑选框(每项「相对路径:行号」,下面一行是那行源码),挑一处再按上面跳; + - 没有定义:不动(本来也不显示链接)。 + - 和普通编辑器里 ⌘ + 点击一样:目标是否按「预览标签」打开听 `workbench.editor.enablePreview`;`workbench.editor.enablePreviewFromCodeNavigation` 为假(默认)时先把当前预览标签固定住,免得被目标替掉。 +- 滚过去:目标块是显示着定义处那个名字的块(找不到就取包含定义那一行的块);它的祖先块折叠着就展开,名字在折叠的属性行子行里(参数、导入项)就展开那一行。整块已在视口里不滚,否则滚到视口正中(比视口高就顶部对齐)。然后目标块的边框和底色往聚焦色(`--vscode-focusBorder`)闪三下,共约 1.5 秒后复原;同一块再跳一次从头闪;系统开了「减少动态效果」时改成静止高亮 1.5 秒。 +- 不按修饰键的点击照旧;⌘ / Ctrl + 点击名字不会触发块的编辑切换、折叠。 + ## 通用契约 放在 `vscode/src/codeReader/contract.ts`,语法树类型用 `@vscode/tree-sitter-wasm` 自带的 web-tree-sitter 类型。 @@ -150,7 +165,12 @@ export type TokenRole = | 'hint' // 属性表里的说明词:未标注 无 N 个 默认导出 … | 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain' -export interface Segment { text: string, role: TokenRole } +/** + * offset:这段照抄自源码里的名字(标识符、属性名、类型名、点分模块名的一节)或字符串时,它在源码里的起始下标, + * 预览拿它向 VS Code 查定义、跳过去;译词、提示词没有。由从语法树叶子生成这段的一方填(模板直译引擎的译文和属性表); + * 第三方翻译插件可以不填,不填就不能跳。 + */ +export interface Segment { text: string, role: TokenRole, offset?: number } /** 属性表里展开的一个子行:几列对齐的格子。 */ export interface PropertyItem { cells: Segment[][] } @@ -315,8 +335,13 @@ interface BlockViewBase { type BlockView = BlockViewBase & ({ translation: Segment[] } | { properties: PropertyRow[] }) ``` -- 扩展 → webview:`{ type: 'document', version, languageId, blocks: BlockView[], translators: { id, label }[], translatorId, error?: string }`,文档每次变化(防抖 150ms)重发。 +- 扩展 → 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`。 - webview → 扩展: + - `{ type: 'probeDefinition', version, offset }`:按住修饰键悬停到带 `offset` 的名字上时问有没有定义;`version` 不是当前文档版本就不答。 + - `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。 - `{ type: 'ready' }` - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 @@ -359,6 +384,8 @@ vscode/src/codeReader/ explorer/fileTree.ts 侧栏文件树 editor/previewEditor.ts CustomTextEditorProvider editor/messages.ts 消息类型 + editor/definitions.ts 查定义(vscode.executeDefinitionProvider)、几处定义的挑选框 + editor/definitionAction.ts 查到的定义 → 怎么跳(纯函数) index.ts registerCodeReader(context): CodeReaderApi vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;属性表在 PropertyGrid.tsx) ``` @@ -370,6 +397,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、导入、常量、变量块只带属性表,其余块只带译文。 - 行替换:多行块替换后缩进正确。 +- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。 ## 收尾 diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 2daaa76..42ae3cc 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -93,7 +93,11 @@ export type TokenRole | 'hint' // 属性表里的说明词:未标注 无 N 个 默认导出 … | 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain' -export interface Segment { text: string, role: TokenRole } +/** + * 一段着色的文字。offset:这段照抄自源码里的名字(标识符、属性名、类型名、点分模块名的一节)或字符串时, + * 它在源码里的起始下标(同 SourceSpan 的下标),预览拿它向 VS Code 查定义、跳过去;译词、提示词没有。 + */ +export interface Segment { text: string, role: TokenRole, offset?: number } /** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值。 */ export interface PropertyItem { cells: Segment[][] } diff --git a/vscode/src/codeReader/editor/definitionAction.test.ts b/vscode/src/codeReader/editor/definitionAction.test.ts new file mode 100644 index 0000000..8e42f4a --- /dev/null +++ b/vscode/src/codeReader/editor/definitionAction.test.ts @@ -0,0 +1,44 @@ +import type { DefinitionTarget } from './definitionAction' +import { describe, expect, it } from 'vitest' +import { definitionAction } from './definitionAction' + +const CURRENT = 'file:///ws/app/routes.py' + +function at(uri: string, line: number, supported = true, character = 4): DefinitionTarget { + return { uri, line, character, supported } +} + +describe('⌘ + 点击查到定义之后怎么跳', () => { + it('没有定义:不动', () => { + expect(definitionAction([], CURRENT)).toEqual({ kind: 'none' }) + }) + + it('定义在当前文件:预览原地滚过去', () => { + const target = at(CURRENT, 12) + expect(definitionAction([target], CURRENT)).toEqual({ kind: 'here', target }) + }) + + it('另一个文件、语言有代码阅读:用预览打开', () => { + const target = at('file:///ws/luffy_agent/core/db.py', 30) + expect(definitionAction([target], CURRENT)).toEqual({ kind: 'preview', target }) + }) + + it('另一个文件、语言没有代码阅读:用普通文本编辑器打开', () => { + const target = at('file:///ws/native/ext.c', 7, false) + expect(definitionAction([target], CURRENT)).toEqual({ kind: 'text', target }) + }) + + it('几处定义:让用户挑,挑好的一处再按上面的规则跳', () => { + const stub = at('file:///ws/typings/db.pyi', 3) + const source = at('file:///ws/luffy_agent/core/db.py', 30) + expect(definitionAction([stub, source], CURRENT)).toEqual({ kind: 'pick', targets: [stub, source] }) + expect(definitionAction([source], CURRENT)).toEqual({ kind: 'preview', target: source }) + }) + + it('同一处报了几遍算一处;同一行不同列算两处', () => { + const target = at('file:///ws/luffy_agent/core/db.py', 30) + expect(definitionAction([target, { ...target }], CURRENT)).toEqual({ kind: 'preview', target }) + const other = at(target.uri, 30, true, 20) + expect(definitionAction([target, other], CURRENT)).toEqual({ kind: 'pick', targets: [target, other] }) + }) +}) diff --git a/vscode/src/codeReader/editor/definitionAction.ts b/vscode/src/codeReader/editor/definitionAction.ts new file mode 100644 index 0000000..b7f2f98 --- /dev/null +++ b/vscode/src/codeReader/editor/definitionAction.ts @@ -0,0 +1,31 @@ +/** 一处定义。uri 是 Uri.toString(),用来和当前文件比、去重;supported:目标文件的语言有代码阅读。 */ +export interface DefinitionTarget { + uri: string + line: number + character: number + supported: boolean +} + +/** + * ⌘ / Ctrl + 点击查到定义之后怎么做: + * none 不动;pick 让用户从几处里挑一处,挑好的那处再交给 definitionAction 决定; + * here 定义就在当前文件,预览原地滚过去;preview 用代码阅读预览打开目标文件;text 目标语言没有代码阅读,用普通文本编辑器打开。 + */ +export type DefinitionAction + = | { kind: 'none' } + | { kind: 'pick', targets: T[] } + | { kind: 'here' | 'preview' | 'text', target: T } + +/** 定义提供者查到的几处(可能重复)→ 怎么跳。current 是当前文件的 Uri.toString()。 */ +export function definitionAction(targets: readonly T[], current: string): DefinitionAction { + const unique = targets.filter((target, i) => + targets.findIndex(other => other.uri === target.uri && other.line === target.line && other.character === target.character) === i) + if (unique.length === 0) + return { kind: 'none' } + if (unique.length > 1) + return { kind: 'pick', targets: unique } + const [target] = unique + if (target.uri === current) + return { kind: 'here', target } + return { kind: target.supported ? 'preview' : 'text', target } +} diff --git a/vscode/src/codeReader/editor/definitions.ts b/vscode/src/codeReader/editor/definitions.ts new file mode 100644 index 0000000..d1f49ed --- /dev/null +++ b/vscode/src/codeReader/editor/definitions.ts @@ -0,0 +1,68 @@ +import type { Location, LocationLink, Position, Range, Uri } from 'vscode' +import type { DefinitionTarget } from './definitionAction' +import { commands, window, workspace } from 'vscode' +import { logger } from '../../logging/logger' + +/** VS Code 查到的一处定义,加上打开目标文件才知道的信息。 */ +export interface Definition extends DefinitionTarget { + location: { uri: Uri, range: Range } + /** 定义处在目标文件里的下标,预览靠它找到是哪块、哪一行属性 */ + offset: number + /** 定义所在那一行的源码,列在挑选框里 */ + lineText: string +} + +/** 交给 VS Code 注册的定义提供者(Pylance、TypeScript 语言服务……),和普通编辑器里 ⌘ + 点击查的是同一套。 */ +async function provided(uri: Uri, position: Position): Promise<(Location | LocationLink)[]> { + try { + return await commands.executeCommand<(Location | LocationLink)[] | undefined>('vscode.executeDefinitionProvider', uri, position) ?? [] + } + catch (err) { + logger.add({ level: 'warn', source: 'codeReader', message: `查定义失败 ${uri.toString()}:${position.line + 1}:${err instanceof Error ? err.message : String(err)}` }) + return [] + } +} + +export async function hasDefinition(uri: Uri, position: Position): Promise { + return (await provided(uri, position)).length > 0 +} + +/** uri 里 position 处名字的所有定义;目标打不开成文本的(例如 Python 命名空间包指向的目录)略过。 */ +export async function findDefinitions(uri: Uri, position: Position, supports: (languageId: string) => boolean): Promise { + const found = await Promise.all((await provided(uri, position)).map(async (item): Promise => { + const location = 'targetUri' in item + ? { uri: item.targetUri, range: item.targetSelectionRange ?? item.targetRange } + : { uri: item.uri, range: item.range } + const { start } = location.range + try { + const document = await workspace.openTextDocument(location.uri) + return [{ + uri: location.uri.toString(), + line: start.line, + character: start.character, + supported: supports(document.languageId), + location, + offset: document.offsetAt(start), + lineText: document.lineAt(start.line).text, + }] + } + catch (err) { + logger.add({ level: 'warn', source: 'codeReader', message: `定义所在的 ${location.uri.toString()} 打不开:${err instanceof Error ? err.message : String(err)}` }) + return [] + } + })) + return found.flat() +} + +/** 几处定义里挑一处:每项是「相对路径:行号」,下面一行是那行源码。 */ +export async function pickDefinition(definitions: T[]): Promise { + const picked = await window.showQuickPick( + definitions.map(definition => ({ + label: `${workspace.asRelativePath(definition.location.uri)}:${definition.line + 1}`, + detail: definition.lineText.trim(), + definition, + })), + { placeHolder: '跳到哪一处定义', matchOnDetail: true }, + ) + return picked?.definition +} diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index e5b4727..99ed837 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -6,6 +6,15 @@ import type { BlockView } from '../blockView' export interface TranslatorOption { id: string, label: string } +/** + * 让预览滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset), + * 预览展开把它折叠住的块和属性行,再让那块闪几下。 + */ +export interface Reveal { + line: number + definition?: number +} + export interface DocumentMessage { type: 'document' /** TextDocument.version:webview 靠它判断是不是新一份文档 */ @@ -15,18 +24,29 @@ export interface DocumentMessage { translators: TranslatorOption[] translatorId: string error?: string - /** 从文本编辑器切过来时它顶部那一行:新预览只在第一份 document 里带,webview 滚到包含这行的块 */ - revealLine?: number + /** 新预览只在第一份 document 里带:从文本编辑器切过来时是它顶部那一行,跳到定义时是定义处 */ + reveal?: Reveal } -export type ExtensionToWebview = DocumentMessage +/** + * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) + * definitionProbed:probeDefinition 的回答,found 表示 VS Code 查得到定义 + */ +export type ExtensionToWebview + = | DocumentMessage + | ({ type: 'reveal' } & Reveal) + | { type: 'definitionProbed', version: number, offset: number, found: boolean } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) * visibleLine:预览视口顶部那块的起始行,切回文本编辑器时把这行放到顶部 + * probeDefinition:按住 ⌘ / Ctrl 悬停到带下标的名字上,问源码 offset 处有没有定义;version 是这份下标所属的文档版本 + * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 */ export type WebviewToExtension = | { type: 'ready' } | { type: 'edit', startLine: number, endLine: number, text: string } | { type: 'selectTranslator', id: string } | { type: 'visibleLine', line: number } + | { type: 'probeDefinition', 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 50431a9..ec05bcf 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,11 +1,14 @@ -import type { CustomTextEditorProvider, Disposable, TextDocument, Uri, WebviewPanel } from 'vscode' +import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode' import type { CodeReaderRegistry } from '../registry' import type { TreeSitterRuntime } from '../runtime' -import type { ExtensionToWebview, WebviewToExtension } from './messages' -import { Range, window, workspace, WorkspaceEdit } from 'vscode' +import type { Definition } from './definitions' +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' import { blockReplacement } from '../editText' +import { definitionAction } from './definitionAction' +import { findDefinitions, hasDefinition, pickDefinition } from './definitions' import { documentMessage } from './documentMessage' export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview' @@ -19,8 +22,8 @@ const REFRESH_DELAY_MS = 150 */ export class PreviewEditorProvider implements CustomTextEditorProvider { private readonly previews = new Set() - /** 按文档 uri:从文本编辑器切过来时它顶部那一行,等着交给马上要打开的新预览 */ - private readonly revealLines = new Map() + /** 按文档 uri:等着交给马上要打开的新预览的位置(从文本编辑器切过来时它顶部那一行,或跳到的定义) */ + private readonly reveals = new Map() constructor( private readonly extensionUri: Uri, @@ -32,8 +35,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri)] } panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html') const key = document.uri.toString() - const preview = new Preview(document, panel, this.registry, this.runtime, this.revealLines.get(key), languageId => this.refresh(languageId)) - this.revealLines.delete(key) + const preview = new Preview(document, panel, this, this.registry, this.runtime, this.reveals.get(key)) + this.reveals.delete(key) this.previews.add(preview) panel.onDidDispose(() => { this.previews.delete(preview) @@ -49,17 +52,24 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { } } - /** 用 open 打开 uri 的预览,新预览先滚到包含 line 的块。 */ - async openAtLine(uri: Uri, line: number, open: () => Thenable): Promise { + /** + * 用 open 打开 uri 的预览并滚到 reveal:新建的预览随第一份文档带过去; + * open 只是切到了已经开着的预览(没有新建)时,直接发给这个预览,不重新打开。 + */ + async openAt(uri: Uri, reveal: Reveal, open: () => Thenable): Promise { const key = uri.toString() - this.revealLines.set(key, line) + this.reveals.set(key, reveal) + let claimed: boolean try { await open() + claimed = this.reveals.get(key) !== reveal } finally { - // 切换命令等新预览解析完才返回;切换没成时也不能把这行留给以后别处的打开 - this.revealLines.delete(key) + // 打开命令等新预览解析完才返回;没打开成时也不能把这一处留给以后别处的打开 + this.reveals.delete(key) } + if (!claimed) + [...this.previews].find(p => p.uri.toString() === key && p.active)?.reveal(reveal) } /** uri 的预览视口顶部那块的起始行;几个编辑器组里都开着这个文件的预览时取活动的那个。 */ @@ -71,7 +81,7 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { } class Preview { - /** webview 发过 ready 之后才发文档,避免脚本还没起来时消息丢掉 */ + /** webview 发过 ready 之后才发消息;标签页切走时页面被销毁,切回来重新加载、再发 ready,这期间也不发 */ private ready = false /** 解析是异步的:只发最后一次请求的结果,慢的旧结果不能盖掉新的 */ private latest = 0 @@ -83,14 +93,21 @@ class Preview { constructor( private readonly document: TextDocument, private readonly panel: WebviewPanel, + private readonly owner: PreviewEditorProvider, private readonly registry: CodeReaderRegistry, private readonly runtime: TreeSitterRuntime, - /** 只随第一份真正发出去的文档走:之后页面因标签页切走重新加载,要按 webview 自己记的位置恢复 */ - private revealLine: number | undefined, - private readonly onTranslatorSelected: (languageId: string) => void, + /** + * 随下一份真正发出去的文档走、只走一次:新预览第一份文档要滚到的位置,或页面正在重新加载时收不到的 reveal。 + * 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复 + */ + private pendingReveal: Reveal | undefined, ) { this.disposables = [ panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)), + panel.onDidChangeViewState(() => { + if (!panel.visible) + this.ready = false + }), workspace.onDidChangeTextDocument((e) => { if (e.document === document && e.contentChanges.length > 0) this.schedulePush() @@ -116,11 +133,19 @@ class Preview { return const request = ++this.latest const message: ExtensionToWebview = await documentMessage(this.document, this.registry, this.runtime) - if (request !== this.latest) + if (request !== this.latest || !this.ready) return - const { revealLine } = this - this.revealLine = undefined - void this.panel.webview.postMessage(revealLine === undefined ? message : { ...message, revealLine }) + const reveal = this.pendingReveal + this.pendingReveal = undefined + this.post(reveal === undefined ? message : { ...message, reveal }) + } + + /** 滚到 reveal:页面在就直接发,正在重新加载就等它发 ready 后随文档带过去。 */ + reveal(reveal: Reveal): void { + if (this.ready) + this.post({ type: 'reveal', ...reveal }) + else + this.pendingReveal = reveal } dispose(): void { @@ -129,6 +154,10 @@ class Preview { disposable.dispose() } + private post(message: ExtensionToWebview): void { + void this.panel.webview.postMessage(message) + } + private schedulePush(): void { clearTimeout(this.timer) this.timer = setTimeout(() => void this.push(), REFRESH_DELAY_MS) @@ -145,13 +174,74 @@ class Preview { return case 'selectTranslator': await this.registry.selectTranslator(this.languageId, msg.id) - this.onTranslatorSelected(this.languageId) + this.owner.refresh(this.languageId) return case 'visibleLine': this.visibleLine = msg.line + return + case 'probeDefinition': + await this.probeDefinition(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 { + 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 }) + } + + /** ⌘ / Ctrl + 点击:跳到 offset 处名字的定义,几处时先让用户挑。 */ + private async goToDefinition(version: number, offset: number): Promise { + const { document } = this + if (version !== document.version) + return + const current = document.uri.toString() + const supports = (languageId: string): boolean => this.registry.languageFor(languageId) !== undefined + let action = definitionAction(await findDefinitions(document.uri, document.positionAt(offset), supports), current) + if (action.kind === 'pick') { + const picked = await pickDefinition(action.targets) + action = definitionAction(picked ? [picked] : [], current) + } + if (action.kind === 'none' || action.kind === 'pick') + return + const { target } = action + const reveal: Reveal = { line: target.line, definition: target.offset } + if (action.kind === 'here') { + this.reveal(reveal) + return + } + try { + await this.openDefinition(action.kind, target, reveal) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.add({ level: 'warn', source: 'codeReader', message: `没能打开定义 ${target.uri}:${message}` }) + void window.showErrorMessage(`代码阅读:没能打开定义所在的 ${workspace.asRelativePath(target.location.uri)}`) + } + } + + /** + * 和普通编辑器里 ⌘ + 点击一样:在同一个编辑器组打开目标(是否按「预览标签」打开听 VS Code 的设置), + * 并按 workbench.editor.enablePreviewFromCodeNavigation 把当前这个预览标签固定住,免得被目标替掉。 + */ + private async openDefinition(kind: 'preview' | 'text', target: Definition, reveal: Reveal): Promise { + if (this.panel.active && !workspace.getConfiguration('workbench.editor').get('enablePreviewFromCodeNavigation', false)) + await commands.executeCommand('workbench.action.keepEditor') + const options: TextDocumentShowOptions = { viewColumn: this.panel.viewColumn } + const { uri, range } = target.location + if (kind === 'text') + await window.showTextDocument(uri, { ...options, selection: range }) + else + await this.owner.openAt(uri, reveal, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options)) + } + private async applyEdit(startLine: number, endLine: number, text: string): Promise { const { document } = this const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text) diff --git a/vscode/src/codeReader/editor/switchEditor.ts b/vscode/src/codeReader/editor/switchEditor.ts index 47e3285..22b94c9 100644 --- a/vscode/src/codeReader/editor/switchEditor.ts +++ b/vscode/src/codeReader/editor/switchEditor.ts @@ -57,7 +57,7 @@ export async function showPreview(previews: PreviewEditorProvider, args: unknown const topLine = uri ? textEditorOf(uri)?.visibleRanges[0]?.start.line : undefined const reopen = (): Thenable => commands.executeCommand(REOPEN_WITH, PREVIEW_VIEW_TYPE, ...args) if (uri && topLine !== undefined) - await previews.openAtLine(uri, topLine, reopen) + await previews.openAt(uri, { line: topLine }, reopen) else await reopen() } diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index 44d02e2..b3105d7 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -55,6 +55,11 @@ function texts(map: Map): Record { return Object.fromEntries([...map].map(([line, { segments }]) => [line, text(segments)])) } +/** 只看文字和着色:去掉各段的源码下标(下标另有用例)。 */ +function colors(value: T): T { + return JSON.parse(JSON.stringify(value, (key, field: unknown) => key === 'offset' ? undefined : field)) as T +} + const CJK = /\p{Script=Han}/u const CJK_ONLY = /^\p{Script=Han}+$/u const WORD_CHAR = /[\p{Script=Han}\w]/u @@ -105,7 +110,7 @@ describe('typeScript 中文直译', () => { it('每个被翻的词单独成段,着色取语法树词性', async () => { const env = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) const issue = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) - expect(env.get(32)?.segments.filter(segment => segment.role !== 'plain')).toEqual([ + expect(colors(env.get(32)?.segments.filter(segment => segment.role !== 'plain'))).toEqual([ { text: '公共', role: 'keyword' }, { text: '接口', role: 'storage' }, { text: 'EnvLockResult', role: 'type' }, @@ -375,7 +380,7 @@ describe('属性表', () => { it('属性名是纯文字;值按词性着色,说明词(未标注、N 个、默认)用 hint', async () => { const env = await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) - const rows = env.get(23) ?? [] + const rows = colors(env.get(23) ?? []) expect(rows.map(row => row.label)).toEqual(['名称', '访问级别', '异步', '输入参数', '输出参数']) expect(rows[0].value).toEqual([{ text: 'findEnvFiles', role: 'function' }]) expect(rows[1].value).toEqual([{ text: '公共', role: 'keyword' }]) @@ -388,8 +393,8 @@ describe('属性表', () => { { text: '字符串', role: 'type' }, { text: '[]>', role: 'plain' }, ]) - expect(valueOf(env.get(12), '来源')).toEqual([{ text: '\'node:fs\'', role: 'string' }]) - expect(env.get(12)?.at(-1)?.items?.[0].cells[0]).toEqual([ + expect(colors(valueOf(env.get(12), '来源'))).toEqual([{ text: '\'node:fs\'', role: 'string' }]) + expect(colors(env.get(12)?.at(-1)?.items?.[0].cells[0])).toEqual([ { text: 'promises', role: 'variable' }, { text: ' ', role: 'plain' }, { text: '作为', role: 'keyword' }, @@ -398,8 +403,8 @@ describe('属性表', () => { ]) const sample = await describeAll(py, 'python', pythonZhTranslator, readFixture('sample.py')) - expect(valueOf(sample.get(3), '来源')).toEqual([{ text: 'os', role: 'type' }]) - expect(sample.get(39)?.find(row => row.label === '输入参数')?.items).toEqual([ + expect(colors(valueOf(sample.get(3), '来源'))).toEqual([{ text: 'os', role: 'type' }]) + expect(colors(sample.get(39)?.find(row => row.label === '输入参数')?.items)).toEqual([ { cells: [[{ text: 'self', role: 'variable' }], [{ text: '未标注', role: 'hint' }], []] }, { cells: [[{ text: 'verbose', role: 'variable' }], [{ text: '布尔', role: 'type' }], [{ text: '默认 ', role: 'hint' }, { text: '假', role: 'storage' }]] }, ]) @@ -464,6 +469,107 @@ describe('属性表', () => { }) }) +describe('源码下标(跳到定义用)', () => { + /** 带下标的段,按出现顺序写成 [文字, 下标]。 */ + function linked(segments: Segment[] | undefined): [string, number][] { + return (segments ?? []).flatMap(segment => segment.offset === undefined ? [] : [[segment.text, segment.offset] as [string, number]]) + } + + function itemCells(rows: PropertyRow[] | undefined, label: string): Segment[][] { + return rows?.find(row => row.label === label)?.items?.flatMap(item => item.cells) ?? [] + } + + function propertySegments(rows: PropertyRow[]): Segment[] { + return rows.flatMap(row => [...row.value, ...(row.items ?? []).flatMap(item => item.cells.flat())]) + } + + it('python:点分模块名每一节、导入项、别名各自带下标,点号不带;前面有中文注释也对得上', async () => { + const source = [ + '# 数据库连接', + 'from luffy_agent.core.db import get_db as conn', + 'import os.path', + 'from . import x', + ].join('\n') + const at = (word: string): number => source.indexOf(word) + const rows = await describeAll(py, 'python', pythonZhTranslator, source) + expect(text(valueOf(rows.get(2), '来源'))).toBe('luffy_agent.core.db') + expect(linked(valueOf(rows.get(2), '来源'))).toEqual([['luffy_agent', at('luffy_agent')], ['core', at('core')], ['db', at('db')]]) + expect(itemCells(rows.get(2), '导入项').map(linked)).toEqual([[['get_db', at('get_db')], ['conn', at('conn')]]]) + expect(linked(valueOf(rows.get(3), '来源'))).toEqual([['os', at('os')], ['path', at('path')]]) + expect(linked(valueOf(rows.get(4), '来源'))).toEqual([]) + expect(itemCells(rows.get(4), '导入项').map(linked)).toEqual([[['x', at('x')]]]) + }) + + it('python:一行译文里的名字带下标,关键字、运算符、字面量译词不带', async () => { + const source = [ + 'def f(user):', + ' if not user.active and user is not None:', + ' raise HTTPException(status_code=404)', + ].join('\n') + const at = (word: string, from = 0): number => source.indexOf(word, from) + const map = await translateAll(py, 'python', pythonZhTranslator, source) + const ifLine = at('if') + expect(text(map.get(2)?.segments)).toBe('如果 非 user.active 并且 user 不是 空') + expect(linked(map.get(2)?.segments)).toEqual([['user', at('user', ifLine)], ['active', at('active')], ['user', at('user is')]]) + expect(linked(map.get(3)?.segments)).toEqual([['HTTPException', at('HTTPException')], ['status_code', at('status_code')]]) + }) + + it('typeScript:模块字符串、导入项、函数名称、参数名和类型、返回类型、译文里的名字带下标', async () => { + const source = [ + '// 读取配置', + 'import { readFile as rf } from \'./fs\'', + 'export async function load(path: string, opts?: Options): Promise {', + ' if (opts === undefined) return rf(path)', + '}', + ].join('\n') + const at = (word: string): number => source.indexOf(word) + const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source) + expect(linked(valueOf(rows.get(2), '来源'))).toEqual([['\'./fs\'', at('\'./fs\'')]]) + expect(itemCells(rows.get(2), '导入项').map(linked)).toEqual([[['readFile', at('readFile')], ['rf', at('rf }')]]]) + expect(linked(valueOf(rows.get(3), '名称'))).toEqual([['load', at('load')]]) + expect(itemCells(rows.get(3), '输入参数').map(linked)).toEqual([ + [['path', at('path:')]], + [], + [], + [['opts', at('opts?')]], + [['Options', at('Options')]], + [], + ]) + expect(linked(valueOf(rows.get(3), '输出参数'))).toEqual([['Promise', at('Promise')], ['Result', at('Result')]]) + const map = await translateAll(ts, 'typescript', typescriptZhTranslator, source) + expect(text(map.get(4)?.segments)).toBe('如果 (opts 全等 未定义)') + expect(linked(map.get(4)?.segments)).toEqual([['opts', at('opts ===')]]) + }) + + it('样例全文:带下标的只有照抄的名字和字符串,下标处的源码就是这段;变量名、函数名都带,译词、说明词都不带', async () => { + const samples: [LanguageAdapter, string, Translator, string][] = [ + [ts, 'typescript', typescriptZhTranslator, 'envLock.ts.txt'], + [ts, 'typescript', typescriptZhTranslator, 'issueLock.ts.txt'], + [py, 'python', pythonZhTranslator, 'sample.py'], + [py, 'python', pythonZhTranslator, 'routes.py'], + ] + for (const [adapter, languageId, translator, name] of samples) { + const source = readFixture(name) + const translated = [...(await translateAll(adapter, languageId, translator, source)).values()].flatMap(block => block.segments) + const described = [...(await describeAll(adapter, languageId, translator, source)).values()].flatMap(propertySegments) + const segments = [...translated, ...described] + expect(linked(segments).length, name).toBeGreaterThan(10) + for (const segment of segments) { + const where = `${name} 「${segment.text}」` + if (segment.role === 'variable' || segment.role === 'function') + expect(segment.offset, where).toBeTypeOf('number') + if (segment.offset === undefined) + continue + expect(['variable', 'function', 'type', 'string'], where).toContain(segment.role) + if (segment.role === 'string') + expect(source[segment.offset], where).toBe(segment.text[0]) + else + expect(source.startsWith(segment.text, segment.offset), where).toBe(true) + } + } + }) +}) + describe('中文模板引擎', () => { function testTranslator(table: Partial): Translator { return createZhTemplateTranslator({ diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 4dfe786..7941946 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -56,6 +56,8 @@ interface Token { raw: string /** 前面要空一格:源码里它前面有空白,或者模板在它前面插了别的项。 */ gap: boolean + /** 照抄的名字、字符串在源码里的起始下标,见 Segment.offset。 */ + offset?: number } /** 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。 */ @@ -69,6 +71,8 @@ interface Collector { const CONTEXT_SEPARATOR = ' > ' const CLOSING = new Set([')', ']', '}']) const HEADER_OPENERS = new Set(['{', ':']) +/** 源码着色里代表名字的词性:着这几种色的具名叶子是标识符、属性名、类型名,能查定义。 */ +const NAME_ROLES = new Set(['variable', 'function', 'type']) function firstLeaf(node: Node): Node { const first = node.child(0) @@ -123,6 +127,12 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return text } + /** 照抄这个节点时带不带源码下标:名字(按名字着色的具名叶子)和整段字符串带,标点、数字、关键字不带。 */ + function offsetOf(node: Node): number | undefined { + const linkable = stringNodes.has(node.type) || (node.childCount === 0 && node.isNamed && NAME_ROLES.has(table.tokenRole(node))) + return linkable ? node.startIndex : undefined + } + /** 能整个换成中文的节点 → 译文和词性。 */ function translateNode(node: Node): { text: string, role: TokenRole } | undefined { if (node.childCount === 0 && table.isTypePosition(node)) { @@ -201,7 +211,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } if (node.childCount === 0 || stringNodes.has(node.type)) { const role = stringNodes.has(node.type) ? 'string' : table.tokenRole(node) - emitSource(out, node, { text: oneLine(node.text), role, translated: false }) + emitSource(out, node, { text: oneLine(node.text), role, translated: false, offset: offsetOf(node) }) return } for (const child of node.children) { @@ -217,8 +227,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return out.tokens } - /** node 的源码原样照抄:叶子按源码着色,叶子之间的空白、标点照抄。 */ - function verbatim(node: Node, source: string): Segment[] { + /** node 的源码原样照抄:叶子按源码着色(给了 role 就都按它),叶子之间的空白、标点照抄。 */ + function verbatim(node: Node, source: string, role?: TokenRole): Segment[] { const segments: Segment[] = [] let cursor = node.startIndex const visit = (current: Node): void => { @@ -232,7 +242,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return } pushSegment(segments, source.slice(cursor, current.startIndex), 'plain') - pushSegment(segments, current.text, table.tokenRole(current)) + pushSegment(segments, current.text, role ?? table.tokenRole(current), offsetOf(current)) cursor = current.endIndex } visit(node) @@ -274,7 +284,9 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return joinTokens(tokensIn(node, span, source)) }, verbatim: node => verbatim(node, source), - module: node => [{ text: node.text, role: stringNodes.has(node.type) ? 'string' : 'type' }], + // 点分模块名逐节照抄,每一节各自能跳到定义,和 VS Code 里一样 + module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node))] : verbatim(node, source, 'type'), + name: (node, role) => [segment(node.text, role, offsetOf(node))], } switch (block.kind) { case 'function': @@ -289,14 +301,18 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return { id: table.id, label: table.label, languageIds: table.languageIds, translate, describe } } -function pushSegment(segments: Segment[], text: string, role: TokenRole): void { +function segment(text: string, role: TokenRole, offset: number | undefined): Segment { + return offset === undefined ? { text, role } : { text, role, offset } +} + +function pushSegment(segments: Segment[], text: string, role: TokenRole, offset?: number): void { if (!text) return const prev = segments.at(-1) if (role === 'plain' && prev?.role === 'plain') prev.text += text else - segments.push({ text, role }) + segments.push(segment(text, role, offset)) } /** 词连成译文:源码里隔着空白的词之间空一格,注释不进译文,右括号前的尾逗号去掉。 */ @@ -312,7 +328,7 @@ function joinTokens(tokens: Token[]): Segment[] { } if (gap && segments.length > 0) pushSegment(segments, ' ', 'plain') - pushSegment(segments, token.text, token.role) + pushSegment(segments, token.text, token.role, token.offset) // 「非」是个词,后面补一个空格,不和操作数粘在一起 gap = token.translated && token.raw === '!' }) @@ -329,6 +345,8 @@ interface Phrases { verbatim: (node: Node) => Segment[] /** 导入来源:字符串字面量按字符串着色,模块名按类型着色。 */ module: (node: Node) => Segment[] + /** 源码里的一个名字原样照抄、按 role 着色(函数名称)。 */ + name: (node: Node, role: TokenRole) => Segment[] } const ACCESS_ZH: Record = { public: '公共', private: '私有', protected: '受保护' } @@ -363,7 +381,7 @@ function aliased(item: Segment[], alias: Node | undefined, say: Phrases): Segmen function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] { const decorators = facts.decorators.map(say.verbatim) return [ - { label: '名称', value: facts.name ? [{ text: facts.name.text, role: 'function' }] : [hint('匿名')] }, + { label: '名称', value: facts.name ? say.name(facts.name, 'function') : [hint('匿名')] }, ...accessRows(facts.access), { label: '异步', value: yesNo(facts.async) }, ...(decorators.length > 0 ? [{ label: '装饰器', value: joined(decorators, '\n') }] : []), diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index c489ba0..7caf257 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -1,11 +1,13 @@ import type { ReactElement } from 'react' import type { BlockTreeState } from './BlockTree' -import type { DocumentMessage } from './types' +import type { DocumentMessage, Reveal } from './types' import type { ViewState } from './viewState' import { TriangleAlert } from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' import { SelectMenu } from '@/components/ui/select-menu' import { BlockLevel } from './BlockTree' +import { useDefinitionLinks } from './useDefinitionLinks' +import { useReveal } from './useReveal' import { useScrollPosition } from './useScrollPosition' import { onMessage, postMessage, viewState } from './vscode' @@ -24,12 +26,25 @@ export function App(): ReactElement | null { const [doc, setDoc] = useState(null) const [view, setView] = useState(savedBlockModes) const [focusId, setFocusId] = useState(null) + /** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */ + const [reveal, setReveal] = useState(null) + const expand = useCallback((ids: string[]) => setView(prev => ({ ...prev, collapsed: prev.collapsed.filter(id => !ids.includes(id)) })), []) + const revealed = useCallback(() => setReveal(null), []) useScrollPosition(doc) + useReveal(doc, reveal, view.collapsed, expand, revealed) + useDefinitionLinks(doc?.version) useEffect(() => { const off = onMessage((msg) => { - if (msg.type === 'document') - setDoc(msg) + switch (msg.type) { + case 'document': + setDoc(msg) + if (msg.reveal) + setReveal(msg.reveal) + return + case 'reveal': + setReveal({ line: msg.line, definition: msg.definition }) + } }) postMessage({ type: 'ready' }) return off diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 5ad4282..0450134 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -83,6 +83,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index b4c1ac8..a8505b5 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -1,12 +1,12 @@ import type { ReactElement } from 'react' import type { Segment } from './types' -/** 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换。 */ +/** 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上,跳到定义用。 */ export function SegmentText({ segments }: { segments: Segment[] }): ReactElement { return ( <> {segments.map((segment, index) => ( - {segment.text} + {segment.text} ))} ) diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 3856546..60d387b 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -94,11 +94,31 @@ body.vscode-high-contrast-light { background: var(--kind); } +/* 跳到定义的目标块:边框和底色往聚焦色闪三下(共 1.5 秒)再复原;减少动态效果时改成静止高亮同样长的时间 */ +@keyframes cr-flash { + 50% { + background: color-mix(in srgb, var(--vscode-focusBorder) 25%, transparent); + border-color: var(--vscode-focusBorder); + } +} +@keyframes cr-flash-still { + from, to { + background: color-mix(in srgb, var(--vscode-focusBorder) 25%, transparent); + border-color: var(--vscode-focusBorder); + } +} +.cr-block[data-flash] { + animation: cr-flash 500ms ease-in-out 3; +} + @media (prefers-reduced-motion: reduce) { .cr-block, .cr-guide { transition: none; } + .cr-block[data-flash] { + animation: cr-flash-still 1500ms; + } } .cr-badge { @@ -134,6 +154,13 @@ body.vscode-high-contrast-light { .cr-role-comment { color: var(--cr-comment); } .cr-role-hint { color: var(--vscode-descriptionForeground); } +/* 按住 ⌘ / Ctrl 停在查得到定义的名字上:和编辑器里的链接一样 */ +.cr-code [data-link] { + color: var(--vscode-editorLink-activeForeground); + text-decoration: underline; + cursor: pointer; +} + /* 属性表的属性名:界面字体,和值一样的行高,两边逐行对齐 */ .cr-prop-label { font-size: 12.5px; diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts new file mode 100644 index 0000000..5509036 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -0,0 +1,88 @@ +import type { BlockKind, BlockView, PropertyRow, Segment } from './types' +import { describe, expect, it } from 'vitest' +import { revealPlan, scrollTopToShow } from './reveal' + +function name(text: string, offset: number): Segment { + return { text, role: 'variable', offset } +} + +function word(text: string): Segment { + return { text, role: 'keyword' } +} + +function textBlock(id: string, kind: BlockKind, startLine: number, translation: Segment[], children: BlockView[] = []): BlockView { + return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], children, translation } +} + +function factBlock(id: string, kind: BlockKind, startLine: number, properties: PropertyRow[], children: BlockView[] = []): BlockView { + return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], children, properties } +} + +/** + * 0 from luffy_agent.core.db import get_db + * 2 def load(path, user): + * 3 if not user: + * 4 return get_db(path) + * 8 LIMIT = 3 + */ +const BLOCKS: BlockView[] = [ + factBlock('0', 'import', 0, [ + { label: '来源', value: [name('luffy_agent', 5), { text: '.', role: 'type' }, name('core', 17)] }, + { label: '导入项', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 34)]] }] }, + ]), + factBlock('1', 'function', 2, [ + { label: '名称', value: [{ text: 'load', role: 'function', offset: 46 }] }, + { label: '异步', value: [{ text: '否', role: 'storage' }] }, + { label: '输入参数', value: [{ text: '2 个', role: 'hint' }], items: [{ cells: [[name('path', 51)], []] }, { cells: [[name('user', 57)], []] }] }, + ], [ + textBlock('1.0', 'if', 3, [word('如果'), word('非'), name('user', 78)], [ + textBlock('1.0.0', 'return', 4, [word('返回'), name('get_db', 102), name('path', 109)]), + ]), + ]), + factBlock('2', 'const', 8, [{ label: '名称', value: [name('LIMIT', 130)] }]), +] + +describe('跳到定义:滚到哪块、展开哪些', () => { + it('名字在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => { + expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) + }) + + it('名字在属性行展开的子行里(参数):连那一行一起展开', () => { + expect(revealPlan(BLOCKS, 2, 57)).toEqual({ id: '1', expand: ['1/p2'] }) + expect(revealPlan(BLOCKS, 0, 34)).toEqual({ id: '0', expand: ['0/p1'] }) + }) + + it('名字在属性行的值里:不用展开属性行', () => { + expect(revealPlan(BLOCKS, 2, 46)).toEqual({ id: '1', expand: [] }) + expect(revealPlan(BLOCKS, 8, 130)).toEqual({ id: '2', expand: [] }) + }) + + it('页面上没有这个名字:按行找包含它的块,也展开祖先', () => { + expect(revealPlan(BLOCKS, 3, 999)).toEqual({ id: '1.0', expand: ['1'] }) + expect(revealPlan(BLOCKS, 6, 999)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) + expect(revealPlan(BLOCKS, 20, 999)).toEqual({ id: '2', expand: [] }) + }) + + it('按行找:同一行有几块取外层那块;行在所有块之前取第一块;没有块时没有目标', () => { + const sameLine = [textBlock('0', 'if', 1, [word('如果')], [textBlock('0.0', 'return', 1, [word('返回')])])] + expect(revealPlan(sameLine, 1, 999)).toEqual({ id: '0', expand: [] }) + expect(revealPlan(sameLine, 0, 999)).toEqual({ id: '0', expand: [] }) + expect(revealPlan([], 0, 0)).toBeUndefined() + }) +}) + +describe('把目标块滚进视口', () => { + it('整块已经在视口里:不滚', () => { + expect(scrollTopToShow({ top: 120, bottom: 160 }, 100, 500)).toBeUndefined() + }) + + it('在视口外:放到视口正中', () => { + expect(scrollTopToShow({ top: 1000, bottom: 1040 }, 0, 500)).toBe(770) + expect(scrollTopToShow({ top: 80, bottom: 120 }, 400, 500)).toBe(0) + }) + + it('露了一半也算在视口外;比视口还高就顶部对齐', () => { + expect(scrollTopToShow({ top: 580, bottom: 620 }, 100, 500)).toBe(350) + expect(scrollTopToShow({ top: 900, bottom: 1600 }, 0, 500)).toBe(900) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts new file mode 100644 index 0000000..2d68e5d --- /dev/null +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -0,0 +1,65 @@ +import type { BlockView, Segment } from './types' +import { propertyRowId } from './viewState' + +/** 跳到定义时要滚到的块,以及要展开的块和属性行:它们折叠着,目标就不在页面上。 */ +export interface RevealPlan { + id: string + expand: string[] +} + +interface Placed { + block: BlockView + /** 从顶层到父块的块 id */ + ancestors: string[] +} + +/** 按页面上的先后(先父块、再子块)列出所有块。 */ +function* placed(blocks: BlockView[], ancestors: string[] = []): Generator { + for (const block of blocks) { + yield { block, ancestors } + yield* placed(block.children, [...ancestors, block.id]) + } +} + +function shows(segments: Segment[], offset: number): boolean { + return segments.some(segment => segment.offset === offset) +} + +/** 块里哪儿显示着 offset 处的名字:'value' 是一行译文或属性行的值,数字是带子行的属性行序号(名字在它展开的子行里),不在这块里是 undefined。 */ +function locate(block: BlockView, offset: number): 'value' | number | undefined { + if ('translation' in block) + return shows(block.translation, offset) ? 'value' : undefined + for (const [index, row] of block.properties.entries()) { + if (shows(row.value, offset)) + return 'value' + if (row.items?.some(item => item.cells.some(cell => shows(cell, offset)))) + return index + } + return undefined +} + +/** + * 跳到定义:目标是显示着定义处那个名字的块,名字在属性行的子行里时连这一行一起展开; + * 页面上找不到这个名字(例如定义是整个模块,落在文件开头)就取包含 line 的块,规则同 blockForLine。 + * 目标的祖先块都要展开。 + */ +export function revealPlan(blocks: BlockView[], line: number, definition: number): RevealPlan | undefined { + let byLine: Placed | undefined + for (const item of placed(blocks)) { + const where = locate(item.block, definition) + if (where !== undefined) + return { id: item.block.id, expand: typeof where === 'number' ? [...item.ancestors, propertyRowId(item.block.id, where)] : item.ancestors } + if (item.block.startLine <= line && (!byLine || item.block.startLine > byLine.block.startLine)) + byLine = item + } + const target = byLine ?? (blocks.length > 0 ? { block: blocks[0], ancestors: [] } : undefined) + return target && { id: target.block.id, expand: target.ancestors } +} + +/** 让一块露出来要滚到的 scrollTop,和 VS Code 跳到定义一样:整块已经在视口里就不滚;否则放到视口正中,比视口还高就顶部对齐。 */ +export function scrollTopToShow(box: { top: number, bottom: number }, viewTop: number, viewHeight: number): number | undefined { + if (box.top >= viewTop && box.bottom <= viewTop + viewHeight) + return undefined + const height = box.bottom - box.top + return Math.max(0, height >= viewHeight ? box.top : box.top - (viewHeight - height) / 2) +} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index f3190ab..6464b7a 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -14,7 +14,8 @@ export type TokenRole | 'hint' // 属性表里的说明词:未标注 无 N 个 默认导出 … | 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain' -export interface Segment { text: string, role: TokenRole } +/** offset:照抄自源码里的名字、字符串时它在源码里的起始下标,⌘ / Ctrl 悬停、点击时拿它查定义;译词、提示词没有 */ +export interface Segment { text: string, role: TokenRole, offset?: number } /** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值 */ export interface PropertyItem { cells: Segment[][] } @@ -45,6 +46,15 @@ export type BlockView = BlockViewBase & ({ translation: Segment[] } | { properti export interface TranslatorOption { id: string, label: string } +/** + * 滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset), + * 要展开把它折叠住的块和属性行,再让那块闪几下 + */ +export interface Reveal { + line: number + definition?: number +} + export interface DocumentMessage { type: 'document' version: number @@ -53,18 +63,29 @@ export interface DocumentMessage { translators: TranslatorOption[] translatorId: string error?: string - /** 从文本编辑器切过来时它顶部那一行:只在新预览的第一份 document 里带,滚到包含这行的块 */ - revealLine?: number + /** 只在新预览的第一份 document 里带:从文本编辑器切过来时是它顶部那一行,跳到定义时是定义处 */ + reveal?: Reveal } -export type ExtensionToWebview = DocumentMessage +/** + * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) + * definitionProbed:probeDefinition 的回答,found 表示查得到定义 + */ +export type ExtensionToWebview + = | DocumentMessage + | ({ type: 'reveal' } & Reveal) + | { type: 'definitionProbed', version: number, offset: number, found: boolean } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进) * visibleLine:视口顶部那块的起始行,切回文本编辑器时扩展端把这行放到顶部 + * probeDefinition:按住 ⌘ / Ctrl 悬停到带下标的名字上,问 offset 处有没有定义;version 是这份下标所属的文档版本 + * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 */ export type WebviewToExtension = | { type: 'ready' } | { type: 'edit', startLine: number, endLine: number, text: string } | { type: 'selectTranslator', id: string } | { type: 'visibleLine', line: number } + | { type: 'probeDefinition', 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 new file mode 100644 index 0000000..4692b9f --- /dev/null +++ b/vscode/webview-ui/src/codeReader/useDefinitionLinks.ts @@ -0,0 +1,124 @@ +import type { WebviewToExtension } from './types' +import { useEffect, useState } from 'react' +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 +} + +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() + private hovered: HTMLElement | null = null + private held = false + private linked: HTMLElement | null = null + + constructor(private readonly post: (msg: WebviewToExtension) => void) {} + + 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 { + this.hovered = nameAt(target) + this.held = held + this.update() + } + + hold(held: boolean): void { + this.held = held + this.update() + } + + 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 + if (link === this.linked) + return + this.linked?.removeAttribute('data-link') + link?.setAttribute('data-link', '') + this.linked = link + } +} + +/** 预览里的名字能 ⌘ / Ctrl + 点击跳到定义;version 是当前显示的文档版本,名字的下标属于它。 */ +export function useDefinitionLinks(version: number | undefined): void { + const [links] = useState(() => new DefinitionLinks(postMessage)) + + useEffect(() => { + links.setVersion(version) + }, [links, version]) + + useEffect(() => { + const onMove = (event: MouseEvent): void => links.point(event.target, modifierHeld(event)) + const onOut = (event: MouseEvent): void => { + if (event.relatedTarget === null) + links.point(null, false) + } + 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) + }) + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseout', onOut) + // 捕获阶段:先于块里的按钮、折叠箭头拿到点击 + document.addEventListener('click', onClick, true) + window.addEventListener('keydown', onKey) + window.addEventListener('keyup', onKey) + window.addEventListener('blur', onBlur) + return () => { + off() + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseout', onOut) + document.removeEventListener('click', onClick, true) + window.removeEventListener('keydown', onKey) + window.removeEventListener('keyup', onKey) + window.removeEventListener('blur', onBlur) + } + }, [links]) +} diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts new file mode 100644 index 0000000..7245072 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -0,0 +1,63 @@ +import type { DocumentMessage, Reveal } from './types' +import { useLayoutEffect } from 'react' +import { revealPlan, scrollTopToShow } from './reveal' +import { blockForLine } from './scrollPosition' +import { measureBlocks } from './useScrollPosition' + +/** 让一块闪几下(codeReader.css 的 [data-flash]);正在闪的同一块再跳一次时从头闪。 */ +function flash(el: HTMLElement): void { + const running = el.getAnimations().filter(animation => animation instanceof CSSAnimation) + if (running.length > 0) { + for (const animation of running) + animation.currentTime = 0 + return + } + const onEnd = (event: AnimationEvent): void => { + if (event.target !== el) + return + el.removeAttribute('data-flash') + el.removeEventListener('animationend', onEnd) + } + el.addEventListener('animationend', onEnd) + el.setAttribute('data-flash', '') +} + +/** + * 滚到 reveal 指的位置,处理完调 done: + * ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部,折叠着的不展开; + * ② 跳到定义:先展开把目标藏起来的块和属性行(expand 之后重新渲染,再进来一次),再把目标块滚进视口、闪几下。 + */ +export function useReveal( + doc: DocumentMessage | null, + reveal: Reveal | null, + collapsed: readonly string[], + expand: (ids: string[]) => void, + done: () => void, +): void { + useLayoutEffect(() => { + if (!doc || !reveal) + return + if (reveal.definition === undefined) { + const top = blockForLine(measureBlocks(), reveal.line)?.top + if (top !== undefined) + window.scrollTo(0, top) + done() + return + } + const plan = revealPlan(doc.blocks, reveal.line, reveal.definition) + const hidden = plan?.expand.filter(id => collapsed.includes(id)) ?? [] + if (hidden.length > 0) { + expand(hidden) + return + } + const el = plan && document.querySelector(`.cr-block[data-id="${plan.id}"]`) + if (el) { + const rect = el.getBoundingClientRect() + const top = scrollTopToShow({ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY }, window.scrollY, window.innerHeight) + if (top !== undefined) + window.scrollTo(0, top) + flash(el) + } + done() + }, [doc, reveal, collapsed, expand, done]) +} diff --git a/vscode/webview-ui/src/codeReader/useScrollPosition.ts b/vscode/webview-ui/src/codeReader/useScrollPosition.ts index f230364..102b49b 100644 --- a/vscode/webview-ui/src/codeReader/useScrollPosition.ts +++ b/vscode/webview-ui/src/codeReader/useScrollPosition.ts @@ -1,14 +1,14 @@ import type { BlockBox } from './scrollPosition' import type { DocumentMessage } from './types' import { useEffect, useLayoutEffect, useRef } from 'react' -import { anchorAt, blockForLine, scrollTopFor } from './scrollPosition' +import { anchorAt, scrollTopFor } from './scrollPosition' import { postMessage, viewState } from './vscode' /** 滚动时最多这么久记一次位置 */ const RECORD_INTERVAL_MS = 100 /** 页面上每块(BlockCard 带 data-line)的位置,按页面上的先后。 */ -function measureBlocks(): BlockBox[] { +export function measureBlocks(): BlockBox[] { const y = window.scrollY return Array.from(document.querySelectorAll('.cr-block[data-line]'), (el) => { const rect = el.getBoundingClientRect() @@ -31,8 +31,8 @@ function record(reportedLine: { current: number | undefined }): void { /** * 记住并恢复预览看到的位置: * ① 滚动时把视口顶部那块记进 webview 状态,页面因标签页切走被重新加载后照着滚回去; - * ② 同时把那块的起始行报给扩展端,切回文本编辑器时把这行放到顶部; - * ③ 第一份文档带 revealLine(刚从文本编辑器切过来)时优先滚到包含这行的块。 + * ② 同时把那块的起始行报给扩展端,切回文本编辑器时把这行放到顶部。 + * 第一份文档带 reveal(刚从文本编辑器切过来、跳到定义)时不恢复,交给 useReveal 滚。 */ export function useScrollPosition(doc: DocumentMessage | null): void { const restored = useRef(false) @@ -43,11 +43,8 @@ export function useScrollPosition(doc: DocumentMessage | null): void { if (!doc || restored.current) return restored.current = true - const boxes = measureBlocks() const saved = viewState.get().scroll - const top = doc.revealLine !== undefined - ? blockForLine(boxes, doc.revealLine)?.top - : saved && scrollTopFor(boxes, saved) + const top = doc.reveal === undefined && saved ? scrollTopFor(measureBlocks(), saved) : undefined if (top !== undefined) window.scrollTo(0, top) record(reportedLine)