From 1dfc0c7b1e63e232b73039a050d2826c4f880543 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sun, 27 Sep 2026 20:02:32 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E7=9A=84=E5=9D=97=E7=A7=8D=E7=B1=BB=E5=8F=AA?= =?UTF-8?q?=E7=94=BB=E5=9B=BE=E6=A0=87=EF=BC=88lucide=EF=BC=8C=E7=A7=8D?= =?UTF-8?q?=E7=B1=BB=E8=89=B2=EF=BC=89=EF=BC=8C=E7=A7=8D=E7=B1=BB=E5=90=8D?= =?UTF-8?q?=E6=94=BE=E8=BF=9B=E9=BC=A0=E6=A0=87=E5=81=9C=E4=B8=8A=E5=8E=BB?= =?UTF-8?q?=E7=9A=84=E6=8F=90=E7=A4=BA=E5=92=8C=E8=AF=BB=E5=B1=8F=E6=96=87?= =?UTF-8?q?=E5=AD=97=EF=BC=9B=E5=BA=93=E8=A7=84=E5=88=99=E7=BB=99=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E7=9A=84=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 48 +++++++++------ vscode/src/codeReader/attributes.ts | 4 +- vscode/src/codeReader/blockView.ts | 2 +- vscode/src/codeReader/contract.ts | 13 ++-- .../codeReader/languages/exceptions.test.ts | 2 +- vscode/src/codeReader/libraryRules.test.ts | 10 ++-- vscode/src/codeReader/rules/celery.test.ts | 4 +- vscode/src/codeReader/rules/celery.ts | 6 +- vscode/src/codeReader/rules/dataclass.test.ts | 4 +- vscode/src/codeReader/rules/dataclass.ts | 3 +- .../src/codeReader/rules/sqlalchemy.test.ts | 2 +- vscode/src/codeReader/rules/sqlalchemy.ts | 3 +- .../webview-ui/src/codeReader/BlockTree.tsx | 9 ++- .../webview-ui/src/codeReader/SegmentText.tsx | 6 +- .../webview-ui/src/codeReader/codeReader.css | 5 +- .../src/codeReader/kindIcons.test.tsx | 59 +++++++++++++++++++ .../webview-ui/src/codeReader/kindIcons.tsx | 51 ++++++++++++++++ vscode/webview-ui/src/codeReader/types.ts | 6 +- .../src/codeReader/viewState.test.ts | 4 +- 19 files changed, 185 insertions(+), 56 deletions(-) create mode 100644 vscode/webview-ui/src/codeReader/kindIcons.test.tsx create mode 100644 vscode/webview-ui/src/codeReader/kindIcons.tsx diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 3e9fcc1..c4519f9 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -18,6 +18,7 @@ | 扩展方式 | 写代码注册:语言插件、翻译插件都实现约定接口,通过本插件 `activate()` 导出的 API 注册;内置实现也走同一个 API | | 多种翻译怎么选 | 预览顶部筛选栏最右边的下拉框,整个文件一起换,按语言记住选择;只有一种时不显示 | | 块筛选 | 预览顶部钉住一条筛选栏,按块的属性(种类、访问级别、异步……)筛;属性由语言插件按和语言无关的 id 报(通用的由宿主从 facts 推),名字由翻译插件起,webview 不认识任何语言;筛选整个工作区共用、记住 | +| 块的种类 | 块左边只画种类图标(lucide 图标,和一行字等高,种类色),种类名是鼠标停上去的提示和读屏念的文字,不再写字;内置种类的图标、颜色、名字由插件定(图标在 webview 一处、颜色在 CSS、名字由翻译插件起),库规则的种类由规则给(`KindBadge` 的名字、颜色、图标)。见「预览(代码块树)」 | | 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 | | 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 | | 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)、类块、枚举块、异常块和库规则的卡片默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 | @@ -113,14 +114,26 @@ - 每一句是一块:导入、函数、类、枚举、异常、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。 - 嵌套的块放在父块下面、往右缩进;每一层左边一条竖线(颜色 `--vscode-editorIndentGuide-background`)。 -- 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签(字取翻译插件给 `kind` 值起的名字,见「筛选」)、内容、右上角一排图标按钮。 -- 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示(`title`)和 `aria-label` 里;整排是 `role="group"`(`aria-label="块操作"`)。先是「问 AI」(sparkles 图标,点开 / 收起块下面的问答区,展开时带按下的底色、`aria-expanded`;文件不在工作区文件夹里时没有,见「问 AI」),再是「编辑 / 预览」切换:预览时铅笔「编辑」,编辑时眼睛「预览」、带按下的底色(`--vscode-toolbar-activeBackground`)。按钮宽 24px、高 20px(和种类标签同高),图标 14px、`descriptionForeground` 色;悬停 `--vscode-toolbar-hoverBackground`,键盘聚焦描 1px `--vscode-focusBorder`;Tab 能走到,回车 / 空格切换。 -- 种类标签和底色按种类区分(颜色见下表);注释块虚线框。 +- 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类图标、内容、右上角一排图标按钮。 +- 种类图标(原型 `kindBadge`、画板「Prototype / 块种类图标」):块左边只画图标、不带字,和一行字等高(20px 的框,图形 16px,lucide 的线宽 2.2),颜色是种类色;种类的名字(翻译插件给 `kind` 值起的,见「筛选」;库规则的种类是规则给的 `badge.label`)写在悬停提示(`title`,同块右上角按钮的悬停提示)和 `aria-label` 里,`role="img"`。和折叠箭头、内容同一行顶对齐。每种内置种类由插件定图标、颜色、名字:图标是 lucide 图标名,只在 webview 的一处(`kindIcons.tsx` 的 `KIND_ICONS`);颜色在 `codeReader.css` 的 `--cr-kind-*`(见「颜色」);名字由翻译插件给。同一类语义共用一个颜色,图标区分具体是哪种: + + | 种类 | 图标 | 种类 | 图标 | 种类 | 图标 | + | --- | --- | --- | --- | --- | --- | + | import 导入 | `import` | field 字段 | `tag` | try 尝试 | `shield` | + | function 函数 | `square-function` | const 常量 | `lock` | catch 捕获 | `shield-alert` | + | class 类 | `box` | variable 变量 | `variable` | finally 最终 | `shield-check` | + | type 类型 | `braces` | if 条件 | `split` | return 返回 | `corner-down-left` | + | enum 枚举 | `list-ordered` | else 否则 | `corner-down-right` | statement 语句 | `code` | + | exception 异常 | `triangle-alert` | loop 循环 | `repeat` | comment 注释 | `message-square-text` | + + 库规则的种类用规则给的图标(`KindBadge.icon`):SQLAlchemy 数据表 `database`、数据类 `package`、Celery 应用 `server-cog`、定时任务 `calendar-clock`。webview 按 lucide 图标名画(`lucide-react` 的全套图标),认不出的名字画虚线方块(`square-dashed`)。 +- 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示(`title`)和 `aria-label` 里;整排是 `role="group"`(`aria-label="块操作"`)。先是「问 AI」(sparkles 图标,点开 / 收起块下面的问答区,展开时带按下的底色、`aria-expanded`;文件不在工作区文件夹里时没有,见「问 AI」),再是「编辑 / 预览」切换:预览时铅笔「编辑」,编辑时眼睛「预览」、带按下的底色(`--vscode-toolbar-activeBackground`)。按钮宽 24px、高 20px(和种类图标同高),图标 14px、`descriptionForeground` 色;悬停 `--vscode-toolbar-hoverBackground`,键盘聚焦描 1px `--vscode-focusBorder`;Tab 能走到,回车 / 空格切换。 +- 种类图标和底色按种类区分(颜色见「颜色」);注释块虚线框。 - 内容两种状态,每块单独切: - 预览:函数、类、枚举、异常、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文的块带说明(Python 字段的属性 docstring)时,说明按 Markdown 排在译文下面。 - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮换成眼睛「预览」;Esc 也回到预览。 - 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里;Python 常量、变量、字段的属性 docstring 算在它那一块里(中间隔着的空行也是)。 -- 库规则认领的块(数据表卡片等,见「库规则」):种类标签的字和颜色由规则给,内容是规则给的属性表;它的编辑范围是原块整条语句(类就是整个类),「编辑」时整段一起改。规则没并进卡片的子块(方法、别的成员)不管写在类主体哪儿(两个字段中间也算),都照源码顺序是卡片的子块,各自的「编辑」照旧只改自己那几行。 +- 库规则认领的块(数据表卡片等,见「库规则」):种类的图标、颜色和名字由规则给,内容是规则给的属性表;它的编辑范围是原块整条语句(类就是整个类),「编辑」时整段一起改。规则没并进卡片的子块(方法、别的成员)不管写在类主体哪儿(两个字段中间也算),都照源码顺序是卡片的子块,各自的「编辑」照旧只改自己那几行。 - 同一行不会同时在两处能改(能同时编辑的几段互不重叠):一块在编辑时,编辑范围和它重叠的子孙块不画(卡片编辑时它下面的子块全收起来,一行写完的 `if x: return 1` 编辑时同一行的子块也不画),回到预览就照原样出来;点一块的「编辑」时,编辑范围和它重叠的别的块退出编辑。跳到定义的目标块正被盖住时闪盖住它的那块。 - 折叠:函数、类、枚举、异常块和库规则的卡片打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 - 顶部筛选栏:见「筛选」;翻译下拉框(多于一种翻译时才显示)也在栏里。 @@ -132,7 +145,7 @@ - 三层分工,webview 不认识任何语言: 1. 语言插件给每块报和语言无关的属性「属性 = 值」,都是稳定的英文 id,不放显示文字。通用词表由契约定(`COMMON_ATTRIBUTES`):`kind`(块的种类)、`access`(`public` / `private` / `protected`,语言可以加自己的值)、`async`、`abstract`、`typeOnly`(`'true'` / `'false'`)。通用属性由宿主从块的 kind 和 facts 推出来(`FunctionFacts.access` / `async`、`ClassFacts.access` / `abstract`、`EnumFacts.access`、`ExceptionFacts.access`、`ImportFacts.typeOnly`、`VariableFacts.access`),只有这一条路,语言插件不用另报;facts 里没填的(语言没有这个概念,例如 JavaScript 没有抽象类、仅类型导入)块就没有这个属性,不是填「否」。语言自己的属性(Rust 的 `unsafe = 'true'`)、facts 表达不了的通用属性值(Kotlin 的 `access = 'internal'`)填在块的 `attributes` 里,和推出来的合在一起,同一属性以它为准;`kind` 总是块的种类。 - 2. 翻译插件给属性和值起名(`Translator.attributeNames`:`access` → 访问级别、`public` → 公共、`kind` 的 `function` → 函数、布尔值 → 是 / 否),和属性表里的属性名、值是同一套叫法(中文直译里属性表的「访问级别」「异步」「抽象」「仅类型」行和筛选共用一份表);种类标签上的字也取它。没起名的显示 id。 + 2. 翻译插件给属性和值起名(`Translator.attributeNames`:`access` → 访问级别、`public` → 公共、`kind` 的 `function` → 函数、布尔值 → 是 / 否),和属性表里的属性名、值是同一套叫法(中文直译里属性表的「访问级别」「异步」「抽象」「仅类型」行和筛选共用一份表);种类图标的悬停提示也取它。没起名的显示 id。 3. webview 只按属性 id / 值 id 筛,显示翻译插件给的名字。 - 下拉框:这份文件里有两种以上值的属性各一个(只有一种值的不给——筛它等于按种类筛);只有一种值、但这个值正被去掉的也给,不然藏起来的块在栏上找不到原因。排列:通用属性按契约的顺序(种类、访问级别、异步、抽象、仅类型),语言自己的属性按在文件里第一次出现的先后排在后面。下拉菜单顶上一行「全选 · 取消全选」,分隔线下面是勾选列表,列出这份文件里出现的值(按第一次出现的先后)和各有几块(所有层的块都算,例如「私有 3 / 公共 3」)。 - 全选 / 取消全选:把这份文件里这个属性出现的值一起勾上 / 去掉,和逐个勾一样记下来;别的文件才有的值记着的去掉状态不动(筛选整个工作区共用)。取消全选后有这个属性的块全藏起来,没有这个属性的照常显示;菜单不关,可以接着勾。 @@ -147,7 +160,7 @@ ### 属性表(函数、类、枚举、异常、导入、常量、变量块) -卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」和值是字典 / 对象字面量的「值」「初始值」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」「字典 N 项」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`);输入参数例外,是一张表格(见下)。字典 / 对象的子行分键 / 值两列,键列宽 150px(编辑区窄时按 28% 收),值列占剩下的。子行下面还能再展开一层(嵌套的字典 / 对象):带下一层的子行第一列前面有折叠箭头(点箭头或第一列切换,默认展开),同一层里其余子行在同样的位置留空、第一列对齐;下一层同样往右缩进 5px、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和导入项的子行一样)。 +卡片外观(折叠箭头、种类图标、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」和值是字典 / 对象字面量的「值」「初始值」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」「字典 N 项」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`);输入参数例外,是一张表格(见下)。字典 / 对象的子行分键 / 值两列,键列宽 150px(编辑区窄时按 28% 收),值列占剩下的。子行下面还能再展开一层(嵌套的字典 / 对象):带下一层的子行第一列前面有折叠箭头(点箭头或第一列切换,默认展开),同一层里其余子行在同样的位置留空、第一列对齐;下一层同样往右缩进 5px、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和导入项的子行一样)。 输入参数的表格(原型 `paramRows`):往右缩进 5px,第一行是表头「名称 | 类型 | 默认值」,下面每个参数一行。列宽按整列的内容定,webview 不认是哪种表格(库规则的字段表同样排,见 `tableTemplate`):整张表格一个网格、各行借用它的列,前几列宽裕时放到整列不换行,最后一列(默认值、说明)分剩下的、至少留 `min(20%, 8em)`;放不下时先让最后一列换行,编辑区再窄前几列平分剩下的宽度、在格子里换行(长名字从中间断开),不撑出卡片、不藏掉哪一列。格子左右留 8px、上下 2px,长类型、长默认值在格子里换行,不挤掉别的列。外框 1px、圆角 4px,格子之间、行之间 1px 分隔线,颜色都是主题的表格分隔色(`--vscode-tree-tableColumnsBorder`,主题没给时退到 `--vscode-editorWidget-border`);表头垫主题的表头底色(`--vscode-keybindingTable-headerBackground`,比编辑区深一档或浅一档),列名界面字体 12px、`descriptionForeground` 色。没有默认值的格子留空。折叠「输入参数」那一行时整张表收起;子行 id、跳到定义闪「它那个参数」、高亮名字的各处出现都和列表一样,闪的是表格里那一行(行边框和相邻行共用,改在行里面描一圈聚焦色)。哪些子行排成表格由翻译插件定:给了列名(`PropertyRow.columns`)的排成表格,没给的(导入项、字典 / 对象的各项)照旧是列表,webview 只看有没有列名。 @@ -225,7 +238,7 @@ 原型画板「遍历 · 推导式」。Python 的列表 / 集合 / 字典推导式、生成器表达式(含 `async for`、几节 `for`、`if`、套着的推导式)不管出现在哪(一行译文、属性表的值 / 初始值 / 默认值、字典项的值),都原地换成一个小框: -- 框:嵌在前后文字里(`display: inline-block`),框头是循环图标(lucide `Repeat`,12px)和结果类型,后面是重排后的译文。边框、底色、框头字的颜色都取「循环」块的种类色(`--cr-kind-loop`:边框 45%、底色 14%,圆角 4px,左右留 5px);框头界面字体 11px、500 字重。上下边框算在 20px 行高里,框和前后文字基线对齐、不把一行撑高;框里套的框行高再减 2px。整个框放不下时挪到下一行,比一行还长时占满一行、在框里换行。 +- 框:嵌在前后文字里(`display: inline-block`),框头是「循环」块的种类图标(`KIND_ICONS.loop`,lucide `repeat`,12px)和结果类型,后面是重排后的译文。边框、底色、框头字的颜色都取「循环」块的种类色(`--cr-kind-loop`:边框 45%、底色 14%,圆角 4px,左右留 5px);框头界面字体 11px、500 字重。上下边框算在 20px 行高里,框和前后文字基线对齐、不把一行撑高;框里套的框行高再减 2px。整个框放不下时挪到下一行,比一行还长时占满一行、在框里换行。 - 结果类型:生成器 / 列表 / 集合 / 字典(`[…]` 列表、`{…}` 集合、`{k: v …}` 字典、`(…)` 生成器)。 - 框里按读的顺序重排:每节 `for` 写「遍历 <来源> 的每个 <变量>」,紧挨着的几节 `for` 用顿号接着写(「遍历 xss 的每个 xs、xs 的每个 x」),`async for` 写「异步遍历」(同步、异步换了就另起一句「,异步遍历 …」),`if` 在它所在的位置写「,仅当 <条件>」(后面再有 `for` 就另起一句「,遍历 …」),最后「,取 <值>」,字典是「<键>: <值>」。来源、变量、条件、值照常直译,里面还有推导式就是框里的框。「遍历」「的每个」「仅当」「取」「异步遍历」按关键字着色,逗号、顿号、空格按普通文字。 - 括号:框代替了推导式自己的方括号 / 花括号 / 圆括号,不再写出来;Python 调用唯一的参数是生成器时只写一对括号(`f(x for x in y)`),这对括号属于调用,照常写在框外,紧贴着框:`"".join(` 框 `)`。生成器是几个参数之一时(`f(a, (x for x in y))`)括号是它自己的,不写。 @@ -640,7 +653,7 @@ export interface Translator { describe: (block: FactBlock, document: SourceDocument) => PropertyRow[] // 函数、类、枚举、异常、导入、常量、变量块 → 属性表 expression: (node: Node, document: SourceDocument) => Inline[] // 一个表达式 / 类型节点整个翻成一行译文,译法同属性表的值;库规则借它 doc: (node: Node, document: SourceDocument) => Markdown | undefined // 说明文字(注释、docstring、当说明用的字符串)→ Markdown 原文,同属性表的「说明」 - attributeNames?: AttributeNames // 筛选属性和值叫什么,和属性表的叫法一致;筛选栏、种类标签用它 + attributeNames?: AttributeNames // 筛选属性和值叫什么,和属性表的叫法一致;筛选栏、种类图标的悬停提示用它 } // ── 库规则(见「库规则」一节)── @@ -648,16 +661,16 @@ export interface Translator { /** 一种颜色在暗色、亮色主题下各用哪个;高对比主题跟着暗 / 亮走。 */ export interface ThemeColor { dark: string, light: string } -/** 种类标签:文字和颜色。 */ -export interface KindBadge { label: string, color: ThemeColor } +/** 库规则的种类:块左边只画图标(种类色),label 是悬停提示和读屏念的名字,筛选栏里也叫它;icon 是 lucide 图标名(kebab-case,如 database)。 */ +export interface KindBadge { label: string, color: ThemeColor, icon: string } /** 库规则认领一块后给出的替身,下面挂着原块留下的子块:规则自己的卡片,或认成的现成属性表块。 */ export type RuleBlock = RuleCard | RuleFacts -/** 规则自己的卡片:一张属性表,种类标签、每一行都由规则给。 */ +/** 规则自己的卡片:一张属性表,种类的图标、颜色、名字和每一行都由规则给。 */ export interface RuleCard { kind: string // 种类 id,「规则 id.名字」(sqlalchemy.table):筛选的 kind 值,要稳定 - badge: KindBadge // 种类标签;筛选栏里这个种类也叫这个名字 + badge: KindBadge // 种类的名字、颜色、图标;筛选栏里这个种类也叫这个名字 rows: PropertyRow[] defines?: SourceRange // 块自己的名字(类名):跳到的定义是它时闪整块 attributes?: BlockAttributes // 规则自己的筛选属性,叠在宿主从原块推出的上面;kind 填了不算 @@ -741,7 +754,7 @@ export interface CodeReaderApi { - 替身(`RuleBlock`)两种,按有没有 `as` 分: - 认成现成的属性表块(`RuleFacts`):`as` 是种类和 facts(`BlockFacts`,例如 `{ kind: 'enum', facts: EnumFacts }`),`absorbs` 同卡片。宿主把原块换成这种块(节点不变,头部是原块整条语句,子块是留下的),此后和语言插件报的这种块完全一样:翻译插件的 `describe` 排属性表、种类名取翻译插件的、颜色取这个种类的、筛选属性从 facts 推、块自己的名字从 facts 取。规则不给用词,也不给标签和颜色。只要检查并进来的是不是这块的子块。 - 规则自己的卡片(`RuleCard`),下面几条都说的是它。 -- 卡片必须给:`kind`(写成「规则 id.名字」,宿主核对前缀;筛选记住的就是它,要稳定)、`badge`(种类标签的字和暗 / 亮两种颜色)、`rows`(用现成的 `PropertyRow`:属性名 + 着色的值或 Markdown,表格就是带 `columns` 的子行)、`absorbs`(并进来的子块)。可以给:`defines`(块自己的名字,跳到定义是它时闪整块)、`attributes`(自己的筛选属性);表格子行填 `PropertyItem.defines`(字段名),跳到这个定义时只闪那一行。 +- 卡片必须给:`kind`(写成「规则 id.名字」,宿主核对前缀;筛选记住的就是它,要稳定)、`badge`(种类的名字、暗 / 亮两种颜色和 lucide 图标名)、`rows`(用现成的 `PropertyRow`:属性名 + 着色的值或 Markdown,表格就是带 `columns` 的子行)、`absorbs`(并进来的子块)。可以给:`defines`(块自己的名字,跳到定义是它时闪整块)、`attributes`(自己的筛选属性);表格子行填 `PropertyItem.defines`(字段名),跳到这个定义时只闪那一行。 - 只经契约:规则拿到的是块(kind、node、header、children、facts)和 `RuleContext`;源码里的名字照抄时自己带下标(`Segment.offset`),表达式、类型交给 `context.translate`(当前翻译插件的 `expression`,名字带下标,⌘ + 点击、高亮各处出现照常),说明文字交给 `context.doc`(当前翻译插件的 `doc`,和别的「说明」一样按 Markdown 显示)。规则可以读自己那门语言的语法树(规则声明了 `languageIds`),但不 import 语言插件、翻译插件和宿主模块(`rules/contractOnly.test.ts` 检查 `rules/` 下非测试文件只 import `../contract` 和同目录的文件)。 - 用词归规则:属性名、表头、种类名、类型译名都在规则自己的词表里,按 `context.displayLanguage`(翻译插件的 `displayLanguage`)挑,没有这种语言的词表就用规则默认的那份;加一种显示语言就是给规则加一份词表。webview 只画结构,不认识「数据表」这些字。 @@ -751,7 +764,7 @@ export interface CodeReaderApi { - 认领:宿主从顶层往下每块问一遍启用的规则,按注册顺序,第一个认领的算(先注册的优先);被认领的块留下的子块照样问。 - 出错:规则抛错、或替身不合约定(种类 id 没带规则 id 前缀、并进来的不是这块的子块、属性表里的引用标的不是自己 / 规则没给 `resolve` / 引用的段没带下标)时,宿主记一条警告日志(输出面板「Superpowers」),当它没认、接着问下一个规则;都没认这块照常显示。预览不会因为规则出错而失败。 - 并进来的子块:不再单独成块,由规则挑(`absorbs`),宿主不替规则多并。没并进来的子块(方法、规则不讲的注释)不管写在哪(字段中间也算),都照源码顺序是替身的子块,预览里照常看得见。替身的编辑范围是原块整条语句(从头部开始,到原块连同各子块的最后一行,类就是整个类),盖住留下的子块的行:替身编辑时预览不画这些子块,同一行不会同时在两处能改(见「预览(代码块树)」)。内置规则并成员时连同紧挨在它前面的注释块一起并(讲的是这个成员),讲方法的注释、最后面的注释照常留下。 -- 卡片在预览里:种类标签的字、颜色取 `badge`(暗色、亮色主题各取一个),内容是 `rows`;筛选的 `kind` 是替身的种类 id,其余属性从原块推出(原来的类是公共的,数据表也是公共的),叠上规则给的;筛选栏里这个种类叫 `badge.label`;和类块一样默认收起子块。 +- 卡片在预览里:种类图标、颜色取 `badge`(`icon`;颜色暗色、亮色主题各取一个),悬停提示是 `badge.label`,内容是 `rows`;筛选的 `kind` 是替身的种类 id,其余属性从原块推出(原来的类是公共的,数据表也是公共的),叠上规则给的;筛选栏里这个种类叫 `badge.label`;和类块一样默认收起子块。 ### 规则自己查定义 @@ -1127,7 +1140,7 @@ interface BlockViewBase { codeTokens: Segment[] // 源码着色(拼起来等于 code) defines?: SourceRange // 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取(库规则的块取 RuleBlock.defines);跳到的定义是它时闪整块 attributes: Record // 筛选属性(含 kind),见「筛选」 - badge?: KindBadge // 库规则的卡片才有:种类标签的字和颜色;webview 按它上色、默认折叠 + badge?: KindBadge // 库规则的卡片才有:种类的名字、颜色、图标;webview 按它画图标、上色、默认折叠 children: BlockView[] } @@ -1184,7 +1197,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | 属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、字典 N 项、对象 N 项、展开、默认导出、全部、整个模块、匿名按 hint;输入参数表格的列名是界面文字(`descriptionForeground`),不是着色的词。推导式框里的「遍历」「的每个」「仅当」「取」「异步遍历」按 keyword,框头按「循环」块的种类色。父类名(TypeScript `extends` 后面、JavaScript `class_heritage` 里、Python 类名后面括号里直接写的名字)源码着色和属性表里都按 type。 -种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;enum 枚举 #b180d7(亮色 #652d90);exception 异常 #f14c4c(亮色 #cd3131,和尝试、捕获、最终同一色系,更红一些);field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c,数据类 #ef7fb0 / #b3306c。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 +种类(种类图标的颜色 + 块底 7% + 边框 35%;亮色主题同色系;图标见「预览(代码块树)」):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;enum 枚举 #b180d7(亮色 #652d90);exception 异常 #f14c4c(亮色 #cd3131,和尝试、捕获、最终同一色系,更红一些);field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c,数据类 #ef7fb0 / #b3306c。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 ## 代码位置 @@ -1254,7 +1267,7 @@ vscode/src/codeReader/ editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights) editor/occurrenceNavigation.ts 跳到下一处 / 上一处高亮:命令、上下文键 index.ts registerCodeReader(context): CodeReaderApi -vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,中文名换字在 displayNames.ts,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts) +vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,块的种类图标(内置种类 → lucide 图标名的唯一一处、按名字画图标)在 kindIcons.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,中文名换字在 displayNames.ts,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts) ``` ## 测试 @@ -1301,6 +1314,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 工作副本(`test/git/reviewWorktree.test.ts`,真的 git:bare 的 origin 上推 `refs/pull/7/head`):拉 PR 的提交建不挂分支的 worktree(父目录不在也建、本机不多出分支),目录手删后同一路径能再建;快进:PR 往前走了且干净 → 快进,已经最新 / 本地在它之上 → 不动,只有未跟踪的文件照样快进,有没提交的改动、本地另有提交 → 不动且改动留着,目录不是工作副本的根 → 报错、外面的仓库不动。 - 复制出什么(`explorer/copyText.test.ts`):文件、目录的名字 / 相对路径 / 绝对路径;不带后缀只去最后一个扩展名(`a.test.ts`、`archive.tar.gz`),`.env` 原样、`.env.local` → `.env`、没有扩展名的原样;相对路径总用 `/`,绝对路径照本机写法(Windows 反斜杠)。 - 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、`title` 和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记;给了「问 AI」时它排在前面、只有图标、问答区展开时带按下标记和 `aria-expanded`。 +- 块的种类图标(webview `kindIcons.test.tsx`,渲染成 HTML):内置种类的图标和原型一一对应、都是 lucide 里有的图标;图标名按 kebab-case 认,认不出的画虚线方块;只有图标、没有字,`role="img"`,`title` 和 `aria-label` 是种类名,20px 的框、16px 的图形;库规则的卡片用规则给的图标。各库规则的测试核对 `badge` 的名字、颜色、图标。 - 问 AI: - 位置记号(`qa/anchor.test.ts`,真实的 Python 解析):有名字 / 没名字的块的记号、同一层重复的编号、两个函数里一样的条件互不影响;上面插几行、改空白记号不变;改别的函数的名字只动它和它里面的块;没名字的块自己的源码改了记号变、有名字的块改签名不变。 - 存法(`qa/qaMap.test.ts`):记一次、删一次、删光不挂 / 不记;文件缺失、解析失败、不是对象当没有;认不出的块和问答跳过、没会话 id 的照认;再问时接着这块最近那条的会话(删了最近的接着剩下里最近的,没问过、没记会话 id 的重新开);用哪个 profile 答的跟着这条存、读得回来,没有的、空的、不是字符串的当没有;手改的顺序、重复的块读进来整理好;写出来排好序、两格缩进、读得回来、删光留 `{}`;改名、挪目录、删除跟着走。图片:文件名跟着这条存、读得回来,跑到别处的路径、不是字符串的名字跳过;删一次问答它的图片跟着删、别的留着;源文件删了图片跟着删,改名、挪目录图片不动。 diff --git a/vscode/src/codeReader/attributes.ts b/vscode/src/codeReader/attributes.ts index d76d2ff..6f6868a 100644 --- a/vscode/src/codeReader/attributes.ts +++ b/vscode/src/codeReader/attributes.ts @@ -46,14 +46,14 @@ export function blockAttributes(block: CodeBlock): BlockAttributes { interface Attributed { attributes: BlockAttributes - /** 库规则认领的块的种类标签:这个种类在筛选栏里也叫它。 */ + /** 库规则认领的块的种类(名字、颜色、图标):这个种类在筛选栏里也叫它的名字。 */ badge?: { label: string } children: Attributed[] } /** * 整份文件(所有层的块)里出现过的属性,每个值有几块。通用属性按 COMMON_ATTRIBUTES 的顺序排在前面, - * 语言自己的属性按第一次出现的先后排在后面。显示名取翻译插件的 names,没给的用 id;库规则的种类用规则给的标签。 + * 语言自己的属性按第一次出现的先后排在后面。显示名取翻译插件的 names,没给的用 id;库规则的种类用规则给的名字(badge.label)。 */ export function fileAttributes(blocks: readonly Attributed[], names: AttributeNames | undefined): FileAttribute[] { const counts = new Map>() diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 43dbe3e..2f16dd0 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -26,7 +26,7 @@ interface BlockViewBase { defines?: SourceRange /** 筛选属性(含 kind),见 blockAttributes;库规则认领的块叠上规则给的,kind 是规则的种类 id。 */ attributes: BlockAttributes - /** 库规则的卡片才有:种类标签的文字和颜色(RuleCard.badge)。预览同类块一样默认收起它的子块。 */ + /** 库规则的卡片才有:种类的名字、颜色和图标(RuleCard.badge)。预览同类块一样默认收起它的子块。 */ badge?: KindBadge children: BlockView[] } diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 66df2c0..8c2744e 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -382,7 +382,7 @@ export interface SourceDocument { markers: SourceRange[] } -/** 筛选属性和值的显示名,筛选栏、种类标签用它;没给名字的显示 id。 */ +/** 筛选属性和值的显示名,筛选栏、块的种类(图标的悬停提示)用它;没给名字的显示 id。 */ export interface AttributeNames { /** 属性 id → 属性名,例如 access → 访问级别。 */ attributes: Record @@ -412,8 +412,11 @@ export interface Translator { /** 一种颜色在暗色、亮色主题下各用哪个(CSS 颜色值);高对比主题跟着暗 / 亮走。 */ export interface ThemeColor { dark: string, light: string } -/** 种类标签:文字和颜色。 */ -export interface KindBadge { label: string, color: ThemeColor } +/** + * 库规则的种类在预览里的样子:块左边只画图标(种类色),名字是悬停提示和读屏念的文字,筛选栏里这个种类也叫它。 + * icon 是 lucide 图标名(https://lucide.dev/icons 上的 kebab-case 名字,如 `database`)。 + */ +export interface KindBadge { label: string, color: ThemeColor, icon: string } /** * 库规则认领一块后给出的替身,预览里它代替原来那块,下面挂着原块留下的子块: @@ -421,11 +424,11 @@ export interface KindBadge { label: string, color: ThemeColor } */ export type RuleBlock = RuleCard | RuleFacts -/** 规则自己的卡片:一张属性表,种类标签、每一行都由规则给(用词归规则)。 */ +/** 规则自己的卡片:一张属性表,种类的图标、颜色、名字和每一行都由规则给(用词归规则)。 */ export interface RuleCard { /** 种类 id,写成「规则 id.名字」(sqlalchemy.table):筛选的 kind 属性值就是它,筛选记住的也是它,要稳定。 */ kind: string - /** 种类标签的文字和颜色:筛选栏里这个种类也叫这个名字。 */ + /** 种类的名字、颜色和图标:筛选栏里这个种类也叫这个名字。 */ badge: KindBadge rows: PropertyRow[] /** 块自己的名字(类名)在源码里的范围:跳到的定义就是它时闪整块。 */ diff --git a/vscode/src/codeReader/languages/exceptions.test.ts b/vscode/src/codeReader/languages/exceptions.test.ts index fe1e4ad..58c1f2a 100644 --- a/vscode/src/codeReader/languages/exceptions.test.ts +++ b/vscode/src/codeReader/languages/exceptions.test.ts @@ -296,7 +296,7 @@ describe('异常卡片(TypeScript)', () => { }) describe('异常卡片(宿主、筛选、中文名)', () => { - it('筛选:种类是 exception、叫「异常」,访问级别同原来的类;没有种类标签(badge),库规则不认', async () => { + it('筛选:种类是 exception、叫「异常」,访问级别同原来的类;没有规则给的种类(badge),库规则不认', async () => { const list = await pyViews(PY_CASES) expect(named(list, 'Base').attributes).toEqual({ kind: 'exception', access: 'public' }) expect(named(list, 'Base').badge).toBeUndefined() diff --git a/vscode/src/codeReader/libraryRules.test.ts b/vscode/src/codeReader/libraryRules.test.ts index 3f7f87b..87211c7 100644 --- a/vscode/src/codeReader/libraryRules.test.ts +++ b/vscode/src/codeReader/libraryRules.test.ts @@ -32,7 +32,7 @@ function cardRule(id: string, overrides: Partial = {}): LibraryRule { languageIds: ['python'], libraries: ['sqlalchemy'], claim: block => block.kind === 'class' - ? { kind: `${id}.card`, badge: { label: `${id} 卡片`, color: { dark: '#fff', light: '#000' } }, rows: [], absorbs: block.children.slice(0, 1), ...overrides } + ? { kind: `${id}.card`, badge: { label: `${id} 卡片`, color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows: [], absorbs: block.children.slice(0, 1), ...overrides } : undefined, } } @@ -91,14 +91,14 @@ describe('库规则:启用', () => { }) describe('库规则:认领', () => { - it('认领的块换成替身:种类、标签、编辑范围是整个类;规则没并进来的子块(夹在中间的注释、方法)照源码顺序留下', async () => { + it('认领的块换成替身:种类、badge、编辑范围是整个类;规则没并进来的子块(夹在中间的注释、方法)照源码顺序留下', async () => { const rule: LibraryRule = { ...cardRule('orm'), claim: (block) => { if (block.kind !== 'class') return undefined const [table, , column] = block.children - return { kind: 'orm.card', badge: { label: '卡片', color: { dark: '#fff', light: '#000' } }, rows: [{ label: '名称', value: [{ text: 'User', role: 'type' }] }], absorbs: [table, column] } + return { kind: 'orm.card', badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows: [{ label: '名称', value: [{ text: 'User', role: 'type' }] }], absorbs: [table, column] } }, } const [, card] = await views(MODEL, [rule]) @@ -128,7 +128,7 @@ describe('库规则:认领', () => { '', ].join('\n') const fields: LibraryRule = { ...cardRule('orm'), claim: block => block.kind === 'class' - ? { kind: 'orm.card', badge: { label: '卡片', color: { dark: '#fff', light: '#000' } }, rows: [], absorbs: block.children.filter(child => child.kind === 'variable') } + ? { kind: 'orm.card', badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows: [], absorbs: block.children.filter(child => child.kind === 'variable') } : undefined } const [, card] = await views(source, [fields]) expect(card.children.map(child => [child.kind, child.startLine, child.endLine])).toEqual([['function', 5, 5]]) @@ -198,7 +198,7 @@ describe('库规则:认领', () => { expect(reports).toEqual(['库规则 lib 给第 3 行的 class 块的替身不合约定,这块照常显示:要并进来的块不是这块的子块']) }) - it('筛选:规则的种类和别的种类一样计数,名字取规则给的标签', async () => { + it('筛选:规则的种类和别的种类一样计数,名字取规则给的名字(badge.label)', async () => { const list = await views(MODEL, [cardRule('orm')]) expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values).toEqual([ { id: 'import', name: '导入', count: 1 }, diff --git a/vscode/src/codeReader/rules/celery.test.ts b/vscode/src/codeReader/rules/celery.test.ts index d29f89f..f847ae7 100644 --- a/vscode/src/codeReader/rules/celery.test.ts +++ b/vscode/src/codeReader/rules/celery.test.ts @@ -46,7 +46,7 @@ function segmentsOf(rows: PropertyRow[]): Segment[] { describe('celery 库规则:应用', () => { it('luffy 的 celery_app:名称 / 应用名 / 消息队列 / 结果存储', async () => { const app = await card(SNAPSHOT, 'celery.app') - expect(app.badge).toEqual({ label: 'Celery 应用', color: { dark: '#98c379', light: '#4d7f17' } }) + expect(app.badge).toEqual({ label: 'Celery 应用', color: { dark: '#98c379', light: '#4d7f17' }, icon: 'server-cog' }) expect(propertyLines(properties(app))).toEqual([ '名称: celery_app', '应用名: "luffy_agent"', @@ -90,7 +90,7 @@ describe('celery 库规则:应用', () => { describe('celery 库规则:定时任务', () => { it('luffy 的 beat_schedule:应用、任务表,频率换算成人话,说明取注释,展开另起一行', async () => { const beat = await card(SNAPSHOT, 'celery.beat') - expect(beat.badge).toEqual({ label: '定时任务', color: { dark: '#56b6c2', light: '#127f8c' } }) + expect(beat.badge).toEqual({ label: '定时任务', color: { dark: '#56b6c2', light: '#127f8c' }, icon: 'calendar-clock' }) expect(propertyLines(properties(beat))).toEqual([ '应用: celery_app', '任务: 12 条,另有展开 1 处', diff --git a/vscode/src/codeReader/rules/celery.ts b/vscode/src/codeReader/rules/celery.ts index 0e44970..b45d6f9 100644 --- a/vscode/src/codeReader/rules/celery.ts +++ b/vscode/src/codeReader/rules/celery.ts @@ -21,6 +21,8 @@ const APP_KIND = 'celery.app' const BEAT_KIND = 'celery.beat' const APP_COLOR: ThemeColor = { dark: '#98c379', light: '#4d7f17' } const BEAT_COLOR: ThemeColor = { dark: '#56b6c2', light: '#127f8c' } +const APP_ICON = 'server-cog' +const BEAT_ICON = 'calendar-clock' /** `Celery(...)` 的位置参数依次是哪个参数(celery.app.base.Celery.__init__)。 */ const APP_PARAMETERS = ['main', 'loader', 'backend', 'amqp', 'events', 'log', 'control', 'set_as_current', 'tasks', 'broker', 'include', 'changes', 'config_source', 'fixups', 'task_cls', 'autofinalize', 'namespace', 'strict_typing'] @@ -500,7 +502,7 @@ function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: N items: entries.map(entry => entryItem(entry, withOptions, env)), }, ] - return { kind: BEAT_KIND, badge: { label: words.beatKind, color: BEAT_COLOR }, rows, absorbs: [] } + return { kind: BEAT_KIND, badge: { label: words.beatKind, color: BEAT_COLOR, icon: BEAT_ICON }, rows, absorbs: [] } } // ─── Celery 应用 ────────────────────────────────────────────────────────────── @@ -527,7 +529,7 @@ function appBlock(target: Node, call: Call, doc: Node | undefined, env: Env): Ru ] return { kind: APP_KIND, - badge: { label: words.appKind, color: APP_COLOR }, + badge: { label: words.appKind, color: APP_COLOR, icon: APP_ICON }, rows, ...(target.type === 'identifier' && { defines: range(target) }), absorbs: [], diff --git a/vscode/src/codeReader/rules/dataclass.test.ts b/vscode/src/codeReader/rules/dataclass.test.ts index b0156fb..2924324 100644 --- a/vscode/src/codeReader/rules/dataclass.test.ts +++ b/vscode/src/codeReader/rules/dataclass.test.ts @@ -128,11 +128,11 @@ describe('dataclasses 库规则(Python)', () => { expect(sandbox.children.map(child => [child.kind, child.startLine])).toEqual([['function', 52]]) }) - it('筛选:种类是 dataclasses.dataclass、叫「数据类」,访问级别同原来的类;种类标签有自己的颜色,默认收起子块', async () => { + it('筛选:种类是 dataclasses.dataclass、叫「数据类」,访问级别同原来的类;种类有自己的颜色和图标,默认收起子块', async () => { const list = await views(SNAPSHOT) const sandbox = named(list, 'SandboxAccess') expect(sandbox.attributes).toEqual({ kind: 'dataclasses.dataclass', access: 'public' }) - expect(sandbox.badge).toEqual({ label: '数据类', color: { dark: '#ef7fb0', light: '#b3306c' } }) + expect(sandbox.badge).toEqual({ label: '数据类', color: { dark: '#ef7fb0', light: '#b3306c' }, icon: 'package' }) expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values) .toContainEqual({ id: 'dataclasses.dataclass', name: '数据类', count: 4 }) }) diff --git a/vscode/src/codeReader/rules/dataclass.ts b/vscode/src/codeReader/rules/dataclass.ts index ee75ef7..5b9ce97 100644 --- a/vscode/src/codeReader/rules/dataclass.ts +++ b/vscode/src/codeReader/rules/dataclass.ts @@ -16,6 +16,7 @@ const LIBRARY = 'dataclasses' const TYPE_LIBRARIES = ['dataclasses', 'typing', 'typing_extensions'] const KIND = 'dataclasses.dataclass' const COLOR: ThemeColor = { dark: '#ef7fb0', light: '#b3306c' } +const ICON = 'package' /** `@dataclass(...)` 各参数不写时的值:写成这个值等于没写,「选项」里不讲。 */ const OPTION_DEFAULTS: Record = { @@ -261,7 +262,7 @@ function dataclassCard(block: ClassBlock, decorator: Node, call: Call | undefine ] return { kind: KIND, - badge: { label: words.kind, color: COLOR }, + badge: { label: words.kind, color: COLOR, icon: ICON }, rows, ...(facts.name && { defines: range(facts.name) }), absorbs: withLeadingComments(children, [...fields.map(field => field.block), ...markers]), diff --git a/vscode/src/codeReader/rules/sqlalchemy.test.ts b/vscode/src/codeReader/rules/sqlalchemy.test.ts index 7917064..44bbf35 100644 --- a/vscode/src/codeReader/rules/sqlalchemy.test.ts +++ b/vscode/src/codeReader/rules/sqlalchemy.test.ts @@ -47,7 +47,7 @@ describe('sqlAlchemy 库规则', () => { it('referralRelation:和原型一样的数据表卡片', async () => { const table = await model(SNAPSHOT, 'ReferralRelation') expect(table.kind).toBe('sqlalchemy.table') - expect(table.badge).toEqual({ label: '数据表', color: { dark: '#e8a860', light: '#a3590d' } }) + expect(table.badge).toEqual({ label: '数据表', color: { dark: '#e8a860', light: '#a3590d' }, icon: 'database' }) expect(propertyLines(properties(table))).toEqual([ '名称: ReferralRelation', '表名: referral_relations', diff --git a/vscode/src/codeReader/rules/sqlalchemy.ts b/vscode/src/codeReader/rules/sqlalchemy.ts index c0207ac..4216ad8 100644 --- a/vscode/src/codeReader/rules/sqlalchemy.ts +++ b/vscode/src/codeReader/rules/sqlalchemy.ts @@ -20,6 +20,7 @@ const SCHEMA_ITEMS = new Set(['ForeignKey', 'Sequence', 'Identity', 'Computed', const SIZE_KEYWORDS = ['length', 'precision', 'scale'] const TABLE_KIND = 'sqlalchemy.table' const TABLE_COLOR: ThemeColor = { dark: '#e8a860', light: '#a3590d' } +const TABLE_ICON = 'database' /** 一种显示语言的用词。 */ interface Words { @@ -435,7 +436,7 @@ function tableBlock(block: ClassBlock, model: Model, env: Env): RuleCard { const members = [model.table, model.args, ...model.columns.map(column => column.member), ...model.relationships.map(relationship => relationship.member)] return { kind: TABLE_KIND, - badge: { label: words.kind, color: TABLE_COLOR }, + badge: { label: words.kind, color: TABLE_COLOR, icon: TABLE_ICON }, rows, ...(facts.name && { defines: range(facts.name) }), absorbs: withLeadingComments(block.children, members.filter((member): member is Member => member !== undefined).map(member => member.block)), diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 11a420e..dbf55f5 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -7,6 +7,7 @@ import { Fragment } from 'react' import { AskThread } from './AskThread' import { BlockTools } from './BlockTools' import { CodeEditor } from './CodeEditor' +import { KindIcon } from './kindIcons' import { PropertyGrid } from './PropertyGrid' import { asksOf, historyOf } from './qa' import { TextPreview } from './TextPreview' @@ -22,7 +23,7 @@ export interface BlockTreeState { temporary: ReadonlySet /** 被正在编辑的祖先盖住、不画的块(它们的行在祖先的编辑范围里),见 coveredByEditing */ covered: ReadonlyMap - /** 种类标签上写什么:翻译插件给种类起的名字(库规则的种类是规则给的标签),没给就是 id */ + /** 种类叫什么(种类图标的悬停提示和 aria-label):翻译插件给种类起的名字(库规则的种类是规则给的 badge.label),没给就是 id */ kindName: (kind: string) => string /** 刚点了「编辑」、要把光标放进去的块 */ focusId: string | null @@ -123,7 +124,7 @@ function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState ) } -/** 一块:折叠箭头、种类标签、预览内容或源码、右上角的一排图标按钮(BlockTools)。 */ +/** 一块:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools)。 */ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement { const editing = tree.editing.has(block.id) const collapsed = tree.collapsed.has(block.id) @@ -150,9 +151,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ) : } - - {tree.kindName(block.kind)} - +
{editing ? ( diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index 1d5bbd6..437f41c 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -1,9 +1,9 @@ import type { LucideIcon } from 'lucide-react' import type { ReactElement } from 'react' import type { GroupStyle, Inline, SegmentGroup } from './types' -import { Repeat } from 'lucide-react' import { useContext } from 'react' import { DisplayNameContext, shownName } from './displayNames' +import { KIND_ICONS, lucideIcon } from './kindIcons' import { isCurrent, segmentOccurrence } from './occurrences' import { HighlightContext } from './useOccurrences' @@ -41,8 +41,8 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement ) } -/** 框的样式 → 框头图标;颜色在 codeReader.css 的 .cr-group[data-style] 里。 */ -const GROUP_ICONS: Record = { iteration: Repeat } +/** 框的样式 → 框头图标:遍历同「循环」块的图标;颜色在 codeReader.css 的 .cr-group[data-style] 里。 */ +const GROUP_ICONS: Record = { iteration: lucideIcon(KIND_ICONS.loop) } /** 框起来的一组词:嵌在前后文字里的小框,放不下时整个挪到下一行,比一行还长时在框里换行。 */ function GroupBox({ group }: { group: SegmentGroup }): ReactElement { diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 266a978..4c78d94 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -56,7 +56,7 @@ body.vscode-high-contrast-light { --cr-kind-comment: #008000; } -/* 种类颜色:同一类语义共用一个颜色,标签文字区分具体是哪种。卡片取自己的种类,子层取所属块的种类 */ +/* 种类颜色:同一类语义共用一个颜色,图标区分具体是哪种(kindIcons.tsx)。卡片取自己的种类,子层取所属块的种类 */ [data-kind="import"] { --kind: var(--cr-kind-import); } [data-kind="function"] { --kind: var(--cr-kind-function); } :is([data-kind="class"], [data-kind="type"]) { --kind: var(--cr-kind-type); } @@ -174,9 +174,8 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(- } } -.cr-badge { +.cr-kind { color: var(--kind); - background: color-mix(in srgb, var(--kind) 18%, transparent); } /* 译文和源码共用的文字排版;源码编辑时输入框和着色层都用它,两层逐字对齐 */ diff --git a/vscode/webview-ui/src/codeReader/kindIcons.test.tsx b/vscode/webview-ui/src/codeReader/kindIcons.test.tsx new file mode 100644 index 0000000..944605a --- /dev/null +++ b/vscode/webview-ui/src/codeReader/kindIcons.test.tsx @@ -0,0 +1,59 @@ +import type { BlockView } from './types' +import { Database, SquareDashed, SquareFunction } from 'lucide-react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { KIND_ICONS, KindIcon, lucideIcon } from './kindIcons' + +function render(block: Pick, name: string): string { + return renderToStaticMarkup() +} + +/** 标签之间的字:图标(svg)没有字 */ +const visibleText = (html: string): string => html.replace(/<[^>]*>/g, '').trim() + +describe('块的种类图标', () => { + it('内置种类各有一个图标,和原型的一一对应', () => { + expect(KIND_ICONS).toEqual({ + import: 'import', + function: 'square-function', + class: 'box', + type: 'braces', + enum: 'list-ordered', + exception: 'triangle-alert', + field: 'tag', + const: 'lock', + variable: 'variable', + if: 'split', + else: 'corner-down-right', + loop: 'repeat', + try: 'shield', + catch: 'shield-alert', + finally: 'shield-check', + return: 'corner-down-left', + statement: 'code', + comment: 'message-square-text', + }) + for (const name of Object.values(KIND_ICONS)) + expect(lucideIcon(name), name).not.toBe(SquareDashed) + }) + + it('lucide 图标名按 kebab-case 认;认不出的名字画虚线方块', () => { + expect(lucideIcon('square-function')).toBe(SquareFunction) + expect(lucideIcon('database')).toBe(Database) + expect(lucideIcon('no-such-icon')).toBe(SquareDashed) + }) + + it('只画图标不带字,名字在悬停提示和 aria-label 里;20px 的框、16px 的图形', () => { + const html = render({ kind: 'function' }, '函数') + expect(visibleText(html)).toBe('') + expect(html).toMatch(/^ { + const html = render({ kind: 'sqlalchemy.table', badge: { label: '数据表', color: { dark: '#e8a860', light: '#a3590d' }, icon: 'database' } }, '数据表') + expect(html).toContain('lucide-database') + expect(html).toContain('title="数据表" aria-label="数据表"') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/kindIcons.tsx b/vscode/webview-ui/src/codeReader/kindIcons.tsx new file mode 100644 index 0000000..3e5d0a2 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/kindIcons.tsx @@ -0,0 +1,51 @@ +import type { LucideIcon } from 'lucide-react' +import type { ReactElement } from 'react' +import type { BlockKind, BlockView } from './types' +import { icons, SquareDashed } from 'lucide-react' + +/** + * 内置种类的图标(lucide 图标名,和库规则给的 badge.icon 同一种写法):块左边只画图标,名字是悬停提示。 + * 同一类语义共用一个颜色(codeReader.css 的 --cr-kind-*:常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),图标区分具体是哪种。 + */ +export const KIND_ICONS: Record = { + import: 'import', + function: 'square-function', + class: 'box', + type: 'braces', + enum: 'list-ordered', + exception: 'triangle-alert', + field: 'tag', + const: 'lock', + variable: 'variable', + if: 'split', + else: 'corner-down-right', + loop: 'repeat', + try: 'shield', + catch: 'shield-alert', + finally: 'shield-check', + return: 'corner-down-left', + statement: 'code', + comment: 'message-square-text', +} + +const LUCIDE: Record = icons + +/** lucide 图标名(kebab-case,`square-function`)→ 图标组件;认不出的名字(规则写错了)画虚线方块。 */ +export function lucideIcon(name: string): LucideIcon { + return LUCIDE[name.replace(/(?:^|-)([a-z0-9])/g, (_, first: string) => first.toUpperCase())] ?? SquareDashed +} + +const BUILT_IN: Partial> = KIND_ICONS + +/** + * 块左边的种类:只画图标,和一行字等高(20px 的框、16px 的图形),颜色是种类色(--kind)。图标是库规则给的(badge.icon), + * 没有就按内置种类查 KIND_ICONS;名字(翻译插件给种类起的,库规则的种类是规则给的)写在悬停提示(title)和读屏念的 aria-label 里。 + */ +export function KindIcon({ block, name }: { block: Pick, name: string }): ReactElement { + const Icon = lucideIcon(block.badge?.icon ?? BUILT_IN[block.kind] ?? '') + return ( + + + + ) +} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 4b4c73c..b7d11dc 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -72,8 +72,8 @@ export interface PropertyRow extends Defines { /** 一种颜色在暗色、亮色主题下各用哪个(CSS 颜色值);高对比主题跟着暗 / 亮走 */ export interface ThemeColor { dark: string, light: string } -/** 种类标签:文字和颜色 */ -export interface KindBadge { label: string, color: ThemeColor } +/** 库规则的种类:名字(悬停提示、读屏念的文字)、颜色和 lucide 图标名;块左边只画图标 */ +export interface KindBadge { label: string, color: ThemeColor, icon: string } interface BlockViewBase { /** 在树里的位置,例如 '6.1.0' */ @@ -93,7 +93,7 @@ interface BlockViewBase { defines?: SourceRange /** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */ attributes: Record - /** 库规则认领的块才有:种类标签的文字和颜色,由规则给;和类块一样默认收起子块 */ + /** 库规则认领的块才有:种类的名字、颜色和图标,由规则给;和类块一样默认收起子块 */ badge?: KindBadge children: BlockView[] } diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index b798427..4be08fa 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -106,8 +106,8 @@ describe('眼下折叠着哪些', () => { expect([...collapsedIds(BLOCKS, {})].sort()).toEqual(['1', '1.0.0', '2', '2.0']) }) - it('没点过:库规则认领的块(带种类标签)和类块一样折叠,不看种类 id', () => { - const table: BlockView = { ...block('0', 'if', [block('0.0', 'return')]), kind: 'sqlalchemy.table', badge: { label: '数据表', color: { dark: '#e8a860', light: '#a3590d' } } } + it('没点过:库规则认领的块(带 badge)和类块一样折叠,不看种类 id', () => { + const table: BlockView = { ...block('0', 'if', [block('0.0', 'return')]), kind: 'sqlalchemy.table', badge: { label: '数据表', color: { dark: '#e8a860', light: '#a3590d' }, icon: 'database' } } expect([...collapsedIds([table, block('1', 'if')], {})]).toEqual(['0']) })