✨ feat(vscode): 代码阅读预览里实参值、return 这类一行译文里跨几行写的字典 / 列表按项拆开,各项带自己的实参表

This commit is contained in:
cruldra committed 2026-10-13 13:57:22 +11:00
1 parent b2c5309d1f
commit 907c4092c8
18 files changed
+515 -102

No files matched your search

@@ -1,3 +1,5 @@
// @vitest-environment happy-dom
import type { BlockTreeState, RewriteTree } from './BlockTree'
import type { Ask, ChatState } from './qa'
import type { BlockView, KindBadge } from './types'
@@ -240,3 +242,85 @@ describe('「注释」一行和「重写注释」按钮', () => {
expect(html(view('4', 'statement', 'save()'))).not.toContain('按注释规范')
})
})
describe('一行译文下面拆开的各项', () => {
/** 一段 Python 里函数体的第一块(return)经过宿主转成的视图数据;Filter 的定义是类。 */
async function returned(source: string): Promise<BlockView> {
const language = createPythonAdapter(WASM_DIR)
const views = await withBlocks(language, 'python', source, blocks => toBlockViews(blocks, { source, language, markers: [], calleeKind: call => call.callee.text === 'Filter' ? 'class' : undefined }, pythonZhTranslator))
return views[0].children[0]
}
const SOURCE = [
'def list_filters(archived, current_user):',
' return [',
' Filter(field="user_id", value=current_user.id),',
' Filter(',
' field="archived_at",',
' value=None,',
' ),',
' {"k": v},',
' ]',
].join('\n')
/** 画成页面(只画这一块),collapsed 是折叠着的 id */
function page(block: BlockView, collapsed: ReadonlySet<string> = new Set(), diff?: BlockTreeState['diff']): HTMLElement {
const root = document.createElement('div')
root.innerHTML = renderToStaticMarkup(<BlockLevel blocks={[block]} tree={{ ...state(collapsed), ...(diff && { diff }) }} />)
return root
}
function byId(root: HTMLElement, id: string): HTMLElement {
const found = root.querySelector<HTMLElement>(`[data-id="${id}"]`)
if (!found)
throw new Error(`页面上没有 ${id}`)
return found
}
/** a 在页面上排在 b 前面 */
function before(a: Element, b: Element): boolean {
return (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
}
it('各项排在译文下面、往右缩一层、左边一条竖线,和变量初始值的各项一样;项下面挂着它的实参表,排在下一项上面', async () => {
const block = await returned(SOURCE)
const root = page(block)
const line = root.querySelector('.cr-code')
expect(line?.textContent).toBe('返回 列表 3 项')
const first = byId(root, `${block.id}/i/0`)
const second = byId(root, `${block.id}/i/1`)
expect(first.textContent).toBe('第 1 项新建 Filter · 2 个参数')
expect(second.textContent).toBe('第 2 项新建 Filter · 2 个参数')
const cell = byId(root, `${block.id}/i/1/t/0`)
expect(cell.textContent).toBe('field"archived_at"')
expect(before(line!, first) && before(second, cell) && before(cell, byId(root, `${block.id}/i/2`))).toBe(true)
expect(first.closest('.cr-table')).toBeNull()
let level = first.parentElement
while (level && !level.firstElementChild?.classList.contains('cr-guide'))
level = level.parentElement
expect(level?.contains(second)).toBe(true)
expect(level?.contains(line)).toBe(false)
})
it('项下面的下一层(字典)能折叠,折着时只收起那一层;实参表总是展开的', async () => {
const block = await returned(SOURCE)
const map = `${block.id}/i/2`
const open = page(block)
expect(byId(open, map).querySelector('button')?.getAttribute('aria-expanded')).toBe('true')
expect(byId(open, `${map}/0`).textContent).toBe('"k"v')
const folded = page(block, new Set([map]))
expect(folded.querySelector(`[data-id="${map}/0"]`)).toBeNull()
expect(folded.querySelector(`[data-id="${block.id}/i/0/t/0"]`)).not.toBeNull()
})
it('标差异:新加的项淡绿底,删掉的项放回原来的位置、划掉', async () => {
const block = await returned(SOURCE)
const gone = { cells: [[{ text: '第 3 项', role: 'hint' as const }], [{ text: 'old', role: 'variable' as const }]] }
const changes = new Map([[block.id, { change: 'mod' as const, own: true as const, marks: { items: { changed: { 2: { added: true as const } }, removed: [{ after: 1, value: gone }] } } }]])
const root = page(block, new Set(), { changes, removed: new Map(), current: true })
expect(byId(root, `${block.id}/i/2`).dataset.diff).toBe('add')
const removed = root.querySelector<HTMLElement>('[data-diff="del"]')
expect(removed?.textContent).toBe('第 3 项old')
expect(before(byId(root, `${block.id}/i/1`), removed!) && before(removed!, byId(root, `${block.id}/i/2`))).toBe(true)
})
})
+11 -4
View File
@@ -13,10 +13,10 @@ import { CodeEditor } from './CodeEditor'
import { DiffText } from './DiffText'
import { removedTitle } from './diffView'
import { KindIcon } from './kindIcons'
import { PropertyGrid, PropertyTable } from './PropertyGrid'
import { PropertyGrid, PropertyItems, PropertyTable } from './PropertyGrid'
import { asksOf, historyOf, unfinished } from './qa'
import { TextPreview } from './TextPreview'
import { tableId } from './viewState'
import { listId, tableId } from './viewState'
/**
* 块树里改代码的那部分:哪些块在编辑、点「编辑」。页面上的块树才有;定义卡片浮窗里的块树没有,
@@ -234,17 +234,24 @@ function BlockRewrite({ anchor, rewrite }: { anchor: string, rewrite: RewriteTre
/**
* 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文;
* 译文下面依次接着摊开实参的表(跨几行写的调用)、说明、几行属性(「注释」一行)。标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。
* 译文下面依次接着拆开的各项(跨几行写的字面量,排法同变量初始值下面的)、摊开实参的表(跨几行写的调用)、说明、几行属性(「注释」一行)。
* 标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。
*/
function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTreeState, marks?: BlockMarks }): ReactElement {
if ('properties' in block)
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} marks={marks?.rows} slots={cardSlots(block, tree)} />
const folding = { collapsed: tree.collapsed, onToggleFold: tree.onToggleFold }
return (
<>
{marks?.translation ? <DiffText diff={marks.translation} /> : <TextPreview text={block.translation} />}
{block.items && (
<div className="mt-[3px]">
<PropertyItems parentId={listId(block.id)} items={block.items} folding={folding} marks={marks?.items} />
</div>
)}
{block.table && (
<div className="mt-[3px]">
<PropertyTable parentId={tableId(block.id)} columns={block.table.columns} items={block.table.items} marks={marks?.table} folding={{ collapsed: tree.collapsed, onToggleFold: tree.onToggleFold }} />
<PropertyTable parentId={tableId(block.id)} columns={block.table.columns} items={block.table.items} marks={marks?.table} folding={folding} />
</div>
)}
{marks?.doc ? <DiffText diff={marks.doc} className="mt-0.5" /> : block.doc && <TextPreview text={block.doc} className="mt-0.5" />}
@@ -133,12 +133,12 @@ function Nested({ children }: { children: ReactNode }): ReactElement {
}
/**
* 展开在一行或一个子行(parentId)下面的一层子行:左边一条竖线,每个子行几列对齐。
* 展开在一行或一个子行(parentId)下面的一层子行:左边一条竖线,每个子行几列对齐。一行译文下面拆开的各项也用它。
* 带下一层的子行(字典 / 对象、跨几行写的列表的各项),下一层展开在它下面、再往右缩进一层,默认展开,能折叠;
* 带实参表的子行(列表里整项是调用),表排在它下面、同样往右缩进一层,总是展开。
* 标差异时(marks)新加的子行淡绿底、左边 +,删掉的放回原来的位置、淡红底划掉、左边 −,改了的格子逐截标。
*/
function PropertyItems({ parentId, items, folding, marks }: { parentId: string, items: PropertyItem[], folding: Folding, marks?: ItemsDiff }): ReactElement {
export function PropertyItems({ parentId, items, folding, marks }: { parentId: string, items: PropertyItem[], folding: Folding, marks?: ItemsDiff }): ReactElement {
const nested = items.some(item => (item.items?.length ?? 0) > 0)
/** 一个子行:删掉的(change 是 del)不带 id、下面的子行总是展开 */
const itemRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined): ReactElement => {
@@ -134,6 +134,23 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
expect(occurrencePlan(blocks, { start: 140, end: 147, kind: 'read' }, () => false)).toEqual({ id: '1', expand: [] })
})
it('名字在一行译文下面拆开的各项里:各项总是展开的,讲这个定义的项(字典的键)只闪它;项下面的下一层要展开那一项,实参表总是展开的', () => {
const blocks: BlockView[] = [
{ ...textBlock('0', 'return', 0, [word('返回'), word('列表 2 项')]), items: [
{ cells: [[word('第 1 项')], [word('新建'), name('Filter', 10)]], table: { columns: ['参数', '值'], items: [{ cells: [[name('field', 20)], [name('user_id', 30)]] }] } },
{ cells: [[word('第 2 项')], [word('字典 1 项')]], items: [{ cells: [[name('key', 40)], [name('value', 45)]], defines: { startIndex: 40, endIndex: 43 } }] },
] },
{ ...textBlock('1', 'return', 5, [word('返回'), word('对象 1 项')]), items: [{ cells: [[name('locked', 60)], [name('flag', 70)]], defines: { startIndex: 60, endIndex: 66 } }] },
]
expect(revealPlan(blocks, 0, 10)).toEqual({ id: '0', expand: [] })
expect(revealPlan(blocks, 0, 30)).toEqual({ id: '0', expand: [] })
expect(revealPlan(blocks, 0, 40)).toEqual({ id: '0', row: '0/i/1/0', expand: ['0/i/1'] })
expect(revealPlan(blocks, 0, 45)).toEqual({ id: '0', expand: ['0/i/1'] })
expect(revealPlan(blocks, 5, 60)).toEqual({ id: '1', row: '1/i/0', expand: [] })
expect(occurrencePlan(blocks, { start: 45, end: 50, kind: 'read' }, () => false)).toEqual({ id: '0', expand: ['0/i/1'] })
expect(occurrencePlan(blocks, { start: 30, end: 37, kind: 'read' }, () => false)).toEqual({ id: '0', expand: [] })
})
it('名字在框里(套了几层都算)也是显示着它', () => {
const inner = { style: 'iteration' as const, label: '列表', segments: [word('遍历'), name('row', 30), word('取'), name('y', 40)] }
const blocks: BlockView[] = [
+25 -13
View File
@@ -1,7 +1,7 @@
import type { BlockView, Inline, Occurrence, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange } from './types'
import { blockPath } from './filter'
import { showsOccurrence, sourceOf } from './occurrences'
import { propertyItemId, propertyRowId, tableId, unfoldToShow } from './viewState'
import { listId, propertyItemId, propertyRowId, tableId, unfoldToShow } from './viewState'
/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */
export interface RevealPlan {
@@ -87,34 +87,46 @@ function rowItems(row: PropertyRow, id: string): Generator<PlacedItem> {
return nestedItems(row.items, id, [id], !row.columns)
}
/** 一行译文下面那张实参表(总是展开的)里一层层的子行。 */
function tableItems(blockId: string, table: PreviewTable | undefined): Generator<PlacedItem> {
return nestedItems(table?.items, tableId(blockId), [], false)
/** 一行译文下面一层层的子行:拆开的各项(这一层总是展开的,项下面的下一层折叠在项上),摊开实参的表(总是展开的)。 */
function* textItems(blockId: string, items: PropertyItem[] | undefined, table: PreviewTable | undefined): Generator<PlacedItem> {
yield* nestedItems(items, listId(blockId), [], true)
yield* nestedItems(table?.items, tableId(blockId), [], false)
}
/** 一层层的子行里,讲 offset 处这个定义的那一个(defines 盖住 offset):只闪它,展开它上面的各层。 */
function definedItem(items: Iterable<PlacedItem>, offset: number): Spot | undefined {
for (const { item, id, expand } of items) {
if (item.defines && covers(item.defines, offset))
return { row: id, expand, span: width(item.defines) }
}
return undefined
}
/**
* 块里哪儿是 offset 处的定义,从具体到笼统:
* ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块;
* ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它,
* ② 属性表、一行译文下面的子行里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它,
* 子行连它上面的各层(所在的行、嵌套时外层的子行)一起展开;
* ③ 块里只是显示着这个名字(一段源码范围盖住 offset:一行译文和它下面的表里的名字、没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。
* ③ 块里只是显示着这个名字(一段源码范围盖住 offset:一行译文和它下面拆开的各项、表里的名字,没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。
* span 是盖住 offset 的那段有多长。不在这块里是 undefined。
*/
function locate(block: BlockView, offset: number): Spot | undefined {
if (block.defines && covers(block.defines, offset))
return { expand: [], span: width(block.defines) }
if ('translation' in block) {
const defined = definedItem(textItems(block.id, block.items, block.table), offset)
if (defined)
return defined
const span = textSpan(block.translation, offset)
return span === undefined ? shownItem(tableItems(block.id, block.table), offset) : { expand: [], span }
return span === undefined ? shownItem(textItems(block.id, block.items, block.table), offset) : { expand: [], span }
}
const rows = block.properties.map((row, index) => ({ row, id: propertyRowId(block.id, index) }))
for (const { row, id } of rows) {
if (row.defines && covers(row.defines, offset))
return { row: id, expand: [], span: width(row.defines) }
for (const { item, id: itemId, expand } of rowItems(row, id)) {
if (item.defines && covers(item.defines, offset))
return { row: itemId, expand, span: width(item.defines) }
}
const defined = definedItem(rowItems(row, id), offset)
if (defined)
return defined
}
for (const { row, id } of rows) {
const value = textSpan(row.value, offset)
@@ -186,10 +198,10 @@ export function uncoveredPlan(plan: RevealPlan, covered: ReadonlyMap<string, str
return owner === undefined ? plan : { id: owner, expand: blockPath(owner).slice(0, -1) }
}
/** 块里哪儿显示着满足 test 的文字(译文和它下面的表、属性行的值、各层子行的格子):要展开的属性行和子行;不在这块里是 undefined。 */
/** 块里哪儿显示着满足 test 的文字(译文和它下面拆开的各项、表,属性行的值、各层子行的格子):要展开的属性行和子行;不在这块里是 undefined。 */
function showingSpot(block: BlockView, test: (text: PreviewText) => boolean): string[] | undefined {
if ('translation' in block)
return test(block.translation) ? [] : showingItem(tableItems(block.id, block.table), test)
return test(block.translation) ? [] : showingItem(textItems(block.id, block.items, block.table), test)
for (const [index, row] of block.properties.entries()) {
if (test(row.value))
return []
+5 -3
View File
@@ -181,9 +181,10 @@ export type CommentTarget
/**
* 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,文件开头的说明是 Markdown,其余是一行译文;
* 一行译文下面依次是:跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文),几行属性(rows:「注释」一行)
* 一行译文下面依次是:跨几行写的字面量拆开的各项(items),跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文),
* 几行属性(rows:「注释」一行)
*/
export type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] })
export type BlockView = BlockViewBase & ({ translation: PreviewText, items?: PropertyItem[], table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] })
export interface TranslatorOption { id: string, label: string }
@@ -407,10 +408,11 @@ export interface RowDiff {
items?: ItemsDiff
}
/** 一块自己的内容(不算子块)改了哪些:属性表(一行译文的卡片是下面那几行 rows)逐行,一行译文逐截,摊开实参的表逐行,说明逐词 */
/** 一块自己的内容(不算子块)改了哪些:属性表(一行译文的卡片是下面那几行 rows)逐行,一行译文逐截,译文下面拆开的各项、摊开实参的表逐行,说明逐词 */
export interface BlockMarks {
rows?: RowsDiff
translation?: TextDiff
items?: ItemsDiff
table?: ItemsDiff
doc?: TextDiff
}
@@ -124,6 +124,14 @@ export function tableId(ownerId: string): string {
return `${ownerId}/t`
}
/**
* 一行译文下面拆开的各项(跨几行写的列表、字典)的上一层 id:`<块 id>/i`,各项的 id 是 propertyItemId(它, 项号)。
* 这一层总是展开的,项下面的下一层照常折叠在项的 id 上。
*/
export function listId(blockId: string): string {
return `${blockId}/i`
}
/**
* 父块看得见时,要让它的一个子块也看得见得展开的(块 id、属性行 id):一般的子块是父块自己(收起子块时不画);
* 挂在父卡片子行上的子块(BlockSlot)是它所在的那一行和它自己。