From ee807ec7a47fae31af3a522437bd3d48c3a77312 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 30 Sep 2026 17:19:54 +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=E6=8C=89=E9=92=A9=E5=AD=90=E5=88=86=E5=8C=BA=EF=BC=9A?= =?UTF-8?q?=E7=8A=B6=E6=80=81=20/=20=E5=BC=95=E7=94=A8=20/=20=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E7=9A=84=E6=95=B0=E6=8D=AE=20/=20=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E5=80=BC=20/=20=E5=89=AF=E4=BD=9C=E7=94=A8=20/=20=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=A4=84=E7=90=86=EF=BC=9B=E5=89=AF=E4=BD=9C=E7=94=A8?= =?UTF-8?q?=E3=80=81=E5=A4=84=E7=90=86=E5=87=BD=E6=95=B0=E6=88=90=E5=AD=90?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E6=94=BE=E8=BF=9B=E5=8D=A1=E7=89=87=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E8=A1=8C=E4=B8=8B=E9=9D=A2=EF=BC=88=E5=BA=93=E8=A7=84?= =?UTF-8?q?=E5=88=99=E6=96=B0=E5=A2=9E=E6=8A=8A=E5=AD=90=E5=9D=97=E6=94=BE?= =?UTF-8?q?=E8=BF=9B=E5=B1=9E=E6=80=A7=E8=A1=A8=E6=9F=90=E4=B8=80=E8=A1=8C?= =?UTF-8?q?=E7=9A=84=E6=9C=BA=E5=88=B6=EF=BC=8C=E4=BA=8B=E4=BB=B6=E5=A4=84?= =?UTF-8?q?=E7=90=86=E8=A1=A8=E7=82=B9=E5=90=8D=E7=A7=B0=E5=B1=95=E5=BC=80?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/blockView.ts | 18 +- vscode/src/codeReader/contract.ts | 13 + vscode/src/codeReader/libraryRules.test.ts | 29 +- vscode/src/codeReader/libraryRules.ts | 11 + vscode/src/codeReader/rules/react.test.ts | 95 +++ vscode/src/codeReader/rules/react.ts | 556 +++++++++++++++++- vscode/src/codeReader/rules/reactTree.ts | 35 ++ vscode/webview-ui/src/codeReader/App.tsx | 1 + .../src/codeReader/BlockTree.test.tsx | 85 +++ .../webview-ui/src/codeReader/BlockTree.tsx | 60 +- .../src/codeReader/PropertyGrid.tsx | 65 +- .../webview-ui/src/codeReader/codeReader.css | 22 + .../src/codeReader/diffView.test.ts | 29 + vscode/webview-ui/src/codeReader/diffView.ts | 33 +- .../webview-ui/src/codeReader/reveal.test.ts | 10 + vscode/webview-ui/src/codeReader/reveal.ts | 7 +- vscode/webview-ui/src/codeReader/types.ts | 11 + .../src/codeReader/viewState.test.ts | 14 +- vscode/webview-ui/src/codeReader/viewState.ts | 19 +- 19 files changed, 1057 insertions(+), 56 deletions(-) create mode 100644 vscode/webview-ui/src/codeReader/BlockTree.test.tsx diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 87d2832..046025f 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -31,9 +31,17 @@ interface BlockViewBase { attributes: BlockAttributes /** 库规则的卡片才有:种类的名字、颜色和图标(RuleCard.badge)。预览同类块一样默认收起它的子块。 */ badge?: KindBadge + /** 库规则放进父卡片里的子块才有(见 RuleSlot):画在父卡片属性表第 row 行下面,不排在父卡片的子块里。 */ + slot?: BlockSlot children: BlockView[] } +/** 子块画在父卡片的哪儿:属性表第 row 行下面;item 是讲它的那个子行,点那个子行的第一格才展开(见 RuleSlot.item)。 */ +export interface BlockSlot { + row: number + item?: number +} + /** * 发给 webview 的一块(可序列化):函数、类、枚举、异常、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文。 * 一行译文下面依次是:跨几行写的调用摊开各个实参的表(TextTranslation.table),块的说明(TextBlock.doc,翻译插件给的 Markdown 原文)。 @@ -325,6 +333,7 @@ export function toBlockViews( return plain({ ...replacement.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, span, id, scope) } const { kind, badge, rows, defines, attributes } = replacement + const slots = new Map((replacement.slots ?? []).map(({ block: child, row, item }): [CodeBlock, BlockSlot] => [child, { row, ...(item !== undefined && { item }) }])) const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), rowsText(span)) return { id, @@ -335,18 +344,19 @@ export function toBlockViews( ...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }), attributes: { ...blockAttributes(block), ...attributes, kind }, badge, - children: list(kept, `${id}.`, inner), + children: list(kept, `${id}.`, inner, 0, slots), properties: rows, } } - /** 一串兄弟块转成视图数据;略去的块不占位置,id 按出了块的往下数。 */ - function list(siblings: readonly CodeBlock[], prefix: string, scope: string, from = 0): BlockView[] { + /** 一串兄弟块转成视图数据;略去的块不占位置,id 按出了块的往下数。slots 是父卡片放进它属性表里的那几块(见 BlockSlot)。 */ + function list(siblings: readonly CodeBlock[], prefix: string, scope: string, from = 0, slots?: ReadonlyMap): BlockView[] { const spans = blockSpans(siblings, source) const views: BlockView[] = [] siblings.forEach((block, i) => { const view = build(block, spans[i], `${prefix}${from + views.length}`, scope) + const slot = slots?.get(block) if (view) - views.push(view) + views.push(slot ? { ...view, slot } : view) }) return views } diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 1a92df2..2e4bd09 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -813,6 +813,19 @@ export interface RuleCard { * 这一块的编辑范围同别的块,是原块整条语句(类就是整个类),盖住留下的子块:它编辑时预览不画这些子块,同一行不会同时在两处能改。 */ absorbs: CodeBlock[] + /** 画在卡片某一行下面的子块(见 RuleSlot);一块只能放一处。 */ + slots?: RuleSlot[] +} + +/** + * 卡片里的一处子块:block(原块 children 里的对象,没并进来的)不排在卡片后面,画在卡片第 row 行下面当子卡片, + * 照常转成块(翻译、库规则、编辑、问 AI、差异都照常)。item 是这一行的子行里讲它的那一个(第几个,0 起): + * 这时它默认收着,点那个子行的第一格才展开、一次只展开一个(React 组件的事件处理表点名称展开处理函数)。 + */ +export interface RuleSlot { + block: CodeBlock + row: number + item?: number } /** diff --git a/vscode/src/codeReader/libraryRules.test.ts b/vscode/src/codeReader/libraryRules.test.ts index 87d498b..72d01b7 100644 --- a/vscode/src/codeReader/libraryRules.test.ts +++ b/vscode/src/codeReader/libraryRules.test.ts @@ -1,5 +1,5 @@ import type { BlockView, RuleRun } from './blockView' -import type { CodeBlock, LibraryRule, RuleBlock, RuleLocation, RuleWorkspace, Segment } from './contract' +import type { CodeBlock, LibraryRule, RuleBlock, RuleLocation, RuleSlot, RuleWorkspace, Segment } from './contract' import { describe, expect, it } from 'vitest' import { fileAttributes } from './attributes' import { toBlockViews } from './blockView' @@ -193,6 +193,33 @@ describe('库规则:认领', () => { expect(reports).toEqual(['库规则 omit 给第 3 行的 class 块的替身不合约定,这块照常显示:要略去的块还有子块']) }) + it('放进卡片属性表里的子块:照常转成块,记下画在哪一行(哪个子行);不是留下的子块、没有那一行 / 子行、放了两处的不合约定', async () => { + const rows = [{ label: '字段', value: [], items: [{ cells: [[{ text: 'name', role: 'variable' as const }]] }], columns: ['名称'] }, { label: '方法', value: [] }] + const slotting = (slots: (block: CodeBlock) => RuleSlot[], id = 'slot'): LibraryRule => ({ + ...cardRule(id), + claim: block => block.kind === 'class' + ? { kind: `${id}.card`, badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows, absorbs: block.children.slice(0, 1), slots: slots(block) } + : undefined, + }) + const [, card] = await views(MODEL, [slotting(block => [{ block: block.children[2], row: 0, item: 0 }, { block: block.children[3], row: 1 }])]) + expect(card.children.map(child => [child.kind, child.slot])).toEqual([['comment', undefined], ['variable', { row: 0, item: 0 }], ['function', { row: 1 }]]) + + const reports: string[] = [] + const bad = await views(MODEL, [ + slotting(block => [{ block: block.children[0], row: 1 }], 'absorbed'), + slotting(block => [{ block: block.children[3], row: 5 }], 'row'), + slotting(block => [{ block: block.children[3], row: 1, item: 0 }], 'item'), + slotting(block => [{ block: block.children[3], row: 1 }, { block: block.children[3], row: 0 }], 'twice'), + ], reports) + expect(bad[1].kind).toBe('class') + expect(reports.map(report => report.replace(/^.*照常显示:/, ''))).toEqual([ + '放进卡片里的块不是留下的子块', + '放进卡片第 5 行下面的块,卡片没有这一行', + '放进卡片第 1 行第 0 个子行的块,这一行没有这个子行', + '同一块放进了卡片里的几处', + ]) + }) + it('认成契约里现成的属性表块:种类、属性表、筛选属性照语言插件报的这种块,编辑范围是整个类;并进来的不是子块时照常显示', async () => { const asEnum = (absorbs: (block: CodeBlock) => CodeBlock[]): LibraryRule => ({ ...cardRule('lib'), diff --git a/vscode/src/codeReader/libraryRules.ts b/vscode/src/codeReader/libraryRules.ts index 97571fc..5ce1716 100644 --- a/vscode/src/codeReader/libraryRules.ts +++ b/vscode/src/codeReader/libraryRules.ts @@ -48,6 +48,17 @@ function problemOf(rule: LibraryRule, block: CodeBlock, claimed: RuleBlock): str return undefined if (!claimed.kind.startsWith(`${rule.id}.`)) return `种类 id「${claimed.kind}」没以「${rule.id}.」开头` + const slots = claimed.slots ?? [] + if (slots.some(slot => !block.children.includes(slot.block) || claimed.absorbs.includes(slot.block))) + return '放进卡片里的块不是留下的子块' + if (new Set(slots.map(slot => slot.block)).size < slots.length) + return '同一块放进了卡片里的几处' + for (const { row, item } of slots) { + if (!claimed.rows[row]) + return `放进卡片第 ${row} 行下面的块,卡片没有这一行` + if (item !== undefined && !claimed.rows[row].items?.[item]) + return `放进卡片第 ${row} 行第 ${item} 个子行的块,这一行没有这个子行` + } for (const { reference, offset } of referencedSegments(claimed.rows)) { if (reference?.rule !== rule.id) return `引用「${reference?.key}」标的规则是「${reference?.rule}」,不是自己` diff --git a/vscode/src/codeReader/rules/react.test.ts b/vscode/src/codeReader/rules/react.test.ts index c7f380b..891e7c0 100644 --- a/vscode/src/codeReader/rules/react.test.ts +++ b/vscode/src/codeReader/rules/react.test.ts @@ -151,3 +151,98 @@ describe('react 库规则:属性', () => { expect(rowLines(await card(source, 'Empty'), '属性')).toEqual(['属性: 无']) }) }) + +/** 块和它的子块写成文字:「种类 行号 @行/子行」(放进父卡片属性表里的写在哪),子块多缩进两格。 */ +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} `))] +} + +describe('react 库规则:分区', () => { + it('luffy 的 LegalDocPage:状态(渲染中重置写进改它的)/ 引用 / 读取的数据 / 派生值 / 副作用 / 事件处理,讲进卡片的语句不再单独成块', async () => { + const view = await card(LEGAL, 'LegalDocPage') + expect(propertyLines(properties(view)).slice(4)).toEqual([ + '状态: 2 个', + ' activeSection | headings[0]?.id ?? "" | setActiveSection(渲染时,当 doc 不全等 prevDoc 时;副作用 2)', + ' prevDoc | doc | setPrevDoc(渲染时,当 doc 不全等 prevDoc 时)', + '引用: 1 个', + ' scrollRef | 空 | 挂在
的 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 | 每个 ')) + expect(html.indexOf('滚回顶部')).toBeLessThan(html.indexOf('事件处理')) + }) + + it('展开一个处理函数:画在表下面,那一行标成展开、垫上它的种类色,卡片左上角的箭头收起它', () => { + const html = render({ 0.1: false, 0: false }) + expect(html).toContain('处理 A') + expect(html).not.toContain('处理 B') + expect(html).toContain('data-open="" data-badge="" style="--kind-dark:#61dafb;--kind-light:#0a7ea4"') + expect(html).toContain('aria-label="收起"') + expect(html).toContain('留下的注释') + }) + + it('折叠放着子块的那一行:它下面的子块跟着收起', () => { + expect(render({ '0/p1': true })).not.toContain('滚回顶部') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 5d2cac0..be90162 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -1,5 +1,6 @@ import type { CSSProperties, ReactElement, ReactNode } from 'react' import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView' +import type { GridSlots } from './PropertyGrid' import type { QaState } from './qa' import type { PendingImage } from './qaImages' import type { BlockMarks, BlockView } from './types' @@ -34,6 +35,8 @@ export interface BlockTreeState { onFocused: () => void onToggleEdit: (id: string) => void onToggleFold: (id: string) => void + /** 一次记下几块、几行折叠还是展开(点子行展开一块、同一行里别的收起) */ + onSetFolds: (folds: Record) => void /** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */ qa?: QaTree /** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */ @@ -56,9 +59,12 @@ export interface QaTree { onSelectProfile: (id: string) => void } -/** 块下面有没有画出来的子块(删掉的子块也算):子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 */ +/** + * 块下面有没有画出来的子块(删掉的子块也算):子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 + * 放进卡片属性表里的子块(BlockSlot)画在卡片里,不算。 + */ function hasShownChildren(block: BlockView, tree: BlockTreeState): boolean { - return block.children.some(child => shown(child, tree)) || (!tree.covered.has(block.id) && tree.diff?.removed.has(block.id) === true) + return block.children.some(child => !child.slot && shown(child, tree)) || (!tree.covered.has(block.id) && tree.diff?.removed.has(block.id) === true) } const CHANGE_NAMES = { add: '新增', mod: '修改', del: '删除' } as const @@ -110,13 +116,20 @@ function kindProps(block: BlockView | undefined): { 'data-kind'?: string, 'data- * 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。 * owner 是这一层所属的块(顶层没有),悬停它时竖线换成它的种类色。 * 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard);「只看改动」时连着没变的块折成一条(UnchangedStrip)。 + * slotted:这一层是画在卡片属性表某一行下面的子块(BlockSlot),blocks 只有那几块。 */ -export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner?: BlockView, tree: BlockTreeState }): ReactElement { - const removed = tree.diff?.removed.get(owner?.id ?? '') ?? [] - // 删掉的块排在它原来前面那块后面;那块被筛掉了也照排,不跟着消失 +export function BlockLevel({ blocks, owner, tree, slotted = false }: { blocks: BlockView[], owner?: BlockView, tree: BlockTreeState, slotted?: boolean }): ReactElement { + // 放进卡片属性表里的子块画在卡片里(slotted 的那一层),这一层不画;删掉的块都放回卡片后面这一层 + const listed = slotted ? blocks : blocks.filter(block => !block.slot) + const removed = slotted ? [] : tree.diff?.removed.get(owner?.id ?? '') ?? [] + const listedIds = new Set(listed.map(block => block.id)) + // 删掉的块排在它原来前面那块后面;那块被筛掉了也照排,不跟着消失;那块画在卡片里时排到这一层最后 const removedAfter = (id: string | undefined): ReactElement[] => removed .filter(item => item.after === id) .map(item => ) + const removedElsewhere = removed + .filter(item => item.after !== undefined && !listedIds.has(item.after)) + .map(item => ) return (
@@ -124,7 +137,7 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner
{removedAfter(undefined)} - {blocks.map(block => ( + {listed.map(block => ( {tree.folded?.runs.has(block.id) && tree.onOpenRun(block.id)} />} {shown(block, tree) && !tree.folded?.folded.has(block.id) && ( @@ -141,6 +154,7 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner {removedAfter(block.id)} ))} + {removedElsewhere}
) @@ -169,7 +183,7 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement { */ function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTreeState, marks?: BlockMarks }): ReactElement { if ('properties' in block) - return + return return ( <> {marks?.translation ? : } @@ -183,12 +197,36 @@ function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTre ) } +/** + * 卡片属性表里放的子块(BlockSlot)怎么画:一行下面的几块画成一层块(同块树);点子行才展开的,点那个子行的第一格展开它、 + * 同一行里别的收起,展开着的再点收起。卡片没有放进属性表的子块时没有。 + */ +function cardSlots(block: BlockView, tree: BlockTreeState): GridSlots | undefined { + const blocks = block.children.filter(child => child.slot) + if (blocks.length === 0) + return undefined + return { + blocks, + render: list => , + onToggle: (child) => { + if (!tree.collapsed.has(child.id)) { + tree.onSetFolds({ [child.id]: true }) + return + } + const others = blocks.filter(other => other !== child && other.slot?.row === child.slot?.row && other.slot?.item !== undefined) + tree.onSetFolds({ ...Object.fromEntries(others.map(other => [other.id, true])), [child.id]: false }) + }, + } +} + /** 一块:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools)。 */ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement { const editing = tree.editing.has(block.id) const collapsed = tree.collapsed.has(block.id) - const hasChildren = hasShownChildren(block, tree) - const Chevron = collapsed ? ChevronRight : ChevronDown + // 点父卡片子行才展开的子块:画出来就是展开着的,箭头收起整张 + const toggled = block.slot?.item !== undefined + const hasChildren = toggled || hasShownChildren(block, tree) + const Chevron = collapsed && !toggled ? ChevronRight : ChevronDown const change = tree.diff?.changes.get(block.id) return (
tree.onToggleFold(block.id)} > diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index a359e75..b4e4fd9 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -1,5 +1,5 @@ -import type { ReactElement } from 'react' -import type { Inline, InlinePart, ItemDiff, ItemsDiff, PropertyItem, PropertyRow, Removed, RowsDiff } from './types' +import type { CSSProperties, ReactElement, ReactNode } from 'react' +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' @@ -17,6 +17,23 @@ function gridTemplate(count: number): string { return [...fixed, 'minmax(0, 1fr)'].join(' ') } +/** + * 库规则放进卡片属性表里的子块(BlockSlot):blocks 是卡片的这些子块,render 把一行下面的几块画成一层块(同块树), + * onToggle 是点子行第一格时展开 / 收起讲它的那一块(点子行才展开的,见 BlockSlot.item)。 + */ +export interface GridSlots { + blocks: BlockView[] + render: (blocks: BlockView[]) => ReactNode + onToggle: (block: BlockView) => void +} + +/** 表格里一个子行讲的子块:展开着没有,它的种类(展开时这一行淡淡垫上它的颜色),点第一格时怎么切换。 */ +interface ItemToggle { + open: boolean + badge?: KindBadge + onToggle: () => void +} + /** 眼下折叠着哪些属性行、子行,点折叠箭头时切换哪个。 */ interface Folding { collapsed: ReadonlySet @@ -137,21 +154,38 @@ function PropertyItems({ parentId, items, folding, marks }: { parentId: string, * 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。一行译文下面摊开实参的表也用它; * 行带 table(实参的值里跨几行写的调用)时,那张表排在最后一格里、文字下面,往右缩一点,一层层往下套。 * 标差异时(marks)有加行、删行的表每行第一格前多一个记号位(+ / −,没变的空着),加的行淡绿底,删的行放回原来的位置、淡红底划掉。 + * 讲某个子块的行(toggles,React 组件的事件处理表)第一格是按钮,点它在表下面展开那一块,展开时这一行淡淡垫上那一块的种类色。 */ -export function PropertyTable({ parentId, columns, items, marks }: { parentId: string, columns: string[], items: PropertyItem[], marks?: ItemsDiff }): ReactElement { +export function PropertyTable({ parentId, columns, items, marks, toggles }: { + parentId: string + columns: string[] + items: PropertyItem[] + marks?: ItemsDiff + /** 按子行下标:讲它的子块(点第一格展开,见 GridSlots) */ + toggles?: ReadonlyMap +}): ReactElement { // 有加行、删行的表每行第一格前留一个记号位 const signed = marks !== undefined && (marks.removed.length > 0 || Object.values(marks.changed).some(mark => mark.added)) - /** 一行:删掉的(change 是 del)不带 id,套着的表不再标 */ - const tableRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined): ReactElement => { + /** 一行:删掉的(change 是 del)不带 id,套着的表不再标;toggle 是这一行讲的子块,第一格成了展开它的按钮 */ + const tableRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined, toggle?: ItemToggle): ReactElement => { const cells = tableCells(item, columns) const text = (cell: Inline[], i: number): ReactElement => ( <> {signed && i === 0 && {SIGNS[change ?? 'none']}} - + {toggle && i === 0 + ? ( + + ) + : } ) + const tint = toggle?.open && toggle.badge + ? { 'data-badge': '', 'style': { '--kind-dark': toggle.badge.color.dark, '--kind-light': toggle.badge.color.light } as CSSProperties } + : {} return ( -
+
{cells.map((cell, i) => item.table && i === cells.length - 1 ? (
@@ -176,7 +210,7 @@ export function PropertyTable({ parentId, columns, items, marks }: { parentId: s const mark = marks?.changed[index] return ( - {tableRow(item, propertyItemId(parentId, index), mark?.added && 'add', mark)} + {tableRow(item, propertyItemId(parentId, index), mark?.added && 'add', mark, toggles?.get(index))} {removed(index)} ) @@ -191,13 +225,15 @@ export function PropertyTable({ parentId, columns, items, marks }: { parentId: s * 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。 * 标差异时(marks)新加的行淡绿底、左边 +,删掉的行放回原来的位置、淡红底划掉、左边 −,值改了的按旧值 → 新值或逐词标。 */ -export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks }: { +export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, slots }: { blockId: string rows: PropertyRow[] collapsed: ReadonlySet onToggleFold: (id: string) => void /** 标差异时这张属性表改了哪些行 */ marks?: RowsDiff + /** 放进这张属性表里的子块(库规则的卡片),见 GridSlots */ + slots?: GridSlots }): ReactElement { const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after) .map(({ value, index }) => ) @@ -210,10 +246,16 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks }: const folded = collapsed.has(id) const Chevron = folded ? ChevronRight : ChevronDown const mark = marks?.changed[index] + const placed = slots?.blocks.filter(block => block.slot?.row === index) ?? [] + // 点子行才展开的子块只画展开着的;别的放进这一行的子块总是画 + const shownBlocks = placed.filter(block => block.slot?.item === undefined || !collapsed.has(block.id)) + const toggles = new Map(placed.flatMap((block): [number, ItemToggle][] => block.slot?.item === undefined + ? [] + : [[block.slot.item, { open: !collapsed.has(block.id), badge: block.badge, onToggle: () => slots?.onToggle(block) }]])) return (
- {layout + {layout || placed.length > 0 ? (
- {!folded && layout === 'table' && } + {!folded && layout === 'table' && } {!folded && layout === 'list' && } + {!folded && slots && shownBlocks.length > 0 &&
{slots.render(shownBlocks)}
} {removed(index)}
) diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 3057f67..1224910 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -473,6 +473,28 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { -- .cr-table-row { border-top: 1px solid var(--cr-table-border); } +/* 讲某个子块的行(React 组件的事件处理表):第一格是按钮,悬停划下划线;展开时这一行淡淡垫上那一块的种类色 */ +.cr-table-row[data-open] { + background: color-mix(in srgb, var(--kind) 10%, transparent); +} +.cr-item-toggle { + margin: 0; + padding: 0; + border: 0; + background: none; + font: inherit; + color: inherit; + text-align: left; + cursor: pointer; +} +.cr-item-toggle:hover { + text-decoration: underline; +} + +/* 放进卡片属性表某一行下面的子块(副作用、展开的处理函数):和表格一样往里缩一点 */ +.cr-slot { + padding-left: 5px; +} .cr-table .cr-table-cell { padding: 2px 8px; } diff --git a/vscode/webview-ui/src/codeReader/diffView.test.ts b/vscode/webview-ui/src/codeReader/diffView.test.ts index 313a60b..e05a889 100644 --- a/vscode/webview-ui/src/codeReader/diffView.test.ts +++ b/vscode/webview-ui/src/codeReader/diffView.test.ts @@ -115,6 +115,35 @@ describe('foldUnchanged', () => { }) }) +/** 一张卡片:两个副作用放在第 7 行下面,两个处理函数放在第 8 行的子行上,后面一个留下的子块;只有第二个处理函数改了 */ +const CARD_BLOCKS = [ + block('0', 'card', [ + block('0.0', 'e1', [], { slot: { row: 7 } }), + block('0.1', 'e2', [], { slot: { row: 7 } }), + block('0.2', 'h1', [], { slot: { row: 8, item: 0 } }), + block('0.3', 'h2', [block('0.3.0', 'h2s', [], { slot: { row: 2 } })], { slot: { row: 8, item: 1 }, startLine: 4 }), + block('0.4', 'rest'), + ]), +] + +function cardTree(): NonNullable> { + return diffTree(CARD_BLOCKS, diff({ blocks: { card: { change: 'mod' }, h2: { change: 'mod' }, h2s: { change: 'mod', own: true } } }), 2, () => true)! +} + +describe('放进卡片属性表里的子块', () => { + it('只看改动:画在卡片里的按所在的行分开折,点子行才展开的不折、改了的照样往里看', () => { + const { runs, folded } = foldUnchanged(CARD_BLOCKS, cardTree(), () => true, new Set(), new Set()) + expect([...runs]).toEqual([['0.0', 2], ['0.4', 1]]) + expect([...folded].sort()).toEqual(['0.0', '0.1', '0.4']) + }) + + it('上一处 / 下一处:要展开的是子块所在的那一行(点子行才展开的连它自己),不是卡片', () => { + expect(changeStops(CARD_BLOCKS, cardTree(), new Set()).map(stop => [stop.id, stop.ancestors])).toEqual([ + ['0.3.0', ['0/p8', '0.3', '0.3/p2']], + ]) + }) +}) + describe('changeStops', () => { it('新增的最外面那块、自己改了的修改块、删掉的块,照页面上的先后;修改块只算自己那几行', () => { const stops = changeStops(TREE_BLOCKS, tree([removed('', 'c'), removed('a', undefined)]), new Set()) diff --git a/vscode/webview-ui/src/codeReader/diffView.ts b/vscode/webview-ui/src/codeReader/diffView.ts index 4ce85e7..4588389 100644 --- a/vscode/webview-ui/src/codeReader/diffView.ts +++ b/vscode/webview-ui/src/codeReader/diffView.ts @@ -1,5 +1,6 @@ import type { BlockView, ChangedBlock, DiffStats, DocumentDiff, RemovedView } from './types' import { inlineText } from './segments' +import { unfoldToShow } from './viewState' /** 页面上怎么标差异:按块 id 取改动,按层取删掉的块。基准里没有这个文件(整份新增)时不逐块标,没有它。 */ export interface DiffTree { @@ -88,8 +89,8 @@ export function foldUnchanged(blocks: readonly BlockView[], tree: DiffTree, show block.children.forEach(fold) } const kept = (block: BlockView): boolean => keep.has(block.id) || block.children.some(kept) - const visit = (list: readonly BlockView[], owner: string): void => { - const breaks = new Set((tree.removed.get(owner) ?? []).map(item => item.after)) + /** 画在同一处的一串兄弟块:连着没变的折成一段 */ + function visitRun(list: readonly BlockView[], breaks: ReadonlySet): void { let run: BlockView[] = [] const close = (): void => { if (run.length > 0 && !opened.has(run[0].id)) { @@ -113,6 +114,22 @@ export function foldUnchanged(blocks: readonly BlockView[], tree: DiffTree, show } close() } + /** 一层子块:放进父卡片里的按所在的行分开折(各画在各的地方),点子行才展开的不折、只往里看改动 */ + function visit(list: readonly BlockView[], owner: string): void { + const breaks = new Set((tree.removed.get(owner) ?? []).map(item => item.after)) + const places = new Map() + for (const block of list) { + if (block.slot?.item !== undefined) { + if (tree.changes.has(block.id) || kept(block)) + visit(block.children, block.id) + continue + } + const place = block.slot ? `${block.slot.row}` : '' + places.set(place, [...places.get(place) ?? [], block]) + } + for (const group of places.values()) + visitRun(group, breaks) + } visit(blocks, '') return { runs, folded } } @@ -120,7 +137,7 @@ export function foldUnchanged(blocks: readonly BlockView[], tree: DiffTree, show /** * 「上一处 / 下一处」和总览条上的一处改动:新增的最外面那块、自己改了的修改块、删掉的块,照页面上的先后。 * id 是页面上的块 id(删掉的块是 PlacedRemoved.id);from / to 是它在新文件里的行(0 起),修改块只算它自己那几行(到第一个子块之前); - * ancestors 是要展开才看得见它的各层块。 + * ancestors 是要展开才看得见它的各层块和属性行(放进父卡片里的子块要展开它所在的那一行,见 unfoldToShow)。 */ export interface ChangeStop { id: string @@ -133,14 +150,16 @@ export interface ChangeStop { /** 页面上的各处改动(见 ChangeStop);hidden 里的块(被筛掉的)连同子孙不算。 */ export function changeStops(blocks: readonly BlockView[], tree: DiffTree, hidden: ReadonlySet): ChangeStop[] { const stops: ChangeStop[] = [] - const visit = (list: readonly BlockView[], owner: string, ancestors: string[]): void => { + /** 一层块:owner 是父块 id(顶层 ''),ancestorsOf 给出要让某个子块看得见得展开的,removedAncestors 是这一层删掉的块要展开的 */ + const visit = (list: readonly BlockView[], owner: string, ancestorsOf: (block: BlockView) => string[], removedAncestors: string[]): void => { const removed = tree.removed.get(owner) ?? [] const removedAfter = (id: string | undefined): void => { for (const item of removed.filter(entry => entry.after === id)) - stops.push({ id: item.id, change: 'del', from: item.removed.line, to: item.removed.line, ancestors }) + stops.push({ id: item.id, change: 'del', from: item.removed.line, to: item.removed.line, ancestors: removedAncestors }) } removedAfter(undefined) for (const block of list) { + const ancestors = ancestorsOf(block) const change = hidden.has(block.id) ? undefined : tree.changes.get(block.id) if (change?.change === 'add' && change.outermost) stops.push({ id: block.id, change: 'add', from: block.startLine, to: block.endLine, ancestors }) @@ -149,11 +168,11 @@ export function changeStops(blocks: readonly BlockView[], tree: DiffTree, hidden stops.push({ id: block.id, change: 'mod', from: block.startLine, to: own, ancestors }) } if (change?.change === 'mod') - visit(block.children, block.id, [...ancestors, block.id]) + visit(block.children, block.id, child => [...ancestors, ...unfoldToShow(block.id, child)], [...ancestors, block.id]) removedAfter(block.id) } } - visit(blocks, '', []) + visit(blocks, '', () => [], []) return stops } diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index b5174b7..cf5d7a2 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -160,6 +160,16 @@ describe('跳到定义:滚到哪块、展开哪些', () => { expect(hidden({ '1': false, '1.0': true })).toEqual(['1.0']) }) + it('放进卡片属性表里的子块:展开它所在的那一行,不展开卡片自己;点子行才展开的连它自己一起展开', () => { + const statement = { ...textBlock('0.1.0', 'statement', 5, [name('scrollTo', 300)]), slot: { row: 2 } } + const handler = { ...factBlock('0.1', 'function', 4, [{ label: '名称', value: [name('handleBack', 200)] }], [statement]), slot: { row: 3, item: 0 } } + const effect = { ...textBlock('0.0', 'statement', 2, [name('useEffect', 100)]), slot: { row: 2 } } + const card = factBlock('0', 'function', 1, [{ label: '名称', value: [name('Page', 10)] }], [effect, handler]) + expect(revealPlan([card], 0, 100)).toEqual({ id: '0.0', expand: [propertyRowId('0', 2)] }) + expect(revealPlan([card], 0, 200)).toEqual({ id: '0.1', expand: [propertyRowId('0', 3), '0.1'] }) + expect(revealPlan([card], 0, 300)).toEqual({ id: '0.1.0', expand: [propertyRowId('0', 3), '0.1', propertyRowId('0.1', 2)] }) + }) + it('按行找:同一行有几块取外层那块;行在所有块之前取第一块;没有块时没有目标', () => { const sameLine = [textBlock('0', 'if', 1, [word('如果')], [textBlock('0.0', 'return', 1, [word('返回')])])] expect(revealPlan(sameLine, 1, 999)).toEqual({ id: '0', expand: [] }) diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts index e282156..6751ecf 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -2,7 +2,7 @@ import type { BlockView, Occurrence, PreviewTable, PreviewText, PropertyItem, So import { blockPath } from './filter' import { showsOccurrence } from './occurrences' import { showsOffset } from './segments' -import { propertyItemId, propertyRowId } from './viewState' +import { propertyItemId, propertyRowId, unfoldToShow } from './viewState' /** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */ export interface RevealPlan { @@ -17,7 +17,7 @@ type Spot = Omit interface Placed { block: BlockView - /** 从顶层到父块的块 id */ + /** 要让它看得见得展开的块、属性行(从顶层往下,见 unfoldToShow) */ ancestors: string[] } @@ -25,7 +25,8 @@ interface Placed { function* placed(blocks: BlockView[], ancestors: string[] = []): Generator { for (const block of blocks) { yield { block, ancestors } - yield* placed(block.children, [...ancestors, block.id]) + for (const child of block.children) + yield* placed([child], [...ancestors, ...unfoldToShow(block.id, child)]) } } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 5aaff89..abcf512 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -124,9 +124,20 @@ interface BlockViewBase { attributes: Record /** 库规则认领的块才有:种类的名字、颜色和图标,由规则给;和类块一样默认收起子块 */ badge?: KindBadge + /** 库规则放进父卡片里的子块才有:画在父卡片属性表第 row 行下面,不排在父卡片的子块里;item 见 BlockSlot */ + slot?: BlockSlot children: BlockView[] } +/** + * 子块画在父卡片的哪儿:属性表第 row 行下面(折叠这一行时跟着收起);item 是讲它的那个子行, + * 这时它默认收着(它的块 id 记成折叠着),点那个子行的第一格才展开、一次只展开一个(React 组件的事件处理表点名称) + */ +export interface BlockSlot { + row: number + item?: number +} + /** * 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文; * 一行译文下面依次是:跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文) diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index 80cf24d..b36c5f8 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -1,7 +1,7 @@ import type { BlockKind, BlockView } from './types' import type { ViewStateStorage } from './viewState' import { describe, expect, it } from 'vitest' -import { collapsedIds, createViewStateStore, propertyItemId, propertyRowId } from './viewState' +import { collapsedIds, createViewStateStore, propertyItemId, propertyRowId, unfoldToShow } from './viewState' /** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回;也能塞进旧版本存的东西 */ function memoryStorage(initial?: unknown): ViewStateStorage { @@ -119,6 +119,18 @@ describe('眼下折叠着哪些', () => { expect([...collapsedIds([block('0', 'exception', [block('0.0', 'function')]), block('1', 'if')], {})]).toEqual(['0', '0.0']) }) + it('放进父卡片里的子块:点子行才展开的默认收着(整张不画),别的照种类;要让它看得见得展开它所在的那一行(点子行才展开的还有它自己)', () => { + const badge = { label: '副作用', color: { dark: '#e5c07b', light: '#9a6a00' }, icon: 'zap' as const } + const effect: BlockView = { ...block('0.0', 'statement', [{ ...block('0.0.0', 'variable'), slot: { row: 1 } }]), badge, kind: 'react.effect', slot: { row: 7 } } + const handler: BlockView = { ...block('0.1', 'function'), badge, kind: 'react.handler', slot: { row: 8, item: 0 } } + const card: BlockView = { ...block('0', 'if', [effect, handler]), badge, kind: 'react.component' } + expect([...collapsedIds([card], {})].sort()).toEqual(['0', '0.0', '0.1']) + expect([...collapsedIds([card], { 0.1: false })].sort()).toEqual(['0', '0.0']) + expect(unfoldToShow('0', effect)).toEqual(['0/p7']) + expect(unfoldToShow('0', handler)).toEqual(['0/p8', '0.1']) + expect(unfoldToShow('0', block('0.2', 'comment'))).toEqual(['0']) + }) + it('标了改动的块默认展开,用户折叠过的照样折叠', () => { expect([...collapsedIds(BLOCKS, {}, new Set(['1', '2']))].sort()).toEqual(['1.0.0', '2.0']) expect([...collapsedIds(BLOCKS, { 1: true }, new Set(['1']))].sort()).toEqual(['1', '1.0.0', '2', '2.0']) diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index 4e703ff..168f7aa 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -54,9 +54,12 @@ function readViewState(saved: unknown): ViewState { return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds } } -/** 块默认折叠吗:函数、类、枚举、异常块和库规则的卡片收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */ -export function foldedByDefault(block: Pick): boolean { - return block.badge !== undefined || ['function', 'class', 'enum', 'exception'].includes(block.kind) +/** + * 块默认折叠吗:函数、类、枚举、异常块和库规则的卡片收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 + * 父卡片里点子行才展开的子块(BlockSlot.item)默认收着:它折叠着就是整张不画。 + */ +export function foldedByDefault(block: Pick): boolean { + return block.slot?.item !== undefined || block.badge !== undefined || ['function', 'class', 'enum', 'exception'].includes(block.kind) } /** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault;opened 里的块(标了改动的)默认展开。 */ @@ -98,3 +101,13 @@ export function propertyItemId(parentId: string, itemIndex: number): string { export function tableId(ownerId: string): string { return `${ownerId}/t` } + +/** + * 父块看得见时,要让它的一个子块也看得见得展开的(块 id、属性行 id):一般的子块是父块自己(收起子块时不画); + * 放进父卡片属性表里的子块(BlockSlot)是它所在的那一行,点子行才展开的还有它自己。 + */ +export function unfoldToShow(parentId: string, child: Pick): string[] { + if (!child.slot) + return [parentId] + return [propertyRowId(parentId, child.slot.row), ...(child.slot.item !== undefined ? [child.id] : [])] +}