From 394cd2fc1c94678ad2113d12304376f426d9f064 Mon Sep 17 00:00:00 2001 From: cruldra Date: Fri, 2 Oct 2026 00:21:30 +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=E5=8D=A1=E7=89=87=E6=A8=AA=E6=8E=92=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E6=A6=82=E5=BF=B5=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 | 46 +++++++++---- vscode/src/codeReader/attributes.test.ts | 2 +- vscode/src/codeReader/blockView.test.ts | 44 +++++++++++- vscode/src/codeReader/blockView.ts | 39 ++++++++++- vscode/src/codeReader/contract.ts | 68 ++++++++++++++++++- vscode/src/codeReader/diff/viewDiff.test.ts | 2 +- vscode/src/codeReader/rules/dataclass.test.ts | 37 ++++++++++ vscode/src/codeReader/rules/dataclass.ts | 13 +++- .../src/codeReader/BlockTree.test.tsx | 16 ++++- .../webview-ui/src/codeReader/BlockTree.tsx | 4 +- .../webview-ui/src/codeReader/SegmentText.tsx | 8 +-- .../src/codeReader/diffView.test.ts | 2 +- .../webview-ui/src/codeReader/editing.test.ts | 2 +- .../webview-ui/src/codeReader/filter.test.ts | 2 +- .../src/codeReader/kindIcons.test.tsx | 50 ++++++++++---- .../webview-ui/src/codeReader/kindIcons.tsx | 65 +++++------------- .../webview-ui/src/codeReader/reveal.test.ts | 6 +- vscode/webview-ui/src/codeReader/types.ts | 19 +++++- .../src/codeReader/viewState.test.ts | 2 +- 19 files changed, 328 insertions(+), 99 deletions(-) diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 6aa7efe..4d4fb4e 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -18,7 +18,7 @@ | 扩展方式 | 写代码注册:语言插件、翻译插件都实现约定接口,通过本插件 `activate()` 导出的 API 注册;内置实现也走同一个 API | | 多种翻译怎么选 | 预览顶部筛选栏最右边的下拉框,整个文件一起换,按语言记住选择;只有一种时不显示 | | 块筛选 | 预览顶部钉住一条筛选栏,按块的属性(种类、访问级别、异步……)筛;属性由语言插件按和语言无关的 id 报(通用的由宿主从 facts 推),名字由翻译插件起,webview 不认识任何语言;筛选整个工作区共用、记住 | -| 块的种类 | 块左边只画种类图标(插件自己定义的一组 lucide 图标里的一个,和一行字等高,种类色),种类名是鼠标停上去的提示和读屏念的文字,不再写字;内置种类的图标、颜色、名字由插件定(图标在 webview 一处、颜色在 CSS、名字由翻译插件起),库规则的种类由规则给(`KindBadge` 的名字、颜色、图标)。见「预览(代码块树)」 | +| 块的种类 | 块左边横排这块认出来的概念图标(各 20px,间距紧)。默认是语言的种类(类、函数……):图标名由宿主按内置种类写进预览数据,名字由翻译插件起,颜色在 CSS;语言插件可以再声明概念,认到才出现。库规则认领时语言的概念留着,规则的种类追加在后面,规则还可以再带认出的概念。悬停各自的名字,读屏把名字连起来念。筛选的种类、卡片边框色仍是规则的种类。同一个概念 id 只留第一个。webview 只按图标名画。见「预览(代码块树)」 | | 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 | | 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 | | 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)、类块、枚举块、异常块和库规则的卡片默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 | @@ -123,8 +123,16 @@ - 每一句是一块:导入、函数、类、枚举、异常、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释;Shell 脚本另有解释器、选项、分情况、命令、管道(见「Shell 脚本」);Dockerfile 另有解析指令、阶段、执行、复制、设置、标签、健康检查、启动(见「Dockerfile」)。 - 嵌套的块放在父块下面、往右缩进;每一层左边一条竖线(颜色 `--vscode-editorIndentGuide-background`)。 -- 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类图标、内容、右上角一排图标按钮。 -- 种类图标(原型 `kindBadge`、画板「Prototype / 块种类图标」):块左边只画图标、不带字,和一行字等高(20px 的框,图形 16px,lucide 的线宽 2.2),颜色是种类色;种类的名字(翻译插件给 `kind` 值起的,见「筛选」;库规则的种类是规则给的 `badge.label`)写在悬停提示(见下「悬停提示」,同块右上角按钮)和 `aria-label` 里,`role="img"`。和折叠箭头、内容同一行顶对齐。每种内置种类由插件定图标、颜色、名字:图标从插件定义的一组图标里取(见下),内置种类 → 图标只在 webview 的一处(`kindIcons.tsx` 的 `KIND_ICONS`);颜色在 `codeReader.css` 的 `--cr-kind-*`(见「颜色」);名字由翻译插件给。同一类语义共用一个颜色,图标区分具体是哪种: +- 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、概念图标、内容、右上角一排图标按钮。 +- 概念图标(原型 `kindBadge`、画板「Prototype / 块种类图标」):块左边横排这块认出来的概念,各一个图标、不带字,和一行字等高(20px 的框,图形 16px,lucide 的线宽 2.2),间距紧。每个图标悬停自己的名字(`data-tooltip`,见下「悬停提示」);整排是一个 `role="img"`,读屏把名字用顿号连起来念。和折叠箭头、内容同一行顶对齐。webview 不自己用种类查图标:图标名由宿主放进预览数据的概念列表(`BlockView.concepts`),webview 只按名字画(`kindIcons.tsx` 的 `KIND_ICON_COMPONENTS`)。 + + 一个概念(`BlockConcept`):稳定 id、悬停名、图标名(只能从 `KIND_ICON_NAMES` 里挑)、颜色。列表按这个顺序,同一个 id 只留第一个: + + 1. 语言的种类,就是这块原来的种类(类、函数、变量……)。图标是内置种类的(`contract.ts` 的 `KIND_ICONS`),名字是翻译插件给 `kind` 值起的(见「筛选」;没有翻译插件时是种类 id)。不另填颜色:图标按 id 用 CSS 的 `--cr-kind-*`(见「颜色」)。 + 2. 语言插件另声明的概念(`LanguageAdapter.concepts`,可选)。认到才出现(比如异步、抽象)。不填就只有种类那一个。宿主不按语言写判断。 + 3. 库规则认领成自己的卡片时,上面的留下,不换掉。规则的种类追加在后面:id 是 `RuleCard.kind`,名字、图标、颜色是 `badge` 的。规则还可以再带认出的概念(`RuleCard.concepts`)。筛选的 `kind`、卡片边框色仍是规则的种类(`badge.color`),不因为多了图标就改。认成现成属性表块的(`RuleFacts`,枚举)没有 badge,概念就是换成的那种块的语言种类。 + + 每种内置种类由插件定图标、颜色、名字。同一类语义共用一个颜色,图标区分具体是哪种: | 种类 | 图标 | 种类 | 图标 | 种类 | 图标 | | --- | --- | --- | --- | --- | --- | @@ -140,9 +148,9 @@ | copy 复制 | `copy` | setting 设置 | `sliders-horizontal` | label 标签 | `tag` | | health 健康检查 | `activity` | start 启动 | `play` | | | - 库规则的种类用规则给的图标(`KindBadge.icon`):SQLAlchemy 数据表 `database`、数据类 `package`、Celery 应用 `server-cog`、定时任务 `calendar-clock`、FastAPI 接口 `globe`、FastAPI 路由器 `network`、React 组件 `atom`、副作用 `zap`、事件处理 `mouse-pointer-click`。 + 库规则的种类用规则给的图标(`KindBadge.icon`),跟在语言的种类图标后面:SQLAlchemy 数据表 `database`、数据类 `package`、Celery 应用 `server-cog`、定时任务 `calendar-clock`、FastAPI 接口 `globe`、FastAPI 路由器 `network`、React 组件 `atom`、副作用 `zap`、事件处理 `mouse-pointer-click`。数据类写了 `frozen=True` 再加一把锁(`lock`,悬停「不可修改」)。 - 这组图标由插件自己定义,是一个封闭的集合:契约里的 `KIND_ICON_NAMES`(上表 26 个——命令、管道、执行共用一个,解释器、解析指令共用一个,选项、设置共用一个,字段、标签共用一个——加库规则用的 9 个(React 组件的原子、副作用的闪电、事件处理的点击手势也在里面),lucide 的 kebab-case 名字),类型 `KindIconName` 是它们的联合,`KindBadge.icon` 只能写其中一个,写别的是类型错误。webview 给每个名字配一个逐个 import 的 lucide 图标组件(`kindIcons.tsx` 的 `KIND_ICON_COMPONENTS`,打包只带这些,预览脚本约 157 kB);webview 的 `KindIconName` 照契约手抄,`kindIconNames.test.ts` 核对两边一样。规则要新图标就把名字加进这组、在 webview 配上图标,没有按任意名字找图标的路。 + 这组图标由插件自己定义,是一个封闭的集合:契约里的 `KIND_ICON_NAMES`(上表 26 个——命令、管道、执行共用一个,解释器、解析指令共用一个,选项、设置共用一个,字段、标签共用一个——加库规则用的 9 个(React 组件的原子、副作用的闪电、事件处理的点击手势也在里面),lucide 的 kebab-case 名字),类型 `KindIconName` 是它们的联合,概念的 `icon` 只能写其中一个,写别的是类型错误。内置种类 → 图标名在 `contract.ts` 的 `KIND_ICONS`(宿主写进概念列表)。webview 给每个名字配一个逐个 import 的 lucide 图标组件(`kindIcons.tsx` 的 `KIND_ICON_COMPONENTS`,打包只带这些,预览脚本约 157 kB);webview 的 `KindIconName` 照契约手抄,`kindIconNames.test.ts` 核对两边一样。规则要新图标就把名字加进这组、在 webview 配上图标,没有按任意名字找图标的路。 - 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示和 `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 能走到,回车 / 空格切换。 - 悬停提示(整个插件的网页只走这一条路,预览和看板共用同一个组件):带提示的元素只写 `data-tooltip="提示"`(名字照旧写在 `aria-label` 里,只有图标的按钮必须有 `aria-label`),页面根上挂一个 `TooltipHost`(`webview-ui/src/components/ui/tooltip.tsx`)统一弹,不用原生 `title`;提示里的换行照样换行(看板摘修复弹框里「迁移」列出的几个文件一行一个);注释、回答里 Markdown 链接的标题(`[文字](网址 "标题")`)也写成 `data-tooltip`。网页的 TSX 里写原生 `title` 属性是 lint 错误(`vscode/eslint.config.mjs`),只有 `iframe` 这种必须带标题的元素例外,组件自己叫 `title` 的属性不算。原因:macOS 上 VS Code 带的 Electron(1.139 是 Electron 43)只在鼠标压着带 `title` 的元素自身边缘时才弹原生提示,停在它里面的图标、文字上不弹(electron/electron#49843,Electron 38 起),种类图标 20px 的框几乎被 16px 的图形占满,基本弹不出来;样子也是系统的。样子照 VS Code 的悬停框:`--vscode-editorHoverWidget-background` 底、`--vscode-editorHoverWidget-border` 边框、`--vscode-editorHoverWidget-foreground` 字、`0 2px 8px --vscode-widget-shadow` 阴影,11px 字,圆角 4px,最宽 22rem;优先在元素上方,上方放不下翻到下方,水平对着元素中间,贴边留 8px,不接鼠标。鼠标停 500ms 才弹,在同一个元素里移动不重新计时,已经弹着时移到另一个带提示的元素上立刻换;移开、移出页面、按下鼠标、按任意键(含 Esc)、滚动就收,收了之后鼠标不离开这个元素不再弹。键盘把焦点移到带提示的元素上(`:focus-visible`)立刻弹,焦点离开就收;提示不是元素自己的名字(`aria-label` 或它的字)时用 `aria-describedby` 挂到元素上,读屏当描述念。 - 种类图标和底色按种类区分(颜色见「颜色」);注释块虚线框。 @@ -891,6 +899,8 @@ export interface LanguageAdapter { steps?: (root: Node, source: string, languageId: string) => StepBlocks /** 认代码里给定义起的中文名(见「中文名」);不填就没有中文名。 */ displayNames?: DisplayNameReader + /** 这块除了自己的种类,还认得出的概念(异步、抽象)。认到才返回;不填就只有种类那一个。种类由宿主补在最前面,这里不要再报。 */ + concepts?: (block: CodeBlock) => BlockConcept[] } /** 代码里给一个定义起的中文名:name 是定义处那个名字,text 是中文名。 */ @@ -954,7 +964,13 @@ export interface ThemeColor { dark: string, light: string } export const KIND_ICON_NAMES = ['import', 'square-function', /* …22 个内置种类的… */ 'database', 'package', 'server-cog', 'calendar-clock', 'globe', 'network', 'atom', 'zap', 'mouse-pointer-click'] as const export type KindIconName = (typeof KIND_ICON_NAMES)[number] -/** 库规则的种类:块左边只画图标(种类色),label 是悬停提示和读屏念的名字,筛选栏里也叫它;icon 只能从 KindIconName 里挑。 */ +/** 内置种类 → 图标名。宿主写进概念列表,webview 不自己查。 */ +export const KIND_ICONS: Record + +/** 一块上认出的一个概念。颜色不填的是语言的种类(按 id 用 CSS 的种类色);规则的概念填颜色,只给这个图标上色。 */ +export interface BlockConcept { id: string, label: string, icon: KindIconName, color?: ThemeColor } + +/** 库规则的种类:筛选栏里叫 label,卡片边框用 color,icon 进概念列表;icon 只能从 KindIconName 里挑。 */ export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName } /** 库规则认领一块后给出的替身,下面挂着原块留下的子块:规则自己的卡片,或认成的现成属性表块;这块讲在了别的卡片里时是略去它。 */ @@ -963,10 +979,11 @@ export type RuleBlock = RuleCard | RuleFacts | RuleOmit /** 这块讲在了别的卡片里,不再单独成块(React 的 "use client" 讲成组件卡片的「客户端组件」标签);不能有子块。 */ export interface RuleOmit { omit: true } -/** 规则自己的卡片:一张属性表,种类的图标、颜色、名字和每一行都由规则给。 */ +/** 规则自己的卡片:一张属性表。种类(badge)决定筛选和边框色;卡片左边的图标是语言的概念再加这个种类、以及 concepts。 */ export interface RuleCard { kind: string // 种类 id,「规则 id.名字」(sqlalchemy.table):筛选的 kind 值,要稳定 - badge: KindBadge // 种类的名字、颜色、图标;筛选栏里这个种类也叫这个名字 + badge: KindBadge // 种类的名字、颜色、图标;筛选栏里这个种类也叫这个名字,卡片边框用这个颜色 + concepts?: BlockConcept[] // 额外认出的概念,排在种类后面;语言的概念仍在更前面,同一个 id 只留第一个 rows: PropertyRow[] defines?: SourceRange // 块自己的名字(类名):跳到的定义是它时闪整块 attributes?: BlockAttributes // 规则自己的筛选属性,叠在宿主从原块推出的上面;kind 填了不算;名字由 LibraryRule.attributeNames 给 @@ -1094,7 +1111,7 @@ export interface CommandRule { - 略去(`RuleOmit`,`{ omit: true }`):这块讲在了别的卡片里(React 文件开头的 `"use client"` 讲成组件卡片的「客户端组件」标签,组件上面没并进块头部的 JSDoc 讲成「说明」),预览不画它、不占块的位置(后面的块 id 接着往下数)。它不能有子块,有子块时宿主当替身不合约定。 - 认成现成的属性表块(`RuleFacts`):`as` 是种类和 facts(`BlockFacts`,例如 `{ kind: 'enum', facts: EnumFacts }`),`absorbs` 同卡片。宿主把原块换成这种块(节点不变,头部是原块整条语句,子块是留下的),此后和语言插件报的这种块完全一样:翻译插件的 `describe` 排属性表、种类名取翻译插件的、颜色取这个种类的、筛选属性从 facts 推、块自己的名字从 facts 取。规则不给用词,也不给标签和颜色。只要检查并进来的是不是这块的子块。 - 规则自己的卡片(`RuleCard`),下面几条都说的是它。 -- 卡片必须给:`kind`(写成「规则 id.名字」,宿主核对前缀;筛选记住的就是它,要稳定)、`badge`(种类的名字、暗 / 亮两种颜色和 lucide 图标名)、`rows`(用现成的 `PropertyRow`:属性名 + 着色的值或 Markdown,表格就是带 `columns` 的子行)、`absorbs`(并进来的子块)。可以给:`defines`(块自己的名字,跳到定义是它时闪整块)、`attributes`(自己的筛选属性)、`slots`(放进卡片某一行下面的子块,见下);表格子行填 `PropertyItem.defines`(字段名),跳到这个定义时只闪那一行。 +- 卡片必须给:`kind`(写成「规则 id.名字」,宿主核对前缀;筛选记住的就是它,要稳定)、`badge`(种类的名字、暗 / 亮两种颜色和 lucide 图标名)、`rows`(用现成的 `PropertyRow`:属性名 + 着色的值或 Markdown,表格就是带 `columns` 的子行)、`absorbs`(并进来的子块)。可以给:`defines`(块自己的名字,跳到定义是它时闪整块)、`attributes`(自己的筛选属性)、`concepts`(额外认出的概念,排在种类图标后面)、`slots`(放进卡片某一行下面的子块,见下);表格子行填 `PropertyItem.defines`(字段名),跳到这个定义时只闪那一行。 - 放进卡片里的子块(`RuleSlot`):留下的子块(没并进来的)可以不排在卡片后面,画在卡片第 `row` 行下面当子卡片(React 组件的副作用画在「副作用」那一行下面);它照常转成块,照样问各规则(副作用那句也被 React 规则认成自己的卡片),编辑、问 AI、差异、筛选都照常。给了 `item` 的是那一行的子行里讲它的那一个:默认收着,点那个子行的第一格才展开、同一行里别的收起(事件处理表点名称)。一块只能放一处,不能同时并进来;`row` 要有这一行、`item` 要有这个子行。 - 只经契约:规则拿到的是块(kind、node、header、children、facts)和 `RuleContext`;源码里的名字照抄时自己带下标(`Segment.offset`),表达式、类型交给 `context.translate`(当前翻译插件的 `expression`,名字带下标,⌘ + 点击、高亮各处出现照常),说明文字交给 `context.doc`(当前翻译插件的 `doc`,和别的「说明」一样按 Markdown 显示),别的文件里定义的名字交给 `context.lookup`(见「规则认领时查别的文件里的定义」);库自己的配色(HTTP 方法)写在段的 `color` 上。规则可以读自己那门语言的语法树(规则声明了 `languageIds`),但不 import 语言插件、翻译插件和宿主模块(`rules/contractOnly.test.ts` 检查 `rules/` 下非测试文件只 import `../contract` 和同目录的文件)。 - 用词归规则:属性名、表头、种类名、类型译名都在规则自己的词表里,按 `context.displayLanguage`(翻译插件的 `displayLanguage`)挑,没有这种语言的词表就用规则默认的那份;加一种显示语言就是给规则加一份词表。webview 只画结构,不认识「数据表」这些字。 @@ -1105,7 +1122,7 @@ export interface CommandRule { - 认领:宿主从顶层往下每块问一遍启用的规则,按注册顺序,第一个认领的算(先注册的优先);被认领的块留下的子块照样问。 - 出错:规则抛错、或替身不合约定(种类 id 没带规则 id 前缀、并进来的不是这块的子块、属性表里的引用标的不是自己 / 规则没给 `resolve` / 引用的段没带下标、放进卡片里的块不是留下的子块 / 放了几处 / 没有那一行或子行、要略去的块还有子块)时,宿主记一条警告日志(输出面板「Superpowers」),当它没认、接着问下一个规则;都没认这块照常显示。预览不会因为规则出错而失败。 - 并进来的子块:不再单独成块,由规则挑(`absorbs`),宿主不替规则多并。没并进来的子块(方法、规则不讲的注释)不管写在哪(字段中间也算),都照源码顺序是替身的子块,预览里照常看得见。替身的编辑范围同别的块,是原块整条语句(从头部开始,到原块连同各子块的最后一行,类就是整个类),盖住留下的子块的行:替身编辑时预览不画这些子块,同一行不会同时在两处能改(见「预览(代码块树)」)。内置规则并成员时连同紧挨在它前面的注释块一起并(讲的是这个成员),讲方法的注释、最后面的注释照常留下。 -- 卡片在预览里:种类图标、颜色取 `badge`(`icon`;颜色暗色、亮色主题各取一个),悬停提示是 `badge.label`,内容是 `rows`,放进卡片的子块画在它那一行下面(这一行有折叠箭头,收起时它们跟着收起;画成一层块,同块树),不算卡片的子块(卡片的折叠箭头只管后面留下的子块);筛选的 `kind` 是替身的种类 id,其余属性从原块推出(原来的类是公共的,数据表也是公共的),叠上规则给的(规则自己的属性叫 `LibraryRule.attributeNames` 给的名字,规则起名时抛错记日志、照写 id);筛选栏里这个种类叫 `badge.label`;和类块一样默认收起子块。 +- 卡片在预览里:左边的图标是语言的概念(原来的类、函数……)再加 `badge` 这一个、以及 `concepts`(同一个 id 只留第一个),悬停各自的名字;卡片边框色取 `badge.color`(暗色、亮色主题各取一个),内容是 `rows`,放进卡片的子块画在它那一行下面(这一行有折叠箭头,收起时它们跟着收起;画成一层块,同块树),不算卡片的子块(卡片的折叠箭头只管后面留下的子块);筛选的 `kind` 是替身的种类 id,其余属性从原块推出(原来的类是公共的,数据表也是公共的),叠上规则给的(规则自己的属性叫 `LibraryRule.attributeNames` 给的名字,规则起名时抛错记日志、照写 id);筛选栏里这个种类叫 `badge.label`;和类块一样默认收起子块。多了图标不改筛选、不改边框色。 ### 规则自己查定义 @@ -1307,7 +1324,7 @@ export interface CommandRule { ### 内置:dataclasses 数据类(Python) -`vscode/src/codeReader/rules/dataclass.ts`,`libraries: ['dataclasses']`(Python 自带的库)。样例是 luffy 的 `user/repositories.py`、`commerce/payments/_driver.py`、`core/sms/services.py`、`sandbox/runtime.py` 里的数据类(节选在 `fixtures/dataclassModels.py`)。种类 `dataclasses.dataclass`,标签「数据类」(暗 #ef7fb0 / 亮 #b3306c),和类块一样默认收起子块。 +`vscode/src/codeReader/rules/dataclass.ts`,`libraries: ['dataclasses']`(Python 自带的库)。样例是 luffy 的 `user/repositories.py`、`commerce/payments/_driver.py`、`core/sms/services.py`、`sandbox/runtime.py` 里的数据类(节选在 `fixtures/dataclassModels.py`)。种类 `dataclasses.dataclass`,标签「数据类」(暗 #ef7fb0 / 亮 #b3306c),和类块一样默认收起子块。卡片左边的图标是「类」再加「数据类」。`frozen=True` 再加一把锁(概念 id `dataclasses.frozen`,悬停「不可修改」,颜色同常量 #9cdcfe / #001080)。`slots` 不加图标。 - 认哪些类:装饰器是 `@dataclass`、`@dataclass(...)`、`@dataclasses.dataclass(...)`(`import dataclasses as dc` 的 `dc.dataclass`、`from dataclasses import dataclass as data` 的 `data` 也算,别名按 `context.imports` 换回)。同名的别处的东西不算(`from pydantic.dataclasses import dataclass`、`@other.dataclass`)。 - 字段照 dataclasses 的规则认:类主体里带注解的名字(`x: int`、`x: int = 1`),照源码顺序。`ClassVar[...]`(`typing.ClassVar`、只写 `ClassVar` 也算)是类变量,不是字段,照常是子块;`InitVar[...]` 是字段,类型写方括号里的,说明前面写「仅初始化时使用」;`_: KW_ONLY` 是记号,不是字段,它后面的字段(没单独写 `kw_only=` 的)在默认值一格写「只能按名字传参」。 @@ -1745,7 +1762,8 @@ interface BlockViewBase { codeTokens: Segment[] // 源码着色(拼起来等于 code) defines?: SourceRange // 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取(库规则的块取 RuleBlock.defines);跳到的定义是它时闪整块 attributes: Record // 筛选属性(含 kind),见「筛选」 - badge?: KindBadge // 库规则的卡片才有:种类的名字、颜色、图标;webview 按它画图标、上色、默认折叠 + concepts: BlockConcept[] // 认出来的概念,从左到右;webview 按图标名画,不自己用种类查 + badge?: KindBadge // 库规则的卡片才有:筛选栏里这个种类的名字,卡片边框的颜色;图标在 concepts 里。和类块一样默认折叠 slot?: { row: number, item?: number } // 库规则放进父卡片属性表里的子块才有(RuleSlot):画在父卡片第 row 行下面;item 是讲它的子行,点它才展开(它的块 id 记成折叠着就是收着) children: BlockView[] } @@ -1904,7 +1922,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,悬停提示在 src/components/ui/tooltip.tsx(和看板共用,页面根上挂一次),块的种类图标(这组图标名 → 图标组件、内置种类 → 图标名,都只在这一处)在 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;代码差异:页面上怎么标(块 id、删掉的块挂哪、只看改动折哪几段、各处改动、上一处 / 下一处跳到哪)在 diffView.ts,筛选栏第二行和新文件横幅在 DiffBar.tsx,卡片里的增删在 DiffText.tsx(Markdown 的增删在 markdown.ts 的 renderMarkdownDiff),总览条在 OverviewRuler.tsx,跳到一处改动在 useChangeNav.ts) +vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,悬停提示在 src/components/ui/tooltip.tsx(和看板共用,页面根上挂一次),块的概念图标(图标名 → 图标组件只在 kindIcons.tsx;内置种类 → 图标名在 contract.ts 的 KIND_ICONS,宿主写进概念列表)在 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;代码差异:页面上怎么标(块 id、删掉的块挂哪、只看改动折哪几段、各处改动、上一处 / 下一处跳到哪)在 diffView.ts,筛选栏第二行和新文件横幅在 DiffBar.tsx,卡片里的增删在 DiffText.tsx(Markdown 的增删在 markdown.ts 的 renderMarkdownDiff),总览条在 OverviewRuler.tsx,跳到一处改动在 useChangeNav.ts) ``` ## 测试 @@ -1972,7 +1990,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 页面(`diffView.test.ts`、`markdown.test.ts`、`viewState.test.ts`):块按记号认成页面上的 id、找不到的不标、不是这一版不画卡片里的标记,删掉的块按层挂、找不到挂处的和被筛掉的不画,新文件不逐块标,标了改动的默认展开;统计的字;删掉的块收起时的名字;只看改动折哪几段(改过的块下面按层折、删掉的块打断、被筛掉的不算、点开过的和要露出来的不折);各处改动的先后和行、被筛掉的不算;上一处 / 下一处接着走、按视口顶上的行找、到头绕回;带增删的 Markdown 照常排版、跨行逐行包、新加的列表项留住记号。 - 复制出什么(`explorer/copyText.test.ts`):文件、目录的名字 / 相对路径 / 绝对路径;不带后缀只去最后一个扩展名(`a.test.ts`、`archive.tar.gz`),`.env` 原样、`.env.local` → `.env`、没有扩展名的原样;相对路径总用 `/`,绝对路径照本机写法(Windows 反斜杠)。 - 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、悬停提示(`data-tooltip`,没有原生 `title`)和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记;给了「问 AI」时它排在前面、只有图标、问答区展开时带按下标记和 `aria-expanded`。 -- 块的种类图标(webview `kindIcons.test.tsx`,渲染成 HTML):内置种类的图标和原型一一对应;这组里每个名字画的就是 lucide 里同名的图标;webview 配了图标的名字和契约的 `KIND_ICON_NAMES` 一样、没有多的(`kindIconNames.test.ts`);只有图标、没有字,`role="img"`,悬停提示(`data-tooltip`,没有原生 `title`) 和 `aria-label` 是种类名,20px 的框、16px 的图形;库规则的卡片用规则给的图标。各库规则的测试核对 `badge` 的名字、颜色、图标。 +- 块的概念图标(webview `kindIcons.test.tsx`,渲染成 HTML):内置种类 → 图标名和原型一一对应(`contract.ts` 的 `KIND_ICONS`);这组里每个名字画的就是 lucide 里同名的图标;webview 配了图标的名字和契约的 `KIND_ICON_NAMES` 一样、没有多的(`kindIconNames.test.ts`);只有图标、没有字,悬停提示(`data-tooltip`,没有原生 `title`)是各自的名字,整排 `role="img"` 的 `aria-label` 把名字用顿号连起来,20px 的框、16px 的图形,横排间距紧;语言的种类按 id 上色,规则的图标用规则给的颜色。概念列表(`blockView.test.ts`、`rules/dataclass.test.ts`):普通类只有「类」;数据类是「类」和「数据类」,`frozen=True` 再加「不可修改」的锁,`slots` 不加;同一个 id 只留第一个(语言的种类优先于后面重复的);悬停名是这些。各库规则的测试核对 `badge` 的名字、颜色、图标。 - 悬停提示(webview `components/ui/tooltip.test.ts`):停 500ms 才弹、在同一个元素里移动不重新计时、没等到就移开不弹、弹着时移到另一个上立刻换、移到没提示的地方就收、按下鼠标 / 按键 / 滚动就收且鼠标不离开不再弹、等着弹时按下这次不弹、键盘聚焦立刻弹失焦收、提示是空的不弹;位置在上方、上方放不下翻到下方、左右钳在视口内。Markdown 链接的标题写成 `data-tooltip`(`markdown.test.ts`);网页 TSX 里的原生 `title` 由 lint 规则拦(`button`、`span` 上报错,`iframe`、组件属性不报)。页面上(无头 Chrome,真的移鼠标、按 Tab):种类图标中心点命中的是图标里的线条、没有盖在上面的东西;停 250ms 还没弹、停满 500ms 在图标上方弹「函数」「接口」,颜色是悬停框的那组变量;Tab 到「编辑」立刻弹、Esc 收;页面上没有原生 `title`;看板上「待接管」、禁用的 .env 锁按钮(禁用的按钮照样弹)、刷新按钮、摘修复弹框里「迁移」的三行文件名(一行一个)都弹,Tab 到按钮立刻弹、Esc 收。亮、暗两套颜色都看过。 - 问 AI: - 位置记号(`qa/anchor.test.ts`,真实的 Python 解析):有名字 / 没名字的块的记号、同一层重复的编号、两个函数里一样的条件互不影响;上面插几行、改空白记号不变;改别的函数的名字只动它和它里面的块;没名字的块自己的源码改了记号变、有名字的块改签名不变。 diff --git a/vscode/src/codeReader/attributes.test.ts b/vscode/src/codeReader/attributes.test.ts index 0567ef6..bafafe4 100644 --- a/vscode/src/codeReader/attributes.test.ts +++ b/vscode/src/codeReader/attributes.test.ts @@ -108,7 +108,7 @@ describe('块的筛选属性:宿主从 kind 和 facts 推出通用属性', () }) function view(id: string, attributes: Record, children: BlockView[] = []): BlockView { - return { id, anchor: id, kind: 'statement', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes, children, translation: [] } + return { id, anchor: id, kind: 'statement', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes, concepts: [{ id: 'statement', label: '语句', icon: 'code' }], children, translation: [] } } describe('文件里出现过的属性', () => { diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index ca08729..f3d9473 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -1,5 +1,5 @@ import type { BlockView } from './blockView' -import type { LanguageAdapter, Translator } from './contract' +import type { LanguageAdapter, LibraryRule, Translator } from './contract' import { describe, expect, it } from 'vitest' import { readBlockViews, readDefinitionKind, toBlockViews } from './blockView' import { replaceLines } from './editText' @@ -333,6 +333,48 @@ describe('blockView', () => { }) }) + it('概念列表:语言的种类在前,语言另认的接着,规则的种类和另认的在后;同一个 id 只留第一个', async () => { + const language: LanguageAdapter = { + ...py, + concepts: block => block.kind === 'class' + ? [ + { id: 'class', label: '重复的类', icon: 'zap' }, + { id: 'python.abstract', label: '抽象', icon: 'braces' }, + ] + : [], + } + const rule: LibraryRule = { + id: 'demo', + label: '演示', + languageIds: ['python'], + libraries: [], + claim: block => block.kind === 'class' + ? { + kind: 'demo.thing', + badge: { label: '东西', color: { dark: '#111111', light: '#222222' }, icon: 'package' }, + rows: [], + absorbs: [], + concepts: [ + { id: 'class', label: '又一个类', icon: 'lock' }, + { id: 'demo.thing', label: '重复的东西', icon: 'tag', color: { dark: '#333333', light: '#444444' } }, + { id: 'demo.extra', label: '额外', icon: 'lock', color: { dark: '#555555', light: '#666666' } }, + ], + } + : undefined, + } + const source = 'class Box:\n pass\n' + const [view] = await withBlocks(language, 'python', source, blocks => + toBlockViews(blocks, { source, language, markers: [] }, pythonZhTranslator, { rules: [rule], report: () => {} })) + expect(view?.concepts).toEqual([ + { id: 'class', label: '类', icon: 'box' }, + { id: 'python.abstract', label: '抽象', icon: 'braces' }, + { id: 'demo.thing', label: '东西', icon: 'package', color: { dark: '#111111', light: '#222222' } }, + { id: 'demo.extra', label: '额外', icon: 'lock', color: { dark: '#555555', light: '#666666' } }, + ]) + expect(view?.kind).toBe('demo.thing') + expect(view?.attributes.kind).toBe('demo.thing') + }) + it('readDefinitionKind:offset 落在哪块自己的名字上就是那块的种类,里层的块优先;不在名字上没有', async () => { const source = 'class UserService:\n def update_user(self):\n pass\n\nclass NotFound(Exception):\n pass\n\nlimit = 3\n' const kind = (text: string): Promise => readDefinitionKind(runtime, py, 'python', source, source.indexOf(text)) diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 046025f..9513150 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,7 +1,8 @@ import type { Tree } from '@vscode/tree-sitter-wasm' -import type { BlockAttributes, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract' +import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleCard, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import { blockAttributes } from './attributes' +import { KIND_ICONS } from './contract' import { commonIndent, splitLines } from './editText' import { activeRules, claimBlock, importsIn } from './libraryRules' import { BlockAnchors } from './qa/anchor' @@ -29,7 +30,12 @@ interface BlockViewBase { defines?: SourceRange /** 筛选属性(含 kind),见 blockAttributes;库规则认领的块叠上规则给的,kind 是规则的种类 id。 */ attributes: BlockAttributes - /** 库规则的卡片才有:种类的名字、颜色和图标(RuleCard.badge)。预览同类块一样默认收起它的子块。 */ + /** + * 这块认出来的概念,从左到右:语言的种类(再加语言插件另认的),库规则的卡片再加规则的种类和规则另认的。 + * 同一个 id 只留第一个。webview 按这个画图标,不自己用种类查。 + */ + concepts: BlockConcept[] + /** 库规则的卡片才有:筛选栏里这个种类的名字,卡片边框的颜色(RuleCard.badge)。图标在 concepts 里。预览同类块一样默认收起它的子块。 */ badge?: KindBadge /** 库规则放进父卡片里的子块才有(见 RuleSlot):画在父卡片属性表第 row 行下面,不排在父卡片的子块里。 */ slot?: BlockSlot @@ -297,6 +303,33 @@ export function toBlockViews( const [only] = steps return layer(steps, part.document, part.translator, NO_RULES)(steps.length === 1 && only ? only.children : steps, `${id}.`, scope, from) } + /** + * 这块的概念,从左到右,同一个 id 只留第一个:语言的种类(图标、名字用内置种类和翻译插件起的种类名), + * 语言插件另认的,库规则的卡片再加规则的种类和规则另认的。card 不填就是没有规则卡片(认成现成块的也算)。 + */ + function blockConcepts(block: CodeBlock, card?: RuleCard): BlockConcept[] { + const seen = new Set() + const concepts: BlockConcept[] = [] + const add = (concept: BlockConcept): void => { + if (seen.has(concept.id)) + return + seen.add(concept.id) + concepts.push(concept) + } + add({ + id: block.kind, + label: layerTranslator?.attributeNames?.values.kind?.[block.kind] ?? block.kind, + icon: KIND_ICONS[block.kind], + }) + for (const concept of layerDocument.language.concepts?.(block) ?? []) + add(concept) + if (card) { + add({ id: card.kind, label: card.badge.label, icon: card.badge.icon, color: card.badge.color }) + for (const concept of card.concepts ?? []) + add(concept) + } + return concepts + } /** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):编辑范围是 span,头部是块自己那部分,子块照常交给各规则看。scope 见 BlockAnchors.place */ function plain(block: CodeBlock, span: SourceSpan, id: string, scope: string): BlockView { const name = ownName(block) @@ -309,6 +342,7 @@ export function toBlockViews( headerEndLine: block.header.endRow, ...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }), attributes: blockAttributes(block), + concepts: blockConcepts(block), children: [ ...list(block.children, `${id}.`, inner), ...embeddedChildren(block, id, inner, block.children.length), @@ -343,6 +377,7 @@ export function toBlockViews( headerEndLine: span.endRow, ...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }), attributes: { ...blockAttributes(block), ...attributes, kind }, + concepts: blockConcepts(block, replacement), badge, children: list(kept, `${id}.`, inner, 0, slots), properties: rows, diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 2e4bd09..becd975 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -652,6 +652,11 @@ export interface LanguageAdapter { steps?: (root: Node, source: string, languageId: string) => StepBlocks /** 认代码里给定义起的中文名(见「中文名」);不填就没有中文名。 */ displayNames?: DisplayNameReader + /** + * 这块除了自己的种类,还认得出的概念(异步、抽象)。认到才返回;不填就只有种类那一个。 + * 种类那一个由宿主按内置种类的图标和翻译插件起的种类名补在最前面,这里不要再报种类。 + */ + concepts?: (block: CodeBlock) => BlockConcept[] } // ── 中文名(见「中文名」)── @@ -777,7 +782,59 @@ export const KIND_ICON_NAMES = [ export type KindIconName = (typeof KIND_ICON_NAMES)[number] -/** 库规则的种类在预览里的样子:块左边只画图标(种类色),名字是悬停提示和读屏念的文字,筛选栏里这个种类也叫它。 */ +/** + * 内置种类用哪个图标。宿主排预览时写进概念列表,webview 只按名字画。 + * 同一类语义共用一个颜色(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', + interpreter: 'terminal', + options: 'sliders-horizontal', + case: 'signpost', + command: 'square-terminal', + pipeline: 'square-terminal', + directive: 'terminal', + stage: 'layers', + run: 'square-terminal', + copy: 'copy', + setting: 'sliders-horizontal', + label: 'tag', + health: 'activity', + start: 'play', +} + +/** + * 一块上认出的一个概念:稳定 id、悬停名、图标名(只能从 KIND_ICON_NAMES 里挑)、颜色。 + * 语言的种类不填颜色,图标按 id 用 CSS 里这种种类的颜色;规则声明的概念填颜色,只给这个图标上色。 + */ +export interface BlockConcept { + /** 稳定 id。语言的种类就是块的种类(class);规则的种类是 RuleCard.kind;规则另认的由规则起。 */ + id: string + /** 悬停提示和读屏念的名字。 */ + label: string + icon: KindIconName + /** 不填是语言的种类(颜色在 CSS)。规则的概念填规则给的颜色,不改卡片边框。 */ + color?: ThemeColor +} + +/** 库规则的种类:筛选栏里叫 label,卡片边框用 color,icon 进概念列表。卡片左边不只这一个图标(见 BlockConcept)。 */ export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName } /** @@ -793,12 +850,17 @@ export type RuleBlock = RuleCard | RuleFacts | RuleOmit */ export interface RuleOmit { omit: true } -/** 规则自己的卡片:一张属性表,种类的图标、颜色、名字和每一行都由规则给(用词归规则)。 */ +/** 规则自己的卡片:一张属性表。种类(badge)决定筛选和边框色;卡片左边的图标是语言的概念再加这个种类、以及 concepts。用词归规则。 */ export interface RuleCard { /** 种类 id,写成「规则 id.名字」(sqlalchemy.table):筛选的 kind 属性值就是它,筛选记住的也是它,要稳定。 */ kind: string - /** 种类的名字、颜色和图标:筛选栏里这个种类也叫这个名字。 */ + /** 种类的名字、颜色和图标:筛选栏里这个种类也叫这个名字,卡片边框用这个颜色。图标进概念列表,不换掉语言的概念。 */ badge: KindBadge + /** + * 认领时额外认出的概念,排在这个种类(badge)后面。语言认的概念仍在更前面;同一个 id 只留第一个。 + * 不改筛选的种类,也不改卡片边框色(仍是 badge)。 + */ + concepts?: BlockConcept[] rows: PropertyRow[] /** 块自己的名字(类名)在源码里的范围:跳到的定义就是它时闪整块。 */ defines?: SourceRange diff --git a/vscode/src/codeReader/diff/viewDiff.test.ts b/vscode/src/codeReader/diff/viewDiff.test.ts index 3a77d2d..aa977a0 100644 --- a/vscode/src/codeReader/diff/viewDiff.test.ts +++ b/vscode/src/codeReader/diff/viewDiff.test.ts @@ -20,7 +20,7 @@ function textView(translation: Inline[] | { markdown: string }, extra: Partial { diff --git a/vscode/src/codeReader/rules/dataclass.test.ts b/vscode/src/codeReader/rules/dataclass.test.ts index 2924324..3bc63d3 100644 --- a/vscode/src/codeReader/rules/dataclass.test.ts +++ b/vscode/src/codeReader/rules/dataclass.test.ts @@ -135,6 +135,43 @@ describe('dataclasses 库规则(Python)', () => { 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 }) + expect(sandbox.concepts.map(concept => concept.label)).toEqual(['类', '数据类', '不可修改']) + }) + + it('概念图标:普通类只有「类」;数据类是「类」和「数据类」,frozen 再加一把锁;slots 不加;悬停名是这些', async () => { + const list = await views([ + 'class Plain:', + ' x = 1', + '', + 'from dataclasses import dataclass', + '', + '@dataclass', + 'class Open:', + ' x: int', + '', + '@dataclass(frozen=False)', + 'class Soft:', + ' x: int', + '', + '@dataclass(frozen=True)', + 'class Closed:', + ' x: int', + '', + '@dataclass(frozen=True, slots=True)', + 'class Packed:', + ' x: int', + '', + ].join('\n')) + const plain = list.find(view => view.kind === 'class') + expect(plain?.concepts).toEqual([{ id: 'class', label: '类', icon: 'box' }]) + const dataClass = { id: 'dataclasses.dataclass', label: '数据类', icon: 'package' as const, color: { dark: '#ef7fb0', light: '#b3306c' } } + const frozen = { id: 'dataclasses.frozen', label: '不可修改', icon: 'lock' as const, color: { dark: '#9cdcfe', light: '#001080' } } + expect(named(list, 'Open').concepts).toEqual([{ id: 'class', label: '类', icon: 'box' }, dataClass]) + expect(named(list, 'Soft').concepts).toEqual([{ id: 'class', label: '类', icon: 'box' }, dataClass]) + expect(named(list, 'Closed').concepts).toEqual([{ id: 'class', label: '类', icon: 'box' }, dataClass, frozen]) + expect(named(list, 'Packed').concepts).toEqual([{ id: 'class', label: '类', icon: 'box' }, dataClass, frozen]) + expect(named(list, 'Closed').attributes.kind).toBe('dataclasses.dataclass') + expect(named(list, 'Closed').badge?.color).toEqual(dataClass.color) }) it('编辑范围是整个类,每行只在一块里能编辑;改卡片写回整个类', async () => { diff --git a/vscode/src/codeReader/rules/dataclass.ts b/vscode/src/codeReader/rules/dataclass.ts index 5bb8eb2..ab5ccb4 100644 --- a/vscode/src/codeReader/rules/dataclass.ts +++ b/vscode/src/codeReader/rules/dataclass.ts @@ -1,4 +1,4 @@ -import type { Access, ClassBlock, CodeBlock, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract' +import type { Access, BlockConcept, ClassBlock, CodeBlock, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract' import type { Call, LibraryNames } from './pythonTree' import { memberDocs, withLeadingComments } from './children' import { aliasesOf, callOf, canonicalName, genericOf, libraryName, libraryNames, stringValue, syntaxChildren, unwrapType } from './pythonTree' @@ -17,6 +17,8 @@ const TYPE_LIBRARIES = ['dataclasses', 'typing', 'typing_extensions'] const KIND = 'dataclasses.dataclass' const COLOR: ThemeColor = { dark: '#ef7fb0', light: '#b3306c' } const ICON: KindIconName = 'package' +/** frozen=True 的锁,颜色同常量:一眼是「不可修改」。 */ +const FROZEN_COLOR: ThemeColor = { dark: '#9cdcfe', light: '#001080' } /** `@dataclass(...)` 各参数不写时的值:写成这个值等于没写,「选项」里不讲。 */ const OPTION_DEFAULTS: Record = { @@ -238,6 +240,13 @@ function fieldItem(field: Field, children: readonly CodeBlock[], env: Env): Prop } } +/** frozen=True 才算一个概念(锁,悬停「不可修改」)。slots 没有一眼能看懂的图标,不加。 */ +function frozenConcept(call: Call | undefined, words: Words): BlockConcept[] { + if (call?.keywords.get('frozen')?.type !== 'true') + return [] + return [{ id: 'dataclasses.frozen', label: words.options['frozen=True'], icon: 'lock', color: FROZEN_COLOR }] +} + function dataclassCard(block: ClassBlock, decorator: Node, call: Call | undefined, env: Env): RuleCard { const { words, context } = env const { labels } = words @@ -260,9 +269,11 @@ function dataclassCard(block: ClassBlock, decorator: Node, call: Call | undefine items: fields.map(field => fieldItem(field, children, env)), }, ] + const concepts = frozenConcept(call, words) return { kind: KIND, badge: { label: words.kind, color: COLOR, icon: ICON }, + ...(concepts.length > 0 && { concepts }), rows, ...(facts.name && { defines: range(facts.name) }), absorbs: withLeadingComments(children, [...fields.map(field => field.block), ...markers]), diff --git a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx index 60cb2e3..7b191d5 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx @@ -15,7 +15,21 @@ vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, v const BADGE: KindBadge = { label: 'React 组件', color: { dark: '#61dafb', light: '#0a7ea4' }, icon: 'atom' } function view(id: string, kind: string, text: string, extra: Partial = {}): BlockView { - return { id, anchor: id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, children: [], translation: [{ text, role: 'plain' }], ...extra } as BlockView + const badge = extra.badge + return { + id, + anchor: id, + kind, + startLine: 0, + endLine: 0, + code: '', + codeTokens: [], + attributes: { kind }, + concepts: [{ id: kind, label: badge?.label ?? kind, icon: badge?.icon ?? 'box', ...(badge && { color: badge.color }) }], + children: [], + translation: [{ text, role: 'plain' }], + ...extra, + } as BlockView } /** 一张组件卡片:第 1 行下面放着一个副作用,第 2 行的两个子行各讲一个处理函数,后面还有一个留下的注释块。 */ diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index be90162..d8e7abb 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -251,7 +251,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ) : } - +
{editing ? ( @@ -303,7 +303,7 @@ function RemovedCard({ placed, tree }: { placed: PlacedRemoved, tree: BlockTreeS > - +
已删除 · diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index 57878e9..d83dae2 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -3,7 +3,7 @@ import type { CSSProperties, ReactElement } from 'react' import type { GroupStyle, Inline, SegmentGroup } from './types' import { useContext, useState } from 'react' import { DisplayNameContext, shownName } from './displayNames' -import { KIND_ICON_COMPONENTS, KIND_ICONS } from './kindIcons' +import { KIND_ICON_COMPONENTS } from './kindIcons' import { isCurrent, segmentOccurrence, sourceOf } from './occurrences' import { HighlightContext } from './useOccurrences' @@ -47,10 +47,10 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement * 颜色在 codeReader.css 的 .cr-group[data-style] 里,框自己给了颜色(color)时用它。 */ const GROUP_ICONS: Record = { - iteration: KIND_ICON_COMPONENTS[KIND_ICONS.loop], - condition: KIND_ICON_COMPONENTS[KIND_ICONS.if], + iteration: KIND_ICON_COMPONENTS.repeat, + condition: KIND_ICON_COMPONENTS.split, branch: undefined, - skip: KIND_ICON_COMPONENTS[KIND_ICONS.return], + skip: KIND_ICON_COMPONENTS['corner-down-left'], tag: undefined, classes: undefined, } diff --git a/vscode/webview-ui/src/codeReader/diffView.test.ts b/vscode/webview-ui/src/codeReader/diffView.test.ts index e05a889..6ec262c 100644 --- a/vscode/webview-ui/src/codeReader/diffView.test.ts +++ b/vscode/webview-ui/src/codeReader/diffView.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { changeStops, diffTree, foldUnchanged, nextStop, openedByDiff, removedTitle, statsParts } from './diffView' function block(id: string, anchor: string, children: BlockView[] = [], extra: Partial = {}): BlockView { - return { id, anchor, kind: 'function', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'function' }, children, translation: [], ...extra } as BlockView + return { id, anchor, kind: 'function', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'function' }, concepts: [{ id: 'function', label: '函数', icon: 'square-function' }], children, translation: [], ...extra } as BlockView } function removed(parent: string, after: string | undefined, kind = 'function'): RemovedView { diff --git a/vscode/webview-ui/src/codeReader/editing.test.ts b/vscode/webview-ui/src/codeReader/editing.test.ts index 2dbeb18..21d282a 100644 --- a/vscode/webview-ui/src/codeReader/editing.test.ts +++ b/vscode/webview-ui/src/codeReader/editing.test.ts @@ -4,7 +4,7 @@ import { coveredByEditing, startEditing } from './editing' import { revealPlan, uncoveredPlan } from './reveal' function block(id: string, kind: string, [startLine, endLine]: [number, number], children: BlockView[] = []): BlockView { - return { id, kind, startLine, endLine, code: '', codeTokens: [], attributes: { kind }, children, properties: [] } + return { id, kind, startLine, endLine, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties: [] } } /** diff --git a/vscode/webview-ui/src/codeReader/filter.test.ts b/vscode/webview-ui/src/codeReader/filter.test.ts index f73422b..7df3a28 100644 --- a/vscode/webview-ui/src/codeReader/filter.test.ts +++ b/vscode/webview-ui/src/codeReader/filter.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, setChecked, toggleValue } from './filter' function block(id: string, kind: BlockKind, extra: Record = {}, children: BlockView[] = []): BlockView { - return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, children, translation: [] } + return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation: [] } } const NONE = new Set() diff --git a/vscode/webview-ui/src/codeReader/kindIcons.test.tsx b/vscode/webview-ui/src/codeReader/kindIcons.test.tsx index 5637d51..ee4af04 100644 --- a/vscode/webview-ui/src/codeReader/kindIcons.test.tsx +++ b/vscode/webview-ui/src/codeReader/kindIcons.test.tsx @@ -1,10 +1,15 @@ -import type { BlockView, KindIconName } from './types' +import type { BlockConcept, KindIconName } from './types' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' -import { KIND_ICON_COMPONENTS, KIND_ICONS, KindIcon } from './kindIcons' +import { KIND_ICONS } from '../../../src/codeReader/contract' +import { KIND_ICON_COMPONENTS, KindIcon } from './kindIcons' -function render(block: Pick, name: string): string { - return renderToStaticMarkup() +function render(concepts: readonly BlockConcept[]): string { + return renderToStaticMarkup() +} + +function concept(id: string, label: string, icon: KindIconName, color?: BlockConcept['color']): BlockConcept { + return { id, label, icon, ...(color && { color }) } } /** 标签之间的字:图标(svg)没有字 */ @@ -49,23 +54,44 @@ describe('块的种类图标', () => { it('这组图标里每个名字画的就是 lucide 里同名的图标', () => { for (const name of Object.keys(KIND_ICON_COMPONENTS) as KindIconName[]) { - const html = render({ kind: 'sqlalchemy.table', badge: { label: name, color: { dark: '#fff', light: '#000' }, icon: name } }, name) + const html = render([concept(name, name, name)]) expect(html, name).toContain(`class="lucide lucide-${name}"`) } }) - it('只画图标不带字,名字在悬停提示(data-tooltip,不用原生 title)和 aria-label 里;20px 的框、16px 的图形', () => { - const html = render({ kind: 'function' }, '函数') + it('只画图标不带字,名字在悬停提示(data-tooltip,不用原生 title)里;读屏念这个名字;20px 的框、16px 的图形', () => { + const html = render([concept('function', '函数', 'square-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('data-tooltip="数据表" aria-label="数据表"') + it('认到几个就横排几个:悬停各自的名字,读屏把名字连起来念;规则的图标用规则给的颜色', () => { + const html = render([ + concept('class', '类', 'box'), + concept('dataclasses.dataclass', '数据类', 'package', { dark: '#ef7fb0', light: '#b3306c' }), + concept('dataclasses.frozen', '不可修改', 'lock', { dark: '#9cdcfe', light: '#001080' }), + ]) + expect(visibleText(html)).toBe('') + expect(html).toContain('aria-label="类,数据类,不可修改"') + expect(html).toContain('data-tooltip="类"') + expect(html).toContain('data-tooltip="数据类"') + expect(html).toContain('data-tooltip="不可修改"') + expect(html).toContain('data-kind="class"') + expect(html).toContain('lucide-box') + expect(html).toContain('lucide-package') + expect(html).toContain('lucide-lock') + expect(html).toContain('--kind-dark:#ef7fb0') + expect(html).toContain('--kind-light:#b3306c') + expect(html).toContain('--kind-dark:#9cdcfe') + expect(html).toContain('--kind-light:#001080') + expect(html).not.toContain(' title=') + expect(html.match(/size-5/g)).toHaveLength(3) + expect(html).toContain('gap-0.5') }) }) diff --git a/vscode/webview-ui/src/codeReader/kindIcons.tsx b/vscode/webview-ui/src/codeReader/kindIcons.tsx index a025063..7f90237 100644 --- a/vscode/webview-ui/src/codeReader/kindIcons.tsx +++ b/vscode/webview-ui/src/codeReader/kindIcons.tsx @@ -1,6 +1,6 @@ import type { LucideIcon } from 'lucide-react' -import type { ReactElement } from 'react' -import type { BlockKind, BlockView, KindIconName } from './types' +import type { CSSProperties, ReactElement } from 'react' +import type { BlockConcept, KindIconName } from './types' import { Activity, Atom, @@ -79,53 +79,24 @@ export const KIND_ICON_COMPONENTS: Record = { } /** - * 内置种类的图标:块左边只画图标,名字是悬停提示。 - * 同一类语义共用一个颜色(codeReader.css 的 --cr-kind-*:常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),图标区分具体是哪种。 + * 卡片左边的概念图标,横排,各 20px,间距紧。图标名由宿主放进概念列表,这里只按名字画。 + * 没有颜色的是语言的种类,按 id 用 CSS 的种类色;有颜色的只给这个图标上色。 + * 每个图标悬停自己的名字(data-tooltip,见 components/ui/tooltip.tsx);读屏把名字连起来念。 */ -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', - interpreter: 'terminal', - options: 'sliders-horizontal', - case: 'signpost', - command: 'square-terminal', - pipeline: 'square-terminal', - directive: 'terminal', - stage: 'layers', - run: 'square-terminal', - copy: 'copy', - setting: 'sliders-horizontal', - label: 'tag', - health: 'activity', - start: 'play', -} - -/** - * 块左边的种类:只画图标,和一行字等高(20px 的框、16px 的图形),颜色是种类色(--kind)。图标是库规则给的(badge.icon), - * 没有就是内置种类的(KIND_ICONS,没带 badge 的块 kind 是 BlockKind);名字(翻译插件给种类起的,库规则的种类是规则给的) - * 写在悬停提示(data-tooltip,见 components/ui/tooltip.tsx)和读屏念的 aria-label 里。 - */ -export function KindIcon({ block, name }: { block: Pick, name: string }): ReactElement { - const Icon = KIND_ICON_COMPONENTS[block.badge?.icon ?? KIND_ICONS[block.kind as BlockKind]] +export function KindIcon({ concepts }: { concepts: readonly BlockConcept[] }): ReactElement { return ( - - + concept.label).join(',')}> + {concepts.map((concept) => { + const Icon = KIND_ICON_COMPONENTS[concept.icon] + const tint = concept.color + ? { 'data-badge': '' as const, 'style': { '--kind-dark': concept.color.dark, '--kind-light': concept.color.light } as CSSProperties } + : { 'data-kind': concept.id } + return ( + + + + ) + })} ) } diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index cf5d7a2..a7ee4c5 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -21,11 +21,11 @@ function word(text: string): Segment { } function textBlock(id: string, kind: BlockKind, startLine: number, translation: Inline[], children: BlockView[] = []): BlockView { - return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, translation } + return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation } } function factBlock(id: string, kind: BlockKind, startLine: number, properties: PropertyRow[], children: BlockView[] = []): BlockView { - return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, properties } + return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties } } /** @@ -127,7 +127,7 @@ describe('跳到定义:滚到哪块、展开哪些', () => { it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => { const blocks: BlockView[] = [ - { id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'comment' }, children: [], translation: { markdown: '见 `get_db`' } }, + { id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'comment' }, concepts: [{ id: 'comment', label: '注释', icon: 'message-square-text' }], children: [], translation: { markdown: '见 `get_db`' } }, factBlock('1', 'function', 1, [ { label: '说明', value: { markdown: '同 `get_db`' } }, { label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 30)]] }] }, diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index abcf512..65f69a5 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -101,7 +101,18 @@ export type KindIconName | 'message-square-text' | 'terminal' | 'sliders-horizontal' | 'signpost' | 'square-terminal' | 'layers' | 'copy' | 'activity' | 'play' | 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network' | 'atom' | 'zap' | 'mouse-pointer-click' -/** 库规则的种类:名字(悬停提示、读屏念的文字)、颜色和图标(从 KindIconName 里挑);块左边只画图标 */ +/** + * 一块上认出的一个概念。宿主放进预览数据:稳定 id、悬停名、图标名。 + * 颜色不填的是语言的种类,按 id 用 CSS 的种类色;填了的只给这个图标上色。 + */ +export interface BlockConcept { + id: string + label: string + icon: KindIconName + color?: ThemeColor +} + +/** 库规则的种类:筛选栏里叫 label,卡片边框用 color。图标在 concepts 里,不单画这一个。 */ export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName } interface BlockViewBase { @@ -109,7 +120,7 @@ interface BlockViewBase { id: string /** 块的位置记号:问答挂在它上面(QaThreadView.anchor),文件别处改了照样对得上;webview 只拿它比相等,不拆开看 */ anchor: string - /** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table);webview 不认识后者,只按 badge 画 */ + /** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table)。筛选用它;图标不看它,看 concepts */ kind: string /** 编辑范围的起止行,0 起:块的完整源码,头部连同各子孙块、结尾(块编辑时子孙都不画,见 coveredByEditing) */ startLine: number @@ -122,7 +133,9 @@ interface BlockViewBase { defines?: SourceRange /** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */ attributes: Record - /** 库规则认领的块才有:种类的名字、颜色和图标,由规则给;和类块一样默认收起子块 */ + /** 这块认出来的概念,从左到右。webview 按图标名画,不自己用种类查 */ + concepts: BlockConcept[] + /** 库规则的卡片才有:卡片边框的颜色,筛选栏里这个种类的名字。和类块一样默认收起子块 */ badge?: KindBadge /** 库规则放进父卡片里的子块才有:画在父卡片属性表第 row 行下面,不排在父卡片的子块里;item 见 BlockSlot */ slot?: BlockSlot diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index b36c5f8..698e948 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -15,7 +15,7 @@ function memoryStorage(initial?: unknown): ViewStateStorage { } function block(id: string, kind: BlockKind, children: BlockView[] = []): BlockView { - return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, children, translation: [] } + return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation: [] } } /**