From 3ac1c783fbeeeaf003ec70478ff71bb29ff08938 Mon Sep 17 00:00:00 2001 From: cruldra Date: Fri, 25 Sep 2026 14:11:44 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E9=98=85=E8=AF=BB=E8=B7=B3=E5=88=B0=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E3=80=81=E5=AF=BC=E5=85=A5=E9=A1=B9=E3=80=81=E5=A4=9A=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E5=A3=B0=E6=98=8E=E9=87=8C=E7=9A=84=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E6=97=B6=E5=8F=AA=E9=97=AA=E5=B1=9E=E6=80=A7=E8=A1=A8=E9=87=8C?= =?UTF-8?q?=E9=82=A3=E4=B8=80=E8=A1=8C=EF=BC=8C=E6=8C=89=E6=BA=90=E7=A0=81?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 28 ++- vscode/src/codeReader/blockView.test.ts | 9 + vscode/src/codeReader/blockView.ts | 22 ++- vscode/src/codeReader/contract.ts | 17 +- vscode/src/codeReader/editor/messages.ts | 2 +- .../codeReader/translators/zhTemplate.test.ts | 33 +++- .../src/codeReader/translators/zhTemplate.ts | 26 ++- .../src/codeReader/PropertyGrid.tsx | 15 +- .../webview-ui/src/codeReader/codeReader.css | 15 +- .../webview-ui/src/codeReader/reveal.test.ts | 165 +++++++++++++++++- vscode/webview-ui/src/codeReader/reveal.ts | 52 ++++-- vscode/webview-ui/src/codeReader/types.ts | 17 +- vscode/webview-ui/src/codeReader/useReveal.ts | 8 +- vscode/webview-ui/src/codeReader/viewState.ts | 5 + 14 files changed, 367 insertions(+), 47 deletions(-) diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index b28b766..972563f 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -85,7 +85,12 @@ - 几处定义:先弹挑选框(每项「相对路径:行号」,下面一行是那行源码),挑一处再按上面跳; - 没有定义:不动。 - 和普通编辑器里 ⌘ + 点击一样:目标是否按「预览标签」打开听 `workbench.editor.enablePreview`;`workbench.editor.enablePreviewFromCodeNavigation` 为假(默认)时先把当前预览标签固定住,免得被目标替掉。 -- 滚过去:目标块是显示着定义处那个名字的块(找不到就取包含定义那一行的块);它的祖先块折叠着就展开,名字在折叠的属性行子行里(参数、导入项)就展开那一行。整块已在视口里不滚,否则滚到视口正中(比视口高就顶部对齐)。然后目标块的边框和底色往聚焦色(`--vscode-focusBorder`)闪三下,共约 1.5 秒后复原;同一块再跳一次从头闪;系统开了「减少动态效果」时改成静止高亮 1.5 秒。 +- 闪哪一处:页面上**就是这处定义**的最具体的那一处,只按源码位置判断(定义提供者给的定义处下标,对上块和属性行带的源码范围),不看属性名文字: + 1. 定义是块自己的名字(函数名;只声明一个变量时的变量名,解构模式整个算)→ 整块。块的这段范围由扩展端从 facts 取(`BlockView.defines`),和翻译插件无关。 + 2. 定义是属性表里某一行 / 子行讲的东西(参数、导入项、一条语句声明几个变量时的某一个)→ 只闪这一行 / 子行。范围是翻译插件从 facts 取的节点(`PropertyRow.defines`、`PropertyItem.defines`),定义处落在范围里就算(`*args` 的定义在 `args` 上、解构参数里的名字都对得上)。 + 3. 其余(定义在一行译文里,例如循环变量;翻译插件没填 `defines`)→ 显示着定义处那个名字的块,名字在属性行的子行里时展开那一行;页面上找不到这个名字就取包含定义那一行的块。 + - 同一文件原地跳、跳进另一个文件的预览,规则一样。 +- 滚过去:目标所在块的祖先块折叠着就展开,目标在折叠的属性行子行里(参数、导入项)就展开那一行。目标已在视口里不滚,否则滚到视口正中(比视口高就顶部对齐)。然后目标的边框和底色往聚焦色(`--vscode-focusBorder`)闪三下,共约 1.5 秒后复原;同一处再跳一次从头闪;系统开了「减少动态效果」时改成静止高亮 1.5 秒。目标是属性行 / 子行但块正在编辑(页面上没有属性表)时闪整块。 - 不按修饰键的点击照旧;⌘ / Ctrl + 点击名字不会触发块的编辑切换、折叠。 ## 通用契约 @@ -97,8 +102,11 @@ export type BlockKind = | 'import' | 'function' | 'class' | 'type' | 'field' | 'const' | 'variable' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' -/** 源码里的一段,行号从 0 开始,end 为开区间的字节偏移。 */ -export interface SourceSpan { startIndex: number, endIndex: number, startRow: number, endRow: number } +/** 源码里一段的下标(源码字符串下标),end 为开区间。 */ +export interface SourceRange { startIndex: number, endIndex: number } + +/** 源码里的一段,带起止行号(从 0 开始)。 */ +export interface SourceSpan extends SourceRange { startRow: number, endRow: number } // ── 属性表块的结构化信息(facts):语言插件从语法树读出,节点都指向源码 ── @@ -175,11 +183,18 @@ export type TokenRole = */ export interface Segment { text: string, role: TokenRole, offset?: number } +/** + * 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字的节点(取自 facts)在源码里的范围—— + * 参数名(连 `*` 前缀、整个解构模式)、导入项(名称到别名)、声明的变量名。跳到的定义落在里面就只闪这一行; + * 第三方翻译插件可以不填,不填就闪整块。 + */ +export interface Defines { defines?: SourceRange } + /** 属性表里展开的一个子行:几列对齐的格子。 */ -export interface PropertyItem { cells: Segment[][] } +export interface PropertyItem extends Defines { cells: Segment[][] } /** 属性表的一行:属性名、着色的值、展开在下面的子行(输入参数、导入项)。 */ -export interface PropertyRow { label: string, value: Segment[], items?: PropertyItem[] } +export interface PropertyRow extends Defines { label: string, value: Segment[], items?: PropertyItem[] } /** 语言插件:语法树 → 代码块树。 */ export interface LanguageAdapter { @@ -333,6 +348,7 @@ interface BlockViewBase { endLine: number code: string // header 源码,去掉共同缩进 codeTokens: Segment[] // 源码着色(拼起来等于 code) + defines?: SourceRange // 块自己的名字(函数名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取;跳到的定义是它时闪整块 children: BlockView[] } @@ -404,7 +420,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、导入、常量、变量块只带属性表,其余块只带译文。 - 行替换:多行块替换后缩进正确。 -- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。 +- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,函数名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量标的范围指回源码原文;块自己的名字只有函数和单变量声明有。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。 ## 收尾 diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index 034bcfa..5758350 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -150,6 +150,15 @@ describe('blockView', () => { expect(signature.filter(token => token.text === 'str')).toEqual([{ text: 'str', role: 'type' }, { text: 'str', role: 'type' }]) }) + it('块自己的名字(defines):函数名、只声明一个变量时的变量名(解构模式整个算);导入、一条语句几个变量、一行译文的块没有', async () => { + const own = (source: string, list: BlockView[]): Record => + Object.fromEntries(list.map(view => [view.id, view.defines && source.slice(view.defines.startIndex, view.defines.endIndex)])) + const pySource = ['import os', 'def f(x): pass', 'a, b = 1, 2', 'for i in x: pass'].join('\n') + expect(own(pySource, await views(py, 'python', pythonZhTranslator, pySource))).toEqual({ 0: undefined, 1: 'f', 2: 'a, b', 3: undefined }) + const tsSource = ['let i = 0, j', 'const { u, v } = o', 'export const h = () => 1'].join('\n') + expect(own(tsSource, await views(ts, 'typescript', typescriptZhTranslator, tsSource))).toEqual({ 0: undefined, 1: '{ u, v }', 2: 'h' }) + }) + it('\\r\\n 和制表符缩进:code 用 \\n 连接,去掉共同的制表符', async () => { const source = 'function f() {\r\n\tif (a &&\r\n\t\tb) {\r\n\t\treturn 1\r\n\t}\r\n}\r\n' const list = await views(ts, 'typescript', undefined, source) diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 0e8093c..502cb26 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,4 +1,4 @@ -import type { BlockKind, CodeBlock, LanguageAdapter, Node, PropertyRow, Segment, TokenRole, Translator } from './contract' +import type { BlockKind, CodeBlock, LanguageAdapter, Node, PropertyRow, Segment, SourceRange, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import { commonIndent, splitLines } from './editText' @@ -13,6 +13,8 @@ interface BlockViewBase { code: string /** 源码着色(拼起来等于 code)。 */ codeTokens: Segment[] + /** 块自己的名字(见 ownName)在源码里的范围:跳到的定义就是它时闪整块,不闪属性表里的某一行。 */ + defines?: SourceRange children: BlockView[] } @@ -80,6 +82,22 @@ function colorLines(root: Node, source: string, lines: Line[], startRow: number, return segments } +/** + * 这块本身就是哪个名字的定义:函数名,只声明一个变量(或一个解构模式)时的变量名。 + * 导入、一条语句声明几个变量时没有,它们的名字各是属性表里的一行(PropertyRow.defines)。 + */ +function ownName(block: CodeBlock): Node | undefined { + switch (block.kind) { + case 'function': + return block.facts.name + case 'const': + case 'variable': + return block.facts.declarators.length === 1 ? block.facts.declarators[0].name : undefined + default: + return undefined + } +} + /** 代码块树 → 视图数据:header 整行源码去共同缩进、按语法树着色、调用翻译插件。 */ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: LanguageAdapter, translator: Translator | undefined): BlockView[] { const lines = lineRanges(source) @@ -88,6 +106,7 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu const { startRow, endRow } = block.header const rows = texts.slice(startRow, endRow + 1) const indent = commonIndent(rows).length + const name = ownName(block) return { id, kind: block.kind, @@ -95,6 +114,7 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu endLine: endRow, code: rows.map(row => row.slice(Math.min(indent, row.length))).join('\n'), codeTokens: colorLines(block.node.tree.rootNode, source, lines, startRow, endRow, indent, adapter.tokenRole), + ...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }), children: block.children.map((child, i) => build(child, `${id}.${i}`)), ...('facts' in block ? { properties: translator ? translator.describe(block, source) : [] } diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 0b6cd55..3652ea3 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -7,8 +7,11 @@ export type BlockKind = | 'import' | 'function' | 'class' | 'type' | 'field' | 'const' | 'variable' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' -/** 源码里的一段,行号从 0 开始;下标是源码字符串下标(web-tree-sitter 的 startIndex / endIndex),end 为开区间。 */ -export interface SourceSpan { startIndex: number, endIndex: number, startRow: number, endRow: number } +/** 源码里一段的下标:源码字符串下标(web-tree-sitter 的 startIndex / endIndex),end 为开区间。 */ +export interface SourceRange { startIndex: number, endIndex: number } + +/** 源码里的一段,带起止行号(从 0 开始)。 */ +export interface SourceSpan extends SourceRange { startRow: number, endRow: number } /** 访问级别。 */ export type Access = 'public' | 'private' | 'protected' @@ -107,11 +110,17 @@ export type TokenRole */ export interface Segment { text: string, role: TokenRole, offset?: number } +/** + * 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字的节点(取自 facts,例如参数名、导入项的名称和别名、 + * 声明的变量名)在源码里的范围。跳到定义时,定义处落在这个范围里就只闪这一行;不填(第三方翻译插件可以不填)就闪整块。 + */ +export interface Defines { defines?: SourceRange } + /** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值。 */ -export interface PropertyItem { cells: Segment[][] } +export interface PropertyItem extends Defines { cells: Segment[][] } /** 属性表的一行:左列属性名,右列着色的值;items 是展开在这一行下面的子行(输入参数、导入项)。 */ -export interface PropertyRow { +export interface PropertyRow extends Defines { label: string value: Segment[] items?: PropertyItem[] diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 8a6d5e1..f46a786 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -8,7 +8,7 @@ export interface TranslatorOption { id: string, label: string } /** * 让预览滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset), - * 预览展开把它折叠住的块和属性行,再让那块闪几下。 + * 预览展开把它折叠住的块和属性行,再让就是这处定义的那一块或属性表里那一行闪几下。 */ export interface Reveal { line: number diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index eac32f0..cf8a5c7 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -55,9 +55,9 @@ 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 + return JSON.parse(JSON.stringify(value, (key, field: unknown) => key === 'offset' || key === 'defines' ? undefined : field)) as T } const CJK = /\p{Script=Han}/u @@ -578,6 +578,35 @@ describe('源码下标(跳到定义用)', () => { expect(linked(map.get(4)?.segments)).toEqual([['opts', at('opts ===')]]) }) + it('属性行、子行标出它讲的定义:参数名(连前缀、整个解构模式)、导入项的名称到别名、每个声明的变量名,其余行不标', async () => { + /** 标了 defines 的行写成「属性名: 那段源码」,子行缩进两格只写那段源码。 */ + const marked = (source: string, rows: PropertyRow[] | undefined): string[] => (rows ?? []).flatMap(row => [ + ...(row.defines ? [`${row.label}: ${source.slice(row.defines.startIndex, row.defines.endIndex)}`] : []), + ...(row.items ?? []).flatMap(item => item.defines ? [` ${source.slice(item.defines.startIndex, item.defines.endIndex)}`] : []), + ]) + const pySource = [ + 'from m import a as b, c', + 'import os.path as p', + 'def f(self, *args, key: int = 1, **kw): pass', + 'x = 1', + ].join('\n') + const pyRows = await describeAll(py, 'python', pythonZhTranslator, pySource) + expect(marked(pySource, pyRows.get(1))).toEqual([' a as b', ' c']) + expect(marked(pySource, pyRows.get(2))).toEqual([' os.path as p']) + expect(marked(pySource, pyRows.get(3))).toEqual([' self', ' *args', ' key', ' **kw']) + expect(marked(pySource, pyRows.get(4))).toEqual(['名称: x']) + + const tsSource = [ + 'import d, * as ns from \'m\'', + 'function g({ u, v }: T, w?: number) {}', + 'let i = 0, j', + ].join('\n') + const tsRows = await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource) + expect(marked(tsSource, tsRows.get(1))).toEqual([' d', ' ns']) + expect(marked(tsSource, tsRows.get(2))).toEqual([' { u, v }', ' w']) + expect(marked(tsSource, tsRows.get(3))).toEqual(['名称: i', '名称: j']) + }) + it('样例全文:带下标的只有照抄的名字和字符串,下标处的源码就是这段;变量名、函数名都带,译词、说明词都不带', async () => { const samples: [LanguageAdapter, string, Translator, string][] = [ [ts, 'typescript', typescriptZhTranslator, 'envLock.ts.txt'], diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 1756117..a5d0d43 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { Access, FactBlock, FunctionFacts, ImportFacts, ImportItem, Node, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' +import type { Access, Defines, FactBlock, FunctionFacts, ImportFacts, ImportItem, Node, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' import { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ @@ -388,6 +388,25 @@ function accessRows(access: Access | undefined): PropertyRow[] { return access ? [{ label: '访问级别', value: [{ text: ACCESS_ZH[access], role: 'keyword' }] }] : [] } +/** 这一行 / 子行讲的定义:几个节点(导入项是名称和别名)合起来在源码里的范围;一个节点都没有就不填。 */ +function defining(...nodes: (Node | undefined)[]): Defines { + const present = nodes.filter((node): node is Node => node !== undefined) + if (present.length === 0) + return {} + return { defines: { startIndex: Math.min(...present.map(node => node.startIndex)), endIndex: Math.max(...present.map(node => node.endIndex)) } } +} + +function importNodes(item: ImportItem): (Node | undefined)[] { + switch (item.kind) { + case 'named': + return [item.name, item.alias] + case 'module': + return [item.module, item.alias] + default: + return [item.alias] + } +} + function aliased(item: Segment[], alias: Node | undefined, say: Phrases): Segment[] { if (!alias) return item @@ -413,6 +432,7 @@ function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] { param.type ? say.translated(param.type) : [hint('未标注')], param.default ? [hint('默认 '), ...say.translated(param.default)] : [], ], + ...defining(param.name), })), }, { label: '输出参数', value: facts.returnType ? say.translated(facts.returnType) : [hint('未标注')] }, @@ -437,7 +457,7 @@ function importRows(facts: ImportFacts, say: Phrases): PropertyRow[] { return [ { label: '来源', value: facts.sources.length > 0 ? joined(facts.sources.map(say.module), ', ') : [hint('无')] }, ...(facts.typeOnly === undefined ? [] : [{ label: '仅类型', value: yesNo(facts.typeOnly) }]), - { label: '导入项', value: count(facts.items.length), items: facts.items.map(item => ({ cells: [itemText(item)] })) }, + { label: '导入项', value: count(facts.items.length), items: facts.items.map(item => ({ cells: [itemText(item)], ...defining(...importNodes(item)) })) }, ] } @@ -445,7 +465,7 @@ function importRows(facts: ImportFacts, say: Phrases): PropertyRow[] { function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] { const valueLabel = block.kind === 'const' ? '值' : '初始值' return block.facts.declarators.flatMap(declarator => [ - { label: '名称', value: say.translated(declarator.name) }, + { label: '名称', value: say.translated(declarator.name), ...defining(declarator.name) }, ...accessRows(block.facts.access), ...(declarator.type ? [{ label: '类型', value: say.translated(declarator.type) }] : []), { label: valueLabel, value: declarator.value ? say.translated(declarator.value) : [hint('无')] }, diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index f826a1b..1b39e3e 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -3,7 +3,7 @@ import type { PropertyItem, PropertyRow } from './types' import { ChevronDown, ChevronRight } from 'lucide-react' import { Fragment } from 'react' import { SegmentText } from './SegmentText' -import { propertyRowId } from './viewState' +import { propertyItemId, propertyRowId } from './viewState' /** 子行里除最后一列以外各列的宽度(参数的名称、类型),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */ const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)'] @@ -14,13 +14,18 @@ function columns(count: number): string { } /** 展开在一行下面的子行:左边一条竖线,每个子行几列对齐。 */ -function PropertyItems({ items }: { items: PropertyItem[] }): ReactElement { +function PropertyItems({ rowId, items }: { rowId: string, items: PropertyItem[] }): ReactElement { return (
{items.map((item, index) => ( -
+
{item.cells.map((cell, i) => (

@@ -49,7 +54,7 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: { const Chevron = folded ? ChevronRight : ChevronDown return ( -

+
{items.length > 0 ? (
- {items.length > 0 && !folded && } + {items.length > 0 && !folded && } ) })} diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 60d387b..97a0153 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -94,7 +94,16 @@ body.vscode-high-contrast-light { background: var(--kind); } -/* 跳到定义的目标块:边框和底色往聚焦色闪三下(共 1.5 秒)再复原;减少动态效果时改成静止高亮同样长的时间 */ +/* 属性行、子行平时看不出边框;往外扩出一圈,跳到定义只闪它时亮起来不贴着字,排版不动 */ +.cr-prop-row, +.cr-prop-item { + margin: -1px -4px; + padding: 0 3px; + border: 1px solid transparent; + border-radius: 4px; +} + +/* 跳到定义的目标(整块,或属性表里的一行 / 子行):边框和底色往聚焦色闪三下(共 1.5 秒)再复原;减少动态效果时改成静止高亮同样长的时间 */ @keyframes cr-flash { 50% { background: color-mix(in srgb, var(--vscode-focusBorder) 25%, transparent); @@ -107,7 +116,7 @@ body.vscode-high-contrast-light { border-color: var(--vscode-focusBorder); } } -.cr-block[data-flash] { +:is(.cr-block, .cr-prop-row, .cr-prop-item)[data-flash] { animation: cr-flash 500ms ease-in-out 3; } @@ -116,7 +125,7 @@ body.vscode-high-contrast-light { .cr-guide { transition: none; } - .cr-block[data-flash] { + :is(.cr-block, .cr-prop-row, .cr-prop-item)[data-flash] { animation: cr-flash-still 1500ms; } } diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index 187f82c..a20f38b 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -1,7 +1,15 @@ +import type { LanguageAdapter, Translator } from '../../../src/codeReader/contract' +import type { RevealPlan } from './reveal' import type { BlockKind, BlockView, PropertyRow, Segment } from './types' import { describe, expect, it } from 'vitest' +import { toBlockViews } from '../../../src/codeReader/blockView' +import { createPythonAdapter } from '../../../src/codeReader/languages/python' +import { createTypeScriptAdapter } from '../../../src/codeReader/languages/typescript' +import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' +import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh' +import { typescriptZhTranslator } from '../../../src/codeReader/translators/typescriptZh' import { revealPlan, scrollTopToShow } from './reveal' -import { collapsedIds } from './viewState' +import { collapsedIds, propertyItemId, propertyRowId } from './viewState' function name(text: string, offset: number): Segment { return { text, role: 'variable', offset } @@ -48,7 +56,7 @@ describe('跳到定义:滚到哪块、展开哪些', () => { expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) }) - it('名字在属性行展开的子行里(参数):连那一行一起展开', () => { + it('翻译插件没标 defines、名字在属性行展开的子行里(参数):闪整块,连那一行一起展开', () => { expect(revealPlan(BLOCKS, 2, 57)).toEqual({ id: '1', expand: ['1/p2'] }) expect(revealPlan(BLOCKS, 0, 34)).toEqual({ id: '0', expand: ['0/p1'] }) }) @@ -80,6 +88,159 @@ describe('跳到定义:滚到哪块、展开哪些', () => { }) }) +describe('跳到定义:闪哪一处(defines)', () => { + const range = (startIndex: number, endIndex: number): { startIndex: number, endIndex: number } => ({ startIndex, endIndex }) + /** + * 0 def f(*args): f 在 4,*args 在 6..11 + * 1 a = 1; b = 2 一条语句声明两个变量,a 在 20,b 在 27 + */ + const blocks: BlockView[] = [ + { + ...factBlock('0', 'function', 0, [ + { label: '名称', value: [name('f', 4)], defines: range(4, 5) }, + { label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('*args', 6)]], defines: range(6, 11) }] }, + ]), + defines: range(4, 5), + }, + factBlock('1', 'variable', 1, [ + { label: '名称', value: [name('a', 20)], defines: range(20, 21) }, + { label: '名称', value: [name('b', 27)], defines: range(27, 28) }, + ]), + ] + + it('定义是块自己的名字:闪整块,属性行也标了它也一样', () => { + expect(revealPlan(blocks, 0, 4)).toEqual({ id: '0', expand: [] }) + }) + + it('defines 是一段范围:定义落在中间也算(*args 的定义在 args 上)', () => { + expect(revealPlan(blocks, 0, 7)).toEqual({ id: '0', row: '0/p1/0', expand: ['0/p1'] }) + }) + + it('一条语句声明几个变量:只闪那个变量的名称行', () => { + expect(revealPlan(blocks, 1, 27)).toEqual({ id: '1', row: '1/p1', expand: [] }) + }) +}) + +describe('跳到定义:TS / Python 源码里闪哪一处', () => { + const ts = createTypeScriptAdapter(WASM_DIR) + const py = createPythonAdapter(WASM_DIR) + + /** 不填 defines 的第三方翻译插件:属性表照旧,行和子行都不标讲的是哪个定义。 */ + function withoutDefines(translator: Translator): Translator { + return { + ...translator, + describe: (block, source) => translator.describe(block, source).map(({ label, value, items }) => + items ? { label, value, items: items.map(({ cells }) => ({ cells })) } : { label, value }), + } + } + + function flatten(list: BlockView[]): BlockView[] { + return list.flatMap(view => [view, ...flatten(view.children)]) + } + + function text(segments: Segment[]): string { + return segments.map(segment => segment.text).join('') + } + + /** 闪的那一处:整块写成它的第一行源码,属性行写成「属性名: 值」,子行各列用 | 隔开。 */ + function flashed(views: BlockView[], plan: RevealPlan | undefined): string { + const block = flatten(views).find(view => view.id === plan?.id) + if (!block || !plan) + return '' + if (plan.row !== undefined && 'properties' in block) { + for (const [index, row] of block.properties.entries()) { + const rowId = propertyRowId(block.id, index) + if (plan.row === rowId) + return `${row.label}: ${text(row.value)}` + const item = row.items?.find((_, i) => plan.row === propertyItemId(rowId, i)) + if (item) + return item.cells.map(text).join(' | ') + } + } + return block.code.split('\n')[0] + } + + /** 定义提供者说定义在 source 里 needle 开头那个名字上:预览滚到哪、展开哪些、闪哪一处。 */ + async function jump(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, needle: string): Promise<{ plan: RevealPlan | undefined, flashed: string }> { + const offset = source.indexOf(needle) + expect(offset, needle).toBeGreaterThanOrEqual(0) + const views: BlockView[] = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, source, adapter, translator)) + const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset) + return { plan, flashed: flashed(views, plan) } + } + + const PY = [ + 'from app.util import fmt, clean as tidy', + '', + 'def mask_phone(phone: str) -> str:', + ' if len(phone) < 9:', + ' return phone', + ' for i in range(3):', + ' print(i)', + ' return fmt(phone)', + '', + 'LIMIT = 3', + 'a, b = 1, 2', + ].join('\n') + + const TS = [ + 'import { readFile as rf, stat } from \'node:fs\'', + '', + 'export function load(path: string, { encoding }: Options): string {', + ' for (const line of lines) {', + ' use(line, stat)', + ' }', + ' return rf(path, encoding)', + '}', + '', + 'let a = 1, b = 2', + 'const LIMIT = 3', + ].join('\n') + + it('python:参数只闪「输入参数」下它那个子行,导入项只闪它那一项', async () => { + const at = (needle: string): ReturnType => jump(py, 'python', pythonZhTranslator, PY, needle) + expect(await at('phone: str')).toEqual({ plan: { id: '1', row: '1/p3/0', expand: ['1/p3'] }, flashed: 'phone | 字符串 | ' }) + expect(await at('tidy')).toEqual({ plan: { id: '0', row: '0/p1/1', expand: ['0/p1'] }, flashed: 'clean 作为 tidy' }) + expect(await at('clean')).toEqual({ plan: { id: '0', row: '0/p1/1', expand: ['0/p1'] }, flashed: 'clean 作为 tidy' }) + }) + + it('python:函数名、变量名(一个解构模式也算一个)是块自己的名字,循环变量在一行译文里:都闪整块', async () => { + const at = (needle: string): ReturnType => jump(py, 'python', pythonZhTranslator, PY, needle) + expect(await at('mask_phone')).toEqual({ plan: { id: '1', expand: [] }, flashed: 'def mask_phone(phone: str) -> str:' }) + expect(await at('LIMIT')).toEqual({ plan: { id: '2', expand: [] }, flashed: 'LIMIT = 3' }) + expect(await at('b = 1')).toEqual({ plan: { id: '3', expand: [] }, flashed: 'a, b = 1, 2' }) + expect(await at('i in range')).toEqual({ plan: { id: '1.1', expand: ['1'] }, flashed: 'for i in range(3):' }) + }) + + it('python:翻译插件没标 defines 时闪整块,名字在子行里照样展开那一行', async () => { + const at = (needle: string): ReturnType => jump(py, 'python', withoutDefines(pythonZhTranslator), PY, needle) + expect(await at('phone: str')).toEqual({ plan: { id: '1', expand: ['1/p3'] }, flashed: 'def mask_phone(phone: str) -> str:' }) + expect(await at('tidy')).toEqual({ plan: { id: '0', expand: ['0/p1'] }, flashed: 'from app.util import fmt, clean as tidy' }) + }) + + it('typeScript:参数(解构参数里的名字也算)、导入项、多变量声明里的一个,只闪它那一行', async () => { + const at = (needle: string): ReturnType => jump(ts, 'typescript', typescriptZhTranslator, TS, needle) + expect(await at('path: string')).toEqual({ plan: { id: '1', row: '1/p3/0', expand: ['1/p3'] }, flashed: 'path | 字符串 | ' }) + expect(await at('encoding }')).toEqual({ plan: { id: '1', row: '1/p3/1', expand: ['1/p3'] }, flashed: '{ encoding } | Options | ' }) + expect(await at('rf,')).toEqual({ plan: { id: '0', row: '0/p2/0', expand: ['0/p2'] }, flashed: 'readFile 作为 rf' }) + expect(await at('stat }')).toEqual({ plan: { id: '0', row: '0/p2/1', expand: ['0/p2'] }, flashed: 'stat' }) + expect(await at('b = 2')).toEqual({ plan: { id: '2', row: '2/p3', expand: [] }, flashed: '名称: b' }) + }) + + it('typeScript:函数名、只声明一个的常量是块自己的名字,循环变量在一行译文里:都闪整块', async () => { + const at = (needle: string): ReturnType => jump(ts, 'typescript', typescriptZhTranslator, TS, needle) + expect(await at('load')).toEqual({ plan: { id: '1', expand: [] }, flashed: 'export function load(path: string, { encoding }: Options): string {' }) + expect(await at('LIMIT')).toEqual({ plan: { id: '3', expand: [] }, flashed: 'const LIMIT = 3' }) + expect(await at('line of')).toEqual({ plan: { id: '1.0', expand: ['1'] }, flashed: 'for (const line of lines) {' }) + }) + + it('typeScript:翻译插件没标 defines 时闪整块', async () => { + const at = (needle: string): ReturnType => jump(ts, 'typescript', withoutDefines(typescriptZhTranslator), TS, needle) + expect(await at('path: string')).toEqual({ plan: { id: '1', expand: ['1/p3'] }, flashed: 'export function load(path: string, { encoding }: Options): string {' }) + expect(await at('b = 2')).toEqual({ plan: { id: '2', expand: [] }, flashed: 'let a = 1, b = 2' }) + }) +}) + describe('把目标块滚进视口', () => { it('整块已经在视口里:不滚', () => { expect(scrollTopToShow({ top: 120, bottom: 160 }, 100, 500)).toBeUndefined() diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts index 2d68e5d..300a74a 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -1,12 +1,17 @@ -import type { BlockView, Segment } from './types' -import { propertyRowId } from './viewState' +import type { BlockView, Segment, SourceRange } from './types' +import { propertyItemId, propertyRowId } from './viewState' -/** 跳到定义时要滚到的块,以及要展开的块和属性行:它们折叠着,目标就不在页面上。 */ +/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块和属性行:它们折叠着,目标就不在页面上。 */ export interface RevealPlan { id: string + /** 属性行 id(propertyRowId)或子行 id(propertyItemId);没有时闪整块 */ + row?: string expand: string[] } +/** 目标在一块里的哪儿:row、expand 同 RevealPlan,expand 不含祖先块。 */ +type Spot = Omit + interface Placed { block: BlockView /** 从顶层到父块的块 id */ @@ -21,34 +26,53 @@ function* placed(blocks: BlockView[], ancestors: string[] = []): Generator segment.offset === offset) } -/** 块里哪儿显示着 offset 处的名字:'value' 是一行译文或属性行的值,数字是带子行的属性行序号(名字在它展开的子行里),不在这块里是 undefined。 */ -function locate(block: BlockView, offset: number): 'value' | number | undefined { +/** + * 块里哪儿是 offset 处的定义,从具体到笼统: + * ① 块自己的名字(函数名、只声明一个变量时的变量名)→ 整块; + * ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个)→ 只闪它,子行连它所在的行一起展开; + * ③ 块里只是显示着这个名字(一行译文里的循环变量、没填 defines 的翻译插件)→ 整块,名字在子行里时展开那一行。 + * 不在这块里是 undefined。 + */ +function locate(block: BlockView, offset: number): Spot | undefined { + if (covers(block.defines, offset)) + return { expand: [] } if ('translation' in block) - return shows(block.translation, offset) ? 'value' : undefined - for (const [index, row] of block.properties.entries()) { + return shows(block.translation, offset) ? { expand: [] } : undefined + const rows = block.properties.map((row, index) => ({ row, id: propertyRowId(block.id, index) })) + for (const { row, id } of rows) { + if (covers(row.defines, offset)) + return { row: id, expand: [] } + const item = row.items?.findIndex(entry => covers(entry.defines, offset)) ?? -1 + if (item >= 0) + return { row: propertyItemId(id, item), expand: [id] } + } + for (const { row, id } of rows) { if (shows(row.value, offset)) - return 'value' + return { expand: [] } if (row.items?.some(item => item.cells.some(cell => shows(cell, offset)))) - return index + return { expand: [id] } } return undefined } /** - * 跳到定义:目标是显示着定义处那个名字的块,名字在属性行的子行里时连这一行一起展开; - * 页面上找不到这个名字(例如定义是整个模块,落在文件开头)就取包含 line 的块,规则同 blockForLine。 + * 跳到定义:目标见 locate;页面上找不到这处定义(例如定义是整个模块,落在文件开头)就取包含 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 } + const spot = locate(item.block, definition) + if (spot) + return { id: item.block.id, ...spot, expand: [...item.ancestors, ...spot.expand] } if (item.block.startLine <= line && (!byLine || item.block.startLine > byLine.block.startLine)) byLine = item } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 8b1eea3..4dd8524 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -17,11 +17,20 @@ export type TokenRole /** offset:照抄自源码里的名字、字符串时它在源码里的起始下标,⌘ / Ctrl 悬停、点击时拿它查定义;译词、提示词没有 */ export interface Segment { text: string, role: TokenRole, offset?: number } +/** 源码里一段的下标,end 为开区间 */ +export interface SourceRange { startIndex: number, endIndex: number } + +/** + * 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字(参数名、导入项、声明的变量名)在源码里的范围。 + * 跳到的定义落在里面就只闪这一行;翻译插件没填就闪整块 + */ +export interface Defines { defines?: SourceRange } + /** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值 */ -export interface PropertyItem { cells: Segment[][] } +export interface PropertyItem extends Defines { cells: Segment[][] } /** 属性表的一行:左列属性名,右列着色的值;items 是展开在这一行下面的子行(输入参数、导入项) */ -export interface PropertyRow { +export interface PropertyRow extends Defines { label: string value: Segment[] items?: PropertyItem[] @@ -38,6 +47,8 @@ interface BlockViewBase { code: string /** 源码着色(拼起来等于 code) */ codeTokens: Segment[] + /** 块自己的名字(函数名、只声明一个变量时的变量名)在源码里的范围:跳到的定义就是它时闪整块 */ + defines?: SourceRange children: BlockView[] } @@ -48,7 +59,7 @@ export interface TranslatorOption { id: string, label: string } /** * 滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset), - * 要展开把它折叠住的块和属性行,再让那块闪几下 + * 要展开把它折叠住的块和属性行,再让就是这处定义的那一块或属性表里那一行闪几下 */ export interface Reveal { line: number diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index 02d919c..c756da4 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -4,7 +4,7 @@ import { revealPlan, scrollTopToShow } from './reveal' import { blockForLine } from './scrollPosition' import { measureBlocks } from './useScrollPosition' -/** 让一块闪几下(codeReader.css 的 [data-flash]);正在闪的同一块再跳一次时从头闪。 */ +/** 让一块或属性表里的一行闪几下(codeReader.css 的 [data-flash]);正在闪的同一处再跳一次时从头闪。 */ function flash(el: HTMLElement): void { const running = el.getAnimations().filter(animation => animation instanceof CSSAnimation) if (running.length > 0) { @@ -25,7 +25,7 @@ function flash(el: HTMLElement): void { /** * 滚到 reveal 指的位置,处理完调 done: * ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部,折叠着的不展开; - * ② 跳到定义:先展开把目标藏起来的块和属性行(expand 之后重新渲染,再进来一次),再把目标块滚进视口、闪几下。 + * ② 跳到定义:先展开把目标藏起来的块和属性行(expand 之后重新渲染,再进来一次),再把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。 */ export function useReveal( doc: DocumentMessage | null, @@ -50,7 +50,9 @@ export function useReveal( expand(hidden) return } - const el = plan && document.querySelector(`.cr-block[data-id="${plan.id}"]`) + // 块正在编辑时页面上没有属性表,就闪整块 + const row = plan?.row === undefined ? null : document.querySelector(`:is(.cr-prop-row, .cr-prop-item)[data-id="${plan.row}"]`) + const el = row ?? (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) diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index d500fb6..206eff4 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -80,3 +80,8 @@ export function collapsedIds(blocks: readonly BlockView[], folds: Readonly