From e8ae19d084ee20da8fdc574bde29312f4db49725 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sun, 4 Oct 2026 15:34:44 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E6=9E=84=E9=80=A0?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E5=8C=BA=EF=BC=8C=E6=94=B9=E5=AE=83=E7=9A=84?= =?UTF-8?q?=E5=92=8C=E6=9D=A5=E8=87=AA=E8=83=BD=E5=8D=95=E7=8B=AC=E6=89=93?= =?UTF-8?q?=E5=BC=80=E6=BA=90=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 14 +-- vscode/src/codeReader/blockView.ts | 6 ++ vscode/src/codeReader/contract.ts | 31 ++++++ vscode/src/codeReader/rules/react.test.ts | 62 ++++++++---- vscode/src/codeReader/rules/react.ts | 70 ++++++++++--- vscode/src/codeReader/rules/reactTree.ts | 2 +- vscode/webview-ui/src/codeReader/App.tsx | 6 +- .../webview-ui/src/codeReader/BlockTree.tsx | 2 +- .../webview-ui/src/codeReader/CodeEditor.tsx | 34 ++++--- .../src/codeReader/PropertyGrid.tsx | 98 +++++++++++++++++-- .../webview-ui/src/codeReader/editing.test.ts | 20 ++++ vscode/webview-ui/src/codeReader/editing.ts | 51 +++++++++- vscode/webview-ui/src/codeReader/types.ts | 19 ++++ 13 files changed, 345 insertions(+), 70 deletions(-) diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index d4da1ab..b938b34 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -1557,7 +1557,7 @@ export interface CommandRule { | --- | --- | | 名称 | 组件名(组件色,带下标);后面跟小标签(`tag` 框):「具名导出」「默认导出」(同 import / export 关键字的颜色;`export default X`、`export { X }` 写在文件别处也算)、「客户端组件」(组件色)、「记忆化」「转发 ref」(同 const 关键字的颜色,memo / forwardRef 包着) | | 说明 | 组件上面的 JSDoc,Markdown | -| 属性 | `N 个`(hint);属性类型写的是一个名字(接口、类型别名、导入的类型)时后面写「 · 来自 XxxProps」,名字带下标(⌘ + 点击跳到类型)。下面一张表格「名称 \| 类型 \| 默认值 \| 必填」,有属性带说明时多一列「说明」。没写参数时写「无」 | +| 构造参数 | `N 个`(hint);属性类型写的是一个名字(接口、类型别名、导入的类型)时后面写「 · 来自 XxxProps」,名字带下标(⌘ + 点击跳到类型)。下面一张表格「名称 \| 类型 \| 默认值 \| 必填」,有属性带说明时多一列「说明」。没写参数时写「无」 | | 状态 | useState / useReducer:「名称 \| 初始值 \| 改它的」 | | 引用 | useRef:「名称 \| 初始值 \| 用在」 | | 读取的数据 | 别的钩子(`useXxx(…)` 赋给变量):「变量 \| 来自 \| 意思」 | @@ -1566,7 +1566,7 @@ export interface CommandRule { | 事件处理 | 组件体里定义的函数(函数声明、值是函数的常量):「名称 \| 绑定在 \| 做什么」,点名称在表下面展开它的子卡片 | | 渲染 | `N 处返回`(hint),下面是返回的 JSX 画成的结构大纲 | -属性表:属性类型的字段照类型里的顺序一行一个(类型字面量、同文件的接口 / 类型别名,交叉类型各部分接起来,`Readonly<…>`、`PropsWithChildren<…>` 看里面;没标注参数类型时 `const X: FC` 的泛型实参、`forwardRef` 的第二个泛型实参),解构里有、类型里没有的接在后面,`...rest` 最后(类型写「其余的属性」)。名称是属性名(定义处带下标:解构出来的是解构那处,没解构的是字段;跳到定义只闪这一行),`children` 后面标「子元素」、函数类型的属性(`(x) => R`、方法签名)标「回调」(组件色的小标签),解构时改了名写「(组件里叫 x)」;类型照翻译插件翻,方法签名写成函数签名;默认值取解构的默认值,没有写「—」;必填看类型的 `?`(是 / 否,storage 色),没类型可看时写了默认值的「否」、别的「—」;说明是字段上面的 JSDoc / 注释、这一行行尾的注释(comment 色,几行接成一行)。类型是导入的名字时经工作区索引查它的字段(索引 `react.propTypes`:看工作区里所有 `**/*.{ts,tsx}`,预筛有 `interface X` / `type X` 才解析,按 typescriptreact 解析;从文件顶层认出接口、类型别名,键是名字、值是各字段的名字、类型写法、可省略、回调、说明的 JSON),查法同「规则认领时查别的文件里的定义」:先照解构的出行、类型「未标注」,查到了按字段重排、重发;别的文件里的类型照写文字(type 色)。 +构造参数:属性类型的字段照类型里的顺序一行一个(类型字面量、同文件的接口 / 类型别名,交叉类型各部分接起来,`Readonly<…>`、`PropsWithChildren<…>` 看里面;没标注参数类型时 `const X: FC` 的泛型实参、`forwardRef` 的第二个泛型实参),解构里有、类型里没有的接在后面,`...rest` 最后(类型写「其余的属性」)。名称是属性名(定义处带下标:解构出来的是解构那处,没解构的是字段;跳到定义只闪这一行),`children` 后面标「子元素」、函数类型的属性(`(x) => R`、方法签名)标「回调」(组件色的小标签),解构时改了名写「(组件里叫 x)」;类型照翻译插件翻,方法签名写成函数签名;默认值取解构的默认值,没有写「—」;必填看类型的 `?`(是 / 否,storage 色),没类型可看时写了默认值的「否」、别的「—」;说明是字段上面的 JSDoc / 注释、这一行行尾的注释(comment 色,几行接成一行)。类型是导入的名字时经工作区索引查它的字段(索引 `react.propTypes`:看工作区里所有 `**/*.{ts,tsx}`,预筛有 `interface X` / `type X` 才解析,按 typescriptreact 解析;从文件顶层认出接口、类型别名,键是名字、值是各字段的名字、类型写法、可省略、回调、说明的 JSON),查法同「规则认领时查别的文件里的定义」:先照解构的出行、类型「未标注」,查到了按字段重排、重发;别的文件里的类型照写文字(type 色)。 状态、引用、读取的数据、派生值: @@ -1589,14 +1589,14 @@ export interface CommandRule { - `cond && ` 前面加「当 … 时」小框(`condition`,条件照常翻);三元一行「如果 …」,下面第一行各带「是」「否」小框(`branch`),一支是 null 时写「不渲染」;`items.map((x) => …)` 一行「对 items 的每一项 x」(`iteration` 小框)后面「key …」,回调里先算的常量写「其中 isActive = …」,再下面是每一项渲染的;JSX 注释写成一行「// …」(comment 色)。 - 几处返回:分几段,提前 `return null`(`if (cond) return null`,主体只有这一句)写「当 … 时」(`skip` 小框)「不渲染」,提前返回 JSX 的「当 … 时」下面是它渲染的,最后一处「否则」(`branch` 小框);只有一处返回时直接是它的大纲。这些 return(和提前返回的 if)并进卡片。 -组件体里归不进上面分区的语句(没讲进分区的注释、别的 if、循环、单独调用的钩子)照常是组件卡片留下的子块。卡片「编辑」是组件的完整代码(整条语句);副作用、处理函数子卡片各自能编辑、问 AI。代码差异在视图数据这一层比:组件卡片的属性表逐行(属性表按名称配对,加的属性标新加的一行),副作用、处理函数子卡片是块,照常配对、逐行标(改了依赖,「时机」那一行逐截标)。 +组件体里归不进上面分区的语句(没讲进分区的注释、别的 if、循环、单独调用的钩子)照常是组件卡片留下的子块。卡片「编辑」是组件的完整代码(整条语句);副作用、处理函数子卡片各自能编辑、问 AI。状态「改它的」里每一处、读取的数据的「来自」各自有同一套铅笔,打开后只出现那一段源码(整行切好、着色好,和块同一套编辑框、同一条改文件的消息):来自是这次钩子调用占的行,不是左边的变量名;改它的里 JSX 是整个属性(名字和值),处理函数、派生值、另一处读取、重置条件各开各的那段(函数、那个值、那次调用、那个条件),纯渲染没有铅笔。这段的行落在组件里面,组件正在编辑时不画这段,两边不能同时编辑。代码差异在视图数据这一层比:组件卡片的属性表逐行(属性表按名称配对,加的属性标新加的一行),副作用、处理函数子卡片是块,照常配对、逐行标(改了依赖,「时机」那一行逐截标)。 样例(`fixtures/desktopTitlebar.tsx.txt`,测试里逐字核对;`⟦…⟧` 是小标签、小框): ``` 名称: DesktopTitlebar ⟦具名导出⟧ ⟦客户端组件⟧ 说明: 窗口顶部 40px. 整条连同里面的文字都是拖拽区 (data-tauri-drag-region="deep"), 按钮除外. 侧栏开关只在侧栏顶部. -属性: 1 个 +构造参数: 1 个 platform | DesktopShell["platform"] | — | 是 读取的数据: 2 个 pathname | usePathname() | 当前路径 @@ -1939,8 +1939,8 @@ vscode/src/codeReader/ rules/enum.ts 内置 enum 库规则(Python):枚举类认成枚举块,成员照 Python 的规则认 rules/dataclass.ts 内置 dataclasses 库规则(Python):数据类卡片,字段照 dataclasses 的规则认 rules/fastapi.ts 内置 FastAPI 库规则(Python):路由器卡片(接口表)、接口卡片(请求表、响应表),参数照 FastAPI 的规则放,异常类的状态码、类型别名、枚举经索引查 - rules/react.ts 内置 React 库规则(TSX / JSX):组件卡片(名称标签、属性表、状态 / 引用 / 读取的数据 / 派生值 / 副作用 / 事件处理、渲染大纲),副作用、处理函数的子卡片,导入的属性类型经索引查 - rules/reactTree.ts React 规则读语法树的办法:认组件、属性、组件体里的钩子和处理函数、名字用在哪、渲染的 JSX 大纲、别的文件里的属性类型(纯函数) + rules/react.ts 内置 React 库规则(TSX / JSX):组件卡片(名称标签、构造参数、状态 / 引用 / 读取的数据 / 派生值 / 副作用 / 事件处理、渲染大纲),副作用、处理函数的子卡片,导入的属性类型经索引查 + rules/reactTree.ts React 规则读语法树的办法:认组件、构造参数、组件体里的钩子和处理函数、名字用在哪、渲染的 JSX 大纲、别的文件里的属性类型(纯函数) commandRules.ts 命令规则的宿主部分:命令名 → 规则(先注册的算),规则出错记下、当它没读 commands/reading.ts 命令规则共用的:认选项(连写拆开、带值的、长选项、--)、拼译文(动作、括号里的意思、照抄不认识的选项) commands/systemCommands.ts 内置命令规则:新建用户 / 用户组(useradd、adduser、addgroup / groupadd)、gpg、install、chown,管 '*' 的只问版本的命令 @@ -2071,7 +2071,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 数据类(`rules/dataclass.test.ts`):`fixtures/dataclassModels.py` 的 `SandboxAccess` 逐字同上面的样例,`AdminUserRow`、`RedirectPayResult`(`field(init=False, default=…)`)、`SmsSent`(`default_factory=dict`)的行;种类 `dataclasses.dataclass`、叫「数据类」、颜色、访问级别同原来的类;编辑范围是整个类、写回原样,`__post_init__` 是子块;类名、字段名的定义范围和带下标的词指回源码原文;装饰器的写法(别名、`dataclasses.dataclass`、`dc.dataclass`)认,pydantic 的、`other.dataclass` 不认,没导入 dataclasses 不认;每个选项的译法、默认值不讲、别的照写、`**x`、别的装饰器一行;字段规则(`ClassVar`、`typing.ClassVar` 留作子块,`InitVar` 的类型和「仅初始化时使用」,`KW_ONLY` 记号并进来、后面的字段「只能按名字传参」、单独写 `kw_only=False` 的「可按位置传参」),`field(...)` 的默认值、`default_factory`(名字、lambda)、各参数、`metadata` 照写、`doc=`,说明取上面的注释块、行尾注释、属性 docstring;没注解的类属性、字段中间的方法照源码顺序是子块,「编辑」写回整个类。 - Pydantic 数据模型(`rules/pydantic.test.ts`):`fixtures/aigcMetadata.py` 的 `AIGCMetadata` 逐字同上面的样例(7 个无默认值字段、选项「严格 · 禁止多余字段」、`model_config` 不单成一块),`TextTrace` 的可空字段默认值是「空」;`fixtures/createShareRequest.py` 的 `CreateShareRequest` 逐字核对字段、说明、长度约束,`@field_validator` 仍是子块;种类 `pydantic.model`、叫「数据模型」、颜色、图标 `braces`;同一文件里继承模型的也算,`TypedDict`、`NamedTuple`、普通类、没导入 pydantic 的不是;别名、`Field` 的默认值 / `default_factory` / 约束、说明优先于注释,`ClassVar` 和没注解的类属性留下;类型里的名字带源码下标;索引只收本文件里能确定的模型、没写 `BaseModel` 的文件不解析,别的文件里的父类查不到先当普通类、查到再换成卡片;差异:加一个字段标新增,改一个默认值标那一格。 - FastAPI 接口(`rules/fastapi.test.ts`):`fixtures/routes.py` 的 `update_user` 逐字同上面的样例(查到 `Forbidden` 是 403 时四条消息写在一行,没查到时不列),名字、颜色、图标、列名、筛选属性、定义范围、编辑范围是整个函数、函数体是子块;着色(URL 的 `{参数}` / 路径,花括号里的参数名 `refers` 到函数的参数,方法的 Swagger 颜色、成功的状态码绿色、自动带的 422 淡色,带下标的词指回源码原文);`fixtures/skillHubRoutes.py` 的 `list_skills`(Annotated 里的 Query、`_` 的依赖)、`get_skill`(HTTPException 的 status_code / detail)、`install`(Request 不算、`-> None` 没有内容);`fixtures/consoleRoutes.py` 的 `create_user`(`status_code=201`、Pydantic 模型、调别的函数间接抛的不算)、`list_users`(查到的类型别名 `FilterQuery` 是依赖、Literal 是简单类型);参数放在哪儿的各条(路径里的名字含 `{x:path}`、各种标记两种写法、上传文件、枚举和类型别名查到的、Pydantic 模型、`Security`、`Depends()`);标签、依赖、`responses=` 叠上路由对象的,`status.HTTP_…` 常量、`response_model=None`、`response_description`、`raise … from`、只写 `raise`、嵌套函数里的;`FastAPI()` 上的接口、别的装饰器一行;不认的(没导入、路由对象在别的文件、不是路由对象、`websocket`、不带括号)。跨文件(工作区索引 + 假的语言扩展):`update_user` 的 `Forbidden` 按定义处查到 `core/exceptions/schemas.py` 的 403,同名的别处的类不算;索引认的类上的 `status_code`(带注解、常量)、枚举类、`Annotated` 类型别名,普通类不认。路由器卡片:`fixtures/routes.py` 的 `router` 逐字同上面的样例,名字、颜色、图标、列名、编辑范围、定义范围;接口表的函数名带下标、路径里的 `{user_id}` 各 `refers` 到各自函数的参数、方法的颜色;接口卡片的「路由器」行排在说明后面、名字带下标指回路由器;写了依赖、默认响应才有这两行,`FastAPI()` 没有前缀,属性 docstring 是说明,没挂接口时「0 个」、不画表格。 -- React 组件(`rules/reactTree.test.ts`、`rules/react.test.ts`,luffy 的七个真实组件):认组件(七个文件里的组件、小写工具函数和不渲染 JSX 的不认;memo / forwardRef 包着的、默认导出、文件末尾 export 的、null 和 JSX 混着返回的);属性(行内类型字面量、同文件接口带 JSDoc、回调、children、默认值、改名、其余属性、方法签名、`FC`、`forwardRef`、导入的类型查不到和查到了);组件体归到哪儿、名字用在哪(副作用、渲染时重置、挂在 `
` 的 ref、处理函数绑在每个按钮上);渲染大纲(原生标签、组件、类、条件、三元、列表、片段、注释、几处返回)和 className 的写法。卡片:`fixtures/desktopTitlebar.tsx.txt` 逐字同上面的样例、没有留下的子块,`"use client"` 和 JSDoc 不单独成块;LegalDocPage 的各分区逐行、副作用 / 处理函数子卡片和它们放在哪一行、样式小标点开的全文;ShareButton 的 useCallback、只在挂载时的副作用;PosterDialog 没绑在 JSX 上的处理函数;差异:加一个属性、改一个副作用的依赖,组件卡片属性表那一行新加、副作用「时机」逐截标。宿主:`libraries` 空的规则都启用;略去的块不出块、有子块的不能略去;放进卡片里的子块记下行 / 子行,不合约定的记下来。webview:放进卡片里的子块画在那一行下面(`BlockTree.test.tsx`),点子行才展开的默认收着、名称是按钮、展开时那一行垫上种类色;默认折叠、跳到定义 / 上一处下一处 / 只看改动展开的是那一行(`viewState`、`reveal`、`diffView` 的测试);小标签、条件小框、类名小标的样子(`SegmentText.test.tsx`)。 +- React 组件(`rules/reactTree.test.ts`、`rules/react.test.ts`,luffy 的七个真实组件):认组件(七个文件里的组件、小写工具函数和不渲染 JSX 的不认;memo / forwardRef 包着的、默认导出、文件末尾 export 的、null 和 JSX 混着返回的);构造参数(行内类型字面量、同文件接口带 JSDoc、回调、children、默认值、改名、其余属性、方法签名、`FC`、`forwardRef`、导入的类型查不到和查到了);组件体归到哪儿、名字用在哪(副作用、渲染时重置、挂在 `
` 的 ref、处理函数绑在每个按钮上);渲染大纲(原生标签、组件、类、条件、三元、列表、片段、注释、几处返回)和 className 的写法。卡片:`fixtures/desktopTitlebar.tsx.txt` 逐字同上面的样例、没有留下的子块,`"use client"` 和 JSDoc 不单独成块;LegalDocPage 的各分区逐行、副作用 / 处理函数子卡片和它们放在哪一行、样式小标点开的全文;ShareButton 的 useCallback、只在挂载时的副作用;PosterDialog 没绑在 JSX 上的处理函数;差异:加一个属性、改一个副作用的依赖,组件卡片属性表那一行新加、副作用「时机」逐截标。宿主:`libraries` 空的规则都启用;略去的块不出块、有子块的不能略去;放进卡片里的子块记下行 / 子行,不合约定的记下来。webview:放进卡片里的子块画在那一行下面(`BlockTree.test.tsx`),点子行才展开的默认收着、名称是按钮、展开时那一行垫上种类色;默认折叠、跳到定义 / 上一处下一处 / 只看改动展开的是那一行(`viewState`、`reveal`、`diffView` 的测试);小标签、条件小框、类名小标的样子(`SegmentText.test.tsx`)。 - 规则查别的文件里的定义(`ruleLookups.test.ts`):第一遍当没查到、记下问了什么(同一索引同名的只记一处);查到后叫重排,交出定义处正是的那一项;同一版本不再查;索引里没有同名的不问语言扩展,定义处不是索引里的项算没有,没变不叫重排;新版本先用上一版的、再查、变了才叫重排;语言扩展没答时已知的不动、隔一会儿再查。 - 带自己颜色的词(webview `SegmentText.test.tsx`):`cr-colored`,暗色、亮色写在 style 上;讲别处名字的段 `data-offset` 记那个名字的下标。 - 异常(`languages/exceptions.test.ts`,真实解析 + 中文直译 + 视图数据):`fixtures/appExceptions.py` 的 `AppException`、`NotFound` 逐字同「异常」的样例(列名随有没有类型、说明变),`GatewayTimeout` 的多段说明,方法是子块;`fixtures/commerceExceptions.py` 16 个类都是异常、只有说明的四行、写了 `__init__` 的构造参数;认哪些类(自带的异常类、`Warning`、不合后缀的自带类 `StopIteration` / `KeyboardInterrupt`、`pkg.errors.AppException`、同一文件里写在后面的父类一层层往下传、泛型和别的父类混写,关键字参数、`Mapping[...]`、`*bases` 不算,函数里的类也认);类属性(带不带注解、只写注解、说明取上面的注释块 / 行尾注释 / 属性 docstring,连着赋值、解构赋值、方法、讲方法的注释、最后面的注释留作子块),构造参数去掉 self、`*args` 开头的不去;`@dataclass` 的异常类照样是异常卡片;编辑范围是整个类、改卡片写回整个类,类名、属性名、参数名的定义范围和带下标的词指回源码原文;TypeScript `fixtures/exceptions.ts.txt`(`extends Error`、类字段含 static、参数属性、JSDoc、写在后面的父类、类表达式、`ns.X`,父类名不合习惯的照常是类)、JavaScript 的类字段;TypeScript 和 Python 同样的异常得到一样的行和列名;筛选的种类叫「异常」、没有 badge;属性的中文名和去掉行尾记号。webview:异常块默认折叠(`viewState.test.ts`),跳到类名闪整块、属性名 / 构造参数名只闪表里那一行(`reveal.test.ts`)。 diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 96d99d7..711fb65 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -289,6 +289,12 @@ export function toBlockViews( translate: node => layerTranslator ? layerTranslator.expression(node, layerDocument) : [{ text: node.text, role: 'plain' }], doc: node => layerTranslator?.doc(node, layerDocument), lookup: layerRules.lookup ?? (() => undefined), + snippet: (node) => { + const startRow = node.startPosition.row + const end = node.endPosition + const endRow = end.column === 0 && end.row > startRow ? end.row - 1 : end.row + return sourceRows({ startIndex: node.startIndex, endIndex: node.endIndex, startRow, endRow }) + }, } /** 一行译文,连着摊开实参的表、块的说明(有才有)。 */ const textView = (block: TextBlock): { translation: PreviewText, table?: PreviewTable, doc?: Markdown } => { diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 91d35cb..cc8fa4d 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -594,11 +594,40 @@ export type PreviewText = Inline[] | Markdown */ export interface Defines { defines?: SourceRange } +/** + * 能单独打开编辑的一段源码:这几行整行,去掉共同缩进,着色和块的 code / codeTokens 同一套切法。 + * 网页不自己切。点铅笔之后只出现这一段,不换整张卡片。 + */ +export interface SourceSnippet { + /** 起止行,0 起,含头含尾。 */ + startLine: number + endLine: number + code: string + /** 着色,拼起来等于 code。 */ + codeTokens: Segment[] +} + +/** + * 属性表一个子行的某一格里,能单独打开的一段源码。 + * cell 是第几格(0 起),from / to 是这一格文字里这一处的范围(含头不含尾)。 + */ +export interface CellEdit { + cell: number + from: number + to: number + snippet: SourceSnippet +} + /** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值。 */ export interface PropertyItem extends Defines { cells: Inline[][] /** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项);一层层可以一直往下套。 */ items?: PropertyItem[] + /** + * 这一行里能单独打开的几段源码(状态「改它的」的每一处、读取的数据的「来自」)。 + * 打开后只出现那一段。没有能打开的就不写。 + */ + edits?: CellEdit[] /** * 排在这一行最后一格里、那格文字下面的表:实参表里一个实参的值里有跨几行写的调用时,是那个调用的实参表, * 表里的行又可以带 table,一层层往下套。总是展开的。 @@ -916,6 +945,8 @@ export interface RuleContext { displayLanguage?: string /** 用当前翻译插件把一个表达式或类型节点翻成一行译文(Translator.expression):名字带源码下标,能跳到定义、高亮各处出现;没有翻译插件时照抄源码。 */ translate: (node: Node) => Inline[] + /** 一个节点占的整行源码和着色(见 SourceSnippet),和块视图切行是同一套。规则把它挂在译文旁边。 */ + snippet: (node: Node) => SourceSnippet /** 用当前翻译插件取一段说明文字(Translator.doc);没有翻译插件、没有文字时没有。 */ doc: (node: Node) => Markdown | undefined /** diff --git a/vscode/src/codeReader/rules/react.test.ts b/vscode/src/codeReader/rules/react.test.ts index 7d19881..66c2899 100644 --- a/vscode/src/codeReader/rules/react.test.ts +++ b/vscode/src/codeReader/rules/react.test.ts @@ -64,7 +64,7 @@ describe('react 库规则:认领', () => { expect(propertyLines(properties(view)).slice(0, 4)).toEqual([ '名称: DesktopTitlebar ⟦具名导出|⟧ ⟦客户端组件|⟧', '说明: 窗口顶部 40px. 整条连同里面的文字都是拖拽区 (data-tauri-drag-region="deep"), 按钮除外. 侧栏开关只在侧栏顶部.', - '属性: 1 个', + '构造参数: 1 个', ' platform | DesktopShell["platform"] | — | 是', ]) }) @@ -83,13 +83,13 @@ describe('react 库规则:认领', () => { }) }) -describe('react 库规则:属性', () => { +describe('react 库规则:构造参数', () => { it('luffy 的 LegalDocPage:来自 LegalDocPageProps(名字能跳到定义),字段带说明时多一列说明', async () => { const view = await card(LEGAL, 'LegalDocPage') - const row = properties(view).find(item => item.label === '属性') + const row = properties(view).find(item => item.label === '构造参数') expect(row?.columns).toEqual(['名称', '类型', '默认值', '必填', '说明']) - expect(rowLines(view, '属性')).toEqual([ - '属性: 1 个 · 来自 LegalDocPageProps', + expect(rowLines(view, '构造参数')).toEqual([ + '构造参数: 1 个 · 来自 LegalDocPageProps', ' doc | "terms" | "privacy" | — | 是 | terms = 用户服务协议; privacy = 隐私政策', ]) const from = row && !('markdown' in row.value) ? row.value.at(-1) : undefined @@ -98,16 +98,16 @@ describe('react 库规则:属性', () => { }) it('luffy 的 ShareButton:回调标「回调」、类型写成函数签名;StepLabel 的 children 标「子元素」', async () => { - expect(rowLines(await card(SHARE, 'ShareButton'), '属性')).toEqual([ - '属性: 5 个', + expect(rowLines(await card(SHARE, 'ShareButton'), '构造参数')).toEqual([ + '构造参数: 5 个', ' open | 布尔 | — | 是 | ', ' onOpenChange ⟦回调|⟧ | (next: 布尔) => 无 | — | 是 | ', ' deployUrl | 字符串 | 空 | — | 是 | 已发布的持久 URL; null = 尚未发布 (点击后由父组件驱动发布)。', ' sessionId | 字符串 | — | 是 | ', ' title | 字符串 | — | 是 | ', ]) - expect(rowLines(await card(POSTER, 'StepLabel'), '属性')).toEqual([ - '属性: 2 个', + expect(rowLines(await card(POSTER, 'StepLabel'), '构造参数')).toEqual([ + '构造参数: 2 个', ' n | 数值 | — | 是', ' children ⟦子元素|⟧ | ReactNode | — | 是', ]) @@ -118,8 +118,8 @@ describe('react 库规则:属性', () => { 'interface Props { size?: number; onPick(v: string): void; label: string }', 'export const A = ({ size = 16, label: text, ...rest }: Props) => ;', ].join('\n') - expect(rowLines(await card(source, 'A'), '属性')).toEqual([ - '属性: 4 个 · 来自 Props', + expect(rowLines(await card(source, 'A'), '构造参数')).toEqual([ + '构造参数: 4 个 · 来自 Props', ' size | 数值 | 16 | 否', ' onPick ⟦回调|⟧ | (v: 字符串) => 无 | — | 是', ' label(组件里叫 text) | 字符串 | — | 是', @@ -133,8 +133,8 @@ describe('react 库规则:属性', () => { 'export const Card = ({ title, onClose = noop }: CardProps) =>
;', 'export function Empty() { return }', ].join('\n') - expect(rowLines(await card(source, 'Card'), '属性')).toEqual([ - '属性: 2 个 · 来自 CardProps', + expect(rowLines(await card(source, 'Card'), '构造参数')).toEqual([ + '构造参数: 2 个 · 来自 CardProps', ' title | 未标注 | — | —', ' onClose | 未标注 | noop | 否', ]) @@ -143,13 +143,13 @@ describe('react 库规则:属性', () => { { name: 'onClose', type: '() => void', optional: true, callback: true }, { name: 'size', type: 'number', optional: true, callback: false }, ]) - expect(rowLines(await card(source, 'Card', known({ CardProps: fields })), '属性')).toEqual([ - '属性: 3 个 · 来自 CardProps', + expect(rowLines(await card(source, 'Card', known({ CardProps: fields })), '构造参数')).toEqual([ + '构造参数: 3 个 · 来自 CardProps', ' title | string | — | 是 | 标题', ' onClose ⟦回调|⟧ | () => void | noop | 否 | ', ' size | number | — | 否 | ', ]) - expect(rowLines(await card(source, 'Empty'), '属性')).toEqual(['属性: 无']) + expect(rowLines(await card(source, 'Empty'), '构造参数')).toEqual(['构造参数: 无']) }) }) @@ -257,11 +257,37 @@ describe('react 库规则:分区', () => { }) }) +describe('单独打开一段源码', () => { + it('读取的数据挂的是这次调用,改它的里 JSX 那一处是整个属性', async () => { + const source = [ + 'export function Panel() {', + ' const pathname =', + ' usePathname()', + ' const [open, setOpen] = useState(false)', + ' return (', + ' ', + ' )', + '}', + ].join('\n') + const view = await card(source, 'Panel') + const read = properties(view).find(row => row.label === '读取的数据')?.items?.[0] + expect(read?.edits?.map(edit => edit.snippet.code)).toEqual(['usePathname()']) + const state = properties(view).find(row => row.label === '状态')?.items?.[0] + expect(state?.edits?.map(edit => edit.snippet.code)).toEqual(['onOpenChange={\n setOpen\n}']) + expect(view.code).toContain('function Panel') + expect(read?.edits?.[0].snippet.code).not.toContain('function Panel') + }) +}) + describe('react 库规则:渲染大纲', () => { it('luffy 的 DesktopTitlebar:整张卡片讲完了组件,没有留下的子块;渲染大纲每行一个元素,原生标签、组件、样式小标、属性、插值', async () => { const view = await card(TITLEBAR, 'DesktopTitlebar') expect(view.children).toEqual([]) - expect(properties(view).map(row => row.label)).toEqual(['名称', '说明', '属性', '读取的数据', '派生值', '渲染']) + expect(properties(view).map(row => row.label)).toEqual(['名称', '说明', '构造参数', '读取的数据', '派生值', '渲染']) expect(rowLines(view, '渲染')).toEqual([ '渲染: 1 处返回', ' div ⟦样式 8 个类|⟧ data-tauri-drag-region="deep"', @@ -388,7 +414,7 @@ describe('react 库规则:代码差异', () => { const componentChange = diff.blocks[component.anchor] expect(componentChange).toMatchObject({ change: 'mod', own: true }) - const propsIndex = properties(component).findIndex(row => row.label === '属性') + const propsIndex = properties(component).findIndex(row => row.label === '构造参数') expect(Object.keys(componentChange.marks?.rows?.changed ?? {})).toEqual([String(propsIndex)]) expect(componentChange.marks?.rows?.changed[propsIndex].items?.changed[1]).toEqual({ added: true }) expect(componentChange.marks?.rows?.removed).toEqual([]) diff --git a/vscode/src/codeReader/rules/react.ts b/vscode/src/codeReader/rules/react.ts index e839529..e209ad6 100644 --- a/vscode/src/codeReader/rules/react.ts +++ b/vscode/src/codeReader/rules/react.ts @@ -1,11 +1,11 @@ -import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, RuleSlot, SegmentGroup, ThemeColor, TokenRole, WorkspaceIndex } from '../contract' +import type { CellEdit, CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, RuleSlot, SegmentGroup, ThemeColor, TokenRole, WorkspaceIndex } from '../contract' import type { BindingFacts, BodyFacts, ClassFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, OutlineNode, Place, PropsFacts, ReadFacts } from './reactTree' import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentsBefore, commentText, componentAt, componentOf, componentsIn, declaredTypes, enclosingComponent, isComponentTag, isFunction, named, outlineOf, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree' import { hint, joined, name, plain, range } from './segments' /** * React 库规则:TSX / JSX 里的 React 函数组件(见 reactTree.ts 的 componentAt)整块换成「React 组件」卡片,不再是普通函数卡片的输入参数 / 输出参数。 - * 卡片:名称(后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签)/ 说明 / 属性(解构的每个属性一行,类型取属性类型里对应的字段; + * 卡片:名称(后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签)/ 说明 / 构造参数(解构的每个属性一行,类型取属性类型里对应的字段; * 属性类型是接口、类型别名时写来自哪个类型,导入的类型经工作区索引查它的字段),再按钩子分区,没有就不画: * 状态(useState / useReducer:名称 / 初始值 / 改它的,渲染时按条件重置状态的 if 也写进改它的)、引用(useRef)、 * 读取的数据(别的钩子:变量 / 来自 / 意思)、派生值(组件体里别的常量、useMemo / useCallback:名称 / 怎么算 / 什么时候算)、 @@ -123,7 +123,7 @@ const ZH: Words = { labels: { name: '名称', doc: '说明', - props: '属性', + props: '构造参数', state: '状态', refs: '引用', reads: '读取的数据', @@ -254,7 +254,7 @@ function docRows(component: ComponentFacts, { context, words }: Env): PropertyRo return doc ? [{ label: words.labels.doc, value: doc }] : [] } -// ─── 属性 ───────────────────────────────────────────────────────────────────── +// ─── 构造参数 ───────────────────────────────────────────────────────────────── /** 工作区里各文件顶层声明的接口、类型别名:名字 → 各字段的文字(JSON,见 FieldText),组件的属性类型是导入的时候查它。 */ const PROP_TYPES_INDEX: WorkspaceIndex = { @@ -357,7 +357,7 @@ function propDoc(entry: PropEntry): Inline[] { return text ? [{ text: text.replace(/\s*\n\s*/g, ' '), role: 'comment' }] : [] } -/** 属性:解构的每个属性一行(名称 / 类型 / 默认值 / 必填,有属性带说明时多一列说明);属性类型是一个名字时「属性」后面写来自哪里。 */ +/** 构造参数:解构的每个属性一行(名称 / 类型 / 默认值 / 必填,有属性带说明时多一列说明);属性类型是一个名字时「构造参数」后面写来自哪里。 */ function propsRow(component: ComponentFacts, env: Env): PropertyRow { const { context, words } = env const props = propsOf(component) @@ -487,16 +487,54 @@ function commentLine(comments: readonly Node[]): string | undefined { // ─── 状态、引用、读取的数据、派生值 ────────────────────────────────────────── /** 状态:名称 / 初始值 / 改它的(改状态的函数,括号里写在哪儿改:副作用、处理函数、渲染时按条件重置……)。 */ +/** 这一处改状态的源码:JSX 取包着值的整个属性,处理函数取函数,派生值取那个值,另一处读取取那次调用,重置取条件。纯渲染没有节点。 */ +function placeNode(place: Place, body: BodyFacts): Node | undefined { + switch (place.kind) { + case 'jsx': + return place.attribute.parent?.type === 'jsx_attribute' ? place.attribute.parent : undefined + case 'handler': + return body.handlers.find(handler => sameNode(handler.name, place.name))?.fn + case 'derived': + return body.derived.find(item => sameNode(item.name, place.name))?.value + case 'read': + return body.reads.find(read => sameNode(read.target, place.target))?.call + case 'reset': + return place.condition + case 'effect': + return body.effects[place.index]?.statement + default: + return undefined + } +} + +/** 「改它的」这一格:函数名,括号里每一处各挂自己的源码。 */ +function changesOf(analysis: Analysis, setter: Node, env: Env): { cell: Inline[], edits: CellEdit[] } { + const places = placesOf(setter, analysis.component, analysis.body) + const parts = places.map(place => placeText(place, analysis, env)) + const cell = [name(setter, 'function'), ...inParens(parts, env)] + const edits: CellEdit[] = [] + let at = 2 + parts.forEach((part, index) => { + const node = placeNode(places[index], analysis.body) + if (node) + edits.push({ cell: 2, from: at, to: at + part.length, snippet: env.context.snippet(node) }) + at += part.length + (index < parts.length - 1 ? 1 : 0) + }) + return { cell, edits } +} + function stateRow(analysis: Analysis, env: Env): PropertyRow { - const { component, body } = analysis + const { body } = analysis const { context, words } = env const items = body.states.map((state): PropertyItem => { const initial = state.initial ? context.translate(state.initial) : [hint(words.nothing)] const reducer = state.reducer ? [hint(words.parens[0]), ...context.translate(state.reducer), hint(words.parens[1])] : [] - const changes = state.setter - ? [name(state.setter, 'function'), ...inParens(placesOf(state.setter, component, body).map(place => placeText(place, analysis, env)), env)] - : [hint(words.nothing)] - return { cells: [binding(state.value, 'variable', env), [...initial, ...reducer], changes], defines: range(state.value) } + const changes = state.setter ? changesOf(analysis, state.setter, env) : { cell: [hint(words.nothing)], edits: [] } + return { + cells: [binding(state.value, 'variable', env), [...initial, ...reducer], changes.cell], + ...(changes.edits.length > 0 && { edits: changes.edits }), + defines: range(state.value), + } }) return { label: words.labels.state, value: [hint(words.count(items.length))], items, columns: words.heads.state } } @@ -601,10 +639,14 @@ function readMeaning(read: ReadFacts, env: Env): Inline[] { /** 读取的数据:变量 / 来自(钩子调用照常翻)/ 意思。 */ function readRow(analysis: Analysis, env: Env): PropertyRow { const { context, words } = env - const items = analysis.body.reads.map((read): PropertyItem => ({ - cells: [binding(read.target, 'variable', env), context.translate(read.call), readMeaning(read, env)], - defines: range(read.target), - })) + const items = analysis.body.reads.map((read): PropertyItem => { + const from = context.translate(read.call) + return { + cells: [binding(read.target, 'variable', env), from, readMeaning(read, env)], + edits: [{ cell: 1, from: 0, to: from.length, snippet: context.snippet(read.call) }], + defines: range(read.target), + } + }) return { label: words.labels.reads, value: [hint(words.count(items.length))], items, columns: words.heads.reads } } diff --git a/vscode/src/codeReader/rules/reactTree.ts b/vscode/src/codeReader/rules/reactTree.ts index 0ba5f29..8de03b7 100644 --- a/vscode/src/codeReader/rules/reactTree.ts +++ b/vscode/src/codeReader/rules/reactTree.ts @@ -1,7 +1,7 @@ import type { CodeBlock, Node } from '../contract' /** - * 读 React 函数组件的语法树(React 库规则用,见 react.ts):认组件,读出它的属性、组件体里的各种钩子、处理函数和渲染的 JSX。 + * 读 React 函数组件的语法树(React 库规则用,见 react.ts):认组件,读出它的构造参数、组件体里的各种钩子、处理函数和渲染的 JSX。 * 只读语法树、不翻译,读出来的节点都指向源码;怎么排成卡片、用什么词归 react.ts。 */ diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index d07ab33..8700387 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -11,10 +11,11 @@ import { BlockLevel } from './BlockTree' import { DiffBar, NewFileBanner } from './DiffBar' import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView' import { acceptNames, DisplayNameContext, documentNames } from './displayNames' -import { coveredByEditing, startEditing } from './editing' +import { coveredByEditing, snippetRefs, startEditing } from './editing' import { applyFilter, excludedBy, keptWhileEditing } from './filter' import { FilterBar } from './FilterBar' import { OverviewRuler } from './OverviewRuler' +import { SnippetEditContext } from './PropertyGrid' import { asksOf, historyOf, PATH_ASK_ANCHOR } from './qa' import { useChangeNav } from './useChangeNav' import { useDefinitionLinks } from './useDefinitionLinks' @@ -67,6 +68,7 @@ export function App(): ReactElement | null { const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds, openedByDiff(changes)), [doc, view.folds, changes]) const filter = useMemo(() => applyFilter(doc?.blocks ?? [], excluded, keep), [doc, excluded, keep]) const covered = useMemo(() => coveredByEditing(doc?.blocks ?? [], new Set(view.editing)), [doc, view.editing]) + const snippetIds = useMemo(() => new Map(snippetRefs(doc?.blocks ?? []).map(ref => [ref.edit, ref.id])), [doc]) /** 「只看改动」开着(整个工作区共用,扩展端记着);本地不改,改了发给扩展端、等它发回来 */ const [onlyChanges, setOnlyChanges] = useState(false) /** 「只看改动」时点开过的几段没变的块(按第一块的 id),筛选一变就重新折 */ @@ -176,6 +178,7 @@ export function App(): ReactElement | null { return ( + :
所有块都被筛选隐藏了。
}
+ ) diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 545b14e..5d40ab2 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -270,7 +270,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): {editing ? ( void @@ -17,11 +25,11 @@ interface CodeEditorProps { } /** 一块源码的编辑状态:着色层显示,透明输入框接收输入,改动防抖后整行替换回文件。 */ -export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement { - const [text, setText] = useState(block.code) +export function CodeEditor({ source, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement { + const [text, setText] = useState(source.code) const textareaRef = useRef(null) - const blockRef = useRef(block) - blockRef.current = block + const sourceRef = useRef(source) + sourceRef.current = source const textRef = useRef(text) textRef.current = text /** 本地改过、还没在新文档里看到的文字;有它时新文档不能覆盖有焦点的输入框 */ @@ -37,7 +45,7 @@ export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }: if (!unsent.current) return unsent.current = false - const { startLine, endLine } = blockRef.current + const { startLine, endLine } = sourceRef.current const value = textRef.current postMessage({ type: 'edit', @@ -51,16 +59,16 @@ export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }: useEffect(() => { const el = textareaRef.current const focused = el !== null && document.activeElement === el - if (focused && unconfirmed.current !== null && unconfirmed.current !== block.code) + if (focused && unconfirmed.current !== null && unconfirmed.current !== source.code) return unconfirmed.current = null sentLineCount.current = null - if (block.code === textRef.current) + if (source.code === textRef.current) return if (focused) restoreSelection.current = [el.selectionStart, el.selectionEnd] - setText(block.code) - }, [version, block.code]) + setText(source.code) + }, [version, source.code]) useLayoutEffect(() => { const el = textareaRef.current @@ -121,7 +129,7 @@ export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }: onBlur={flush} /> diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index 696cf42..2686336 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -1,7 +1,8 @@ import type { CSSProperties, ReactElement, ReactNode } from 'react' -import type { BlockView, Inline, InlinePart, ItemDiff, ItemsDiff, KindBadge, PropertyItem, PropertyRow, Removed, RowsDiff } from './types' -import { ChevronDown, ChevronRight } from 'lucide-react' -import { Fragment } from 'react' +import type { BlockView, CellEdit, Inline, InlinePart, ItemDiff, ItemsDiff, KindBadge, PropertyItem, PropertyRow, Removed, RowsDiff } from './types' +import { ChevronDown, ChevronRight, Eye, Pencil } from 'lucide-react' +import { createContext, Fragment, useContext } from 'react' +import { CodeEditor } from './CodeEditor' import { DiffInline, DiffText } from './DiffText' import { inlineText } from './segments' import { SegmentText } from './SegmentText' @@ -56,6 +57,83 @@ function CellText({ cell, parts }: { cell: Inline[], parts: InlinePart[] | undef return parts ? : } +/** 格子里单独打开的那段源码:和块共用同一套铅笔、同一个编辑框。 */ +export interface SnippetEditState { + ids: ReadonlyMap + editing: ReadonlySet + onToggle: (id: string) => void + version: number + focusId: string | null + onFocused: () => void +} + +export const SnippetEditContext = createContext(null) + +function EditToggle({ editing, onToggle }: { editing: boolean, onToggle: () => void }): ReactElement { + const label = editing ? '预览' : '编辑' + const Icon = editing ? Eye : Pencil + return ( + + ) +} + +function SnippetSpan({ edit, segments }: { edit: CellEdit, segments: Inline[] }): ReactElement { + const ctx = useContext(SnippetEditContext) + const id = ctx?.ids.get(edit) + if (!ctx || id === undefined) + return + const editing = ctx.editing.has(id) + return ( + + {editing + ? ( + + ctx.onToggle(id)} + /> + + ) + : } + ctx.onToggle(id)} /> + + ) +} + +function CellView({ cell, index, edits, parts }: { + cell: Inline[] + index: number + edits: CellEdit[] | undefined + parts: InlinePart[] | undefined +}): ReactElement { + const mine = (edits ?? []).filter(edit => edit.cell === index).sort((a, b) => a.from - b.from) + if (mine.length === 0) + return + const chunks: ReactElement[] = [] + let at = 0 + mine.forEach((edit, n) => { + if (edit.from > at) + chunks.push() + chunks.push() + at = edit.to + }) + if (at < cell.length) + chunks.push() + return <>{chunks} +} + /** * 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换; * 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。 @@ -115,9 +193,9 @@ function PropertyItems({ parentId, items, folding, marks }: { parentId: string, {item.cells.map((cell, i) => i === 0 && nested ? 0 && change !== 'del'} folding={folds} /> : ( -

- -

+
+ +
))} {children.length > 0 && !folds.collapsed.has(id) && (change === 'del' @@ -177,10 +255,10 @@ export function PropertyTable({ parentId, columns, items, marks, toggles }: { {toggle && i === 0 ? ( ) - : } + : } ) const tint = toggle?.open && toggle.badge @@ -191,11 +269,11 @@ export function PropertyTable({ parentId, columns, items, marks, toggles }: { {cells.map((cell, i) => item.table && i === cells.length - 1 ? (
-

{text(cell, i)}

+
{text(cell, i)}
) - :

{text(cell, i)}

)} + :
{text(cell, i)}
)} ) } diff --git a/vscode/webview-ui/src/codeReader/editing.test.ts b/vscode/webview-ui/src/codeReader/editing.test.ts index 21d282a..f8c2226 100644 --- a/vscode/webview-ui/src/codeReader/editing.test.ts +++ b/vscode/webview-ui/src/codeReader/editing.test.ts @@ -63,4 +63,24 @@ describe('同一行不会同时在两处能改', () => { const plain = revealPlan(BLOCKS, 15, 999) expect(plain && uncoveredPlan(plain, covered)).toEqual(plain) }) + + it('格子里单独打开的一段,和盖住它的块、跟它重叠的另一段不能同时编辑', () => { + const snippet = (startLine: number, endLine: number) => ({ startLine, endLine, code: 'x', codeTokens: [] }) + const card = block('1', 'react.component', [0, 20]) + card.properties = [{ + label: '状态', + value: [], + items: [{ + cells: [[{ text: 'a', role: 'plain' }], [{ text: 'b', role: 'plain' }]], + edits: [ + { cell: 0, from: 0, to: 1, snippet: snippet(4, 6) }, + { cell: 1, from: 0, to: 1, snippet: snippet(6, 7) }, + ], + }], + }] + const blocks = [card, block('2', 'function', [30, 32])] + expect(startEditing(blocks, ['1~0', '2'], '1')).toEqual(['2', '1']) + expect(startEditing(blocks, ['1', '2'], '1~0')).toEqual(['2', '1~0']) + expect(startEditing(blocks, ['1~0', '2'], '1~1')).toEqual(['2', '1~1']) + }) }) diff --git a/vscode/webview-ui/src/codeReader/editing.ts b/vscode/webview-ui/src/codeReader/editing.ts index 3058d30..5c25f61 100644 --- a/vscode/webview-ui/src/codeReader/editing.ts +++ b/vscode/webview-ui/src/codeReader/editing.ts @@ -1,9 +1,11 @@ -import type { BlockView } from './types' +import type { BlockView, CellEdit, PropertyItem } from './types' /** * 同一时刻能编辑的几段源码不重叠,同一行不会同时在两处能改: * ① 块的编辑范围是它的完整源码,子孙都在它的编辑框里:块编辑时它的子孙都不画; - * ② 点「编辑」时,编辑范围和这块重叠的别的块(祖先、子孙,`} else {` 这样共用一行的兄弟块)退出编辑。 + * ② 点「编辑」时,编辑范围和这块重叠的别的块(祖先、子孙,`} else {` 这样共用一行的兄弟块)退出编辑; + * ③ 属性表格子里单独打开的那一段(见 CellEdit)行号落在组件块里面,和块同一条规矩:重叠就不能同时编辑。 + * 组件正在编辑时预览不画,里面这段的编辑器也不画。 */ type Lines = Pick @@ -41,12 +43,51 @@ function* allBlocks(blocks: readonly BlockView[]): Generator { } /** 点了 id 这块的「编辑」之后正在编辑的块:加上它,编辑范围和它重叠的别的块退出编辑。 */ +export interface SnippetRef { + id: string + edit: CellEdit + startLine: number + endLine: number +} + +/** 各块属性表里能单独打开的源码,按画出来的顺序编号。id 是「块 id~序号」。 */ +export function snippetRefs(blocks: readonly BlockView[]): SnippetRef[] { + const refs: SnippetRef[] = [] + const take = (blockId: string, items: PropertyItem[] | undefined, n: number): number => { + for (const item of items ?? []) { + for (const edit of item.edits ?? []) { + refs.push({ id: `${blockId}~${n}`, edit, startLine: edit.snippet.startLine, endLine: edit.snippet.endLine }) + n += 1 + } + n = take(blockId, item.items, n) + n = take(blockId, item.table?.items, n) + } + return n + } + for (const block of allBlocks(blocks)) { + let n = 0 + if ('properties' in block) { + for (const row of block.properties) + n = take(block.id, row.items, n) + } + else if (block.table) { + take(block.id, block.table.items, 0) + } + } + return refs +} + +/** 点了 id 这段的「编辑」之后正在编辑的几段:加上它,行重叠的别的段(块,或格子里的一段源码)退出编辑。 */ export function startEditing(blocks: readonly BlockView[], editing: readonly string[], id: string): string[] { - const byId = new Map([...allBlocks(blocks)].map(block => [block.id, block])) + const byId = new Map() + for (const block of allBlocks(blocks)) + byId.set(block.id, block) + for (const ref of snippetRefs(blocks)) + byId.set(ref.id, ref) const target = byId.get(id) const others = editing.filter((other) => { - const block = byId.get(other) - return other !== id && !(target && block && overlaps(target, block)) + const span = byId.get(other) + return other !== id && !(target && span && overlaps(target, span)) }) return [...others, id] } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 6c3d405..4dec0ee 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -66,11 +66,30 @@ export interface SourceRange { startIndex: number, endIndex: number } */ export interface Defines { defines?: SourceRange } +/** 能单独打开编辑的一段源码:整行切好、着色好,和块的 code / codeTokens 同一套。网页不自己切 */ +export interface SourceSnippet { + /** 起止行,0 起,含头含尾 */ + startLine: number + endLine: number + code: string + codeTokens: Segment[] +} + +/** 属性表一个子行的某一格里,能单独打开的一段源码。cell 是第几格,from / to 是这一格文字里这一处(含头不含尾) */ +export interface CellEdit { + cell: number + from: number + to: number + snippet: SourceSnippet +} + /** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值 */ export interface PropertyItem extends Defines { cells: Inline[][] /** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项),一层层可以一直往下套 */ items?: PropertyItem[] + /** 这一行里能单独打开的几段源码。打开后只出现那一段。没有能打开的就不写 */ + edits?: CellEdit[] /** 排在这一行最后一格里、那格文字下面的表(实参的值里跨几行写的调用的实参表),表里的行又可以带 table;总是展开 */ table?: PreviewTable }