✨ feat(vscode): 代码阅读 React 组件卡片按钩子分区:状态 / 引用 / 读取的数据 / 派生值 / 副作用 / 事件处理;副作用、处理函数成子卡片放进卡片那一行下面(库规则新增把子块放进属性表某一行的机制,事件处理表点名称展开)

This commit is contained in:
cruldra committed 2026-10-03 03:11:46 +08:00
1 parent 15f3a6600d
commit ee807ec7a4
19 files changed
+1057 -56

No files matched your search

+1
View File
@@ -146,6 +146,7 @@ export function App(): ReactElement | null {
}))
},
onToggleFold: id => setFolded([id], !collapsed.has(id)),
onSetFolds: folds => setView(prev => ({ ...prev, folds: { ...prev.folds, ...folds } })),
...(qa.state.available && { qa }),
...(changes && { diff: changes }),
...(folded && { folded }),
@@ -0,0 +1,85 @@
import type { BlockTreeState } from './BlockTree'
import type { BlockView, KindBadge } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { BlockLevel } from './BlockTree'
import { collapsedIds } from './viewState'
// 高亮的上下文所在的模块、编辑框都连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
vi.mock('./useOccurrences', async () => {
const { createContext } = await import('react')
return { HighlightContext: createContext(undefined) }
})
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
const BADGE: KindBadge = { label: 'React 组件', color: { dark: '#61dafb', light: '#0a7ea4' }, icon: 'atom' }
function view(id: string, kind: string, text: string, extra: Partial<BlockView> = {}): BlockView {
return { id, anchor: id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, children: [], translation: [{ text, role: 'plain' }], ...extra } as BlockView
}
/** 一张组件卡片:第 1 行下面放着一个副作用,第 2 行的两个子行各讲一个处理函数,后面还有一个留下的注释块。 */
const CARD: BlockView = {
...view('0', 'react.component', ''),
badge: BADGE,
properties: [
{ label: '名称', value: [{ text: 'Page', role: 'function' }] },
{ label: '副作用', value: [{ text: '1 个', role: 'hint' }] },
{ label: '事件处理', value: [{ text: '2 个', role: 'hint' }], columns: ['名称', '绑定在', '做什么'], items: [
{ cells: [[{ text: 'handleA', role: 'function' }], [], []] },
{ cells: [[{ text: 'handleB', role: 'function' }], [], []] },
] },
],
children: [
view('0.0', 'react.effect', '滚回顶部', { slot: { row: 1 }, badge: { ...BADGE, label: '副作用', icon: 'zap' } }),
view('0.1', 'react.handler', '处理 A', { slot: { row: 2, item: 0 }, badge: { ...BADGE, label: '事件处理', icon: 'mouse-pointer-click' } }),
view('0.2', 'react.handler', '处理 B', { slot: { row: 2, item: 1 }, badge: { ...BADGE, label: '事件处理', icon: 'mouse-pointer-click' } }),
view('0.3', 'comment', '留下的注释'),
],
}
function render(folds: Record<string, boolean>): string {
const collapsed = collapsedIds([CARD], folds)
const tree: BlockTreeState = {
version: 1,
editing: new Set(),
collapsed,
hidden: new Set(),
temporary: new Set(),
covered: new Map(),
kindName: kind => kind,
focusId: null,
onFocused: () => {},
onToggleEdit: () => {},
onToggleFold: () => {},
onSetFolds: () => {},
onOpenRun: () => {},
}
return renderToStaticMarkup(<BlockLevel blocks={[CARD]} tree={tree} />)
}
describe('放进卡片属性表里的子块', () => {
it('副作用画在它那一行下面;点子行才展开的处理函数默认不画,名称是展开它的按钮;卡片收着时留下的子块不画', () => {
const html = render({})
expect(html).toContain('滚回顶部')
expect(html).not.toContain('处理 A')
expect(html).not.toContain('留下的注释')
expect(html).toContain('<button type="button" class="cr-item-toggle" aria-expanded="false" aria-label="展开handleA">')
// 副作用画在「副作用」那一行后面、「事件处理」那一行前面
expect(html.indexOf('滚回顶部')).toBeGreaterThan(html.indexOf('副作用</button>'))
expect(html.indexOf('滚回顶部')).toBeLessThan(html.indexOf('事件处理</button>'))
})
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('滚回顶部')
})
})
+49 -11
View File
@@ -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<string, boolean>) => 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 => <RemovedCard key={item.id} placed={item} tree={tree} />)
const removedElsewhere = removed
.filter(item => item.after !== undefined && !listedIds.has(item.after))
.map(item => <RemovedCard key={item.id} placed={item} tree={tree} />)
return (
<div className="cr-level flex gap-2" {...kindProps(owner)}>
<div className="flex w-3 shrink-0 justify-center">
@@ -124,7 +137,7 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{removedAfter(undefined)}
{blocks.map(block => (
{listed.map(block => (
<Fragment key={block.id}>
{tree.folded?.runs.has(block.id) && <UnchangedStrip count={tree.folded.runs.get(block.id) ?? 0} tree={tree} onOpen={() => 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)}
</Fragment>
))}
{removedElsewhere}
</div>
</div>
)
@@ -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 <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} marks={marks?.rows} />
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} marks={marks?.rows} slots={cardSlots(block, tree)} />
return (
<>
{marks?.translation ? <DiffText diff={marks.translation} /> : <TextPreview text={block.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 => <BlockLevel blocks={list} owner={block} tree={tree} slotted />,
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 (
<div
@@ -205,8 +243,8 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
<button
type="button"
className="flex h-5 w-3.5 shrink-0 items-center justify-center rounded-sm text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)]"
aria-label={collapsed ? '展开子块' : '折叠子块'}
aria-expanded={!collapsed}
aria-label={toggled ? '收起' : collapsed ? '展开子块' : '折叠子块'}
aria-expanded={toggled || !collapsed}
onClick={() => tree.onToggleFold(block.id)}
>
<Chevron size={14} strokeWidth={1.8} />
@@ -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<string>
@@ -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<number, ItemToggle>
}): 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 && <span className="cr-diff-sign" data-change={change} aria-hidden>{SIGNS[change ?? 'none']}</span>}
<CellText cell={cell} parts={mark?.cells?.[i]} />
{toggle && i === 0
? (
<button type="button" className="cr-item-toggle" aria-expanded={toggle.open} aria-label={`${toggle.open ? '收起' : '展开'}${inlineText(cell)}`} onClick={toggle.onToggle}>
<CellText cell={cell} parts={mark?.cells?.[i]} />
</button>
)
: <CellText cell={cell} parts={mark?.cells?.[i]} />}
</>
)
const tint = toggle?.open && toggle.badge
? { 'data-badge': '', 'style': { '--kind-dark': toggle.badge.color.dark, '--kind-light': toggle.badge.color.light } as CSSProperties }
: {}
return (
<div className="cr-table-row" role="row" data-id={change === 'del' ? undefined : id} data-diff={change}>
<div className="cr-table-row" role="row" data-id={change === 'del' ? undefined : id} data-diff={change} data-open={toggle?.open ? '' : undefined} {...tint}>
{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">
@@ -176,7 +210,7 @@ export function PropertyTable({ parentId, columns, items, marks }: { parentId: s
const mark = marks?.changed[index]
return (
<Fragment key={index}>
{tableRow(item, propertyItemId(parentId, index), mark?.added && 'add', mark)}
{tableRow(item, propertyItemId(parentId, index), mark?.added && 'add', mark, toggles?.get(index))}
{removed(index)}
</Fragment>
)
@@ -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<string>
onToggleFold: (id: string) => void
/** 标差异时这张属性表改了哪些行 */
marks?: RowsDiff
/** 放进这张属性表里的子块(库规则的卡片),见 GridSlots */
slots?: GridSlots
}): ReactElement {
const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after)
.map(({ value, index }) => <RemovedRow key={`removed-${index}`} id={`${blockId}/x${index}`} row={value} />)
@@ -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 (
<Fragment key={index}>
<div className="cr-prop-row grid grid-cols-subgrid" data-id={id} data-diff={mark?.added && 'add'}>
{layout
{layout || placed.length > 0
? (
<button
type="button"
@@ -229,8 +271,9 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks }:
: <span className="cr-prop-label flex h-5 items-center">{row.label}</span>}
{mark?.value ? <DiffText diff={mark.value} className="min-w-0" /> : <TextPreview text={row.value} className="min-w-0" />}
</div>
{!folded && layout === 'table' && <PropertyTable parentId={id} columns={row.columns ?? []} items={row.items ?? []} marks={mark?.items} />}
{!folded && layout === 'table' && <PropertyTable parentId={id} columns={row.columns ?? []} items={row.items ?? []} marks={mark?.items} toggles={toggles} />}
{!folded && layout === 'list' && <PropertyItems parentId={id} items={row.items ?? []} folding={{ collapsed, onToggleFold }} marks={mark?.items} />}
{!folded && slots && shownBlocks.length > 0 && <div className="cr-slot">{slots.render(shownBlocks)}</div>}
{removed(index)}
</Fragment>
)
@@ -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;
}
@@ -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<ReturnType<typeof diffTree>> {
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())
+26 -7
View File
@@ -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<string | undefined>): 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<string, BlockView[]>()
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<string>): 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
}
@@ -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: [] })
+4 -3
View File
@@ -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<RevealPlan, 'id'>
interface Placed {
block: BlockView
/** 从顶层到父块的块 id */
/** 要让它看得见得展开的块、属性行(从顶层往下,见 unfoldToShow) */
ancestors: string[]
}
@@ -25,7 +25,8 @@ interface Placed {
function* placed(blocks: BlockView[], ancestors: string[] = []): Generator<Placed> {
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)])
}
}
+11
View File
@@ -124,9 +124,20 @@ interface BlockViewBase {
attributes: Record<string, string>
/** 库规则认领的块才有:种类的名字、颜色和图标,由规则给;和类块一样默认收起子块 */
badge?: KindBadge
/** 库规则放进父卡片里的子块才有:画在父卡片属性表第 row 行下面,不排在父卡片的子块里;item 见 BlockSlot */
slot?: BlockSlot
children: BlockView[]
}
/**
* 子块画在父卡片的哪儿:属性表第 row 行下面(折叠这一行时跟着收起);item 是讲它的那个子行,
* 这时它默认收着(它的块 id 记成折叠着),点那个子行的第一格才展开、一次只展开一个(React 组件的事件处理表点名称)
*/
export interface BlockSlot {
row: number
item?: number
}
/**
* 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文;
* 一行译文下面依次是:跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文)
@@ -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'])
+16 -3
View File
@@ -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<BlockView, 'kind' | 'badge'>): boolean {
return block.badge !== undefined || ['function', 'class', 'enum', 'exception'].includes(block.kind)
/**
* 块默认折叠吗:函数、类、枚举、异常块和库规则的卡片收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。
* 父卡片里点子行才展开的子块(BlockSlot.item)默认收着:它折叠着就是整张不画。
*/
export function foldedByDefault(block: Pick<BlockView, 'kind' | 'badge' | 'slot'>): 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<BlockView, 'id' | 'slot'>): string[] {
if (!child.slot)
return [parentId]
return [propertyRowId(parentId, child.slot.row), ...(child.slot.item !== undefined ? [child.id] : [])]
}