diff --git a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx index d43d8b4..95f239f 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx @@ -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 { + 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' diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 999bb3c..70143b7 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -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 = 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, 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 + return const folding = { collapsed: folds, onToggleFold: tree.onToggleFold } + // 整句是链的块译文只剩链外面的词,什么都没剩时不画这一行 + const empty = Array.isArray(block.translation) && block.translation.length === 0 return ( <> - {marks?.translation ? : } + {marks?.translation ? : !empty && } {block.items && (
@@ -436,8 +444,8 @@ const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { bl )} {marks?.doc ? : block.doc && } {block.rows && ( -
- +
+
)} @@ -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, tree: BlockTreeHandle): ChainSlots | undefined { + const blocks = children.filter(child => child.step) + if (blocks.length === 0) + return undefined + return { + blocks, + render: list => , + open: slot => !folds.has(chainStepId(block.id, slot)), + onToggle: slot => tree.onToggleFold(chainStepId(block.id, slot)), + } +} + /** * 一块的卡片:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools,只读的块树没有)。 * 卡片上记着块记号(data-anchor),说明里不带路径的引用靠它认自己写在哪块(见 commentLinkOf)。 diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index 623630f..d407e91 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -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 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 @@ -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 ) : {row.label}} - + {row.chain + ? ( +
+ + +
+ ) + : }
{!folded && layout === 'table' && } {!folded && layout === 'list' && } @@ -357,7 +378,10 @@ function RemovedRow({ id, row }: { id: string, row: PropertyRow }): ReactElement <>
{row.label} - +
+ + {row.chain && } +
{layout && (
@@ -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,闭包的小框不能点。 + * + * ![链式调用卡片](docs/prototype/code-reader/openpencil/exports/rust-chain-a.png) + */ +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 ( +
+ {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 ( +
+ +
+ +
+

+

+
+ {step.lines && ( +
+ +
+ {step.lines.map((line, at) => ( +
+ +

+
+ ))} +
+
+ )} +
+ {step.rows?.map((item, at) => ( +
+ {item.label} + +
+ ))} + {step.table && } + {fold?.open &&
{slots?.render(blocks)}
} +
+
+ ) + })} +
+ ) +} + +/** 轨道上的一个点,上下各一截线连着前后两步(第一步上面、最后一步下面不画);小链的点是空心的。 */ +function ChainDot({ first, last, hollow = false }: { first: boolean, last: boolean, hollow?: boolean }): ReactElement { + return ( + + + + + + ) +} diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index 4597fa7..6dfa4db 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -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 = { 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 + if (group.style === 'fold') + return const Icon = GROUP_ICONS[group.style] return ( @@ -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 ( + + ) +} + /** 收起来的一串类名(「样式 N 个类」):悬停看全部类名,点一下在它下面弹出、盖住后面的字,再点收起。 */ function ClassesChip({ group }: { group: SegmentGroup }): ReactElement { const [open, setOpen] = useState(false) diff --git a/vscode/webview-ui/src/codeReader/chainFold.ts b/vscode/webview-ui/src/codeReader/chainFold.ts new file mode 100644 index 0000000..87a302c --- /dev/null +++ b/vscode/webview-ui/src/codeReader/chainFold.ts @@ -0,0 +1,13 @@ +import { createContext } from 'react' + +/** 链式调用的一步收起来的闭包眼下展开着没有,点它时怎么切换。 */ +export interface ChainFold { + open: boolean + onToggle: () => void +} + +/** + * 链式调用卡片的一步交给它值里那几个 fold 框(收起来的闭包、async 块)的开关;不在链的某一步里(删掉的块的旧内容)时没有, + * fold 框只是一个不能点的小框。 + */ +export const ChainFoldContext = createContext(undefined) diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 3327eef..125a4b7 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -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; } diff --git a/vscode/webview-ui/src/codeReader/diffView.ts b/vscode/webview-ui/src/codeReader/diffView.ts index 25cf6d4..f8f4ed6 100644 --- a/vscode/webview-ui/src/codeReader/diffView.ts +++ b/vscode/webview-ui/src/codeReader/diffView.ts @@ -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 } diff --git a/vscode/webview-ui/src/codeReader/itemHeight.ts b/vscode/webview-ui/src/codeReader/itemHeight.ts index a70df55..8ea5e38 100644 --- a/vscode/webview-ui/src/codeReader/itemHeight.ts +++ b/vscode/webview-ui/src/codeReader/itemHeight.ts @@ -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) } /** diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index d00838c..b70d4a4 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -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: [] }) diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts index 29de664..6ff680a 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -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 { 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 { + 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), []) } } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 1757bb5..45810b7 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -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 原文), diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index 1e130c6..4d8e492 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -1,7 +1,7 @@ import type { BlockKind, BlockView } from './types' import type { ViewStateStorage } from './viewState' import { describe, expect, it } from 'vitest' -import { collapsedIds, createViewStateStore, 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']) diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index f1e20d6..93d630b 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -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>, opened: ReadonlySet = new Set()): Set { 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): 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): string[] { + if (child.step) + return [chainStepId(parentId, child.step)] if (!child.slot) return [parentId] return [propertyRowId(parentId, child.slot.row), child.id]