✨ feat(vscode): 代码阅读把字典、对象字面量的值展开成键值树

This commit is contained in:
cruldra committed 2026-09-25 20:57:27 +08:00
1 parent 51f2ef7bfa
commit 4930dc0d42
20 files changed
+578 -103

No files matched your search

+16 -4
View File
@@ -85,11 +85,19 @@ export interface ImportFacts {
items: ImportItem[]
}
/** 字典 / 对象字面量里的一项,照源码顺序;注释不算。 */
export type EntryFacts
= | { kind: 'pair', key: Node, value: Node, entries?: EntryFacts[] } // 'a': 1、a: 1、[k]: v;TypeScript 的简写 { a } 键和值是同一个节点;entries 同 DeclaratorFacts.entries
| { kind: 'spread', value: Node } // **other、...other:value 是展开的那个表达式
| { kind: 'method', name: Node, node: Node, header: SourceSpan } // 对象字面量里的方法、getter、setter:node 是整个方法,header 同 BlockShape.header,只到主体开始之前
export interface DeclaratorFacts {
/** 变量名;解构声明是整个模式。 */
name: Node
type?: Node
value?: Node
/** 值是字典 / 对象字面量(Python 的 dictionary、TypeScript 的 object)时是它的各项,空字面量是空数组;别的值没有。 */
entries?: EntryFacts[]
}
export interface VariableFacts {
@@ -152,14 +160,18 @@ export type PreviewText = Segment[] | Markdown
/**
* 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字的节点(取自 facts,例如参数名、导入项的名称和别名、
* 声明的变量名、类的泛型参数)在源码里的范围。跳到定义时,定义处落在这个范围里就只闪这一行;不填(第三方翻译插件可以不填)就闪整块。
* 声明的变量名、类的泛型参数、字典 / 对象字面量一项的键)在源码里的范围。跳到定义时,定义处落在这个范围里就只闪这一行;不填(第三方翻译插件可以不填)就闪整块。
*/
export interface Defines { defines?: SourceRange }
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值。 */
export interface PropertyItem extends Defines { cells: Segment[][] }
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值。 */
export interface PropertyItem extends Defines {
cells: Segment[][]
/** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项);一层层可以一直往下套。 */
items?: PropertyItem[]
}
/** 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项)。 */
/** 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项、字典 / 对象的各项)。 */
export interface PropertyRow extends Defines {
label: string
value: PreviewText
@@ -236,6 +236,35 @@ describe('python 属性表信息(facts)', () => {
expect(found[9]).toMatchObject({ name: '_helper', access: 'private' })
expect(found[10]).not.toHaveProperty('access')
})
it('字典字面量的各项:字符串键、名字键、嵌套字典连它的各项、**展开;空字典是空数组,别的值没有;类属性也有', async () => {
const source = [
'_KEYS = {',
' "published": "contests:publish",',
' key: 1,',
' # 注释不算一项',
' "nested": {"a": {}, "b": [1]},',
' **other,',
'}',
'EMPTY = {}',
'ITEMS = [1]',
'class K:',
' labels = {"x": 1}',
].join('\n')
const found = await facts(source)
expect(found[1]).toMatchObject({ access: 'private', declarators: [{ name: '_KEYS', entries: [
{ kind: 'pair', key: '"published"', value: '"contests:publish"' },
{ kind: 'pair', key: 'key', value: '1' },
{ kind: 'pair', key: '"nested"', value: '{"a": {}, "b": [1]}', entries: [
{ kind: 'pair', key: '"a"', value: '{}', entries: [] },
{ kind: 'pair', key: '"b"', value: '[1]' },
] },
{ kind: 'spread', value: 'other' },
] }] })
expect(found[8]).toEqual({ access: 'public', declarators: [{ name: 'EMPTY', value: '{}', entries: [] }] })
expect(found[9]).toEqual({ access: 'public', declarators: [{ name: 'ITEMS', value: '[1]' }] })
expect(found[11]).toEqual({ access: 'public', declarators: [{ name: 'labels', value: '{"x": 1}', entries: [{ kind: 'pair', key: '"x"', value: '1' }] }] })
})
})
describe('python 类', () => {
+20 -2
View File
@@ -1,4 +1,4 @@
import type { Access, BlockShape, ClassFacts, CodeBlock, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { Access, BlockShape, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { CommentRules } from './shared'
import * as path from 'node:path'
import { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared'
@@ -230,16 +230,34 @@ function expressionBlock(node: Node, scope: Scope, docstring: boolean): CodeBloc
const type = expression.childForFieldName('type')
if (scope === 'class' && type)
return leafBlock('field', node)
const value = expression.childForFieldName('right')
return {
kind: scope === 'module' && isConstantTarget(left) ? 'const' : 'variable',
...leafShape(node),
facts: {
access: scopeAccess(left, scope),
declarators: [{ name: left, type: type ?? undefined, value: expression.childForFieldName('right') ?? undefined }],
declarators: [{ name: left, type: type ?? undefined, value: value ?? undefined, entries: dictionaryEntries(value) }],
},
}
}
/** 字典字面量的各项:`键: 值`(值又是字典时连它的各项)、`**other`;值不是字典字面量时没有。 */
function dictionaryEntries(value: Node | null): EntryFacts[] | undefined {
if (value?.type !== 'dictionary')
return undefined
return syntaxChildren(value).flatMap((item): EntryFacts[] => {
if (item.type === 'dictionary_splat') {
const [spread] = syntaxChildren(item)
return spread ? [{ kind: 'spread', value: spread }] : []
}
const key = item.childForFieldName('key')
const itemValue = item.childForFieldName('value')
if (item.type !== 'pair' || !key || !itemValue)
return []
return [{ kind: 'pair', key, value: itemValue, entries: dictionaryEntries(itemValue) }]
})
}
/** elif / else 展平成紧跟 if 的兄弟块。 */
function ifBlocks(node: Node): CodeBlock[] {
const blocks = [bodyBlock('if', node, bodyOf(node), 'other')]
@@ -325,7 +325,58 @@ describe('typeScript 属性表信息(facts)', () => {
})
expect(env[24]).toEqual({ declarators: [{ name: 'folder', value: 'workspace.workspaceFolders?.find(f => f.uri.fsPath === workspaceRoot)' }] })
expect(env[38]).toMatchObject({ name: 'chmodAll', access: 'private', async: true })
expect(env[40]).toEqual({ declarators: [{ name: 'result', type: 'EnvLockResult', value: '{ total: files.length, ok: [], failed: [] }' }] })
expect(env[40]).toEqual({ declarators: [{
name: 'result',
type: 'EnvLockResult',
value: '{ total: files.length, ok: [], failed: [] }',
entries: [
{ kind: 'pair', key: 'total', value: 'files.length' },
{ kind: 'pair', key: 'ok', value: '[]' },
{ kind: 'pair', key: 'failed', value: '[]' },
],
}] })
})
it('对象字面量的各项:各种键、简写(键和值是同一个节点)、展开、方法(头部到主体之前)、嵌套对象连它的各项;空对象是空数组,别的值没有', async () => {
const source = [
'const o = {',
' a: 1,',
' \'b-c\': x,',
' [k]: 2,',
' s,',
' ...rest,',
' // 注释不算一项',
' async load(id: string): Promise<void> {',
' await run(id)',
' },',
' get size() { return 1 },',
' n: { d: 4, e: {} },',
'}',
'const empty = {}',
'const list = [1]',
].join('\n')
const at = (text: string): number => source.indexOf(text)
const found = await facts(source)
expect(found[1]).toMatchObject({ declarators: [{ entries: [
{ kind: 'pair', key: 'a', value: '1' },
{ kind: 'pair', key: '\'b-c\'', value: 'x' },
{ kind: 'pair', key: '[k]', value: '2' },
{ kind: 'pair', key: 's', value: 's' },
{ kind: 'spread', value: 'rest' },
{
kind: 'method',
name: 'load',
node: 'async load(id: string): Promise<void> {\n await run(id)\n }',
header: { startIndex: at('async load'), endIndex: at(' {\n await'), startRow: 7, endRow: 7 },
},
{ kind: 'method', name: 'size', header: { startIndex: at('get size'), endIndex: at(' { return 1'), startRow: 10, endRow: 10 } },
{ kind: 'pair', key: 'n', value: '{ d: 4, e: {} }', entries: [
{ kind: 'pair', key: 'd', value: '4' },
{ kind: 'pair', key: 'e', value: '{}', entries: [] },
] },
] }] })
expect(found[14]).toEqual({ access: 'private', declarators: [{ name: 'empty', value: '{}', entries: [] }] })
expect(found[15]).toEqual({ access: 'private', declarators: [{ name: 'list', value: '[1]' }] })
})
it('导入:默认导入、* as、仅类型、副作用导入、require', async () => {
+33 -3
View File
@@ -1,7 +1,7 @@
import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFacts, EntryFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { CommentRules } from './shared'
import * as path from 'node:path'
import { children, compoundShape, extendHeader, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared'
import { children, compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren } from './shared'
const GRAMMARS: Record<string, string> = {
typescript: 'tree-sitter-typescript.wasm',
@@ -205,13 +205,43 @@ function parameterFacts(param: Node): ParameterFacts {
}
function declaratorFacts(declarator: Node): DeclaratorFacts {
const value = declarator.childForFieldName('value')
return {
name: declarator.childForFieldName('name') ?? declarator,
type: annotatedType(declarator.childForFieldName('type')),
value: declarator.childForFieldName('value') ?? undefined,
value: value ?? undefined,
entries: objectEntries(value),
}
}
/** 对象字面量的各项:`键: 值`(值又是对象时连它的各项)、简写 `{ a }`、`...other`、方法;值不是对象字面量时没有。 */
function objectEntries(value: Node | null): EntryFacts[] | undefined {
if (value?.type !== 'object')
return undefined
return syntaxChildren(value).flatMap((member): EntryFacts[] => {
switch (member.type) {
case 'pair': {
const key = member.childForFieldName('key')
const memberValue = member.childForFieldName('value')
return key && memberValue ? [{ kind: 'pair', key, value: memberValue, entries: objectEntries(memberValue) }] : []
}
case 'shorthand_property_identifier':
return [{ kind: 'pair', key: member, value: member }]
case 'spread_element': {
const [spread] = syntaxChildren(member)
return spread ? [{ kind: 'spread', value: spread }] : []
}
case 'method_definition': {
const name = member.childForFieldName('name')
const body = member.childForFieldName('body')
return name ? [{ kind: 'method', name, node: member, header: body ? headerSpan(member, body.startIndex) : nodeSpan(member) }] : []
}
default:
return []
}
})
}
/** import 语句:默认导入、`* as x`、`{ a as b }`、`import x = require('m')`;`import type` 只导入类型。 */
function importFacts(node: Node): ImportFacts {
const require = named(node).find(child => child.type === 'import_require_clause')
+6 -5
View File
@@ -1,4 +1,4 @@
import type { BlockKind, CodeBlock, LanguageAdapter, PreviewText, PropertyRow, Segment } from './contract'
import type { BlockKind, CodeBlock, LanguageAdapter, PreviewText, PropertyItem, PropertyRow, Segment } from './contract'
import { readFileSync } from 'node:fs'
import * as path from 'node:path'
import { Node } from '@vscode/tree-sitter-wasm'
@@ -54,12 +54,13 @@ export function previewText(text: PreviewText): string {
return 'markdown' in text ? text.markdown : segmentText(text)
}
/** 属性表写成文字:每行 `名称: 值`,展开的子行缩进两格、各列用 ` | ` 隔开。 */
/** 属性表写成文字:每行 `名称: 值`,展开的子行比上一层多缩进两格、各列用 ` | ` 隔开。 */
export function propertyLines(rows: PropertyRow[]): string[] {
return rows.flatMap(row => [
`${row.label}: ${previewText(row.value)}`,
...(row.items ?? []).map(item => ` ${item.cells.map(segmentText).join(' | ')}`),
const itemLines = (items: PropertyItem[] | undefined, indent: string): string[] => (items ?? []).flatMap(item => [
`${indent}${item.cells.map(segmentText).join(' | ')}`,
...itemLines(item.items, `${indent} `),
])
return rows.flatMap(row => [`${row.label}: ${previewText(row.value)}`, ...itemLines(row.items, ' ')])
}
/** 语法树节点换成它的源码,没填的可选字段去掉。 */
@@ -80,6 +80,7 @@ export const pythonZhTranslator: Translator = createZhTemplateTranslator({
operators: { '==': '等于', '!=': '不等于' },
operatorParents: ['comparison_operator'],
emptyLiterals: { list: '空列表', dictionary: '空字典' },
mapLiterals: { dictionary: '字典' },
stringNodes: ['string'],
commentText,
// `a if c else b` 的三个具名子节点依次是 a、c、b,语法没给它们起字段名
@@ -85,7 +85,8 @@ export const typescriptZhTranslator: Translator = createZhTemplateTranslator({
isTypePosition: leaf => leaf.parent?.type === 'predefined_type',
operators: { '===': '全等', '!==': '不全等', '==': '等于', '!=': '不等于', '&&': '并且', '||': '或者', '!': '非' },
operatorParents: ['binary_expression', 'unary_expression'],
emptyLiterals: { array: '空数组' },
emptyLiterals: { array: '空数组', object: '空对象' },
mapLiterals: { object: '对象' },
stringNodes: ['string', 'template_string', 'regex'],
commentText,
nodeTemplates: {
@@ -1,4 +1,4 @@
import type { LanguageAdapter, PropertyRow, Segment, Translator } from '../contract'
import type { LanguageAdapter, PropertyItem, PropertyRow, Segment, Translator } from '../contract'
import type { ZhTable } from './zhTemplate'
import { describe, expect, it } from 'vitest'
import { createPythonAdapter } from '../languages/python'
@@ -362,7 +362,7 @@ describe('属性表', () => {
expect(env[15]).toEqual(['名称: ENV_PATTERN', '访问级别: 私有', '值: \'**/.env*\''])
expect(env[27]).toEqual(['名称: include', '初始值: 新建 RelativePattern(folder, ENV_PATTERN)'])
expect(env[28]).toEqual(['名称: uris', '初始值: 等待 workspace.findFiles(include, EXCLUDE_PATTERN)'])
expect(env[40]).toEqual(['名称: result', '类型: EnvLockResult', '初始值: { total: files.length, ok: 空数组, failed: 空数组 }'])
expect(env[40]).toEqual(['名称: result', '类型: EnvLockResult', '初始值: 对象 3 项', ' total | files.length', ' ok | 空数组', ' failed | 空数组'])
expect(issue[1]).toEqual(['来源: \'../gitea/types\'', '仅类型: 是', '导入项: 1 个', ' Issue'])
expect(issue[14]).toEqual(['名称: issue', '初始值: issues.find(i => i.number 全等 issueNumber)'])
expect(routes[19]).toEqual([
@@ -620,6 +620,86 @@ describe('属性表', () => {
expect(rows[1]).toEqual(['名称: i', '访问级别: 私有', '初始值: 0', '名称: n', '访问级别: 私有', '类型: 数值', '初始值: 无'])
expect(rows[3]).toEqual(['名称: v', '初始值: useMemo(() =>'])
})
it('字典:值写「字典 N 项」,每项一个子行「键 | 值」,嵌套的字典一层层往下展开;**展开的键写「展开」,空字典照旧一行', async () => {
const source = [
'_CONTEST_STATUS_KEYS = {',
' "published": "contests:publish",',
' "ended": "contests:end",',
' "draft": "contests:update",',
'}',
'CONFIG = {"db": {"host": host, "opts": {}}, **defaults, 1: await load()}',
'EMPTY = {}',
].join('\n')
const rows = rowTexts(await describeAll(py, 'python', pythonZhTranslator, source))
expect(rows[1]).toEqual([
'名称: _CONTEST_STATUS_KEYS',
'访问级别: 私有',
'初始值: 字典 3 项',
' "published" | "contests:publish"',
' "ended" | "contests:end"',
' "draft" | "contests:update"',
])
expect(rows[6]).toEqual([
'名称: CONFIG',
'访问级别: 公共',
'值: 字典 3 项',
' "db" | 字典 2 项',
' "host" | host',
' "opts" | 空字典',
' 展开 | defaults',
' 1 | 等待 load()',
])
expect(rows[7]).toEqual(['名称: EMPTY', '访问级别: 公共', '值: 空字典'])
})
it('对象:值写「对象 N 项」,键照源码写法(计算属性名照常翻),简写的值就是它自己,方法的值是它的头部,嵌套的一层层往下展开;空对象一行「空对象」', async () => {
const source = [
'export const handlers = {',
' total: files.length,',
' \'x-y\': await load(),',
' [key]: !ok,',
' files,',
' ...rest,',
' async load(id: string): Promise<void> {',
' await run(id)',
' },',
' nested: { a: [], b: { c: 1 } },',
'}',
'let none = {}',
].join('\n')
const described = await describeAll(ts, 'typescript', typescriptZhTranslator, source)
const rows = rowTexts(described)
expect(rows[1]).toEqual([
'名称: handlers',
'访问级别: 公共',
'值: 对象 7 项',
' total | files.length',
' \'x-y\' | 等待 load()',
' [key] | 非 ok',
' files | files',
' 展开 | rest',
' load | 异步 load(id: 字符串): Promise<无>',
' nested | 对象 2 项',
' a | 空数组',
' b | 对象 1 项',
' c | 1',
])
expect(rows[12]).toEqual(['名称: none', '访问级别: 私有', '初始值: 空对象'])
const value = colors(described.get(1) ?? []).find(row => row.label === '值')
expect(value?.value).toEqual([{ text: '对象 7 项', role: 'hint' }])
expect(value?.items?.map(item => item.cells[0])).toEqual([
[{ text: 'total', role: 'variable' }],
[{ text: '\'x-y\'', role: 'string' }],
[{ text: '[', role: 'plain' }, { text: 'key', role: 'variable' }, { text: ']', role: 'plain' }],
[{ text: 'files', role: 'variable' }],
[{ text: '展开', role: 'hint' }],
[{ text: 'load', role: 'function' }],
[{ text: 'nested', role: 'variable' }],
])
expect(value?.items?.[6].cells[1]).toEqual([{ text: '对象 2 项', role: 'hint' }])
})
})
describe('源码下标(跳到定义用)', () => {
@@ -633,7 +713,8 @@ describe('源码下标(跳到定义用)', () => {
}
function propertySegments(rows: PropertyRow[]): Segment[] {
return rows.flatMap(row => [...('markdown' in row.value ? [] : row.value), ...(row.items ?? []).flatMap(item => item.cells.flat())])
const itemSegments = (items: PropertyItem[] | undefined): Segment[] => (items ?? []).flatMap(item => [...item.cells.flat(), ...itemSegments(item.items)])
return rows.flatMap(row => [...('markdown' in row.value ? [] : row.value), ...itemSegments(row.items)])
}
it('python:点分模块名每一节、导入项、别名各自带下标,点号不带;前面有中文注释也对得上', async () => {
@@ -723,6 +804,37 @@ describe('源码下标(跳到定义用)', () => {
expect(marked(tsSource, tsRows.get(3))).toEqual(['名称: i', '名称: j'])
})
it('字典 / 对象字面量:键和值里的名字、字符串带下标,嵌套的也是;每项标出它讲的定义是键,展开没有', async () => {
const source = [
'const o = {',
' total: files.length,',
' "k": v,',
' inner: { deep: x },',
' ...rest,',
' load() {},',
'}',
].join('\n')
const at = (word: string): number => source.indexOf(word)
const items = (await describeAll(ts, 'typescript', typescriptZhTranslator, source)).get(1)?.find(row => row.label === '值')?.items ?? []
expect(items.map(item => item.cells.map(linked))).toEqual([
[[['total', at('total')]], [['files', at('files')], ['length', at('length')]]],
[[['"k"', at('"k"')]], [['v', at('v,')]]],
[[['inner', at('inner')]], []],
[[], [['rest', at('rest')]]],
[[['load', at('load')]], [['load', at('load')]]],
])
expect(items[2].items?.map(item => item.cells.map(linked))).toEqual([[[['deep', at('deep')]], [['x', at('x }')]]]])
expect(items.map(item => item.defines && source.slice(item.defines.startIndex, item.defines.endIndex))).toEqual(['total', '"k"', 'inner', undefined, 'load'])
expect(items[2].items?.[0].defines).toEqual({ startIndex: at('deep'), endIndex: at('deep') + 'deep'.length })
const pySource = 'X = {"a": b, **c}'
const pyItems = (await describeAll(py, 'python', pythonZhTranslator, pySource)).get(1)?.find(row => row.label === '值')?.items ?? []
expect(pyItems.map(item => item.cells.map(linked))).toEqual([
[[['"a"', pySource.indexOf('"a"')]], [['b', pySource.indexOf('b')]]],
[[], [['c', pySource.indexOf('c')]]],
])
})
it('类:名称、类型参数、父类、实现的接口里的名字带下标;只有「类型参数」一行标出它讲的定义(这几个泛型参数),名称是块自己的名字、不标', async () => {
const source = 'export class Repo<K, T extends Entity> extends Base<T> implements Loader<K>, Disposable {}'
const at = (word: string): number => source.indexOf(word)
@@ -789,6 +901,7 @@ describe('中文模板引擎', () => {
operators: {},
operatorParents: [],
emptyLiterals: {},
mapLiterals: {},
stringNodes: [],
commentText: node => node.text,
nodeTemplates: {},
+48 -15
View File
@@ -1,4 +1,4 @@
import type { Access, ClassFacts, Defines, FactBlock, FunctionFacts, ImportFacts, ImportItem, Markdown, Node, PreviewText, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract'
import type { Access, ClassFacts, Defines, EntryFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract'
import { commonIndent, splitLines } from '../editText'
/** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */
@@ -36,8 +36,10 @@ export interface ZhTable {
/** 翻成文字的运算符:只认父节点在 operatorParents 里的运算符节点(TS 的非空断言 `x!` 不算)。 */
operators: Record<string, string>
operatorParents: string[]
/** 空字面量(没有具名子节点)整个换成一个词:空数组 / 空列表 / 空字典。 */
/** 空字面量(没有具名子节点)整个换成一个词:空数组 / 空列表 / 空字典 / 空对象。 */
emptyLiterals: Record<string, string>
/** 字典 / 对象字面量(语言插件给了 entries 的值)的叫法,按节点类型查:属性表里的值写成「字典 3 项」,各项展开成子行。 */
mapLiterals: Record<string, string>
/** 整段原样照抄、按字符串着色的节点。 */
stringNodes: string[]
/** 注释(或 docstring)节点去掉注释符号后的原文,可以多行。 */
@@ -283,17 +285,17 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
function describe(block: FactBlock, source: string): PropertyRow[] {
const { header } = block
// 值里带语句块主体的回调(useMemo(() => { … }))只翻到主体开始之前,和块头部一致
const within = (node: Node, part: SourceSpan): Segment[] => {
const span = part.endIndex > header.endIndex ? { ...part, endIndex: header.endIndex, endRow: header.endRow } : part
return joinTokens(tokensIn(node, span, source))
}
const phrases: Phrases = {
// 值里带语句块主体的回调(useMemo(() => { … }))只翻到主体开始之前,和块头部一致
translated: (node) => {
const clipped = node.endIndex > header.endIndex
const span = {
startIndex: node.startIndex,
endIndex: clipped ? header.endIndex : node.endIndex,
startRow: node.startPosition.row,
endRow: clipped ? header.endRow : node.endPosition.row,
}
return joinTokens(tokensIn(node, span, source))
translated: node => within(node, { startIndex: node.startIndex, endIndex: node.endIndex, startRow: node.startPosition.row, endRow: node.endPosition.row }),
within,
entryCount: (literal, size) => {
const name = table.mapLiterals[literal.type]
return [hint(name ? `${name} ${size} 项` : `${size} 项`)]
},
verbatim: node => verbatim(node, source),
// 点分模块名逐节照抄,每一节各自能跳到定义,和 VS Code 里一样
@@ -355,10 +357,14 @@ function joinTokens(tokens: Token[]): Segment[] {
// ─── 属性表 ───────────────────────────────────────────────────────────────────
/** 属性表里值的三种来源。 */
/** 属性表里的值怎么写。 */
interface Phrases {
/** 按表翻译,只翻落在块头部里的那一段。 */
translated: (node: Node) => Segment[]
/** 同 translated,只翻 node 落在 span 里的那一段(对象字面量里方法的头部)。 */
within: (node: Node, span: SourceSpan) => Segment[]
/** 字典 / 对象字面量的项数:「字典 3 项」,叫法按字面量的节点类型查表。 */
entryCount: (literal: Node, size: number) => Segment[]
/** 源码原样照抄、按源码着色(装饰器)。 */
verbatim: (node: Node) => Segment[]
/** 导入来源:字符串字面量按字符串着色,模块名按类型着色。 */
@@ -492,13 +498,40 @@ function importRows(facts: ImportFacts, say: Phrases): PropertyRow[] {
]
}
/** 常量 / 变量:每个声明的变量依次是名称 / 访问级别 / 类型(有标注才有)/ 值(变量叫初始值)。 */
/** 常量 / 变量:每个声明的变量依次是名称 / 访问级别 / 类型(有标注才有)/ 值(变量叫初始值,字典 / 对象字面量见 literalValue)。 */
function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] {
const valueLabel = block.kind === 'const' ? '值' : '初始值'
return block.facts.declarators.flatMap(declarator => [
{ label: '名称', value: say.translated(declarator.name), ...defining(declarator.name) },
...accessRows(block.facts.access),
...(declarator.type ? [{ label: '类型', value: say.translated(declarator.type) }] : []),
{ label: valueLabel, value: declarator.value ? say.translated(declarator.value) : [hint('无')] },
{ label: valueLabel, ...(declarator.value ? literalValue(declarator.value, declarator.entries, say) : { value: [hint('无')] }) },
])
}
/**
* 值:字典 / 对象字面量(语言插件给了 entries)写项数,各项展开成下一层子行;
* 空字面量和别的值是一行译文(空的译成「空字典」「空对象」)。
*/
function literalValue(value: Node, entries: EntryFacts[] | undefined, say: Phrases): { value: Segment[], items?: PropertyItem[] } {
if (!entries?.length)
return { value: say.translated(value) }
return { value: say.entryCount(value, entries.length), items: entries.map(entry => entryItem(entry, say)) }
}
/**
* 字典 / 对象字面量的一项是一个子行,两列:键 | 值。键照源码写法(字符串带引号,计算属性名 `[k]` 照常翻),
* `**x`、`...x` 的键写「展开」、值是展开的表达式,方法的值是它的头部。讲的定义是键(对象的属性名)。
*/
function entryItem(entry: EntryFacts, say: Phrases): PropertyItem {
switch (entry.kind) {
case 'pair': {
const { value, ...nested } = literalValue(entry.value, entry.entries, say)
return { cells: [say.translated(entry.key), value], ...nested, ...defining(entry.key) }
}
case 'spread':
return { cells: [[hint('展开')], say.translated(entry.value)] }
case 'method':
return { cells: [say.translated(entry.name), say.within(entry.node, entry.header)], ...defining(entry.name) }
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ export function App(): ReactElement | null {
/** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */
const [reveal, setReveal] = useState<Reveal | null>(null)
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds), [doc, view.folds])
/** 记下这几块、属性行折叠还是展开,从此不再按默认 */
/** 记下这几块、属性行、子行折叠还是展开,从此不再按默认 */
const setFolded = useCallback((ids: string[], folded: boolean) => setView(prev => ({
...prev,
folds: { ...prev.folds, ...Object.fromEntries(ids.map(id => [id, folded])) },
@@ -29,7 +29,7 @@ const KIND_LABEL: Record<BlockKind, string> = {
export interface BlockTreeState {
version: number
editing: ReadonlySet<string>
/** 眼下折叠着的块 id 和属性行 id(propertyRowId):用户的选择叠在默认上,见 collapsedIds */
/** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */
collapsed: ReadonlySet<string>
/** 刚点了「编辑」、要把光标放进去的块 */
focusId: string | null
@@ -1,12 +1,12 @@
import type { ReactElement } from 'react'
import type { PropertyItem, PropertyRow } from './types'
import type { PropertyItem, PropertyRow, Segment } from './types'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { Fragment } from 'react'
import { SegmentText } from './SegmentText'
import { TextPreview } from './TextPreview'
import { propertyItemId, propertyRowId } from './viewState'
/** 子行里除最后一列以外各列的宽度(参数的名称、类型),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */
/** 子行里除最后一列以外各列的宽度(参数的名称、类型,字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */
const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)']
function columns(count: number): string {
@@ -14,32 +14,89 @@ function columns(count: number): string {
return [...fixed, 'minmax(0, 1fr)'].join(' ')
}
/** 展开在一行下面的子行:左边一条竖线,每个子行几列对齐。 */
function PropertyItems({ rowId, items }: { rowId: string, items: PropertyItem[] }): ReactElement {
/** 眼下折叠着哪些属性行、子行,点折叠箭头时切换哪个。 */
interface Folding {
collapsed: ReadonlySet<string>
onToggleFold: (id: string) => void
}
function segmentsText(segments: Segment[]): string {
return segments.map(segment => segment.text).join('')
}
/**
* 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换;
* 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。
*/
function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Segment[], foldable: boolean, folding: Folding }): ReactElement {
if (!foldable) {
return (
<div className="flex min-w-0 gap-1">
<span className="w-3.5 shrink-0" />
<p className="cr-code min-w-0">
<SegmentText segments={cell} />
</p>
</div>
)
}
const folded = folding.collapsed.has(id)
const Chevron = folded ? ChevronRight : ChevronDown
return (
<button
type="button"
className="group flex min-w-0 items-start gap-1 rounded-sm text-left"
aria-label={`${folded ? '展开' : '折叠'}${segmentsText(cell)}`}
aria-expanded={!folded}
onClick={() => folding.onToggleFold(id)}
>
<span className="flex h-5 w-3.5 shrink-0 items-center">
<Chevron size={14} strokeWidth={1.8} className="text-[var(--vscode-descriptionForeground)] group-hover:text-[var(--vscode-foreground)]" />
</span>
<span className="cr-code min-w-0">
<SegmentText segments={cell} />
</span>
</button>
)
}
/**
* 展开在一行或一个子行(parentId)下面的一层子行:左边一条竖线,每个子行几列对齐。
* 带下一层的子行(字典 / 对象的值又是字典 / 对象),下一层展开在它下面、再往右缩进一层,默认展开。
*/
function PropertyItems({ parentId, items, folding }: { parentId: string, items: PropertyItem[], folding: Folding }): ReactElement {
const nested = items.some(item => (item.items?.length ?? 0) > 0)
return (
<div className="flex gap-2.5 pl-[5px]">
<div className="cr-guide w-px shrink-0" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
{items.map((item, index) => (
<div
key={index}
className="cr-prop-item grid gap-x-3"
data-id={propertyItemId(rowId, index)}
style={{ gridTemplateColumns: columns(item.cells.length) }}
>
{item.cells.map((cell, i) => (
<p key={i} className="cr-code min-w-0">
<SegmentText segments={cell} />
</p>
))}
</div>
))}
{items.map((item, index) => {
const id = propertyItemId(parentId, index)
const children = item.items ?? []
return (
<Fragment key={index}>
<div
className="cr-prop-item grid gap-x-3"
data-id={id}
style={{ gridTemplateColumns: columns(item.cells.length) }}
>
{item.cells.map((cell, i) => i === 0 && nested
? <ItemHead key={i} id={id} cell={cell} foldable={children.length > 0} folding={folding} />
: (
<p key={i} className="cr-code min-w-0">
<SegmentText segments={cell} />
</p>
))}
</div>
{children.length > 0 && !folding.collapsed.has(id) && <PropertyItems parentId={id} items={children} folding={folding} />}
</Fragment>
)
})}
</div>
</div>
)
}
/** 函数、类、导入、常量、变量块的预览:属性名一列对齐,值按词性着色;输入参数、导入项下面展开子行。 */
/** 函数、类、导入、常量、变量块的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行。 */
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: {
blockId: string
rows: PropertyRow[]
@@ -72,7 +129,7 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: {
: <span className="cr-prop-label flex h-5 w-[76px] shrink-0 items-center">{row.label}</span>}
<TextPreview text={row.value} className="min-w-0 flex-1" />
</div>
{items.length > 0 && !folded && <PropertyItems rowId={id} items={items} />}
{items.length > 0 && !folded && <PropertyItems parentId={id} items={items} folding={{ collapsed, onToggleFold }} />}
</Fragment>
)
})}
@@ -1,6 +1,6 @@
import type { LanguageAdapter, Translator } from '../../../src/codeReader/contract'
import type { RevealPlan } from './reveal'
import type { BlockKind, BlockView, PropertyRow, Segment } from './types'
import type { BlockKind, BlockView, PropertyItem, PropertyRow, Segment } from './types'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../../../src/codeReader/blockView'
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
@@ -138,13 +138,25 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => {
/** 不填 defines 的第三方翻译插件:属性表照旧,行和子行都不标讲的是哪个定义。 */
function withoutDefines(translator: Translator): Translator {
const strip = (items: PropertyItem[]): PropertyItem[] => items.map(({ cells, items: nested }) => nested ? { cells, items: strip(nested) } : { cells })
return {
...translator,
describe: (block, source) => translator.describe(block, source).map(({ label, value, items }) =>
items ? { label, value, items: items.map(({ cells }) => ({ cells })) } : { label, value }),
items ? { label, value, items: strip(items) } : { label, value }),
}
}
/** parentId 下面一层层的子行里 id 是 target 的那个。 */
function findItem(items: PropertyItem[] | undefined, parentId: string, target: string): PropertyItem | undefined {
for (const [index, item] of (items ?? []).entries()) {
const id = propertyItemId(parentId, index)
const found = id === target ? item : findItem(item.items, id, target)
if (found)
return found
}
return undefined
}
function flatten(list: BlockView[]): BlockView[] {
return list.flatMap(view => [view, ...flatten(view.children)])
}
@@ -163,7 +175,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => {
const rowId = propertyRowId(block.id, index)
if (plan.row === rowId)
return `${row.label}: ${text(row.value)}`
const item = row.items?.find((_, i) => plan.row === propertyItemId(rowId, i))
const item = findItem(row.items, rowId, plan.row)
if (item)
return item.cells.map(text).join(' | ')
}
@@ -267,6 +279,27 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => {
expect(await atPy('run')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'def run(self):' })
})
it('字典 / 对象:定义落在对象的属性名上只闪那一项,嵌套的连外层各层一起展开;值里只是显示着的名字闪整块,展开到它那一层', async () => {
const source = [
'export const routes = {',
' home: { path: \'/\', page: Home },',
' about,',
'}',
'use(routes.home.path)',
].join('\n')
const at = (needle: string): ReturnType<typeof jump> => jump(ts, 'typescript', typescriptZhTranslator, source, needle)
expect(await at('home:')).toEqual({ plan: { id: '0', row: '0/p2/0', expand: ['0/p2'] }, flashed: 'home | 对象 2 项' })
expect(await at('path:')).toEqual({ plan: { id: '0', row: '0/p2/0/0', expand: ['0/p2', '0/p2/0'] }, flashed: 'path | \'/\'' })
expect(await at('about,')).toEqual({ plan: { id: '0', row: '0/p2/1', expand: ['0/p2'] }, flashed: 'about | about' })
expect(await at('Home')).toEqual({ plan: { id: '0', expand: ['0/p2', '0/p2/0'] }, flashed: 'export const routes = {' })
const plain = (needle: string): ReturnType<typeof jump> => jump(ts, 'typescript', withoutDefines(typescriptZhTranslator), source, needle)
expect(await plain('path:')).toEqual({ plan: { id: '0', expand: ['0/p2', '0/p2/0'] }, flashed: 'export const routes = {' })
const pySource = 'CONFIG = {"db": {"host": host}}'
const atPy = (needle: string): ReturnType<typeof jump> => jump(py, 'python', pythonZhTranslator, pySource, needle)
expect(await atPy('host}')).toEqual({ plan: { id: '0', expand: ['0/p2', '0/p2/0'] }, flashed: 'CONFIG = {"db": {"host": host}}' })
})
it('typeScript:翻译插件没标 defines 时闪整块', async () => {
const at = (needle: string): ReturnType<typeof jump> => jump(ts, 'typescript', withoutDefines(typescriptZhTranslator), TS, needle)
expect(await at('path: string')).toEqual({ plan: { id: '1', expand: ['1/p3'] }, flashed: 'export function load(path: string, { encoding }: Options): string {' })
+36 -9
View File
@@ -1,7 +1,7 @@
import type { BlockView, PreviewText, SourceRange } from './types'
import type { BlockView, PreviewText, PropertyItem, SourceRange } from './types'
import { propertyItemId, propertyRowId } from './viewState'
/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块和属性行:它们折叠着,目标就不在页面上。 */
/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */
export interface RevealPlan {
id: string
/** 属性行 id(propertyRowId)或子行 id(propertyItemId);没有时闪整块 */
@@ -38,8 +38,9 @@ function shows(text: PreviewText, offset: number): boolean {
/**
* 块里哪儿是 offset 处的定义,从具体到笼统:
* ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块;
* ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个)→ 只闪它,子行连它所在的行一起展开;
* ③ 块里只是显示着这个名字(一行译文里的循环变量、没填 defines 的翻译插件)→ 整块,名字在子行里时展开那一行。
* ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它,
* 子行连它上面的各层(所在的行、嵌套时外层的子行)一起展开;
* ③ 块里只是显示着这个名字(一行译文里的循环变量、没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。
* 不在这块里是 undefined。
*/
function locate(block: BlockView, offset: number): Spot | undefined {
@@ -51,15 +52,41 @@ function locate(block: BlockView, offset: number): Spot | undefined {
for (const { row, id } of rows) {
if (covers(row.defines, offset))
return { row: id, expand: [] }
const item = row.items?.findIndex(entry => covers(entry.defines, offset)) ?? -1
if (item >= 0)
return { row: propertyItemId(id, item), expand: [id] }
const item = definingItem(row.items, id, offset)
if (item)
return item
}
for (const { row, id } of rows) {
if (shows(row.value, offset))
return { expand: [] }
if (row.items?.some(item => item.cells.some(cell => shows(cell, offset))))
return { expand: [id] }
const expand = showingItem(row.items, id, offset)
if (expand)
return { expand }
}
return undefined
}
/** parentId 下面一层层的子行里,defines 盖住 offset 的那个:闪它,展开它上面的各层(parentId 和它所在的那串上层子行)。 */
function definingItem(items: PropertyItem[] | undefined, parentId: string, offset: number): Spot | undefined {
for (const [index, item] of (items ?? []).entries()) {
const id = propertyItemId(parentId, index)
if (covers(item.defines, offset))
return { row: id, expand: [parentId] }
const inner = definingItem(item.items, id, offset)
if (inner)
return { ...inner, expand: [parentId, ...inner.expand] }
}
return undefined
}
/** parentId 下面一层层的子行里,哪个显示着 offset 处的名字:要展开的各层(parentId 到那个子行所在的那一层)。 */
function showingItem(items: PropertyItem[] | undefined, parentId: string, offset: number): string[] | undefined {
for (const [index, item] of (items ?? []).entries()) {
if (item.cells.some(cell => shows(cell, offset)))
return [parentId]
const inner = showingItem(item.items, propertyItemId(parentId, index), offset)
if (inner)
return [parentId, ...inner]
}
return undefined
}
+8 -4
View File
@@ -27,15 +27,19 @@ export type PreviewText = Segment[] | Markdown
export interface SourceRange { startIndex: number, endIndex: number }
/**
* 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字(参数名、导入项、声明的变量名)在源码里的范围。
* 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字(参数名、导入项、声明的变量名、字典 / 对象一项的键)在源码里的范围。
* 跳到的定义落在里面就只闪这一行;翻译插件没填就闪整块
*/
export interface Defines { defines?: SourceRange }
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值 */
export interface PropertyItem extends Defines { cells: Segment[][] }
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值 */
export interface PropertyItem extends Defines {
cells: Segment[][]
/** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项),一层层可以一直往下套 */
items?: PropertyItem[]
}
/** 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项) */
/** 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项、字典 / 对象的各项) */
export interface PropertyRow extends Defines {
label: string
value: PreviewText
@@ -25,7 +25,7 @@ function flash(el: HTMLElement): void {
/**
* 滚到 reveal 指的位置,处理完调 done:
* ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部,折叠着的不展开;
* ② 跳到定义:先展开把目标藏起来的块和属性行(expand 之后重新渲染,再进来一次),再把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。
* ② 跳到定义:先展开把目标藏起来的块、属性行和子行(expand 之后重新渲染,再进来一次),再把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。
*/
export function useReveal(
doc: DocumentMessage | null,
@@ -1,7 +1,7 @@
import type { BlockKind, BlockView } from './types'
import type { ViewStateStorage } from './viewState'
import { describe, expect, it } from 'vitest'
import { collapsedIds, createViewStateStore, propertyRowId } from './viewState'
import { collapsedIds, createViewStateStore, propertyItemId, propertyRowId } from './viewState'
/** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回;也能塞进旧版本存的东西 */
function memoryStorage(initial?: unknown): ViewStateStorage {
@@ -73,6 +73,32 @@ describe('webview 状态', () => {
createViewStateStore(storage).update({ folds: { 6.1: false, [propertyRowId('6.1.0', 3)]: true } })
expect(createViewStateStore(storage).get().folds).toEqual({ '6.1': false, '6.1.0/p3': true })
})
it('子行 id 是上一层的 id 加子行序号,一层层往后接;不同位置的不会撞上,也不会和块 id、属性行 id 撞上', () => {
const row = propertyRowId('6.1.0', 3)
expect(propertyItemId(row, 1)).toBe('6.1.0/p3/1')
expect(propertyItemId(propertyItemId(row, 1), 0)).toBe('6.1.0/p3/1/0')
const ids = [
'6',
'6.1',
propertyRowId('6', 1),
propertyRowId('6', 11),
propertyItemId(propertyRowId('6', 1), 1),
propertyItemId(propertyRowId('6', 11), 1),
propertyItemId(propertyItemId(propertyRowId('6', 1), 1), 1),
propertyItemId(propertyItemId(propertyRowId('6', 1), 11), 1),
propertyItemId(propertyItemId(propertyRowId('6', 1), 1), 11),
propertyItemId(propertyRowId('6.1', 1), 1),
]
expect(new Set(ids).size).toBe(ids.length)
})
it('嵌套子行的折叠选择也存在 folds 里,页面重新加载后还在', () => {
const storage = memoryStorage()
const nested = propertyItemId(propertyItemId(propertyRowId('2', 2), 0), 1)
createViewStateStore(storage).update({ folds: { [nested]: true, [propertyItemId(propertyRowId('2', 2), 0)]: false } })
expect(createViewStateStore(storage).get().folds).toEqual({ '2/p2/0/1': true, '2/p2/0': false })
})
})
describe('眼下折叠着哪些', () => {
@@ -84,4 +110,11 @@ describe('眼下折叠着哪些', () => {
const folds = { '1': false, '2': false, '1.0': true, '1.0.0': false, [propertyRowId('0', 2)]: true, [propertyRowId('2.0', 1)]: false }
expect([...collapsedIds(BLOCKS, folds)].sort()).toEqual(['0/p2', '1.0', '2.0'])
})
it('带下一层的子行默认展开,用户折叠过的按他的', () => {
const outer = propertyItemId(propertyRowId('0', 2), 0)
const inner = propertyItemId(outer, 3)
expect(collapsedIds(BLOCKS, {}).has(outer)).toBe(false)
expect([...collapsedIds(BLOCKS, { [outer]: false, [inner]: true })].sort()).toEqual(['0/p2/0/3', '1', '1.0.0', '2', '2.0'])
})
})
+13 -8
View File
@@ -3,11 +3,11 @@ import type { BlockKind, BlockView } from './types'
/**
* 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、
* 用户把哪些块和属性行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行 id 见 propertyRowId),以及滚到了哪。
* 用户把哪些块、属性行和子行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行、子行 id 见 propertyRowId、propertyItemId),以及滚到了哪。
*/
export interface ViewState {
editing: string[]
/** 用户自己点过折叠箭头的块和属性行:id → 折叠着没有。没点过的按默认,见 collapsedIds */
/** 用户自己点过折叠箭头的块、属性行和子行:id → 折叠着没有。没点过的按默认,见 collapsedIds */
folds: Record<string, boolean>
scroll?: ScrollAnchor
}
@@ -54,12 +54,12 @@ function readViewState(saved: unknown): ViewState {
return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds }
}
/** 块默认折叠吗:函数、类块收起主体(属性表照常显示),其余块展开。属性行默认都展开。 */
/** 块默认折叠吗:函数、类块收起主体(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */
export function foldedByDefault(kind: BlockKind): boolean {
return kind === 'function' || kind === 'class'
}
/** 眼下折叠着的块和属性行:用户点过的按他点的,没点过的按 foldedByDefault。 */
/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault。 */
export function collapsedIds(blocks: readonly BlockView[], folds: Readonly<Record<string, boolean>>): Set<string> {
const collapsed = new Set(Object.keys(folds).filter(id => folds[id]))
const visit = (list: readonly BlockView[]): void => {
@@ -74,14 +74,19 @@ export function collapsedIds(blocks: readonly BlockView[], folds: Readonly<Recor
}
/**
* 属性表里带子行的那一行(输入参数、导入项)的折叠 id,和块 id 放在同一份 folds 里:
* 属性表里带子行的那一行(输入参数、导入项、字典 / 对象的值)的折叠 id,和块 id 放在同一份 folds 里:
* `<块 id>/p<行序号>`,例如 '6.1.0/p3'。块 id 只由数字和点组成,带 '/' 的不会和任何块 id 撞上。
*/
export function propertyRowId(blockId: string, rowIndex: number): string {
return `${blockId}/p${rowIndex}`
}
/** 属性行展开的第几个子行,例如 '6.1.0/p3/1'。只用来在页面上找到它(跳到定义时闪它),子行不折叠、不进 folds。 */
export function propertyItemId(rowId: string, itemIndex: number): string {
return `${rowId}/${itemIndex}`
/**
* 一行或一个子行(parentId)下面展开的第几个子行:`<上一层的 id>/<子行序号>`,一层套一层往后接,
* 例如 '6.1.0/p3/1' 是第 3 行的第 1 个子行,'6.1.0/p3/1/0' 是它下面的第 0 个。跳到定义时拿它在页面上找到要闪的子行;
* 带下一层子行的子行(字典 / 对象的值又是字典 / 对象)也拿它当折叠 id,和属性行一样记进 folds、默认展开。
* 只有行那一节带 'p',后面各节都是纯数字,不同位置的 id 不会撞上,也不会和块 id、属性行 id 撞上。
*/
export function propertyItemId(parentId: string, itemIndex: number): string {
return `${parentId}/${itemIndex}`
}