✨ 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

-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