diff --git a/vscode/src/codeReader/rules/react.test.ts b/vscode/src/codeReader/rules/react.test.ts
index 891e7c0..50a83d8 100644
--- a/vscode/src/codeReader/rules/react.test.ts
+++ b/vscode/src/codeReader/rules/react.test.ts
@@ -1,9 +1,10 @@
import type { BlockView } from '../blockView'
-import type { PropertyRow, RuleContext } from '../contract'
+import type { PropertyItem, PropertyRow, RuleContext } from '../contract'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
+import { diffBlockTrees } from '../diff/blockDiff'
import { createTypeScriptAdapter } from '../languages/typescript'
-import { propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
+import { propertyLines, readFixture, segmentText, WASM_DIR, withBlocks } from '../testSupport'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { reactRule } from './react'
@@ -152,6 +153,16 @@ describe('react 库规则:属性', () => {
})
})
+/** 一层层子行里头一个满足 test 的。 */
+function findItem(items: PropertyItem[] | undefined, test: (item: PropertyItem) => boolean): PropertyItem | undefined {
+ for (const item of items ?? []) {
+ const found = test(item) ? item : findItem(item.items, test)
+ if (found)
+ return found
+ }
+ return undefined
+}
+
/** 块和它的子块写成文字:「种类 行号 @行/子行」(放进父卡片属性表里的写在哪),子块多缩进两格。 */
function outlineOf(view: BlockView, pad = ''): string[] {
const slot = view.slot ? ` @${view.slot.row}${view.slot.item !== undefined ? `/${view.slot.item}` : ''}` : ''
@@ -161,7 +172,7 @@ function outlineOf(view: BlockView, pad = ''): string[] {
describe('react 库规则:分区', () => {
it('luffy 的 LegalDocPage:状态(渲染中重置写进改它的)/ 引用 / 读取的数据 / 派生值 / 副作用 / 事件处理,讲进卡片的语句不再单独成块', async () => {
const view = await card(LEGAL, 'LegalDocPage')
- expect(propertyLines(properties(view)).slice(4)).toEqual([
+ expect(propertyLines(properties(view)).slice(4, 20)).toEqual([
'状态: 2 个',
' activeSection | headings[0]?.id ?? "" | setActiveSection(渲染时,当 doc 不全等 prevDoc 时;副作用 2)',
' prevDoc | doc | setPrevDoc(渲染时,当 doc 不全等 prevDoc 时)',
@@ -188,7 +199,6 @@ describe('react 库规则:分区', () => {
' react.handler 104 @8/0',
' react.handler 112 @8/1',
' react.handler 116 @8/2',
- ' return 124',
])
})
@@ -246,3 +256,123 @@ describe('react 库规则:分区', () => {
expect(rowLines(poster, '副作用')).toEqual(['副作用: 1 个'])
})
})
+
+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 处返回',
+ ' div ⟦样式 8 个类|⟧ data-tauri-drag-region="deep"',
+ ' div ⟦样式 3 个类|⟧',
+ ' ⟦|当 platform 全等 "macos" 时⟧ span ⟦样式 1 个类|⟧ data-testid="traffic-light-inset" aria-hidden',
+ ' div ⟦样式 6 个类|⟧',
+ ' span ⟦样式 4 个类|⟧ {center}',
+ ' ⟦|当 platform 全等 "windows" 时⟧ WindowControls',
+ ])
+ const row = properties(view).find(item => item.label === '渲染')
+ const [div] = row?.items ?? []
+ 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' })
+ const controls = div.items?.[2].cells[0].find(piece => 'role' in piece && piece.text === 'WindowControls')
+ expect(controls).toMatchObject({ role: 'type', color: { dark: '#61dafb', light: '#0a7ea4' } })
+ 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 处返回',
+ ' 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([
+ ' ⟦|如果 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 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',
+ 'doc 全等 "terms" 时:bg-surface text-foreground shadow-sm',
+ '否则:text-muted-foreground hover:text-foreground',
+ ].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()',
+ ])
+ expect(rowLines(await card(readFixture('appVaultSummary.tsx.txt'), 'AppVaultSummary'), '渲染').slice(0, 5)).toEqual([
+ '渲染: 1 处返回',
+ ' <> 片段,不产生元素',
+ ' SettingHeader compact align="center" title="密钥与变量" description="按应用保存,这个应用的所有会话共用" right=',
+ ' ⟦|当 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]}')
+ })
+
+ 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([
+ ' ⟦|如果 pending⟧',
+ ' ⟦|是⟧ "删除中…"',
+ ' ⟦|否⟧ "确认删除并清空数据"',
+ ])
+ })
+})
+
+describe('react 库规则:代码差异', () => {
+ 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) => {')
+ .replace(' scrollRef.current?.scrollTo({ top: 0 });\n }, [doc]);', ' scrollRef.current?.scrollTo({ top: 0 });\n }, [doc, markdown]);')
+ expect(after).not.toBe(LEGAL)
+ const before = await views(LEGAL)
+ const now = await views(after)
+ const diff = diffBlockTrees({ blocks: before, source: LEGAL }, { blocks: now, source: after })
+ const component = now.find(view => view.kind === 'react.component') as BlockView
+ const effect = component.children.find(child => child.kind === 'react.effect') as BlockView
+
+ const componentChange = diff.blocks[component.anchor]
+ expect(componentChange).toMatchObject({ change: 'mod', own: true })
+ const propsIndex = properties(component).findIndex(row => row.label === '属性')
+ expect(Object.keys(componentChange.marks?.rows?.changed ?? {})).toEqual([String(propsIndex)])
+ expect(componentChange.marks?.rows?.changed[propsIndex].items?.changed[1]).toEqual({ added: true })
+ expect(componentChange.marks?.rows?.removed).toEqual([])
+
+ const effectChange = diff.blocks[effect.anchor]
+ expect(effectChange).toMatchObject({ change: 'mod', own: true })
+ expect(effectChange.marks?.rows?.changed[0].value).toEqual({
+ 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' }] },
+ ],
+ })
+ // 另一个副作用、处理函数没变,不标
+ const unchanged = component.children.filter(child => child !== effect && child.kind !== 'comment')
+ expect(unchanged.map(child => diff.blocks[child.anchor])).toEqual(unchanged.map(() => undefined))
+ expect(diff.stats).toEqual({ add: 0, mod: 2, del: 0 })
+ })
+})
diff --git a/vscode/src/codeReader/rules/react.ts b/vscode/src/codeReader/rules/react.ts
index b3d5f14..1a03184 100644
--- a/vscode/src/codeReader/rules/react.ts
+++ b/vscode/src/codeReader/rules/react.ts
@@ -1,6 +1,6 @@
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, RuleSlot, SegmentGroup, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
-import type { BindingFacts, BodyFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, Place, PropsFacts, ReadFacts } from './reactTree'
-import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentsBefore, commentText, componentAt, componentOf, componentsIn, declaredTypes, enclosingComponent, isComponentTag, isFunction, named, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
+import type { BindingFacts, BodyFacts, ClassFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, OutlineNode, Place, PropsFacts, ReadFacts } from './reactTree'
+import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentsBefore, commentText, componentAt, componentOf, componentsIn, declaredTypes, enclosingComponent, isComponentTag, isFunction, named, openTag, outlineOf, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
import { hint, joined, name, plain, range } from './segments'
/**
@@ -10,7 +10,9 @@ import { hint, joined, name, plain, range } from './segments'
* 状态(useState / useReducer:名称 / 初始值 / 改它的,渲染时按条件重置状态的 if 也写进改它的)、引用(useRef)、
* 读取的数据(别的钩子:变量 / 来自 / 意思)、派生值(组件体里别的常量、useMemo / useCallback:名称 / 怎么算 / 什么时候算)、
* 副作用(每个 useEffect / useLayoutEffect 一张子卡片:时机 / 做什么 / 清理)、事件处理(组件里定义的函数:名称 / 绑定在 / 做什么,
- * 点名称展开它的子卡片)。讲进分区的语句并进卡片;副作用、处理函数(和主体是语句块的 useMemo / useCallback)照常是块,放进卡片那一行下面。
+ * 点名称展开它的子卡片)、渲染(返回的 JSX 画成缩进的结构大纲:原生标签灰色、组件用组件色能跳到定义,className 收成「样式 N 个类」,
+ * `cond &&` 写「当 … 时」,三元分「是 / 否」两支,`.map` 写「对 … 的每一项」并标 key,提前 return null 写「当 … 时 不渲染」)。
+ * 讲进分区的语句并进卡片;副作用、处理函数(和主体是语句块的 useMemo / useCallback)照常是块,放进卡片那一行下面。
* 文件开头的 "use client" 讲成了「客户端组件」、组件上面的 JSDoc 讲成了「说明」,都不再单独成块。
* 新的 JSX 写法不用导入 react,规则不看导入:TSX / JSX 文件都启用。用词都在词表里,按翻译插件的显示语言挑。
*/
@@ -34,7 +36,7 @@ const WRAPPER_COLOR: ThemeColor = { dark: '#569cd6', light: '#0000ff' }
interface Words {
kinds: Record<'component' | 'effect' | 'handler', string>
- labels: Record<'name' | 'doc' | 'props' | 'state' | 'refs' | 'reads' | 'derived' | 'effects' | 'handlers' | 'timing' | 'does' | 'cleanup' | 'parameters', string>
+ labels: Record<'name' | 'doc' | 'props' | 'state' | 'refs' | 'reads' | 'derived' | 'effects' | 'handlers' | 'render' | 'timing' | 'does' | 'cleanup' | 'parameters', string>
/** 各分区表格的列 */
heads: Record<'state' | 'refs' | 'reads' | 'derived' | 'handlers', string[]>
/** 副作用的时机、派生值什么时候算:a、b 变了时,只在挂载时,每次渲染后,每次渲染 */
@@ -74,6 +76,26 @@ interface Words {
context: [string, string]
customHook: string
libraryHook: (module: string) => string
+ /** 渲染:「N 处返回」,大纲一行前面的「当 … 时」「如果 …」「是 / 否 / 否则」「对 … 的每一项」、不渲染 */
+ returns: (n: number) => string
+ when: [string, string]
+ if: string
+ branches: Record<'yes' | 'no' | 'otherwise', string>
+ noRender: string
+ eachItem: [string, string]
+ /** 列表的 key、回调里先算的常量:「key h.id」「其中 isActive = …」 */
+ key: string
+ local: string
+ /** <>> 片段 */
+ fragment: string
+ /** 跨几行写的对象属性值只写有哪些键:「对象:h2、a」 */
+ object: string
+ /** 样式小标:「样式 N 个类 · 随 doc 变」;点开的全文里按条件切换的一行「条件 时:类」、「否则:类」,算出来的「另加 表达式」 */
+ classes: (n: number) => string
+ classesBy: (subjects: string) => string
+ classesWhen: (condition: string) => string
+ classesElse: string
+ classesExtra: string
tags: Record<'named' | 'default' | 'client' | 'memo' | 'forwardRef', string>
/** 属性名后面的标签:函数类型的属性、children */
roles: Record<'callback' | 'children', string>
@@ -107,6 +129,7 @@ const ZH: Words = {
derived: '派生值',
effects: '副作用',
handlers: '事件处理',
+ render: '渲染',
timing: '时机',
does: '做什么',
cleanup: '清理',
@@ -163,6 +186,21 @@ const ZH: Words = {
context: ['从 ', ' 上下文读'],
customHook: '自定义钩子',
libraryHook: module => `${module} 的钩子`,
+ returns: n => `${n} 处返回`,
+ when: ['当', '时'],
+ if: '如果',
+ branches: { yes: '是', no: '否', otherwise: '否则' },
+ noRender: '不渲染',
+ eachItem: ['对', '的每一项'],
+ key: 'key ',
+ local: '其中 ',
+ fragment: '片段,不产生元素',
+ object: '对象:',
+ classes: n => `样式 ${n} 个类`,
+ classesBy: subjects => ` · 随 ${subjects} 变`,
+ classesWhen: condition => `${condition} 时:`,
+ classesElse: '否则:',
+ classesExtra: '另加 ',
tags: { named: '具名导出', default: '默认导出', client: '客户端组件', memo: '记忆化', forwardRef: '转发 ref' },
roles: { callback: '回调', children: '子元素' },
propHeads: ['名称', '类型', '默认值', '必填'],
@@ -699,6 +737,191 @@ function nestedCard(block: CodeBlock, env: Env): RuleCard | undefined {
return callback && fn ? handlerCard(block, callback.name, fn, commentsBefore(callback.statement), env) : undefined
}
+// ─── 渲染 ─────────────────────────────────────────────────────────────────────
+
+/** 着色的词拼成的字(框只算框里的词),写进样式小标点开的全文。 */
+function flatText(pieces: readonly Inline[]): string {
+ return pieces.map(piece => 'role' in piece ? piece.text : flatText(piece.segments)).join('')
+}
+
+/** 大纲一行前面的小框(见 GroupStyle 的 condition / branch / skip / iteration)。 */
+function pill(style: SegmentGroup['style'], segments: Inline[]): SegmentGroup {
+ return { style, label: '', segments }
+}
+
+/** 「当 cond 时」。 */
+function whenText(condition: Node, { context, words }: Env): Inline[] {
+ return [keyword(words.when[0]), plain(' '), ...context.translate(condition), plain(' '), keyword(words.when[1])]
+}
+
+/** className 按条件切换时是随什么变的:比较的左边(`doc === "terms"` 随 doc)、取反的那个(`!x` 随 x),别的写法就是它自己。 */
+function classSubject(condition: Node): string {
+ const value = unwrap(condition)
+ if (value?.type === 'binary_expression')
+ return value.childForFieldName('left')?.text ?? value.text
+ if (value?.type === 'unary_expression')
+ return value.childForFieldName('argument')?.text ?? value.text
+ return value?.text ?? condition.text
+}
+
+/** 长 className 收成的「样式 N 个类」小标,点开、悬停看全部类名(按条件切换的一行写一条);一个类都没写时没有。 */
+function classesChip(classes: ClassFacts, { context, words }: Env): SegmentGroup | undefined {
+ const { base, conditions, dynamic } = classes
+ if (base.length === 0 && conditions.length === 0 && dynamic.length === 0)
+ return undefined
+ const subjects = [...new Set([...conditions.map(item => classSubject(item.condition)), ...dynamic.map(node => node.text)])]
+ const detail = [
+ ...(base.length > 0 ? [base.join(' ')] : []),
+ ...conditions.flatMap(item => [
+ `${words.classesWhen(flatText(context.translate(item.condition)))}${item.yes.join(' ')}`,
+ ...(item.no.length > 0 ? [`${words.classesElse}${item.no.join(' ')}`] : []),
+ ]),
+ ...dynamic.map(node => `${words.classesExtra}${flatText(context.translate(node))}`),
+ ]
+ return {
+ style: 'classes',
+ label: `${words.classes(base.length)}${subjects.length > 0 ? words.classesBy(subjects.join(words.listSeparator)) : ''}`,
+ segments: [],
+ detail: detail.join('\n'),
+ }
+}
+
+/** 标签名:组件用组件色、能跳到定义(`a.b` 一节节都能跳),原生标签灰色;片段写 `<>`。 */
+function tagName(nameNode: Node | undefined, component: boolean): Inline[] {
+ if (!nameNode)
+ return [{ text: '<>', role: 'plain', color: NATIVE_TAG_COLOR }]
+ if (!component)
+ return [{ text: nameNode.text, role: 'plain', color: NATIVE_TAG_COLOR }]
+ const parts = (node: Node): Inline[] => {
+ if (node.type !== 'member_expression')
+ return [{ ...name(node, 'type'), color: COMPONENT_COLOR }]
+ const object = node.childForFieldName('object')
+ const property = node.childForFieldName('property')
+ return [...(object ? parts(object) : []), { text: '.', role: 'plain', color: COMPONENT_COLOR }, ...(property ? [{ ...name(property, 'type'), color: COMPONENT_COLOR }] : [])]
+ }
+ return parts(nameNode)
+}
+
+/** 一个属性写成「名字=值」:字符串、表达式照常翻,跨几行写的对象只写有哪些键,JSX 元素写成 ``;只写了名字的就是名字,`{...x}` 照写。 */
+function attributeText(attribute: Node, env: Env): Inline[] {
+ const { context, words } = env
+ if (attribute.type === 'jsx_expression')
+ return [plain('{'), ...context.translate(named(attribute).find(child => child.type !== 'comment') ?? attribute), plain('}')]
+ const nameNode = named(attribute)[0]
+ const value = attributeValue(attribute)
+ const head: Inline = { text: nameNode?.text ?? '', role: 'variable', ...(nameNode && { offset: nameNode.startIndex }) }
+ if (!value)
+ return [head]
+ const inner = value.type === 'jsx_expression' ? unwrap(named(value).find(child => child.type !== 'comment')) : value
+ let shown: Inline[]
+ if (!inner)
+ shown = context.translate(value)
+ else if (inner.type === 'jsx_element' || inner.type === 'jsx_self_closing_element')
+ shown = elementRef(openTag(inner))
+ else if (inner.type === 'object' && inner.startPosition.row !== inner.endPosition.row)
+ shown = [hint(words.object), ...joined(named(inner).flatMap(pair => pair.childForFieldName('key') ? [[name(pair.childForFieldName('key') as Node, 'variable')]] : []), hint(words.listSeparator))]
+ else
+ shown = context.translate(inner)
+ return [head, plain('='), ...shown]
+}
+
+/** 写在同一行的文字、插值:只有一段文字时带引号(「"法律与隐私"」),插值写成 `{…}`,和文字混着时文字不带引号。 */
+function inlineText(nodes: readonly Node[], { context }: Env): Inline[] {
+ // 文字里的换行、缩进并成一个空格,整行两头的空白去掉;和插值挨着的空格留着(「{a} · {b}」)
+ const texts = nodes.map((node, i) => {
+ let text = node.text.replace(/\s+/g, ' ')
+ if (i === 0)
+ text = text.trimStart()
+ if (i === nodes.length - 1)
+ text = text.trimEnd()
+ return text
+ })
+ if (nodes.length === 1 && nodes[0].type === 'jsx_text')
+ return [{ text: `"${texts[0]}"`, role: 'string' }]
+ return nodes.flatMap((node, i): Inline[] => {
+ if (node.type === 'jsx_expression') {
+ const inner = named(node).find(child => child.type !== 'comment')
+ return inner ? [hint('{'), ...context.translate(inner), hint('}')] : []
+ }
+ if (node.type === 'jsx_text')
+ return texts[i] ? [{ text: texts[i], role: 'string' }] : []
+ return context.translate(node)
+ })
+}
+
+/** 一行里的几段,用空格隔开。 */
+function spaced(parts: Inline[][]): Inline[] {
+ return joined(parts.filter(part => part.length > 0), plain(' '))
+}
+
+/** 大纲的几个节点写成子行;prefix 是写在第一行最前面的小框(三元的「是 / 否」)。 */
+function outlineItems(nodes: readonly OutlineNode[], env: Env, prefix: Inline[] = []): PropertyItem[] {
+ return nodes.flatMap((node, index) => outlineNode(node, env, index === 0 ? prefix : []))
+}
+
+/** 大纲的一个节点:元素一行(子元素在下一层)、文字、JSX 注释、三元(下面是 / 否两支)、列表(下面是每一项渲染的)、空。 */
+function outlineNode(node: OutlineNode, env: Env, prefix: Inline[]): PropertyItem[] {
+ const { context, words } = env
+ const when = 'when' in node && node.when ? [pill('condition', whenText(node.when, env))] : []
+ const line = (parts: Inline[][], children: PropertyItem[] = []): PropertyItem[] =>
+ [{ cells: [spaced([prefix, when, ...parts])], ...(children.length > 0 && { items: children }) }]
+ switch (node.kind) {
+ case 'element': {
+ const chip = node.classes && classesChip(node.classes, env)
+ return line([
+ tagName(node.name, node.component),
+ node.name ? [] : [hint(words.fragment)],
+ chip ? [chip] : [],
+ joined(node.attributes.map(attribute => attributeText(attribute, env)), plain(' ')),
+ node.text.length > 0 ? inlineText(node.text, env) : [],
+ ], outlineItems(node.children, env))
+ }
+ case 'text':
+ return line([inlineText(node.nodes, env)])
+ case 'comment':
+ return [{ cells: [spaced([prefix, [{ text: `// ${commentText([node.node])}`, role: 'comment' }]])] }]
+ case 'ternary':
+ return line([[pill('condition', [keyword(words.if), plain(' '), ...context.translate(node.condition)])]], [
+ ...outlineItems(node.yes, env, [pill('branch', [keyword(words.branches.yes)])]),
+ ...outlineItems(node.no, env, [pill('branch', [keyword(words.branches.no)])]),
+ ])
+ case 'map':
+ return line([
+ [pill('iteration', [keyword(words.eachItem[0]), plain(' '), ...context.translate(node.source), plain(' '), keyword(words.eachItem[1]), ...(node.item ? [plain(' '), ...context.translate(node.item)] : [])])],
+ node.key ? [hint(words.key), ...context.translate(node.key)] : [],
+ ], [
+ ...node.locals.map((local): PropertyItem => {
+ const localName = local.childForFieldName('name')
+ const value = local.childForFieldName('value')
+ return { cells: [[hint(words.local), ...(localName ? binding(localName, 'variable', env) : []), plain(' = '), ...(value ? context.translate(value) : [])]] }
+ }),
+ ...outlineItems(node.children, env),
+ ])
+ default:
+ return line([[hint(words.noRender)]])
+ }
+}
+
+/**
+ * 渲染:返回的 JSX 画成缩进的结构大纲,每行一个元素。只有一处返回时就是它;几处返回时分几段,各带条件:
+ * 提前返回 null 的写「当 … 时 不渲染」,提前返回 JSX 的「当 … 时」下面是它渲染的,最后一处是「否则」。
+ */
+function renderRow(analysis: Analysis, env: Env): PropertyRow {
+ const { words } = env
+ const { returns } = analysis.body
+ const items = returns.length === 1 && !returns[0].when
+ ? outlineItems(outlineOf(returns[0].value), env)
+ : returns.flatMap((part): PropertyItem[] => {
+ if (!part.value)
+ return [{ cells: [spaced([[pill('skip', part.when ? whenText(part.when, env) : [keyword(words.branches.otherwise)])], [hint(words.noRender)]])] }]
+ return [{
+ cells: [[pill('branch', part.when ? whenText(part.when, env) : [keyword(words.branches.otherwise)])]],
+ items: outlineItems(outlineOf(part.value), env),
+ }]
+ })
+ return { label: words.labels.render, value: [hint(words.returns(returns.length))], ...(items.length > 0 && { items }) }
+}
+
// ─── 认领 ─────────────────────────────────────────────────────────────────────
/** 组件卡片。 */
@@ -757,6 +980,10 @@ function componentCard(block: CodeBlock, component: ComponentFacts, env: Env): R
absorb(handler.comments[0])
})
}
+ if (body.returns.length > 0) {
+ rows.push(renderRow(analysis, env))
+ absorb(...body.returns.map(part => part.statement))
+ }
return {
kind: KIND,
badge: badge(words),
diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx
index b4e4fd9..696cf42 100644
--- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx
+++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx
@@ -73,21 +73,23 @@ function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Inline[],
}
const folded = folding.collapsed.has(id)
const Chevron = folded ? ChevronRight : ChevronDown
+ const toggle = (): void => folding.onToggleFold(id)
+ // 只有箭头是按钮:这一列里还可能有别的按钮(渲染大纲的「样式 N 个类」),按钮里不能套按钮;点这一列的字照样切换
return (
-