✨ feat(vscode): React 文件照源码一块一张卡片,组件卡片只留名称、说明、构造参数

- 组件体的语句不再并进组件卡片,照源码顺序是它的子块;状态、引用、数据源、派生值、副作用、事件处理函数、渲染输出各成一张卡片,回调和函数里的语句是这些卡片下面的子卡片
- 普通变量、提前返回的 if、注释照语言默认的卡片;表格格子里只写名字和位置,去掉「改它的」「来自」格子里单独打开源码的那条路
- 用词改成书面叫法(更新位置、使用位置、计算表达式、依赖项、触发时机、清理函数、绑定事件、样式取决于……),新增 toggle-right、anchor、download、sigma、layout-template 五个种类图标
- 夹具加上 luffy 的 QueuedBubble.tsx、PlanChecklist.tsx,规格的 React 一节照新样子改写
This commit is contained in:
cruldra committed 2026-10-08 07:17:25 +08:00
1 parent 194544b4ba
commit 6ce4ef7ee2
20 files changed
+1327 -989

No files matched your search

+85 -46
View File
@@ -1561,76 +1561,115 @@ export interface CommandRule {
### 内置:React 组件(TypeScript / JavaScript)
`vscode/src/codeReader/rules/react.ts`(读语法树的办法在 `rules/reactTree.ts`,都是纯函数),`languageIds: ['typescriptreact', 'javascriptreact']`,`libraries: []`(新的 JSX 写法不用导入 react,TSX / JSX 文件都启用)。原型:`code.js` 的「React 组件」一节(`TITLEBAR_CARD`、`LEGAL_CARD`、`reactCard`),插件菜单「React 组件」那一页的四张画板;样例是 luffy 的 `components/desktop/DesktopTitlebar.tsx`(`fixtures/desktopTitlebar.tsx.txt`,简单)、`components/LegalDocPage.tsx`(`fixtures/legalDocPage.tsx.txt`,复杂),说明板的例子 `DeleteAppDialog`、`DesktopSettingsDialog`、`AppVaultSummary`、`ShareButton`、`PosterDialog` 也复制进了 `fixtures/`。三种卡片:
`vscode/src/codeReader/rules/react.ts`(读语法树的办法在 `rules/reactTree.ts`,都是纯函数),`languageIds: ['typescriptreact', 'javascriptreact']`,`libraries: []`(新的 JSX 写法不用导入 react,TSX / JSX 文件都启用)。原型:`docs/prototype/code-reader/openpencil/react.js` 的三张画板「编辑器全貌」「卡片种类」「对照」(导出图 `exports/react-main.png`、`react-kinds.png`、`react-compare.png`);样例是 luffy 的 `chat/code/bubbles/QueuedBubble.tsx`(`fixtures/queuedBubble.tsx.txt`)和 `chat/code/PlanChecklist.tsx` 第 91–226 行(`fixtures/planChecklist.tsx.txt`,前面带上文件开头的导入),以前的 `DesktopTitlebar`、`LegalDocPage`、`DeleteAppDialog`、`DesktopSettingsDialog`、`AppVaultSummary`、`ShareButton`、`PosterDialog`、`OverviewSection` 也在 `fixtures/`。
- 组件:种类 `react.component`,名字「React 组件」,图标 `atom`,颜色暗 #61dafb / 亮 #0a7ea4(组件色,渲染大纲里的组件、「客户端组件」标签同色),和函数块一样默认收起留下的子块。
- 副作用:种类 `react.effect`,名字「副作用」,图标 `zap`,颜色暗 #e5c07b / 亮 #9a6a00。
- 事件处理:种类 `react.handler`,名字「事件处理」,图标 `mouse-pointer-click`,颜色暗 #dcdcaa / 亮 #795e26。
TSX 和 Python 一样,从上往下一块语法一张卡片,嵌套的块排在父卡片下面,缩进、竖线、折叠同函数体。规则只换掉 React 的几种写法,别的块(导入、接口、类型、常量、普通变量、if、注释、尝试……)照语言默认的卡片。组件卡片不把组件体并进来:组件体的语句照源码顺序是它的子块,照常交给各规则看。表格的格子里只写名字和位置,函数体只出现在子卡片里。
认哪些是组件:名字大写开头;是函数声明、值是函数的变量(箭头函数、函数表达式,直接返回 JSX 的也算),或被 `memo(…)` / `forwardRef(…)`(`React.memo` 也算,可以套几层)包着的函数;函数自己的 return(嵌套函数里的不算)里有一处返回 JSX(元素、片段,三元、`&&` 的某一支是 JSX 也算;和 `null` 混着返回也算)。小写开头的工具函数、不返回 JSX 的大写函数照常是函数块。组件里又定义的组件自己也是一张组件卡片,不算处理函数。
卡片种类(颜色写暗 / 亮):
整块换成组件卡片,不再是普通函数卡片的输入参数 / 输出参数。文件开头的 `"use client"`(文件里有组件时)和组件上面没并进块头部的 JSDoc(值是 `memo(…)`、直接返回 JSX 的常量块)略去(`RuleOmit`),讲成卡片上的「客户端组件」标签和「说明」。属性表(有才显示,没有就不画):
| 种类 id | 名字 | 图标 | 颜色 | 认哪一块 | 行 |
| --- | --- | --- | --- | --- | --- |
| `react.component` | React 组件 | `atom` | #61dafb / #0a7ea4 | 组件的声明 | 名称 / 说明 / 构造参数 |
| `react.state` | 状态 | `toggle-right` | #9cdcfe / #001080 | `const [v, setV] = useState(…)`、`useReducer(…)` | 名称 / 初始值 / 更新位置 |
| `react.ref` | 引用 | `anchor` | #9cdcfe / #001080 | `useRef(…)` | 名称 / 初始值 / 使用位置 |
| `react.source` | 数据源 | `download` | #9cdcfe / #001080 | 别的钩子赋给变量(`useQuery`、`usePathname`、`useRouter`、自定义 `useXxx`) | 变量 / 钩子 / 说明 |
| `react.derived` | 派生值 | `sigma` | #9cdcfe / #001080 | `useMemo`、`useCallback` | 名称 / 计算表达式 / 依赖项 |
| `react.effect` | 副作用 | `zap` | #e5c07b / #9a6a00 | `useEffect`、`useLayoutEffect`、`useInsertionEffect` | 触发时机 / 清理函数 |
| `react.handler` | 事件处理函数 | `mouse-pointer-click` | #dcdcaa / #795e26 | 组件体里定义的函数(函数声明、值是函数的常量) | 名称 / 参数 / 绑定事件 |
| `react.render` | 渲染输出 | `layout-template` | #89d185 / #388a34 | 组件函数自己返回 JSX 的 return | JSX 结构大纲 |
组件色同时用在渲染大纲里的组件、「客户端组件」标签上;状态、引用、数据源、派生值都是数据,共用变量块的浅蓝,靠图标区分;副作用同「选项」块的黄,事件处理函数同「函数」块,渲染输出同「返回」块的绿。筛选栏的「种类」按这几个 id 筛,叫上表的名字。
认哪些是组件:名字大写开头;是函数声明、值是函数的变量(箭头函数、函数表达式,直接返回 JSX 的也算),或被 `memo(…)` / `forwardRef(…)`(`React.memo` 也算,可以套几层)包着的函数;函数自己的 return(嵌套函数里的不算)里有一处返回 JSX(元素、片段,三元、`&&` 的某一支是 JSX 也算;和 `null` 混着返回也算)。小写开头的工具函数、不返回 JSX 的大写函数照常是函数块。组件里又定义的组件自己也是一张组件卡片,不算事件处理函数。
认领(`LibraryRule.claim` 一块一块看,组件卡片的 `absorbs` 是空的):
- 组件块(函数块、只声明一个变量的常量 / 变量块)是组件时换成组件卡片。文件开头的 `"use client"`(文件里有组件时)和组件上面没并进块头部的 JSDoc(值是 `memo(…)`、直接返回 JSX 的常量块)略去(`RuleOmit`),讲成卡片上的「客户端组件」标签和「说明」。
- 组件体里的块:语法树往上找,块的语句直接是某个组件函数主体(语句块)的子语句才认(`enclosingComponent`:所在的函数经 `componentOfFunction` 找回它的声明,再用认组件的 `componentAt` 核对),按组件体的归类(`bodyOf`)认成状态、引用、数据源、派生值、副作用、事件处理函数。
- `return` 块:所在的最内层函数(`enclosingFunction`)是组件函数、返回的是 JSX 时认成渲染输出;写在 `if` 里的提前返回 JSX 也算,它是如果卡片下面的子块。返回 `null` 这类的照常是返回块。
- 别的照语言默认:组件体里普通的 `const x = 表达式` 是变量卡片,`if (…) return null` 是如果卡片、下面是返回卡片,注释照常成块(不再并进卡片)。
- 回调、函数里的语句来自 TypeScript 语言插件本来给的子块:调用的实参是带语句块的函数时(任何调用都一样),那句语句、那个变量的子块就是函数里的语句,所以 `useEffect(() => {…})`、`useCallback(() => {…})`、`useMemo(() => {…})` 的语句排在副作用、派生值卡片下面;事件处理函数是函数块,函数体照常是子块。
组件卡片:
| 行 | 内容 |
| --- | --- |
| 名称 | 组件名(组件色,带下标);后面跟小标签(`tag` 框):「具名导出」「默认导出」(同 import / export 关键字的颜色;`export default X`、`export { X }` 写在文件别处也算)、「客户端组件」(组件色)、「记忆化」「转发 ref」(同 const 关键字的颜色,memo / forwardRef 包着) |
| 说明 | 组件上面的 JSDoc,Markdown |
| 构造参数 | `N 个`(hint);属性类型写的是一个名字(接口、类型别名、导入的类型)时后面写「 · 来自 XxxProps」,名字带下标(⌘ + 点击跳到类型)。下面一张表格「名称 \| 类型 \| 默认值 \| 必填」,有属性带说明时多一列「说明」。没写参数时写「无」 |
| 状态 | useState / useReducer:「名称 \| 初始值 \| 改它的」 |
| 引用 | useRef:「名称 \| 初始值 \| 用在」 |
| 读取的数据 | 别的钩子(`useXxx(…)` 赋给变量):「变量 \| 来自 \| 意思」 |
| 派生值 | 组件体里别的常量,和 useMemo / useCallback:「名称 \| 怎么算 \| 什么时候算」 |
| 副作用 | `N 个`(hint),下面每个 useEffect / useLayoutEffect / useInsertionEffect 一张副作用子卡片 |
| 事件处理 | 组件体里定义的函数(函数声明、值是函数的常量):「名称 \| 绑定在 \| 做什么」,点名称在表下面展开它的子卡片 |
| 渲染 | `N 处返回`(hint),下面是返回的 JSX 画成的结构大纲 |
| 构造参数 | `N 个`(hint);属性类型写的是一个名字(接口、类型别名、导入的类型)时后面写「 · 类型 XxxProps」,名字带下标(⌘ + 点击跳到类型)。下面一张表格「名称 \| 类型 \| 默认值 \| 必填」,有属性带说明时多一列「说明」。没写参数时写「无」 |
构造参数:属性类型的字段照类型里的顺序一行一个(类型字面量、同文件的接口 / 类型别名,交叉类型各部分接起来,`Readonly<…>`、`PropsWithChildren<…>` 看里面;没标注参数类型时 `const X: FC<Props>` 的泛型实参、`forwardRef<R, Props>` 的第二个泛型实参),解构里有、类型里没有的接在后面,`...rest` 最后(类型写「其余的属性」)。名称是属性名(定义处带下标:解构出来的是解构那处,没解构的是字段;跳到定义只闪这一行),`children` 后面标「子元素」、函数类型的属性(`(x) => R`、方法签名)标「回调」(组件色的小标签),解构时改了名写「(组件里叫 x)」;类型照翻译插件翻,方法签名写成函数签名;默认值取解构的默认值,没有写「—」;必填看类型的 `?`(是 / 否,storage 色),没类型可看时写了默认值的「否」、别的「—」;说明是字段上面的 JSDoc / 注释、这一行行尾的注释(comment 色,几行接成一行)。类型是导入的名字时经工作区索引查它的字段(索引 `react.propTypes`:看工作区里所有 `**/*.{ts,tsx}`,预筛有 `interface X` / `type X` 才解析,按 typescriptreact 解析;从文件顶层认出接口、类型别名,键是名字、值是各字段的名字、类型写法、可省略、回调、说明的 JSON),查法同「规则认领时查别的文件里的定义」:先照解构的出行、类型「未标注」,查到了按字段重排、重发;别的文件里的类型照写文字(type 色)。
直接返回 JSX 的箭头函数组件(`const Tag = ({ text }) => <span>{text}</span>`)没有子块可放,它渲染的大纲作为第四行「JSX 结构大纲」写在组件卡片里。
状态、引用、读取的数据、派生值:
构造参数:属性类型的字段照类型里的顺序一行一个(类型字面量、同文件的接口 / 类型别名,交叉类型各部分接起来,`Readonly<…>`、`PropsWithChildren<…>` 看里面;没标注参数类型时 `const X: FC<Props>` 的泛型实参、`forwardRef<R, Props>` 的第二个泛型实参),解构里有、类型里没有的接在后面,`...rest` 最后(类型写「其余的属性」)。名称是属性名(定义处带下标:解构出来的是解构那处,没解构的是字段;跳到定义只闪这一行),`children` 后面标「子元素」、函数类型的属性(`(x) => R`、方法签名)标「回调」(组件色的小标签),解构时改了名写「(组件内名为 x)」;类型照翻译插件翻,方法签名写成函数签名;默认值取解构的默认值,没有写「—」;必填看类型的 `?`(是 / 否,storage 色),没类型可看时写了默认值的「否」、别的「—」;说明是字段上面的 JSDoc / 注释、这一行行尾的注释(comment 色,几行接成一行)。类型是导入的名字时经工作区索引查它的字段(索引 `react.propTypes`:看工作区里所有 `**/*.{ts,tsx}`,预筛有 `interface X` / `type X` 才解析,按 typescriptreact 解析;从文件顶层认出接口、类型别名,键是名字、值是各字段的名字、类型写法、可省略、回调、说明的 JSON),查法同「规则认领时查别的文件里的定义」:先照解构的出行、类型「未标注」,查到了按字段重排、重发;别的文件里的类型照写文字(type 色)。
- 名称、变量带下标(解构模式照常翻),跳到定义只闪这一行。
- 状态的初始值:useState 的实参、useReducer 的第二个实参(后面括号里写 reducer);改它的:改状态的函数名(function 色,带下标),后面括号里写在哪儿改,「;」隔开:「副作用」(几个时「副作用 2」)、处理函数名、派生值名、「<input> 的 onChange(传 e.target.value)」(绑在 JSX 属性上;在列表里一项项渲染的前面加「每个」)、「渲染时,当 … 时」(渲染时按条件重置状态的 `if (…) { setX(…) }`:主体都是在调改状态的函数,这句并进卡片)。
- 引用的用在:「挂在 <main> 的 ref 上」(元素写成 `<Tag>`:组件用组件色、能跳到定义(`refers` 到标签名),原生标签灰色,带 `aria-label` / `title` 字符串时后面写「(返回)」),别处用到的「副作用 1、handleSectionClick 里用它」。
- 读取的数据的来自照常翻;意思:库自带的钩子照词表(next/navigation 的 `usePathname` 当前路径、`useRouter` 路由器,跳转用、`useSearchParams`、`useParams`、`useSelectedLayoutSegment(s)`,react 的 `useId`、`useTransition`、`useSyncExternalStore`、`useOptimistic`、`useActionState`、`useDeferredValue`,react-dom 的 `useFormStatus`);`useContext(X)` 是「从 X 上下文读」;传选择器的 `useXxx((s) => s.a.b)` 是「从 xxx store 读 a.b」(store 名:导入路径最后一节是 store 时取上一节,`@/lib/desktop/store` 是 desktop;否则去掉 store、use 字样,再不行取钩子名),选择器不是直接取属性的写「从 xxx store 算 …」;自己项目里的(相对路径、`@/` 这类别名、同文件)是「自定义钩子」,别的库的是「xxx 的钩子」(hint)。
- 派生值的怎么算照常翻:useMemo 是算值的那个表达式,useCallback 写「函数 」加函数;主体是语句块时写「N 句」,那句照常是块、放进这一行对应的子行上,点名称展开(useCallback 的用事件处理子卡片,useMemo 的照常是变量块);什么时候算:普通常量「每次渲染」,useMemo / useCallback 按依赖读(同副作用的时机)、后面写「(useMemo)」。
状态、引用、数据源、派生值(名称、变量带下标,解构模式照常翻;跳到定义闪这张卡片):
副作用子卡片(组件体里那句 `useEffect(…)` 照常是块,被规则认成这张卡片,放进卡片「副作用」那一行下面):时机按依赖数组读,`[a, b]` 是「a、b 变了时」,`[]` 是「只在挂载时」,没写是「每次渲染后」(keyword 色),useLayoutEffect 后面加「,浏览器绘制前」;做什么是紧贴在上面的注释(几行接成一行,hint 色;这段注释并进组件卡片),没写注释时写「N 句」;副作用函数里的语句照常是块,放进「做什么」那一行下面;最后返回清理函数时那句 return 并进来,多一行「清理」:`() => …` 翻它的主体,语句块一句句翻、「;」隔开。
- 状态的初始值:useState 的实参、useReducer 的第二个实参(后面括号里写 reducer)。更新位置:调用改状态函数的各处,只写名字和位置,「、」隔开,写出来一样的只留一处,没有时「—」:「副作用」(几个时「副作用 2」,副作用色)、事件处理函数名 / 派生值名 / 变量名 / 数据源的变量名(带下标,⌘ + 点击跳到那张卡片)、「<textarea>(排队消息内容) 的 onChange」(绑在 JSX 属性上;元素写成 `<Tag>`:组件用组件色、能跳到定义(`refers` 到标签名),原生标签灰色,带 `aria-label` / `title` 字符串时后面写上它;在列表里一项项渲染的前面加「每个」)、「渲染期间,当 … 时」(渲染时按条件重置状态的 `if (…) { setX(…) }`,这句 if 照常是如果卡片)、「渲染输出」(JSX 里别处用到)。
- 引用的使用位置:「<textarea>(排队消息内容) 的 ref 属性」,别处用到的「;」接在后面、几处「、」隔开:「<main> 的 ref 属性;副作用 1、副作用 2、handleSectionClick」。
- 数据源的钩子是这次调用,照常翻;说明:库自带的钩子照词表(next/navigation 的 `usePathname` 当前路径、`useRouter` 路由器,用于页面跳转、`useSearchParams` URL 查询参数、`useParams` 路由动态参数、`useSelectedLayoutSegment(s)` 当前激活的下一级 / 各级布局段,react 的 `useId`、`useTransition`、`useSyncExternalStore`、`useOptimistic`、`useActionState`、`useDeferredValue`,react-dom 的 `useFormStatus`);`useContext(X)` 是「X 上下文的值」;传选择器的 `useXxx((s) => s.a.b)` 是「xxx store 中的 a.b」(store 名:导入路径最后一节是 store 时取上一节,`@/lib/desktop/store` 是 desktop;否则去掉 store、use 字样,再不行取钩子名),选择器不是直接取属性的写「基于 xxx store 计算 …」;自己项目里的(相对路径、`@/` 这类别名、同文件)是「自定义钩子」,别的库的是「xxx 提供的钩子」(hint)。
- 派生值的计算表达式:useMemo 是算值的那个表达式(照常翻),主体是语句块时写「N 条语句」;useCallback 记住的函数主体是语句块时写「函数(N 条语句)」,直接返回表达式的照常翻整个函数。依赖项:依赖数组的各项「、」隔开,后面写「(useMemo)」「(useCallback)」;空数组写「无」,没写依赖数组写「未指定」。主体是语句块时里面的语句是这张卡片的子块。
事件处理:表里名称是函数名(function 色,带下标);绑定在从 JSX 属性反查:「<button>(打印) 的 onClick」,在属性值里调用时写传的实参「(传 h.id)」,在列表里一项项渲染的写「每个 <button>」,几处「、」隔开;没绑在 JSX 上时写被谁调用「被 download、share 调用」,都没有「—」;做什么是紧贴在上面的注释(函数声明的 JSDoc 也算),直接返回表达式的箭头函数照常翻它,别的写「N 句」。处理函数那句照常是块,被规则认成事件处理子卡片(名称 / 参数 `id: 字符串` / 做什么,函数里的语句放进「做什么」下面),放进表格对应的子行上:默认收着,点名称展开、那一行淡淡垫上事件处理的颜色,同一张表里别的收起;再点名称或子卡片左上角的箭头收起。
副作用:触发时机按依赖数组读,`[a, b]` 是「a、b 变化时」,`[]` 是「仅挂载时」,没写是「每次渲染后」(keyword 色),依赖写成别的表达式时「deps 变化时」;useLayoutEffect 后面加「,浏览器绘制前」。最后返回清理函数时那句 return 并进来,多一行「清理函数」:`() => …` 翻它的主体,语句块一句句翻、「;」隔开。副作用函数里别的语句是子块。
渲染大纲(「渲染」一行下面的子行,一行一个元素,下一层缩进;带下一层的行有折叠箭头):
事件处理函数:名称是函数名(function 色,带下标);参数照常翻(`e: KeyboardEvent<HTMLDivElement>`),几个「, 」隔开,没有写「无」;绑定事件从 JSX 属性反查:「<div>(修改排队消息) 的 onKeyDown」,在属性值里调用时写传入的实参「(传入 h.id)」,在列表里一项项渲染的写「每个 <button>」,几处「、」隔开;没绑在 JSX 上时写由谁调用「由 download、share 调用」,都没有「—」。函数里的语句是子块(如果、返回、调用、变量、注释、尝试……)。
渲染输出(「JSX 结构大纲」一行下面的子行,一行一个元素,下一层缩进;带下一层的行有折叠箭头):
- 元素:标签名,原生标签灰色(暗 #808080 / 亮 #6f6f6f),组件用组件色、带下标能跳到定义(`a.b` 一节节都能跳);`<></>` 写「<> 片段,不产生元素」。
- `className`(`class`)收成「样式 N 个类」小标(`classes` 框):N 是写死的类;按条件切换的(模板字符串里的三元、`cn(…)` / `clsx(…)` 的实参、`cond && "x"`、`{ "x": cond }`)、算出来的表达式在后面写「 · 随 doc 变」(比较的左边、取反的那个,算出来的写它自己);点开在下面弹出全部类名(写死的一行,按条件的「doc 全等 "terms" 时:…」「否则:…」,算出来的「另加 …」),盖住后面的字,再点收起;悬停也能看。
- 别的属性(`key` 不写,列表那一行写)写成「名字=值」,两个空格隔开:字符串照写,表达式照常翻,跨几行写的对象只写有哪些键「对象:h2、a」,值是 JSX 元素时写 `<Tag>`,只写了名字的(`aria-hidden`)就是名字,`{...x}` 照写。
- `className`(`class`)收成「样式 N 个类」小标(`classes` 框):N 是写死的类;按条件切换的(模板字符串里的三元、`cn(…)` / `clsx(…)` 的实参、`cond && "x"`、`{ "x": cond }`)、算出来的表达式在后面写「 · 取决于 doc」(比较的左边、取反的那个,算出来的写它自己);点开在下面弹出全部类名(写死的一行,按条件的「doc 全等 "terms" 时:…」「否则:…」,算出来的「另加 …」),盖住后面的字,再点收起;悬停也能看。
- 别的属性(`key` 不写,列表那一行写)写成「名字=值」,两个空格隔开:字符串照写,表达式照常翻,跨几行写的对象只写有哪些键「对象:h2、a」,只写了名字的(`aria-hidden`)就是名字,`{...x}` 照写。属性值是元素、条件或列表时不写进元素那一行:属性名单独成行,值照同一套大纲缩进在下面。
- 子节点只有文字、插值时写在同一行:只有一段文字时带引号,插值写成 `{…}`(花括号 hint 色),和文字混着时文字不带引号。
- `cond && <X/>` 前面加「当 … 时」小框(`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。状态「改它的」里每一处、读取的数据的「来自」各自有同一套铅笔,打开后只出现那一段源码(整行切好、着色好,和块同一套编辑框、同一条改文件的消息):来自是这次钩子调用占的行,不是左边的变量名;改它的里 JSX 是整个属性(名字和值),处理函数、派生值、另一处读取、重置条件各开各的那段(函数、那个值、那次调用、那个条件),纯渲染没有铅笔。这段的行落在组件里面,组件正在编辑时不画这段,两边不能同时编辑。代码差异在视图数据这一层比:组件卡片的属性表逐行(属性表按名称配对,加的属性标新加的一行),副作用、处理函数子卡片是块,照常配对、逐行标(改了依赖,「时机」那一行逐截标)。
每张卡片「编辑」是它那条语句的完整代码,组件卡片是整个组件(盖住它的子块);组件体里的卡片各自能编辑、问 AI。代码差异在视图数据这一层比:组件卡片的属性表逐行(属性表按名称配对,加的属性标新加的一行),组件体里的卡片是块,照常配对、逐行标(改了依赖,「触发时机」那一行逐截标)。
样例(`fixtures/desktopTitlebar.tsx.txt`,测试里逐字核对;`⟦…⟧` 是小标签、小框):
样例(`fixtures/planChecklist.tsx.txt` 的 `PlanChecklist`,测试里逐字核对;`⟦…⟧` 是小标签、小框,子块多缩进两格,说明只写第一行):
```
名称: DesktopTitlebar ⟦具名导出⟧ ⟦客户端组件⟧
说明: 窗口顶部 40px. 整条连同里面的文字都是拖拽区 (data-tauri-drag-region="deep"), 按钮除外. 侧栏开关只在侧栏顶部.
构造参数: 1 个
platform | DesktopShell["platform"] | — | 是
读取的数据: 2 个
pathname | usePathname() | 当前路径
title | useDesktopShell((s) => s.title) | 从 desktop store 读 title
派生值: 1 个
center | title ?? PAGE_TITLES[pathname] ?? "" | 每次渲染
渲染: 1 处返回
div ⟦样式 8 个类⟧ data-tauri-drag-region="deep"
div ⟦样式 3 个类⟧
⟦当 platform 全等 "macos" 时⟧ span ⟦样式 1 个类⟧ data-testid="traffic-light-inset" aria-hidden
div ⟦样式 6 个类⟧
span ⟦样式 4 个类⟧ {center}
⟦当 platform 全等 "windows" 时⟧ WindowControls
react.component 23
名称: PlanChecklist ⟦具名导出⟧ ⟦客户端组件⟧
说明: 执行期"计划清单"实时勾选 (TodoWrite 式): code-agent 批准计划后边执行边刷新各步
构造参数: 2 个 · 类型 PlanChecklistProps
todos | PlanTodo[] | — | 是 | 执行期计划清单 (覆盖式刷新); 空数组时不渲染.
active | 布尔 | — | 是 | 本轮是否仍在流式执行中. ``in_progress`` 仅在 ``active`` 时转圈; …
react.state 35
名称: collapsed
初始值: 假
更新位置: 副作用、toggle
react.effect 37
触发时机: 仅挂载时
try 38: 尝试
if 39: 如果 (localStorage.getItem(PLAN_CHECKLIST_COLLAPSED_KEY) 全等 "1")
statement 40: queueMicrotask(() => setCollapsed(真));
catch 42: 捕获
comment 43: localStorage 不可用 (隐私模式等) 时静默用默认
react.derived 47
名称: toggle
计算表达式: 函数(3 条语句)
依赖项: collapsed(useCallback)
variable 48
名称: next
初始值: 非 collapsed
statement 49: setCollapsed(next);
try 50: 尝试
statement 51: localStorage.setItem(PLAN_CHECKLIST_COLLAPSED_KEY, 如果 next 则 "1" 否则 "0");
catch 52: 捕获
comment 53: 忽略写入失败
if 57: 如果 (todos.length 全等 0)
return 57: 返回 空;
variable 58
名称: whole
初始值: wholeStatusOf(todos, active)
…(doneCount、counter、current 三张变量卡片,一张注释卡片,headline 变量卡片)
react.render 65
JSX 结构大纲:
Collapsible ⟦样式 1 个类 · 取决于 CARD_SURFACE⟧ open=非 collapsed onOpenChange=toggle
CollapsibleTrigger ⟦样式 3 个类 · 取决于 FLUSH_ROW_INTERACTIVE⟧ data-testid="plan-checklist-toggle"
div ⟦样式 4 个类⟧ data-testid="plan-progress"
…
```
和原型不一样的地方:原型里「做什么」「意思」、派生值的读法(「title;没有就按路径查 PAGE_TITLES」)是照设计手写的中文,实现里是注释原文、照翻译插件翻的译文和词表;条件照翻译插件读(`===` 是「全等」,原型写「是」);渲染大纲带下一层的行有折叠箭头;副作用子卡片收起时不换成一行摘要,「做什么」那一行的箭头收起它下面的语句。
和原型不一样的地方:接口 `PlanChecklistProps` 照 TypeScript 的接口块(一行译文,字段、注释是子块),原型「对照」里画成的字段表(名称 / 类型 / 必填 / 说明)这次没做;渲染输出的大纲挂在「JSX 结构大纲」这一行下面,不是直接画在卡片里;更新位置、使用位置里的「副作用」是副作用色的字,不能跳(副作用没有名字可查定义),原生标签(`<textarea>`)也不能跳,组件标签能跳到组件定义。
### 内置:Rust 的库
-6
View File
@@ -290,12 +290,6 @@ 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 } => {
+9 -36
View File
@@ -555,12 +555,11 @@ export interface Segment { text: string, role: TokenRole, offset?: number, refer
* 框的样式,决定框头的图标和框的颜色:
* - iteration:写在表达式里的遍历(渲染大纲里 `.map` 的「对 … 的每一项」也是),图标、颜色同「循环」块;
* - condition:按条件(渲染大纲里 `cond &&` 的「当 … 时」、三元的「如果 …」),图标、颜色同「条件」块;
* - branch:条件的一支(「是」「否」「否则」),颜色同「条件」块,不带图标;
* - skip:这个条件下不渲染(提前 `return null`),图标、颜色同「返回」块;
* - branch:条件的一支(三元的「是」「否」),颜色同「条件」块,不带图标;
* - tag:小标签(名称后面的「具名导出」、属性名后面的「回调」),只有框头的字,颜色是 SegmentGroup.color;
* - classes:收起来的一串类名(「样式 N 个类」),中性色,点开、悬停看 SegmentGroup.detail。
*/
export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'skip' | 'tag' | 'classes'
export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'tag' | 'classes'
/**
* 框起来的一组词:译文里自成一体的一段(写在表达式里的遍历、渲染大纲一行前面的条件),预览把它画成嵌在前后文字里的一个小框,
@@ -595,40 +594,11 @@ 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,一层层往下套。总是展开的。
@@ -774,7 +744,7 @@ export interface ThemeColor { dark: string, light: string }
/**
* 种类图标:插件自己定义的一组 lucide 图标(https://lucide.dev/icons 上的 kebab-case 名字)。前 26 个给内置种类(几种共用一个:
* 命令、管道、执行共用 square-terminal,解释器、解析指令共用 terminal,选项、设置共用 sliders-horizontal,字段、标签共用 tag),
* 后面的给库规则的种类(React 组件的原子、副作用的闪电、事件处理的点击手势也在里面);库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标),
* 后面的给库规则的种类(React 的组件、状态、引用、数据源、派生值、副作用、事件处理函数、渲染输出也在里面);库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标),
* 没有按任意名字找图标的路。
*/
export const KIND_ICON_NAMES = [
@@ -813,6 +783,11 @@ export const KIND_ICON_NAMES = [
'atom',
'zap',
'mouse-pointer-click',
'toggle-right',
'anchor',
'download',
'sigma',
'layout-template',
] as const
export type KindIconName = (typeof KIND_ICON_NAMES)[number]
@@ -920,7 +895,7 @@ export interface RuleCard {
/**
* 卡片里的一处子块:block(原块 children 里的对象,没并进来的)不排在卡片后面,画在卡片第 row 行下面当子卡片,
* 照常转成块(翻译、库规则、编辑、问 AI、差异都照常)。item 是这一行的子行里讲它的那一个(第几个,0 起):
* 这时它默认收着,点那个子行的第一格才展开、一次只展开一个(React 组件的事件处理表点名称展开处理函数)。
* 这时它默认收着,点那个子行的第一格才展开、一次只展开一个(Rust 实现块的方法表点方法名展开那张函数卡)。
*/
export interface RuleSlot {
block: CodeBlock
@@ -947,8 +922,6 @@ export interface RuleContext {
displayLanguage?: string
/** 用当前翻译插件把一个表达式或类型节点翻成一行译文(Translator.expression):名字带源码下标,能跳到定义、高亮各处出现;没有翻译插件时照抄源码。 */
translate: (node: Node) => Inline[]
/** 一个节点占的整行源码和着色(见 SourceSnippet),和块视图切行是同一套。规则把它挂在译文旁边。 */
snippet: (node: Node) => SourceSnippet
/** 用当前翻译插件取一段说明文字(Translator.doc);没有翻译插件、没有文字时没有。 */
doc: (node: Node) => Markdown | undefined
/**
@@ -0,0 +1,148 @@
"use client";
import { Circle, CircleDot, Pause } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { CARD_SURFACE } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { Collapsible, CollapsibleChevron, CollapsiblePanel, CollapsibleTrigger } from "@/components/ui/collapsible";
import { FLUSH_ROW_INTERACTIVE } from "@/components/ui/interactive";
import { StatusMark } from "@/components/ui/status-mark";
import type { PlanTodo } from "./events";
interface PlanChecklistProps {
/** 执行期计划清单 (覆盖式刷新); 空数组时不渲染. */
todos: PlanTodo[];
/**
* 本轮是否仍在流式执行中. ``in_progress`` 仅在 ``active`` 时转圈;
* turn 结束/报错后转 ``false``, 当前步停转 (显示"停在此步"), 不再空转误导.
*/
active: boolean;
}
/**
* 执行期"计划清单"实时勾选 (TodoWrite 式): code-agent 批准计划后边执行边刷新各步
* 状态, 用户看到"做到哪一步了". 渲染在 composer 上方, 由 planTodos 驱动.
*
* 进度叙事三件套: 顶部分段进度条 (一段一步) · 左侧轨道串起各步 · 当前步整行铺中性底,
* 不看文字也知道走到哪. 设计稿见 docs/prototype/todolist/openpencil.
*
* 可折叠: 七八步的清单会把对话区挤成一小截, 点标题栏折成一行 (分段条 + 当前步 + 计数).
* 偏好全局粘性 (localStorage): 折过一次以后所有会话默认折叠, 展开同理.
* SSR / 首屏恒为展开, 水合后 effect 读偏好校正 (与 ComposerSizeProvider 同款).
*/
export const PlanChecklist = ({ todos, active }: PlanChecklistProps) => {
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
try {
if (localStorage.getItem(PLAN_CHECKLIST_COLLAPSED_KEY) === "1") {
queueMicrotask(() => setCollapsed(true));
}
} catch {
/* localStorage 不可用 (隐私模式等) 时静默用默认 */
}
}, []);
const toggle = useCallback(() => {
const next = !collapsed;
setCollapsed(next);
try {
localStorage.setItem(PLAN_CHECKLIST_COLLAPSED_KEY, next ? "1" : "0");
} catch {
/* 忽略写入失败 */
}
}, [collapsed]);
if (todos.length === 0) return null;
const whole = wholeStatusOf(todos, active);
const doneCount = todos.filter((t) => t.status === "done").length;
const counter = `${doneCount} / ${todos.length}`;
const current = todos.find((t) => t.status === "in_progress");
// 折叠行永远带一步的名字: 跑着是当前步, 做完了是最后一步, 不留光秃秃的计数
const headline = (current ?? todos[todos.length - 1]).title;
return (
<Collapsible
open={!collapsed}
onOpenChange={toggle}
className={cn(CARD_SURFACE, "overflow-hidden")}
>
<CollapsibleTrigger
data-testid="plan-checklist-toggle"
className={cn(FLUSH_ROW_INTERACTIVE, "block w-full text-left")}
>
<div data-testid="plan-progress" className="flex gap-1 px-3.5 pt-3">
{todos.map((todo) => (
<span
key={todo.title}
className={cn("h-[3px] flex-1 rounded-full", segmentClass(todo.status, whole))}
/>
))}
</div>
{collapsed ? (
<div className="flex items-center gap-2.5 px-3.5 pt-2.5 pb-3">
<StatusIcon status={current ? "in_progress" : "done"} active={active} />
<span
className={cn(
"min-w-0 flex-1 truncate text-ui leading-[22px] font-medium",
whole === "done" ? "text-muted-foreground" : "text-foreground",
)}
>
{headline}
</span>
<span className="text-caption text-ink-4 tabular-nums">{counter}</span>
<span className="flex items-center gap-0.5 text-caption text-ink-4">
展开
<CollapsibleChevron strokeWidth={1.8} />
</span>
</div>
) : (
<div className="flex items-center gap-2 px-3.5 pt-2.5 pb-2">
<span className="text-caption font-medium tracking-wide text-ink-2">计划清单</span>
<span className="text-caption text-ink-4 tabular-nums">{counter}</span>
<span className="flex-1" />
<WholeStatusLabel whole={whole} />
<span className="flex items-center gap-0.5 text-caption text-ink-4">
收起
<CollapsibleChevron strokeWidth={1.8} />
</span>
</div>
)}
</CollapsibleTrigger>
<CollapsiblePanel>
<ol className="max-h-[40vh] overflow-y-auto border-t border-border px-2 pt-1.5 pb-2">
{todos.map((todo, i) => {
const focus = todo.status === "in_progress";
const last = i === todos.length - 1;
return (
<li
key={todo.title}
className={cn("flex gap-2.5 rounded-md px-2", focus && "bg-surface-2")}
>
{/* 轨道: 首行顶端留空, 末行不画下段; 9px 上段让 16px 图标对齐 22px 行高的第一行 */}
<span className="flex w-4 shrink-0 flex-col items-center">
<span
className={cn("h-[9px] w-0.5", i > 0 && railClass(todos[i - 1].status === "done"))}
/>
<StatusIcon status={todo.status} active={active} />
{!last && (
<span className={cn("w-0.5 flex-1", railClass(todo.status === "done"))} />
)}
</span>
<span
className={cn(
"min-w-0 flex-1 py-1.5 text-ui leading-[22px]",
titleClass(todo.status),
)}
>
{todo.title}
</span>
</li>
);
})}
</ol>
</CollapsiblePanel>
</Collapsible>
);
};
@@ -0,0 +1,223 @@
"use client";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { Clock, FileText, Pause, Pencil, X } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { FIELD_TEXT } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { QueuedMessage } from "../events";
import { USER_BUBBLE_SHAPE_CLASS, USER_FILE_CHIP_CLASS } from "./UserBubble";
const QUEUE_ACTION_CLASS = buttonVariants({ variant: "ghost", size: "icon-xs", className: "text-ink-4" });
/**
* 消息流底部的排队消息组: 上方一行说明, 下面每条一个描边气泡, 靠右排.
* 有条目在原地修改时说明换成「修改中」, 保存之前整队先不发出.
*/
export const QueuedMessages = ({
queue,
editingId,
onCancel,
onEdit,
onSaveEdit,
onCancelEdit,
}: {
queue: QueuedMessage[];
/** 正在原地修改的那条; null 表示没有. */
editingId: string | null;
onCancel?: (id: string) => void;
onEdit?: (id: string) => void;
onSaveEdit?: (id: string, text: string) => void;
onCancelEdit?: () => void;
}) => {
if (queue.length === 0) return null;
const editing = editingId !== null;
return (
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-1 text-caption text-muted-foreground">
{editing ? <Pause size={12} aria-hidden="true" /> : <Clock size={12} aria-hidden="true" />}
<span>{editing ? "修改中 · 保存之前先不发出" : "排队中 · 点停止可立即发出第 1 条"}</span>
</div>
{queue.map((q) => (
<QueuedBubble
key={q.id}
item={q}
editing={q.id === editingId}
onCancel={onCancel}
onEdit={onEdit}
onSaveEdit={onSaveEdit}
onCancelEdit={onCancelEdit}
/>
))}
</div>
);
};
/**
* 一条排队消息: 描边气泡, 字用弱色, 气泡内右侧是编辑 (铅笔) 与取消排队 (×).
* 不给 onEdit / onCancel 时对应按钮不显示. editing 时气泡原地变成修改框, 附件照常显示.
*/
export const QueuedBubble = ({
item,
editing = false,
onCancel,
onEdit,
onSaveEdit,
onCancelEdit,
}: {
item: QueuedMessage;
editing?: boolean;
onCancel?: (id: string) => void;
onEdit?: (id: string) => void;
onSaveEdit?: (id: string, text: string) => void;
onCancelEdit?: () => void;
}) => {
const images = item.attachments.filter((a) => a.kind === "image");
const files = item.attachments.filter((a) => a.kind === "file");
const hasText = item.text.trim().length > 0;
const hasActions = onEdit != null || onCancel != null;
return (
<div className={cn("flex max-w-[85%] flex-col items-end gap-2 md:max-w-[75%]", editing && "w-full")}>
{files.length > 0 && (
<div className="flex flex-wrap justify-end gap-2 w-full">
{files.map((f) => (
<span key={f.url} className={USER_FILE_CHIP_CLASS}>
<FileText size={16} className="shrink-0 text-muted-foreground" />
<span className="max-w-[200px] truncate">{f.name ?? "文件附件"}</span>
</span>
))}
</div>
)}
{images.length > 0 && (
<div className="flex flex-wrap justify-end gap-2 w-full">
{images.map((img) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={img.url}
src={img.url}
alt={img.name ?? "图片附件"}
className="block max-w-[160px] max-h-[160px] w-auto h-auto object-cover rounded-xl border border-border"
/>
))}
</div>
)}
{editing ? (
<QueuedEditor item={item} onSave={onSaveEdit} onCancel={onCancelEdit} />
) : (
(hasText || hasActions) && (
<div
className={cn(
USER_BUBBLE_SHAPE_CLASS,
"flex items-start gap-1.5 border border-border-strong text-muted-foreground",
hasActions && "pr-1.5",
)}
>
{hasText && <span className="min-w-0">{item.text}</span>}
{hasActions && (
<div className="flex shrink-0 items-center">
{onEdit && (
<Tooltip>
<TooltipTrigger
type="button"
onClick={() => onEdit(item.id)}
aria-label="编辑这条排队消息"
className={QUEUE_ACTION_CLASS}
>
<Pencil className="size-3.5" />
</TooltipTrigger>
<TooltipContent>编辑</TooltipContent>
</Tooltip>
)}
{onCancel && (
<Tooltip>
<TooltipTrigger
type="button"
onClick={() => onCancel(item.id)}
aria-label="取消这条排队消息"
className={QUEUE_ACTION_CLASS}
>
<X className="size-3.5" />
</TooltipTrigger>
<TooltipContent>取消排队</TooltipContent>
</Tooltip>
)}
</div>
)}
</div>
)
)}
</div>
);
};
/**
* 排队消息的原地修改框: 只改文字, 附件不动. 文本框随内容长高.
* Cmd/Ctrl+Enter 保存, Esc 取消; 文字为空且没有附件时不能保存.
*/
const QueuedEditor = ({
item,
onSave,
onCancel,
}: {
item: QueuedMessage;
onSave?: (id: string, text: string) => void;
onCancel?: () => void;
}) => {
const [draft, setDraft] = useState(item.text);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const canSave = draft.trim().length > 0 || item.attachments.length > 0;
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}, []);
const save = () => {
if (canSave) onSave?.(item.id, draft);
};
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.nativeEvent.isComposing) return;
if (e.key === "Escape") {
// 修改框里的 Esc 只用来取消修改, 不再往外传: 页面上「连按两次 Esc 停止这一轮」不该把它算进去
e.preventDefault();
e.stopPropagation();
onCancel?.();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
save();
}
};
return (
<div
role="group"
aria-label="修改排队消息"
onKeyDown={handleKeyDown}
className={cn(USER_BUBBLE_SHAPE_CLASS, "flex w-full flex-col gap-2.5 border border-primary bg-card text-foreground")}
>
<textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
aria-label="排队消息内容"
className={cn(FIELD_TEXT, "block w-full resize-none bg-transparent outline-none field-sizing-content")}
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onCancel}>
取消
</Button>
<Button size="sm" disabled={!canSave} onClick={save}>
保存
</Button>
</div>
</div>
);
};
+372 -174
View File
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { diffBlockTrees } from '../diff/blockDiff'
import { createTypeScriptAdapter } from '../languages/typescript'
import { propertyLines, readFixture, segmentText, WASM_DIR, withBlocks } from '../testSupport'
import { propertyLines, readFixture, segmentText, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { reactRule } from './react'
@@ -14,6 +14,11 @@ const TITLEBAR = readFixture('desktopTitlebar.tsx.txt')
const LEGAL = readFixture('legalDocPage.tsx.txt')
const POSTER = readFixture('posterDialog.tsx.txt')
const SHARE = readFixture('shareButton.tsx.txt')
const SETTINGS = readFixture('desktopSettingsDialog.tsx.txt')
const PLAN = readFixture('planChecklist.tsx.txt')
const QUEUED = readFixture('queuedBubble.tsx.txt')
const DATA = { dark: '#9cdcfe', light: '#001080' }
/** 规则查名字的定义时查到的值(名字原文 → IndexEntry.value),别的没查到。 */
function known(values: Record<string, string>): RuleContext['lookup'] {
@@ -35,6 +40,11 @@ function properties(view: BlockView | undefined): PropertyRow[] {
return view.properties
}
/** 卡片的属性表写成文字,见 propertyLines。 */
function lines(view: BlockView | undefined): string[] {
return propertyLines(properties(view))
}
/** 组件名是 name 的组件卡片。 */
async function card(source: string, name: string, lookup?: RuleContext['lookup']): Promise<BlockView> {
const found = flatten(await views(source, lookup)).find(view => view.kind === 'react.component' && view.defines && source.slice(view.defines.startIndex, view.defines.endIndex) === name)
@@ -43,30 +53,45 @@ async function card(source: string, name: string, lookup?: RuleContext['lookup']
return found
}
/** 卡片下面从第 line 行(1 起)开始的那一块子块。 */
function childAt(view: BlockView, line: number): BlockView {
const found = view.children.find(child => child.startLine + 1 === line && child.kind !== 'comment')
if (!found)
throw new Error(`第 ${line} 行没有子块`)
return found
}
/** 卡片里 label 那一行(连子行)写成的文字,见 propertyLines。 */
function rowLines(view: BlockView, label: string): string[] {
const row = properties(view).find(item => item.label === label)
return row ? propertyLines([row]) : []
}
/** 子块写成「种类 行号」。 */
function kinds(view: BlockView): string[] {
return view.children.map(child => `${child.kind} ${child.startLine + 1}`)
}
describe('react 库规则:认领', () => {
it('只管 TSX / JSX,不看导入(新的 JSX 写法不用导入 react)', () => {
expect(reactRule.languageIds).toEqual(['typescriptreact', 'javascriptreact'])
expect(reactRule.libraries).toEqual([])
})
it('luffy 的 DesktopTitlebar:五条导入后面是组件卡片,"use client" 和文档注释不再单独成块', async () => {
it('luffy 的 DesktopTitlebar:五条导入后面是组件卡片,只有名称 / 说明 / 构造参数;组件体照源码顺序是子块,"use client" 和文档注释不单独成块', async () => {
const blocks = await views(TITLEBAR)
expect(blocks.map(view => `${view.kind} ${view.startLine + 1}`)).toEqual(['import 3', 'import 5', 'import 6', 'import 7', 'import 9', 'react.component 11'])
const view = blocks[5]
expect(view.badge).toEqual({ label: 'React 组件', color: { dark: '#61dafb', light: '#0a7ea4' }, icon: 'atom' })
expect(view.attributes).toMatchObject({ kind: 'react.component', access: 'public' })
expect(propertyLines(properties(view)).slice(0, 4)).toEqual([
expect(lines(view)).toEqual([
'名称: DesktopTitlebar ⟦具名导出|⟧ ⟦客户端组件|⟧',
'说明: 窗口顶部 40px. 整条连同里面的文字都是拖拽区 (data-tauri-drag-region="deep"), 按钮除外. 侧栏开关只在侧栏顶部.',
'构造参数: 1 个',
' platform | DesktopShell["platform"] | — | 是',
])
expect(kinds(view)).toEqual(['react.source 13', 'react.source 14', 'variable 15', 'react.render 17'])
expect(view.children.map(child => child.slot)).toEqual([undefined, undefined, undefined, undefined])
})
it('文件里没有组件时 "use client" 照常成块;组件上面没并进块头部的 JSDoc 讲成说明、不单独成块', async () => {
@@ -74,22 +99,30 @@ describe('react 库规则:认领', () => {
const source = ['import { memo } from "react";', '/** 一行标签 */', 'export const Label = memo(({ text }: { text: string }) => <span>{text}</span>);'].join('\n')
const blocks = await views(source)
expect(blocks.map(view => view.kind)).toEqual(['import', 'react.component'])
expect(propertyLines(properties(blocks[1])).slice(0, 2)).toEqual(['名称: Label ⟦具名导出|⟧ ⟦记忆化|⟧', '说明: 一行标签'])
expect(lines(blocks[1]).slice(0, 2)).toEqual(['名称: Label ⟦具名导出|⟧ ⟦记忆化|⟧', '说明: 一行标签'])
})
it('小写开头的工具函数、不渲染 JSX 的大写函数照常是函数块', async () => {
const blocks = await views(['const slugify = (raw: string) => raw.trim();', 'function Upper() { return 1 }', 'function lower() { return <i /> }'].join('\n'))
expect(blocks.map(view => view.kind)).toEqual(['const', 'function', 'function'])
})
it('luffy 的九个文件认领下来都合约定,宿主没记下问题', async () => {
const files = ['appVaultSummary', 'deleteAppDialog', 'desktopSettingsDialog', 'desktopTitlebar', 'legalDocPage', 'overviewSection', 'planChecklist', 'posterDialog', 'queuedBubble', 'shareButton']
const reports: string[] = []
for (const file of files)
await views(readFixture(`${file}.tsx.txt`), undefined, reports)
expect(reports).toEqual([])
})
})
describe('react 库规则:构造参数', () => {
it('luffy 的 LegalDocPage:来自 LegalDocPageProps(名字能跳到定义),字段带说明时多一列说明', async () => {
it('luffy 的 LegalDocPage:类型 LegalDocPageProps(名字能跳到定义),字段带说明时多一列说明', async () => {
const view = await card(LEGAL, 'LegalDocPage')
const row = properties(view).find(item => item.label === '构造参数')
expect(row?.columns).toEqual(['名称', '类型', '默认值', '必填', '说明'])
expect(rowLines(view, '构造参数')).toEqual([
'构造参数: 1 个 · 来自 LegalDocPageProps',
'构造参数: 1 个 · 类型 LegalDocPageProps',
' doc | "terms" | "privacy" | — | 是 | terms = 用户服务协议; privacy = 隐私政策',
])
const from = row && !('markdown' in row.value) ? row.value.at(-1) : undefined
@@ -119,10 +152,10 @@ describe('react 库规则:构造参数', () => {
'export const A = ({ size = 16, label: text, ...rest }: Props) => <i />;',
].join('\n')
expect(rowLines(await card(source, 'A'), '构造参数')).toEqual([
'构造参数: 4 个 · 来自 Props',
'构造参数: 4 个 · 类型 Props',
' size | 数值 | 16 | 否',
' onPick ⟦回调|⟧ | (v: 字符串) => 无 | — | 是',
' label(组件里叫 text) | 字符串 | — | 是',
' label(组件内名为 text) | 字符串 | — | 是',
' ...rest | 其余的属性 | — | 否',
])
})
@@ -134,7 +167,7 @@ describe('react 库规则:构造参数', () => {
'export function Empty() { return <i /> }',
].join('\n')
expect(rowLines(await card(source, 'Card'), '构造参数')).toEqual([
'构造参数: 2 个 · 来自 CardProps',
'构造参数: 2 个 · 类型 CardProps',
' title | 未标注 | — | —',
' onClose | 未标注 | noop | 否',
])
@@ -144,7 +177,7 @@ describe('react 库规则:构造参数', () => {
{ name: 'size', type: 'number', optional: true, callback: false },
])
expect(rowLines(await card(source, 'Card', known({ CardProps: fields })), '构造参数')).toEqual([
'构造参数: 3 个 · 来自 CardProps',
'构造参数: 3 个 · 类型 CardProps',
' title | string | — | 是 | 标题',
' onClose ⟦回调|⟧ | () => void | noop | 否 | ',
' size | number | — | 否 | ',
@@ -153,6 +186,283 @@ describe('react 库规则:构造参数', () => {
})
})
describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklist,原型「对照」)', () => {
it('组件卡片的行只有名称 / 说明 / 构造参数,组件体的语句照源码顺序是它的子块', async () => {
const view = await card(PLAN, 'PlanChecklist')
expect(properties(view).map(row => row.label)).toEqual(['名称', '说明', '构造参数'])
expect(rowLines(view, '构造参数')[0]).toBe('构造参数: 2 个 · 类型 PlanChecklistProps')
expect(kinds(view)).toEqual([
'react.state 35',
'react.effect 37',
'react.derived 47',
'if 57',
'variable 58',
'variable 59',
'variable 60',
'variable 61',
'comment 62',
'variable 63',
'react.render 65',
])
})
it('状态:名称 / 初始值 / 更新位置,更新位置只列名字和位置,名字能跳到定义,不带函数体', async () => {
const state = childAt(await card(PLAN, 'PlanChecklist'), 35)
expect(state.badge).toEqual({ label: '状态', color: DATA, icon: 'toggle-right' })
expect(lines(state)).toEqual(['名称: collapsed', '初始值: 假', '更新位置: 副作用、toggle'])
const updates = properties(state)[2]
expect(updates.items).toBeUndefined()
const toggle = !('markdown' in updates.value) ? updates.value.find(piece => 'role' in piece && piece.text === 'toggle') : undefined
const offset = toggle && 'role' in toggle ? toggle.offset ?? -1 : -1
expect(PLAN.slice(offset, offset + 6)).toBe('toggle')
expect(PLAN.slice(0, offset).split('\n').length).toBe(47)
expect(state.children).toEqual([])
})
it('副作用、useCallback 的派生值:只留各自的几行,函数里的 if / 调用 / 尝试是下面的子卡片', async () => {
const view = await card(PLAN, 'PlanChecklist')
const effect = childAt(view, 37)
expect(effect.badge).toEqual({ label: '副作用', color: { dark: '#e5c07b', light: '#9a6a00' }, icon: 'zap' })
expect(viewLines([effect])).toEqual([
'react.effect 37',
' 触发时机: 仅挂载时',
' try 38: 尝试',
' if 39: 如果 (localStorage.getItem(PLAN_CHECKLIST_COLLAPSED_KEY) 全等 "1")',
' statement 40: queueMicrotask(() => setCollapsed(真));',
' catch 42: 捕获',
' comment 43: localStorage 不可用 (隐私模式等) 时静默用默认',
])
const toggle = childAt(view, 47)
expect(toggle.badge).toEqual({ label: '派生值', color: DATA, icon: 'sigma' })
expect(viewLines([toggle])).toEqual([
'react.derived 47',
' 名称: toggle',
' 计算表达式: 函数(3 条语句)',
' 依赖项: collapsed(useCallback)',
' variable 48',
' 名称: next',
' 初始值: 非 collapsed',
' statement 49: setCollapsed(next);',
' try 50: 尝试',
' statement 51: localStorage.setItem(PLAN_CHECKLIST_COLLAPSED_KEY, 如果 next 则 "1" 否则 "0");',
' catch 52: 捕获',
' comment 53: 忽略写入失败',
])
expect(PLAN.slice(toggle.defines?.startIndex, toggle.defines?.endIndex)).toBe('toggle')
})
it('普通 const 是变量卡片,提前返回是如果卡片,最后的 JSX 是渲染输出卡片', async () => {
const view = await card(PLAN, 'PlanChecklist')
expect(viewLines([childAt(view, 57)])).toEqual(['if 57: 如果 (todos.length 全等 0)', ' return 57: 返回 空;'])
expect(viewLines([childAt(view, 58)])).toEqual(['variable 58', ' 名称: whole', ' 初始值: wholeStatusOf(todos, active)'])
const render = childAt(view, 65)
expect(render.badge).toEqual({ label: '渲染输出', color: { dark: '#89d185', light: '#388a34' }, icon: 'layout-template' })
expect(lines(render).slice(0, 4)).toEqual([
'JSX 结构大纲: ',
' Collapsible ⟦样式 1 个类 · 取决于 CARD_SURFACE|⟧ open=非 collapsed onOpenChange=toggle',
' CollapsibleTrigger ⟦样式 3 个类 · 取决于 FLUSH_ROW_INTERACTIVE|⟧ data-testid="plan-checklist-toggle"',
' div ⟦样式 4 个类|⟧ data-testid="plan-progress"',
])
})
})
describe('react 库规则:luffy 的 QueuedBubble(原型「编辑器全貌」)', () => {
it('原地修改框 QueuedEditor:状态、引用、变量、副作用、两个事件处理函数、渲染输出照源码顺序,函数体是子卡片', async () => {
const view = await card(QUEUED, 'QueuedEditor')
expect(lines(view).slice(0, 1)).toEqual(['名称: QueuedEditor ⟦客户端组件|⟧'])
expect(viewLines(view.children)).toEqual([
'react.state 171',
' 名称: draft',
' 初始值: item.text',
' 更新位置: <textarea>(排队消息内容) 的 onChange',
'react.ref 172',
' 名称: textareaRef',
' 初始值: 空',
' 使用位置: <textarea>(排队消息内容) 的 ref 属性;副作用',
'variable 173',
' 名称: canSave',
' 初始值: draft.trim().length > 0 或者 item.attachments.length > 0',
'react.effect 175',
' 触发时机: 仅挂载时',
' variable 176',
' 名称: el',
' 初始值: textareaRef.current',
' if 177: 如果 (非 el)',
' return 177: 返回;',
' statement 178: el.focus();',
' statement 179: el.setSelectionRange(el.value.length, el.value.length);',
'react.handler 182',
' 名称: save',
' 参数: 无',
' 绑定事件: <Button> 的 onClick',
' if 183: 如果 (canSave)',
' statement 183: onSave?.(item.id, draft);',
'react.handler 186',
' 名称: handleKeyDown',
' 参数: e: KeyboardEvent<HTMLDivElement>',
' 绑定事件: <div>(修改排队消息) 的 onKeyDown',
' if 187: 如果 (e.nativeEvent.isComposing)',
' return 187: 返回;',
' if 188: 如果 (e.key 全等 "Escape")',
' comment 189: 修改框里的 Esc 只用来取消修改, 不再往外传: 页面上「连按两次 Esc 停止这一轮」不该把它算进去',
' statement 190: e.preventDefault();',
' statement 191: e.stopPropagation();',
' statement 192: onCancel?.();',
' else 193: 否则 如果 (e.key 全等 "Enter" 并且 (e.metaKey 或者 e.ctrlKey))',
' statement 194: e.preventDefault();',
' statement 195: save();',
'react.render 199',
' JSX 结构大纲: ',
' div ⟦样式 8 个类 · 取决于 USER_BUBBLE_SHAPE_CLASS|⟧ role="group" aria-label="修改排队消息" onKeyDown=handleKeyDown',
' textarea ⟦样式 6 个类 · 取决于 FIELD_TEXT|⟧ ref=textareaRef value=draft onChange=(e) => setDraft(e.target.value) aria-label="排队消息内容"',
' div ⟦样式 3 个类|⟧',
' Button variant="ghost" size="sm" onClick=onCancel "取消"',
' Button size="sm" disabled=非 canSave onClick=save "保存"',
])
})
it('引用、事件处理函数的样子;名称能跳到定义', async () => {
const view = await card(QUEUED, 'QueuedEditor')
expect(childAt(view, 172).badge).toEqual({ label: '引用', color: DATA, icon: 'anchor' })
const handler = childAt(view, 186)
expect(handler.badge).toEqual({ label: '事件处理函数', color: { dark: '#dcdcaa', light: '#795e26' }, icon: 'mouse-pointer-click' })
expect(QUEUED.slice(handler.defines?.startIndex, handler.defines?.endIndex)).toBe('handleKeyDown')
})
it('排队消息组 QueuedMessages:提前 return null 是如果卡片、里面是返回卡片,最后返回的 JSX 照大纲画三元和列表', async () => {
const view = await card(QUEUED, 'QueuedMessages')
expect(kinds(view)).toEqual(['if 36', 'variable 37', 'react.render 39'])
expect(viewLines([childAt(view, 36)])).toEqual(['if 36: 如果 (queue.length 全等 0)', ' return 36: 返回 空;'])
expect(lines(childAt(view, 39))).toEqual([
'JSX 结构大纲: ',
' div ⟦样式 4 个类|⟧',
' div ⟦样式 5 个类|⟧',
' ⟦|如果 editing⟧',
' ⟦|是⟧ Pause size=12 aria-hidden="true"',
' ⟦|否⟧ Clock size=12 aria-hidden="true"',
' span',
' ⟦|如果 editing⟧',
' ⟦|是⟧ "修改中 · 保存之前先不发出"',
' ⟦|否⟧ "排队中 · 点停止可立即发出第 1 条"',
' ⟦|对 queue 的每一项 q⟧ key q.id',
' QueuedBubble item=q editing=q.id 全等 editingId onCancel=onCancel onEdit=onEdit onSaveEdit=onSaveEdit onCancelEdit=onCancelEdit',
])
})
})
describe('react 库规则:数据源、派生值、副作用、事件处理函数的写法', () => {
it('数据源:库自带的钩子写说明,store 选择器写哪个 store 中的什么、基于哪个 store 计算什么,项目里的是自定义钩子', async () => {
const titlebar = await card(TITLEBAR, 'DesktopTitlebar')
expect(childAt(titlebar, 13).badge).toEqual({ label: '数据源', color: DATA, icon: 'download' })
expect(lines(childAt(titlebar, 13))).toEqual(['变量: pathname', '钩子: usePathname()', '说明: 当前路径'])
expect(lines(childAt(titlebar, 14))).toEqual(['变量: title', '钩子: useDesktopShell((s) => s.title)', '说明: desktop store 中的 title'])
const legal = await card(LEGAL, 'LegalDocPage')
expect(lines(childAt(legal, 58))).toEqual(['变量: cfg', '钩子: useSiteConfig()', '说明: 自定义钩子'])
expect(lines(childAt(legal, 59))).toEqual(['变量: router', '钩子: useRouter()', '说明: 路由器,用于页面跳转'])
expect(rowLines(childAt(await card(SETTINGS, 'WorkspaceRootRow'), 90), '说明')).toEqual(['说明: 基于 desktop store 计算 displayPath(s.workspace_root, s.home)'])
})
it('luffy 的 LegalDocPage:useMemo、渲染期间按条件重置写进更新位置、引用的使用位置、清理函数并进副作用、处理函数绑在每个按钮上,注释照常成块', async () => {
const view = await card(LEGAL, 'LegalDocPage')
expect(kinds(view)).toEqual([
'react.source 58',
'react.source 59',
'variable 60',
'react.derived 61',
'react.ref 63',
'react.state 64',
'comment 68',
'react.state 69',
'if 70',
'comment 75',
'react.effect 76',
'comment 80',
'react.effect 81',
'react.handler 104',
'react.handler 112',
'react.handler 116',
'react.render 124',
])
expect(lines(childAt(view, 61))).toEqual(['名称: headings', '计算表达式: extractHeadings(markdown)', '依赖项: markdown(useMemo)'])
expect(lines(childAt(view, 63))).toEqual(['名称: scrollRef', '初始值: 空', '使用位置: <main> 的 ref 属性;副作用 1、副作用 2、handleSectionClick'])
expect(lines(childAt(view, 64))).toEqual(['名称: activeSection', '初始值: headings[0]?.id ?? ""', '更新位置: 渲染期间,当 doc 不全等 prevDoc 时、副作用 2'])
const effect = childAt(view, 81)
expect(lines(effect)).toEqual(['触发时机: doc、headings 变化时', '清理函数: container.removeEventListener("scroll", handleScroll)'])
expect(kinds(effect)).toEqual(['variable 82', 'if 83', 'function 84', 'statement 100'])
expect(lines(childAt(view, 104))).toEqual(['名称: handleSectionClick', '参数: id: 字符串', '绑定事件: 每个 <button> 的 onClick(传入 h.id)'])
expect(kinds(childAt(view, 104))).toEqual(['variable 105', 'variable 106', 'if 107'])
expect(rowLines(childAt(view, 112), '绑定事件')).toEqual(['绑定事件: <button>(打印) 的 onClick'])
})
it('luffy 的 ShareButton、PosterDialog:带清理的仅挂载时副作用、useCallback,没绑在 JSX 上的处理函数写由谁调用,一样的位置只写一次', async () => {
const share = await card(SHARE, 'ShareButton')
expect(lines(childAt(share, 40))).toEqual(['触发时机: 仅挂载时', '清理函数: activeRef.current = 假;'])
expect(lines(childAt(share, 39))).toEqual(['名称: activeRef', '初始值: 真', '使用位置: 副作用 1、startShare'])
expect(lines(childAt(share, 50))).toEqual(['名称: startShare', '计算表达式: 函数(3 条语句)', '依赖项: deployUrl、sessionId、title(useCallback)'])
expect(kinds(childAt(share, 50))).toEqual(['if 51', 'statement 52', 'statement 53'])
const poster = await card(POSTER, 'PosterDialog')
expect(lines(childAt(poster, 95))).toEqual(['名称: exportPng', '参数: 无', '绑定事件: 由 download、share 调用'])
expect(rowLines(childAt(poster, 76), '更新位置')).toEqual(['更新位置: 每个 <button> 的 onClick'])
})
it('依赖项、触发时机的几种写法;JSX 的提前返回是如果卡片下面的渲染输出;直接返回 JSX 的组件把大纲写在卡片里', async () => {
const source = [
'export function Box({ items }: { items: string[] }) {',
' const total = useMemo(() => {',
' const n = items.length;',
' return n * 2;',
' }, []);',
' const label = useMemo(() => format(items));',
' const onPick = useCallback((id: string) => pick(id), [items]);',
' useLayoutEffect(() => {',
' measure();',
' });',
' useEffect(() => {',
' sync(items);',
' }, deps);',
' if (items.length === 0) return <Empty onPick={onPick} />;',
' return <ul title={label}>{total}</ul>;',
'}',
'const Tag = ({ text }: { text: string }) => <span>{text}</span>;',
].join('\n')
const box = await card(source, 'Box')
expect(viewLines(box.children)).toEqual([
'react.derived 2',
' 名称: total',
' 计算表达式: 2 条语句',
' 依赖项: 无(useMemo)',
' variable 3',
' 名称: n',
' 初始值: items.length',
' return 4: 返回 n * 2;',
'react.derived 6',
' 名称: label',
' 计算表达式: format(items)',
' 依赖项: 未指定(useMemo)',
'react.derived 7',
' 名称: onPick',
' 计算表达式: (id: 字符串) => pick(id)',
' 依赖项: items(useCallback)',
'react.effect 8',
' 触发时机: 每次渲染后,浏览器绘制前',
' statement 9: measure();',
'react.effect 11',
' 触发时机: deps 变化时',
' statement 12: sync(items);',
'if 14: 如果 (items.length 全等 0)',
' react.render 14',
' JSX 结构大纲: ',
' Empty onPick=onPick',
'react.render 15',
' JSX 结构大纲: ',
' ul title=label {total}',
])
const tag = await card(source, 'Tag')
expect(lines(tag)).toEqual(['名称: Tag', '构造参数: 1 个', ' text | 字符串 | — | 是', 'JSX 结构大纲: ', ' span {text}'])
expect(tag.children).toEqual([])
})
})
/** 一层层子行里头一个满足 test 的。 */
function findItem(items: PropertyItem[] | undefined, test: (item: PropertyItem) => boolean): PropertyItem | undefined {
for (const item of items ?? []) {
@@ -163,133 +473,24 @@ function findItem(items: PropertyItem[] | undefined, test: (item: PropertyItem)
return undefined
}
/** 块和它的子块写成文字:「种类 行号 @行/子行」(放进父卡片属性表里的写在哪),子块多缩进两格。 */
function outlineOf(view: BlockView, pad = ''): string[] {
const slot = view.slot ? ` @${view.slot.row}${view.slot.item !== undefined ? `/${view.slot.item}` : ''}` : ''
return [`${pad}${view.kind} ${view.startLine + 1}${slot}`, ...view.children.flatMap(child => outlineOf(child, `${pad} `))]
/** 组件最后那张渲染输出卡片。 */
async function render(source: string, name: string): Promise<BlockView> {
const found = (await card(source, name)).children.filter(child => child.kind === 'react.render').at(-1)
if (!found)
throw new Error(`${name} 没有渲染输出卡片`)
return found
}
describe('react 库规则:分区', () => {
it('luffy 的 LegalDocPage:状态(渲染中重置写进改它的)/ 引用 / 读取的数据 / 派生值 / 副作用 / 事件处理,讲进卡片的语句不再单独成块', async () => {
const view = await card(LEGAL, 'LegalDocPage')
expect(propertyLines(properties(view)).slice(4, 20)).toEqual([
'状态: 2 个',
' activeSection | headings[0]?.id ?? "" | setActiveSection(渲染时,当 doc 不全等 prevDoc 时;副作用 2)',
' prevDoc | doc | setPrevDoc(渲染时,当 doc 不全等 prevDoc 时)',
'引用: 1 个',
' scrollRef | 空 | 挂在 <main> 的 ref 上;副作用 1、副作用 2、handleSectionClick 里用它',
'读取的数据: 2 个',
' cfg | useSiteConfig() | 自定义钩子',
' router | useRouter() | 路由器,跳转用',
'派生值: 2 个',
' markdown | 如果 doc 全等 "terms" 则 cfg.termsContent 否则 cfg.privacyContent | 每次渲染',
' headings | extractHeadings(markdown) | markdown 变了时(useMemo)',
'副作用: 2 个',
'事件处理: 3 个',
' handleSectionClick | 每个 <button> 的 onClick(传 h.id) | 3 句',
' handlePrint | <button>(打印) 的 onClick | 1 句',
' handleBack | <button>(返回) 的 onClick | 2 句',
])
// 没讲进卡片的注释照常是子块;副作用放在「副作用」那一行下面,处理函数放在事件处理表对应的子行上
expect(outlineOf(view).filter(line => !/^ {4}/.test(line))).toEqual([
'react.component 51',
' comment 68',
' react.effect 76 @7',
' react.effect 81 @7',
' react.handler 104 @8/0',
' react.handler 112 @8/1',
' react.handler 116 @8/2',
])
})
it('副作用子卡片:时机按依赖读、做什么是上面的注释、返回的清理函数并进「清理」,别的语句放在「做什么」下面', async () => {
const view = await card(LEGAL, 'LegalDocPage')
const [first, second] = view.children.filter(child => child.kind === 'react.effect')
expect(first.badge).toEqual({ label: '副作用', color: { dark: '#e5c07b', light: '#9a6a00' }, icon: 'zap' })
expect(propertyLines(properties(first))).toEqual(['时机: doc 变了时', '做什么: 切换文档时正文滚回顶部 (DOM 副作用).'])
expect(propertyLines(properties(second))).toEqual([
'时机: doc、headings 变了时',
'做什么: 滚动时按 heading.top 计算当前高亮章节.',
'清理: container.removeEventListener("scroll", handleScroll)',
])
expect(second.children.map(child => `${child.kind} ${child.startLine + 1} @${child.slot?.row}`)).toEqual(['variable 82 @1', 'if 83 @1', 'function 84 @1', 'statement 100 @1'])
})
it('处理函数子卡片:名称(能跳到定义)/ 参数 / 做什么,函数里的语句放在「做什么」下面', async () => {
const view = await card(LEGAL, 'LegalDocPage')
const handler = view.children.find(child => child.kind === 'react.handler')
expect(handler?.badge).toEqual({ label: '事件处理', color: { dark: '#dcdcaa', light: '#795e26' }, icon: 'mouse-pointer-click' })
expect(handler && LEGAL.slice(handler.defines?.startIndex, handler.defines?.endIndex)).toBe('handleSectionClick')
expect(propertyLines(properties(handler))).toEqual(['名称: handleSectionClick', '参数: id: 字符串', '做什么: 3 句'])
expect(handler?.children.map(child => `${child.kind} @${child.slot?.row}`)).toEqual(['variable @2', 'variable @2', 'if @2'])
})
it('luffy 的 ShareButton:useCallback 算派生值、写依赖,主体是语句块时点名称展开它的子卡片;只在挂载时的副作用', async () => {
const view = await card(SHARE, 'ShareButton')
expect(rowLines(view, '派生值')).toEqual(['派生值: 1 个', ' startShare | 函数 3 句 | deployUrl、sessionId、title 变了时(useCallback)'])
expect(rowLines(view, '引用')).toEqual([
'引用: 2 个',
' activeRef | 真 | 副作用 1、startShare 里用它',
' creatingRef | 假 | startShare 里用它',
])
const callback = view.children.find(child => child.kind === 'react.handler')
expect(callback?.slot).toEqual({ row: 5, item: 0 })
const [mounted] = view.children.filter(child => child.kind === 'react.effect')
expect(propertyLines(properties(mounted))).toEqual(['时机: 只在挂载时', '做什么: 2 句', '清理: activeRef.current = 假;'])
})
it('读取的数据:store 选择器读成从哪个 store 读什么,别的库的钩子写哪个库;没绑在 JSX 上的处理函数写被谁调用', async () => {
const titlebar = await card(TITLEBAR, 'DesktopTitlebar')
expect(rowLines(titlebar, '读取的数据')).toEqual([
'读取的数据: 2 个',
' pathname | usePathname() | 当前路径',
' title | useDesktopShell((s) => s.title) | 从 desktop store 读 title',
])
expect(rowLines(titlebar, '派生值')).toEqual(['派生值: 1 个', ' center | title ?? PAGE_TITLES[pathname] ?? "" | 每次渲染'])
const poster = await card(POSTER, 'PosterDialog')
expect(rowLines(poster, '事件处理')).toEqual([
'事件处理: 3 个',
' exportPng | 被 download、share 调用 | 1 句',
' download | <Button> 的 onClick | 9 句',
' share | <Button> 的 onClick | 4 句',
])
expect(rowLines(poster, '副作用')).toEqual(['副作用: 1 个'])
})
})
describe('单独打开一段源码', () => {
it('读取的数据挂的是这次调用,改它的里 JSX 那一处是整个属性', async () => {
const source = [
'export function Panel() {',
' const pathname =',
' usePathname()',
' const [open, setOpen] = useState(false)',
' return (',
' <Dialog',
' onOpenChange={',
' setOpen',
' }',
' />',
' )',
'}',
].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')
})
})
/** 渲染输出卡片的大纲那一行的子行。 */
function outlineItems(view: BlockView): PropertyItem[] {
return properties(view)[0].items ?? []
}
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(rowLines(view, '渲染')).toEqual([
'渲染: 1 处返回',
it('luffy 的 DesktopTitlebar:大纲每行一个元素,原生标签、组件、样式小标、属性、插值', async () => {
const view = await render(TITLEBAR, 'DesktopTitlebar')
expect(lines(view)).toEqual([
'JSX 结构大纲: ',
' div ⟦样式 8 个类|⟧ data-tauri-drag-region="deep"',
' div ⟦样式 3 个类|⟧',
' ⟦|当 platform 全等 "macos" 时⟧ span ⟦样式 1 个类|⟧ data-testid="traffic-light-inset" aria-hidden',
@@ -297,8 +498,7 @@ describe('react 库规则:渲染大纲', () => {
' span ⟦样式 4 个类|⟧ {center}',
' ⟦|当 platform 全等 "windows" 时⟧ WindowControls',
])
const row = properties(view).find(item => item.label === '渲染')
const [div] = row?.items ?? []
const [div] = outlineItems(view)
const [tagSegment, , chip] = div.cells[0]
expect(tagSegment).toEqual({ text: 'div', role: 'plain', color: { dark: '#808080', light: '#6f6f6f' } })
expect(chip).toMatchObject({ style: 'classes', detail: 'flex h-10 shrink-0 select-none items-center border-b border-border bg-background' })
@@ -307,29 +507,28 @@ describe('react 库规则:渲染大纲', () => {
expect(controls && 'role' in controls && TITLEBAR.slice(controls.offset, (controls.offset ?? 0) + 14)).toBe('WindowControls')
})
it('luffy 的 LegalDocPage:JSX 注释、随条件切换的类(点开看每种情况的类)、列表(key、其中先算的常量)、三元的是 / 否两支,return 并进卡片', async () => {
const view = await card(LEGAL, 'LegalDocPage')
expect(view.children.map(child => child.kind)).not.toContain('return')
const lines = rowLines(view, '渲染')
expect(lines.slice(0, 4)).toEqual([
'渲染: 1 处返回',
it('luffy 的 LegalDocPage:JSX 注释、随条件切换的类(点开看每种情况的类)、列表(key、其中先算的常量)、三元的是 / 否两支', async () => {
const view = await render(LEGAL, 'LegalDocPage')
const text = lines(view)
expect(text.slice(0, 4)).toEqual([
'JSX 结构大纲: ',
' div ⟦样式 9 个类|⟧',
' // 顶部导航栏: 返回 + 文档切换 + 打印',
' header ⟦样式 13 个类|⟧',
])
expect(lines).toContain(' button ⟦样式 10 个类|⟧ type="button" onClick=handleBack aria-label="返回"')
expect(lines).toContain(' Link ⟦样式 12 个类 · 随 doc 变|⟧ href="/terms"')
expect(lines).toContain(' ⟦|对 headings 的每一项 h⟧ key h.id')
expect(lines).toContain(' 其中 isActive = activeSection 全等 h.id')
expect(lines).toContain(' button ⟦样式 7 个类 · 随 isActive 变|⟧ type="button" onClick=() => handleSectionClick(h.id) {h.title}')
const ternary = lines.indexOf(' ⟦|如果 markdown.trim()⟧')
expect(lines.slice(ternary, ternary + 4)).toEqual([
expect(text).toContain(' button ⟦样式 10 个类|⟧ type="button" onClick=handleBack aria-label="返回"')
expect(text).toContain(' Link ⟦样式 12 个类 · 取决于 doc|⟧ href="/terms"')
expect(text).toContain(' ⟦|对 headings 的每一项 h⟧ key h.id')
expect(text).toContain(' 其中 isActive = activeSection 全等 h.id')
expect(text).toContain(' button ⟦样式 7 个类 · 取决于 isActive|⟧ type="button" onClick=() => handleSectionClick(h.id) {h.title}')
const ternary = text.indexOf(' ⟦|如果 markdown.trim()⟧')
expect(text.slice(ternary, ternary + 4)).toEqual([
' ⟦|如果 markdown.trim()⟧',
' ⟦|是⟧ div ⟦样式 3 个类|⟧',
' ReactMarkdown remarkPlugins=[remarkGfm] components=对象:h2、a {markdown}',
' ⟦|否⟧ p ⟦样式 2 个类|⟧ {DOC_TITLE[doc]}内容尚未配置, 请在管理后台“站点配置”中填写。',
])
const link = findItem(properties(view).find(item => item.label === '渲染')?.items, item => segmentText(item.cells[0]).startsWith('Link'))
const link = findItem(outlineItems(view), item => segmentText(item.cells[0]).startsWith('Link'))
const chip = link?.cells[0].find(piece => !('role' in piece) && piece.style === 'classes')
expect(chip && !('role' in chip) && chip.detail).toBe([
'flex items-center gap-1.5 px-4 md:px-6 py-1.5 rounded-full text-[13px] md:text-[14px] font-semibold transition-all duration-300',
@@ -338,16 +537,15 @@ describe('react 库规则:渲染大纲', () => {
].join('\n'))
})
it('几处返回分几段:提前 return null 写「当 … 时 不渲染」,最后一处是「否则」;片段写「片段,不产生元素」', async () => {
const settings = readFixture('desktopSettingsDialog.tsx.txt')
expect(rowLines(await card(settings, 'DesktopSettingsDialog'), '渲染').slice(0, 4)).toEqual([
'渲染: 2 处返回',
' ⟦|当 非 section 时⟧ 不渲染',
' ⟦|否则⟧',
' Dialog open onOpenChange=(next) => 非 next 并且 closeSettings()',
it('luffy 的 DesktopSettingsDialog:先 return null 是如果卡片,最后返回的 JSX 是渲染输出;AppVaultSummary 的片段写「片段,不产生元素」', async () => {
const dialog = await card(SETTINGS, 'DesktopSettingsDialog')
expect(viewLines([childAt(dialog, 180)])).toEqual(['if 180: 如果 (非 section)', ' return 180: 返回 空;'])
expect(lines(childAt(dialog, 181)).slice(0, 2)).toEqual([
'JSX 结构大纲: ',
' Dialog open onOpenChange=(next) => 非 next 并且 closeSettings()',
])
expect(rowLines(await card(readFixture('appVaultSummary.tsx.txt'), 'AppVaultSummary'), '渲染').slice(0, 7)).toEqual([
'渲染: 1 处返回',
expect(lines(await render(readFixture('appVaultSummary.tsx.txt'), 'AppVaultSummary')).slice(0, 7)).toEqual([
'JSX 结构大纲: ',
' <> 片段,不产生元素',
' SettingHeader compact align="center" title="密钥与变量" description="按应用保存,这个应用的所有会话共用"',
' right',
@@ -355,15 +553,15 @@ describe('react 库规则:渲染大纲', () => {
' ⟦|当 effective.length > 0 时⟧ ul ⟦样式 2 个类|⟧',
' ⟦|对 effective 的每一项 { item, label }⟧ key item.id',
])
expect(rowLines(await card(settings, 'RuntimeSection'), '渲染')).toContain(' p ⟦样式 3 个类|⟧ {w.subdir} · {STATE_NOTE[w.state]}')
expect(lines(await render(SETTINGS, 'RuntimeSection'))).toContain(' p ⟦样式 3 个类|⟧ {w.subdir} · {STATE_NOTE[w.state]}')
})
it('luffy 的 OverviewSection:属性值里的条件和元素折到属性名下面,className 收成样式小标,短属性留在元素那一行', async () => {
const view = await card(readFixture('overviewSection.tsx.txt'), 'OverviewSection')
const lines = rowLines(view, '渲染')
const next = lines.indexOf(' SettingsRow label="状态"')
expect(lines.slice(0, next)).toEqual([
'渲染: 1 处返回',
const view = await render(readFixture('overviewSection.tsx.txt'), 'OverviewSection')
const text = lines(view)
const next = text.indexOf(' SettingsRow label="状态"')
expect(text.slice(0, next)).toEqual([
'JSX 结构大纲: ',
' SectionCard title="概览"',
' SettingsRow label="线上地址"',
' value',
@@ -381,17 +579,17 @@ describe('react 库规则:渲染大纲', () => {
' render',
' Link href=sectionHref(detail.id, "domain")',
])
expect(lines).toContain(' SettingsRow label="规格" value=deployment?.instance_type_code ?? "—" note="重新发布时可换档"')
expect(lines.join('\n')).not.toContain('text-primary underline')
const anchor = findItem(properties(view).find(item => item.label === '渲染')?.items, item => item.cells[0].some(piece => 'role' in piece && piece.text === 'a'))
expect(text).toContain(' SettingsRow label="规格" value=deployment?.instance_type_code ?? "—" note="重新发布时可换档"')
expect(text.join('\n')).not.toContain('text-primary underline')
const anchor = findItem(outlineItems(view), item => item.cells[0].some(piece => 'role' in piece && piece.text === 'a'))
const chip = anchor?.cells[0].find(piece => !('role' in piece) && piece.style === 'classes')
expect(chip && !('role' in chip) && chip.detail).toBe('text-primary underline underline-offset-2')
})
it('luffy 的 DeleteAppDialog 的删除按钮:文字的三元写成是 / 否两支', async () => {
const lines = rowLines(await card(readFixture('deleteAppDialog.tsx.txt'), 'DeleteAppDialog'), '渲染')
const button = lines.indexOf(' Button variant="destructive" onClick=() => remove.mutate() disabled=非 ready 或者 pending')
expect(lines.slice(button + 1, button + 4)).toEqual([
const text = lines(await render(readFixture('deleteAppDialog.tsx.txt'), 'DeleteAppDialog'))
const button = text.indexOf(' Button variant="destructive" onClick=() => remove.mutate() disabled=非 ready 或者 pending')
expect(text.slice(button + 1, button + 4)).toEqual([
' ⟦|如果 pending⟧',
' ⟦|是⟧ "删除中…"',
' ⟦|否⟧ "确认删除并清空数据"',
@@ -400,7 +598,7 @@ describe('react 库规则:渲染大纲', () => {
})
describe('react 库规则:代码差异', () => {
it('给组件加一个属性、改一个副作用的依赖:组件卡片标修改、属性表那一行新加,副作用子卡片标修改、时机逐截标', async () => {
it('给组件加一个属性、改一个副作用的依赖:组件卡片标修改、属性表那一行新加,副作用卡片标修改、触发时机逐截标', async () => {
const after = LEGAL
.replace(' doc: "terms" | "privacy";\n}', ' doc: "terms" | "privacy";\n /** 窄屏时收起目录 */\n compact?: boolean;\n}')
.replace('export const LegalDocPage = ({ doc }: LegalDocPageProps) => {', 'export const LegalDocPage = ({ doc, compact = false }: LegalDocPageProps) => {')
@@ -425,11 +623,11 @@ describe('react 库规则:代码差异', () => {
inline: [
{ segments: [{ text: 'doc', role: 'variable', offset: expect.any(Number) }] },
{ segments: [{ text: '、', role: 'hint' }, { text: 'markdown', role: 'variable', offset: expect.any(Number) }], change: 'add' },
{ segments: [{ text: ' ', role: 'plain' }, { text: '变了时', role: 'keyword' }] },
{ segments: [{ text: ' ', role: 'plain' }, { text: '变化时', role: 'keyword' }] },
],
})
// 另一个副作用、处理函数没变,不标
const unchanged = component.children.filter(child => child !== effect && child.kind !== 'comment')
// 组件体里别的卡片没变,不标
const unchanged = component.children.filter(child => child !== effect)
expect(unchanged.map(child => diff.blocks[child.anchor])).toEqual(unchanged.map(() => undefined))
expect(diff.stats).toEqual({ add: 0, mod: 2, del: 0 })
})
File diff suppressed because it is too large. Load diff
+51 -21
View File
@@ -3,7 +3,7 @@ import type { ComponentFacts, OutlineNode } from './reactTree'
import { describe, expect, it } from 'vitest'
import { createTypeScriptAdapter } from '../languages/typescript'
import { nodesToText, readFixture, walk, WASM_DIR, withBlocks } from '../testSupport'
import { bodyOf, classesOf, componentOf, declaredTypes, enclosingComponent, outlineOf, placesOf, propsOf } from './reactTree'
import { bodyOf, classesOf, componentOf, componentOfFunction, declaredTypes, enclosingComponent, enclosingFunction, outlineOf, placesOf, propsOf } from './reactTree'
const ts = createTypeScriptAdapter(WASM_DIR)
@@ -38,6 +38,12 @@ async function withComponent<T>(source: string, name: string, use: (component: C
})
}
/** 组件最后一句渲染的 JSX:return 返回的值,组件体是表达式时是它自己。 */
function rendered(component: ComponentFacts): Node | undefined {
const last = bodyOf(component).returns.at(-1)
return last?.type === 'return_statement' ? last.namedChildren.find(child => child?.type !== 'comment') ?? undefined : last
}
/** 大纲写成文字:元素是「名字(组件带 *).类数+按条件切换的个数 属性… 文字」,下一层多缩进两格;条件、三元、列表照读法写。 */
function outlineLines(nodes: readonly OutlineNode[], pad = ''): string[] {
const text = (list: readonly Node[]): string => list.map(item => item.text.replace(/\s+/g, ' ').trim()).join('')
@@ -120,6 +126,31 @@ describe('react 组件:认组件', () => {
// 副作用函数里面的语句不是组件体的直接子语句
const nested = card?.children.find(child => child.children.length > 0 && child.kind === 'statement')?.children[0]
expect(nested && enclosingComponent(nested.node)).toBeUndefined()
// 副作用函数里的语句所在的函数是副作用函数,不是组件;组件体里的 return 所在的函数是组件
const fn = nested && enclosingFunction(nested.node)
expect(fn && componentOfFunction(fn)).toBeUndefined()
const last = card?.children.at(-1)
const owner = last && enclosingFunction(last.node)
expect(last?.kind).toBe('return')
expect(owner && componentOfFunction(owner)?.name.text).toBe('LegalDocPage')
})
})
it('提前返回的 if 里的 return 所在的函数也是组件;memo 包着的组件函数认得出', async () => {
const source = [
'export const Row = memo(({ id }: { id: string }) => {',
' if (!id) {',
' return <i />;',
' }',
' return <li>{id}</li>;',
'});',
].join('\n')
await withComponents(source, (components, blocks) => {
const inner = blocks[0].children[0].children[0]
expect(inner.kind).toBe('return')
const fn = enclosingFunction(inner.node)
expect(fn && componentOfFunction(fn)?.name.text).toBe('Row')
expect(components.map(component => component.name.text)).toEqual(['Row'])
})
})
})
@@ -213,11 +244,12 @@ describe('react 组件:组件体', () => {
states: nodesToText(body.states),
refs: nodesToText(body.refs),
reads: body.reads.map(read => `${read.target.text} = ${read.hook.text}`),
derived: body.derived.map(item => `${item.name.text}${item.memo ? ` ${item.memo.hook.text} ${item.memo.deps?.text}` : ''}`),
effects: body.effects.map(effect => `${effect.hook.text} ${effect.deps?.text} ${effect.cleanup ? '有清理' : '无清理'} ${effect.comments.map(comment => comment.text).join('')}`),
derived: body.derived.map(item => `${item.name.text} ${item.hook.text} ${item.deps?.text}`),
variables: body.variables.map(item => item.name.text),
effects: body.effects.map(effect => `${effect.hook.text} ${effect.deps?.text} ${effect.cleanup ? '有清理' : '无清理'}`),
handlers: body.handlers.map(handler => handler.name.text),
resets: body.resets.map(reset => `${reset.condition.text}: ${reset.setters.map(setter => setter.text).join('、')}`),
returns: body.returns.map(item => `${item.when?.text ?? '否则'} ${item.value ? '渲染' : '不渲染'}`),
resets: body.resets.map(reset => reset.condition.text),
returns: body.returns.map(statement => statement.type),
}
})
expect(facts.states).toMatchObject([
@@ -226,26 +258,24 @@ describe('react 组件:组件体', () => {
])
expect(facts.refs).toMatchObject([{ name: 'scrollRef', initial: 'null' }])
expect(facts.reads).toEqual(['cfg = useSiteConfig', 'router = useRouter'])
expect(facts.derived).toEqual(['markdown', 'headings useMemo [markdown]'])
expect(facts.effects).toEqual([
'useEffect [doc] 无清理 // 切换文档时正文滚回顶部 (DOM 副作用).',
'useEffect [doc, headings] 有清理 // 滚动时按 heading.top 计算当前高亮章节.',
])
expect(facts.derived).toEqual(['headings useMemo [markdown]'])
expect(facts.variables).toEqual(['markdown'])
expect(facts.effects).toEqual(['useEffect [doc] 无清理', 'useEffect [doc, headings] 有清理'])
expect(facts.handlers).toEqual(['handleSectionClick', 'handlePrint', 'handleBack'])
expect(facts.resets).toEqual(['doc !== prevDoc: setPrevDoc、setActiveSection'])
expect(facts.returns).toEqual(['否则 渲染'])
expect(facts.resets).toEqual(['doc !== prevDoc'])
expect(facts.returns).toEqual(['return_statement'])
})
it('luffy 的 DesktopSettingsDialog:先 return null 的提前返回带条件;StepLabel 直接返回的 JSX 是唯一的返回', async () => {
const returns = await withComponent(SETTINGS, 'DesktopSettingsDialog', component => bodyOf(component).returns.map(item => `${item.when?.text ?? '否则'} ${item.value ? '渲染' : '不渲染'}`))
expect(returns).toEqual(['!section 不渲染', '否则 渲染'])
it('luffy 的 DesktopSettingsDialog:先 return null 的提前返回也算渲染的一句;StepLabel 直接返回的 JSX 是唯一的一句', async () => {
const returns = await withComponent(SETTINGS, 'DesktopSettingsDialog', component => bodyOf(component).returns.map(statement => statement.type))
expect(returns).toEqual(['if_statement', 'return_statement'])
const reads = await withComponent(SETTINGS, 'DesktopSettingsDialog', component => bodyOf(component).reads.map(read => `${read.target.text} = ${read.call.text}`))
expect(reads).toEqual([
'section = useDesktopShell((s) => s.settingsSection)',
'openSettings = useDesktopShell((s) => s.openSettings)',
'closeSettings = useDesktopShell((s) => s.closeSettings)',
])
expect(await withComponent(SETTINGS, 'DesktopSettingsDialog', component => bodyOf(component).derived.map(item => item.name.text))).toEqual(['platform'])
expect(await withComponent(SETTINGS, 'DesktopSettingsDialog', component => bodyOf(component).variables.map(item => item.name.text))).toEqual(['platform'])
const step = await withComponent(POSTER, 'StepLabel', component => bodyOf(component).returns.length)
expect(step).toBe(1)
})
@@ -254,7 +284,7 @@ describe('react 组件:组件体', () => {
const facts = await withComponent(SHARE, 'ShareButton', (component) => {
const body = bodyOf(component)
return {
derived: body.derived.map(item => `${item.name.text} ${item.memo?.hook.text} ${item.memo?.deps?.text}`),
derived: body.derived.map(item => `${item.name.text} ${item.hook.text} ${item.deps?.text}`),
refs: body.refs.map(ref => ref.name.text),
effects: body.effects.map(effect => effect.deps?.text),
}
@@ -304,7 +334,7 @@ describe('react 组件:用在哪', () => {
describe('react 组件:渲染的 JSX', () => {
it('luffy 的 DesktopTitlebar:原生标签、组件、类、属性,cond && <X/> 带条件,只有插值的写在同一行', async () => {
const lines = await withComponent(TITLEBAR, 'DesktopTitlebar', component => outlineLines(outlineOf(bodyOf(component).returns[0].value)))
const lines = await withComponent(TITLEBAR, 'DesktopTitlebar', component => outlineLines(outlineOf(rendered(component))))
expect(lines).toEqual([
'div .8 data-tauri-drag-region="deep"',
' div .3',
@@ -316,7 +346,7 @@ describe('react 组件:渲染的 JSX', () => {
})
it('luffy 的 LegalDocPage:JSX 注释、按条件切换的类、列表(其中先算的常量、key)、三元的是 / 否两支', async () => {
const lines = await withComponent(LEGAL, 'LegalDocPage', component => outlineLines(outlineOf(bodyOf(component).returns[0].value)))
const lines = await withComponent(LEGAL, 'LegalDocPage', component => outlineLines(outlineOf(rendered(component))))
expect(lines.slice(0, 5)).toEqual([
'div .9',
' /* 顶部导航栏: 返回 + 文档切换 + 打印 */',
@@ -333,7 +363,7 @@ describe('react 组件:渲染的 JSX', () => {
})
it('luffy 的 DeleteAppDialog 的删除按钮:文字的三元;列表解构每一项;AppVaultSummary 的片段', async () => {
const lines = await withComponent(DELETE_APP, 'DeleteAppDialog', component => outlineLines(outlineOf(bodyOf(component).returns[0].value)))
const lines = await withComponent(DELETE_APP, 'DeleteAppDialog', component => outlineLines(outlineOf(rendered(component))))
expect(lines).toContain(' 对 losses 的每一项 { icon: Icon, text } key text')
expect(lines).toContain(' li .3')
expect(lines).toContain(' Icon* .3 size={14}')
@@ -346,7 +376,7 @@ describe('react 组件:渲染的 JSX', () => {
' 否',
' 「"确认删除并清空数据"」',
])
const vault = await withComponent(VAULT, 'AppVaultSummary', component => outlineLines(outlineOf(bodyOf(component).returns[0].value)))
const vault = await withComponent(VAULT, 'AppVaultSummary', component => outlineLines(outlineOf(rendered(component))))
expect(vault[0]).toBe('<>')
expect(vault[2]).toBe(' 当 effective.length > 0 时 ul .2')
})
+65 -68
View File
@@ -283,9 +283,22 @@ export function componentsIn(root: Node): ComponentFacts[] {
export function enclosingComponent(statement: Node): ComponentFacts | undefined {
const body = statement.parent
const fn = body?.type === 'statement_block' ? body.parent : null
if (!fn || !isFunction(fn))
return undefined
// 组件函数只会是函数声明、变量的值,或 memo / forwardRef 的实参(可以套几层);别的函数(副作用、处理函数)里的语句不用再往上找
const component = fn && isFunction(fn) ? componentOfFunction(fn) : undefined
return component && sameNode(component.body, body) ? component : undefined
}
/** node 所在的最内层函数(node 自己不算);不在函数里时没有。 */
export function enclosingFunction(node: Node): Node | undefined {
for (let current = node.parent; current; current = current.parent) {
if (isFunction(current))
return current
}
return undefined
}
/** 函数是组件函数时读出这个组件;副作用、处理函数这类别的函数没有。 */
export function componentOfFunction(fn: Node): ComponentFacts | undefined {
// 组件函数只会是函数声明、变量的值,或 memo / forwardRef 的实参(可以套几层)
let outer: Node | null = fn
if (fn.type !== 'function_declaration') {
let value: Node = fn
@@ -303,7 +316,7 @@ export function enclosingComponent(statement: Node): ComponentFacts | undefined
if (outer?.parent?.type === 'export_statement')
outer = outer.parent
const component = outer ? componentAt(outer) : undefined
return component && sameNode(component.body, body) ? component : undefined
return component && sameNode(component.fn, fn) ? component : undefined
}
// ─── 属性 ─────────────────────────────────────────────────────────────────────
@@ -518,7 +531,7 @@ export interface StateFacts {
hook: Node
/** 状态的名字(解构的第一项;没解构时是整个变量名)。 */
value: Node
/** 改它的函数(解构的第二项);没解构出来时没有。 */
/** 更新它的函数(解构的第二项);没解构出来时没有。 */
setter?: Node
/** 初始值:useState 的实参,useReducer 的第二个实参。 */
initial?: Node
@@ -533,7 +546,7 @@ export interface RefFacts {
initial?: Node
}
/** 读取的数据:别的钩子调用赋给的变量(`const pathname = usePathname()`、`const cfg = useSiteConfig()`)。 */
/** 数据源:别的钩子调用赋给的变量(`const pathname = usePathname()`、`const cfg = useSiteConfig()`)。 */
export interface ReadFacts {
statement: Node
/** 赋给的变量(名字,或解构模式)。 */
@@ -542,13 +555,22 @@ export interface ReadFacts {
hook: Node
}
/** 派生值:组件体里别的常量,和 useMemo / useCallback 算出来的。 */
/** 派生值:useMemo / useCallback 算出来的。 */
export interface DerivedFacts {
statement: Node
name: Node
value: Node
/** useMemo / useCallback:钩子名、第一个实参(算值的函数、要记住的函数)、依赖数组。 */
memo?: { hook: Node, callback?: Node, deps?: Node }
hook: Node
/** 第一个实参:算值的函数、要记住的函数。 */
callback?: Node
/** 第二个实参:依赖数组。 */
deps?: Node
}
/** 组件体里别的变量(`const x = 表达式`):照常是变量块,记下来只为认出名字用在哪。 */
export interface VariableFacts {
statement: Node
/** 变量名,或解构模式。 */
name: Node
}
/** 副作用:`useEffect(() => { … }, [deps])`、useLayoutEffect、useInsertionEffect。 */
@@ -561,8 +583,6 @@ export interface EffectFacts {
deps?: Node
/** 副作用函数主体里最后返回清理函数的那句 return;没有时没有。 */
cleanup?: Node
/** 紧贴在上面的注释(连着几行的行注释都算):说它做什么。 */
comments: Node[]
}
/** 处理函数:组件体里定义的函数(函数声明、值是函数的常量)。 */
@@ -570,52 +590,32 @@ export interface HandlerFacts {
statement: Node
name: Node
fn: Node
/** 紧贴在上面的注释(函数声明的 JSDoc 也算):说它做什么。 */
comments: Node[]
}
/** 渲染时按条件改状态(React 的「渲染中重置」写法):`if (doc !== prevDoc) { setPrevDoc(doc); … }`。 */
export interface ResetFacts {
statement: Node
condition: Node
/** 里面调用的改状态的函数,照源码顺序。 */
setters: Node[]
}
/** 组件的一处返回:提前返回(`if (cond) return …`)带条件;value 是返回的 JSX,返回 null 这类空值时没有。 */
export interface ReturnFacts {
statement: Node
when?: Node
value?: Node
}
/** 组件体里各种语句归到哪儿;没归进来的语句照常是子块。 */
/** 组件体里各种语句归到哪儿:规则按它认领组件体里的块,也按它说名字用在哪。 */
export interface BodyFacts {
states: StateFacts[]
refs: RefFacts[]
reads: ReadFacts[]
derived: DerivedFacts[]
variables: VariableFacts[]
effects: EffectFacts[]
handlers: HandlerFacts[]
resets: ResetFacts[]
returns: ReturnFacts[]
/** 渲染的那几句:return、提前返回的 `if (cond) return …`;组件体是表达式时是它自己。 */
returns: Node[]
}
const STATE_HOOKS = new Set(['useState', 'useReducer'])
const MEMO_HOOKS = new Set(['useMemo', 'useCallback'])
const EFFECT_HOOKS = new Set(['useEffect', 'useLayoutEffect', 'useInsertionEffect'])
/** 紧贴在语句上面的注释:连着几行的行注释、一段块注释都算,中间不能隔空行。 */
export function commentsBefore(statement: Node): Node[] {
const found: Node[] = []
let row = statement.startPosition.row
for (let previous = statement.previousNamedSibling; previous?.type === 'comment' && previous.endPosition.row === row - 1; previous = previous.previousNamedSibling) {
found.unshift(previous)
row = previous.startPosition.row
}
return found
}
/** 副作用函数主体里最后一句:返回清理函数的 return(`return () => …`、`return cleanup`)。 */
function cleanupOf(callback: Node | undefined): Node | undefined {
const body = callback?.childForFieldName('body')
@@ -631,19 +631,16 @@ function onlyDeclarator(statement: Node): Node | undefined {
return declarators.length === 1 ? declarators[0] : undefined
}
/** 一句 `if (cond) return …`(主体只有这一句 return,没有 else):条件和返回的值。 */
function earlyReturn(statement: Node): ReturnFacts | undefined {
/** 一句 `if (cond) return …`(主体只有这一句 return,没有 else),返回的是空值或 JSX。 */
function isEarlyReturn(statement: Node): boolean {
if (statement.type !== 'if_statement' || statement.childForFieldName('alternative'))
return undefined
return false
const consequence = statement.childForFieldName('consequence')
const only = consequence?.type === 'statement_block' ? syntax(consequence) : consequence ? [consequence] : []
const condition = unwrap(statement.childForFieldName('condition'))
if (only.length !== 1 || only[0].type !== 'return_statement' || !condition)
return undefined
if (only.length !== 1 || only[0].type !== 'return_statement')
return false
const value = syntax(only[0])[0]
if (!isNothing(value) && !isJsx(value))
return undefined
return { statement, when: condition, ...(!isNothing(value) && value && { value }) }
return isNothing(value) || isJsx(value)
}
/** 一句 if 的主体都是在调用改状态的函数(没有 else):渲染时按条件重置状态。 */
@@ -657,19 +654,18 @@ function resetOf(statement: Node, setters: ReadonlySet<string>): ResetFacts | un
const names = calls.map(call => call?.type === 'call_expression' ? unwrap(call.childForFieldName('function')) : undefined)
if (!condition || names.length === 0 || names.some(name => name?.type !== 'identifier' || !setters.has(name.text)))
return undefined
return { statement, condition, setters: names.filter((name): name is Node => !!name) }
return { statement, condition }
}
/** 组件体里的各种语句(见 BodyFacts);箭头函数直接返回的 JSX 是唯一的一处返回。 */
/** 组件体里的各种语句(见 BodyFacts)。 */
export function bodyOf(component: ComponentFacts): BodyFacts {
const facts: BodyFacts = { states: [], refs: [], reads: [], derived: [], effects: [], handlers: [], resets: [], returns: [] }
const facts: BodyFacts = { states: [], refs: [], reads: [], derived: [], variables: [], effects: [], handlers: [], resets: [], returns: [] }
if (component.body.type !== 'statement_block') {
facts.returns.push({ statement: component.body, ...(!isNothing(component.body) && { value: component.body }) })
facts.returns.push(component.body)
return facts
}
const statements = syntax(component.body)
const setters = new Set<string>()
for (const statement of statements) {
for (const statement of syntax(component.body)) {
const declarator = onlyDeclarator(statement)
const target = declarator?.childForFieldName('name')
const value = unwrap(declarator?.childForFieldName('value'))
@@ -688,7 +684,7 @@ export function bodyOf(component: ComponentFacts): BodyFacts {
}
else if (declarator && target && value && hook && MEMO_HOOKS.has(hook.text)) {
const [callback, deps] = argumentsOf(value)
facts.derived.push({ statement, name: target, value, memo: { hook, ...(callback && { callback }), ...(deps && { deps }) } })
facts.derived.push({ statement, name: target, hook, ...(callback && { callback }), ...(deps && { deps }) })
}
else if (declarator && target && value && hook) {
facts.reads.push({ statement, target, call: value, hook })
@@ -697,13 +693,13 @@ export function bodyOf(component: ComponentFacts): BodyFacts {
// 组件里又定义的组件照常成块(自己也是一张组件卡片),不算处理函数
}
else if (declarator && target?.type === 'identifier' && value && isFunction(value)) {
facts.handlers.push({ statement, name: target, fn: value, comments: commentsBefore(statement) })
facts.handlers.push({ statement, name: target, fn: value })
}
else if (declarator && target && value) {
facts.derived.push({ statement, name: target, value })
else if (declarator && target) {
facts.variables.push({ statement, name: target })
}
else if (statement.type === 'function_declaration' && statement.childForFieldName('name')) {
facts.handlers.push({ statement, name: statement.childForFieldName('name') as Node, fn: statement, comments: commentsBefore(statement) })
facts.handlers.push({ statement, name: statement.childForFieldName('name') as Node, fn: statement })
}
else if (statement.type === 'expression_statement') {
const call = unwrap(syntax(statement)[0])
@@ -711,19 +707,15 @@ export function bodyOf(component: ComponentFacts): BodyFacts {
if (call && effect && EFFECT_HOOKS.has(effect.text)) {
const [callback, deps] = argumentsOf(call)
const cleanup = cleanupOf(callback)
facts.effects.push({ statement, hook: effect, ...(callback && { callback }), ...(deps && { deps }), ...(cleanup && { cleanup }), comments: commentsBefore(statement) })
facts.effects.push({ statement, hook: effect, ...(callback && { callback }), ...(deps && { deps }), ...(cleanup && { cleanup }) })
}
}
else if (statement.type === 'return_statement') {
const returned = syntax(statement)[0]
facts.returns.push({ statement, ...(!isNothing(returned) && returned && { value: returned }) })
else if (statement.type === 'return_statement' || isEarlyReturn(statement)) {
facts.returns.push(statement)
}
else {
const early = earlyReturn(statement)
const reset = early ? undefined : resetOf(statement, setters)
if (early)
facts.returns.push(early)
else if (reset)
const reset = resetOf(statement, setters)
if (reset)
facts.resets.push(reset)
}
}
@@ -766,13 +758,14 @@ export function statementsOf(fn: Node): Node[] {
/**
* 组件里一个名字(改状态的函数、引用、处理函数)用到的一处在哪:某个副作用里(第几个,0 起)、某个处理函数里、某个派生值里、
* 某个读数据的钩子调用里、渲染时按条件重置状态的 if 里、JSX 的某个属性上(element 是开标签,each 表示在 .map 里一项项渲染,
* call 是在属性值里调用它的那次调用),或渲染的别处(JSX 里、提前返回的条件)。
* 某个别的变量的值里、某个读数据的钩子调用里、渲染时按条件重置状态的 if 里、JSX 的某个属性上(element 是开标签,
* each 表示在 .map 里一项项渲染,call 是在属性值里调用它的那次调用),或渲染的别处(JSX 里、提前返回的条件)。
*/
export type Place
= | { kind: 'effect', index: number }
| { kind: 'handler', name: Node }
| { kind: 'derived', name: Node }
| { kind: 'variable', name: Node }
| { kind: 'read', target: Node }
| { kind: 'reset', condition: Node }
| { kind: 'jsx', element: Node, attribute: Node, each: boolean, call?: Node }
@@ -827,13 +820,16 @@ function placeOf(node: Node, component: ComponentFacts, body: BodyFacts): Place
const derived = body.derived.find(at)
if (derived)
return { kind: 'derived', name: derived.name }
const variable = body.variables.find(at)
if (variable)
return { kind: 'variable', name: variable.name }
const read = body.reads.find(at)
if (read)
return { kind: 'read', target: read.target }
const reset = body.resets.find(at)
if (reset)
return { kind: 'reset', condition: reset.condition }
if (body.returns.some(at))
if (body.returns.some(item => sameNode(item, statement)))
return jsxPlace(node, statement)
return undefined
}
@@ -845,6 +841,7 @@ function placeKey(place: Place): string {
return `effect:${place.index}`
case 'handler':
case 'derived':
case 'variable':
return `${place.kind}:${place.name.startIndex}`
case 'read':
return `read:${place.target.startIndex}`
+1 -1
View File
@@ -7,7 +7,7 @@ const BADGE: KindBadge = { label: '实现', color: COLOR, icon: 'square-function
/**
* impl 的方法表:点方法名展开那张函数卡。
* 子块放进属性表某一行,和 React 事件处理同一条路。不看导入,Rust 文件都启用。
* 子块放进属性表某一行(RuleSlot)。不看导入,Rust 文件都启用。
*/
export const rustImplRule: LibraryRule = {
id: 'rust',
+1 -5
View File
@@ -11,11 +11,10 @@ import { BlockLevel } from './BlockTree'
import { DiffBar, NewFileBanner } from './DiffBar'
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
import { acceptNames, DisplayNameContext, documentNames } from './displayNames'
import { coveredByEditing, snippetRefs, startEditing } from './editing'
import { coveredByEditing, 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'
@@ -68,7 +67,6 @@ 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),筛选一变就重新折 */
@@ -178,7 +176,6 @@ export function App(): ReactElement | null {
return (
<HighlightContext value={highlight}>
<DisplayNameContext value={names?.names ?? NO_NAMES}>
<SnippetEditContext value={{ ids: snippetIds, editing: tree.editing, onToggle: tree.onToggleEdit, version: doc.version, focusId, onFocused }}>
<FilterBar
attributes={doc.attributes}
excluded={excluded}
@@ -239,7 +236,6 @@ export function App(): ReactElement | null {
? <BlockLevel blocks={doc.blocks} tree={tree} />
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
</div>
</SnippetEditContext>
</DisplayNameContext>
</HighlightContext>
)
@@ -270,7 +270,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
{editing
? (
<CodeEditor
source={block}
block={block}
version={tree.version}
autoFocus={tree.focusId === block.id}
onAutoFocused={tree.onFocused}
+13 -21
View File
@@ -1,5 +1,5 @@
import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react'
import type { Segment } from './types'
import type { BlockView } from './types'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { recolor } from './segments'
import { SegmentText } from './SegmentText'
@@ -7,17 +7,9 @@ import { postMessage } from './vscode'
const EDIT_DEBOUNCE_MS = 200
/** 编辑框要的那一段源码:整块,或格子里单独打开的一段,字段一样。 */
export interface EditableSource {
startLine: number
endLine: number
code: string
codeTokens: Segment[]
}
interface CodeEditorProps {
source: EditableSource
/** 文档版本:每来一份新文档都要对一次输入框内容,哪怕这一段的代码没变 */
block: BlockView
/** 文档版本:每来一份新文档都要对一次输入框内容,哪怕这一块的代码没变 */
version: number
autoFocus: boolean
onAutoFocused: () => void
@@ -25,11 +17,11 @@ interface CodeEditorProps {
}
/** 一块源码的编辑状态:着色层显示,透明输入框接收输入,改动防抖后整行替换回文件。 */
export function CodeEditor({ source, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement {
const [text, setText] = useState(source.code)
export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement {
const [text, setText] = useState(block.code)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const sourceRef = useRef(source)
sourceRef.current = source
const blockRef = useRef(block)
blockRef.current = block
const textRef = useRef(text)
textRef.current = text
/** 本地改过、还没在新文档里看到的文字;有它时新文档不能覆盖有焦点的输入框 */
@@ -45,7 +37,7 @@ export function CodeEditor({ source, version, autoFocus, onAutoFocused, onExit }
if (!unsent.current)
return
unsent.current = false
const { startLine, endLine } = sourceRef.current
const { startLine, endLine } = blockRef.current
const value = textRef.current
postMessage({
type: 'edit',
@@ -59,16 +51,16 @@ export function CodeEditor({ source, version, autoFocus, onAutoFocused, onExit }
useEffect(() => {
const el = textareaRef.current
const focused = el !== null && document.activeElement === el
if (focused && unconfirmed.current !== null && unconfirmed.current !== source.code)
if (focused && unconfirmed.current !== null && unconfirmed.current !== block.code)
return
unconfirmed.current = null
sentLineCount.current = null
if (source.code === textRef.current)
if (block.code === textRef.current)
return
if (focused)
restoreSelection.current = [el.selectionStart, el.selectionEnd]
setText(source.code)
}, [version, source.code])
setText(block.code)
}, [version, block.code])
useLayoutEffect(() => {
const el = textareaRef.current
@@ -129,7 +121,7 @@ export function CodeEditor({ source, version, autoFocus, onAutoFocused, onExit }
onBlur={flush}
/>
<pre className="cr-code" aria-hidden="true">
<SegmentText segments={recolor(source.codeTokens, source.code, text)} />
<SegmentText segments={recolor(block.codeTokens, block.code, text)} />
{text === '' || text.endsWith('\n') ? ' ' : ''}
</pre>
</div>
@@ -1,8 +1,7 @@
import type { CSSProperties, ReactElement, ReactNode } 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 type { BlockView, Inline, InlinePart, ItemDiff, ItemsDiff, KindBadge, PropertyItem, PropertyRow, Removed, RowsDiff } from './types'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { Fragment } from 'react'
import { DiffInline, DiffText } from './DiffText'
import { inlineText } from './segments'
import { SegmentText } from './SegmentText'
@@ -57,83 +56,6 @@ function CellText({ cell, parts }: { cell: Inline[], parts: InlinePart[] | undef
return parts ? <DiffInline parts={parts} /> : <SegmentText segments={cell} />
}
/** 格子里单独打开的那段源码:和块共用同一套铅笔、同一个编辑框。 */
export interface SnippetEditState {
ids: ReadonlyMap<CellEdit, string>
editing: ReadonlySet<string>
onToggle: (id: string) => void
version: number
focusId: string | null
onFocused: () => void
}
export const SnippetEditContext = createContext<SnippetEditState | null>(null)
function EditToggle({ editing, onToggle }: { editing: boolean, onToggle: () => void }): ReactElement {
const label = editing ? '预览' : '编辑'
const Icon = editing ? Eye : Pencil
return (
<button
type="button"
className="cr-tool inline-flex h-5 w-6 items-center justify-center rounded align-middle"
data-active={editing ? '' : undefined}
data-tooltip={label}
aria-label={label}
onClick={onToggle}
>
<Icon size={14} strokeWidth={1.8} aria-hidden />
</button>
)
}
function SnippetSpan({ edit, segments }: { edit: CellEdit, segments: Inline[] }): ReactElement {
const ctx = useContext(SnippetEditContext)
const id = ctx?.ids.get(edit)
if (!ctx || id === undefined)
return <SegmentText segments={segments} />
const editing = ctx.editing.has(id)
return (
<span className="inline-flex flex-wrap items-baseline gap-0.5">
{editing
? (
<span className="block w-full">
<CodeEditor
source={edit.snippet}
version={ctx.version}
autoFocus={ctx.focusId === id}
onAutoFocused={ctx.onFocused}
onExit={() => ctx.onToggle(id)}
/>
</span>
)
: <SegmentText segments={segments} />}
<EditToggle editing={editing} onToggle={() => ctx.onToggle(id)} />
</span>
)
}
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 <CellText cell={cell} parts={parts} />
const chunks: ReactElement[] = []
let at = 0
mine.forEach((edit, n) => {
if (edit.from > at)
chunks.push(<SegmentText key={`t${n}`} segments={cell.slice(at, edit.from)} />)
chunks.push(<SnippetSpan key={`s${n}`} edit={edit} segments={cell.slice(edit.from, edit.to)} />)
at = edit.to
})
if (at < cell.length)
chunks.push(<SegmentText key="tail" segments={cell.slice(at)} />)
return <>{chunks}</>
}
/**
* 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换;
* 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。
@@ -193,9 +115,9 @@ function PropertyItems({ parentId, items, folding, marks }: { parentId: string,
{item.cells.map((cell, i) => i === 0 && nested
? <ItemHead key={i} id={id} cell={cell} foldable={children.length > 0 && change !== 'del'} folding={folds} />
: (
<div key={i} className="cr-code min-w-0">
<CellView cell={cell} index={i} edits={item.edits} parts={mark?.cells?.[i]} />
</div>
<p key={i} className="cr-code min-w-0">
<CellText cell={cell} parts={mark?.cells?.[i]} />
</p>
))}
</div>
{children.length > 0 && !folds.collapsed.has(id) && (change === 'del'
@@ -234,7 +156,7 @@ function PropertyItems({ parentId, items, folding, marks }: { parentId: string,
* 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。一行译文下面摊开实参的表也用它;
* 行带 table(实参的值里跨几行写的调用)时,那张表排在最后一格里、文字下面,往右缩一点,一层层往下套。
* 标差异时(marks)有加行、删行的表每行第一格前多一个记号位(+ / −,没变的空着),加的行淡绿底,删的行放回原来的位置、淡红底划掉。
* 讲某个子块的行(toggles,React 组件的事件处理表)第一格是按钮,点它在表下面展开那一块,展开时这一行淡淡垫上那一块的种类色。
* 讲某个子块的行(toggles,Rust 实现块的方法表)第一格是按钮,点它在表下面展开那一块,展开时这一行淡淡垫上那一块的种类色。
*/
export function PropertyTable({ parentId, columns, items, marks, toggles }: {
parentId: string
@@ -255,10 +177,10 @@ export function PropertyTable({ parentId, columns, items, marks, toggles }: {
{toggle && i === 0
? (
<button type="button" className="cr-item-toggle" aria-expanded={toggle.open} aria-label={`${toggle.open ? '收起' : '展开'}${inlineText(cell)}`} onClick={toggle.onToggle}>
<CellView cell={cell} index={i} edits={item.edits} parts={mark?.cells?.[i]} />
<CellText cell={cell} parts={mark?.cells?.[i]} />
</button>
)
: <CellView cell={cell} index={i} edits={item.edits} parts={mark?.cells?.[i]} />}
: <CellText cell={cell} parts={mark?.cells?.[i]} />}
</>
)
const tint = toggle?.open && toggle.badge
@@ -269,11 +191,11 @@ export function PropertyTable({ parentId, columns, items, marks, toggles }: {
{cells.map((cell, i) => item.table && i === cells.length - 1
? (
<div key={i} className="cr-table-cell flex min-w-0 flex-col gap-[3px]" role="cell">
<div className="cr-code">{text(cell, i)}</div>
<p className="cr-code">{text(cell, i)}</p>
<PropertyTable parentId={tableId(id)} columns={item.table.columns} items={item.table.items} marks={mark?.table} />
</div>
)
: <div key={i} className="cr-table-cell cr-code min-w-0" role="cell">{text(cell, i)}</div>)}
: <p key={i} className="cr-table-cell cr-code min-w-0" role="cell">{text(cell, i)}</p>)}
</div>
)
}
@@ -43,14 +43,13 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement
}
/**
* 框的样式 → 框头图标:遍历同「循环」块、按条件同「条件」块、不渲染同「返回」块的图标;条件的一支、小标签、类名不带图标。
* 框的样式 → 框头图标:遍历同「循环」块、按条件同「条件」块的图标;条件的一支、小标签、类名不带图标。
* 颜色在 codeReader.css 的 .cr-group[data-style] 里,框自己给了颜色(color)时用它。
*/
const GROUP_ICONS: Record<GroupStyle, LucideIcon | undefined> = {
iteration: KIND_ICON_COMPONENTS.repeat,
condition: KIND_ICON_COMPONENTS.split,
branch: undefined,
skip: KIND_ICON_COMPONENTS['corner-down-left'],
tag: undefined,
classes: undefined,
}
@@ -412,11 +412,10 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c
}
/*
* 渲染大纲一行前面的小框(React 组件卡片):按条件、条件的一支同「条件」块的颜色,不渲染同「返回」块;
* 渲染大纲一行前面的小框(React 渲染输出卡片):按条件、条件的一支同「条件」块的颜色;
* 小标签(名称后面的「具名导出」、属性名后面的「回调」)用规则给的颜色(暗色、亮色各一个,按主题挑)
*/
.cr-group:is([data-style="condition"], [data-style="branch"]) { --group: var(--cr-kind-branch); }
.cr-group[data-style="skip"] { --group: var(--cr-kind-return); }
.cr-group[data-colored] { --group: var(--group-dark); }
body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --group: var(--group-light); }
.cr-group[data-style="tag"] {
@@ -495,7 +494,7 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --
.cr-table-row {
border-top: 1px solid var(--cr-table-border);
}
/* 讲某个子块的行(React 组件的事件处理表):第一格是按钮,悬停划下划线;展开时这一行淡淡垫上那一块的种类色 */
/* 讲某个子块的行(Rust 实现块的方法表):第一格是按钮,悬停划下划线;展开时这一行淡淡垫上那一块的种类色 */
.cr-table-row[data-open] {
background: color-mix(in srgb, var(--kind) 10%, transparent);
}
@@ -63,24 +63,4 @@ 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'])
})
})
+5 -46
View File
@@ -1,11 +1,9 @@
import type { BlockView, CellEdit, PropertyItem } from './types'
import type { BlockView } from './types'
/**
* 同一时刻能编辑的几段源码不重叠,同一行不会同时在两处能改:
* ① 块的编辑范围是它的完整源码,子孙都在它的编辑框里:块编辑时它的子孙都不画;
* ② 点「编辑」时,编辑范围和这块重叠的别的块(祖先、子孙,`} else {` 这样共用一行的兄弟块)退出编辑;
* ③ 属性表格子里单独打开的那一段(见 CellEdit)行号落在组件块里面,和块同一条规矩:重叠就不能同时编辑。
* 组件正在编辑时预览不画,里面这段的编辑器也不画。
* ② 点「编辑」时,编辑范围和这块重叠的别的块(祖先、子孙,`} else {` 这样共用一行的兄弟块)退出编辑。
*/
type Lines = Pick<BlockView, 'startLine' | 'endLine'>
@@ -43,51 +41,12 @@ function* allBlocks(blocks: readonly BlockView[]): Generator<BlockView> {
}
/** 点了 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<string, Lines>()
for (const block of allBlocks(blocks))
byId.set(block.id, block)
for (const ref of snippetRefs(blocks))
byId.set(ref.id, ref)
const byId = new Map([...allBlocks(blocks)].map(block => [block.id, block]))
const target = byId.get(id)
const others = editing.filter((other) => {
const span = byId.get(other)
return other !== id && !(target && span && overlaps(target, span))
const block = byId.get(other)
return other !== id && !(target && block && overlaps(target, block))
})
return [...others, id]
}
@@ -3,6 +3,7 @@ import type { CSSProperties, ReactElement } from 'react'
import type { BlockConcept, KindIconName } from './types'
import {
Activity,
Anchor,
Atom,
Box,
Braces,
@@ -12,9 +13,11 @@ import {
CornerDownLeft,
CornerDownRight,
Database,
Download,
Globe,
Import,
Layers,
LayoutTemplate,
ListOrdered,
Lock,
MessageSquareText,
@@ -27,6 +30,7 @@ import {
Shield,
ShieldAlert,
ShieldCheck,
Sigma,
Signpost,
SlidersHorizontal,
Split,
@@ -34,6 +38,7 @@ import {
SquareTerminal,
Tag,
Terminal,
ToggleRight,
TriangleAlert,
Variable,
Zap,
@@ -76,6 +81,11 @@ export const KIND_ICON_COMPONENTS: Record<KindIconName, LucideIcon> = {
'atom': Atom,
'zap': Zap,
'mouse-pointer-click': MousePointerClick,
'toggle-right': ToggleRight,
'anchor': Anchor,
'download': Download,
'sigma': Sigma,
'layout-template': LayoutTemplate,
}
/**
+4 -22
View File
@@ -31,10 +31,10 @@ export interface RuleReference { rule: string, key: string }
/**
* 框的样式,决定框头的图标和框的颜色:iteration 写在表达式里的遍历(同「循环」块),condition 按条件(同「条件」块),
* branch 条件的一支(同「条件」块的颜色、不带图标),skip 这个条件下不渲染(同「返回」块),tag 小标签(颜色是 SegmentGroup.color),
* branch 条件的一支(同「条件」块的颜色、不带图标),tag 小标签(颜色是 SegmentGroup.color),
* classes 收起来的一串类名(中性色,点开、悬停看 detail)
*/
export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'skip' | 'tag' | 'classes'
export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'tag' | 'classes'
/**
* 框起来的一组词:画成嵌在前后文字里的一个小框,框头是图标和 label(只有图标时 label 是空的);框里的词照样带源码下标,还能再套框。
@@ -66,30 +66,11 @@ 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
}
@@ -120,6 +101,7 @@ export type KindIconName
| 'split' | 'corner-down-right' | 'repeat' | 'shield' | 'shield-alert' | 'shield-check' | 'corner-down-left' | 'code'
| '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'
| 'toggle-right' | 'anchor' | 'download' | 'sigma' | 'layout-template'
/**
* 一块上认出的一个概念。宿主放进预览数据:稳定 id、悬停名、图标名。
@@ -167,7 +149,7 @@ interface BlockViewBase {
/**
* 子块画在父卡片的哪儿:属性表第 row 行下面(折叠这一行时跟着收起);item 是讲它的那个子行,
* 这时它默认收着(它的块 id 记成折叠着),点那个子行的第一格才展开、一次只展开一个(React 组件的事件处理表点名称)
* 这时它默认收着(它的块 id 记成折叠着),点那个子行的第一格才展开、一次只展开一个(Rust 实现块的方法表点方法名)
*/
export interface BlockSlot {
row: number