✨ feat(vscode): React 钩子卡片按变量讲,属性表加操作列、调用树能查所有引用

- 钩子调用讲成状态、引用、记忆值、记忆函数、上下文值、过渡、副作用、钩子返回值、钩子调用几种卡片,行照签名表 reactHooks.ts 排,去掉 react.hook
- 解构接住时名称一行写出解构,展开是「第几项 | 名称 | 说明」表,每项一个调用树操作;单个名字的按钮在名称一行最右边
- 属性表的行、子行可以带操作(RowAction),表格多一列「操作」,宿主按块记号加序号起键
- 调用树服务按键数次数,新增查所有引用(references):按所在函数分组,去掉定义处和依赖数组里的
- 钩子收的函数参数都收成小框,函数体是卡片子块,直接返回的表达式由 functionBody 排成「返回 …」卡片;小框和卡片折叠箭头同一个开关
- 规则卡片可指定语言种类图标(languageKind)、种类图标悬停字和颜色(KindBadge.tooltip / iconColor)
This commit is contained in:
cruldra committed 2026-10-16 22:16:09 +11:00
1 parent a139341132
commit 86beaac371
38 files changed
+1971 -813

No files matched your search

+64 -9
View File
@@ -155,18 +155,18 @@ describe('页面重新加载后,调用树弹框照旧开着', () => {
await load()
await deliver(documentOf(3, [block('0', 'mask_phone')]))
await act(async () => document.body.querySelector<HTMLButtonElement>('[data-call-tree="mask_phone"]')!.click())
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' })
await deliver({ type: 'callTree', version: 3, anchor: 'mask_phone', name: 'mask_phone', where: 'utils/phone.py:28', trees: TREES })
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, key: 'mask_phone' })
await deliver({ type: 'callTree', version: 3, key: 'mask_phone', name: 'mask_phone', where: 'utils/phone.py:28', trees: TREES })
await act(async () => document.body.querySelectorAll<HTMLButtonElement>('[role="tab"]')[1].click())
expect(saved?.callTree).toEqual({ anchor: 'mask_phone', entry: 1 })
expect(saved?.callTree).toEqual({ key: 'mask_phone', entry: 1 })
await unload()
await load()
expect(dialog()).toBeNull()
await deliver(documentOf(3, [block('0', 'mask_phone')]))
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' })
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, key: 'mask_phone' })
expect(dialog()!.textContent).toContain('正在查调用关系')
await deliver({ type: 'callTree', version: 3, anchor: 'mask_phone', name: 'mask_phone', where: 'utils/phone.py:28', trees: TREES })
await deliver({ type: 'callTree', version: 3, key: 'mask_phone', name: 'mask_phone', where: 'utils/phone.py:28', trees: TREES })
expect(selectedTab()).toBe('夜间对账1处')
await act(async () => dialog()!.querySelector<HTMLButtonElement>('[aria-label="关闭"]')!.click())
@@ -179,17 +179,17 @@ describe('页面重新加载后,调用树弹框照旧开着', () => {
})
it('重新加载后文档还没到,页面又重新加载了一次:记着的弹框还在', async () => {
saved = { editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 1 } }
saved = { editing: [], folds: {}, callTree: { key: 'mask_phone', entry: 1 } }
await load()
await unload()
await load()
await deliver(documentOf(3, [block('0', 'mask_phone')]))
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' })
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, key: 'mask_phone' })
expect(dialog()).not.toBeNull()
})
it('那块已经不在了:不开弹框、不要树,记录丢掉', async () => {
saved = { editing: [], folds: {}, callTree: { anchor: 'gone', entry: 0 } }
saved = { editing: [], folds: {}, callTree: { key: 'gone', entry: 0 } }
await load()
await deliver(documentOf(4, [block('0', 'mask_phone')]))
expect(dialog()).toBeNull()
@@ -198,7 +198,7 @@ describe('页面重新加载后,调用树弹框照旧开着', () => {
})
it('弹框开着时文档换了一版:照旧关掉,记录跟着清掉', async () => {
saved = { editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 0 } }
saved = { editing: [], folds: {}, callTree: { key: 'mask_phone', entry: 0 } }
await load()
await deliver(documentOf(5, [block('0', 'mask_phone')]))
expect(dialog()).not.toBeNull()
@@ -208,6 +208,61 @@ describe('页面重新加载后,调用树弹框照旧开着', () => {
})
})
describe('卡片属性表里各行的操作', () => {
/** React 状态卡片:「名称」一行解构成两项,各带一个查引用的操作,键是块的记号接序号 */
function stateCard(): UnhashedBlock {
const action = (index: number, label: string) => ({ kind: 'callTree' as const, name: { startIndex: 7 + index * 6, endIndex: 11 + index * 6 }, mode: 'references' as const, label, key: `state|${index}` })
return {
id: '0',
anchor: 'state',
kind: 'react.state',
badge: { label: '状态', color: { dark: '#9cdcfe', light: '#001080' }, icon: 'toggle-right' },
span: NO_SPAN,
attributes: { kind: 'react.state' },
concepts: [{ id: 'variable', label: '变量', icon: 'variable' }],
children: [],
properties: [{
label: '名称',
value: [{ text: '[open, setOpen]', role: 'plain' }],
columns: ['第几项', '名称', '说明'],
items: [
{ cells: [[{ text: '1', role: 'hint' }], [{ text: 'open', role: 'variable' }], [{ text: '当前值', role: 'plain' }]], actions: [action(0, '谁用了这个状态')] },
{ cells: [[{ text: '2', role: 'hint' }], [{ text: 'setOpen', role: 'function' }], [{ text: '修改函数', role: 'plain' }]], actions: [action(1, '谁改了这个状态')] },
],
}],
}
}
it('次数按操作的键到;点操作按钮发 openCallTree 带操作的键,收到这个键的树就打开弹框', async () => {
await load()
await deliver(documentOf(3, [stateCard()]))
expect(container.querySelector('[role="columnheader"]:last-child')?.textContent).toBe('操作')
await deliver({ type: 'callTreeCounts', version: 3, counts: { 'state|0': 3 } })
expect(container.querySelector('[data-call-tree="state|0"]')?.textContent).toBe('3')
expect(container.querySelector('[data-call-tree="state|1"]')?.textContent).toBe('')
expect(container.querySelector('[data-call-tree="state|1"]')?.getAttribute('data-tooltip')).toBe('谁改了这个状态')
await act(async () => container.querySelector<HTMLButtonElement>('[data-call-tree="state|1"]')!.click())
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, key: 'state|1' })
expect(container.querySelector('[data-call-tree="state|1"]')?.getAttribute('aria-expanded')).toBe('true')
await deliver({ type: 'callTree', version: 3, key: 'state|1', name: 'setOpen', where: 'Panel.tsx:3', trees: TREES })
expect(selectedTab()).toBe('读取用户资料1处')
expect(saved?.callTree).toEqual({ key: 'state|1', entry: 0 })
})
it('记着的弹框是某一行的操作:页面重新加载后照样打开;卡片上没有这个键的操作时不开', async () => {
saved = { editing: [], folds: {}, callTree: { key: 'state|1', entry: 0 } }
await load()
await deliver(documentOf(3, [stateCard()]))
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, key: 'state|1' })
await unload()
saved = { editing: [], folds: {}, callTree: { key: 'state|7', entry: 0 } }
await load()
await deliver(documentOf(3, [stateCard()]))
expect(dialog()).toBeNull()
expect(host.posted).not.toContainEqual(expect.objectContaining({ type: 'openCallTree' }))
})
})
describe('问 AI 和重写注释各按各的能不能用', () => {
const DOC = documentOf(1, [{ ...block('0', 'rglob'), commentTarget: 'replace' }])
const qaMessage = (rewritable: boolean): ExtensionToWebview => ({ type: 'qa', available: true, rewritable, threads: [], path: [], subject: 'file', current: [], rewrites: [], rewriteCurrent: [] })
+29 -12
View File
@@ -23,6 +23,7 @@ import { pageLayout } from './pageLayout'
import { createPageStore } from './pageState'
import { NO_PLACEMENT, placementOf } from './placement'
import { chatOf, PATH_ASK_ANCHOR, sameChat } from './qa'
import { hasAction } from './rowActions'
import { scrollOwner } from './scrollOwner'
import { TextMarksContext } from './SegmentText'
import { shareBlocks, shareUnchanged } from './sharing'
@@ -45,12 +46,28 @@ import { onMessage, postMessage, viewState } from './vscode'
const NONE: ReadonlySet<string> = new Set()
/** 打开 doc 里 anchor 那块的调用树弹框,先是「正在查」,等扩展端发树来;这块不在了、或者它看不了调用树时是 undefined。 */
function openingCall(doc: DocumentMessage, anchor: string, entry: number): OpenCall | undefined {
const block = blockAt(doc.blocks, anchor)
if (!block?.callTree)
/**
* 打开 doc 里键是 key 的调用树弹框,先是「正在查」,等扩展端发树来;弹框按它所在那块的种类上色。
* 它所在的块不在了、那块看不了这个调用树时是 undefined。
*/
function openingCall(doc: DocumentMessage, key: string, entry: number): OpenCall | undefined {
const block = callOwner(doc.blocks, key)
if (!block)
return undefined
return { anchor, version: doc.version, entry, status: 'loading', name: '', where: '', kind: block.kind, ...(block.badge && { badge: block.badge }), trees: [] }
return { key, version: doc.version, entry, status: 'loading', name: '', where: '', kind: block.kind, ...(block.badge && { badge: block.badge }), trees: [] }
}
/**
* 键是 key 的调用树在哪块上:块自己的(键就是块的记号,块能看调用树),或块属性表里一个操作的
* (键是块的记号接 `|` 和序号,那块上有这个键的操作,见 [hasAction])。
*/
function callOwner(blocks: readonly BlockView[], key: string): BlockView | undefined {
const own = blockAt(blocks, key)
if (own?.callTree)
return own
const cut = key.lastIndexOf('|')
const block = cut >= 0 ? blockAt(blocks, key.slice(0, cut)) : undefined
return block && hasAction(block, key) ? block : undefined
}
function blockAt(blocks: readonly BlockView[], anchor: string): BlockView | undefined {
@@ -150,13 +167,13 @@ export function App(): ReactElement | null {
}),
onFocused: () => page.update({ focusId: null }),
},
onOpenCall: (anchor) => {
onOpenCall: (key) => {
const current = page.get().doc
const opening = current && openingCall(current, anchor, 0)
const opening = current && openingCall(current, key, 0)
if (!opening)
return
page.update({ openCall: opening })
postMessage({ type: 'openCallTree', version: opening.version, anchor })
postMessage({ type: 'openCallTree', version: opening.version, key })
},
qa,
rewrite,
@@ -170,7 +187,7 @@ export function App(): ReactElement | null {
restoreCall.current = undefined
// 指纹没变的块还是原来的对象:没变的卡片不重画
const next = { ...msg, blocks: shareBlocks(page.get().doc?.blocks, msg.blocks) }
const reopened = restoring && openingCall(next, restoring.anchor, restoring.entry)
const reopened = restoring && openingCall(next, restoring.key, restoring.entry)
marks.set(current => ({ ...current, names: nameMap(msg.names), placement: placementOf(next.blocks) }))
page.update(current => ({
doc: next,
@@ -180,7 +197,7 @@ export function App(): ReactElement | null {
...(msg.reveal && { reveal: msg.reveal }),
}))
if (reopened)
postMessage({ type: 'openCallTree', version: msg.version, anchor: reopened.anchor })
postMessage({ type: 'openCallTree', version: msg.version, key: reopened.key })
else if (restoring)
viewState.update({ callTree: undefined })
return
@@ -194,7 +211,7 @@ export function App(): ReactElement | null {
if (msg.version !== page.get().doc?.version)
return
page.update(({ openCall: prev }) => ({
openCall: !prev || prev.anchor !== msg.anchor || prev.version !== msg.version
openCall: !prev || prev.key !== msg.key || prev.version !== msg.version
? prev
: { ...prev, status: 'ready', name: msg.name, where: msg.where, trees: msg.trees, contractMiss: msg.contractMiss },
}))
@@ -236,7 +253,7 @@ export function App(): ReactElement | null {
useEffect(() => {
if (restoreCall.current)
return
viewState.update({ callTree: openCall ? { anchor: openCall.anchor, entry: openCall.entry } : undefined })
viewState.update({ callTree: openCall ? { key: openCall.key, entry: openCall.entry } : undefined })
}, [openCall])
if (!doc)
@@ -9,7 +9,7 @@ function render(editing: boolean, ask?: { asking: boolean }, rewrite?: { rewriti
onToggleEdit={() => {}}
{...(ask && { asking: ask.asking, onToggleAsk: () => {} })}
{...(rewrite && { rewrite: { rewriting: rewrite.rewriting, fresh: rewrite.fresh ?? false, reopen: rewrite.reopen ?? false, onRewrite: () => {} } })}
{...(callTree && { callTree: { anchor: 'fn', count: callTree.count, open: false, onOpen: () => {} } })}
{...(callTree && { callTree: { key: 'fn', count: callTree.count, open: false, onOpen: () => {} } })}
/>,
)
}
+32 -17
View File
@@ -29,8 +29,8 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite
onToggleAsk?: () => void
/** 没给就不显示「按注释规范重写」(不能写注释的块、工作区外的库文件) */
rewrite?: RewriteTool
/** 没给就不显示调用树。count 是后台数出的棵数,大于 0 时按钮上带这个数字,是 0 时只有图标(还没数到、没有调用者,点开时会当场再问) */
callTree?: { anchor: string, count: number, open: boolean, onOpen: () => void }
/** 没给就不显示调用树。key 是块的记号,count 是后台数出的棵数(见 [CallTreeButton]) */
callTree?: { key: string, count: number, open: boolean, onOpen: () => void }
}): ReactElement {
const label = editing ? '预览' : '编辑'
const Icon = editing ? Eye : Pencil
@@ -73,21 +73,36 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite
>
<Icon size={14} strokeWidth={1.8} aria-hidden />
</button>
{callTree && (
<button
type="button"
className={cn('cr-tool flex h-5 items-center justify-center rounded', callTree.count > 0 ? 'gap-px px-1' : 'w-6')}
data-call-tree={callTree.anchor}
data-active={callTree.open ? '' : undefined}
data-tooltip="调用树"
aria-label="调用树"
aria-expanded={callTree.open}
onClick={callTree.onOpen}
>
<GitBranch size={14} strokeWidth={1.8} aria-hidden />
{callTree.count > 0 && <span className="cr-call-count">{callTree.count}</span>}
</button>
)}
{callTree && <CallTreeButton callKey={callTree.key} label="调用树" count={callTree.count} open={callTree.open} onOpen={callTree.onOpen} />}
</div>
)
}
/**
* 看调用树的按钮:块右上角的「调用树」,属性表里一行的操作(「谁用了这个状态」)都用它。只有图标,名字写在悬停提示和读屏用的 aria-label 里;
* count 是后台数出的次数,大于 0 时按钮上带这个数字,是 0 时只有图标(还没数到、没有调用者,点开时会当场再问);弹框开着时带按下的底色。
*/
export function CallTreeButton({ callKey, label, count, open, onOpen }: {
/** 这个调用树的键(块的记号,或操作的键),记在 data-call-tree 上 */
callKey: string
label: string
count: number
open: boolean
onOpen: () => void
}): ReactElement {
return (
<button
type="button"
className={cn('cr-tool flex h-5 shrink-0 items-center justify-center rounded', count > 0 ? 'gap-px px-1' : 'w-6')}
data-call-tree={callKey}
data-active={open ? '' : undefined}
data-tooltip={label}
aria-label={label}
aria-expanded={open}
onClick={onOpen}
>
<GitBranch size={14} strokeWidth={1.8} aria-hidden />
{count > 0 && <span className="cr-call-count">{count}</span>}
</button>
)
}
@@ -3,6 +3,8 @@
import type { BlockTreeHandle, BlockTreeState, RewriteTree } from './BlockTree'
import type { Ask, ChatState } from './qa'
import type { BlockView, KindBadge } from './types'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { fileAttributes } from '../../../src/codeReader/attributes'
@@ -151,13 +153,64 @@ describe('路由筛选后展开函数体', () => {
})
describe('库规则卡片的淡底', () => {
it('卡片给了 tint 时淡底用它、描边仍是种类色(React 钩子卡片按来源分底色):两个颜色都写在卡片上', () => {
const tinted: BlockView = { ...view('0', 'react.hook', 'useRouter'), badge: BADGE, tint: { dark: '#e5c07b', light: '#9a6a00' } }
expect(markup([tinted], tree())).toContain('data-kind="react.hook" data-badge="" data-tint="" style="--kind-dark:#61dafb;--kind-light:#0a7ea4;--tint-dark:#e5c07b;--tint-light:#9a6a00"')
it('卡片给了 tint 时淡底用它、描边仍是种类色(React 钩子返回值按来源分底色):两个颜色都写在卡片上', () => {
const tinted: BlockView = { ...view('0', 'react.hookValue', 'useRouter'), badge: BADGE, tint: { dark: '#e5c07b', light: '#9a6a00' } }
expect(markup([tinted], tree())).toContain('data-kind="react.hookValue" data-badge="" data-tint="" style="--kind-dark:#61dafb;--kind-light:#0a7ea4;--tint-dark:#e5c07b;--tint-light:#9a6a00"')
expect(markup([{ ...tinted, tint: undefined }], tree())).not.toContain('data-tint')
})
})
describe('卡片里收起来的函数小框', () => {
/** React 记忆值卡片:「计算」一行是收起来的函数,函数体(一张返回卡片)是卡片下面那一层的子块 */
const memo = (children: BlockView[]): BlockView => ({
...view('0', 'react.memo', ''),
badge: BADGE,
properties: [
{ label: '名称', value: [{ text: 'visible', role: 'variable' }] },
{ label: '计算', value: [{ style: 'fold', label: '函数', segments: [{ text: '· 1 句', role: 'hint' }] }] },
],
children,
})
const RETURN = view('0.0', 'return', '返回 results.filter(…)')
const chip = (html: string): string => html.match(/<button[^>]*data-style="fold"[^>]*>/)?.[0] ?? ''
it('卡片有下面那一层的子块时,小框能点、和卡片的折叠箭头同开同关', () => {
const closed = chip(markup([memo([RETURN])], tree({ collapsed: new Set(['0']) })))
expect(closed).toContain('aria-expanded="false"')
expect(closed).toContain('aria-label="展开函数"')
expect(closed).not.toContain('disabled')
const open = chip(markup([memo([RETURN])], tree({ collapsed: new Set() })))
expect(open).toContain('aria-expanded="true"')
expect(open).toContain('data-open=""')
})
it('点小框切换的是卡片自己的折叠', async () => {
const toggled: string[] = []
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
await act(async () => root.render(<BlockTree blocks={[memo([RETURN])]} tree={{ ...tree({ collapsed: new Set(['0']) }), onToggleFold: id => toggled.push(id) }} />))
await act(async () => container.querySelector<HTMLButtonElement>('[data-style="fold"]')!.click())
expect(toggled).toEqual(['0'])
await act(async () => root.unmount())
container.remove()
vi.unstubAllGlobals()
})
it('卡片没有子块、子块都挂在子行上时,小框只是不能点的小框,也不沿用外层卡片的开关', () => {
expect(chip(markup([memo([])], tree()))).toContain('disabled')
const slotted = view('0.0', 'demo.method', '', { slot: { row: 0, item: 0 } })
expect(chip(markup([memo([slotted])], tree()))).toContain('disabled')
// 挂在外层卡片子行上的子卡片画在外层卡片的属性表里,外层卡片自己有下面那一层的子块
const inner: BlockView = { ...memo([]), id: '0.0', slot: { row: 1, item: 0 } }
const outer: BlockView = { ...CARD, children: [inner, view('0.2', 'statement', '留下的语句')] }
const html = markup([outer], tree({ collapsed: new Set() }))
expect(html).toContain('计算')
expect(chip(html)).toContain('disabled')
})
})
describe('挂在卡片子行上的子块', () => {
it('默认不画,子行的名称是展开它的按钮;卡片收着时留下的子块不画', () => {
const html = render({})
+69 -10
View File
@@ -6,12 +6,14 @@ import type { Place, Placement } from './placement'
import type { ChainSlots, GridSlots } from './PropertyGrid'
import type { ChatState, ChatView, QaState } from './qa'
import type { PendingImage } from './qaImages'
import type { RowActions } from './rowActions'
import type { ReadableStore } from './store'
import type { BlockMarks, BlockView, CardView, ChangedBlock } from './types'
import { ChevronDown, ChevronRight, ChevronsUpDown, EyeOff } from 'lucide-react'
import { createContext, Fragment, memo, useContext, useMemo, useState } from 'react'
import { AskThread, REWRITE_THREAD } from './AskThread'
import { BlockTools } from './BlockTools'
import { ChainFoldContext } from './chainFold'
import { CodeEditor } from './CodeEditor'
import { DiffText } from './DiffText'
import { removedTitle } from './diffView'
@@ -19,6 +21,7 @@ import { estimatedHeight } from './itemHeight'
import { KindIcon } from './kindIcons'
import { PropertyGrid, PropertyItems, PropertyTable } from './PropertyGrid'
import { chatOf, EMPTY_CHAT, sameChat, unfinished } from './qa'
import { cardActions, RowActionsContext } from './rowActions'
import { BlockIdContext } from './SegmentText'
import { shallowEqual, staticStore, useStore } from './store'
import { TextPreview } from './TextPreview'
@@ -36,10 +39,13 @@ export interface EditState {
focusId: string | null
}
/** 函数、类落在几棵调用树里,开着哪一块的弹框。定义浮窗没有,跟问 AI 一样不画这排按钮。 */
/**
* 各个调用树的次数,开着哪个的弹框,都按调用树的键:块自己的(函数、类)是块的记号,卡片属性表里的操作是操作的键
* (见 [CallTreeAction.key](vscode/webview-ui/src/codeReader/types.ts#CallTreeAction))。定义浮窗没有,跟问 AI 一样不画这些按钮。
*/
export interface CallTreeState {
counts: ReadonlyMap<string, number>
openAnchor?: string
openKey?: string
}
/**
@@ -80,8 +86,8 @@ export interface BlockTreeHandle {
onOpenRun: (id: string) => void
/** 编辑:点「编辑 / 预览」、光标放进去之后。和 state.edit 同时有 */
edit?: { onToggleEdit: (id: string) => void, onFocused: () => void }
/** 打开一块的调用树弹框。和 state.callTree 同时有 */
onOpenCall?: (anchor: string) => void
/** 打开键是 key 的调用树弹框(块的记号,或卡片上一个操作的键)。和 state.callTree 同时有 */
onOpenCall?: (key: string) => void
/** 问 AI;问答没地方存(没打开工作区文件夹)时 `state` 里 [ChatState.available] 是 `false` */
qa?: QaTree
/** 按注释规范重写;工作区外的库文件不能重写,`state` 里 [ChatState.available] 是 `false` */
@@ -229,6 +235,10 @@ interface BlockShape {
/** 调用树:扩展端标了能看调用树(BlockView.callTree)的块才有棵数 */
calls?: number
callOpen: boolean
/** 卡片属性表里各个操作的次数(按 [cardActions] 的顺序,逗号连起来,比得了相等);没有调用树状态、卡片没有操作时没有 */
actionCounts?: string
/** 开着的调用树是这张卡片上的某个操作时,是它的键 */
actionOpen?: string
}
function shapeOf(block: BlockView, state: BlockTreeState): BlockShape {
@@ -250,7 +260,32 @@ function shapeOf(block: BlockView, state: BlockTreeState): BlockShape {
sourceText: editing ? state.edit?.sourceText : undefined,
place: editing ? state.edit?.placement.of(block.id) : undefined,
calls: state.callTree && block.callTree ? state.callTree.counts.get(block.anchor) ?? 0 : undefined,
callOpen: state.callTree?.openAnchor === block.anchor,
callOpen: state.callTree?.openKey === block.anchor,
...actionShape(block, state.callTree),
}
}
/** 卡片属性表里各个操作的次数、开着的是哪个(见 [BlockShape.actionCounts]);没有调用树状态、卡片没有操作时都没有。 */
function actionShape(block: BlockView, callTree: CallTreeState | undefined): Pick<BlockShape, 'actionCounts' | 'actionOpen'> {
const actions = callTree ? cardActions(block) : []
if (!callTree || actions.length === 0)
return {}
const open = actions.find(action => action.key === callTree.openKey)?.key
return {
actionCounts: actions.map(action => callTree.counts.get(action.key ?? '') ?? 0).join(','),
...(open !== undefined && { actionOpen: open }),
}
}
/** 卡片交给属性表里操作按钮的状态和动作(见 [RowActions]):次数按 [BlockShape.actionCounts] 对回各个键;没有次数(没有调用树状态、卡片没有操作)时没有。 */
function rowActionsOf(block: BlockView, actionCounts: string | undefined, actionOpen: string | undefined, onOpenCall: ((key: string) => void) | undefined): RowActions | undefined {
if (actionCounts === undefined || !onOpenCall)
return undefined
const counts = actionCounts.split(',').map(Number)
return {
counts: new Map(cardActions(block).map((action, i) => [action.key ?? '', counts[i] ?? 0])),
...(actionOpen !== undefined && { openKey: actionOpen }),
onOpen: onOpenCall,
}
}
@@ -421,11 +456,27 @@ export function BlockLevel({ blocks, owner, slotted = false, items = false }: {
* 译文下面依次接着拆开的各项(跨几行写的字面量,排法同变量初始值下面的)、摊开实参的表(跨几行写的调用)、说明、几行属性(「注释」一行)。
* folds 是这张卡片里折叠着的(见 foldsByCard);标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。
* children 是这块的子块,挂在子行上的画在属性表里(删掉的块不画子块,没有)。
* 卡片有排在它下面那一层的子块时(不挂在子行上、不挂在链的某一步下面),内容里收起来的函数小框(React 钩子收的函数参数)和卡片的折叠箭头是同一个开关
* (见 [ChainFoldContext]);没有时小框不能点,也不沿用外层卡片的开关。
*/
const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { block: CardView, children?: BlockView[], folds: ReadonlySet<string>, marks?: BlockMarks }): ReactElement => {
const tree = useBlockTree()
const slots = useMemo(() => cardSlots(block, children, folds, tree), [block, children, folds, tree])
const chain = useMemo(() => chainSlots(block, children, folds, tree), [block, children, folds, tree])
const open = !folds.has(block.id)
const below = children.some(child => !inCard(child))
const fold = useMemo(() => below ? { open, onToggle: () => tree.onToggleFold(block.id) } : undefined, [below, open, tree, block.id])
return (
<ChainFoldContext value={fold}>
<PreviewBody block={block} folds={folds} marks={marks} slots={slots} chain={chain} />
</ChainFoldContext>
)
})
BlockPreview.displayName = 'BlockPreview'
/** 预览状态的内容本身(见 [BlockPreview]):属性表,或一行译文连同下面那几样。 */
function PreviewBody({ block, folds, marks, slots, chain }: { block: CardView, folds: ReadonlySet<string>, marks?: BlockMarks, slots?: GridSlots, chain?: ChainSlots }): ReactElement {
const tree = useBlockTree()
if ('properties' in block)
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={folds} onToggleFold={tree.onToggleFold} marks={marks?.rows} slots={slots} chain={chain} />
const folding = { collapsed: folds, onToggleFold: tree.onToggleFold }
@@ -452,8 +503,7 @@ const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { bl
)}
</>
)
})
BlockPreview.displayName = 'BlockPreview'
}
/**
* 挂在卡片子行上的子块(BlockSlot)怎么画:一行下面展开着的几块画成一层块(同块树);点那个子行的第一格展开它、
@@ -501,6 +551,8 @@ function BlockCard({ block, shape, ask, rewrite }: { block: BlockView, shape: Bl
const tree = useBlockTree()
const { edit, onOpenCall } = tree
const { anchor } = block
const { actionCounts, actionOpen } = shape
const actions = useMemo(() => rowActionsOf(block, actionCounts, actionOpen, onOpenCall), [block, actionCounts, actionOpen, onOpenCall])
const collapsed = shape.folds.has(block.id)
const toggled = block.slot !== undefined
const Chevron = collapsed && !toggled ? ChevronRight : ChevronDown
@@ -544,7 +596,11 @@ function BlockCard({ block, shape, ask, rewrite }: { block: BlockView, shape: Bl
onExit={() => edit.onToggleEdit(block.id)}
/>
)
: <BlockPreview block={block} children={block.children} folds={shape.folds} marks={shape.current ? change?.marks : undefined} />}
: (
<RowActionsContext value={actions}>
<BlockPreview block={block} children={block.children} folds={shape.folds} marks={shape.current ? change?.marks : undefined} />
</RowActionsContext>
)}
</div>
{shape.temporary && (
<span className="cr-temporary flex h-5 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] whitespace-nowrap">
@@ -559,7 +615,7 @@ function BlockCard({ block, shape, ask, rewrite }: { block: BlockView, shape: Bl
asking={ask.asking}
onToggleAsk={tree.qa && ask.asking !== undefined ? () => tree.qa?.onToggle(anchor) : undefined}
rewrite={tree.rewrite && tool && { ...tool, onRewrite: () => tool.reopen ? tree.rewrite?.onOpen(anchor) : tree.rewrite?.onRewrite(anchor) }}
callTree={onOpenCall && shape.calls !== undefined ? { anchor, count: shape.calls, open: shape.callOpen, onOpen: () => onOpenCall(anchor) } : undefined}
callTree={onOpenCall && shape.calls !== undefined ? { key: anchor, count: shape.calls, open: shape.callOpen, onOpen: () => onOpenCall(anchor) } : undefined}
/>
)}
</div>
@@ -602,7 +658,10 @@ function RemovedCard({ placed }: { placed: PlacedRemoved }): ReactElement {
</div>
{open && (
<div className="cr-removed-old mt-1">
<BlockPreview block={old} folds={folds} />
{/* 旧内容里的操作只是看看,不画按钮 */}
<RowActionsContext value={undefined}>
<BlockPreview block={old} folds={folds} />
</RowActionsContext>
</div>
)}
</div>
@@ -49,8 +49,8 @@ function block(kind: string, id = kind, callTree?: BlockView['callTree']): Block
}])[0]
}
/** 块树:callTree 是各块的棵数、开着哪一块的弹框和点按钮时做什么;edit 是 false 时是只读的树 */
function tree(callTree?: CallTreeState & { onOpen: (anchor: string) => void }, edit = true): BlockTreeHandle {
/** 块树:callTree 是各个调用树的次数、开着哪个的弹框和点按钮时做什么;edit 是 false 时是只读的树 */
function tree(callTree?: CallTreeState & { onOpen: (key: string) => void }, edit = true): BlockTreeHandle {
return {
state: staticStore(treeState({
...(edit && { edit: { version: 1, sourceText: { source: '', colors: { roles: [], runs: [] } }, placement: NO_PLACEMENT, editing: new Set<string>(), focusId: null } }),
@@ -58,7 +58,7 @@ function tree(callTree?: CallTreeState & { onOpen: (anchor: string) => void }, e
hidden: new Set(),
temporary: new Set(),
covered: new Map(),
...(callTree && { callTree: { counts: callTree.counts, ...(callTree.openAnchor !== undefined && { openAnchor: callTree.openAnchor }) } }),
...(callTree && { callTree: { counts: callTree.counts, ...(callTree.openKey !== undefined && { openKey: callTree.openKey }) } }),
})),
onToggleFold: () => {},
onSetFolds: () => {},
@@ -123,7 +123,7 @@ describe('调用树弹框', () => {
<>
<BlockTree
blocks={[card]}
tree={tree({ counts: new Map([['mask', 3]]), openAnchor: open ? 'mask' : undefined, onOpen: () => setOpen(true) })}
tree={tree({ counts: new Map([['mask', 3]]), openKey: open ? 'mask' : undefined, onOpen: () => setOpen(true) })}
/>
{open && (
<CallTreeDialog
@@ -2,7 +2,8 @@
import type { Root } from 'react-dom/client'
import type { BlockKind, CallFacts } from '../../../src/codeReader/contract'
import type { PropertyRow } from './types'
import type { RowActions } from './rowActions'
import type { CallTreeAction, PropertyRow } from './types'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -11,6 +12,7 @@ import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { PropertyGrid } from './PropertyGrid'
import { RowActionsContext } from './rowActions'
import { propertyRowId } from './viewState'
const py = createPythonAdapter(WASM_DIR)
@@ -123,3 +125,67 @@ describe('拆开的列表', () => {
expect(container.querySelector(`[data-id="${row}/0/0/t/0"]`)).not.toBeNull()
})
})
describe('属性表上各行的操作', () => {
const action = (key: string, label: string): CallTreeAction => ({ kind: 'callTree', name: { startIndex: 0, endIndex: 4 }, mode: 'references', label, key })
/** 数组解构的「名称」一行:两项各带一个操作,第三项(嵌套的解构)没有 */
const destructured: PropertyRow = {
label: '名称',
value: [{ text: '[open, setOpen]', role: 'plain' }],
columns: ['第几项', '名称', '说明'],
items: [
{ cells: [[{ text: '1', role: 'hint' }], [{ text: 'open', role: 'variable' }], [{ text: '当前值', role: 'plain' }]], actions: [action('s|0', '谁用了这个状态')] },
{ cells: [[{ text: '2', role: 'hint' }], [{ text: 'setOpen', role: 'function' }], [{ text: '修改函数', role: 'plain' }]], actions: [action('s|1', '谁改了这个状态')] },
{ cells: [[{ text: '3', role: 'hint' }], [{ text: '{ a }', role: 'plain' }], []] },
],
}
/** 只接一个名字的「名称」一行,操作在行上 */
const single: PropertyRow = { label: '名称', value: [{ text: 'router', role: 'variable' }], actions: [action('r|0', '谁用了')] }
async function renderWith(rows: PropertyRow[], actions: RowActions | undefined): Promise<void> {
await act(async () => root.render(
<RowActionsContext value={actions}>
<PropertyGrid blockId="0" rows={rows} collapsed={new Set()} onToggleFold={() => {}} />
</RowActionsContext>,
))
}
const buttonOf = (key: string): HTMLButtonElement | null => container.querySelector<HTMLButtonElement>(`[data-call-tree="${key}"]`)
it('表格多出最后一列「操作」:各行的按钮在这一格里,次数大于 0 带数字、0 只有图标,悬停写操作的说法;没有操作的行空着', async () => {
await renderWith([destructured], { counts: new Map([['s|0', 3]]), onOpen: () => {} })
const heads = [...container.querySelectorAll('[role="columnheader"]')].map(head => head.textContent)
expect(heads).toEqual(['第几项', '名称', '说明', '操作'])
expect(container.querySelector<HTMLElement>('.cr-table')!.style.gridTemplateColumns).toMatch(/ max-content$/)
const rows = [...container.querySelectorAll('.cr-table-row')]
expect(rows.map(row => row.querySelectorAll('[role="cell"]').length)).toEqual([4, 4, 4])
expect(rows[0].querySelector('.cr-table-ops [data-call-tree="s|0"]')?.textContent).toBe('3')
expect(buttonOf('s|1')?.textContent).toBe('')
expect(buttonOf('s|1')?.getAttribute('data-tooltip')).toBe('谁改了这个状态')
expect(buttonOf('s|1')?.getAttribute('aria-label')).toBe('谁改了这个状态')
expect(rows[2].querySelector('.cr-table-ops')?.childElementCount).toBe(0)
})
it('点按钮按操作的键打开;开着的那个带按下的样子', async () => {
const opened: string[] = []
await renderWith([destructured], { counts: new Map(), openKey: 's|1', onOpen: key => opened.push(key) })
await act(async () => buttonOf('s|0')!.click())
expect(opened).toEqual(['s|0'])
expect(buttonOf('s|1')?.hasAttribute('data-active')).toBe(true)
expect(buttonOf('s|0')?.hasAttribute('data-active')).toBe(false)
})
it('卡片没给操作的状态(定义卡片浮窗、删掉的块的旧内容)时没有「操作」一列,也没有按钮', async () => {
await renderWith([destructured, single], undefined)
expect([...container.querySelectorAll('[role="columnheader"]')].map(head => head.textContent)).toEqual(['第几项', '名称', '说明'])
expect(container.querySelector('[data-call-tree]')).toBeNull()
})
it('只接一个名字时操作在「名称」一行的最右边,排在值后面', async () => {
await renderWith([single], { counts: new Map([['r|0', 1]]), onOpen: () => {} })
const row = byId(propertyRowId('0', 0))
const button = row.querySelector('[data-call-tree="r|0"]')!
expect(button.textContent).toBe('1')
expect(before(row.querySelector('[data-find$="value"]')!, button)).toBe(true)
})
})
@@ -1,10 +1,12 @@
import type { CSSProperties, ReactElement, ReactNode } from 'react'
import type { BlockView, ChainSlot, ChainStep, Inline, ItemDiff, ItemsDiff, KindBadge, PreviewText, PropertyItem, PropertyRow, Removed, RowDiff, RowsDiff, TextDiff } from './types'
import type { BlockView, ChainSlot, ChainStep, Inline, ItemDiff, ItemsDiff, KindBadge, PreviewText, PropertyItem, PropertyRow, Removed, RowAction, RowDiff, RowsDiff, TextDiff } from './types'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { Fragment } from 'react'
import { Fragment, useContext } from 'react'
import { cn } from '@/lib/utils'
import { CallTreeButton } from './BlockTools'
import { ChainFoldContext } from './chainFold'
import { DiffInline, DiffText } from './DiffText'
import { RowActionsContext } from './rowActions'
import { inlineText } from './segments'
import { SegmentText } from './SegmentText'
import { subRowsLayout, tableCells, tableTemplate } from './subRows'
@@ -134,6 +136,24 @@ function ItemHead({ id, cell, foldable, folding, find }: { id: string, cell: Pre
)
}
/**
* 一行能做的操作([RowAction]),按钮从左往右排:现在都是看调用树([CallTreeButton]),悬停写操作自己的说法,次数、开着没有照卡片给的(见 [RowActionsContext])。
* 卡片没给(定义卡片浮窗、删掉的块的旧内容)、操作没有键时不画。
*/
function RowActionButtons({ actions }: { actions: readonly RowAction[] | undefined }): ReactElement | null {
const state = useContext(RowActionsContext)
const keyed = state ? (actions ?? []).flatMap(action => action.key === undefined ? [] : [{ action, key: action.key }]) : []
if (!state || keyed.length === 0)
return null
return (
<span className="cr-row-actions" role="group" aria-label="操作">
{keyed.map(({ action, key }) => (
<CallTreeButton key={key} callKey={key} label={action.label} count={state.counts.get(key) ?? 0} open={state.openKey === key} onOpen={() => state.onOpen(key)} />
))}
</span>
)
}
/** 往右缩进一层、左边一条竖线:一行或一个子行下面的一层子行,列表的一项下面挂的实参表 */
function Nested({ children }: { children: ReactNode }): ReactElement {
return (
@@ -211,6 +231,8 @@ export function PropertyItems({ parentId, items, folding, marks }: { parentId: s
* 行带 table(实参的值里跨几行写的调用)时那张表排在它们后面,往右缩一点。一层层往下套。
* 标差异时(marks)有加行、删行的表每行第一格前多一个记号位(+ / −,没变的空着),加的行淡绿底,删的行放回原来的位置、淡红底划掉。
* 讲某个子块的行(toggles,Rust 实现块的方法表)第一格是按钮,点它在表下面展开那一块,展开时这一行淡淡垫上那一块的种类色。
* 有行带操作([PropertyItem.actions])、卡片又给了操作的状态([RowActionsContext])时,最后多一列「操作」,宽度按按钮最多的那一行,
* 各行的操作按钮在这一格里从左往右排,没有操作的行空着。
*/
export function PropertyTable({ parentId, columns, items, marks, folding, toggles }: {
parentId: string
@@ -224,6 +246,7 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle
}): ReactElement {
// 有加行、删行的表每行第一格前留一个记号位
const signed = marks !== undefined && (marks.removed.length > 0 || Object.values(marks.changed).some(mark => mark.added))
const operable = useContext(RowActionsContext) !== undefined && items.some(item => (item.actions?.length ?? 0) > 0)
/** 一行:删掉的(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)
@@ -255,6 +278,11 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle
</div>
)
: <Fragment key={i}>{cellBox(cell, i, 'cr-table-cell')}</Fragment>)}
{operable && (
<div className="cr-table-cell cr-table-ops" role="cell">
{change !== 'del' && <RowActionButtons actions={item.actions} />}
</div>
)}
</div>
)
}
@@ -262,9 +290,10 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle
.map(({ value, index }) => <Fragment key={`removed-${index}`}>{tableRow(value, `${parentId}/x${index}`, 'del', undefined)}</Fragment>)
return (
<div className="pl-[5px]">
<div className="cr-table" role="table" style={{ gridTemplateColumns: tableTemplate(columns.length) }}>
<div className="cr-table" role="table" style={{ gridTemplateColumns: operable ? `${tableTemplate(columns.length)} max-content` : tableTemplate(columns.length) }}>
<div className="cr-table-head" role="row">
{columns.map((name, i) => <span key={i} className="cr-table-cell" role="columnheader" data-find={textKey(parentId, `h${i}`)}>{name}</span>)}
{operable && <span className="cr-table-cell" role="columnheader">操作</span>}
</div>
{removed(-1)}
{items.map((item, index) => {
@@ -283,7 +312,8 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle
/**
* 函数、类、枚举、导入、常量、变量块和库规则的卡片的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行,
* 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。
* 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。一行带操作([PropertyRow.actions],React 卡片只接一个名字时的「名称」一行)时,
* 按钮排在这一行最右边(见 [RowActionButtons])。
* 标差异时(marks)新加的行淡绿底、左边 +,删掉的行放回原来的位置、淡红底划掉、左边 −,值改了的按旧值 → 新值或逐词标。
*/
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, slots, chain }: {
@@ -334,14 +364,16 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl
</button>
)
: <span className="cr-prop-label flex h-5 items-center" data-find={textKey(id, 'label')}>{row.label}</span>}
{row.chain
? (
<div className="min-w-0">
<RowValue row={row} mark={mark} id={id} />
<ChainRail blockId={blockId} row={index} steps={row.chain} folding={folding} slots={chain} />
</div>
)
: <RowValue row={row} mark={mark} id={id} />}
<WithActions actions={row.actions}>
{row.chain
? (
<div className="min-w-0">
<RowValue row={row} mark={mark} id={id} />
<ChainRail blockId={blockId} row={index} steps={row.chain} folding={folding} slots={chain} />
</div>
)
: <RowValue row={row} mark={mark} id={id} />}
</WithActions>
</div>
{!folded && layout === 'table' && <PropertyTable parentId={id} columns={row.columns ?? []} items={row.items ?? []} marks={mark?.items} folding={folding} toggles={toggles} />}
{!folded && layout === 'list' && <PropertyItems parentId={id} items={row.items ?? []} folding={folding} marks={mark?.items} />}
@@ -354,6 +386,19 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl
)
}
/** 属性行的值,这一行带操作、卡片又给了操作的状态时,操作按钮排在值的最右边;不然只有值。 */
function WithActions({ actions, children }: { actions: readonly RowAction[] | undefined, children: ReactElement }): ReactElement {
const state = useContext(RowActionsContext)
if (!state || !actions?.some(action => action.key !== undefined))
return children
return (
<div className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1">{children}</div>
<RowActionButtons actions={actions} />
</div>
)
}
/**
* 一行的值:着色的词或 Markdown(改了的逐截 / 逐词标);跟着说明(PropertyRow.note,输出参数的 `@return`)时说明接在值后面,
* 同一行放得下就并排,剩的地方不到 12 个字宽时换到下一行。`id` 是这一行的 id,值和说明按它记上记号(见 [textKey]);删掉的行没有。
@@ -140,8 +140,9 @@ function GroupBox({ group }: { group: SegmentGroup }): ReactElement {
}
/**
* 链式调用卡片里收起来的闭包、async 块(「闭包 |app| · 37 句」):前面一个折叠箭头,点它展开、收起这一步下面挂着的子块
* (开关是所在那一步给的,见 [ChainFoldContext]);不在链的某一步里时只是一个小框。
* 收起来的一段函数:链式调用卡片里的闭包、async 块(「闭包 |app| · 37 句」),React 钩子卡片里收的函数参数(「函数 · 2 句」)。
* 前面一个折叠箭头,点它展开、收起讲它的子块:链的一步下面挂着的子块,或卡片下面那一层(开关是所在那一步、那张卡片给的,见 [ChainFoldContext]);
* 没有开关时只是一个小框。
*/
function FoldChip({ group }: { group: SegmentGroup }): ReactElement {
const fold = useContext(ChainFoldContext)
@@ -1,13 +1,14 @@
import { createContext } from 'react'
/** 链式调用的一步收起来的闭包眼下展开着没有,点它时怎么切换。 */
/** 收起来的函数小框(fold 框)讲的子块眼下展开着没有,点它时怎么切换。 */
export interface ChainFold {
open: boolean
onToggle: () => void
}
/**
* 链式调用卡片的一步交给它值里那几个 fold 框(收起来的闭包、async 块)的开关;不在链的某一步里(删掉的块的旧内容)时没有,
* fold 框只是一个不能点的小框。
* 交给内容里那几个 fold 框的开关。链式调用卡片的一步给它值里收起来的闭包、async 块,开关的是挂在这一步下面的子块;
* 卡片有排在它下面那一层的子块时,卡片给它内容里收起来的函数(React 钩子收的函数参数),开关同卡片的折叠箭头。
* 都不在(删掉的块的旧内容、没有子块的卡片)时没有,fold 框只是一个不能点的小框。
*/
export const ChainFoldContext = createContext<ChainFold | undefined>(undefined)
@@ -717,6 +717,17 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --
.cr-table .cr-table-cell + .cr-table-cell {
border-left: 1px solid var(--cr-table-border);
}
/* 「操作」一列:这一行能做的操作按钮横排,垂直居中(见 PropertyGrid 的 RowActionButtons) */
.cr-table .cr-table-ops {
display: flex;
align-items: center;
}
.cr-row-actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 2px;
}
.cr-table-head .cr-table-cell {
font-size: 12px;
line-height: 20px;
+10 -7
View File
@@ -12,9 +12,12 @@ import { placementOf } from './placement'
import { createStore } from './store'
import { collapsedIds, foldsByCard } from './viewState'
/** 开着的调用树弹框:哪一版文档的哪一块、选中第几个入口,扩展端发来的树 */
/**
* 开着的调用树弹框:哪一版文档的哪个调用树(键:块的记号,或卡片上一个操作的键,见 [CallTreeAction.key](vscode/webview-ui/src/codeReader/types.ts#CallTreeAction))、
* 选中第几个入口,扩展端发来的树;kind、badge 是那块的(弹框按它上色)
*/
export interface OpenCall {
anchor: string
key: string
version: number
entry: number
status: 'loading' | 'ready'
@@ -47,7 +50,7 @@ export interface PageInputs {
onlyChanges: boolean
/** 「只看改动」时点开过的几段没变的块(按第一块的 id),筛选一变就重新折 */
openedRuns: ReadonlySet<string>
/** 后台数出的各块落在几棵调用树里,是哪一版文档的 */
/** 后台数出的各个调用树的次数(按键,见 [OpenCall.key]),是哪一版文档的 */
callCounts: { version: number, counts: Record<string, number> } | undefined
openCall: OpenCall | null
}
@@ -122,7 +125,7 @@ export function createPageStore(initial: Pick<PageInputs, 'editing' | 'folds'>):
editing: ReadonlySet<string>,
focusId: string | null,
counts: ReadonlyMap<string, number>,
openAnchor: string | undefined,
openKey: string | undefined,
collapsed: ReadonlySet<string>,
folds: ReadonlyMap<string, ReadonlySet<string>>,
filter: FilterView,
@@ -131,7 +134,7 @@ export function createPageStore(initial: Pick<PageInputs, 'editing' | 'folds'>):
folded: FoldedRuns | undefined,
): BlockTreeState => ({
edit: { version, sourceText, placement, editing, focusId },
callTree: { counts, ...(openAnchor !== undefined && { openAnchor }) },
callTree: { counts, ...(openKey !== undefined && { openKey }) },
collapsed,
folds,
hidden: filter.hidden,
@@ -152,7 +155,7 @@ export function createPageStore(initial: Pick<PageInputs, 'editing' | 'folds'>):
const editing = editingOf(inputs.editing)
const covered = coveredOf(blocks, editing)
const folded = foldedOf(blocks, changes, inputs.onlyChanges, filter, covered, inputs.openedRuns, inputs.keep)
const openAnchor = inputs.openCall?.version === version ? inputs.openCall.anchor : undefined
const openKey = inputs.openCall?.version === version ? inputs.openCall.key : undefined
return {
...inputs,
changes,
@@ -163,7 +166,7 @@ export function createPageStore(initial: Pick<PageInputs, 'editing' | 'folds'>):
placement,
unshown: unshownOf(filter, folded),
stops: stopsOf(blocks, placement, changes, filter),
tree: treeOf(version, sourceTextOf(doc?.source ?? '', doc?.colors ?? NO_COLORS), placement, editing, inputs.focusId, countsOf(doc?.version, inputs.callCounts), openAnchor, collapsed, foldsOf(collapsed), filter, covered, changes, folded),
tree: treeOf(version, sourceTextOf(doc?.source ?? '', doc?.colors ?? NO_COLORS), placement, editing, inputs.focusId, countsOf(doc?.version, inputs.callCounts), openKey, collapsed, foldsOf(collapsed), filter, covered, changes, folded),
}
}
@@ -0,0 +1,58 @@
import type { CallTreeAction, CardView, PropertyItem, PropertyRow } from './types'
import { createContext } from 'react'
/**
* 一张卡片属性表上各个操作([CallTreeAction])眼下的样子和动作:各自的次数(按操作的键),开着的是哪一个,点开一个。
* 页面上的块树才有;定义卡片浮窗、删掉的块的旧内容没有,不画「操作」一列和行尾的按钮。
*/
export interface RowActions {
counts: ReadonlyMap<string, number>
openKey?: string
onOpen: (key: string) => void
}
/** 卡片交给它属性表里各个操作按钮的 [RowActions];每张卡片都重新给一次,子卡片不沿用父卡片的。 */
export const RowActionsContext = createContext<RowActions | undefined>(undefined)
const known = new WeakMap<CardView, CallTreeAction[]>()
/**
* 一张卡片上带键的各个操作,从上往下:一行先是它自己的,再是它的子行(子行先是自己的,再是下一层子行、套着的表),同扩展端排键的顺序。
* 一张卡片只算一次。
*/
export function cardActions(card: CardView): CallTreeAction[] {
const cached = known.get(card)
if (cached)
return cached
const found: CallTreeAction[] = []
const add = (actions: readonly CallTreeAction[] | undefined): void => {
for (const action of actions ?? []) {
if (action.key !== undefined)
found.push(action)
}
}
const item = (entry: PropertyItem): void => {
add(entry.actions)
entry.items?.forEach(item)
entry.table?.items.forEach(item)
}
const row = (entry: PropertyRow): void => {
add(entry.actions)
entry.items?.forEach(item)
}
if ('properties' in card) {
card.properties.forEach(row)
}
else {
card.items?.forEach(item)
card.table?.items.forEach(item)
card.rows?.forEach(row)
}
known.set(card, found)
return found
}
/** 键是 key 的操作在这张卡片上吗。 */
export function hasAction(card: CardView, key: string): boolean {
return cardActions(card).some(action => action.key === key)
}
+41 -10
View File
@@ -80,6 +80,26 @@ export interface PropertyItem extends Defines {
items?: PropertyItem[]
/** 最后一格的值里那次调用的实参表:表格的行排在最后一格里、文字下面,排成列表的子行排在它下面;总是展开,表里的行又可以带 items、table */
table?: PreviewTable
/** 这一行能做的操作(见 [RowAction]):表格的行排在最后多出来的「操作」一列,从左往右;列表的子行没有这一列,不画 */
actions?: RowAction[]
}
/** 表格一行、属性一行上能做的一个操作,页面画成一个按钮。现在只有看调用树一种([CallTreeAction]) */
export type RowAction = CallTreeAction
/**
* 看调用树(同扩展端 contract 的 CallTreeAction):查源码里一个名字被谁用、被谁调用,按钮上带次数(0 次只画图标),点开是调用树弹框。
* name、mode、except 是扩展端查的时候用的,这边只看 key(对次数、点开哪个调用树)和 label(悬停的字)
*/
export interface CallTreeAction {
kind: 'callTree'
name: SourceRange
mode: 'calls' | 'references'
/** 按钮悬停的字:「谁用了这个状态」「谁改了这个状态」 */
label: string
except?: SourceRange[]
/** 扩展端起的键(块的记号加序号):callTreeCounts 按它给次数,openCallTree 按它打开 */
key?: string
}
/**
@@ -95,6 +115,8 @@ export interface PropertyRow extends Defines {
columns?: string[]
/** 值是一条链式调用的起点时,链的各步:排成一条竖轨道,接在值下面 */
chain?: ChainStep[]
/** 这一行能做的操作(见 [RowAction]):按钮排在这一行最右边 */
actions?: RowAction[]
}
/** 一张带表头和边框的表:列名,每行几列对齐的格子,同 PropertyRow 给了 columns 的子行 */
@@ -137,8 +159,17 @@ export interface BlockConcept {
color?: ThemeColor
}
/** 库规则的种类:筛选栏里叫 label,卡片边框用 color。图标在 concepts 里,不单画这一个。 */
export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName }
/**
* 库规则的种类:筛选栏里叫 label,卡片边框用 color。图标在 concepts 里,不单画这一个;
* tooltip、iconColor 是种类图标悬停的字和颜色,扩展端已经放进 concepts 里了,这边不另外用
*/
export interface KindBadge {
label: string
color: ThemeColor
icon: KindIconName
tooltip?: string
iconColor?: ThemeColor
}
/**
* 块在源码里占的地方,按相对的量记,前面的块变长变短、挪了位置它都不跟着变:
@@ -182,11 +213,10 @@ interface CardViewBase {
}
/**
* 一块能看调用树:接口卡片带上它讲的接口,查的不是块自己的名字时带上那个名字在块里的范围(React 钩子卡片查返回的 `setOpen`);
* 都是扩展端查调用者用的,这边只看有没有 callTree。
* 一块能看调用树,查块自己的名字:接口卡片带上它讲的接口;扩展端查调用者用的,这边只看有没有 callTree。
* 卡片属性表里各行自己查的名字是那一行的操作([CallTreeAction]),不在这里。
*/
export interface CallTreeTarget {
name?: SourceRange
endpoint?: { method: string, path: string, operationIdPrefix?: string, sameRouter?: { method: string, path: string }[] }
}
@@ -580,8 +610,9 @@ export interface CodeColors {
* repoLinkBuiltin:点的不带路径的引用是语言自带、又问不到定义的名字,没打开,改当普通文字显示;symbol、block、file 同 openRepoLink
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了,block、file 同 openRepoLink
* repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在
* callTreeCounts:version 这份文档里,每个能看调用树的块落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理
* callTree:anchor 那块的调用树。trees 空着、又没有 contractMiss 是没有调用它的地方;接口卡片在契约文件里没对上时带 contractMiss。
* callTreeCounts:version 这份文档里各个调用树的次数(只包括大于 0 的),按调用树的键:能看调用树的块(callTree)是块的记号,
* 落在几棵调用树里;卡片属性表里的操作([CallTreeAction])是它的键([CallTreeAction.key])。不是页面上这个版本的不理
* callTree:键是 key 的那个调用树(同 callTreeCounts)。trees 空着、又没有 contractMiss 是没有调用它的地方;接口卡片在契约文件里没对上时带 contractMiss。
* 不是页面上这个版本的不理
*/
/** 点节点跳到哪:文件和位置,行和列从 0 起。 */
@@ -652,7 +683,7 @@ export type ExtensionToWebview
| { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] }
| { type: 'focusPathAsk' }
| { type: 'callTreeCounts', version: number, counts: Record<string, number> }
| { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[], contractMiss?: ContractMiss }
| { type: 'callTree', version: number, key: string, name: string, where: string, trees: CallTree[], contractMiss?: ContractMiss }
/**
* ready:页面准备好了,可以收消息;openedFrom 是页面状态里记着的这个标签页是从哪儿打开的,新开的页面没记过就不带
@@ -684,7 +715,7 @@ export type ExtensionToWebview
* block 是它写在哪块的注释里(见 SymbolReference),从那块的声明起一层层往外找
* checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(references)问哪些找不到。id 标出是哪一次问
* checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问
* openCallTree:打开 anchor 那块的调用树。version 是这块所在的文档版本
* openCallTree:打开键是 key 的调用树(块的记号,或卡片上一个操作的键,同 callTreeCounts)。version 是它所在的文档版本
* goToCallNode:点了调用树上的一个节点,打开 uri 那个文件,跳到 line 行 character 列(见 CallTreeNode.jump)
*/
export type WebviewToExtension
@@ -712,5 +743,5 @@ export type WebviewToExtension
| { type: 'openRepoLink', path?: string, symbol?: string, block?: string, file?: string, lines?: LineRange }
| { type: 'checkRepoLinks', id: number, paths: string[], references: SymbolReference[] }
| { type: 'checkRepoImages', id: number, paths: string[] }
| { type: 'openCallTree', version: number, anchor: string }
| { type: 'openCallTree', version: number, key: string }
| { type: 'goToCallNode', uri: string, line: number, character: number }
@@ -73,11 +73,11 @@ describe('webview 状态', () => {
it('开着的调用树弹框(哪一块、选中第几个入口)页面重新加载后取回,关掉后就没了;形状不对的当没记过', () => {
const storage = memoryStorage()
const store = createViewStateStore(storage)
store.update({ callTree: { anchor: 'mask_phone', entry: 1 } })
expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 1 } })
store.update({ callTree: { key: 'mask_phone', entry: 1 } })
expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: {}, callTree: { key: 'mask_phone', entry: 1 } })
store.update({ callTree: undefined })
expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: {} })
for (const callTree of ['mask_phone', { anchor: 1, entry: 0 }, { anchor: 'mask_phone' }, { anchor: 'mask_phone', entry: -1 }, { anchor: 'mask_phone', entry: 1.5 }])
for (const callTree of ['mask_phone', { key: 1, entry: 0 }, { key: 'mask_phone' }, { key: 'mask_phone', entry: -1 }, { key: 'mask_phone', entry: 1.5 }, { anchor: 'mask_phone', entry: 0 }])
expect(createViewStateStore(memoryStorage({ callTree })).get()).toEqual({ editing: [], folds: {} })
})
@@ -4,7 +4,7 @@ import type { BlockView, ChainSlot, OpenedFrom } from './types'
/**
* 页面重新加载(webview 进程崩了重建、开发者重载 webview)时靠它恢复(标签页切走时页面一直留着,用不上它):哪些块停在「编辑」、
* 用户把哪些块、属性行和子行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行、子行 id 见 [propertyRowId]、[propertyItemId]),滚到了哪,
* 以及开着哪一块的调用树弹框、选中第几个入口。
* 以及开着哪个调用树弹框、选中第几个入口。
* VS Code 重启后恢复标签页时也带着它,这个标签页是从哪儿打开的靠它报回扩展端。
*/
export interface ViewState {
@@ -18,9 +18,9 @@ export interface ViewState {
callTree?: CallTreeView
}
/** 开着的调用树弹框:哪一块(anchor),选中第几个入口(从 0 起)。 */
/** 开着的调用树弹框:哪个调用树(键,同 [OpenCall.key](vscode/webview-ui/src/codeReader/pageState.ts#OpenCall)),选中第几个入口(从 0 起)。 */
export interface CallTreeView {
anchor: string
key: string
entry: number
}
@@ -73,7 +73,7 @@ function readViewState(saved: unknown): ViewState {
}
function isCallTreeView(value: unknown): value is CallTreeView {
return isRecord(value) && typeof value.anchor === 'string' && Number.isInteger(value.entry) && (value.entry as number) >= 0
return isRecord(value) && typeof value.key === 'string' && Number.isInteger(value.entry) && (value.entry as number) >= 0
}
/**