✨ feat(vscode): 代码阅读页面画链式调用卡片:竖轨道、小链、收起的闭包和合成的表
链的各步排在值下面的竖轨道上,方法一列对齐、轨道线是函数色淡到 35%;实参是链的缩进成空心点小链,注释挂在步骤下;收起的闭包是能点的小框,点开那一步才画挂着的子块(折叠 id 记在属性行下面);跳到定义、查找、高度估算、只看改动都算上链里的字和步骤下的子块
This commit is contained in:
1 parent
ee595f3da9
commit
1956879aa1
13 files changed
+531
-47
No files matched your search
@@ -9,9 +9,11 @@ import { fileAttributes } from '../../../src/codeReader/attributes'
|
||||
import { readBlockViews, toBlockViews } from '../../../src/codeReader/blockView'
|
||||
import { pageBlocks } from '../../../src/codeReader/editor/pageBlocks'
|
||||
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
|
||||
import { createRustAdapter } from '../../../src/codeReader/languages/rust'
|
||||
import { fastapiRule } from '../../../src/codeReader/rules/fastapi'
|
||||
import { runtime, WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
|
||||
import { readFixture, runtime, WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
|
||||
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
|
||||
import { rustZhTranslator } from '../../../src/codeReader/translators/rustZh'
|
||||
import { BlockTree, treeState } from './BlockTree'
|
||||
import { applyFilter } from './filter'
|
||||
import { FilterBar } from './FilterBar'
|
||||
@@ -19,7 +21,7 @@ import { NO_PLACEMENT, placementOf } from './placement'
|
||||
import { EMPTY_CHAT } from './qa'
|
||||
import { staticStore } from './store'
|
||||
import { NO_SPAN } from './testBlocks'
|
||||
import { collapsedIds } from './viewState'
|
||||
import { chainStepId, collapsedIds } from './viewState'
|
||||
|
||||
// 编辑框连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
|
||||
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
|
||||
@@ -323,6 +325,81 @@ describe('一行译文下面拆开的各项', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('链式调用卡片', () => {
|
||||
/** 一段 Rust 经过宿主转成的发给页面的块:第一个函数和它函数体里的块 */
|
||||
async function rustBlocks(source: string): Promise<BlockView[]> {
|
||||
const language = createRustAdapter(WASM_DIR)
|
||||
const views = await withBlocks(language, 'rust', source, blocks => toBlockViews(blocks, { source, language, markers: [] }, rustZhTranslator))
|
||||
return pageBlocks(views, source).filter(block => block.kind === 'function')
|
||||
}
|
||||
|
||||
/** 画成页面,函数展开着;open 是点开的那几步(折叠 id) */
|
||||
async function chainPage(source: string, open: string[] = []): Promise<{ root: HTMLElement, chain: BlockView }> {
|
||||
const [fn] = await rustBlocks(source)
|
||||
const folds = Object.fromEntries([[fn.id, false], ...open.map(id => [id, false])])
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = markup([fn], tree({ collapsed: collapsedIds([fn], folds) }))
|
||||
const chain = fn.children.find(child => 'translation' in child && child.rows?.some(row => row.chain))!
|
||||
return { root, chain }
|
||||
}
|
||||
|
||||
it('起点、步骤两行,链的各步排在「步骤」那一行的值下面的竖轨道上:一步一个点、方法和值;合成的一步下面是各次实参的表;写在上面的注释挂在那一步下面', async () => {
|
||||
const { root, chain } = await chainPage(readFixture('rustChain.rs'))
|
||||
const card = root.querySelector(`[data-id="${chain.id}"]`)!
|
||||
expect(card.querySelector('.cr-code')?.closest('.cr-prop-row')).not.toBeNull()
|
||||
const steps = [...card.querySelectorAll('.cr-chain-step')]
|
||||
expect(steps.map(step => step.querySelector('.cr-chain-line')?.textContent)).toEqual([
|
||||
'.plugin4 次',
|
||||
'.setup闭包|app| · 3 句',
|
||||
'.on_window_event闭包|window, event| · 1 句',
|
||||
'.manageapp_state',
|
||||
'.invoke_handlertauri::generate_handler![ device_resume, settings_get, web::preview_open ]',
|
||||
'.buildtauri::generate_context!()',
|
||||
'.expect"error while building tauri application"',
|
||||
'.run闭包|app, event| · match event · 2 支',
|
||||
])
|
||||
expect(steps.every(step => step.querySelector(':scope > .cr-chain-rail > .cr-chain-dot'))).toBe(true)
|
||||
const plugin = steps[0]
|
||||
expect(plugin.querySelector('.cr-chain-label')?.textContent).toBe('注释')
|
||||
expect(plugin.querySelector('.cr-table-head')?.textContent).toBe('实参')
|
||||
expect([...plugin.querySelectorAll('.cr-table-row')].map(row => row.textContent)).toHaveLength(4)
|
||||
expect(card.closest('.cr-prop-row')).toBeNull()
|
||||
})
|
||||
|
||||
it('收起来的闭包是一个按钮,默认收着、闭包体的子块不画;点开那一步(它的折叠 id 展开)后子块画在那一步下面,别的步照旧收着', async () => {
|
||||
const source = readFixture('rustChain.rs')
|
||||
const closed = (await chainPage(source)).root
|
||||
const setupFold = closed.querySelectorAll('.cr-chain-step')[1].querySelector('button[data-style="fold"]')!
|
||||
expect(setupFold.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(setupFold.getAttribute('aria-label')).toBe('展开闭包')
|
||||
expect(closed.textContent).not.toContain('tunnels.json')
|
||||
const { root, chain } = await chainPage(source, [chainStepId(chain0(await rustBlocks(source)), { row: 1, step: 1 })])
|
||||
const [, setup, windowEvent] = root.querySelectorAll('.cr-chain-step')
|
||||
expect(setup.querySelector('button[data-style="fold"]')?.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(setup.querySelector('.cr-chain-blocks')?.textContent).toContain('tunnels.json')
|
||||
expect(windowEvent.querySelector('.cr-chain-blocks')).toBeNull()
|
||||
expect(root.textContent).not.toContain('window.hide')
|
||||
// 挂在步骤下面的子块不再排到卡片后面那一层
|
||||
expect(root.querySelector(`[data-id="${chain.id}"] + .cr-level`)).toBeNull()
|
||||
})
|
||||
|
||||
it('实参本身是链的一步在值下面缩进成一条小链(空心点),一节一行;变量的初始值是起点,链接在下面', async () => {
|
||||
const source = 'fn f() {\n let names = rows.iter().filter(|r| r.active).collect();\n tauri::Builder::default()\n .plugin(\n opener::Builder::new()\n .open(false)\n .build(),\n )\n .manage(state);\n}\n'
|
||||
const { root } = await chainPage(source)
|
||||
const mini = root.querySelector('.cr-chain-mini')!
|
||||
expect([...mini.querySelectorAll('.cr-chain-step')].map(line => line.textContent)).toEqual(['opener::Builder::new()', '.open(false)', '.build()'])
|
||||
expect(mini.querySelectorAll('.cr-chain-dot[data-hollow]')).toHaveLength(3)
|
||||
const value = [...root.querySelectorAll('.cr-prop-row')].find(row => row.textContent?.startsWith('初始值'))!
|
||||
expect(value.querySelector('.cr-code')?.textContent).toBe('rows')
|
||||
expect([...value.querySelectorAll('.cr-chain-line')].map(line => line.textContent)).toEqual(['遍历', '筛选|r| r.active', '收集'])
|
||||
})
|
||||
})
|
||||
|
||||
/** 一个函数体里第一张链式调用卡片的块 id */
|
||||
function chain0(blocks: BlockView[]): string {
|
||||
return blocks[0].children.find(child => 'translation' in child && child.rows?.some(row => row.chain))!.id
|
||||
}
|
||||
|
||||
describe('编辑框', () => {
|
||||
it('编辑着的块:编辑框里是文档源码按它的编辑范围取的整行、去掉共同缩进,着色取自整份源码的着色', async () => {
|
||||
const source = 'def f(x):\n if x:\n return "a"\n'
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SourceText } from './blockSource'
|
||||
import type { RewriteTool } from './BlockTools'
|
||||
import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView'
|
||||
import type { Place, Placement } from './placement'
|
||||
import type { GridSlots } from './PropertyGrid'
|
||||
import type { ChainSlots, GridSlots } from './PropertyGrid'
|
||||
import type { ChatState, ChatView, QaState } from './qa'
|
||||
import type { PendingImage } from './qaImages'
|
||||
import type { ReadableStore } from './store'
|
||||
@@ -22,7 +22,7 @@ import { chatOf, EMPTY_CHAT, sameChat, unfinished } from './qa'
|
||||
import { BlockIdContext } from './SegmentText'
|
||||
import { shallowEqual, staticStore, useStore } from './store'
|
||||
import { TextPreview } from './TextPreview'
|
||||
import { foldsByCard, listId, tableId, textKey } from './viewState'
|
||||
import { chainStepId, foldsByCard, listId, tableId, textKey } from './viewState'
|
||||
|
||||
/** 块树里改代码的那部分状态:哪些块在编辑。页面上的块树才有;定义卡片浮窗里的块树没有,卡片只读,不画右上角的按钮 */
|
||||
export interface EditState {
|
||||
@@ -150,10 +150,15 @@ const NO_CHAT: ReadableStore<ChatState> = staticStore(EMPTY_CHAT)
|
||||
|
||||
/**
|
||||
* 块下面有没有画出来的子块(删掉的子块也算):子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。
|
||||
* 挂在卡片子行上的子块(BlockSlot)画在卡片属性表里,不算。
|
||||
* 挂在卡片子行上的子块(BlockSlot)、挂在链式调用某一步下面的子块(ChainSlot)画在卡片里,不算。
|
||||
*/
|
||||
function hasShownChildren(block: BlockView, state: BlockTreeState): boolean {
|
||||
return block.children.some(child => !child.slot && shown(child, state)) || (!state.covered.has(block.id) && state.diff?.removed.has(block.id) === true)
|
||||
return block.children.some(child => !inCard(child) && shown(child, state)) || (!state.covered.has(block.id) && state.diff?.removed.has(block.id) === true)
|
||||
}
|
||||
|
||||
/** 子块画在父卡片里(属性表子行上的 BlockSlot、链式调用某一步下面的 ChainSlot),不排在父卡片后面那一层。 */
|
||||
function inCard(block: BlockView): boolean {
|
||||
return block.slot !== undefined || block.step !== undefined
|
||||
}
|
||||
|
||||
const CHANGE_NAMES = { add: '新增', mod: '修改', del: '删除' } as const
|
||||
@@ -372,12 +377,12 @@ function Item({ item, height, children }: { item: boolean, height: number, child
|
||||
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。
|
||||
* owner 是这一层所属的块(顶层没有,记在 data-owner 上,钉住卡片时按它算卡片连同子块管到哪、套着几层),悬停它时竖线换成它的种类色。
|
||||
* 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard);「只看改动」时连着没变的块折成一条(UnchangedStrip)。
|
||||
* slotted:这一层是画在卡片属性表某一行下面的子块(BlockSlot),blocks 只有那几块。items:这一层每块套一层 .cr-item(只有页面上的顶层)。
|
||||
* slotted:这一层是画在卡片里的子块(属性表某一行下面的 BlockSlot、链式调用某一步下面的 ChainSlot),blocks 只有那几块。items:这一层每块套一层 .cr-item(只有页面上的顶层)。
|
||||
*/
|
||||
export function BlockLevel({ blocks, owner, slotted = false, items = false }: { blocks: BlockView[], owner?: CardView, slotted?: boolean, items?: boolean }): ReactElement {
|
||||
const tree = useBlockTree()
|
||||
// 挂在卡片子行上的子块画在卡片属性表里(slotted 的那一层),这一层不画;删掉的块都放回卡片后面这一层
|
||||
const listed = useMemo(() => slotted ? blocks : blocks.filter(block => !block.slot), [blocks, slotted])
|
||||
// 挂在卡片子行上、链式调用某一步下面的子块画在卡片里(slotted 的那一层),这一层不画;删掉的块都放回卡片后面这一层
|
||||
const listed = useMemo(() => slotted ? blocks : blocks.filter(block => !inCard(block)), [blocks, slotted])
|
||||
const { removed, diff } = useStore(tree.state, state => ({
|
||||
removed: slotted ? NO_REMOVED : state.diff?.removed.get(owner?.id ?? '') ?? NO_REMOVED,
|
||||
diff: state.diff !== undefined,
|
||||
@@ -418,12 +423,15 @@ export function BlockLevel({ blocks, owner, slotted = false, items = false }: {
|
||||
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])
|
||||
if ('properties' in block)
|
||||
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={folds} onToggleFold={tree.onToggleFold} marks={marks?.rows} slots={slots} />
|
||||
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 }
|
||||
// 整句是链的块译文只剩链外面的词,什么都没剩时不画这一行
|
||||
const empty = Array.isArray(block.translation) && block.translation.length === 0
|
||||
return (
|
||||
<>
|
||||
{marks?.translation ? <DiffText diff={marks.translation} find={textKey(block.id, 'text')} /> : <TextPreview text={block.translation} find={textKey(block.id, 'text')} />}
|
||||
{marks?.translation ? <DiffText diff={marks.translation} find={textKey(block.id, 'text')} /> : !empty && <TextPreview text={block.translation} find={textKey(block.id, 'text')} />}
|
||||
{block.items && (
|
||||
<div className="mt-[3px]">
|
||||
<PropertyItems parentId={listId(block.id)} items={block.items} folding={folding} marks={marks?.items} />
|
||||
@@ -436,8 +444,8 @@ const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { bl
|
||||
)}
|
||||
{marks?.doc ? <DiffText diff={marks.doc} className="mt-0.5" find={textKey(block.id, 'doc')} /> : block.doc && <TextPreview text={block.doc} className="mt-0.5" find={textKey(block.id, 'doc')} />}
|
||||
{block.rows && (
|
||||
<div className="mt-[3px]">
|
||||
<PropertyGrid blockId={block.id} rows={block.rows} collapsed={folds} onToggleFold={tree.onToggleFold} marks={marks?.rows} />
|
||||
<div className={empty ? undefined : 'mt-[3px]'}>
|
||||
<PropertyGrid blockId={block.id} rows={block.rows} collapsed={folds} onToggleFold={tree.onToggleFold} marks={marks?.rows} chain={chain} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -467,6 +475,22 @@ function cardSlots(block: CardView, children: readonly BlockView[], folds: Reado
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂在链式调用某一步下面的子块(ChainSlot)怎么画:一步下面的几块画成一层块(同块树);那一步的折叠 id(chainStepId)没折着就画,
|
||||
* 点那一步收起来的闭包时切换它。卡片没有这样的子块时没有。
|
||||
*/
|
||||
function chainSlots(block: CardView, children: readonly BlockView[], folds: ReadonlySet<string>, tree: BlockTreeHandle): ChainSlots | undefined {
|
||||
const blocks = children.filter(child => child.step)
|
||||
if (blocks.length === 0)
|
||||
return undefined
|
||||
return {
|
||||
blocks,
|
||||
render: list => <BlockLevel blocks={list} owner={block} slotted />,
|
||||
open: slot => !folds.has(chainStepId(block.id, slot)),
|
||||
onToggle: slot => tree.onToggleFold(chainStepId(block.id, slot)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一块的卡片:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools,只读的块树没有)。
|
||||
* 卡片上记着块记号(data-anchor),说明里不带路径的引用靠它认自己写在哪块(见 commentLinkOf)。
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { CSSProperties, ReactElement, ReactNode } from 'react'
|
||||
import type { BlockView, 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, RowDiff, RowsDiff, TextDiff } from './types'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChainFoldContext } from './chainFold'
|
||||
import { DiffInline, DiffText } from './DiffText'
|
||||
import { inlineText } from './segments'
|
||||
import { SegmentText } from './SegmentText'
|
||||
import { subRowsLayout, tableCells, tableTemplate } from './subRows'
|
||||
import { TextPreview } from './TextPreview'
|
||||
import { propertyItemId, propertyRowId, tableId, textKey } from './viewState'
|
||||
import { chainStepId, propertyItemId, propertyRowId, tableId, textKey } from './viewState'
|
||||
|
||||
/** 列表子行里除最后一列以外各列的宽度(字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */
|
||||
const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)']
|
||||
@@ -28,6 +29,17 @@ export interface GridSlots {
|
||||
onToggle: (block: BlockView) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂在卡片链式调用某一步下面的子块(ChainSlot):blocks 是卡片的这些子块,render 把一步下面的几块画成一层块(同块树),
|
||||
* open 是那一步眼下展开着没有,onToggle 是点那一步收起来的闭包时展开 / 收起它。
|
||||
*/
|
||||
export interface ChainSlots {
|
||||
blocks: BlockView[]
|
||||
render: (blocks: BlockView[]) => ReactNode
|
||||
open: (slot: ChainSlot) => boolean
|
||||
onToggle: (slot: ChainSlot) => void
|
||||
}
|
||||
|
||||
/** 表格里一个子行讲的子块:展开着没有,它的种类(展开时这一行淡淡垫上它的颜色),点第一格时怎么切换。 */
|
||||
interface ItemToggle {
|
||||
open: boolean
|
||||
@@ -36,7 +48,7 @@ interface ItemToggle {
|
||||
}
|
||||
|
||||
/** 眼下折叠着哪些属性行、子行,点折叠箭头时切换哪个。 */
|
||||
interface Folding {
|
||||
export interface Folding {
|
||||
collapsed: ReadonlySet<string>
|
||||
onToggleFold: (id: string) => void
|
||||
}
|
||||
@@ -274,7 +286,7 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle
|
||||
* 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。
|
||||
* 标差异时(marks)新加的行淡绿底、左边 +,删掉的行放回原来的位置、淡红底划掉、左边 −,值改了的按旧值 → 新值或逐词标。
|
||||
*/
|
||||
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, slots }: {
|
||||
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, slots, chain }: {
|
||||
blockId: string
|
||||
rows: PropertyRow[]
|
||||
collapsed: ReadonlySet<string>
|
||||
@@ -283,6 +295,8 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl
|
||||
marks?: RowsDiff
|
||||
/** 挂在这张属性表子行上的子块(库规则的卡片),见 GridSlots */
|
||||
slots?: GridSlots
|
||||
/** 挂在某一行链式调用某一步下面的子块,见 ChainSlots */
|
||||
chain?: ChainSlots
|
||||
}): ReactElement {
|
||||
const folding: Folding = { collapsed, onToggleFold }
|
||||
const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after)
|
||||
@@ -320,7 +334,14 @@ 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>}
|
||||
<RowValue row={row} mark={mark} id={id} />
|
||||
{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} />}
|
||||
</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} />}
|
||||
@@ -357,7 +378,10 @@ function RemovedRow({ id, row }: { id: string, row: PropertyRow }): ReactElement
|
||||
<>
|
||||
<div className="cr-prop-row grid grid-cols-subgrid" data-diff="del">
|
||||
<span className="cr-prop-label flex h-5 items-center">{row.label}</span>
|
||||
<RowValue row={row} mark={undefined} />
|
||||
<div className="min-w-0">
|
||||
<RowValue row={row} mark={undefined} />
|
||||
{row.chain && <ChainRail blockId={id} row={0} steps={row.chain} folding={FIXED} />}
|
||||
</div>
|
||||
</div>
|
||||
{layout && (
|
||||
<div className="cr-diff-gone">
|
||||
@@ -369,3 +393,85 @@ function RemovedRow({ id, row }: { id: string, row: PropertyRow }): ReactElement
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 一列着色的词大约占几个等宽字宽:汉字这类全角字算两个。 */
|
||||
function textWidth(pieces: Inline[]): number {
|
||||
let width = 0
|
||||
for (const char of inlineText(pieces))
|
||||
width += /[\u2E80-\uFFEF]/.test(char) ? 2 : 1
|
||||
return width
|
||||
}
|
||||
|
||||
/**
|
||||
* 链式调用的各步排成的竖轨道,接在属性行的值(链的起点)下面:一步一个实心点,点与点之间一条函数色的线;方法那一列按最长的那个对齐,
|
||||
* 值那一列照抄实参,收起来的闭包是能点的小框,点开时这一步挂着的子块(slots)画在这一步下面。
|
||||
* 一步下面依次是小链(空心点,一节一行)、几行属性(写在上面的注释,属性名在方法那一列)、表(合成的一步各次的实参)、展开着的子块。
|
||||
* 删掉的行里画的旧链没有 slots,闭包的小框不能点。
|
||||
*
|
||||
* 
|
||||
*/
|
||||
export function ChainRail({ blockId, row, steps, folding, slots }: {
|
||||
blockId: string
|
||||
/** 链在属性表的第几行 */
|
||||
row: number
|
||||
steps: ChainStep[]
|
||||
folding: Folding
|
||||
slots?: ChainSlots
|
||||
}): ReactElement {
|
||||
const width = Math.max(4, ...steps.map(step => textWidth(step.name)))
|
||||
return (
|
||||
<div className="cr-chain" style={{ '--cr-chain-name': `${width}ch` } as CSSProperties}>
|
||||
{steps.map((step, index) => {
|
||||
const slot = { row, step: index }
|
||||
const id = chainStepId(blockId, slot)
|
||||
const blocks = slots?.blocks.filter(block => block.step?.row === row && block.step.step === index) ?? []
|
||||
const fold = slots && blocks.length > 0 ? { open: slots.open(slot), onToggle: () => slots.onToggle(slot) } : undefined
|
||||
return (
|
||||
<div key={index} className="cr-chain-step" data-id={id}>
|
||||
<ChainDot first={index === 0} last={index === steps.length - 1} />
|
||||
<div className="cr-chain-body">
|
||||
<ChainFoldContext value={fold}>
|
||||
<div className="cr-chain-line">
|
||||
<p className="cr-code cr-chain-name" data-find={textKey(id, 'name')}><SegmentText segments={step.name} /></p>
|
||||
<p className="cr-code min-w-0" data-find={textKey(id, 'value')}><SegmentText segments={step.value} /></p>
|
||||
</div>
|
||||
{step.lines && (
|
||||
<div className="cr-chain-line">
|
||||
<span />
|
||||
<div className="cr-chain-mini">
|
||||
{step.lines.map((line, at) => (
|
||||
<div key={at} className="cr-chain-step">
|
||||
<ChainDot first={at === 0} last={at === (step.lines?.length ?? 0) - 1} hollow />
|
||||
<p className="cr-code min-w-0" data-find={textKey(id, `l${at}`)}><SegmentText segments={line} /></p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ChainFoldContext>
|
||||
{step.rows?.map((item, at) => (
|
||||
<div key={at} className="cr-chain-line">
|
||||
<span className="cr-chain-label" data-find={textKey(id, `r${at}-label`)}>{item.label}</span>
|
||||
<TextPreview text={item.value} className="min-w-0" find={textKey(id, `r${at}`)} />
|
||||
</div>
|
||||
))}
|
||||
{step.table && <PropertyTable parentId={tableId(id)} columns={step.table.columns} items={step.table.items} folding={folding} />}
|
||||
{fold?.open && <div className="cr-chain-blocks">{slots?.render(blocks)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 轨道上的一个点,上下各一截线连着前后两步(第一步上面、最后一步下面不画);小链的点是空心的。 */
|
||||
function ChainDot({ first, last, hollow = false }: { first: boolean, last: boolean, hollow?: boolean }): ReactElement {
|
||||
return (
|
||||
<span className="cr-chain-rail" data-first={first ? '' : undefined} data-last={last ? '' : undefined}>
|
||||
<span className="cr-chain-up" />
|
||||
<span className="cr-chain-dot" data-hollow={hollow ? '' : undefined} />
|
||||
<span className="cr-chain-down" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import type { Highlight, OccurrenceStyle } from './occurrences'
|
||||
import type { Placement } from './placement'
|
||||
import type { ReadableStore } from './store'
|
||||
import type { GroupStyle, Inline, Segment, SegmentGroup, ShownName } from './types'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { createContext, memo, useContext, useState } from 'react'
|
||||
import { ChainFoldContext } from './chainFold'
|
||||
import { sameShownName, shownName } from './displayNames'
|
||||
import { KIND_ICON_COMPONENTS } from './kindIcons'
|
||||
import { isCurrent, segmentOccurrence, sourceOf } from './occurrences'
|
||||
@@ -106,6 +108,7 @@ const GROUP_ICONS: Record<GroupStyle, LucideIcon | undefined> = {
|
||||
branch: undefined,
|
||||
tag: undefined,
|
||||
classes: undefined,
|
||||
fold: undefined,
|
||||
}
|
||||
|
||||
/** 框自己的颜色,暗色、亮色两个都写在 style 上,CSS 按主题挑一个(见 .cr-group[data-colored])。 */
|
||||
@@ -120,6 +123,8 @@ function groupColor(group: SegmentGroup): CSSProperties | undefined {
|
||||
function GroupBox({ group }: { group: SegmentGroup }): ReactElement {
|
||||
if (group.style === 'classes')
|
||||
return <ClassesChip group={group} />
|
||||
if (group.style === 'fold')
|
||||
return <FoldChip group={group} />
|
||||
const Icon = GROUP_ICONS[group.style]
|
||||
return (
|
||||
<span className="cr-group" data-style={group.style} data-colored={group.color ? '' : undefined} style={groupColor(group)}>
|
||||
@@ -134,6 +139,33 @@ function GroupBox({ group }: { group: SegmentGroup }): ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 链式调用卡片里收起来的闭包、async 块(「闭包 |app| · 37 句」):前面一个折叠箭头,点它展开、收起这一步下面挂着的子块
|
||||
* (开关是所在那一步给的,见 [ChainFoldContext]);不在链的某一步里时只是一个小框。
|
||||
*/
|
||||
function FoldChip({ group }: { group: SegmentGroup }): ReactElement {
|
||||
const fold = useContext(ChainFoldContext)
|
||||
const Chevron = fold?.open ? ChevronDown : ChevronRight
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="cr-group"
|
||||
data-style="fold"
|
||||
data-open={fold?.open ? '' : undefined}
|
||||
aria-expanded={fold?.open ?? false}
|
||||
aria-label={`${fold?.open ? '收起' : '展开'}${group.label}`}
|
||||
disabled={!fold}
|
||||
onClick={fold?.onToggle}
|
||||
>
|
||||
<span className="cr-group-head">
|
||||
<Chevron size={12} strokeWidth={2} aria-hidden />
|
||||
{group.label}
|
||||
</span>
|
||||
<SegmentText segments={group.segments} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** 收起来的一串类名(「样式 N 个类」):悬停看全部类名,点一下在它下面弹出、盖住后面的字,再点收起。 */
|
||||
function ClassesChip({ group }: { group: SegmentGroup }): ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
/** 链式调用的一步收起来的闭包眼下展开着没有,点它时怎么切换。 */
|
||||
export interface ChainFold {
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 链式调用卡片的一步交给它值里那几个 fold 框(收起来的闭包、async 块)的开关;不在链的某一步里(删掉的块的旧内容)时没有,
|
||||
* fold 框只是一个不能点的小框。
|
||||
*/
|
||||
export const ChainFoldContext = createContext<ChainFold | undefined>(undefined)
|
||||
@@ -512,28 +512,33 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --
|
||||
}
|
||||
|
||||
/*
|
||||
* 收起来的一串类名(「样式 N 个类」):中性色的小框,是个按钮;点开时换成焦点色的边、在下面弹出全部类名,
|
||||
* 弹层盖住后面的字,不挤开它们
|
||||
* 收起来的一串类名(「样式 N 个类」)、链式调用里收起来的闭包(「闭包 |app| · 37 句」):中性色的小框,是个按钮;
|
||||
* 点开时换成焦点色的边,类名在下面弹出全部类名(弹层盖住后面的字,不挤开它们),闭包在这一步下面展开子块。
|
||||
* 闭包框不在链的某一步里时(删掉的块的旧内容)不能点
|
||||
*/
|
||||
.cr-group[data-style="classes"] {
|
||||
.cr-group:is([data-style="classes"], [data-style="fold"]) {
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: var(--vscode-input-background);
|
||||
border-color: var(--vscode-editorWidget-border, var(--vscode-contrastBorder, transparent));
|
||||
}
|
||||
.cr-group[data-style="classes"] .cr-group-head {
|
||||
.cr-group:is([data-style="classes"], [data-style="fold"]) .cr-group-head {
|
||||
font-weight: 400;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
.cr-group[data-style="classes"]:hover,
|
||||
.cr-group[data-style="classes"][data-open] {
|
||||
.cr-group:is([data-style="classes"], [data-style="fold"]):not(:disabled):hover,
|
||||
.cr-group:is([data-style="classes"], [data-style="fold"])[data-open] {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
.cr-group[data-style="classes"][data-open] .cr-group-head {
|
||||
.cr-group:is([data-style="classes"], [data-style="fold"])[data-open] .cr-group-head {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
.cr-group[data-style="fold"]:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.cr-classes {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@@ -604,6 +609,99 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --
|
||||
.cr-slot {
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 链式调用的竖轨道(见 ChainRail):一步一行,左边一列是点和上下两截线(函数色淡到 35%,第一步上面、最后一步下面不画),
|
||||
* 方法那一列按最长的那个定宽(--cr-chain-name)、各步对齐;步与步之间的空隙在每一步的下边距里,线才连得上。
|
||||
* 小链用空心的小点、细一点的说明色线,一节一行
|
||||
*/
|
||||
.cr-chain {
|
||||
--cr-chain-line: color-mix(in srgb, var(--cr-function) 35%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.cr-chain-step {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.cr-chain-rail {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 14px;
|
||||
}
|
||||
.cr-chain-up {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 6px;
|
||||
background: var(--cr-chain-line);
|
||||
}
|
||||
.cr-chain-down {
|
||||
flex: 1;
|
||||
width: 2px;
|
||||
background: var(--cr-chain-line);
|
||||
}
|
||||
.cr-chain-rail[data-first] > .cr-chain-up,
|
||||
.cr-chain-rail[data-last] > .cr-chain-down {
|
||||
visibility: hidden;
|
||||
}
|
||||
.cr-chain-dot {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--cr-function);
|
||||
}
|
||||
.cr-chain-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.cr-chain-step:last-child > .cr-chain-body {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.cr-chain-line {
|
||||
display: grid;
|
||||
grid-template-columns: var(--cr-chain-name) minmax(0, 1fr);
|
||||
column-gap: 12px;
|
||||
}
|
||||
.cr-chain-name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cr-chain-label {
|
||||
font-size: 12.5px;
|
||||
line-height: 20px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cr-chain-mini {
|
||||
--cr-chain-line: color-mix(in srgb, var(--vscode-descriptionForeground) 45%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.cr-chain-mini .cr-chain-up {
|
||||
width: 1px;
|
||||
height: 7px;
|
||||
}
|
||||
.cr-chain-mini .cr-chain-down {
|
||||
width: 1px;
|
||||
}
|
||||
.cr-chain-dot[data-hollow] {
|
||||
box-sizing: border-box;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border: 1px solid var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
}
|
||||
.cr-chain-blocks {
|
||||
padding-top: 2px;
|
||||
}
|
||||
.cr-table .cr-table-cell {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
@@ -115,14 +115,14 @@ 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))
|
||||
for (const block of list) {
|
||||
if (block.slot && (tree.changes.has(block.id) || kept(block)))
|
||||
if ((block.slot || block.step) && (tree.changes.has(block.id) || kept(block)))
|
||||
visit(block.children, block.id)
|
||||
}
|
||||
visitRun(list.filter(block => !block.slot), breaks)
|
||||
visitRun(list.filter(block => !block.slot && !block.step), breaks)
|
||||
}
|
||||
visit(blocks, '')
|
||||
return { runs, folded }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BlockView, PreviewText, PropertyItem, PropertyRow } from './types'
|
||||
import type { BlockView, ChainStep, PreviewText, PropertyItem, PropertyRow } from './types'
|
||||
import { foldedByDefault } from './viewState'
|
||||
|
||||
/** 一行着色的词的高(像素),同 .cr-code 的行高 */
|
||||
@@ -26,7 +26,12 @@ function tableHeight(items: readonly PropertyItem[]): number {
|
||||
}
|
||||
|
||||
function rowsHeight(rows: readonly PropertyRow[]): number {
|
||||
return rows.reduce((sum, row) => sum + textHeight(row.value) + ROW_GAP + (row.columns ? tableHeight(row.items ?? []) : itemsHeight(row.items)), 0)
|
||||
return rows.reduce((sum, row) => sum + textHeight(row.value) + ROW_GAP + (row.columns ? tableHeight(row.items ?? []) : itemsHeight(row.items)) + chainHeight(row.chain), 0)
|
||||
}
|
||||
|
||||
/** 链式调用的各步:每步一行、步与步之间的空隙,加上小链各节、几行属性、表;挂在步骤下面的子块默认收着,不算 */
|
||||
function chainHeight(steps: readonly ChainStep[] | undefined): number {
|
||||
return (steps ?? []).reduce((sum, step) => sum + LINE + 6 + LINE * (step.lines?.length ?? 0) + rowsHeight(step.rows ?? []) + (step.table ? tableHeight(step.table.items) : 0), 0)
|
||||
}
|
||||
|
||||
/** 一张卡片大约多高:属性表按行数,一行译文按译文、拆开的各项、实参表、说明、几行属性 */
|
||||
@@ -49,9 +54,9 @@ function subtreeHeight(block: BlockView): number {
|
||||
return height
|
||||
}
|
||||
|
||||
/** 子块那一层:挂在卡片子行上的(BlockSlot)画在卡片里,默认收着,不算 */
|
||||
/** 子块那一层:挂在卡片子行上的(BlockSlot)、链式调用某一步下面的(ChainSlot)画在卡片里,默认收着,不算 */
|
||||
function childrenHeight(block: BlockView): number {
|
||||
return block.children.reduce((sum, child) => child.slot ? sum : sum + GAP + subtreeHeight(child), 0)
|
||||
return block.children.reduce((sum, child) => child.slot || child.step ? sum : sum + GAP + subtreeHeight(child), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -187,6 +187,38 @@ describe('卡片上的各段字:页面上的先后、画在哪、要展开的'
|
||||
])
|
||||
})
|
||||
|
||||
it('链式调用:起点、步骤两行,「步骤」那一行带着的各步依次是方法、值、小链各节、注释的名字和值、表,都总是展开的', () => {
|
||||
const line: WrittenBlock = {
|
||||
...textBlock('5', 'statement', 11, []),
|
||||
rows: [
|
||||
{ label: '起点', value: [name('app', 1)] },
|
||||
{ label: '步骤', value: [{ text: '2 个', role: 'hint' }], chain: [
|
||||
{ name: [word('.plugin')], value: [], lines: [[name('opener', 5)], [word('.build()')]], rows: [{ label: '注释', value: { markdown: '先装插件' } }] },
|
||||
{ name: [word('.manage')], value: [{ text: '2 次', role: 'hint' }], table: { columns: ['实参'], items: [{ cells: [[name('a', 9)]] }] } },
|
||||
] },
|
||||
],
|
||||
}
|
||||
expect(texts(placed([line])[0])).toEqual([
|
||||
['5#text', [], []],
|
||||
['5/p0#label', '起点', []],
|
||||
['5/p0#value', [name('app', 1)], []],
|
||||
['5/p1#label', '步骤', []],
|
||||
['5/p1#value', [{ text: '2 个', role: 'hint' }], []],
|
||||
['5/p1/s0#name', [word('.plugin')], []],
|
||||
['5/p1/s0#value', [], []],
|
||||
['5/p1/s0#l0', [name('opener', 5)], []],
|
||||
['5/p1/s0#l1', [word('.build()')], []],
|
||||
['5/p1/s0#r0-label', '注释', []],
|
||||
['5/p1/s0#r0', { markdown: '先装插件' }, []],
|
||||
['5/p1/s1#name', [word('.manage')], []],
|
||||
['5/p1/s1#value', [{ text: '2 次', role: 'hint' }], []],
|
||||
['5/p1/s1/t#h0', '实参', []],
|
||||
['5/p1/s1/t/0#0', [name('a', 9)], []],
|
||||
])
|
||||
expect(occurrenceIn([line], { start: 9, end: 10, kind: 'read' }, () => false)).toEqual({ id: '5', expand: [] })
|
||||
expect(revealIn([line], 11, 5)).toEqual({ id: '5', expand: [] })
|
||||
})
|
||||
|
||||
it('用键盘跳到一处出现也按它找:一行译文下面几行属性里显示着的名字也算', () => {
|
||||
const line: WrittenBlock = { ...textBlock('0', 'statement', 0, [word('设')]), rows: [{ label: '值', value: [name('get_db', 30)] }] }
|
||||
expect(occurrenceIn([line], { start: 30, end: 36, kind: 'read' }, () => false)).toEqual({ id: '0', expand: [] })
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { BlockView, CardView, Inline, Occurrence, PreviewTable, PreviewText
|
||||
import { blockPath } from './filter'
|
||||
import { showsOccurrence, sourceOf } from './occurrences'
|
||||
import { subRowsLayout, tableCells } from './subRows'
|
||||
import { listId, propertyItemId, propertyRowId, tableId, textKey, unfoldToShow } from './viewState'
|
||||
import { chainStepId, listId, propertyItemId, propertyRowId, tableId, textKey, unfoldToShow } from './viewState'
|
||||
|
||||
/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */
|
||||
export interface RevealPlan {
|
||||
@@ -120,27 +120,46 @@ function locate(block: BlockView, offset: number): Spot | undefined {
|
||||
if (block.defines && covers(block.defines, offset))
|
||||
return { expand: [], span: width(block.defines), defining: true }
|
||||
if ('translation' in block) {
|
||||
const defined = definedItem(textItems(block.id, block.items, block.table), offset)
|
||||
const rows = block.rows ?? []
|
||||
const defined = definedItem(textItems(block.id, block.items, block.table), offset) ?? definedRow(block.id, rows, offset)
|
||||
if (defined)
|
||||
return defined
|
||||
const span = textSpan(block.translation, offset)
|
||||
return span === undefined ? shownItem(textItems(block.id, block.items, block.table), offset) : { expand: [], span, defining: false }
|
||||
if (span !== undefined)
|
||||
return { expand: [], span, defining: false }
|
||||
return shownItem(textItems(block.id, block.items, block.table), offset) ?? shownRow(block.id, rows, offset)
|
||||
}
|
||||
const rows = block.properties.map((row, index) => ({ row, id: propertyRowId(block.id, index) }))
|
||||
for (const { row, id } of rows) {
|
||||
return definedRow(block.id, block.properties, offset) ?? shownRow(block.id, block.properties, offset)
|
||||
}
|
||||
|
||||
/** 属性表各行里讲 offset 处这个定义的([locate] 的 ②):行自己的 defines,或它下面一层层的子行。 */
|
||||
function definedRow(blockId: string, rows: PropertyRow[], offset: number): Spot | undefined {
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const id = propertyRowId(blockId, index)
|
||||
if (row.defines && covers(row.defines, offset))
|
||||
return { row: id, expand: [], span: width(row.defines), defining: true }
|
||||
const defined = definedItem(rowItems(row, id), offset)
|
||||
if (defined)
|
||||
return defined
|
||||
}
|
||||
for (const { row, id } of rows) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 属性表各行里只是显示着 offset 处这个名字的([locate] 的 ③):值,子行的格子,带着的链式调用各步的字。 */
|
||||
function shownRow(blockId: string, rows: PropertyRow[], offset: number): Spot | undefined {
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const id = propertyRowId(blockId, index)
|
||||
const value = textSpan(row.value, offset)
|
||||
if (value !== undefined)
|
||||
return { expand: [], span: value, defining: false }
|
||||
const shown = shownItem(rowItems(row, id), offset)
|
||||
if (shown)
|
||||
return shown
|
||||
for (const { text, expand } of chainTexts(blockId, row, index)) {
|
||||
const span = typeof text === 'string' ? undefined : textSpan(text, offset)
|
||||
if (span !== undefined)
|
||||
return { expand, span, defining: false }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -260,6 +279,27 @@ function* rowTexts(blockId: string, rows: PropertyRow[]): Generator<CardText> {
|
||||
yield* tableTexts({ columns: row.columns ?? [], items: row.items ?? [] }, id, [id])
|
||||
else if (layout === 'list')
|
||||
yield* itemTexts(row.items, id, [id])
|
||||
yield* chainTexts(blockId, row, index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性表第 `index` 行带着的链式调用各步的字(同 [ChainRail](vscode/webview-ui/src/codeReader/PropertyGrid.tsx#ChainRail) 的排法):
|
||||
* 方法、值、小链各节、几行属性的名字和值、表,都总是展开的。挂在步骤下面的子块是别的块,不在这里。
|
||||
*/
|
||||
function* chainTexts(blockId: string, row: PropertyRow, index: number): Generator<CardText> {
|
||||
for (const [at, step] of (row.chain ?? []).entries()) {
|
||||
const id = chainStepId(blockId, { row: index, step: at })
|
||||
yield { key: textKey(id, 'name'), text: step.name, expand: [] }
|
||||
yield { key: textKey(id, 'value'), text: step.value, expand: [] }
|
||||
for (const [line, text] of (step.lines ?? []).entries())
|
||||
yield { key: textKey(id, `l${line}`), text, expand: [] }
|
||||
for (const [item, { label, value }] of (step.rows ?? []).entries()) {
|
||||
yield { key: textKey(id, `r${item}-label`), text: label, expand: [] }
|
||||
yield { key: textKey(id, `r${item}`), text: value, expand: [] }
|
||||
}
|
||||
if (step.table)
|
||||
yield* tableTexts(step.table, tableId(id), [])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ export interface RuleReference { rule: string, key: string }
|
||||
/**
|
||||
* 框的样式,决定框头的图标和框的颜色:iteration 写在表达式里的遍历(同「循环」块),condition 按条件(同「条件」块),
|
||||
* branch 条件的一支(同「条件」块的颜色、不带图标),tag 小标签(颜色是 SegmentGroup.color),
|
||||
* classes 收起来的一串类名(中性色,点开、悬停看 detail)
|
||||
* classes 收起来的一串类名(中性色,点开、悬停看 detail),fold 链式调用卡片里收起来的闭包、async 块(点它展开、收起那一步下面挂着的子块)
|
||||
*/
|
||||
export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'tag' | 'classes'
|
||||
export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'tag' | 'classes' | 'fold'
|
||||
|
||||
/**
|
||||
* 框起来的一组词:画成嵌在前后文字里的一个小框,框头是图标和 label(只有图标时 label 是空的);框里的词照样带源码下标,还能再套框。
|
||||
@@ -93,6 +93,8 @@ export interface PropertyRow extends Defines {
|
||||
note?: Markdown
|
||||
items?: PropertyItem[]
|
||||
columns?: string[]
|
||||
/** 值是一条链式调用的起点时,链的各步:排成一条竖轨道,接在值下面 */
|
||||
chain?: ChainStep[]
|
||||
}
|
||||
|
||||
/** 一张带表头和边框的表:列名,每行几列对齐的格子,同 PropertyRow 给了 columns 的子行 */
|
||||
@@ -101,6 +103,18 @@ export interface PreviewTable {
|
||||
items: PropertyItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 链式调用卡片的一步(同扩展端 contract 的 ChainStep):方法那一列(叫法或 `.名字`)、值那一列(收起来的闭包是 fold 框),
|
||||
* 下面依次是小链(一节一行)、几行属性(写在这一步上面的注释)、表(合成的一步各次的实参,或规则换成的表)
|
||||
*/
|
||||
export interface ChainStep {
|
||||
name: Inline[]
|
||||
value: Inline[]
|
||||
lines?: Inline[][]
|
||||
rows?: PropertyRow[]
|
||||
table?: PreviewTable
|
||||
}
|
||||
|
||||
/** 一种颜色在暗色、亮色主题下各用哪个(CSS 颜色值);高对比主题跟着暗 / 亮走 */
|
||||
export interface ThemeColor { dark: string, light: string }
|
||||
|
||||
@@ -159,6 +173,8 @@ interface CardViewBase {
|
||||
badge?: KindBadge
|
||||
/** 库规则挂在父卡片子行上的子块才有:展开时画在父卡片属性表第 row 行下面,不排在父卡片的子块里;见 BlockSlot */
|
||||
slot?: BlockSlot
|
||||
/** 挂在父卡片链式调用某一步下面的子块才有:画在那一步下面,不排在父卡片的子块里;见 ChainSlot */
|
||||
step?: ChainSlot
|
||||
/** 能看调用树的块才有(有名字的函数、类,讲 HTTP 接口的卡片):有它才摆调用树按钮 */
|
||||
callTree?: CallTreeTarget
|
||||
}
|
||||
@@ -177,6 +193,15 @@ export interface BlockSlot {
|
||||
item: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 子块挂在父卡片链式调用的哪一步:属性表(一行译文的卡片是下面那几行)第 row 行那条链的第 step 步。
|
||||
* 那一步默认收着(折叠 id 见 chainStepId),点它的闭包展开,这一步下面的子块一起画出来
|
||||
*/
|
||||
export interface ChainSlot {
|
||||
row: number
|
||||
step: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,文件开头的说明是 Markdown,其余是一行译文;
|
||||
* 一行译文下面依次是:跨几行写的字面量拆开的各项(items),跨几行写的调用摊开各个实参的表(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, foldsByCard, propertyItemId, propertyRowId, unfoldToShow } from './viewState'
|
||||
import { chainStepId, collapsedIds, createViewStateStore, foldsByCard, propertyItemId, propertyRowId, unfoldToShow } from './viewState'
|
||||
|
||||
/** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回;也能塞进旧版本存的东西 */
|
||||
function memoryStorage(initial?: unknown): ViewStateStorage {
|
||||
@@ -149,6 +149,19 @@ describe('眼下折叠着哪些', () => {
|
||||
expect(unfoldToShow('0', block('0.2', 'comment'))).toEqual(['0'])
|
||||
})
|
||||
|
||||
it('挂在链式调用某一步下面的子块:那一步默认收着(它的折叠 id 记在属性行下面),子块自己照常;要让它看得见得展开那一步;那一步下面有标了改动的块时默认展开', () => {
|
||||
const first: BlockView = { ...block('0.0', 'statement'), step: { row: 1, step: 2 } }
|
||||
const second: BlockView = { ...block('0.1', 'if', [block('0.1.0', 'return')]), step: { row: 1, step: 2 } }
|
||||
const third: BlockView = { ...block('0.2', 'statement'), step: { row: 1, step: 4 } }
|
||||
const chain: BlockView = block('0', 'statement', [first, second, third])
|
||||
expect(chainStepId('0', { row: 1, step: 2 })).toBe('0/p1/s2')
|
||||
expect([...collapsedIds([chain], {})].sort()).toEqual(['0/p1/s2', '0/p1/s4'])
|
||||
expect([...collapsedIds([chain], { '0/p1/s2': false })]).toEqual(['0/p1/s4'])
|
||||
expect([...collapsedIds([chain], {}, new Set(['0.1']))]).toEqual(['0/p1/s4'])
|
||||
expect(unfoldToShow('0', second)).toEqual(['0/p1/s2'])
|
||||
expect(foldsByCard(new Set(['0/p1/s2'])).get('0')).toEqual(new Set(['0/p1/s2']))
|
||||
})
|
||||
|
||||
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'])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ScrollAnchor } from './scrollPosition'
|
||||
import type { BlockView, OpenedFrom } from './types'
|
||||
import type { BlockView, ChainSlot, OpenedFrom } from './types'
|
||||
|
||||
/**
|
||||
* 页面重新加载(webview 进程崩了重建、开发者重载 webview)时靠它恢复(标签页切走时页面一直留着,用不上它):哪些块停在「编辑」、
|
||||
@@ -84,13 +84,22 @@ export function foldedByDefault(block: Pick<BlockView, 'kind' | 'badge' | 'slot'
|
||||
return block.slot !== undefined || block.badge !== undefined || ['function', 'class', 'enum', 'exception'].includes(block.kind)
|
||||
}
|
||||
|
||||
/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault;opened 里的块(标了改动的)默认展开。 */
|
||||
/**
|
||||
* 眼下折叠着的块、属性行、子行和链式调用的步:用户点过的按他点的,没点过的按 foldedByDefault,链里挂着子块的步默认收着;
|
||||
* opened 里的块(标了改动的)默认展开,它挂在哪一步下面那一步也默认展开。
|
||||
*/
|
||||
export function collapsedIds(blocks: readonly BlockView[], folds: Readonly<Record<string, boolean>>, opened: ReadonlySet<string> = new Set()): Set<string> {
|
||||
const collapsed = new Set(Object.keys(folds).filter(id => folds[id]))
|
||||
const visit = (list: readonly BlockView[]): void => {
|
||||
for (const block of list) {
|
||||
if (folds[block.id] === undefined && foldedByDefault(block) && !opened.has(block.id))
|
||||
collapsed.add(block.id)
|
||||
const steps = block.children.flatMap(child => child.step ? [{ id: chainStepId(block.id, child.step), opened: opened.has(child.id) }] : [])
|
||||
const shown = new Set(steps.filter(step => step.opened).map(step => step.id))
|
||||
for (const { id } of steps) {
|
||||
if (folds[id] === undefined && !shown.has(id))
|
||||
collapsed.add(id)
|
||||
}
|
||||
visit(block.children)
|
||||
}
|
||||
}
|
||||
@@ -175,10 +184,20 @@ export function textKey(ownerId: string, part: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 父块看得见时,要让它的一个子块也看得见得展开的(块 id、属性行 id):一般的子块是父块自己(收起子块时不画);
|
||||
* 挂在父卡片子行上的子块(BlockSlot)是它所在的那一行和它自己。
|
||||
* 链式调用的一步收起来的闭包的折叠 id,和块 id 放在同一份 folds 里:`<属性行 id>/s<步序号>`,例如 '6.1.0/p1/s3'。
|
||||
* 默认收着(见 collapsedIds),展开时这一步下面挂着的子块一起画出来;带 '/' 的 id 归它那张卡片(见 foldsByCard)。
|
||||
*/
|
||||
export function unfoldToShow(parentId: string, child: Pick<BlockView, 'id' | 'slot'>): string[] {
|
||||
export function chainStepId(blockId: string, slot: ChainSlot): string {
|
||||
return `${propertyRowId(blockId, slot.row)}/s${slot.step}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 父块看得见时,要让它的一个子块也看得见得展开的(块 id、属性行 id、链的步):一般的子块是父块自己(收起子块时不画);
|
||||
* 挂在父卡片子行上的子块(BlockSlot)是它所在的那一行和它自己;挂在链式调用某一步下面的子块(ChainSlot)是那一步。
|
||||
*/
|
||||
export function unfoldToShow(parentId: string, child: Pick<BlockView, 'id' | 'slot' | 'step'>): string[] {
|
||||
if (child.step)
|
||||
return [chainStepId(parentId, child.step)]
|
||||
if (!child.slot)
|
||||
return [parentId]
|
||||
return [propertyRowId(parentId, child.slot.row), child.id]
|
||||
|
||||
Reference in new issue
Block a user