✨ feat(vscode): React 文件照源码一块一张卡片,组件卡片只留名称、说明、构造参数
- 组件体的语句不再并进组件卡片,照源码顺序是它的子块;状态、引用、数据源、派生值、副作用、事件处理函数、渲染输出各成一张卡片,回调和函数里的语句是这些卡片下面的子卡片 - 普通变量、提前返回的 if、注释照语言默认的卡片;表格格子里只写名字和位置,去掉「改它的」「来自」格子里单独打开源码的那条路 - 用词改成书面叫法(更新位置、使用位置、计算表达式、依赖项、触发时机、清理函数、绑定事件、样式取决于……),新增 toggle-right、anchor、download、sigma、layout-template 五个种类图标 - 夹具加上 luffy 的 QueuedBubble.tsx、PlanChecklist.tsx,规格的 React 一节照新样子改写
This commit is contained in:
1 parent
194544b4ba
commit
6ce4ef7ee2
20 files changed
+1327
-989
No files matched your search
@@ -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 } => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -7,7 +7,7 @@ const BADGE: KindBadge = { label: '实现', color: COLOR, icon: 'square-function
|
||||
|
||||
/**
|
||||
* impl 的方法表:点方法名展开那张函数卡。
|
||||
* 子块放进属性表某一行,和 React 事件处理同一条路。不看导入,Rust 文件都启用。
|
||||
* 子块放进属性表某一行(RuleSlot)。不看导入,Rust 文件都启用。
|
||||
*/
|
||||
export const rustImplRule: LibraryRule = {
|
||||
id: 'rust',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user