✨ feat(vscode): 代码阅读预览顶部加块筛选栏

语言插件按和语言无关的 id 报块属性(通用的由宿主从 facts 推),翻译插件起名,webview 只按 id 筛;
筛选整个工作区共用、记在 workspaceState;JavaScript 不再显示「抽象:否」「仅类型:否」
This commit is contained in:
cruldra committed 2026-09-25 21:31:19 +08:00
1 parent 4eaf2ae11d
commit 1c7e31c51a
29 files changed
+1364 -319

No files matched your search

+59 -14
View File
@@ -16,7 +16,8 @@
| 第一版翻译 | 只做中文模板直译;AI 解读以后作为另一个翻译插件加 | | 第一版翻译 | 只做中文模板直译;AI 解读以后作为另一个翻译插件加 |
| 第一版语言 | TypeScript / TSX(含 JavaScript / JSX)、Python | | 第一版语言 | TypeScript / TSX(含 JavaScript / JSX)、Python |
| 扩展方式 | 写代码注册:语言插件、翻译插件都实现约定接口,通过本插件 `activate()` 导出的 API 注册;内置实现也走同一个 API | | 扩展方式 | 写代码注册:语言插件、翻译插件都实现约定接口,通过本插件 `activate()` 导出的 API 注册;内置实现也走同一个 API |
| 多种翻译怎么选 | 预览顶部下拉框,整个文件一起换,按语言记住选择;只有一种时不显示 | | 多种翻译怎么选 | 预览顶部筛选栏最右边的下拉框,整个文件一起换,按语言记住选择;只有一种时不显示 |
| 块筛选 | 预览顶部钉住一条筛选栏,按块的属性(种类、访问级别、异步……)筛;属性由语言插件按和语言无关的 id 报(通用的由宿主从 facts 推),名字由翻译插件起,webview 不认识任何语言;筛选整个工作区共用、记住 |
| 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 | | 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 |
| 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 | | 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 |
| 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)和类块默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 | | 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)和类块默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 |
@@ -64,16 +65,34 @@
- 每一句是一块:导入、函数、类、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。 - 每一句是一块:导入、函数、类、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。
- 嵌套的块放在父块下面、往右缩进;每一层左边一条竖线(颜色 `--vscode-editorIndentGuide-background`)。 - 嵌套的块放在父块下面、往右缩进;每一层左边一条竖线(颜色 `--vscode-editorIndentGuide-background`)。
- 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签、内容、右侧切换按钮。 - 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签(字取翻译插件给 `kind` 值起的名字,见「筛选」)、内容、右侧切换按钮。
- 种类标签和底色按种类区分(颜色见下表);注释块虚线框。 - 种类标签和底色按种类区分(颜色见下表);注释块虚线框。
- 内容两种状态,每块单独切: - 内容两种状态,每块单独切:
- 预览:函数、类、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。 - 预览:函数、类、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。
- 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。 - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。
- 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里。 - 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(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、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和参数、导入项的子行一样)。 卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`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 个` 或「无」,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(`默认 ` + 译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) | | 函数 | 名称(函数色;匿名函数写「匿名」)/ 说明(有说明才显示:JSDoc 去掉 `/**`、`*/` 和每行开头的 `*`,docstring 去掉引号;再去掉共同缩进和首尾空行,`@param` 这类标签行照抄;得到的原文按 Markdown 渲染,见「注释的 Markdown 排版」;里面的名字不能点)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(`默认 ` + 译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) |
| 类 | 名称(类型色;匿名类 `export default class {}` 写「匿名」;赋给变量、类字段的类表达式写变量名、字段名)/ 说明(有说明才显示,规则同函数)/ 访问级别 / 抽象(是 / 否,只有 TypeScript 有,看 `abstract class`,TypeScript 的类都有这一行)/ 装饰器(同函数)/ 类型参数(有才显示:TypeScript `<K, T extends X = Y>` 每个参数的译文,逗号隔开,如 `K, T 限定为 X = Y`;Python 3.12 的 `class G[T]` 同样)/ 父类(有才显示:TypeScript `extends` 后面的表达式连着泛型实参的译文,如 `Base<字符串>`;Python 类名后面括号里的参数列表照写的顺序逐项翻,关键字参数、`*bases` 也在这一行,如 `Base, metaclass=ABCMeta`;逗号隔开)/ 实现的接口(有才显示:TypeScript `implements` 后面每个类型的译文,逗号隔开) | | 类 | 名称(类型色;匿名类 `export default class {}` 写「匿名」;赋给变量、类字段的类表达式写变量名、字段名)/ 说明(有说明才显示,规则同函数)/ 访问级别 / 抽象(是 / 否,只有 TypeScript / TSX 有,看 `abstract class`,TypeScript 的类都有这一行;JavaScript / JSX 没有)/ 装饰器(同函数)/ 类型参数(有才显示:TypeScript `<K, T extends X = Y>` 每个参数的译文,逗号隔开,如 `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` 导入多个模块时写模块名) | | 导入 | 来源(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`)就重复几组 | | 常量 / 变量 | 名称(解构声明写整个模式,如 `{ p, q }`)/ 访问级别 / 类型(写了标注才有)/ 值(常量)或初始值(变量):初始化表达式的译文,没有写「无」;是字典 / 对象字面量时见下面「字典 / 对象字面量」。一条语句声明几个变量(`let a = 1, b = 2`)就重复几组 |
访问级别(公共 / 私有 / 受保护): 访问级别(公共 / 私有 / 受保护):
@@ -161,7 +180,7 @@ export interface FunctionFacts {
name?: Node // 匿名函数没有 name?: Node // 匿名函数没有
doc?: Node // 函数的说明:紧贴在前面的 JSDoc 注释节点(TS)、主体开头的 docstring 字符串节点(Python) doc?: Node // 函数的说明:紧贴在前面的 JSDoc 注释节点(TS)、主体开头的 docstring 字符串节点(Python)
access?: Access // 嵌套在语句块里的函数没有 access?: Access // 嵌套在语句块里的函数没有
async: boolean async?: boolean // 没有这个概念的语言不填
decorators: Node[] decorators: Node[]
parameters: ParameterFacts[] parameters: ParameterFacts[]
returnType?: Node returnType?: Node
@@ -174,7 +193,7 @@ export interface ClassFacts {
name?: Node // 匿名类没有;赋给变量、类字段的类表达式取变量名、字段名 name?: Node // 匿名类没有;赋给变量、类字段的类表达式取变量名、字段名
doc?: Node // 同 FunctionFacts.doc doc?: Node // 同 FunctionFacts.doc
access?: Access // 嵌套在语句块里的类没有 access?: Access // 嵌套在语句块里的类没有
abstract?: boolean // 只有 TypeScript 填 abstract?: boolean // 只有 TypeScript 填(JavaScript 不填)
decorators: Node[] decorators: Node[]
typeParameters: Node[] // 每个泛型参数一项 typeParameters: Node[] // 每个泛型参数一项
bases: BaseFacts[] bases: BaseFacts[]
@@ -189,7 +208,7 @@ export type ImportItem =
export interface ImportFacts { export interface ImportFacts {
sources: Node[] // 来源模块的写法;Python 的 import a, b 有多个 sources: Node[] // 来源模块的写法;Python 的 import a, b 有多个
typeOnly?: boolean // 只有 TypeScript 填 typeOnly?: boolean // 只有 TypeScript 填(JavaScript 不填)
items: ImportItem[] items: ImportItem[]
} }
@@ -211,12 +230,24 @@ export interface VariableFacts {
declarators: DeclaratorFacts[] 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<string, string>
export interface BlockShape { export interface BlockShape {
/** 这一块对应的语法树节点(整条语句)。 */ /** 这一块对应的语法树节点(整条语句)。 */
node: Node node: Node
/** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句;函数、类的说明(doc)也算在里面。 */ /** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句;函数、类的说明(doc)也算在里面。 */
header: SourceSpan header: SourceSpan
children: CodeBlock[] children: CodeBlock[]
/** 语言自己报的筛选属性:通用词表以外的(Rust unsafe: 'true'),或 facts 表达不了的通用属性值(Kotlin access: 'internal',盖过推出来的);kind 填了不算。 */
attributes?: BlockAttributes
} }
/** 预览成属性表的块:种类和 facts 绑在一起,语言插件漏给 facts 编译不过。 */ /** 预览成属性表的块:种类和 facts 绑在一起,语言插件漏给 facts 编译不过。 */
@@ -278,11 +309,18 @@ export interface LanguageAdapter {
languageIds: string[] languageIds: string[]
/** 该 languageId 用的语法包 .wasm 的绝对路径(宿主负责加载,保证和解析器运行时是同一份)。 */ /** 该 languageId 用的语法包 .wasm 的绝对路径(宿主负责加载,保证和解析器运行时是同一份)。 */
grammarPath: (languageId: string) => string 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 tokenRole: (leaf: Node) => TokenRole
} }
/** 筛选属性和值的显示名;没给名字的显示 id。 */
export interface AttributeNames {
attributes: Record<string, string> // access → 访问级别
values: Record<string, Record<string, string>> // access → public → 公共、async → true → 是、kind → function → 函数
}
/** 翻译插件:代码块 → 一行译文或属性表。 */ /** 翻译插件:代码块 → 一行译文或属性表。 */
export interface Translator { export interface Translator {
id: string // 'typescript.zh-template' id: string // 'typescript.zh-template'
@@ -290,6 +328,7 @@ export interface Translator {
languageIds: string[] languageIds: string[]
translate: (block: TextBlock, source: string) => PreviewText // 只翻 header 那一段;注释块给 Markdown 原文 translate: (block: TextBlock, source: string) => PreviewText // 只翻 header 那一段;注释块给 Markdown 原文
describe: (block: FactBlock, source: string) => PropertyRow[] // 函数、类、导入、常量、变量块 → 属性表 describe: (block: FactBlock, source: string) => PropertyRow[] // 函数、类、导入、常量、变量块 → 属性表
attributeNames?: AttributeNames // 筛选属性和值叫什么,和属性表的叫法一致;筛选栏、种类标签用它
} }
export interface CodeReaderApi { export interface CodeReaderApi {
@@ -301,7 +340,7 @@ export interface CodeReaderApi {
- `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。 - `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。
- 内置的 TypeScript、Python 语言插件和两个中文直译翻译插件在 `activate()` 里通过同一个 API 注册,没有旁路。 - 内置的 TypeScript、Python 语言插件和两个中文直译翻译插件在 `activate()` 里通过同一个 API 注册,没有旁路。
- 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。 - 注册表变化时更新 `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)从哪来 ### 属性表信息(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 函数:`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 类:`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 导入:`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 之前)。 - 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 函数:`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` 也算;没有抽象、实现的接口。 - 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 源码,去掉共同缩进 code: string // header 源码,去掉共同缩进
codeTokens: Segment[] // 源码着色(拼起来等于 code) codeTokens: Segment[] // 源码着色(拼起来等于 code)
defines?: SourceRange // 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取;跳到的定义是它时闪整块 defines?: SourceRange // 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取;跳到的定义是它时闪整块
attributes: Record<string, string> // 筛选属性(含 kind),见「筛选」
children: BlockView[] children: BlockView[]
} }
@@ -442,15 +482,17 @@ type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: P
``` ```
- 扩展 → webview: - 扩展 → 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: 'reveal', line, definition? }`:同上,发给已经开着的预览。页面正在重新加载(标签页切回来)时收不到,扩展端改成随它 `ready` 后的第一份文档带过去。
- `{ type: 'definitionProbed', id, found }`:回答第 `id` 次 `probeDefinition`。 - `{ type: 'definitionProbed', id, found }`:回答第 `id` 次 `probeDefinition`。
- `{ type: 'filter', excluded: Record<属性 id, 值 id[]> }`:整个工作区共用的块筛选。webview 发 `ready` 后先于第一份文档发一次(第一份文档按筛选画),之后任何一个预览改了都发给所有开着的预览(包括改的那个)。
- webview → 扩展: - webview → 扩展:
- `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。 - `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。
- `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。 - `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。
- `{ type: 'ready' }` - `{ type: 'ready' }`
- `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。
- `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。
- `{ type: 'setFilter', excluded }`:改块筛选(整份替换);扩展端逐项核对后记进 `workspaceState`,再发 `filter` 给所有预览。webview 不在本地先改,只按扩展端发来的显示。
- 块的编辑状态和用户的折叠选择在 webview 里按块 id 记(`vscode.setState`):`{ editing: string[], folds: Record<id, boolean>, 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`(记成展开)。读回来的状态逐项核对,旧版本存的、形状不对的项当没存过。文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 - 块的编辑状态和用户的折叠选择在 webview 里按块 id 记(`vscode.setState`):`{ editing: string[], folds: Record<id, boolean>, 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 registry.ts 注册表 + CodeReaderApi
runtime.ts web-tree-sitter 初始化、语法包加载、解析 runtime.ts web-tree-sitter 初始化、语法包加载、解析
blockView.ts CodeBlock → BlockView(源码着色、去缩进、调用翻译) blockView.ts CodeBlock → BlockView(源码着色、去缩进、调用翻译)
attributes.ts 块的筛选属性(从 facts 推出通用属性 + 语言自己报的)、文件里出现过的属性和块数
languages/typescript.ts languages/typescript.ts
languages/python.ts languages/python.ts
translators/zhTemplate.ts 模板直译引擎(按语言的表驱动) translators/zhTemplate.ts 模板直译引擎(按语言的表驱动)
@@ -495,10 +538,11 @@ vscode/src/codeReader/
notes/commands.ts 「添加注释 / 编辑注释」输入框 notes/commands.ts 「添加注释 / 编辑注释」输入框
editor/previewEditor.ts CustomTextEditorProvider editor/previewEditor.ts CustomTextEditorProvider
editor/messages.ts 消息类型 editor/messages.ts 消息类型
editor/blockFilter.ts 块筛选:workspaceState 读写、通知所有预览
editor/definitions.ts 查定义(vscode.executeDefinitionProvider)、几处定义的挑选框 editor/definitions.ts 查定义(vscode.executeDefinitionProvider)、几处定义的挑选框
editor/definitionAction.ts 查到的定义 → 怎么跳(纯函数) editor/definitionAction.ts 查到的定义 → 怎么跳(纯函数)
index.ts registerCodeReader(context): CodeReaderApi 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` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。 - 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。
- 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。
- 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。
- 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。 - 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。
## 收尾 ## 收尾
+157
View File
@@ -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<BlockView[]> {
return withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, source, adapter, translator))
}
/** 每块的筛选属性,按 header 起始行(1 起)索引;同一行有几块取外层那块。 */
function attributesByLine(list: BlockView[], out: Record<number, Record<string, string>> = {}): Record<number, Record<string, string>> {
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 <div /> }'))
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<string, string>, 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)
})
})
+74
View File
@@ -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<Record<CommonAttribute, string>> {
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<string, Map<string, number>>()
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<string, number>()
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 })),
}))
}
+6 -2
View File
@@ -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 type { TreeSitterRuntime } from './runtime'
import { blockAttributes } from './attributes'
import { commonIndent, splitLines } from './editText' import { commonIndent, splitLines } from './editText'
interface BlockViewBase { interface BlockViewBase {
@@ -15,6 +16,8 @@ interface BlockViewBase {
codeTokens: Segment[] codeTokens: Segment[]
/** 块自己的名字(见 ownName)在源码里的范围:跳到的定义就是它时闪整块,不闪属性表里的某一行。 */ /** 块自己的名字(见 ownName)在源码里的范围:跳到的定义就是它时闪整块,不闪属性表里的某一行。 */
defines?: SourceRange defines?: SourceRange
/** 筛选属性(含 kind),见 blockAttributes。 */
attributes: BlockAttributes
children: BlockView[] 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'), 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), codeTokens: colorLines(block.node.tree.rootNode, source, lines, startRow, endRow, indent, adapter.tokenRole),
...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }), ...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }),
attributes: blockAttributes(block),
children: block.children.map((child, i) => build(child, `${id}.${i}`)), children: block.children.map((child, i) => build(child, `${id}.${i}`)),
...('facts' in block ...('facts' in block
? { properties: translator ? translator.describe(block, source) : [] } ? { properties: translator ? translator.describe(block, source) : [] }
@@ -135,7 +139,7 @@ export async function readBlockViews(
): Promise<BlockView[]> { ): Promise<BlockView[]> {
const tree = await runtime.parse(adapter, languageId, source) const tree = await runtime.parse(adapter, languageId, source)
try { try {
return toBlockViews(adapter.toBlocks(tree.rootNode, source), source, adapter, translator) return toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), source, adapter, translator)
} }
finally { finally {
tree.delete() tree.delete()
+34 -2
View File
@@ -36,7 +36,8 @@ export interface FunctionFacts {
doc?: Node doc?: Node
/** 嵌套在函数体、条件等语句块里的函数没有。 */ /** 嵌套在函数体、条件等语句块里的函数没有。 */
access?: Access access?: Access
async: boolean /** 异步函数;没有这个概念的语言不填。 */
async?: boolean
decorators: Node[] decorators: Node[]
parameters: ParameterFacts[] parameters: ParameterFacts[]
returnType?: Node returnType?: Node
@@ -107,6 +108,21 @@ export interface VariableFacts {
declarators: DeclaratorFacts[] 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<string, string>
export interface BlockShape { export interface BlockShape {
/** 这一块对应的语法树节点(整条语句)。 */ /** 这一块对应的语法树节点(整条语句)。 */
node: Node node: Node
@@ -116,6 +132,11 @@ export interface BlockShape {
*/ */
header: SourceSpan header: SourceSpan
children: CodeBlock[] children: CodeBlock[]
/**
* 语言自己报的筛选属性,和宿主推出的通用属性(见 COMMON_ATTRIBUTES)合在一起:通用词表以外的属性(Rust 的 unsafe: 'true'),
* 或 facts 表达不了的通用属性值(Kotlin 的 access: 'internal',盖过从 facts 推出的)。kind 总是块的种类,这里填了不算。
*/
attributes?: BlockAttributes
} }
/** 预览成属性表的块:语言插件从语法树读出结构化信息(facts),翻译插件把它排成属性表。 */ /** 预览成属性表的块:语言插件从语法树读出结构化信息(facts),翻译插件把它排成属性表。 */
@@ -185,11 +206,20 @@ export interface LanguageAdapter {
languageIds: string[] languageIds: string[]
/** 该 languageId 用的语法包 .wasm 的绝对路径(宿主负责加载,保证和解析器运行时是同一份)。 */ /** 该 languageId 用的语法包 .wasm 的绝对路径(宿主负责加载,保证和解析器运行时是同一份)。 */
grammarPath: (languageId: string) => string 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 tokenRole: (leaf: Node) => TokenRole
} }
/** 筛选属性和值的显示名,筛选栏、种类标签用它;没给名字的显示 id。 */
export interface AttributeNames {
/** 属性 id → 属性名,例如 access → 访问级别。 */
attributes: Record<string, string>
/** 属性 id → 值 id → 值名,例如 access → public → 公共、async → true → 是、kind → function → 函数。 */
values: Record<string, Record<string, string>>
}
/** 翻译插件:代码块 → 一行译文或属性表。 */ /** 翻译插件:代码块 → 一行译文或属性表。 */
export interface Translator { export interface Translator {
id: string // 'typescript.zh-template' id: string // 'typescript.zh-template'
@@ -199,6 +229,8 @@ export interface Translator {
translate: (block: TextBlock, source: string) => PreviewText translate: (block: TextBlock, source: string) => PreviewText
/** 函数、类、导入、常量、变量块 → 属性表。 */ /** 函数、类、导入、常量、变量块 → 属性表。 */
describe: (block: FactBlock, source: string) => PropertyRow[] describe: (block: FactBlock, source: string) => PropertyRow[]
/** 筛选属性和值叫什么,和属性表里的属性名、值用同一套叫法;不给就都显示 id。 */
attributeNames?: AttributeNames
} }
export interface CodeReaderApi { export interface CodeReaderApi {
@@ -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<string, unknown> = {}): Pick<Memento, 'get' | 'update'> & { store: Record<string, unknown> } {
const store = { ...initial }
return {
store,
get: <T>(key: string): T | undefined => store[key] as T | undefined,
update: async (key: string, value: unknown) => {
store[key] = value
},
} as Pick<Memento, 'get' | 'update'> & { store: Record<string, unknown> }
}
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({})
})
})
@@ -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<string, string[]>
function isRecord(value: unknown): value is Record<string, unknown> {
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<Memento, 'get' | 'update'>) {}
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<void> {
const excluded = normalizeExcluded(value)
await this.state.update(FILTER_KEY, excluded)
for (const listener of [...this.listeners])
listener(excluded)
}
}
@@ -36,11 +36,15 @@ describe('预览的 document 消息', () => {
expect(message.error).toBeUndefined() expect(message.error).toBeUndefined()
expect(message.blocks.map(b => b.kind)).toEqual(['if']) expect(message.blocks.map(b => b.kind)).toEqual(['if'])
expect(message.blocks[0].children).toMatchObject([{ kind: 'return', translation: [{ text: '返回' }, { text: ' ' }, { text: '空数组' }] }]) 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 () => { it('没有语言插件的语言:带 error,不解析', async () => {
const message = await documentMessage(doc('rust', 'fn main() {}'), registryWithTypeScript(), runtime) const message = await documentMessage(doc('rust', 'fn main() {}'), registryWithTypeScript(), runtime)
expect(message.blocks).toEqual([]) expect(message.blocks).toEqual([])
expect(message.attributes).toEqual([])
expect(message.translators).toEqual([]) expect(message.translators).toEqual([])
expect(message.error).toContain('rust') expect(message.error).toContain('rust')
}) })
@@ -1,6 +1,7 @@
import type { CodeReaderRegistry } from '../registry' import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime' import type { TreeSitterRuntime } from '../runtime'
import type { DocumentMessage } from './messages' import type { DocumentMessage } from './messages'
import { fileAttributes } from '../attributes'
import { readBlockViews } from '../blockView' import { readBlockViews } from '../blockView'
/** 生成预览数据要读的那点文档信息(TextDocument 的子集)。 */ /** 生成预览数据要读的那点文档信息(TextDocument 的子集)。 */
@@ -24,6 +25,7 @@ export async function documentMessage(
version, version,
languageId, languageId,
blocks: [], blocks: [],
attributes: [],
translators: registry.translatorsFor(languageId).map(({ id, label }) => ({ id, label })), translators: registry.translatorsFor(languageId).map(({ id, label }) => ({ id, label })),
translatorId: translator?.id ?? '', translatorId: translator?.id ?? '',
} }
@@ -31,7 +33,8 @@ export async function documentMessage(
if (!adapter) if (!adapter)
return { ...message, error: `代码阅读还不支持「${languageId}」这种语言` } return { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }
try { 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) { catch (err) {
return { ...message, error: err instanceof Error ? err.message : String(err) } return { ...message, error: err instanceof Error ? err.message : String(err) }
+10
View File
@@ -2,7 +2,11 @@
* 代码阅读预览与扩展之间的消息,与 webview 端 `webview-ui/src/codeReader/types.ts` 同构,两边手动保持一致。 * 代码阅读预览与扩展之间的消息,与 webview 端 `webview-ui/src/codeReader/types.ts` 同构,两边手动保持一致。
*/ */
import type { FileAttribute } from '../attributes'
import type { BlockView } from '../blockView' import type { BlockView } from '../blockView'
import type { Excluded } from './blockFilter'
export type { Excluded }
export interface TranslatorOption { id: string, label: string } export interface TranslatorOption { id: string, label: string }
@@ -21,6 +25,8 @@ export interface DocumentMessage {
version: number version: number
languageId: string languageId: string
blocks: BlockView[] blocks: BlockView[]
/** 这份文件里出现过的筛选属性和值(带显示名、块数),按筛选栏的先后排,见 fileAttributes */
attributes: FileAttribute[]
translators: TranslatorOption[] translators: TranslatorOption[]
translatorId: string translatorId: string
error?: string error?: string
@@ -31,11 +37,13 @@ export interface DocumentMessage {
/** /**
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:id 那次 probeDefinition 的回答,found 表示 VS Code 查得到定义 * definitionProbed:id 那次 probeDefinition 的回答,found 表示 VS Code 查得到定义
* filter:整个工作区共用的块筛选;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览
*/ */
export type ExtensionToWebview export type ExtensionToWebview
= | DocumentMessage = | DocumentMessage
| ({ type: 'reveal' } & Reveal) | ({ type: 'reveal' } & Reveal)
| { type: 'definitionProbed', id: number, found: boolean } | { type: 'definitionProbed', id: number, found: boolean }
| { type: 'filter', excluded: Excluded }
/** /**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进)
@@ -43,9 +51,11 @@ export type ExtensionToWebview
* probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问源码 offset 处有没有定义;id 标出是哪一次问,回答带回去; * probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问源码 offset 处有没有定义;id 标出是哪一次问,回答带回去;
* version 是这份下标所属的文档版本 * version 是这份下标所属的文档版本
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览
*/ */
export type WebviewToExtension export type WebviewToExtension
= | { type: 'ready' } = | { type: 'ready' }
| { type: 'setFilter', excluded: Excluded }
| { type: 'edit', startLine: number, endLine: number, text: string } | { type: 'edit', startLine: number, endLine: number, text: string }
| { type: 'selectTranslator', id: string } | { type: 'selectTranslator', id: string }
| { type: 'visibleLine', line: number } | { type: 'visibleLine', line: number }
+14 -1
View File
@@ -1,6 +1,7 @@
import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode' import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode'
import type { CodeReaderRegistry } from '../registry' import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime' import type { TreeSitterRuntime } from '../runtime'
import type { BlockFilter } from './blockFilter'
import type { Definition } from './definitions' import type { Definition } from './definitions'
import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages' import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages'
import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode' import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode'
@@ -29,13 +30,14 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
private readonly extensionUri: Uri, private readonly extensionUri: Uri,
private readonly registry: CodeReaderRegistry, private readonly registry: CodeReaderRegistry,
private readonly runtime: TreeSitterRuntime, private readonly runtime: TreeSitterRuntime,
private readonly filter: BlockFilter,
) {} ) {}
resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void {
panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri)] } panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri)] }
panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html') panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html')
const key = document.uri.toString() 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.reveals.delete(key)
this.previews.add(preview) this.previews.add(preview)
panel.onDidDispose(() => { panel.onDidDispose(() => {
@@ -96,6 +98,7 @@ class Preview {
private readonly owner: PreviewEditorProvider, private readonly owner: PreviewEditorProvider,
private readonly registry: CodeReaderRegistry, private readonly registry: CodeReaderRegistry,
private readonly runtime: TreeSitterRuntime, private readonly runtime: TreeSitterRuntime,
private readonly filter: BlockFilter,
/** /**
* 随下一份真正发出去的文档走、只走一次:新预览第一份文档要滚到的位置,或页面正在重新加载时收不到的 reveal。 * 随下一份真正发出去的文档走、只走一次:新预览第一份文档要滚到的位置,或页面正在重新加载时收不到的 reveal。
* 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复 * 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复
@@ -112,6 +115,11 @@ class Preview {
if (e.document === document && e.contentChanges.length > 0) if (e.document === document && e.contentChanges.length > 0)
this.schedulePush() this.schedulePush()
}), }),
// 页面没 ready 时收不到,等它 ready 再随第一份文档之前补发
filter.subscribe((excluded) => {
if (this.ready)
this.post({ type: 'filter', excluded })
}),
] ]
} }
@@ -167,8 +175,13 @@ class Preview {
switch (msg.type) { switch (msg.type) {
case 'ready': case 'ready':
this.ready = true this.ready = true
// 先于文档发:第一份文档按筛选画,不会先整份露出来再藏
this.post({ type: 'filter', excluded: this.filter.excluded })
await this.push() await this.push()
return return
case 'setFilter':
await this.filter.set(msg.excluded)
return
case 'edit': case 'edit':
await this.applyEdit(msg.startLine, msg.endLine, msg.text) await this.applyEdit(msg.startLine, msg.endLine, msg.text)
return return
+2 -1
View File
@@ -1,6 +1,7 @@
import type { ExtensionContext, Uri } from 'vscode' import type { ExtensionContext, Uri } from 'vscode'
import type { CodeReaderApi } from './contract' import type { CodeReaderApi } from './contract'
import { commands, window, workspace } from 'vscode' import { commands, window, workspace } from 'vscode'
import { BlockFilter } from './editor/blockFilter'
import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor' import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor'
import { showPreview, showSource } from './editor/switchEditor' import { showPreview, showSource } from './editor/switchEditor'
import { FileTreeProvider } from './explorer/fileTree' import { FileTreeProvider } from './explorer/fileTree'
@@ -45,7 +46,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
export function registerCodeReader(context: ExtensionContext): CodeReaderApi { export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
const registry = new CodeReaderRegistry(context.globalState) const registry = new CodeReaderRegistry(context.globalState)
const runtime = new TreeSitterRuntime(bundledWasmDir()) 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 notes = new FileNotes()
const files = new FileTreeProvider(OPEN_COMMAND, notes) const files = new FileTreeProvider(OPEN_COMMAND, notes)
@@ -532,4 +532,15 @@ describe('typeScript 类', () => {
expect(js[1]).toMatchObject({ name: 'A', bases: [{ value: 'mixin(B)' }], implements: [] }) expect(js[1]).toMatchObject({ name: 'A', bases: [{ value: 'mixin(B)' }], implements: [] })
expect(js[2]).toMatchObject({ access: 'public', bases: [{ value: 'C' }] }) 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 })
})
}) })
+224 -211
View File
@@ -10,6 +10,9 @@ const GRAMMARS: Record<string, string> = {
javascriptreact: 'tree-sitter-javascript.wasm', 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 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 LOOPS = new Set(['for_statement', 'for_in_statement', 'while_statement', 'do_statement'])
const CLASSES = new Set(['class_declaration', 'abstract_class_declaration', 'class']) const CLASSES = new Set(['class_declaration', 'abstract_class_declaration', 'class'])
@@ -41,118 +44,6 @@ function callbackBody(expression: Node | null | undefined): Node | null {
return 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 是私有,嵌套在语句块里的没有访问级别。 */ /** 模块里的声明:export 了是公共,顶层没 export 是私有,嵌套在语句块里的没有访问级别。 */
function declarationAccess(outer: Node, top: boolean): Access | undefined { function declarationAccess(outer: Node, top: boolean): Access | undefined {
if (outer.type === 'export_statement') 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` 只导入类型。 */ /** import 语句:默认导入、`* as x`、`{ a as b }`、`import x = require('m')`;TypeScript(typed)的 `import type` 只导入类型。 */
function importFacts(node: Node): ImportFacts { function importFacts(node: Node, typed: boolean): ImportFacts {
const require = named(node).find(child => child.type === 'import_require_clause') const require = named(node).find(child => child.type === 'import_require_clause')
const source = (require ?? node).childForFieldName('source') const source = (require ?? node).childForFieldName('source')
const items: ImportItem[] = [] 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 } const sources = source ? [source] : []
} return typed ? { sources, typeOnly: children(node).some(child => child.type === 'type'), items } : { sources, 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)
} }
/** /**
* 类的装饰器写在 export 前面时挂在 export 语句上,写在 class 前面时挂在类声明上; * 类的装饰器写在 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 holders = sameNode(outer, declaration) ? [declaration] : [outer, declaration]
const heritage = syntaxChildren(named(declaration).find(child => child.type === 'class_heritage')) const heritage = syntaxChildren(named(declaration).find(child => child.type === 'class_heritage'))
return { return {
name: name ?? undefined, name: name ?? undefined,
access, 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'))], decorators: [...decorators, ...holders.flatMap(holder => named(holder).filter(child => child.type === 'decorator'))],
typeParameters: syntaxChildren(declaration.childForFieldName('type_parameters')), typeParameters: syntaxChildren(declaration.childForFieldName('type_parameters')),
bases: heritage.flatMap((part) => { bases: heritage.flatMap((part) => {
@@ -370,35 +196,222 @@ function extendsBases(clause: Node): BaseFacts[] {
return bases return bases
} }
function memberBlocks(member: Node, decorators: Node[]): CodeBlock[] { /**
switch (member.type) { * 读代码块树的一套函数。typed:TypeScript(typescript、typescriptreact),分得出抽象类、仅类型导入;
case 'method_definition': * JavaScript 没有这两个概念,facts 里不填(见 classFacts、importFacts)。
case 'method_signature': */
case 'abstract_method_signature': function blockReader(typed: boolean): (root: Node) => CodeBlock[] {
return [functionBlock(member, member, member.childForFieldName('name'), memberAccess(member), decorators)] function statements(items: Node[], top: boolean, afterRow: number): CodeBlock[] {
case 'class_static_block': return listBlocks(items, afterRow, item => statementBlocks(item, top), COMMENTS)
return [bodyBlock('statement', member, member.childForFieldName('body'))] }
default: {
const value = member.childForFieldName('value') /** 主体是语句块时子块来自它的语句,否则主体本身就是唯一的子语句(`if (x) return`)。 */
if (value && CLASSES.has(value.type)) function bodyShape(outer: Node, body: Node | null): BlockShape {
return [classBlock(member, value, member.childForFieldName('name'), memberAccess(member), decorators)] if (!body)
if (value && functionBody(value)) return leafShape(outer)
return [functionBlock(member, value, member.childForFieldName('name'), memberAccess(member), decorators)] const items = body.type === 'statement_block' ? named(body) : [body]
return [leafBlock('field', member)] 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。 */ /** export 包着声明时按声明定种类,块节点取整个 export_statement(头部包含 export)。 */
function membersBlock(declaration: Node, outer: Node): CodeBlock { function exportBlocks(node: Node, top: boolean): CodeBlock[] {
const body = declaration.childForFieldName('body') const declaration = node.childForFieldName('declaration')
if (!body) if (declaration)
return leafBlock('type', outer) return declarationBlocks(declaration, node, top)
return { const value = node.childForFieldName('value')
kind: 'type', if (value && CLASSES.has(value.type))
...compoundShape(outer, body.startIndex, named(body), (list, afterRow) => return [classBlock(node, value, value.childForFieldName('name'), 'public')]
listBlocks(list, afterRow, member => [leafBlock('field', member)], COMMENTS)), 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}`) throw new Error(`TypeScript 语言插件不支持 ${languageId}`)
return path.join(wasmDir, file) return path.join(wasmDir, file)
}, },
toBlocks: root => statements(named(root), true, -1), toBlocks: (root, _source, languageId) => blockReader(TYPED_LANGUAGES.has(languageId))(root),
tokenRole: typescriptTokenRole, tokenRole: typescriptTokenRole,
} }
} }
+1 -1
View File
@@ -17,7 +17,7 @@ export function readFixture(name: string): string {
export async function withBlocks<T>(adapter: LanguageAdapter, languageId: string, source: string, use: (blocks: CodeBlock[]) => T): Promise<T> { export async function withBlocks<T>(adapter: LanguageAdapter, languageId: string, source: string, use: (blocks: CodeBlock[]) => T): Promise<T> {
const tree = await runtime.parse(adapter, languageId, source) const tree = await runtime.parse(adapter, languageId, source)
try { try {
return use(adapter.toBlocks(tree.rootNode, source)) return use(adapter.toBlocks(tree.rootNode, source, languageId))
} }
finally { finally {
tree.delete() tree.delete()
+38 -11
View File
@@ -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' import { commonIndent, splitLines } from '../editText'
/** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */
@@ -122,6 +122,34 @@ function slotChild(node: Node, slot: Exclude<TemplatePart, string>): Node | null
return node.namedChildren.filter(child => child && !child.isExtra)[slot.namedChild] return node.namedChildren.filter(child => child && !child.isExtra)[slot.namedChild]
} }
const ATTRIBUTE_ZH: Record<CommonAttribute, string> = { kind: '种类', access: '访问级别', async: '异步', abstract: '抽象', typeOnly: '仅类型' }
const KIND_ZH: Record<BlockKind, string> = {
import: '导入',
function: '函数',
class: '类',
type: '类型',
field: '字段',
const: '常量',
variable: '变量',
if: '条件',
else: '否则',
loop: '循环',
try: '尝试',
catch: '捕获',
finally: '最终',
return: '返回',
statement: '语句',
comment: '注释',
}
const ACCESS_ZH: Record<Access, string> = { 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 { export function createZhTemplateTranslator(table: ZhTable): Translator {
const operatorParents = new Set(table.operatorParents) const operatorParents = new Set(table.operatorParents)
const stringNodes = new Set(table.stringNodes) 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 { function segment(text: string, role: TokenRole, offset: number | undefined): Segment {
@@ -375,14 +403,13 @@ interface Phrases {
doc: (node: Node) => Markdown | undefined doc: (node: Node) => Markdown | undefined
} }
const ACCESS_ZH: Record<Access, string> = { public: '公共', private: '私有', protected: '受保护' }
function hint(text: string): Segment { function hint(text: string): Segment {
return { text, role: 'hint' } return { text, role: 'hint' }
} }
function yesNo(value: boolean): Segment[] { /** 布尔属性的一行:没有这个概念的语言(facts 里没填)没有这一行。 */
return [{ text: value ? '是' : '否', role: 'storage' }] 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[] { function count(n: number): Segment[] {
@@ -394,7 +421,7 @@ function joined(parts: Segment[][], separator: string): Segment[] {
} }
function accessRows(access: Access | undefined): PropertyRow[] { 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)] return [...item, { text: ' ', role: 'plain' }, { text: '作为', role: 'keyword' }, { text: ' ', role: 'plain' }, ...say.translated(alias)]
} }
/** 函数:名称 / 说明 / 访问级别 / 异步 / 装饰器 / 输入参数(每个参数一行:名称、类型、默认值)/ 输出参数。 */ /** 函数:名称 / 说明 / 访问级别 / 异步(只有分这个的语言才有)/ 装饰器 / 输入参数(每个参数一行:名称、类型、默认值)/ 输出参数。 */
function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] { function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] {
return [ return [
{ label: '名称', value: facts.name ? say.name(facts.name, 'function') : [hint('匿名')] }, { label: '名称', value: facts.name ? say.name(facts.name, 'function') : [hint('匿名')] },
...docRows(facts.doc, say), ...docRows(facts.doc, say),
...accessRows(facts.access), ...accessRows(facts.access),
{ label: '异步', value: yesNo(facts.async) }, ...flagRows('async', facts.async),
...decoratorRows(facts.decorators, say), ...decoratorRows(facts.decorators, say),
{ {
label: '输入参数', label: '输入参数',
@@ -457,7 +484,7 @@ function classRows(facts: ClassFacts, say: Phrases): PropertyRow[] {
{ label: '名称', value: facts.name ? say.name(facts.name, 'type') : [hint('匿名')] }, { label: '名称', value: facts.name ? say.name(facts.name, 'type') : [hint('匿名')] },
...docRows(facts.doc, say), ...docRows(facts.doc, say),
...accessRows(facts.access), ...accessRows(facts.access),
...(facts.abstract === undefined ? [] : [{ label: '抽象', value: yesNo(facts.abstract) }]), ...flagRows('abstract', facts.abstract),
...decoratorRows(facts.decorators, say), ...decoratorRows(facts.decorators, say),
...listRow('类型参数', facts.typeParameters.map(say.translated), defining(...facts.typeParameters)), ...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))), ...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 [ return [
{ label: '来源', value: facts.sources.length > 0 ? joined(facts.sources.map(say.module), ', ') : [hint('无')] }, { 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)) })) }, { label: '导入项', value: count(facts.items.length), items: facts.items.map(item => ({ cells: [itemText(item)], ...defining(...importNodes(item)) })) },
] ]
} }
+54 -32
View File
@@ -1,11 +1,12 @@
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
import type { BlockTreeState } from './BlockTree' import type { BlockTreeState } from './BlockTree'
import type { DocumentMessage, Reveal } from './types' import type { DocumentMessage, Excluded, Reveal } from './types'
import type { ViewState } from './viewState' import type { ViewState } from './viewState'
import { TriangleAlert } from 'lucide-react' import { TriangleAlert } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SelectMenu } from '@/components/ui/select-menu'
import { BlockLevel } from './BlockTree' import { BlockLevel } from './BlockTree'
import { applyFilter, keptWhileEditing } from './filter'
import { FilterBar } from './FilterBar'
import { useDefinitionLinks } from './useDefinitionLinks' import { useDefinitionLinks } from './useDefinitionLinks'
import { useReveal } from './useReveal' import { useReveal } from './useReveal'
import { useScrollPosition } from './useScrollPosition' import { useScrollPosition } from './useScrollPosition'
@@ -14,6 +15,8 @@ import { onMessage, postMessage, viewState } from './vscode'
type BlockModes = Pick<ViewState, 'editing' | 'folds'> type BlockModes = Pick<ViewState, 'editing' | 'folds'>
const NONE: ReadonlySet<string> = new Set()
function toggled(ids: string[], id: string): string[] { function toggled(ids: string[], id: string): string[] {
return ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id] return ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id]
} }
@@ -29,16 +32,24 @@ export function App(): ReactElement | null {
const [focusId, setFocusId] = useState<string | null>(null) const [focusId, setFocusId] = useState<string | null>(null)
/** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */ /** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */
const [reveal, setReveal] = useState<Reveal | null>(null) const [reveal, setReveal] = useState<Reveal | null>(null)
/** 扩展端发来的块筛选(整个工作区共用);本地不改,改了发给扩展端、等它发回来 */
const [excluded, setExcluded] = useState<Excluded>({})
/** 跳到定义时临时露出来的块(目标和它的各层祖先),筛选一变、文档一刷新就回到筛选状态 */
const [keep, setKeep] = useState<ReadonlySet<string>>(NONE)
/** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */
const editing = useRef(view.editing)
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds), [doc, view.folds]) 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 => ({ const setFolded = useCallback((ids: string[], folded: boolean) => setView(prev => ({
...prev, ...prev,
folds: { ...prev.folds, ...Object.fromEntries(ids.map(id => [id, folded])) }, folds: { ...prev.folds, ...Object.fromEntries(ids.map(id => [id, folded])) },
})), []) })), [])
const expand = useCallback((ids: string[]) => setFolded(ids, false), [setFolded]) 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), []) const revealed = useCallback(() => setReveal(null), [])
useScrollPosition(doc) useScrollPosition(doc)
useReveal(doc, reveal, collapsed, expand, revealed) useReveal(doc, reveal, collapsed, filter.hidden, show, expand, revealed)
useDefinitionLinks(doc?.version) useDefinitionLinks(doc?.version)
useEffect(() => { useEffect(() => {
@@ -46,11 +57,16 @@ export function App(): ReactElement | null {
switch (msg.type) { switch (msg.type) {
case 'document': case 'document':
setDoc(msg) setDoc(msg)
setKeep(prev => keptWhileEditing(prev, editing.current))
if (msg.reveal) if (msg.reveal)
setReveal(msg.reveal) setReveal(msg.reveal)
return return
case 'reveal': case 'reveal':
setReveal({ line: msg.line, definition: msg.definition }) setReveal({ line: msg.line, definition: msg.definition })
return
case 'filter':
setExcluded(msg.excluded)
setKeep(NONE)
} }
}) })
postMessage({ type: 'ready' }) postMessage({ type: 'ready' })
@@ -59,13 +75,18 @@ export function App(): ReactElement | null {
useEffect(() => { useEffect(() => {
viewState.update(view) viewState.update(view)
editing.current = view.editing
}, [view]) }, [view])
const onFocused = useCallback(() => setFocusId(null), []) 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 && { const tree = useMemo((): BlockTreeState | null => doc && {
version: doc.version, version: doc.version,
editing: new Set(view.editing), editing: new Set(view.editing),
collapsed, collapsed,
hidden: filter.hidden,
temporary: filter.temporary,
kindName: kind => kindNames.get(kind) ?? kind,
focusId, focusId,
onFocused, onFocused,
onToggleEdit: (id) => { onToggleEdit: (id) => {
@@ -73,40 +94,41 @@ export function App(): ReactElement | null {
setView(prev => ({ ...prev, editing: toggled(prev.editing, id) })) setView(prev => ({ ...prev, editing: toggled(prev.editing, id) }))
}, },
onToggleFold: id => setFolded([id], !collapsed.has(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) if (!doc || !tree)
return null return null
return ( return (
<div className="mx-auto box-content max-w-[880px] px-6 py-5"> <>
{doc.translators.length > 1 && ( <FilterBar
<div className="mb-3 flex items-center justify-end gap-2"> attributes={doc.attributes}
<span className="text-xs text-[var(--vscode-descriptionForeground)]">翻译</span> excluded={excluded}
<SelectMenu view={filter}
value={doc.translatorId} onChange={next => postMessage({ type: 'setFilter', excluded: next })}
options={doc.translators.map(t => ({ value: t.id, label: t.label }))} translators={doc.translators}
onChange={id => postMessage({ type: 'selectTranslator', id })} translatorId={doc.translatorId}
title="选择翻译方式" onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })}
className="w-40 text-left" />
/> <div className="mx-auto box-content max-w-[880px] px-6 py-5">
</div> {doc.error !== undefined && (
)} <div className="mb-3 flex items-start gap-2 rounded-lg border border-[var(--vscode-inputValidation-errorBorder)] bg-[var(--vscode-inputValidation-errorBackground)] px-3 py-2">
{doc.error !== undefined && ( <TriangleAlert size={16} strokeWidth={1.8} className="mt-px shrink-0 text-[var(--vscode-errorForeground)]" />
<div className="mb-3 flex items-start gap-2 rounded-lg border border-[var(--vscode-inputValidation-errorBorder)] bg-[var(--vscode-inputValidation-errorBackground)] px-3 py-2"> <div className="min-w-0">
<TriangleAlert size={16} strokeWidth={1.8} className="mt-px shrink-0 text-[var(--vscode-errorForeground)]" /> <div className="break-words">
<div className="min-w-0"> 解析失败:
<div className="break-words"> {doc.error}
解析失败: </div>
{doc.error} <div className="mt-0.5 text-xs text-[var(--vscode-descriptionForeground)]">点编辑器右上角的「编辑」查看源码。</div>
</div> </div>
<div className="mt-0.5 text-xs text-[var(--vscode-descriptionForeground)]">点编辑器右上角的「编辑」查看源码。</div>
</div> </div>
</div> )}
)} {doc.blocks.length === 0
{doc.blocks.length > 0 ? doc.error === undefined && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>
? <BlockLevel blocks={doc.blocks} tree={tree} /> : doc.blocks.some(block => !filter.hidden.has(block.id))
: doc.error === undefined && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>} ? <BlockLevel blocks={doc.blocks} tree={tree} />
</div> : <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
</div>
</>
) )
} }
+23 -25
View File
@@ -1,36 +1,23 @@
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
import type { BlockKind, BlockView } from './types' 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 { Fragment } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { CodeEditor } from './CodeEditor' import { CodeEditor } from './CodeEditor'
import { PropertyGrid } from './PropertyGrid' import { PropertyGrid } from './PropertyGrid'
import { TextPreview } from './TextPreview' import { TextPreview } from './TextPreview'
const KIND_LABEL: Record<BlockKind, string> = {
import: '导入',
function: '函数',
class: '类',
type: '类型',
field: '字段',
const: '常量',
variable: '变量',
if: '条件',
else: '否则',
loop: '循环',
try: '尝试',
catch: '捕获',
finally: '最终',
return: '返回',
statement: '语句',
comment: '注释',
}
export interface BlockTreeState { export interface BlockTreeState {
version: number version: number
editing: ReadonlySet<string> editing: ReadonlySet<string>
/** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */ /** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */
collapsed: ReadonlySet<string> collapsed: ReadonlySet<string>
/** 按筛选不画的块(连同子孙),见 applyFilter */
hidden: ReadonlySet<string>
/** 本来被筛掉、跳到定义时临时显示的块 */
temporary: ReadonlySet<string>
/** 种类标签上写什么:翻译插件给种类起的名字,没给就是 id */
kindName: (kind: BlockKind) => string
/** 刚点了「编辑」、要把光标放进去的块 */ /** 刚点了「编辑」、要把光标放进去的块 */
focusId: string | null focusId: string | null
onFocused: () => void onFocused: () => void
@@ -38,8 +25,13 @@ export interface BlockTreeState {
onToggleFold: (id: string) => void onToggleFold: (id: string) => void
} }
/** 块下面有没有画出来的子块:子块都被筛掉时不留空的一层、不给折叠箭头。 */
function hasShownChildren(block: BlockView, tree: BlockTreeState): boolean {
return block.children.some(child => !tree.hidden.has(child.id))
}
/** /**
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。 * 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉的块不画,不留空位,竖线跟着变短。
* kind 是这一层所属块的种类(顶层没有),悬停那个块时竖线换成它的颜色。 * kind 是这一层所属块的种类(顶层没有),悬停那个块时竖线换成它的颜色。
*/ */
export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?: BlockKind, tree: BlockTreeState }): ReactElement { 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?:
<div className="cr-guide w-px" /> <div className="cr-guide w-px" />
</div> </div>
<div className="flex min-w-0 flex-1 flex-col gap-2"> <div className="flex min-w-0 flex-1 flex-col gap-2">
{blocks.map(block => ( {blocks.filter(block => !tree.hidden.has(block.id)).map(block => (
<Fragment key={block.id}> <Fragment key={block.id}>
<BlockCard block={block} tree={tree} /> <BlockCard block={block} tree={tree} />
{block.children.length > 0 && !tree.collapsed.has(block.id) && ( {hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
<BlockLevel blocks={block.children} kind={block.kind} tree={tree} /> <BlockLevel blocks={block.children} kind={block.kind} tree={tree} />
)} )}
</Fragment> </Fragment>
@@ -73,7 +65,7 @@ function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState
function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement { function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
const editing = tree.editing.has(block.id) const editing = tree.editing.has(block.id)
const collapsed = tree.collapsed.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 const Chevron = collapsed ? ChevronRight : ChevronDown
return ( return (
<div <div
@@ -97,7 +89,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
) )
: <span className="w-3.5 shrink-0" />} : <span className="w-3.5 shrink-0" />}
<span className="cr-badge flex h-5 w-11 shrink-0 items-center justify-center rounded text-[11px] leading-[14px] font-medium"> <span className="cr-badge flex h-5 w-11 shrink-0 items-center justify-center rounded text-[11px] leading-[14px] font-medium">
{KIND_LABEL[block.kind]} {tree.kindName(block.kind)}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{editing {editing
@@ -112,6 +104,12 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
) )
: <BlockPreview block={block} tree={tree} />} : <BlockPreview block={block} tree={tree} />}
</div> </div>
{tree.temporary.has(block.id) && (
<span className="cr-temporary flex h-5 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] whitespace-nowrap">
<EyeOff size={12} strokeWidth={1.8} aria-hidden />
已被筛选隐藏,临时显示
</span>
)}
<button <button
type="button" type="button"
className={cn( className={cn(
@@ -0,0 +1,194 @@
import type { KeyboardEvent, ReactElement } from 'react'
import type { Facet, FilterView } from './filter'
import type { Excluded, FileAttribute, TranslatorOption } from './types'
import { Check, ChevronDown, ListFilter } from 'lucide-react'
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { SelectMenu } from '@/components/ui/select-menu'
import { facetLabel, facetsOf, toggleValue } from './filter'
export interface FilterBarProps {
attributes: FileAttribute[]
excluded: Excluded
view: FilterView
onChange: (excluded: Excluded) => void
translators: TranslatorOption[]
translatorId: string
onSelectTranslator: (id: string) => void
}
/**
* 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(共 N 块 / 已隐藏 N 块 · 清除),
* 多于一种翻译时最右边是翻译下拉框。内容和代码块同宽对齐,滚动时块从它下面过去。
*/
export function FilterBar({ attributes, excluded, view, onChange, translators, translatorId, onSelectTranslator }: FilterBarProps): ReactElement {
return (
<div className="cr-filter-bar">
<div className="mx-auto box-content flex max-w-[880px] flex-wrap items-center gap-2 px-6 py-1.5">
<span className="flex h-6 w-[52px] shrink-0 items-center gap-1 text-xs text-[var(--vscode-descriptionForeground)]">
<ListFilter size={14} strokeWidth={1.8} aria-hidden />
筛选
</span>
{facetsOf(attributes, excluded).map(facet => (
<FacetMenu key={facet.id} facet={facet} onToggle={value => onChange(toggleValue(excluded, facet.id, value))} />
))}
<span className="ml-auto flex h-6 items-center gap-1.5 pl-2 text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">
{view.hiddenCount > 0
? (
<>
{`已隐藏 ${view.hiddenCount} 块`}
<span aria-hidden>·</span>
<button type="button" className="cr-link" onClick={() => onChange({})}>清除</button>
</>
)
: `共 ${view.total} 块`}
</span>
{translators.length > 1 && (
<span className="flex h-6 items-center gap-2 border-l border-[var(--vscode-panel-border)] pl-3">
<span className="text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">翻译</span>
<SelectMenu
value={translatorId}
options={translators.map(t => ({ value: t.id, label: t.label }))}
onChange={onSelectTranslator}
title="选择翻译方式"
className="w-36 text-left"
/>
</span>
)}
</div>
</div>
)
}
/**
* 一个属性的下拉框:按钮上是属性名(去掉了值时写勾着哪些,边框换聚焦色),点开是勾选列表,每个值后面是块数。
* 列表盖在代码块上面;点外面、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<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const listRef = useRef<HTMLDivElement>(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<HTMLElement>(`[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<HTMLButtonElement>): 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<HTMLDivElement>): void => {
const move: Record<string, number> = { 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 (
<div ref={rootRef} className="relative">
<button
ref={buttonRef}
type="button"
className="cr-chip flex h-6 w-[150px] items-center gap-1 rounded px-2 text-left text-xs"
data-active={label.active ? '' : undefined}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listId : undefined}
title={label.text}
onClick={() => open ? setOpen(false) : openAt(0)}
onKeyDown={onButtonKeyDown}
>
<span className="min-w-0 flex-1 truncate">{label.text}</span>
<ChevronDown size={12} strokeWidth={1.8} className="shrink-0 text-[var(--vscode-descriptionForeground)]" aria-hidden />
</button>
{open && (
<div
ref={listRef}
id={listId}
role="listbox"
aria-multiselectable
aria-label={facet.name}
aria-activedescendant={`${listId}-${active}`}
tabIndex={-1}
className="cr-menu absolute top-[calc(100%+2px)] left-0 z-20 max-h-[min(360px,60vh)] w-[200px] overflow-y-auto rounded-md py-1"
onKeyDown={onListKeyDown}
>
{facet.values.map((value, index) => (
<div
key={value.id}
id={`${listId}-${index}`}
role="option"
aria-selected={value.checked}
data-index={index}
data-active={index === active ? '' : undefined}
className="cr-option flex cursor-pointer items-center gap-2 px-2.5 py-1 text-xs"
// 焦点留在列表上,方向键接着能用
onMouseDown={event => event.preventDefault()}
onMouseMove={() => setActive(index)}
onClick={() => onToggle(value.id)}
>
<span className="cr-check flex size-3.5 shrink-0 items-center justify-center" data-checked={value.checked ? '' : undefined} aria-hidden>
{value.checked && <Check size={12} strokeWidth={2.4} />}
</span>
<span className="min-w-0 flex-1 truncate">{value.name}</span>
<span className="cr-option-count shrink-0 tabular-nums">{value.count}</span>
</div>
))}
</div>
)}
</div>
)
}
@@ -281,3 +281,70 @@ body.vscode-high-contrast-light {
position: relative; position: relative;
pointer-events: none; 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);
}
@@ -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<string, string> = {}, children: BlockView[] = []): BlockView {
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, children, translation: [] }
}
const NONE = new Set<string>()
/**
* 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())
})
})
+100
View File
@@ -0,0 +1,100 @@
import type { AttributeValueCount, BlockView, Excluded, FileAttribute } from './types'
/** 页面上按筛选怎么显示。 */
export interface FilterView {
/** 不画出来的块:被筛掉的块连同子孙,临时显示的除外 */
hidden: ReadonlySet<string>
/** 本来被筛掉、跳到定义时临时显示的块,带「已被筛选隐藏,临时显示」 */
temporary: ReadonlySet<string>
/** 直接被筛掉的块数,跟着藏起来的子孙不算:「已隐藏 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<string>): FilterView {
const hidden = new Set<string>()
const temporary = new Set<string>()
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<string>, editing: readonly string[]): Set<string> {
const paths = new Set(editing.flatMap(blockPath))
return new Set([...keep].filter(id => paths.has(id)))
}
@@ -20,11 +20,11 @@ function word(text: string): Segment {
} }
function textBlock(id: string, kind: BlockKind, startLine: number, translation: Segment[], children: BlockView[] = []): BlockView { 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 { 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 里的名字不带下标,不算显示着定义', () => { it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => {
const blocks: BlockView[] = [ 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, [ factBlock('1', 'function', 1, [
{ label: '说明', value: { markdown: '同 `get_db`' } }, { label: '说明', value: { markdown: '同 `get_db`' } },
{ label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 30)]] }] }, { label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 30)]] }] },
+4 -1
View File
@@ -108,7 +108,10 @@ export function revealPlan(blocks: BlockView[], line: number, definition: number
return target && { id: target.block.id, expand: target.ancestors } 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 { export function scrollTopToShow(box: { top: number, bottom: number }, viewTop: number, viewHeight: number): number | undefined {
if (box.top >= viewTop && box.bottom <= viewTop + viewHeight) if (box.top >= viewTop && box.bottom <= viewTop + viewHeight)
return undefined return undefined
@@ -7,20 +7,23 @@ export interface BlockBox {
bottom: number bottom: number
} }
/** 视口顶部那块的起始行 + 视口顶部在块内往下偏了多少像素;按块对不上时退回 scrollTop。 */ /**
* 视口顶部那块的起始行 + 视口顶部在块内往下偏了多少像素;按块对不上时退回 scrollTop。
* 「视口顶部」指露出块的那部分的顶部,也就是钉住的筛选栏的下沿;scrollTop 是它在页面里的位置。
*/
export interface ScrollAnchor { export interface ScrollAnchor {
line: number line: number
offset: number offset: number
scrollTop: number scrollTop: number
} }
/** 视口顶部那块:第一个下边还在视口顶部以下的块;全都滚过去了就取最后一块。没有块时没有位置可记。 */ /** 视口顶部(scrollTop,见 ScrollAnchor)那块:第一个下边还在视口顶部以下的块;全都滚过去了就取最后一块。没有块时没有位置可记。 */
export function anchorAt(boxes: readonly BlockBox[], scrollTop: number): ScrollAnchor | undefined { export function anchorAt(boxes: readonly BlockBox[], scrollTop: number): ScrollAnchor | undefined {
const box = boxes.find(b => b.bottom > scrollTop) ?? boxes.at(-1) const box = boxes.find(b => b.bottom > scrollTop) ?? boxes.at(-1)
return box && { line: box.line, offset: scrollTop - box.top, scrollTop } return box && { line: box.line, offset: scrollTop - box.top, scrollTop }
} }
/** 恢复时的 scrollTop:起始行还是那一行的块还在就对齐到它,否则用原来的 scrollTop。 */ /** 恢复时视口顶部要在的位置(见 ScrollAnchor):起始行还是那一行的块还在就对齐到它,否则用原来的 scrollTop。 */
export function scrollTopFor(boxes: readonly BlockBox[], anchor: ScrollAnchor): number { export function scrollTopFor(boxes: readonly BlockBox[], anchor: ScrollAnchor): number {
const box = boxes.find(b => b.line === anchor.line) const box = boxes.find(b => b.line === anchor.line)
return box ? box.top + anchor.offset : anchor.scrollTop return box ? box.top + anchor.offset : anchor.scrollTop
+25
View File
@@ -59,6 +59,8 @@ interface BlockViewBase {
codeTokens: Segment[] codeTokens: Segment[]
/** 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围:跳到的定义就是它时闪整块 */ /** 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围:跳到的定义就是它时闪整块 */
defines?: SourceRange defines?: SourceRange
/** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */
attributes: Record<string, string>
children: BlockView[] children: BlockView[]
} }
@@ -67,6 +69,23 @@ export type BlockView = BlockViewBase & ({ translation: PreviewText } | { proper
export interface TranslatorOption { id: string, label: string } 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<string, string[]>
/** /**
* 滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset), * 滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset),
* 要展开把它折叠住的块和属性行,再让就是这处定义的那一块或属性表里那一行闪几下 * 要展开把它折叠住的块和属性行,再让就是这处定义的那一块或属性表里那一行闪几下
@@ -81,6 +100,8 @@ export interface DocumentMessage {
version: number version: number
languageId: string languageId: string
blocks: BlockView[] blocks: BlockView[]
/** 这份文件里出现过的筛选属性和值(带显示名、块数),按筛选栏的先后排:通用属性在前,语言自己的在后 */
attributes: FileAttribute[]
translators: TranslatorOption[] translators: TranslatorOption[]
translatorId: string translatorId: string
error?: string error?: string
@@ -91,11 +112,13 @@ export interface DocumentMessage {
/** /**
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义 * definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义
* filter:整个工作区共用的块筛选;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
*/ */
export type ExtensionToWebview export type ExtensionToWebview
= | DocumentMessage = | DocumentMessage
| ({ type: 'reveal' } & Reveal) | ({ type: 'reveal' } & Reveal)
| { type: 'definitionProbed', id: number, found: boolean } | { type: 'definitionProbed', id: number, found: boolean }
| { type: 'filter', excluded: Excluded }
/** /**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进) * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)
@@ -103,9 +126,11 @@ export type ExtensionToWebview
* probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问 offset 处有没有定义;id 标出是哪一次问,回答带回来; * probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问 offset 处有没有定义;id 标出是哪一次问,回答带回来;
* version 是这份下标所属的文档版本 * version 是这份下标所属的文档版本
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
*/ */
export type WebviewToExtension export type WebviewToExtension
= | { type: 'ready' } = | { type: 'ready' }
| { type: 'setFilter', excluded: Excluded }
| { type: 'edit', startLine: number, endLine: number, text: string } | { type: 'edit', startLine: number, endLine: number, text: string }
| { type: 'selectTranslator', id: string } | { type: 'selectTranslator', id: string }
| { type: 'visibleLine', line: number } | { type: 'visibleLine', line: number }
+17 -7
View File
@@ -1,8 +1,9 @@
import type { DocumentMessage, Reveal } from './types' import type { DocumentMessage, Reveal } from './types'
import { useLayoutEffect } from 'react' import { useLayoutEffect } from 'react'
import { blockPath } from './filter'
import { revealPlan, scrollTopToShow } from './reveal' import { revealPlan, scrollTopToShow } from './reveal'
import { blockForLine } from './scrollPosition' import { blockForLine } from './scrollPosition'
import { measureBlocks } from './useScrollPosition' import { measureBlocks, pinnedHeight } from './useScrollPosition'
/** 让一块或属性表里的一行闪几下(codeReader.css 的 [data-flash]);正在闪的同一处再跳一次时从头闪。 */ /** 让一块或属性表里的一行闪几下(codeReader.css 的 [data-flash]);正在闪的同一处再跳一次时从头闪。 */
function flash(el: HTMLElement): void { function flash(el: HTMLElement): void {
@@ -24,13 +25,16 @@ function flash(el: HTMLElement): void {
/** /**
* 滚到 reveal 指的位置,处理完调 done: * 滚到 reveal 指的位置,处理完调 done:
* ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部,折叠着的不展开; * ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部(筛选栏下面),折叠着的、被筛掉的不展开;
* ② 跳到定义:先展开把目标藏起来的块、属性行和子行(expand 之后重新渲染,再进来一次),再把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。 * ② 跳到定义:目标或它的祖先块被筛掉了,先临时露出这条路径(show);再展开把目标折叠住的块、属性行和子行(expand)——
* 每一步之后重新渲染、再进来一次;最后把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。
*/ */
export function useReveal( export function useReveal(
doc: DocumentMessage | null, doc: DocumentMessage | null,
reveal: Reveal | null, reveal: Reveal | null,
collapsed: ReadonlySet<string>, collapsed: ReadonlySet<string>,
filtered: ReadonlySet<string>,
show: (ids: string[]) => void,
expand: (ids: string[]) => void, expand: (ids: string[]) => void,
done: () => void, done: () => void,
): void { ): void {
@@ -40,11 +44,16 @@ export function useReveal(
if (reveal.definition === undefined) { if (reveal.definition === undefined) {
const top = blockForLine(measureBlocks(), reveal.line)?.top const top = blockForLine(measureBlocks(), reveal.line)?.top
if (top !== undefined) if (top !== undefined)
window.scrollTo(0, top) window.scrollTo(0, top - pinnedHeight())
done() done()
return return
} }
const plan = revealPlan(doc.blocks, reveal.line, reveal.definition) 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)) ?? [] const hidden = plan?.expand.filter(id => collapsed.has(id)) ?? []
if (hidden.length > 0) { if (hidden.length > 0) {
expand(hidden) expand(hidden)
@@ -55,11 +64,12 @@ export function useReveal(
const el = row ?? (plan && document.querySelector<HTMLElement>(`.cr-block[data-id="${plan.id}"]`)) const el = row ?? (plan && document.querySelector<HTMLElement>(`.cr-block[data-id="${plan.id}"]`))
if (el) { if (el) {
const rect = el.getBoundingClientRect() 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) if (top !== undefined)
window.scrollTo(0, top) window.scrollTo(0, top - pinned)
flash(el) flash(el)
} }
done() done()
}, [doc, reveal, collapsed, expand, done]) }, [doc, reveal, collapsed, filtered, show, expand, done])
} }
@@ -7,7 +7,12 @@ import { postMessage, viewState } from './vscode'
/** 滚动时最多这么久记一次位置 */ /** 滚动时最多这么久记一次位置 */
const RECORD_INTERVAL_MS = 100 const RECORD_INTERVAL_MS = 100
/** 页面上每块(BlockCard 带 data-line)的位置,按页面上的先后。 */ /** 顶部钉住的筛选栏的高度:视口里露出块的部分从它下面开始,记位置、滚过去都按这部分算。 */
export function pinnedHeight(): number {
return document.querySelector<HTMLElement>('.cr-filter-bar')?.offsetHeight ?? 0
}
/** 页面上每块(BlockCard 带 data-line)的位置,按页面上的先后;被筛掉的块不在页面上,不算。 */
export function measureBlocks(): BlockBox[] { export function measureBlocks(): BlockBox[] {
const y = window.scrollY const y = window.scrollY
return Array.from(document.querySelectorAll<HTMLElement>('.cr-block[data-line]'), (el) => { return Array.from(document.querySelectorAll<HTMLElement>('.cr-block[data-line]'), (el) => {
@@ -18,7 +23,7 @@ export function measureBlocks(): BlockBox[] {
/** 记下视口顶部那块;它的起始行变了才报给扩展端。 */ /** 记下视口顶部那块;它的起始行变了才报给扩展端。 */
function record(reportedLine: { current: number | undefined }): void { function record(reportedLine: { current: number | undefined }): void {
const anchor = anchorAt(measureBlocks(), window.scrollY) const anchor = anchorAt(measureBlocks(), window.scrollY + pinnedHeight())
if (!anchor) if (!anchor)
return return
viewState.update({ scroll: anchor }) viewState.update({ scroll: anchor })
@@ -46,7 +51,7 @@ export function useScrollPosition(doc: DocumentMessage | null): void {
const saved = viewState.get().scroll const saved = viewState.get().scroll
const top = doc.reveal === undefined && saved ? scrollTopFor(measureBlocks(), saved) : undefined const top = doc.reveal === undefined && saved ? scrollTopFor(measureBlocks(), saved) : undefined
if (top !== undefined) if (top !== undefined)
window.scrollTo(0, top) window.scrollTo(0, top - pinnedHeight())
record(reportedLine) record(reportedLine)
}, [doc]) }, [doc])
@@ -15,7 +15,7 @@ function memoryStorage(initial?: unknown): ViewStateStorage {
} }
function block(id: string, kind: BlockKind, children: BlockView[] = []): BlockView { 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: [] }
} }
/** /**