diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index e1ab86f..a3bcb60 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -16,7 +16,8 @@ | 第一版翻译 | 只做中文模板直译;AI 解读以后作为另一个翻译插件加 | | 第一版语言 | TypeScript / TSX(含 JavaScript / JSX)、Python | | 扩展方式 | 写代码注册:语言插件、翻译插件都实现约定接口,通过本插件 `activate()` 导出的 API 注册;内置实现也走同一个 API | -| 多种翻译怎么选 | 预览顶部下拉框,整个文件一起换,按语言记住选择;只有一种时不显示 | +| 多种翻译怎么选 | 预览顶部筛选栏最右边的下拉框,整个文件一起换,按语言记住选择;只有一种时不显示 | +| 块筛选 | 预览顶部钉住一条筛选栏,按块的属性(种类、访问级别、异步……)筛;属性由语言插件按和语言无关的 id 报(通用的由宿主从 facts 推),名字由翻译插件起,webview 不认识任何语言;筛选整个工作区共用、记住 | | 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 | | 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 | | 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)和类块默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 | @@ -64,16 +65,34 @@ - 每一句是一块:导入、函数、类、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。 - 嵌套的块放在父块下面、往右缩进;每一层左边一条竖线(颜色 `--vscode-editorIndentGuide-background`)。 -- 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签、内容、右侧切换按钮。 +- 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签(字取翻译插件给 `kind` 值起的名字,见「筛选」)、内容、右侧切换按钮。 - 种类标签和底色按种类区分(颜色见下表);注释块虚线框。 - 内容两种状态,每块单独切: - 预览:函数、类、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。 - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。 - 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里。 - 折叠:函数、类块打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 -- 顶部工具栏:翻译下拉框(多于一种翻译时才显示)。 +- 顶部筛选栏:见「筛选」;翻译下拉框(多于一种翻译时才显示)也在栏里。 - 解析失败:显示错误原因,并提示用「编辑」查看源码。 +### 筛选 + +预览顶部钉住一条筛选栏,按块的属性藏掉一部分块(原型画板「筛选 · 默认 / 按访问级别 / 按种类」)。 + +- 三层分工,webview 不认识任何语言: + 1. 语言插件给每块报和语言无关的属性「属性 = 值」,都是稳定的英文 id,不放显示文字。通用词表由契约定(`COMMON_ATTRIBUTES`):`kind`(块的种类)、`access`(`public` / `private` / `protected`,语言可以加自己的值)、`async`、`abstract`、`typeOnly`(`'true'` / `'false'`)。通用属性由宿主从块的 kind 和 facts 推出来(`FunctionFacts.access` / `async`、`ClassFacts.access` / `abstract`、`ImportFacts.typeOnly`、`VariableFacts.access`),只有这一条路,语言插件不用另报;facts 里没填的(语言没有这个概念,例如 JavaScript 没有抽象类、仅类型导入)块就没有这个属性,不是填「否」。语言自己的属性(Rust 的 `unsafe = 'true'`)、facts 表达不了的通用属性值(Kotlin 的 `access = 'internal'`)填在块的 `attributes` 里,和推出来的合在一起,同一属性以它为准;`kind` 总是块的种类。 + 2. 翻译插件给属性和值起名(`Translator.attributeNames`:`access` → 访问级别、`public` → 公共、`kind` 的 `function` → 函数、布尔值 → 是 / 否),和属性表里的属性名、值是同一套叫法(中文直译里属性表的「访问级别」「异步」「抽象」「仅类型」行和筛选共用一份表);种类标签上的字也取它。没起名的显示 id。 + 3. webview 只按属性 id / 值 id 筛,显示翻译插件给的名字。 +- 下拉框:这份文件里有两种以上值的属性各一个(只有一种值的不给——筛它等于按种类筛);只有一种值、但这个值正被去掉的也给,不然藏起来的块在栏上找不到原因。排列:通用属性按契约的顺序(种类、访问级别、异步、抽象、仅类型),语言自己的属性按在文件里第一次出现的先后排在后面。下拉菜单是勾选列表,列出这份文件里出现的值(按第一次出现的先后)和各有几块(所有层的块都算,例如「私有 3 / 公共 3」)。 +- 规则:同一属性里勾着的值满足一个就显示,不同属性要同时满足;块没有某个属性就不受那个属性的筛选影响(条件、返回块没有访问级别,按访问级别筛时照常显示);块被筛掉,连同它的子块一起藏,不留空位,层级竖线跟着变短;子块都被筛掉的块不给折叠箭头。 +- 汇总:什么都没筛掉时右边写「共 N 块」(N 是整份文件所有层的块数);筛掉了写「已隐藏 N 块 · 清除」,N 只数直接被筛掉的块,跟着藏起来的子块不算;点「清除」整份筛选清空。页面上一块都不剩时写「所有块都被筛选隐藏了。」 +- 下拉框上的字:没去掉值时是属性名;去掉了(边框换 `--vscode-focusBorder`)勾着一种写「访问级别:公共」,几种写「种类:已选 11 种」,一种都不勾写「种类:未选」。 +- 记住:记的是每个属性**去掉的值**(`{ 属性 id: 值 id[] }`),所以文件里第一次出现的值默认显示。整个工作区共用一份(不按文件),存在扩展端的 `workspaceState`(`superpowers.codeReader.filter`),重新加载窗口后还在;一个预览改了,所有开着的预览跟着变。某个属性在这份文件里没出现就没有它的下拉框,记着的去掉的值照样对它出现的文件起作用。 +- 跳到定义到被筛掉的块:临时显示目标和通向它的路径(被筛掉的祖先块下面只露出这条路径,目标自己的子块照常按筛选显示,函数照样能展开看主体),本来被筛掉的块右边带小提示「已被筛选隐藏,临时显示」,照常展开、滚过去、闪。筛选一变,或文档刷新(正在编辑的块和它的祖先除外,不能打着字就消失)就回到筛选状态。 +- 折叠、跳到定义、属性行 id、滚动位置恢复照旧;被筛掉的块不在页面上,按行滚动(从文本编辑器切过来)落到它前面最近的一块。视口顶部按筛选栏下沿算:记位置、恢复、滚过去都让开筛选栏。 +- 样子:筛选栏钉在预览顶部(`position: sticky`,块从它下面滚过去),横贯编辑区、下边一条 `--vscode-panel-border`、底色 `--vscode-editor-background`,里面的内容和代码块同宽对齐:「≡ 筛选」(宽 52px)、每个属性一个下拉框(宽 150px、高 24px,`--vscode-dropdown-background` / `--vscode-dropdown-border` / `--vscode-dropdown-foreground`)、右边汇总(`descriptionForeground`,「清除」`--vscode-textLink-foreground`)、再右边翻译下拉框;放不下就换行。下拉菜单盖在代码块上面:宽 200px,`--vscode-editorWidget-background`、`--vscode-editorWidget-border`、`--vscode-widget-shadow` 阴影;每项勾选框(勾着是 `--vscode-focusBorder` 底)、值、右边块数(`--vscode-editorLineNumber-foreground`);悬停、键盘高亮 `--vscode-list-hoverBackground`。 +- 键盘和读屏:下拉框按钮 `aria-haspopup="listbox"`、`aria-expanded`;菜单 `role="listbox"`、`aria-multiselectable`,每项 `role="option"`、`aria-selected` 表示勾着。按钮上 ↓ / 回车 / 空格打开(↑ 打开并停在最后一项),列表里 ↑ ↓ Home End 移动,空格 / 回车勾选,Esc 关掉并回到按钮,Tab、点外面关掉。 + ### 属性表(函数、类、导入、常量、变量块) 卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」和值是字典 / 对象字面量的「值」「初始值」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」「字典 N 项」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`)。输入参数的子行分名称 / 类型 / 默认值三列对齐,前两列宽 150px / 220px,编辑区窄时按 28% / 38% 收,默认值列占剩下的;字典 / 对象的子行分键 / 值两列,键列同名称列。子行下面还能再展开一层(嵌套的字典 / 对象):带下一层的子行第一列前面有折叠箭头(点箭头或第一列切换,默认展开),同一层里其余子行在同样的位置留空、第一列对齐;下一层同样往右缩进 5px、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和参数、导入项的子行一样)。 @@ -81,8 +100,8 @@ | 种类 | 行 | | --- | --- | | 函数 | 名称(函数色;匿名函数写「匿名」)/ 说明(有说明才显示:JSDoc 去掉 `/**`、`*/` 和每行开头的 `*`,docstring 去掉引号;再去掉共同缩进和首尾空行,`@param` 这类标签行照抄;得到的原文按 Markdown 渲染,见「注释的 Markdown 排版」;里面的名字不能点)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(`默认 ` + 译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) | -| 类 | 名称(类型色;匿名类 `export default class {}` 写「匿名」;赋给变量、类字段的类表达式写变量名、字段名)/ 说明(有说明才显示,规则同函数)/ 访问级别 / 抽象(是 / 否,只有 TypeScript 有,看 `abstract class`,TypeScript 的类都有这一行)/ 装饰器(同函数)/ 类型参数(有才显示:TypeScript `` 每个参数的译文,逗号隔开,如 `K, T 限定为 X = Y`;Python 3.12 的 `class G[T]` 同样)/ 父类(有才显示:TypeScript `extends` 后面的表达式连着泛型实参的译文,如 `Base<字符串>`;Python 类名后面括号里的参数列表照写的顺序逐项翻,关键字参数、`*bases` 也在这一行,如 `Base, metaclass=ABCMeta`;逗号隔开)/ 实现的接口(有才显示:TypeScript `implements` 后面每个类型的译文,逗号隔开) | -| 导入 | 来源(TypeScript 是字符串字面量原样,如 `'node:fs'`,字符串色;Python 是点分模块名,如 `fastapi`、`.models`,类型色)/ 仅类型(是 / 否,只有 TypeScript 有,看 `import type`)/ 导入项(`N 个` 或「无」,每项一行:`name`、`name 作为 alias`;默认导入写「默认导出 作为 x」,`* as x` 写「全部 作为 x」,Python `from m import *` 写「全部」,Python `import a.b as c` 写「整个模块 作为 c」,一条 `import a, b` 导入多个模块时写模块名) | +| 类 | 名称(类型色;匿名类 `export default class {}` 写「匿名」;赋给变量、类字段的类表达式写变量名、字段名)/ 说明(有说明才显示,规则同函数)/ 访问级别 / 抽象(是 / 否,只有 TypeScript / TSX 有,看 `abstract class`,TypeScript 的类都有这一行;JavaScript / JSX 没有)/ 装饰器(同函数)/ 类型参数(有才显示:TypeScript `` 每个参数的译文,逗号隔开,如 `K, T 限定为 X = Y`;Python 3.12 的 `class G[T]` 同样)/ 父类(有才显示:TypeScript `extends` 后面的表达式连着泛型实参的译文,如 `Base<字符串>`;Python 类名后面括号里的参数列表照写的顺序逐项翻,关键字参数、`*bases` 也在这一行,如 `Base, metaclass=ABCMeta`;逗号隔开)/ 实现的接口(有才显示:TypeScript `implements` 后面每个类型的译文,逗号隔开) | +| 导入 | 来源(TypeScript 是字符串字面量原样,如 `'node:fs'`,字符串色;Python 是点分模块名,如 `fastapi`、`.models`,类型色)/ 仅类型(是 / 否,只有 TypeScript / TSX 有,看 `import type`;JavaScript / JSX 没有)/ 导入项(`N 个` 或「无」,每项一行:`name`、`name 作为 alias`;默认导入写「默认导出 作为 x」,`* as x` 写「全部 作为 x」,Python `from m import *` 写「全部」,Python `import a.b as c` 写「整个模块 作为 c」,一条 `import a, b` 导入多个模块时写模块名) | | 常量 / 变量 | 名称(解构声明写整个模式,如 `{ p, q }`)/ 访问级别 / 类型(写了标注才有)/ 值(常量)或初始值(变量):初始化表达式的译文,没有写「无」;是字典 / 对象字面量时见下面「字典 / 对象字面量」。一条语句声明几个变量(`let a = 1, b = 2`)就重复几组 | 访问级别(公共 / 私有 / 受保护): @@ -161,7 +180,7 @@ export interface FunctionFacts { name?: Node // 匿名函数没有 doc?: Node // 函数的说明:紧贴在前面的 JSDoc 注释节点(TS)、主体开头的 docstring 字符串节点(Python) access?: Access // 嵌套在语句块里的函数没有 - async: boolean + async?: boolean // 没有这个概念的语言不填 decorators: Node[] parameters: ParameterFacts[] returnType?: Node @@ -174,7 +193,7 @@ export interface ClassFacts { name?: Node // 匿名类没有;赋给变量、类字段的类表达式取变量名、字段名 doc?: Node // 同 FunctionFacts.doc access?: Access // 嵌套在语句块里的类没有 - abstract?: boolean // 只有 TypeScript 填 + abstract?: boolean // 只有 TypeScript 填(JavaScript 不填) decorators: Node[] typeParameters: Node[] // 每个泛型参数一项 bases: BaseFacts[] @@ -189,7 +208,7 @@ export type ImportItem = export interface ImportFacts { sources: Node[] // 来源模块的写法;Python 的 import a, b 有多个 - typeOnly?: boolean // 只有 TypeScript 填 + typeOnly?: boolean // 只有 TypeScript 填(JavaScript 不填) items: ImportItem[] } @@ -211,12 +230,24 @@ export interface VariableFacts { declarators: DeclaratorFacts[] } +/** + * 筛选属性的通用词表,筛选栏按这个顺序排:kind(BlockKind)、access(public / private / protected,语言可加值)、 + * async / abstract / typeOnly('true' / 'false')。宿主从 kind 和 facts 推出,facts 里没填的块就没有这个属性。 + */ +export const COMMON_ATTRIBUTES = ['kind', 'access', 'async', 'abstract', 'typeOnly'] as const +export type CommonAttribute = (typeof COMMON_ATTRIBUTES)[number] + +/** 块的筛选属性:属性 id → 值 id,都是稳定的英文 id,不放显示文字。 */ +export type BlockAttributes = Record + export interface BlockShape { /** 这一块对应的语法树节点(整条语句)。 */ node: Node /** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句;函数、类的说明(doc)也算在里面。 */ header: SourceSpan children: CodeBlock[] + /** 语言自己报的筛选属性:通用词表以外的(Rust unsafe: 'true'),或 facts 表达不了的通用属性值(Kotlin access: 'internal',盖过推出来的);kind 填了不算。 */ + attributes?: BlockAttributes } /** 预览成属性表的块:种类和 facts 绑在一起,语言插件漏给 facts 编译不过。 */ @@ -278,11 +309,18 @@ export interface LanguageAdapter { languageIds: string[] /** 该 languageId 用的语法包 .wasm 的绝对路径(宿主负责加载,保证和解析器运行时是同一份)。 */ grammarPath: (languageId: string) => string - toBlocks: (root: Node, source: string) => CodeBlock[] + /** languageId:一个插件覆盖几种方言时按它区分(JavaScript 没有 TypeScript 的抽象类、仅类型导入)。 */ + toBlocks: (root: Node, source: string, languageId: string) => CodeBlock[] /** 源码着色:给语法树叶子节点定词性。 */ tokenRole: (leaf: Node) => TokenRole } +/** 筛选属性和值的显示名;没给名字的显示 id。 */ +export interface AttributeNames { + attributes: Record // access → 访问级别 + values: Record> // access → public → 公共、async → true → 是、kind → function → 函数 +} + /** 翻译插件:代码块 → 一行译文或属性表。 */ export interface Translator { id: string // 'typescript.zh-template' @@ -290,6 +328,7 @@ export interface Translator { languageIds: string[] translate: (block: TextBlock, source: string) => PreviewText // 只翻 header 那一段;注释块给 Markdown 原文 describe: (block: FactBlock, source: string) => PropertyRow[] // 函数、类、导入、常量、变量块 → 属性表 + attributeNames?: AttributeNames // 筛选属性和值叫什么,和属性表的叫法一致;筛选栏、种类标签用它 } export interface CodeReaderApi { @@ -301,7 +340,7 @@ export interface CodeReaderApi { - `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。 - 内置的 TypeScript、Python 语言插件和两个中文直译翻译插件在 `activate()` 里通过同一个 API 注册,没有旁路。 - 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。 -- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译。第三方语言插件给函数、类、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表;自己语言的字典 / 映射字面量填进 `DeclaratorFacts.entries` 就能展开成子行(中文模板直译按字面量的节点类型查叫法,见 `ZhTable.mapLiterals`)。 +- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译,也给筛选属性和值起名(`attributeNames`)。筛选属性的通用部分由宿主从 facts 推出(`attributes.ts` 的 `blockAttributes`),语言插件只在 `BlockShape.attributes` 里补自己语言特有的。第三方语言插件给函数、类、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表;自己语言的字典 / 映射字面量填进 `DeclaratorFacts.entries` 就能展开成子行(中文模板直译按字面量的节点类型查叫法,见 `ZhTable.mapLiterals`)。 ## 解析运行时 @@ -347,8 +386,8 @@ export interface CodeReaderApi { ### 属性表信息(facts)从哪来 - TypeScript 函数:`function_declaration` / `generator_function_declaration` / `function_signature`、类里的 `method_definition` / 方法签名、值是带语句块主体函数的变量和类字段、`export default` 的函数值。名称取声明名 / 变量名 / 字段名;说明取紧贴在前面的 JSDoc 注释节点(见上面代码块树规则);参数来自 `formal_parameters`(`required_parameter` / `optional_parameter` 的 pattern、type、value,JavaScript 的 `assignment_pattern`),不带括号的单参数箭头函数取 `parameter`;返回类型取 `return_type` 里的类型;异步看有没有 `async`;类成员的装饰器是类主体里排在它前面的 `decorator` 兄弟节点。 -- TypeScript 类:`class_declaration` / `abstract_class_declaration`、类表达式 `class`(`export default class {}`、变量的值、类字段的值)。名称取类名 / 变量名 / 字段名;说明同函数;访问级别同函数(类字段按类成员);抽象看节点类型是不是 `abstract_class_declaration`;装饰器是 `export_statement` 和类声明自己的 `decorator` 子节点(`@d export class`、`export @d class`、`@d class` 都算),类字段的值是类表达式时还有类主体里排在它前面的 `decorator`;泛型参数是 `type_parameters` 里每个 `type_parameter`;父类、实现的接口在 `class_heritage` 里:`extends_clause` 的每个 value 一项,跟在它后面的 `type_arguments` 归它,`implements_clause` 里每个类型一项;JavaScript 的 `class_heritage` 直接是 `extends` 后面的表达式。 -- TypeScript 导入:`import_statement` 的 `source`(`import x = require()` 取 `import_require_clause` 里的);`import_clause` 里的标识符是默认导入,`namespace_import` 是 `* as`,`named_imports` 里每个 `import_specifier` 的 name / alias;语句本身带 `type` 是仅类型。 +- TypeScript 类:`class_declaration` / `abstract_class_declaration`、类表达式 `class`(`export default class {}`、变量的值、类字段的值)。名称取类名 / 变量名 / 字段名;说明同函数;访问级别同函数(类字段按类成员);抽象看节点类型是不是 `abstract_class_declaration`(只有 languageId 是 typescript / typescriptreact 时填,JavaScript 没有这个概念,不填);装饰器是 `export_statement` 和类声明自己的 `decorator` 子节点(`@d export class`、`export @d class`、`@d class` 都算),类字段的值是类表达式时还有类主体里排在它前面的 `decorator`;泛型参数是 `type_parameters` 里每个 `type_parameter`;父类、实现的接口在 `class_heritage` 里:`extends_clause` 的每个 value 一项,跟在它后面的 `type_arguments` 归它,`implements_clause` 里每个类型一项;JavaScript 的 `class_heritage` 直接是 `extends` 后面的表达式。 +- TypeScript 导入:`import_statement` 的 `source`(`import x = require()` 取 `import_require_clause` 里的);`import_clause` 里的标识符是默认导入,`namespace_import` 是 `* as`,`named_imports` 里每个 `import_specifier` 的 name / alias;语句本身带 `type` 是仅类型(同抽象,只有 TypeScript 填)。 - TypeScript 变量:每个 `variable_declarator` 的 name / type / value;value 是 `object` 时 entries 是它的具名子节点(注释除外):`pair` 的 key / value(value 又是 `object` 时连它的 entries)、`shorthand_property_identifier`(键和值都是它)、`spread_element` 里的表达式、`method_definition` 的 name 和头部(到 body 之前)。 - Python 函数:`function_definition` 的 name / parameters / return_type,`async` 子节点;说明是主体开头 docstring 的字符串节点(`string` / `concatenated_string`);装饰器是 `decorated_definition` 里的 `decorator`;参数 `identifier`、`typed_parameter`(名字是第一个具名子节点)、`default_parameter`、`typed_default_parameter`、`*args` / `**kwargs`,`*` 和 `/` 分隔符不算参数。 - Python 类:`class_definition` 的 name;说明是主体开头 docstring 的字符串节点;装饰器同函数;泛型参数是 Python 3.12 `type_parameters` 里的每一项;父类是 `superclasses`(`argument_list`)里的每个具名子节点,照写的顺序,关键字参数(`metaclass=ABCMeta`)、`*bases`、`**kw` 也算;没有抽象、实现的接口。 @@ -434,6 +473,7 @@ interface BlockViewBase { code: string // header 源码,去掉共同缩进 codeTokens: Segment[] // 源码着色(拼起来等于 code) defines?: SourceRange // 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取;跳到的定义是它时闪整块 + attributes: Record // 筛选属性(含 kind),见「筛选」 children: BlockView[] } @@ -442,15 +482,17 @@ type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: P ``` - 扩展 → webview: - - `{ type: 'document', version, languageId, blocks: BlockView[], translators: { id, label }[], translatorId, error?: string, reveal?: Reveal }`,文档每次变化(防抖 150ms)重发。`reveal = { line, definition? }` 只随新预览的第一份文档走:从文本编辑器切过来时是它顶部那一行(滚到包含这行的块、顶到顶部,折叠的不展开);跳到定义时还带 `definition`(定义处名字在源码里的下标),按「跳到定义」一节展开、滚动、闪。 + - `{ type: 'document', version, languageId, blocks: BlockView[], attributes: FileAttribute[], translators: { id, label }[], translatorId, error?: string, reveal?: Reveal }`,文档每次变化(防抖 150ms)重发。`attributes` 是这份文件里出现过的筛选属性,按筛选栏的先后排:`{ id, name, values: { id, name, count }[] }`,名字取翻译插件的 `attributeNames`,没有的用 id。`reveal = { line, definition? }` 只随新预览的第一份文档走:从文本编辑器切过来时是它顶部那一行(滚到包含这行的块、顶到顶部,折叠的不展开);跳到定义时还带 `definition`(定义处名字在源码里的下标),按「跳到定义」一节展开、滚动、闪。 - `{ type: 'reveal', line, definition? }`:同上,发给已经开着的预览。页面正在重新加载(标签页切回来)时收不到,扩展端改成随它 `ready` 后的第一份文档带过去。 - `{ type: 'definitionProbed', id, found }`:回答第 `id` 次 `probeDefinition`。 + - `{ type: 'filter', excluded: Record<属性 id, 值 id[]> }`:整个工作区共用的块筛选。webview 发 `ready` 后先于第一份文档发一次(第一份文档按筛选画),之后任何一个预览改了都发给所有开着的预览(包括改的那个)。 - webview → 扩展: - `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。 - `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。 - `{ type: 'ready' }` - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 + - `{ type: 'setFilter', excluded }`:改块筛选(整份替换);扩展端逐项核对后记进 `workspaceState`,再发 `filter` 给所有预览。webview 不在本地先改,只按扩展端发来的显示。 - 块的编辑状态和用户的折叠选择在 webview 里按块 id 记(`vscode.setState`):`{ editing: string[], folds: Record, scroll? }`。`folds` 只记用户点过的(id → 折叠着没有),属性行和带下一层的子行也记在里面:属性行 id 是 `<块 id>/p<行序号>`(例如 `6.1.0/p3`),子行 id 是上一层(行或子行)的 id 加 `/<子行序号>`,一层层往后接(例如 `6.1.0/p3/1` 是第 3 行的第 1 个子行,`6.1.0/p3/1/0` 是它下面的第 0 个);块 id 只有数字和点,只有行那一节带 `p`、其余各节是纯数字,所以块、行、各层子行的 id 互不相撞。子行 id 也是跳到定义时在页面上找要闪的那一项用的。没记的按默认:函数、类块折叠,其余块、属性行和子行展开。眼下折叠着哪些由「默认 + folds」算出来,界面、跳到定义的展开都只看这一份。跳到定义展开祖先块、属性行时也记进 `folds`(记成展开)。读回来的状态逐项核对,旧版本存的、形状不对的项当没存过。文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 ## 颜色 @@ -482,6 +524,7 @@ vscode/src/codeReader/ registry.ts 注册表 + CodeReaderApi runtime.ts web-tree-sitter 初始化、语法包加载、解析 blockView.ts CodeBlock → BlockView(源码着色、去缩进、调用翻译) + attributes.ts 块的筛选属性(从 facts 推出通用属性 + 语言自己报的)、文件里出现过的属性和块数 languages/typescript.ts languages/python.ts translators/zhTemplate.ts 模板直译引擎(按语言的表驱动) @@ -495,10 +538,11 @@ vscode/src/codeReader/ notes/commands.ts 「添加注释 / 编辑注释」输入框 editor/previewEditor.ts CustomTextEditorProvider editor/messages.ts 消息类型 + editor/blockFilter.ts 块筛选:workspaceState 读写、通知所有预览 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,Markdown 渲染在 markdown.ts / TextPreview.tsx) +vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;属性表在 PropertyGrid.tsx,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts) ``` ## 测试 @@ -514,6 +558,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 行替换:多行块替换后缩进正确。 - 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。 +- 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。 - 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。 ## 收尾 diff --git a/vscode/src/codeReader/attributes.test.ts b/vscode/src/codeReader/attributes.test.ts new file mode 100644 index 0000000..e9fe976 --- /dev/null +++ b/vscode/src/codeReader/attributes.test.ts @@ -0,0 +1,157 @@ +import type { BlockView } from './blockView' +import type { CodeBlock, LanguageAdapter, Translator } from './contract' +import { describe, expect, it } from 'vitest' +import { blockAttributes, fileAttributes } from './attributes' +import { toBlockViews } from './blockView' +import { createPythonAdapter } from './languages/python' +import { createTypeScriptAdapter } from './languages/typescript' +import { propertyLines, WASM_DIR, withBlocks } from './testSupport' +import { pythonZhTranslator } from './translators/pythonZh' +import { typescriptZhTranslator } from './translators/typescriptZh' + +const ts = createTypeScriptAdapter(WASM_DIR) +const py = createPythonAdapter(WASM_DIR) + +function views(adapter: LanguageAdapter, languageId: string, source: string, translator?: Translator): Promise { + return withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, source, adapter, translator)) +} + +/** 每块的筛选属性,按 header 起始行(1 起)索引;同一行有几块取外层那块。 */ +function attributesByLine(list: BlockView[], out: Record> = {}): Record> { + for (const view of list) { + out[view.startLine + 1] ??= view.attributes + attributesByLine(view.children, out) + } + return out +} + +const TS_SOURCE = [ + 'import type { A } from \'a\'', // 1 + 'import { b } from \'b\'', // 2 + 'export abstract class Repo {', // 3 + ' private async load() {}', // 4 + ' count = 0', // 5 + '}', // 6 + 'class Plain {}', // 7 + 'const LIMIT = 3', // 8 + 'export function run() {', // 9 + ' if (ok) {', // 10 + ' return 1', // 11 + ' }', // 12 + '}', // 13 +].join('\n') + +describe('块的筛选属性:宿主从 kind 和 facts 推出通用属性', () => { + it('typeScript:种类、访问级别、异步、抽象、仅类型;没有访问级别的块(条件、返回、字段)不带 access', async () => { + expect(attributesByLine(await views(ts, 'typescript', TS_SOURCE))).toEqual({ + 1: { kind: 'import', typeOnly: 'true' }, + 2: { kind: 'import', typeOnly: 'false' }, + 3: { kind: 'class', access: 'public', abstract: 'true' }, + 4: { kind: 'function', access: 'private', async: 'true' }, + 5: { kind: 'field' }, + 7: { kind: 'class', access: 'private', abstract: 'false' }, + 8: { kind: 'const', access: 'private' }, + 9: { kind: 'function', access: 'public', async: 'false' }, + 10: { kind: 'if' }, + 11: { kind: 'return' }, + }) + }) + + it('tSX 和 TypeScript 一样分抽象类、仅类型导入', async () => { + const found = attributesByLine(await views(ts, 'typescriptreact', 'import type { A } from \'a\'\nabstract class B {}\nconst C = () => { return
}')) + expect(found[1]).toEqual({ kind: 'import', typeOnly: 'true' }) + expect(found[2]).toEqual({ kind: 'class', access: 'private', abstract: 'true' }) + expect(found[3]).toEqual({ kind: 'function', access: 'private', async: 'false' }) + }) + + it('javaScript / JSX 没有抽象类、仅类型导入:块上没有这两个属性,属性表里也没有这两行', async () => { + const source = 'import { b } from \'b\'\nexport class K {}\nasync function f() {}' + for (const languageId of ['javascript', 'javascriptreact']) { + const list = await views(ts, languageId, source, typescriptZhTranslator) + expect(attributesByLine(list)).toEqual({ + 1: { kind: 'import' }, + 2: { kind: 'class', access: 'public' }, + 3: { kind: 'function', access: 'private', async: 'true' }, + }) + const rows = list.map(view => 'properties' in view ? propertyLines(view.properties) : []) + expect(rows[0]).toEqual(['来源: \'b\'', '导入项: 1 个', ' b']) + expect(rows[1]).toEqual(['名称: K', '访问级别: 公共']) + } + }) + + it('python:访问级别、异步;没有抽象、仅类型', async () => { + const source = [ + 'import os', // 1 + 'class _Hidden:', // 2 + ' async def run(self):', // 3 + ' pass', // 4 + 'LIMIT = 3', // 5 + 'def load():', // 6 + ' for x in y:', // 7 + ' pass', // 8 + ].join('\n') + const found = attributesByLine(await views(py, 'python', source)) + expect(found[1]).toEqual({ kind: 'import' }) + expect(found[2]).toEqual({ kind: 'class', access: 'private' }) + expect(found[3]).toEqual({ kind: 'function', access: 'public', async: 'true' }) + expect(found[5]).toEqual({ kind: 'const', access: 'public' }) + expect(found[6]).toEqual({ kind: 'function', access: 'public', async: 'false' }) + expect(found[7]).toEqual({ kind: 'loop' }) + }) + + it('语言插件自己报的属性叠在上面:加新属性、盖过 facts 推出的值(Kotlin 的 internal),kind 总是块的种类', async () => { + await withBlocks(ts, 'typescript', 'export function f() {}', ([block]) => { + const extended: CodeBlock = { ...block, attributes: { access: 'internal', unsafe: 'true', kind: 'other' } } + expect(blockAttributes(extended)).toEqual({ kind: 'function', access: 'internal', async: 'false', unsafe: 'true' }) + }) + }) +}) + +function view(id: string, attributes: Record, children: BlockView[] = []): BlockView { + return { id, kind: 'statement', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes, children, translation: [] } +} + +describe('文件里出现过的属性', () => { + const blocks = [ + view('0', { kind: 'const', unsafe: 'true', access: 'private' }), + view('1', { kind: 'function', access: 'public', async: 'false', pure: 'yes' }, [ + view('1.0', { kind: 'if' }), + view('1.1', { kind: 'function', async: 'true', unsafe: 'false' }), + ]), + view('2', { kind: 'import', typeOnly: 'false' }), + ] + + it('通用属性按契约的顺序排在前面,语言自己的按第一次出现的先后排在后面;值按第一次出现的先后,带所有层的块数', () => { + const found = fileAttributes(blocks, undefined) + expect(found.map(attribute => attribute.id)).toEqual(['kind', 'access', 'async', 'typeOnly', 'unsafe', 'pure']) + expect(found[0].values).toEqual([ + { id: 'const', name: 'const', count: 1 }, + { id: 'function', name: 'function', count: 2 }, + { id: 'if', name: 'if', count: 1 }, + { id: 'import', name: 'import', count: 1 }, + ]) + expect(found[4].values.map(value => [value.id, value.count])).toEqual([['true', 1], ['false', 1]]) + }) + + it('显示名取翻译插件给的,没给的属性、值显示 id', () => { + const found = fileAttributes(blocks, { attributes: { access: '访问级别' }, values: { access: { public: '公共' } } }) + expect(found[1]).toEqual({ id: 'access', name: '访问级别', values: [{ id: 'private', name: 'private', count: 1 }, { id: 'public', name: '公共', count: 1 }] }) + expect(found[0].name).toBe('kind') + }) + + it('中文直译给通用属性和值起名,和属性表里的叫法一样', async () => { + const list = await views(ts, 'typescript', TS_SOURCE, typescriptZhTranslator) + const found = fileAttributes(list, typescriptZhTranslator.attributeNames) + const names = Object.fromEntries(found.map(attribute => [attribute.name, attribute.values.map(value => `${value.name} ${value.count}`)])) + expect(names).toEqual({ + 种类: ['导入 2', '类 2', '函数 2', '字段 1', '常量 1', '条件 1', '返回 1'], + 访问级别: ['公共 2', '私有 3'], + 异步: ['是 1', '否 1'], + 抽象: ['是 1', '否 1'], + 仅类型: ['是 1', '否 1'], + }) + const labels = list.flatMap(block => 'properties' in block ? block.properties.map(row => row.label) : []) + expect(labels).toEqual(expect.arrayContaining(['访问级别', '抽象', '仅类型'])) + expect(pythonZhTranslator.attributeNames).toEqual(typescriptZhTranslator.attributeNames) + }) +}) diff --git a/vscode/src/codeReader/attributes.ts b/vscode/src/codeReader/attributes.ts new file mode 100644 index 0000000..b29910a --- /dev/null +++ b/vscode/src/codeReader/attributes.ts @@ -0,0 +1,74 @@ +import type { AttributeNames, BlockAttributes, CodeBlock, CommonAttribute } from './contract' +import { COMMON_ATTRIBUTES } from './contract' + +/** 这份文件里出现过的一个属性值:显示名和有几块是这个值。 */ +export interface AttributeValueCount { + id: string + name: string + count: number +} + +/** 这份文件里出现过的一个属性:显示名,出现过的值按第一次出现的先后排。 */ +export interface FileAttribute { + id: string + name: string + values: AttributeValueCount[] +} + +function flag(value: boolean | undefined): string | undefined { + return value === undefined ? undefined : String(value) +} + +/** 从 facts 推出的通用属性(kind 以外);facts 里没填的是 undefined。 */ +function factAttributes(block: CodeBlock): Partial> { + switch (block.kind) { + case 'function': + return { access: block.facts.access, async: flag(block.facts.async) } + case 'class': + return { access: block.facts.access, abstract: flag(block.facts.abstract) } + case 'import': + return { typeOnly: flag(block.facts.typeOnly) } + case 'const': + case 'variable': + return { access: block.facts.access } + default: + return {} + } +} + +/** 块的筛选属性:从 facts 推出的通用属性,叠上语言插件自己报的(同一属性以它为准),kind 总是块的种类。 */ +export function blockAttributes(block: CodeBlock): BlockAttributes { + const derived = Object.entries(factAttributes(block)).filter((entry): entry is [string, string] => entry[1] !== undefined) + return { ...Object.fromEntries(derived), ...block.attributes, kind: block.kind } +} + +interface Attributed { + attributes: BlockAttributes + children: Attributed[] +} + +/** + * 整份文件(所有层的块)里出现过的属性,每个值有几块。通用属性按 COMMON_ATTRIBUTES 的顺序排在前面, + * 语言自己的属性按第一次出现的先后排在后面。显示名取翻译插件的 names,没给的用 id。 + */ +export function fileAttributes(blocks: readonly Attributed[], names: AttributeNames | undefined): FileAttribute[] { + const counts = new Map>() + const visit = (list: readonly Attributed[]): void => { + for (const block of list) { + for (const [id, value] of Object.entries(block.attributes)) { + const values = counts.get(id) ?? new Map() + counts.set(id, values.set(value, (values.get(value) ?? 0) + 1)) + } + visit(block.children) + } + } + visit(blocks) + const common: readonly string[] = COMMON_ATTRIBUTES + const rank = (id: string): number => common.includes(id) ? common.indexOf(id) : common.length + // sort 是稳定的:语言自己的属性同排在最后,保持第一次出现的先后 + return [...counts].sort(([a], [b]) => rank(a) - rank(b)).map(([id, values]) => ({ + id, + name: names?.attributes[id] ?? id, + values: [...values].map(([value, count]) => ({ id: value, name: names?.values[id]?.[value] ?? value, count })), + })) +} diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index fb1fdd0..1602ca0 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,5 +1,6 @@ -import type { BlockKind, CodeBlock, LanguageAdapter, Node, PreviewText, PropertyRow, Segment, SourceRange, TokenRole, Translator } from './contract' +import type { BlockAttributes, BlockKind, CodeBlock, LanguageAdapter, Node, PreviewText, PropertyRow, Segment, SourceRange, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' +import { blockAttributes } from './attributes' import { commonIndent, splitLines } from './editText' interface BlockViewBase { @@ -15,6 +16,8 @@ interface BlockViewBase { codeTokens: Segment[] /** 块自己的名字(见 ownName)在源码里的范围:跳到的定义就是它时闪整块,不闪属性表里的某一行。 */ defines?: SourceRange + /** 筛选属性(含 kind),见 blockAttributes。 */ + attributes: BlockAttributes children: BlockView[] } @@ -116,6 +119,7 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu 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 } }), + attributes: blockAttributes(block), children: block.children.map((child, i) => build(child, `${id}.${i}`)), ...('facts' in block ? { properties: translator ? translator.describe(block, source) : [] } @@ -135,7 +139,7 @@ export async function readBlockViews( ): Promise { const tree = await runtime.parse(adapter, languageId, source) try { - return toBlockViews(adapter.toBlocks(tree.rootNode, source), source, adapter, translator) + return toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), source, adapter, translator) } finally { tree.delete() diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index a5a397a..0227d26 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -36,7 +36,8 @@ export interface FunctionFacts { doc?: Node /** 嵌套在函数体、条件等语句块里的函数没有。 */ access?: Access - async: boolean + /** 异步函数;没有这个概念的语言不填。 */ + async?: boolean decorators: Node[] parameters: ParameterFacts[] returnType?: Node @@ -107,6 +108,21 @@ export interface VariableFacts { declarators: DeclaratorFacts[] } +/** + * 筛选属性的通用词表,筛选栏按这个顺序排,语言自己的属性排在后面: + * - kind:块的种类(BlockKind); + * - access:访问级别,'public' | 'private' | 'protected',语言可以加自己的值(Kotlin 的 'internal',见 BlockShape.attributes); + * - async、abstract、typeOnly:异步、抽象、仅类型,值是 'true' | 'false'。 + * 宿主从块的 kind 和 facts 推出它们(FunctionFacts.access / async、ClassFacts.access / abstract、ImportFacts.typeOnly、VariableFacts.access), + * 语言插件不用另报;facts 里没填的(语言没有这个概念、块没有这一项),块就没有这个属性,也不受这个属性的筛选影响。 + */ +export const COMMON_ATTRIBUTES = ['kind', 'access', 'async', 'abstract', 'typeOnly'] as const + +export type CommonAttribute = (typeof COMMON_ATTRIBUTES)[number] + +/** 块的筛选属性:属性 id → 值 id,都是稳定的英文 id,不放显示文字(显示名由翻译插件给,见 AttributeNames)。 */ +export type BlockAttributes = Record + export interface BlockShape { /** 这一块对应的语法树节点(整条语句)。 */ node: Node @@ -116,6 +132,11 @@ export interface BlockShape { */ header: SourceSpan children: CodeBlock[] + /** + * 语言自己报的筛选属性,和宿主推出的通用属性(见 COMMON_ATTRIBUTES)合在一起:通用词表以外的属性(Rust 的 unsafe: 'true'), + * 或 facts 表达不了的通用属性值(Kotlin 的 access: 'internal',盖过从 facts 推出的)。kind 总是块的种类,这里填了不算。 + */ + attributes?: BlockAttributes } /** 预览成属性表的块:语言插件从语法树读出结构化信息(facts),翻译插件把它排成属性表。 */ @@ -185,11 +206,20 @@ export interface LanguageAdapter { languageIds: string[] /** 该 languageId 用的语法包 .wasm 的绝对路径(宿主负责加载,保证和解析器运行时是同一份)。 */ grammarPath: (languageId: string) => string - toBlocks: (root: Node, source: string) => CodeBlock[] + /** languageId 是这份源码的 languageId:一个插件覆盖几种方言时按它区分(JavaScript 没有 TypeScript 的抽象类、仅类型导入)。 */ + toBlocks: (root: Node, source: string, languageId: string) => CodeBlock[] /** 源码着色:给语法树叶子节点定词性。 */ tokenRole: (leaf: Node) => TokenRole } +/** 筛选属性和值的显示名,筛选栏、种类标签用它;没给名字的显示 id。 */ +export interface AttributeNames { + /** 属性 id → 属性名,例如 access → 访问级别。 */ + attributes: Record + /** 属性 id → 值 id → 值名,例如 access → public → 公共、async → true → 是、kind → function → 函数。 */ + values: Record> +} + /** 翻译插件:代码块 → 一行译文或属性表。 */ export interface Translator { id: string // 'typescript.zh-template' @@ -199,6 +229,8 @@ export interface Translator { translate: (block: TextBlock, source: string) => PreviewText /** 函数、类、导入、常量、变量块 → 属性表。 */ describe: (block: FactBlock, source: string) => PropertyRow[] + /** 筛选属性和值叫什么,和属性表里的属性名、值用同一套叫法;不给就都显示 id。 */ + attributeNames?: AttributeNames } export interface CodeReaderApi { diff --git a/vscode/src/codeReader/editor/blockFilter.test.ts b/vscode/src/codeReader/editor/blockFilter.test.ts new file mode 100644 index 0000000..81b900f --- /dev/null +++ b/vscode/src/codeReader/editor/blockFilter.test.ts @@ -0,0 +1,44 @@ +import type { Memento } from 'vscode' +import type { Excluded } from './blockFilter' +import { describe, expect, it } from 'vitest' +import { BlockFilter, FILTER_KEY, normalizeExcluded } from './blockFilter' + +function memento(initial: Record = {}): Pick & { store: Record } { + const store = { ...initial } + return { + store, + get: (key: string): T | undefined => store[key] as T | undefined, + update: async (key: string, value: unknown) => { + store[key] = value + }, + } as Pick & { store: Record } +} + +describe('块筛选:整个工作区共用一份', () => { + it('记的是去掉的值,写进 workspaceState,重新打开窗口(新的实例)读得回来', async () => { + const state = memento() + await new BlockFilter(state).set({ access: ['private'], kind: ['comment', 'import'] }) + expect(state.store[FILTER_KEY]).toEqual({ access: ['private'], kind: ['comment', 'import'] }) + expect(new BlockFilter(state).excluded).toEqual({ access: ['private'], kind: ['comment', 'import'] }) + }) + + it('一个预览改了,所有订阅的预览都收到新的筛选;取消订阅后不再收到', async () => { + const filter = new BlockFilter(memento()) + const first: Excluded[] = [] + const second: Excluded[] = [] + filter.subscribe(excluded => first.push(excluded)) + const off = filter.subscribe(excluded => second.push(excluded)) + await filter.set({ async: ['true'] }) + off.dispose() + await filter.set({}) + expect(first).toEqual([{ async: ['true'] }, {}]) + expect(second).toEqual([{ async: ['true'] }]) + }) + + it('读回来的、webview 发来的逐项核对:形状不对的当没有,值去重排序,空的属性去掉', () => { + expect(normalizeExcluded(undefined)).toEqual({}) + expect(normalizeExcluded(['access'])).toEqual({}) + expect(normalizeExcluded({ kind: ['if', 'comment', 'if', 3], access: 'private', async: [] })).toEqual({ kind: ['comment', 'if'] }) + expect(new BlockFilter(memento({ [FILTER_KEY]: 'broken' })).excluded).toEqual({}) + }) +}) diff --git a/vscode/src/codeReader/editor/blockFilter.ts b/vscode/src/codeReader/editor/blockFilter.ts new file mode 100644 index 0000000..c2bb986 --- /dev/null +++ b/vscode/src/codeReader/editor/blockFilter.ts @@ -0,0 +1,53 @@ +import type { Disposable, Memento } from 'vscode' + +/** workspaceState 里记的块筛选:Excluded。 */ +export const FILTER_KEY = 'superpowers.codeReader.filter' + +/** + * 块筛选:属性 id → 不勾(藏起来)的值 id。记的是去掉的值而不是勾着的值: + * 一份文件里第一次出现的值默认显示,换到没出现过这些值的文件也不用改。 + */ +export type Excluded = Record + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** 读回来的、webview 发来的筛选逐项核对:只留字符串值,去重排序;一个值都不剩的属性去掉。 */ +export function normalizeExcluded(value: unknown): Excluded { + if (!isRecord(value)) + return {} + const entries = Object.entries(value).flatMap(([attribute, values]): [string, string[]][] => { + const kept = Array.isArray(values) ? [...new Set(values.filter((item): item is string => typeof item === 'string'))].sort() : [] + return kept.length > 0 ? [[attribute, kept]] : [] + }) + return Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b))) +} + +/** + * 整个工作区共用的一份块筛选:记在 workspaceState 里,重新加载窗口后还在; + * 一个预览改了,所有订阅的预览都收到(包括改的那个,webview 只按扩展端发来的显示)。 + * 不依赖 vscode 运行时,便于单元测试。 + */ +export class BlockFilter { + private readonly listeners = new Set<(excluded: Excluded) => void>() + + constructor(private readonly state: Pick) {} + + get excluded(): Excluded { + return normalizeExcluded(this.state.get(FILTER_KEY)) + } + + /** 筛选变了就调 listener,带上新的筛选。 */ + subscribe(listener: (excluded: Excluded) => void): Disposable { + this.listeners.add(listener) + return { dispose: () => this.listeners.delete(listener) } + } + + async set(value: unknown): Promise { + const excluded = normalizeExcluded(value) + await this.state.update(FILTER_KEY, excluded) + for (const listener of [...this.listeners]) + listener(excluded) + } +} diff --git a/vscode/src/codeReader/editor/documentMessage.test.ts b/vscode/src/codeReader/editor/documentMessage.test.ts index 4fd430d..289e98d 100644 --- a/vscode/src/codeReader/editor/documentMessage.test.ts +++ b/vscode/src/codeReader/editor/documentMessage.test.ts @@ -36,11 +36,15 @@ describe('预览的 document 消息', () => { expect(message.error).toBeUndefined() expect(message.blocks.map(b => b.kind)).toEqual(['if']) expect(message.blocks[0].children).toMatchObject([{ kind: 'return', translation: [{ text: '返回' }, { text: ' ' }, { text: '空数组' }] }]) + expect(message.attributes).toEqual([ + { id: 'kind', name: '种类', values: [{ id: 'if', name: '条件', count: 1 }, { id: 'return', name: '返回', count: 1 }] }, + ]) }) it('没有语言插件的语言:带 error,不解析', async () => { const message = await documentMessage(doc('rust', 'fn main() {}'), registryWithTypeScript(), runtime) expect(message.blocks).toEqual([]) + expect(message.attributes).toEqual([]) expect(message.translators).toEqual([]) expect(message.error).toContain('rust') }) diff --git a/vscode/src/codeReader/editor/documentMessage.ts b/vscode/src/codeReader/editor/documentMessage.ts index cb9100b..7b2ee59 100644 --- a/vscode/src/codeReader/editor/documentMessage.ts +++ b/vscode/src/codeReader/editor/documentMessage.ts @@ -1,6 +1,7 @@ import type { CodeReaderRegistry } from '../registry' import type { TreeSitterRuntime } from '../runtime' import type { DocumentMessage } from './messages' +import { fileAttributes } from '../attributes' import { readBlockViews } from '../blockView' /** 生成预览数据要读的那点文档信息(TextDocument 的子集)。 */ @@ -24,6 +25,7 @@ export async function documentMessage( version, languageId, blocks: [], + attributes: [], translators: registry.translatorsFor(languageId).map(({ id, label }) => ({ id, label })), translatorId: translator?.id ?? '', } @@ -31,7 +33,8 @@ export async function documentMessage( if (!adapter) return { ...message, error: `代码阅读还不支持「${languageId}」这种语言` } try { - return { ...message, blocks: await readBlockViews(runtime, adapter, translator, languageId, source) } + const blocks = await readBlockViews(runtime, adapter, translator, languageId, source) + return { ...message, blocks, attributes: fileAttributes(blocks, translator?.attributeNames) } } catch (err) { return { ...message, error: err instanceof Error ? err.message : String(err) } diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index f46a786..ddcfade 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -2,7 +2,11 @@ * 代码阅读预览与扩展之间的消息,与 webview 端 `webview-ui/src/codeReader/types.ts` 同构,两边手动保持一致。 */ +import type { FileAttribute } from '../attributes' import type { BlockView } from '../blockView' +import type { Excluded } from './blockFilter' + +export type { Excluded } export interface TranslatorOption { id: string, label: string } @@ -21,6 +25,8 @@ export interface DocumentMessage { version: number languageId: string blocks: BlockView[] + /** 这份文件里出现过的筛选属性和值(带显示名、块数),按筛选栏的先后排,见 fileAttributes */ + attributes: FileAttribute[] translators: TranslatorOption[] translatorId: string error?: string @@ -31,11 +37,13 @@ export interface DocumentMessage { /** * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) * definitionProbed:id 那次 probeDefinition 的回答,found 表示 VS Code 查得到定义 + * filter:整个工作区共用的块筛选;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览 */ export type ExtensionToWebview = | DocumentMessage | ({ type: 'reveal' } & Reveal) | { type: 'definitionProbed', id: number, found: boolean } + | { type: 'filter', excluded: Excluded } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) @@ -43,9 +51,11 @@ export type ExtensionToWebview * probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问源码 offset 处有没有定义;id 标出是哪一次问,回答带回去; * version 是这份下标所属的文档版本 * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 + * setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览 */ export type WebviewToExtension = | { type: 'ready' } + | { type: 'setFilter', excluded: Excluded } | { type: 'edit', startLine: number, endLine: number, text: string } | { type: 'selectTranslator', id: string } | { type: 'visibleLine', line: number } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 5aa6431..a4346d9 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,6 +1,7 @@ import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode' import type { CodeReaderRegistry } from '../registry' import type { TreeSitterRuntime } from '../runtime' +import type { BlockFilter } from './blockFilter' import type { Definition } from './definitions' import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages' import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode' @@ -29,13 +30,14 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { private readonly extensionUri: Uri, private readonly registry: CodeReaderRegistry, private readonly runtime: TreeSitterRuntime, + private readonly filter: BlockFilter, ) {} resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { 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, this.registry, this.runtime, this.reveals.get(key)) + const preview = new Preview(document, panel, this, this.registry, this.runtime, this.filter, this.reveals.get(key)) this.reveals.delete(key) this.previews.add(preview) panel.onDidDispose(() => { @@ -96,6 +98,7 @@ class Preview { private readonly owner: PreviewEditorProvider, private readonly registry: CodeReaderRegistry, private readonly runtime: TreeSitterRuntime, + private readonly filter: BlockFilter, /** * 随下一份真正发出去的文档走、只走一次:新预览第一份文档要滚到的位置,或页面正在重新加载时收不到的 reveal。 * 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复 @@ -112,6 +115,11 @@ class Preview { if (e.document === document && e.contentChanges.length > 0) this.schedulePush() }), + // 页面没 ready 时收不到,等它 ready 再随第一份文档之前补发 + filter.subscribe((excluded) => { + if (this.ready) + this.post({ type: 'filter', excluded }) + }), ] } @@ -167,8 +175,13 @@ class Preview { switch (msg.type) { case 'ready': this.ready = true + // 先于文档发:第一份文档按筛选画,不会先整份露出来再藏 + this.post({ type: 'filter', excluded: this.filter.excluded }) await this.push() return + case 'setFilter': + await this.filter.set(msg.excluded) + return case 'edit': await this.applyEdit(msg.startLine, msg.endLine, msg.text) return diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index f5db9e8..d9ebc46 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -1,6 +1,7 @@ import type { ExtensionContext, Uri } from 'vscode' import type { CodeReaderApi } from './contract' import { commands, window, workspace } from 'vscode' +import { BlockFilter } from './editor/blockFilter' import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor' import { showPreview, showSource } from './editor/switchEditor' import { FileTreeProvider } from './explorer/fileTree' @@ -45,7 +46,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise { export function registerCodeReader(context: ExtensionContext): CodeReaderApi { const registry = new CodeReaderRegistry(context.globalState) const runtime = new TreeSitterRuntime(bundledWasmDir()) - const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime) + const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState)) const notes = new FileNotes() const files = new FileTreeProvider(OPEN_COMMAND, notes) diff --git a/vscode/src/codeReader/languages/typescript.test.ts b/vscode/src/codeReader/languages/typescript.test.ts index 496e040..00c274d 100644 --- a/vscode/src/codeReader/languages/typescript.test.ts +++ b/vscode/src/codeReader/languages/typescript.test.ts @@ -532,4 +532,15 @@ describe('typeScript 类', () => { expect(js[1]).toMatchObject({ name: 'A', bases: [{ value: 'mixin(B)' }], implements: [] }) expect(js[2]).toMatchObject({ access: 'public', bases: [{ value: 'C' }] }) }) + + it('javaScript / JSX 没有抽象类、仅类型导入:facts 里不填 abstract、typeOnly(不是填 false)', async () => { + for (const languageId of ['javascript', 'javascriptreact']) { + const js = await facts('import { a } from \'a\'\nclass K {}', languageId) + expect(js[1]).toEqual({ sources: ['\'a\''], items: [{ kind: 'named', name: 'a' }] }) + expect(js[2]).toEqual({ name: 'K', access: 'private', decorators: [], typeParameters: [], bases: [], implements: [] }) + } + const tsx = await facts('import { a } from \'a\'\nclass K {}', 'typescriptreact') + expect(tsx[1]).toMatchObject({ typeOnly: false }) + expect(tsx[2]).toMatchObject({ abstract: false }) + }) }) diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index db81dcb..eca103c 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -10,6 +10,9 @@ const GRAMMARS: Record = { javascriptreact: 'tree-sitter-javascript.wasm', } +/** 分得出抽象类、仅类型导入的 languageId;JavaScript / JSX 没有这两个概念。 */ +const TYPED_LANGUAGES = new Set(['typescript', 'typescriptreact']) + const FUNCTION_VALUES = new Set(['arrow_function', 'function_expression', 'function', 'generator_function']) const LOOPS = new Set(['for_statement', 'for_in_statement', 'while_statement', 'do_statement']) const CLASSES = new Set(['class_declaration', 'abstract_class_declaration', 'class']) @@ -41,118 +44,6 @@ function callbackBody(expression: Node | null | undefined): Node | null { return null } -function statements(items: Node[], top: boolean, afterRow: number): CodeBlock[] { - return listBlocks(items, afterRow, item => statementBlocks(item, top), COMMENTS) -} - -/** 主体是语句块时子块来自它的语句,否则主体本身就是唯一的子语句(`if (x) return`)。 */ -function bodyShape(outer: Node, body: Node | null): BlockShape { - if (!body) - return leafShape(outer) - const items = body.type === 'statement_block' ? named(body) : [body] - return compoundShape(outer, body.startIndex, items, (list, afterRow) => statements(list, false, afterRow)) -} - -function bodyBlock(kind: TextKind, outer: Node, body: Node | null): CodeBlock { - return { kind, ...bodyShape(outer, body) } -} - -function statementBlocks(node: Node, top: boolean): CodeBlock[] { - switch (node.type) { - case 'import_statement': - return [{ kind: 'import', ...leafShape(node), facts: importFacts(node) }] - case 'export_statement': - return exportBlocks(node, top) - case 'if_statement': - return ifBlocks(node, node, 'if') - case 'try_statement': - return tryBlocks(node) - case 'switch_statement': - return [switchBlock(node)] - case 'switch_case': - case 'switch_default': - return [caseBlock(node)] - case 'return_statement': - return [leafBlock('return', node)] - case 'expression_statement': - return [bodyBlock('statement', node, callbackBody(node.firstNamedChild))] - case 'labeled_statement': { - const [first, ...rest] = statementBlocks(node.childForFieldName('body') ?? node, top) - return first ? [{ ...extendHeader(first, node), node }, ...rest] : [] - } - case 'statement_block': - return statements(named(node), top, node.startPosition.row) - case 'empty_statement': - return [] - default: - if (LOOPS.has(node.type)) - return [bodyBlock('loop', node, node.childForFieldName('body'))] - return declarationBlocks(node, node, top) - } -} - -/** export 包着声明时按声明定种类,块节点取整个 export_statement(头部包含 export)。 */ -function exportBlocks(node: Node, top: boolean): CodeBlock[] { - const declaration = node.childForFieldName('declaration') - if (declaration) - return declarationBlocks(declaration, node, top) - const value = node.childForFieldName('value') - if (value && CLASSES.has(value.type)) - return [classBlock(node, value, value.childForFieldName('name'), 'public')] - if (value && functionBody(value)) - return [functionBlock(node, value, value.childForFieldName('name'), 'public')] - return [leafBlock('statement', node)] -} - -function declarationBlocks(declaration: Node, outer: Node, top: boolean): CodeBlock[] { - switch (declaration.type) { - case 'function_declaration': - case 'generator_function_declaration': - case 'function_signature': - return [functionBlock(outer, declaration, declaration.childForFieldName('name'), declarationAccess(outer, top))] - case 'lexical_declaration': - case 'variable_declaration': - return [variableBlock(declaration, outer, top)] - case 'interface_declaration': - case 'enum_declaration': - return [membersBlock(declaration, outer)] - case 'type_alias_declaration': - return [leafBlock('type', outer)] - default: { - if (CLASSES.has(declaration.type)) - return [classBlock(outer, declaration, declaration.childForFieldName('name'), declarationAccess(outer, top))] - const body = declaration.childForFieldName('body') - return [body?.type === 'statement_block' ? bodyBlock('statement', outer, body) : leafBlock('statement', outer)] - } - } -} - -/** 唯一声明值是带语句块主体的函数 → function,是类表达式 → class;否则模块顶层的 const → const,其余 → variable。 */ -function variableBlock(declaration: Node, outer: Node, top: boolean): CodeBlock { - const declarators = named(declaration).filter(child => child.type === 'variable_declarator') - const access = declarationAccess(outer, top) - const value = declarators.length === 1 ? declarators[0].childForFieldName('value') : null - if (value && CLASSES.has(value.type)) - return classBlock(outer, value, declarators[0].childForFieldName('name'), access) - if (value && functionBody(value)) - return functionBlock(outer, value, declarators[0].childForFieldName('name'), access) - const isConst = declaration.type === 'lexical_declaration' && declaration.childForFieldName('kind')?.type === 'const' - return { - kind: top && isConst ? 'const' : 'variable', - ...bodyShape(outer, callbackBody(value)), - facts: { access, declarators: declarators.map(declaratorFacts) }, - } -} - -/** 函数块:fn 是声明、方法或函数值,带语句块主体时子块来自主体;name 是它在源码里的名字(变量名、方法名)。 */ -function functionBlock(outer: Node, fn: Node, name: Node | null, access: Access | undefined, decorators: Node[] = []): CodeBlock { - return { - kind: 'function', - ...bodyShape(outer, fn.childForFieldName('body')), - facts: functionFacts(fn, name, access, decorators), - } -} - /** 模块里的声明:export 了是公共,顶层没 export 是私有,嵌套在语句块里的没有访问级别。 */ function declarationAccess(outer: Node, top: boolean): Access | undefined { if (outer.type === 'export_statement') @@ -242,8 +133,8 @@ function objectEntries(value: Node | null): EntryFacts[] | undefined { }) } -/** import 语句:默认导入、`* as x`、`{ a as b }`、`import x = require('m')`;`import type` 只导入类型。 */ -function importFacts(node: Node): ImportFacts { +/** import 语句:默认导入、`* as x`、`{ a as b }`、`import x = require('m')`;TypeScript(typed)的 `import type` 只导入类型。 */ +function importFacts(node: Node, typed: boolean): ImportFacts { const require = named(node).find(child => child.type === 'import_require_clause') const source = (require ?? node).childForFieldName('source') const items: ImportItem[] = [] @@ -265,87 +156,22 @@ function importFacts(node: Node): ImportFacts { } } } - return { sources: source ? [source] : [], typeOnly: children(node).some(child => child.type === 'type'), items } -} - -/** if 的 else / else if 展平成紧跟其后的兄弟块。 */ -function ifBlocks(ifNode: Node, outer: Node, kind: 'if' | 'else'): CodeBlock[] { - const blocks = [bodyBlock(kind, outer, ifNode.childForFieldName('consequence'))] - const alternative = ifNode.childForFieldName('alternative') - if (alternative) { - const inner = named(alternative).find(child => child.type !== 'comment') ?? null - if (inner?.type === 'if_statement') - blocks.push(...ifBlocks(inner, alternative, 'else')) - else - blocks.push(bodyBlock('else', alternative, inner)) - } - return blocks -} - -function tryBlocks(node: Node): CodeBlock[] { - const blocks = [bodyBlock('try', node, node.childForFieldName('body'))] - const handler = node.childForFieldName('handler') - if (handler) - blocks.push(bodyBlock('catch', handler, handler.childForFieldName('body'))) - const finalizer = node.childForFieldName('finalizer') - if (finalizer) - blocks.push(bodyBlock('finally', finalizer, finalizer.childForFieldName('body'))) - return blocks -} - -function switchBlock(node: Node): CodeBlock { - const body = node.childForFieldName('body') - if (!body) - return leafBlock('if', node) - return { kind: 'if', ...compoundShape(node, body.startIndex, named(body), (list, afterRow) => statements(list, false, afterRow)) } -} - -/** case / default:头部到冒号为止,子块来自它下面的语句。 */ -function caseBlock(node: Node): CodeBlock { - const [first] = node.childrenForFieldName('body') - const bodyStart = first?.startIndex ?? node.endIndex - const items = named(node).filter(child => child.startIndex >= bodyStart) - return { kind: 'if', ...compoundShape(node, bodyStart, items, (list, afterRow) => statements(list, false, afterRow)) } -} - -/** - * 类块:declaration 是类声明或类表达式,name 是它在源码里的名字(类名;赋给变量、类字段的类表达式取变量名、字段名), - * decorators 是类主体里排在它前面的装饰器(类字段的值是类表达式时)。 - */ -function classBlock(outer: Node, declaration: Node, name: Node | null, access: Access | undefined, decorators: Node[] = []): CodeBlock { - const body = declaration.childForFieldName('body') - const facts = classFacts(outer, declaration, name, access, decorators) - if (!body) - return { kind: 'class', ...leafShape(outer), facts } - return { kind: 'class', ...compoundShape(outer, body.startIndex, named(body), classMembers), facts } -} - -/** 类成员:成员前面的装饰器是类主体里的兄弟节点,算进成员头部,也交给成员当装饰器。 */ -function classMembers(list: Node[], afterRow: number): CodeBlock[] { - let decorators: Node[] = [] - return listBlocks(list, afterRow, (member) => { - if (member.type === 'decorator') { - decorators.push(member) - return [] - } - const blocks = memberBlocks(member, decorators) - const [from] = decorators - decorators = [] - return from ? blocks.map((block, i) => i === 0 ? extendHeader(block, from) : block) : blocks - }, COMMENTS) + const sources = source ? [source] : [] + return typed ? { sources, typeOnly: children(node).some(child => child.type === 'type'), items } : { sources, items } } /** * 类的装饰器写在 export 前面时挂在 export 语句上,写在 class 前面时挂在类声明上; - * 父类、实现的接口在 class_heritage 里:TypeScript 分 extends_clause / implements_clause,JavaScript 直接是 extends 后面的表达式。 + * 父类、实现的接口在 class_heritage 里:TypeScript 分 extends_clause / implements_clause,JavaScript 直接是 extends 后面的表达式; + * 只有 TypeScript(typed)分抽象类。 */ -function classFacts(outer: Node, declaration: Node, name: Node | null, access: Access | undefined, decorators: Node[]): ClassFacts { +function classFacts(outer: Node, declaration: Node, name: Node | null, access: Access | undefined, decorators: Node[], typed: boolean): ClassFacts { const holders = sameNode(outer, declaration) ? [declaration] : [outer, declaration] const heritage = syntaxChildren(named(declaration).find(child => child.type === 'class_heritage')) return { name: name ?? undefined, access, - abstract: declaration.type === 'abstract_class_declaration', + ...(typed && { abstract: declaration.type === 'abstract_class_declaration' }), decorators: [...decorators, ...holders.flatMap(holder => named(holder).filter(child => child.type === 'decorator'))], typeParameters: syntaxChildren(declaration.childForFieldName('type_parameters')), bases: heritage.flatMap((part) => { @@ -370,35 +196,222 @@ function extendsBases(clause: Node): BaseFacts[] { return bases } -function memberBlocks(member: Node, decorators: Node[]): CodeBlock[] { - switch (member.type) { - case 'method_definition': - case 'method_signature': - case 'abstract_method_signature': - return [functionBlock(member, member, member.childForFieldName('name'), memberAccess(member), decorators)] - case 'class_static_block': - return [bodyBlock('statement', member, member.childForFieldName('body'))] - default: { - const value = member.childForFieldName('value') - if (value && CLASSES.has(value.type)) - return [classBlock(member, value, member.childForFieldName('name'), memberAccess(member), decorators)] - if (value && functionBody(value)) - return [functionBlock(member, value, member.childForFieldName('name'), memberAccess(member), decorators)] - return [leafBlock('field', member)] +/** + * 读代码块树的一套函数。typed:TypeScript(typescript、typescriptreact),分得出抽象类、仅类型导入; + * JavaScript 没有这两个概念,facts 里不填(见 classFacts、importFacts)。 + */ +function blockReader(typed: boolean): (root: Node) => CodeBlock[] { + function statements(items: Node[], top: boolean, afterRow: number): CodeBlock[] { + return listBlocks(items, afterRow, item => statementBlocks(item, top), COMMENTS) + } + + /** 主体是语句块时子块来自它的语句,否则主体本身就是唯一的子语句(`if (x) return`)。 */ + function bodyShape(outer: Node, body: Node | null): BlockShape { + if (!body) + return leafShape(outer) + const items = body.type === 'statement_block' ? named(body) : [body] + return compoundShape(outer, body.startIndex, items, (list, afterRow) => statements(list, false, afterRow)) + } + + function bodyBlock(kind: TextKind, outer: Node, body: Node | null): CodeBlock { + return { kind, ...bodyShape(outer, body) } + } + + function statementBlocks(node: Node, top: boolean): CodeBlock[] { + switch (node.type) { + case 'import_statement': + return [{ kind: 'import', ...leafShape(node), facts: importFacts(node, typed) }] + case 'export_statement': + return exportBlocks(node, top) + case 'if_statement': + return ifBlocks(node, node, 'if') + case 'try_statement': + return tryBlocks(node) + case 'switch_statement': + return [switchBlock(node)] + case 'switch_case': + case 'switch_default': + return [caseBlock(node)] + case 'return_statement': + return [leafBlock('return', node)] + case 'expression_statement': + return [bodyBlock('statement', node, callbackBody(node.firstNamedChild))] + case 'labeled_statement': { + const [first, ...rest] = statementBlocks(node.childForFieldName('body') ?? node, top) + return first ? [{ ...extendHeader(first, node), node }, ...rest] : [] + } + case 'statement_block': + return statements(named(node), top, node.startPosition.row) + case 'empty_statement': + return [] + default: + if (LOOPS.has(node.type)) + return [bodyBlock('loop', node, node.childForFieldName('body'))] + return declarationBlocks(node, node, top) } } -} -/** interface / enum:成员(属性、方法签名、枚举项)→ field。 */ -function membersBlock(declaration: Node, outer: Node): CodeBlock { - const body = declaration.childForFieldName('body') - if (!body) - return leafBlock('type', outer) - return { - kind: 'type', - ...compoundShape(outer, body.startIndex, named(body), (list, afterRow) => - listBlocks(list, afterRow, member => [leafBlock('field', member)], COMMENTS)), + /** export 包着声明时按声明定种类,块节点取整个 export_statement(头部包含 export)。 */ + function exportBlocks(node: Node, top: boolean): CodeBlock[] { + const declaration = node.childForFieldName('declaration') + if (declaration) + return declarationBlocks(declaration, node, top) + const value = node.childForFieldName('value') + if (value && CLASSES.has(value.type)) + return [classBlock(node, value, value.childForFieldName('name'), 'public')] + if (value && functionBody(value)) + return [functionBlock(node, value, value.childForFieldName('name'), 'public')] + return [leafBlock('statement', node)] } + + function declarationBlocks(declaration: Node, outer: Node, top: boolean): CodeBlock[] { + switch (declaration.type) { + case 'function_declaration': + case 'generator_function_declaration': + case 'function_signature': + return [functionBlock(outer, declaration, declaration.childForFieldName('name'), declarationAccess(outer, top))] + case 'lexical_declaration': + case 'variable_declaration': + return [variableBlock(declaration, outer, top)] + case 'interface_declaration': + case 'enum_declaration': + return [membersBlock(declaration, outer)] + case 'type_alias_declaration': + return [leafBlock('type', outer)] + default: { + if (CLASSES.has(declaration.type)) + return [classBlock(outer, declaration, declaration.childForFieldName('name'), declarationAccess(outer, top))] + const body = declaration.childForFieldName('body') + return [body?.type === 'statement_block' ? bodyBlock('statement', outer, body) : leafBlock('statement', outer)] + } + } + } + + /** 唯一声明值是带语句块主体的函数 → function,是类表达式 → class;否则模块顶层的 const → const,其余 → variable。 */ + function variableBlock(declaration: Node, outer: Node, top: boolean): CodeBlock { + const declarators = named(declaration).filter(child => child.type === 'variable_declarator') + const access = declarationAccess(outer, top) + const value = declarators.length === 1 ? declarators[0].childForFieldName('value') : null + if (value && CLASSES.has(value.type)) + return classBlock(outer, value, declarators[0].childForFieldName('name'), access) + if (value && functionBody(value)) + return functionBlock(outer, value, declarators[0].childForFieldName('name'), access) + const isConst = declaration.type === 'lexical_declaration' && declaration.childForFieldName('kind')?.type === 'const' + return { + kind: top && isConst ? 'const' : 'variable', + ...bodyShape(outer, callbackBody(value)), + facts: { access, declarators: declarators.map(declaratorFacts) }, + } + } + + /** 函数块:fn 是声明、方法或函数值,带语句块主体时子块来自主体;name 是它在源码里的名字(变量名、方法名)。 */ + function functionBlock(outer: Node, fn: Node, name: Node | null, access: Access | undefined, decorators: Node[] = []): CodeBlock { + return { + kind: 'function', + ...bodyShape(outer, fn.childForFieldName('body')), + facts: functionFacts(fn, name, access, decorators), + } + } + + /** if 的 else / else if 展平成紧跟其后的兄弟块。 */ + function ifBlocks(ifNode: Node, outer: Node, kind: 'if' | 'else'): CodeBlock[] { + const blocks = [bodyBlock(kind, outer, ifNode.childForFieldName('consequence'))] + const alternative = ifNode.childForFieldName('alternative') + if (alternative) { + const inner = named(alternative).find(child => child.type !== 'comment') ?? null + if (inner?.type === 'if_statement') + blocks.push(...ifBlocks(inner, alternative, 'else')) + else + blocks.push(bodyBlock('else', alternative, inner)) + } + return blocks + } + + function tryBlocks(node: Node): CodeBlock[] { + const blocks = [bodyBlock('try', node, node.childForFieldName('body'))] + const handler = node.childForFieldName('handler') + if (handler) + blocks.push(bodyBlock('catch', handler, handler.childForFieldName('body'))) + const finalizer = node.childForFieldName('finalizer') + if (finalizer) + blocks.push(bodyBlock('finally', finalizer, finalizer.childForFieldName('body'))) + return blocks + } + + function switchBlock(node: Node): CodeBlock { + const body = node.childForFieldName('body') + if (!body) + return leafBlock('if', node) + return { kind: 'if', ...compoundShape(node, body.startIndex, named(body), (list, afterRow) => statements(list, false, afterRow)) } + } + + /** case / default:头部到冒号为止,子块来自它下面的语句。 */ + function caseBlock(node: Node): CodeBlock { + const [first] = node.childrenForFieldName('body') + const bodyStart = first?.startIndex ?? node.endIndex + const items = named(node).filter(child => child.startIndex >= bodyStart) + return { kind: 'if', ...compoundShape(node, bodyStart, items, (list, afterRow) => statements(list, false, afterRow)) } + } + + /** + * 类块:declaration 是类声明或类表达式,name 是它在源码里的名字(类名;赋给变量、类字段的类表达式取变量名、字段名), + * decorators 是类主体里排在它前面的装饰器(类字段的值是类表达式时)。 + */ + function classBlock(outer: Node, declaration: Node, name: Node | null, access: Access | undefined, decorators: Node[] = []): CodeBlock { + const body = declaration.childForFieldName('body') + const facts = classFacts(outer, declaration, name, access, decorators, typed) + if (!body) + return { kind: 'class', ...leafShape(outer), facts } + return { kind: 'class', ...compoundShape(outer, body.startIndex, named(body), classMembers), facts } + } + + /** 类成员:成员前面的装饰器是类主体里的兄弟节点,算进成员头部,也交给成员当装饰器。 */ + function classMembers(list: Node[], afterRow: number): CodeBlock[] { + let decorators: Node[] = [] + return listBlocks(list, afterRow, (member) => { + if (member.type === 'decorator') { + decorators.push(member) + return [] + } + const blocks = memberBlocks(member, decorators) + const [from] = decorators + decorators = [] + return from ? blocks.map((block, i) => i === 0 ? extendHeader(block, from) : block) : blocks + }, COMMENTS) + } + + function memberBlocks(member: Node, decorators: Node[]): CodeBlock[] { + switch (member.type) { + case 'method_definition': + case 'method_signature': + case 'abstract_method_signature': + return [functionBlock(member, member, member.childForFieldName('name'), memberAccess(member), decorators)] + case 'class_static_block': + return [bodyBlock('statement', member, member.childForFieldName('body'))] + default: { + const value = member.childForFieldName('value') + if (value && CLASSES.has(value.type)) + return [classBlock(member, value, member.childForFieldName('name'), memberAccess(member), decorators)] + if (value && functionBody(value)) + return [functionBlock(member, value, member.childForFieldName('name'), memberAccess(member), decorators)] + return [leafBlock('field', member)] + } + } + } + + /** interface / enum:成员(属性、方法签名、枚举项)→ field。 */ + function membersBlock(declaration: Node, outer: Node): CodeBlock { + const body = declaration.childForFieldName('body') + if (!body) + return leafBlock('type', outer) + return { + kind: 'type', + ...compoundShape(outer, body.startIndex, named(body), (list, afterRow) => + listBlocks(list, afterRow, member => [leafBlock('field', member)], COMMENTS)), + } + } + + return root => statements(named(root), true, -1) } // ─── 源码着色 ───────────────────────────────────────────────────────────────── @@ -509,7 +522,7 @@ export function createTypeScriptAdapter(wasmDir: string): LanguageAdapter { throw new Error(`TypeScript 语言插件不支持 ${languageId}`) return path.join(wasmDir, file) }, - toBlocks: root => statements(named(root), true, -1), + toBlocks: (root, _source, languageId) => blockReader(TYPED_LANGUAGES.has(languageId))(root), tokenRole: typescriptTokenRole, } } diff --git a/vscode/src/codeReader/testSupport.ts b/vscode/src/codeReader/testSupport.ts index e5a2ed0..d440bd1 100644 --- a/vscode/src/codeReader/testSupport.ts +++ b/vscode/src/codeReader/testSupport.ts @@ -17,7 +17,7 @@ export function readFixture(name: string): string { export async function withBlocks(adapter: LanguageAdapter, languageId: string, source: string, use: (blocks: CodeBlock[]) => T): Promise { const tree = await runtime.parse(adapter, languageId, source) try { - return use(adapter.toBlocks(tree.rootNode, source)) + return use(adapter.toBlocks(tree.rootNode, source, languageId)) } finally { tree.delete() diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index f827c33..71c7e13 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { Access, ClassFacts, Defines, EntryFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' +import type { Access, AttributeNames, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' import { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ @@ -122,6 +122,34 @@ function slotChild(node: Node, slot: Exclude): Node | null return node.namedChildren.filter(child => child && !child.isExtra)[slot.namedChild] } +const ATTRIBUTE_ZH: Record = { kind: '种类', access: '访问级别', async: '异步', abstract: '抽象', typeOnly: '仅类型' } +const KIND_ZH: Record = { + import: '导入', + function: '函数', + class: '类', + type: '类型', + field: '字段', + const: '常量', + variable: '变量', + if: '条件', + else: '否则', + loop: '循环', + try: '尝试', + catch: '捕获', + finally: '最终', + return: '返回', + statement: '语句', + comment: '注释', +} +const ACCESS_ZH: Record = { public: '公共', private: '私有', protected: '受保护' } +const YES_NO: Record<'true' | 'false', string> = { true: '是', false: '否' } + +/** 筛选属性和值的中文叫法;属性表里的访问级别、异步、抽象、仅类型这几行,属性名和值也用这一套。 */ +const ZH_ATTRIBUTE_NAMES: AttributeNames = { + attributes: ATTRIBUTE_ZH, + values: { kind: KIND_ZH, access: ACCESS_ZH, async: YES_NO, abstract: YES_NO, typeOnly: YES_NO }, +} + export function createZhTemplateTranslator(table: ZhTable): Translator { const operatorParents = new Set(table.operatorParents) const stringNodes = new Set(table.stringNodes) @@ -318,7 +346,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } } - return { id: table.id, label: table.label, languageIds: table.languageIds, translate, describe } + return { id: table.id, label: table.label, languageIds: table.languageIds, translate, describe, attributeNames: ZH_ATTRIBUTE_NAMES } } function segment(text: string, role: TokenRole, offset: number | undefined): Segment { @@ -375,14 +403,13 @@ interface Phrases { doc: (node: Node) => Markdown | undefined } -const ACCESS_ZH: Record = { public: '公共', private: '私有', protected: '受保护' } - function hint(text: string): Segment { return { text, role: 'hint' } } -function yesNo(value: boolean): Segment[] { - return [{ text: value ? '是' : '否', role: 'storage' }] +/** 布尔属性的一行:没有这个概念的语言(facts 里没填)没有这一行。 */ +function flagRows(attribute: 'async' | 'abstract' | 'typeOnly', value: boolean | undefined): PropertyRow[] { + return value === undefined ? [] : [{ label: ATTRIBUTE_ZH[attribute], value: [{ text: YES_NO[`${value}`], role: 'storage' }] }] } function count(n: number): Segment[] { @@ -394,7 +421,7 @@ function joined(parts: Segment[][], separator: string): Segment[] { } function accessRows(access: Access | undefined): PropertyRow[] { - return access ? [{ label: '访问级别', value: [{ text: ACCESS_ZH[access], role: 'keyword' }] }] : [] + return access ? [{ label: ATTRIBUTE_ZH.access, value: [{ text: ACCESS_ZH[access], role: 'keyword' }] }] : [] } /** 这一行 / 子行讲的定义:几个节点(导入项是名称和别名)合起来在源码里的范围;一个节点都没有就不填。 */ @@ -422,13 +449,13 @@ function aliased(item: Segment[], alias: Node | undefined, say: Phrases): Segmen return [...item, { text: ' ', role: 'plain' }, { text: '作为', role: 'keyword' }, { text: ' ', role: 'plain' }, ...say.translated(alias)] } -/** 函数:名称 / 说明 / 访问级别 / 异步 / 装饰器 / 输入参数(每个参数一行:名称、类型、默认值)/ 输出参数。 */ +/** 函数:名称 / 说明 / 访问级别 / 异步(只有分这个的语言才有)/ 装饰器 / 输入参数(每个参数一行:名称、类型、默认值)/ 输出参数。 */ function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] { return [ { label: '名称', value: facts.name ? say.name(facts.name, 'function') : [hint('匿名')] }, ...docRows(facts.doc, say), ...accessRows(facts.access), - { label: '异步', value: yesNo(facts.async) }, + ...flagRows('async', facts.async), ...decoratorRows(facts.decorators, say), { label: '输入参数', @@ -457,7 +484,7 @@ function classRows(facts: ClassFacts, say: Phrases): PropertyRow[] { { label: '名称', value: facts.name ? say.name(facts.name, 'type') : [hint('匿名')] }, ...docRows(facts.doc, say), ...accessRows(facts.access), - ...(facts.abstract === undefined ? [] : [{ label: '抽象', value: yesNo(facts.abstract) }]), + ...flagRows('abstract', facts.abstract), ...decoratorRows(facts.decorators, say), ...listRow('类型参数', facts.typeParameters.map(say.translated), defining(...facts.typeParameters)), ...listRow('父类', facts.bases.map(base => base.typeArguments ? [...say.translated(base.value), ...say.translated(base.typeArguments)] : say.translated(base.value))), @@ -493,7 +520,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) }]), + ...flagRows('typeOnly', facts.typeOnly), { label: '导入项', value: count(facts.items.length), items: facts.items.map(item => ({ cells: [itemText(item)], ...defining(...importNodes(item)) })) }, ] } diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index dc1f931..7e6cf20 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -1,11 +1,12 @@ import type { ReactElement } from 'react' import type { BlockTreeState } from './BlockTree' -import type { DocumentMessage, Reveal } from './types' +import type { DocumentMessage, Excluded, 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BlockLevel } from './BlockTree' +import { applyFilter, keptWhileEditing } from './filter' +import { FilterBar } from './FilterBar' import { useDefinitionLinks } from './useDefinitionLinks' import { useReveal } from './useReveal' import { useScrollPosition } from './useScrollPosition' @@ -14,6 +15,8 @@ import { onMessage, postMessage, viewState } from './vscode' type BlockModes = Pick +const NONE: ReadonlySet = new Set() + function toggled(ids: string[], id: string): string[] { return ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id] } @@ -29,16 +32,24 @@ export function App(): ReactElement | null { const [focusId, setFocusId] = useState(null) /** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */ const [reveal, setReveal] = useState(null) + /** 扩展端发来的块筛选(整个工作区共用);本地不改,改了发给扩展端、等它发回来 */ + const [excluded, setExcluded] = useState({}) + /** 跳到定义时临时露出来的块(目标和它的各层祖先),筛选一变、文档一刷新就回到筛选状态 */ + const [keep, setKeep] = useState>(NONE) + /** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */ + const editing = useRef(view.editing) const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds), [doc, view.folds]) + const filter = useMemo(() => applyFilter(doc?.blocks ?? [], excluded, keep), [doc, excluded, keep]) /** 记下这几块、属性行、子行折叠还是展开,从此不再按默认 */ const setFolded = useCallback((ids: string[], folded: boolean) => setView(prev => ({ ...prev, folds: { ...prev.folds, ...Object.fromEntries(ids.map(id => [id, folded])) }, })), []) const expand = useCallback((ids: string[]) => setFolded(ids, false), [setFolded]) + const show = useCallback((ids: string[]) => setKeep(prev => new Set([...prev, ...ids])), []) const revealed = useCallback(() => setReveal(null), []) useScrollPosition(doc) - useReveal(doc, reveal, collapsed, expand, revealed) + useReveal(doc, reveal, collapsed, filter.hidden, show, expand, revealed) useDefinitionLinks(doc?.version) useEffect(() => { @@ -46,11 +57,16 @@ export function App(): ReactElement | null { switch (msg.type) { case 'document': setDoc(msg) + setKeep(prev => keptWhileEditing(prev, editing.current)) if (msg.reveal) setReveal(msg.reveal) return case 'reveal': setReveal({ line: msg.line, definition: msg.definition }) + return + case 'filter': + setExcluded(msg.excluded) + setKeep(NONE) } }) postMessage({ type: 'ready' }) @@ -59,13 +75,18 @@ export function App(): ReactElement | null { useEffect(() => { viewState.update(view) + editing.current = view.editing }, [view]) const onFocused = useCallback(() => setFocusId(null), []) + const kindNames = useMemo(() => new Map(doc?.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])), [doc]) const tree = useMemo((): BlockTreeState | null => doc && { version: doc.version, editing: new Set(view.editing), collapsed, + hidden: filter.hidden, + temporary: filter.temporary, + kindName: kind => kindNames.get(kind) ?? kind, focusId, onFocused, onToggleEdit: (id) => { @@ -73,40 +94,41 @@ export function App(): ReactElement | null { setView(prev => ({ ...prev, editing: toggled(prev.editing, id) })) }, onToggleFold: id => setFolded([id], !collapsed.has(id)), - }, [doc, view, collapsed, focusId, onFocused, setFolded]) + }, [doc, view, collapsed, filter, kindNames, focusId, onFocused, setFolded]) if (!doc || !tree) return null return ( -
- {doc.translators.length > 1 && ( -
- 翻译 - ({ value: t.id, label: t.label }))} - onChange={id => postMessage({ type: 'selectTranslator', id })} - title="选择翻译方式" - className="w-40 text-left" - /> -
- )} - {doc.error !== undefined && ( -
- -
-
- 解析失败: - {doc.error} + <> + postMessage({ type: 'setFilter', excluded: next })} + translators={doc.translators} + translatorId={doc.translatorId} + onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })} + /> +
+ {doc.error !== undefined && ( +
+ +
+
+ 解析失败: + {doc.error} +
+
点编辑器右上角的「编辑」查看源码。
-
点编辑器右上角的「编辑」查看源码。
-
- )} - {doc.blocks.length > 0 - ? - : doc.error === undefined &&
这个文件里没有代码。
} -
+ )} + {doc.blocks.length === 0 + ? doc.error === undefined &&
这个文件里没有代码。
+ : doc.blocks.some(block => !filter.hidden.has(block.id)) + ? + :
所有块都被筛选隐藏了。
} +
+ ) } diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index f0e8135..47a11ab 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -1,36 +1,23 @@ import type { ReactElement } from 'react' import type { BlockKind, BlockView } from './types' -import { ChevronDown, ChevronRight, Eye, Pencil } from 'lucide-react' +import { ChevronDown, ChevronRight, Eye, EyeOff, Pencil } from 'lucide-react' import { Fragment } from 'react' import { cn } from '@/lib/utils' import { CodeEditor } from './CodeEditor' import { PropertyGrid } from './PropertyGrid' import { TextPreview } from './TextPreview' -const KIND_LABEL: Record = { - import: '导入', - function: '函数', - class: '类', - type: '类型', - field: '字段', - const: '常量', - variable: '变量', - if: '条件', - else: '否则', - loop: '循环', - try: '尝试', - catch: '捕获', - finally: '最终', - return: '返回', - statement: '语句', - comment: '注释', -} - export interface BlockTreeState { version: number editing: ReadonlySet /** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */ collapsed: ReadonlySet + /** 按筛选不画的块(连同子孙),见 applyFilter */ + hidden: ReadonlySet + /** 本来被筛掉、跳到定义时临时显示的块 */ + temporary: ReadonlySet + /** 种类标签上写什么:翻译插件给种类起的名字,没给就是 id */ + kindName: (kind: BlockKind) => string /** 刚点了「编辑」、要把光标放进去的块 */ focusId: string | null onFocused: () => void @@ -38,8 +25,13 @@ export interface BlockTreeState { onToggleFold: (id: string) => void } +/** 块下面有没有画出来的子块:子块都被筛掉时不留空的一层、不给折叠箭头。 */ +function hasShownChildren(block: BlockView, tree: BlockTreeState): boolean { + return block.children.some(child => !tree.hidden.has(child.id)) +} + /** - * 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。 + * 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉的块不画,不留空位,竖线跟着变短。 * kind 是这一层所属块的种类(顶层没有),悬停那个块时竖线换成它的颜色。 */ export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?: BlockKind, tree: BlockTreeState }): ReactElement { @@ -49,10 +41,10 @@ export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?:
- {blocks.map(block => ( + {blocks.filter(block => !tree.hidden.has(block.id)).map(block => ( - {block.children.length > 0 && !tree.collapsed.has(block.id) && ( + {hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && ( )} @@ -73,7 +65,7 @@ function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement { const editing = tree.editing.has(block.id) const collapsed = tree.collapsed.has(block.id) - const hasChildren = block.children.length > 0 + const hasChildren = hasShownChildren(block, tree) const Chevron = collapsed ? ChevronRight : ChevronDown return (
} - {KIND_LABEL[block.kind]} + {tree.kindName(block.kind)}
{editing @@ -112,6 +104,12 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ) : }
+ {tree.temporary.has(block.id) && ( + + + 已被筛选隐藏,临时显示 + + )} + + ) + : `共 ${view.total} 块`} + + {translators.length > 1 && ( + + 翻译 + ({ value: t.id, label: t.label }))} + onChange={onSelectTranslator} + title="选择翻译方式" + className="w-36 text-left" + /> + + )} +
+
+ ) +} + +/** + * 一个属性的下拉框:按钮上是属性名(去掉了值时写勾着哪些,边框换聚焦色),点开是勾选列表,每个值后面是块数。 + * 列表盖在代码块上面;点外面、Esc、Tab 关掉。键盘:按钮上 ↓ ↑ 回车 空格打开,列表里 ↑ ↓ Home End 移动,空格 / 回车勾选。 + */ +function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string) => void }): ReactElement { + const [open, setOpen] = useState(false) + const [activeIndex, setActive] = useState(0) + const rootRef = useRef(null) + const buttonRef = useRef(null) + const listRef = useRef(null) + const listId = useId() + const label = facetLabel(facet) + const count = facet.values.length + // 列表开着时文档刷新、值变少了,高亮落回最后一项 + const active = Math.min(activeIndex, count - 1) + + useEffect(() => { + if (!open) + return + const onPointerDown = (event: MouseEvent): void => { + if (event.target instanceof Node && !rootRef.current?.contains(event.target)) + setOpen(false) + } + document.addEventListener('mousedown', onPointerDown, true) + return () => document.removeEventListener('mousedown', onPointerDown, true) + }, [open]) + + useLayoutEffect(() => { + if (open) + listRef.current?.focus() + }, [open]) + + useLayoutEffect(() => { + if (open) + listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: 'nearest' }) + }, [open, active]) + + const openAt = (index: number): void => { + setActive(index) + setOpen(true) + } + + const close = (): void => { + setOpen(false) + buttonRef.current?.focus() + } + + const onButtonKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + openAt(0) + } + else if (event.key === 'ArrowUp') { + event.preventDefault() + openAt(count - 1) + } + } + + const onListKeyDown = (event: KeyboardEvent): void => { + const move: Record = { ArrowDown: (active + 1) % count, ArrowUp: (active - 1 + count) % count, Home: 0, End: count - 1 } + if (event.key in move) { + event.preventDefault() + setActive(move[event.key]) + } + else if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault() + onToggle(facet.values[active].id) + } + else if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + close() + } + else if (event.key === 'Tab') { + setOpen(false) + } + } + + return ( +
+ + {open && ( +
+ {facet.values.map((value, index) => ( +
event.preventDefault()} + onMouseMove={() => setActive(index)} + onClick={() => onToggle(value.id)} + > + + {value.checked && } + + {value.name} + {value.count} +
+ ))} +
+ )} +
+ ) +} diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index ecbd897..3eb59ec 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -281,3 +281,70 @@ body.vscode-high-contrast-light { position: relative; pointer-events: none; } + +/* 筛选栏:钉在预览顶部、横贯整个编辑区,里面的内容和代码块同宽对齐;打开的下拉菜单盖在代码块上面 */ +.cr-filter-bar { + position: sticky; + top: 0; + z-index: 10; + background: var(--vscode-editor-background); + border-bottom: 1px solid var(--vscode-panel-border, transparent); +} +.cr-chip { + color: var(--vscode-dropdown-foreground, var(--vscode-foreground)); + background: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-dropdown-border, var(--vscode-dropdown-background)); +} +.cr-chip[data-active] { + border-color: var(--vscode-focusBorder); +} +.cr-chip:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} +.cr-menu { + color: var(--vscode-editorWidget-foreground, var(--vscode-foreground)); + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent)); + box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgb(0 0 0 / 36%)); + outline: none; +} +.cr-option[data-active] { + background: var(--vscode-list-hoverBackground); +} +/* 键盘在列表里走时,高亮项再描一圈聚焦色 */ +.cr-menu:focus-visible .cr-option[data-active] { + outline: 1px solid var(--vscode-list-focusOutline, var(--vscode-focusBorder)); + outline-offset: -1px; +} +.cr-check { + border: 1px solid var(--vscode-checkbox-border, var(--vscode-editorLineNumber-foreground)); + border-radius: 3px; + background: var(--vscode-checkbox-background, transparent); +} +.cr-check[data-checked] { + color: var(--vscode-button-foreground, #fff); + background: var(--vscode-focusBorder); + border-color: var(--vscode-focusBorder); +} +.cr-option-count { + color: var(--vscode-editorLineNumber-foreground); +} +.cr-link { + color: var(--vscode-textLink-foreground); +} +.cr-link:hover { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} +.cr-link:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; + border-radius: 2px; +} + +/* 跳到定义临时露出来的、本来被筛掉的块:右边一个小提示 */ +.cr-temporary { + color: var(--vscode-descriptionForeground); + background: color-mix(in srgb, var(--vscode-focusBorder) 15%, transparent); +} diff --git a/vscode/webview-ui/src/codeReader/filter.test.ts b/vscode/webview-ui/src/codeReader/filter.test.ts new file mode 100644 index 0000000..2e24eeb --- /dev/null +++ b/vscode/webview-ui/src/codeReader/filter.test.ts @@ -0,0 +1,127 @@ +import type { BlockKind, BlockView, FileAttribute } from './types' +import { describe, expect, it } from 'vitest' +import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, toggleValue } from './filter' + +function block(id: string, kind: BlockKind, extra: Record = {}, children: BlockView[] = []): BlockView { + return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, children, translation: [] } +} + +const NONE = new Set() + +/** + * 0 const A(私有) + * 1 function run(公共) + * 1.0 if + * 1.0.0 return + * 1.1 const B(私有,嵌套在函数里的也有访问级别时) + * 2 function helper(私有) + * 2.0 if + * 3 comment + */ +const BLOCKS = [ + block('0', 'const', { access: 'private' }), + block('1', 'function', { access: 'public', async: 'true' }, [ + block('1.0', 'if', {}, [block('1.0.0', 'return')]), + block('1.1', 'const', { access: 'private' }), + ]), + block('2', 'function', { access: 'private', async: 'false' }, [block('2.0', 'if')]), + block('3', 'comment'), +] + +describe('按筛选藏块', () => { + it('不筛:一块都不藏,共几块数的是所有层', () => { + expect(applyFilter(BLOCKS, {}, NONE)).toEqual({ hidden: new Set(), temporary: new Set(), hiddenCount: 0, total: 8 }) + }) + + it('去掉「私有」:私有的块连同子孙藏起来;没有访问级别的块(条件、返回、注释)不受影响;已隐藏只数直接被筛掉的块', () => { + const view = applyFilter(BLOCKS, { access: ['private'] }, NONE) + expect([...view.hidden].sort()).toEqual(['0', '1.1', '2', '2.0']) + expect(view.hiddenCount).toBe(3) + }) + + it('同一属性里勾着的值满足一个就显示,不同属性要同时满足', () => { + const kinds = { kind: ['comment', 'if', 'return'] } + expect([...applyFilter(BLOCKS, kinds, NONE).hidden].sort()).toEqual(['1.0', '1.0.0', '2.0', '3']) + const both = applyFilter(BLOCKS, { access: ['private'], async: ['false'] }, NONE) + expect([...both.hidden].sort()).toEqual(['0', '1.1', '2', '2.0']) + expect(excludedBy(BLOCKS[1], { access: ['private'], async: ['false'] })).toBe(false) + expect(excludedBy(BLOCKS[1], { async: ['true'] })).toBe(true) + }) + + it('藏着的块里又被筛掉的子块不重复计数', () => { + expect(applyFilter(BLOCKS, { kind: ['function', 'if'] }, NONE).hiddenCount).toBe(2) + }) + + it('跳到定义临时露出来的:藏着的祖先下面只露出通向目标的路径,本来藏着的记进 temporary;已隐藏的块数照旧按筛选算', () => { + const view = applyFilter(BLOCKS, { access: ['private'] }, new Set(blockPath('1.1'))) + expect([...view.hidden].sort()).toEqual(['0', '2', '2.0']) + expect([...view.temporary]).toEqual(['1.1']) + expect(view.hiddenCount).toBe(3) + const path = applyFilter(BLOCKS, { kind: ['function'] }, new Set(blockPath('1.0'))) + expect([...path.hidden].sort()).toEqual(['1.1', '2', '2.0']) + expect([...path.temporary].sort()).toEqual(['1', '1.0']) + expect(path.hiddenCount).toBe(2) + const visible = applyFilter(BLOCKS, { access: ['private'] }, new Set(blockPath('1.0'))) + expect(visible.temporary).toEqual(new Set()) + }) + + it('临时显示的目标自己的子块照常按筛选显示,子块里被筛掉的仍然藏着', () => { + const view = applyFilter(BLOCKS, { access: ['private'] }, new Set(blockPath('2'))) + expect([...view.temporary]).toEqual(['2']) + expect([...view.hidden].sort()).toEqual(['0', '1.1']) + const inner = applyFilter(BLOCKS, { kind: ['function', 'return'] }, new Set(blockPath('1'))) + expect([...inner.hidden].sort()).toEqual(['1.0.0', '2', '2.0']) + expect(inner.hiddenCount).toBe(2) + }) +}) + +const ATTRIBUTES: FileAttribute[] = [ + { id: 'kind', name: '种类', values: [{ id: 'const', name: '常量', count: 2 }, { id: 'function', name: '函数', count: 2 }] }, + { id: 'access', name: '访问级别', values: [{ id: 'private', name: '私有', count: 3 }, { id: 'public', name: '公共', count: 1 }] }, + { id: 'typeOnly', name: '仅类型', values: [{ id: 'false', name: '否', count: 4 }] }, + { id: 'unsafe', name: 'unsafe', values: [{ id: 'true', name: 'true', count: 1 }, { id: 'false', name: 'false', count: 1 }] }, +] + +describe('筛选栏上的下拉框', () => { + it('给有两种以上值的属性,照文件里属性的先后;只有一种值的不给,除非这个值正被去掉', () => { + expect(facetsOf(ATTRIBUTES, {}).map(facet => facet.id)).toEqual(['kind', 'access', 'unsafe']) + expect(facetsOf(ATTRIBUTES, { typeOnly: ['false'] }).map(facet => facet.id)).toEqual(['kind', 'access', 'typeOnly', 'unsafe']) + expect(facetsOf(ATTRIBUTES, { typeOnly: ['true'] }).map(facet => facet.id)).toEqual(['kind', 'access', 'unsafe']) + }) + + it('选项是文件里出现的值和块数,没去掉的勾着', () => { + const [, access] = facetsOf(ATTRIBUTES, { access: ['private', 'protected'] }) + expect(access.values).toEqual([ + { id: 'private', name: '私有', count: 3, checked: false }, + { id: 'public', name: '公共', count: 1, checked: true }, + ]) + }) + + it('按钮上的字:没去掉值时是属性名;勾一种写值名,几种写「已选 N 种」,都不勾写「未选」', () => { + const [kind, access] = facetsOf(ATTRIBUTES, { access: ['private'] }) + expect(facetLabel(kind)).toEqual({ text: '种类', active: false }) + expect(facetLabel(access)).toEqual({ text: '访问级别:公共', active: true }) + const three = { id: 'k', name: '种类', values: ['a', 'b', 'c'].map(id => ({ id, name: id, count: 1, checked: id !== 'c' })) } + expect(facetLabel(three)).toEqual({ text: '种类:已选 2 种', active: true }) + expect(facetLabel({ ...three, values: three.values.map(value => ({ ...value, checked: false })) })).toEqual({ text: '种类:未选', active: true }) + }) + + it('勾 / 不勾一个值:记去掉的值,一个都不去掉的属性不留', () => { + expect(toggleValue({}, 'access', 'private')).toEqual({ access: ['private'] }) + expect(toggleValue({ access: ['private'], kind: ['if'] }, 'access', 'public')).toEqual({ access: ['private', 'public'], kind: ['if'] }) + expect(toggleValue({ access: ['private'], kind: ['if'] }, 'access', 'private')).toEqual({ kind: ['if'] }) + }) +}) + +describe('临时显示的块', () => { + it('块 id 的各段前缀就是从顶层到它的路径', () => { + expect(blockPath('1.0.2')).toEqual(['1', '1.0', '1.0.2']) + expect(blockPath('3')).toEqual(['3']) + }) + + it('文档刷新后只留正在编辑的块和它的祖先', () => { + const keep = new Set(['2', '2.0', '2.0.1', '5']) + expect(keptWhileEditing(keep, ['2.0'])).toEqual(new Set(['2', '2.0'])) + expect(keptWhileEditing(keep, [])).toEqual(new Set()) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/filter.ts b/vscode/webview-ui/src/codeReader/filter.ts new file mode 100644 index 0000000..51b4860 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/filter.ts @@ -0,0 +1,100 @@ +import type { AttributeValueCount, BlockView, Excluded, FileAttribute } from './types' + +/** 页面上按筛选怎么显示。 */ +export interface FilterView { + /** 不画出来的块:被筛掉的块连同子孙,临时显示的除外 */ + hidden: ReadonlySet + /** 本来被筛掉、跳到定义时临时显示的块,带「已被筛选隐藏,临时显示」 */ + temporary: ReadonlySet + /** 直接被筛掉的块数,跟着藏起来的子孙不算:「已隐藏 N 块」 */ + hiddenCount: number + /** 整份文件(所有层)的块数:「共 N 块」 */ + total: number +} + +/** 筛选栏上的一个下拉框:一个属性和它在这份文件里的值,checked 是勾着(没被去掉)。 */ +export interface Facet { + id: string + name: string + values: (AttributeValueCount & { checked: boolean })[] +} + +/** + * 块被不被筛掉:它有的属性里有一个的值被去掉了就筛掉。也就是同一属性里勾着的值满足一个就行、不同属性要同时满足; + * 块没有的属性(条件、返回块没有访问级别)不受那个属性的筛选影响。 + */ +export function excludedBy(block: BlockView, excluded: Excluded): boolean { + return Object.entries(block.attributes).some(([attribute, value]) => excluded[attribute]?.includes(value) ?? false) +} + +/** + * 按筛选算出哪些块不画:块被筛掉,连同它的子孙一起藏。keep 是跳到定义时要临时露出来的块(目标和它的各层祖先), + * 其中本来藏着的照画、记进 temporary:藏着的祖先下面只露出通向目标的那条路径,目标自己和没临时显示的块一样, + * 它的子块照常按筛选显示(函数照样能展开看主体)。 + */ +export function applyFilter(blocks: readonly BlockView[], excluded: Excluded, keep: ReadonlySet): FilterView { + const hidden = new Set() + const temporary = new Set() + let hiddenCount = 0 + let total = 0 + // filteredAbove 只按筛选算(数「已隐藏」用),hiddenAbove 还算上临时显示 + const visit = (list: readonly BlockView[], filteredAbove: boolean, hiddenAbove: boolean): void => { + for (const block of list) { + total += 1 + const out = excludedBy(block, excluded) + if (out && !filteredAbove) + hiddenCount += 1 + const hiddenHere = hiddenAbove || out + if (hiddenHere) + (keep.has(block.id) ? temporary : hidden).add(block.id) + const target = keep.has(block.id) && !block.children.some(child => keep.has(child.id)) + visit(block.children, filteredAbove || out, hiddenHere && !target) + } + } + visit(blocks, false, false) + return { hidden, temporary, hiddenCount, total } +} + +/** + * 筛选栏上给哪些属性下拉框,照 attributes 的先后:这份文件里有两种以上值的属性(只有一种值的,筛它等于按种类筛,不给); + * 只有一种值、但这个值正被去掉的也给,不然藏起来的块在栏上找不到原因、也勾不回来。 + */ +export function facetsOf(attributes: readonly FileAttribute[], excluded: Excluded): Facet[] { + return attributes.flatMap((attribute) => { + const values = attribute.values.map(value => ({ ...value, checked: !(excluded[attribute.id]?.includes(value.id) ?? false) })) + return values.length >= 2 || values.some(value => !value.checked) ? [{ id: attribute.id, name: attribute.name, values }] : [] + }) +} + +/** 下拉框上写什么:没去掉值时是属性名;去掉了(active),勾着一种写「属性名:值名」,几种写「属性名:已选 N 种」,一种不勾写「属性名:未选」。 */ +export function facetLabel(facet: Facet): { text: string, active: boolean } { + const checked = facet.values.filter(value => value.checked) + if (checked.length === facet.values.length) + return { text: facet.name, active: false } + if (checked.length === 0) + return { text: `${facet.name}:未选`, active: true } + return { text: checked.length === 1 ? `${facet.name}:${checked[0].name}` : `${facet.name}:已选 ${checked.length} 种`, active: true } +} + +/** 勾上 / 去掉属性的一个值后的筛选;一个值都没去掉的属性不留。 */ +export function toggleValue(excluded: Excluded, attribute: string, value: string): Excluded { + const current = excluded[attribute] ?? [] + const next = current.includes(value) ? current.filter(item => item !== value) : [...current, value] + const rest = Object.fromEntries(Object.entries(excluded).filter(([key]) => key !== attribute)) + return next.length > 0 ? { ...rest, [attribute]: next } : rest +} + +/** 块 id 是它在树里的位置('1.0.2'):从顶层到它的各块就是 id 的各段前缀 '1'、'1.0'、'1.0.2'。 */ +export function blockPath(id: string): string[] { + const parts = id.split('.') + return parts.map((_, i) => parts.slice(0, i + 1).join('.')) +} + +/** + * 文档刷新后临时显示的块回到筛选状态,只留正在编辑的块和它的祖先:块 id 按位置编号,刷新后可能换成别的块; + * 但正在改的那块不能打着字就消失。 + */ +export function keptWhileEditing(keep: ReadonlySet, editing: readonly string[]): Set { + const paths = new Set(editing.flatMap(blockPath)) + return new Set([...keep].filter(id => paths.has(id))) +} diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index f202a3e..35f041b 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -20,11 +20,11 @@ function word(text: string): Segment { } function textBlock(id: string, kind: BlockKind, startLine: number, translation: Segment[], children: BlockView[] = []): BlockView { - return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], children, translation } + return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, 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 } + return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, properties } } /** @@ -58,7 +58,7 @@ describe('跳到定义:滚到哪块、展开哪些', () => { it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => { const blocks: BlockView[] = [ - { id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], children: [], translation: { markdown: '见 `get_db`' } }, + { id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'comment' }, children: [], translation: { markdown: '见 `get_db`' } }, factBlock('1', 'function', 1, [ { label: '说明', value: { markdown: '同 `get_db`' } }, { label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 30)]] }] }, diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts index ba28a3e..4507be0 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -108,7 +108,10 @@ export function revealPlan(blocks: BlockView[], line: number, definition: number return target && { id: target.block.id, expand: target.ancestors } } -/** 让一块露出来要滚到的 scrollTop,和 VS Code 跳到定义一样:整块已经在视口里就不滚;否则放到视口正中,比视口还高就顶部对齐。 */ +/** + * 让一块露出来时视口顶部要在的位置,和 VS Code 跳到定义一样:整块已经在视口里就不滚;否则放到视口正中,比视口还高就顶部对齐。 + * viewTop / viewHeight 是视口里露出块的那部分(钉住的筛选栏下面)。 + */ export function scrollTopToShow(box: { top: number, bottom: number }, viewTop: number, viewHeight: number): number | undefined { if (box.top >= viewTop && box.bottom <= viewTop + viewHeight) return undefined diff --git a/vscode/webview-ui/src/codeReader/scrollPosition.ts b/vscode/webview-ui/src/codeReader/scrollPosition.ts index 9056869..9b519a9 100644 --- a/vscode/webview-ui/src/codeReader/scrollPosition.ts +++ b/vscode/webview-ui/src/codeReader/scrollPosition.ts @@ -7,20 +7,23 @@ export interface BlockBox { bottom: number } -/** 视口顶部那块的起始行 + 视口顶部在块内往下偏了多少像素;按块对不上时退回 scrollTop。 */ +/** + * 视口顶部那块的起始行 + 视口顶部在块内往下偏了多少像素;按块对不上时退回 scrollTop。 + * 「视口顶部」指露出块的那部分的顶部,也就是钉住的筛选栏的下沿;scrollTop 是它在页面里的位置。 + */ export interface ScrollAnchor { line: number offset: number scrollTop: number } -/** 视口顶部那块:第一个下边还在视口顶部以下的块;全都滚过去了就取最后一块。没有块时没有位置可记。 */ +/** 视口顶部(scrollTop,见 ScrollAnchor)那块:第一个下边还在视口顶部以下的块;全都滚过去了就取最后一块。没有块时没有位置可记。 */ export function anchorAt(boxes: readonly BlockBox[], scrollTop: number): ScrollAnchor | undefined { const box = boxes.find(b => b.bottom > scrollTop) ?? boxes.at(-1) return box && { line: box.line, offset: scrollTop - box.top, scrollTop } } -/** 恢复时的 scrollTop:起始行还是那一行的块还在就对齐到它,否则用原来的 scrollTop。 */ +/** 恢复时视口顶部要在的位置(见 ScrollAnchor):起始行还是那一行的块还在就对齐到它,否则用原来的 scrollTop。 */ export function scrollTopFor(boxes: readonly BlockBox[], anchor: ScrollAnchor): number { const box = boxes.find(b => b.line === anchor.line) return box ? box.top + anchor.offset : anchor.scrollTop diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index b01011b..815984f 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -59,6 +59,8 @@ interface BlockViewBase { codeTokens: Segment[] /** 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围:跳到的定义就是它时闪整块 */ defines?: SourceRange + /** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */ + attributes: Record children: BlockView[] } @@ -67,6 +69,23 @@ export type BlockView = BlockViewBase & ({ translation: PreviewText } | { proper export interface TranslatorOption { id: string, label: string } +/** 这份文件里出现过的一个属性值:显示名和有几块是这个值 */ +export interface AttributeValueCount { + id: string + name: string + count: number +} + +/** 这份文件里出现过的一个筛选属性:显示名,出现过的值按第一次出现的先后排 */ +export interface FileAttribute { + id: string + name: string + values: AttributeValueCount[] +} + +/** 块筛选:属性 id → 不勾(藏起来)的值 id;整个工作区的预览共用一份,记在扩展端 */ +export type Excluded = Record + /** * 滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset), * 要展开把它折叠住的块和属性行,再让就是这处定义的那一块或属性表里那一行闪几下 @@ -81,6 +100,8 @@ export interface DocumentMessage { version: number languageId: string blocks: BlockView[] + /** 这份文件里出现过的筛选属性和值(带显示名、块数),按筛选栏的先后排:通用属性在前,语言自己的在后 */ + attributes: FileAttribute[] translators: TranslatorOption[] translatorId: string error?: string @@ -91,11 +112,13 @@ export interface DocumentMessage { /** * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) * definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义 + * filter:整个工作区共用的块筛选;ready 后先于第一份文档来一次,之后任何一个预览改了都会来 */ export type ExtensionToWebview = | DocumentMessage | ({ type: 'reveal' } & Reveal) | { type: 'definitionProbed', id: number, found: boolean } + | { type: 'filter', excluded: Excluded } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进) @@ -103,9 +126,11 @@ export type ExtensionToWebview * probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问 offset 处有没有定义;id 标出是哪一次问,回答带回来; * version 是这份下标所属的文档版本 * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 + * setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示 */ export type WebviewToExtension = | { type: 'ready' } + | { type: 'setFilter', excluded: Excluded } | { type: 'edit', startLine: number, endLine: number, text: string } | { type: 'selectTranslator', id: string } | { type: 'visibleLine', line: number } diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index 84d105c..7c46219 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -1,8 +1,9 @@ import type { DocumentMessage, Reveal } from './types' import { useLayoutEffect } from 'react' +import { blockPath } from './filter' import { revealPlan, scrollTopToShow } from './reveal' import { blockForLine } from './scrollPosition' -import { measureBlocks } from './useScrollPosition' +import { measureBlocks, pinnedHeight } from './useScrollPosition' /** 让一块或属性表里的一行闪几下(codeReader.css 的 [data-flash]);正在闪的同一处再跳一次时从头闪。 */ function flash(el: HTMLElement): void { @@ -24,13 +25,16 @@ function flash(el: HTMLElement): void { /** * 滚到 reveal 指的位置,处理完调 done: - * ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部,折叠着的不展开; - * ② 跳到定义:先展开把目标藏起来的块、属性行和子行(expand 之后重新渲染,再进来一次),再把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。 + * ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部(筛选栏下面),折叠着的、被筛掉的不展开; + * ② 跳到定义:目标或它的祖先块被筛掉了,先临时露出这条路径(show);再展开把目标折叠住的块、属性行和子行(expand)—— + * 每一步之后重新渲染、再进来一次;最后把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。 */ export function useReveal( doc: DocumentMessage | null, reveal: Reveal | null, collapsed: ReadonlySet, + filtered: ReadonlySet, + show: (ids: string[]) => void, expand: (ids: string[]) => void, done: () => void, ): void { @@ -40,11 +44,16 @@ export function useReveal( if (reveal.definition === undefined) { const top = blockForLine(measureBlocks(), reveal.line)?.top if (top !== undefined) - window.scrollTo(0, top) + window.scrollTo(0, top - pinnedHeight()) done() return } const plan = revealPlan(doc.blocks, reveal.line, reveal.definition) + const path = plan ? blockPath(plan.id) : [] + if (path.some(id => filtered.has(id))) { + show(path) + return + } const hidden = plan?.expand.filter(id => collapsed.has(id)) ?? [] if (hidden.length > 0) { expand(hidden) @@ -55,11 +64,12 @@ export function useReveal( 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) + const pinned = pinnedHeight() + const top = scrollTopToShow({ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY }, window.scrollY + pinned, window.innerHeight - pinned) if (top !== undefined) - window.scrollTo(0, top) + window.scrollTo(0, top - pinned) flash(el) } done() - }, [doc, reveal, collapsed, expand, done]) + }, [doc, reveal, collapsed, filtered, show, expand, done]) } diff --git a/vscode/webview-ui/src/codeReader/useScrollPosition.ts b/vscode/webview-ui/src/codeReader/useScrollPosition.ts index 102b49b..762bcd0 100644 --- a/vscode/webview-ui/src/codeReader/useScrollPosition.ts +++ b/vscode/webview-ui/src/codeReader/useScrollPosition.ts @@ -7,7 +7,12 @@ import { postMessage, viewState } from './vscode' /** 滚动时最多这么久记一次位置 */ const RECORD_INTERVAL_MS = 100 -/** 页面上每块(BlockCard 带 data-line)的位置,按页面上的先后。 */ +/** 顶部钉住的筛选栏的高度:视口里露出块的部分从它下面开始,记位置、滚过去都按这部分算。 */ +export function pinnedHeight(): number { + return document.querySelector('.cr-filter-bar')?.offsetHeight ?? 0 +} + +/** 页面上每块(BlockCard 带 data-line)的位置,按页面上的先后;被筛掉的块不在页面上,不算。 */ export function measureBlocks(): BlockBox[] { const y = window.scrollY return Array.from(document.querySelectorAll('.cr-block[data-line]'), (el) => { @@ -18,7 +23,7 @@ export function measureBlocks(): BlockBox[] { /** 记下视口顶部那块;它的起始行变了才报给扩展端。 */ function record(reportedLine: { current: number | undefined }): void { - const anchor = anchorAt(measureBlocks(), window.scrollY) + const anchor = anchorAt(measureBlocks(), window.scrollY + pinnedHeight()) if (!anchor) return viewState.update({ scroll: anchor }) @@ -46,7 +51,7 @@ export function useScrollPosition(doc: DocumentMessage | null): void { const saved = viewState.get().scroll const top = doc.reveal === undefined && saved ? scrollTopFor(measureBlocks(), saved) : undefined if (top !== undefined) - window.scrollTo(0, top) + window.scrollTo(0, top - pinnedHeight()) record(reportedLine) }, [doc]) diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index 712130a..d49c6cd 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -15,7 +15,7 @@ function memoryStorage(initial?: unknown): ViewStateStorage { } function block(id: string, kind: BlockKind, children: BlockView[] = []): BlockView { - return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], children, translation: [] } + return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, children, translation: [] } } /**