From fd8b03a5637974cfb4dbc464bb931cbc20317e80 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 30 Sep 2026 17:24:13 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=20React=20=E7=BB=84=E4=BB=B6=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E5=8A=A0=E6=B8=B2=E6=9F=93=E5=A4=A7=E7=BA=B2=EF=BC=9A?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E6=A0=87=E7=AD=BE=E7=81=B0=E8=89=B2=E3=80=81?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=94=A8=E7=BB=84=E4=BB=B6=E8=89=B2=E8=83=BD?= =?UTF-8?q?=E8=B7=B3=E5=88=B0=E5=AE=9A=E4=B9=89=EF=BC=8CclassName=20?= =?UTF-8?q?=E6=94=B6=E6=88=90=E3=80=8C=E6=A0=B7=E5=BC=8F=20N=20=E4=B8=AA?= =?UTF-8?q?=E7=B1=BB=E3=80=8D=E7=82=B9=E5=BC=80=E7=9C=8B=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E7=B1=BB=E5=90=8D=EF=BC=8Ccond=20&&=20=E5=86=99=E3=80=8C?= =?UTF-8?q?=E5=BD=93=20=E2=80=A6=20=E6=97=B6=E3=80=8D=E3=80=81=E4=B8=89?= =?UTF-8?q?=E5=85=83=E5=88=86=E6=98=AF=20/=20=E5=90=A6=E4=B8=A4=E6=94=AF?= =?UTF-8?q?=E3=80=81.map=20=E5=86=99=E3=80=8C=E5=AF=B9=20=E2=80=A6=20?= =?UTF-8?q?=E7=9A=84=E6=AF=8F=E4=B8=80=E9=A1=B9=E3=80=8D=E6=A0=87=20key?= =?UTF-8?q?=E3=80=81=E6=8F=90=E5=89=8D=20return=20null=20=E5=86=99?= =?UTF-8?q?=E4=B8=8D=E6=B8=B2=E6=9F=93=E3=80=81=E7=89=87=E6=AE=B5=E5=92=8C?= =?UTF-8?q?=20JSX=20=E6=B3=A8=E9=87=8A=E7=85=A7=E5=86=99=EF=BC=9B=E5=B7=AE?= =?UTF-8?q?=E5=BC=82=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=E5=8A=A0=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E3=80=81=E6=94=B9=E5=89=AF=E4=BD=9C=E7=94=A8=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/rules/react.test.ts | 138 +++++++++- vscode/src/codeReader/rules/react.ts | 235 +++++++++++++++++- .../src/codeReader/PropertyGrid.tsx | 26 +- .../webview-ui/src/codeReader/SegmentText.tsx | 3 +- 4 files changed, 381 insertions(+), 21 deletions(-) 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 ( - +

- - +

+ ) } diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index 1ff770b..57878e9 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -85,7 +85,8 @@ function GroupBox({ group }: { group: SegmentGroup }): ReactElement { function ClassesChip({ group }: { group: SegmentGroup }): ReactElement { const [open, setOpen] = useState(false) return ( - + // 点小标、点弹层里的字都不往外传:大纲里点这一行的字会折叠它的子行,点小标只开关类名 + event.stopPropagation()}>