✨ feat(vscode): 代码阅读函数块默认折叠,函数说明进属性表「说明」行

This commit is contained in:
cruldra committed 2026-09-25 13:09:53 +08:00
1 parent 62f48fe67c
commit 9b32d4b5a8
17 files changed
+489 -100

No files matched your search

+45 -8
View File
@@ -64,14 +64,50 @@ describe('blockView', () => {
}
})
it.each(SAMPLES)('%#: 各块头部的行互不重叠,每行源码最多归一块编辑', async (adapter, languageId, translator, fixture) => {
const owner = new Map<number, string>()
for (const view of flatten(await views(adapter, languageId, translator, readFixture(fixture)))) {
for (let line = view.startLine; line <= view.endLine; line++) {
expect(owner.get(line), `第 ${line} 行又归 ${view.id}`).toBeUndefined()
owner.set(line, view.id)
}
}
})
it('函数的说明算在函数块的头部里:编辑时和签名一起显示、一起改', async () => {
const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
expect(byId(env, '5')).toMatchObject({ kind: 'function', startLine: 17, endLine: 22 })
expect(byId(env, '5').code.split('\n')).toEqual([
'/**',
' * Scan the workspace folder rooted at `workspaceRoot` for all `.env*`',
' * files, skipping `node_modules`, `.git`, and `.claude`. Returns absolute',
' * fs paths. Empty array if the workspace folder isn\'t currently open.',
' */',
'export async function findEnvFiles(workspaceRoot: string): Promise<string[]> {',
])
expect(byId(env, '4')).toMatchObject({ kind: 'const', startLine: 15, endLine: 15 })
const routes = await views(py, 'python', pythonZhTranslator, readFixture('routes.py'))
const getUser = flatten(routes).find(view => view.kind === 'function' && view.startLine === 28)
expect(getUser).toMatchObject({ startLine: 28, endLine: 37 })
expect(getUser?.code.split('\n').slice(-5)).toEqual([
') -> UserDetailResponse:',
' """获取用户详情。',
'',
' 管理员可查看任意用户,普通用户只能查看自己。',
' """',
])
expect(getUser?.children[0]).toMatchObject({ kind: 'comment', startLine: 38, endLine: 38 })
})
it('id 是块在树里的位置,行号 0 起,多行块去掉共同缩进', async () => {
const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
expect(byId(env, '6.1.0')).toMatchObject({ kind: 'return', startLine: 25, endLine: 25, code: 'return []' })
expect(byId(env, '6.1')).toMatchObject({ kind: 'if', startLine: 24, code: 'if (!folder)' })
expect(byId(env, '8.2.1')).toMatchObject({ kind: 'catch', startLine: 45, code: 'catch (err) {' })
expect(byId(env, '5.1.0')).toMatchObject({ kind: 'return', startLine: 25, endLine: 25, code: 'return []' })
expect(byId(env, '5.1')).toMatchObject({ kind: 'if', startLine: 24, code: 'if (!folder)' })
expect(byId(env, '7.2.1')).toMatchObject({ kind: 'catch', startLine: 45, code: 'catch (err) {' })
const issue = await views(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt'))
expect(byId(issue, '3.4')).toMatchObject({
expect(byId(issue, '2.4')).toMatchObject({
kind: 'return',
startLine: 19,
endLine: 23,
@@ -81,8 +117,8 @@ describe('blockView', () => {
it('源码着色来自语法树:每个叶子一段,空白和标点归 plain', async () => {
const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
const tokens = byId(env, '6.3').codeTokens
expect(byId(env, '6.3').code).toBe('const uris = await workspace.findFiles(include, EXCLUDE_PATTERN)')
const tokens = byId(env, '5.3').codeTokens
expect(byId(env, '5.3').code).toBe('const uris = await workspace.findFiles(include, EXCLUDE_PATTERN)')
expect(tokens.slice(0, 7)).toEqual([
{ text: 'const', role: 'storage' },
{ text: ' ', role: 'plain' },
@@ -97,14 +133,15 @@ describe('blockView', () => {
// 中文注释之后的行:语法树下标按字符串下标算,不按 UTF-8 字节
const issue = await views(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt'))
expect(byId(issue, '3').codeTokens.slice(0, 5)).toEqual([
expect(byId(issue, '2').codeTokens.slice(0, 7)).toEqual([
{ text: '/**\n * 纯判定:工单被未完成的前置挡住才算锁定。快照里找不到工单或前置一律放行,\n * 与 webview 的 `isIssueLocked` 保持同一语义。\n */', role: 'comment' },
{ text: '\n', role: 'plain' },
{ text: 'export', role: 'keyword' },
{ text: ' ', role: 'plain' },
{ text: 'function', role: 'storage' },
{ text: ' ', role: 'plain' },
{ text: 'evaluateLockedReason', role: 'function' },
])
expect(byId(issue, '2').codeTokens).toEqual([{ text: byId(issue, '2').code, role: 'comment' }])
const sample = await views(py, 'python', pythonZhTranslator, readFixture('sample.py'))
const signature = byId(sample, '5').codeTokens
+9 -1
View File
@@ -26,6 +26,11 @@ export interface ParameterFacts {
export interface FunctionFacts {
/** 匿名函数(`export default function () {}`)没有。 */
name?: Node
/**
* 函数自己的说明:TypeScript 是紧贴在声明前面的 JSDoc 注释(以 `/**` 开头),Python 是主体开头的 docstring(字符串节点)。
* 它算在块的头部里,不再单独成一块注释。
*/
doc?: Node
/** 嵌套在函数体、条件等语句块里的函数没有。 */
access?: Access
async: boolean
@@ -67,7 +72,10 @@ export interface VariableFacts {
export interface BlockShape {
/** 这一块对应的语法树节点(整条语句)。 */
node: Node
/** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。 */
/**
* 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。
* 函数的说明(FunctionFacts.doc)也算在里面:JSDoc 往上延到注释开头,docstring 往下延到字符串结尾。
*/
header: SourceSpan
children: CodeBlock[]
}
+9 -4
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { toBlockViews } from './blockView'
import { blockReplacement, commonIndent, reindentBlock, replaceLines } from './editText'
import { createPythonAdapter } from './languages/python'
import { createTypeScriptAdapter } from './languages/typescript'
import { readFixture, WASM_DIR, withBlocks } from './testSupport'
@@ -33,10 +34,14 @@ describe('行替换', () => {
expect(reindentBlock([' x'], 'a\n b')).toBe(' a\n b')
})
it('把预览里的 code 原样写回,文件不变(去缩进与补缩进互逆)', async () => {
const adapter = createTypeScriptAdapter(WASM_DIR)
const source = readFixture('envLock.ts.txt')
const views = await withBlocks(adapter, 'typescript', source, blocks => toBlockViews(blocks, source, adapter, undefined))
it.each([
[createTypeScriptAdapter(WASM_DIR), 'typescript', 'envLock.ts.txt'],
[createTypeScriptAdapter(WASM_DIR), 'typescript', 'issueLock.ts.txt'],
[createPythonAdapter(WASM_DIR), 'python', 'sample.py'],
[createPythonAdapter(WASM_DIR), 'python', 'routes.py'],
] as const)('%#: 把预览里的 code 原样写回,文件不变(去缩进与补缩进互逆,函数头部连着说明也一样)', async (adapter, languageId, fixture) => {
const source = readFixture(fixture)
const views = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, source, adapter, undefined))
const all = views.flatMap(function flat(view): typeof views {
return [view, ...view.children.flatMap(flat)]
})
+39 -2
View File
@@ -20,8 +20,7 @@ const SAMPLE: Outline[] = [
{ at: 4, kind: 'import' },
{ at: 6, kind: 'const' },
{ at: 7, kind: 'variable' },
{ at: 10, kind: 'function', kids: [
{ at: 11, kind: 'comment' },
{ at: [10, 11], kind: 'function', kids: [
{ at: 12, kind: 'comment' },
{ at: 13, kind: 'variable' },
{ at: 14, kind: 'if', kids: [{ at: 15, kind: 'return' }] },
@@ -91,6 +90,42 @@ describe('python 代码块树', () => {
})
})
describe('python 函数的 docstring', () => {
const source = [
'class K:',
' """类说明"""',
' def run(self): # 行尾',
' # 先说一句',
' """跑一遍。',
'',
' 细节。',
' """ # 行尾',
' # 真正的注释',
' return 1',
'def g(): "单行"',
].join('\n')
it('算进函数头部、不单独成块,它前面的注释也在头部里;后面的注释照旧成块;类的 docstring 照旧是注释块', async () => {
expect(await tree(source)).toEqual([
{ at: 1, kind: 'class', kids: [
{ at: 2, kind: 'comment' },
{ at: [3, 8], kind: 'function', kids: [
{ at: 9, kind: 'comment' },
{ at: 10, kind: 'return' },
] },
] },
{ at: 11, kind: 'function' },
])
})
it('进 facts.doc:是那个字符串节点;没有 docstring 的函数没有', async () => {
const found = await facts(source)
expect(found[3]).toMatchObject({ name: 'run', doc: '"""跑一遍。\n\n 细节。\n """' })
expect(found[11]).toMatchObject({ name: 'g', doc: '"单行"' })
expect(await facts('def f():\n x = 1\n """not a docstring"""')).not.toHaveProperty('1.doc')
})
})
describe('python 属性表信息(facts)', () => {
it('sample.py:import / from import、模块和类里的赋值、函数和方法', async () => {
const sample = await facts(readFixture('sample.py'))
@@ -99,6 +134,7 @@ describe('python 属性表信息(facts)', () => {
expect(sample[6]).toEqual({ access: 'public', declarators: [{ name: 'MAX_DEPTH', value: '8' }] })
expect(sample[10]).toEqual({
name: 'find_env_files',
doc: '"""Return every .env file under root."""',
access: 'public',
async: false,
decorators: [],
@@ -128,6 +164,7 @@ describe('python 属性表信息(facts)', () => {
})
expect(routes[29]).toEqual({
name: 'get_user',
doc: '"""获取用户详情。\n\n 管理员可查看任意用户,普通用户只能查看自己。\n """',
access: 'public',
async: true,
decorators: ['@router.get("/{user_id}", response_model=UserDetailResponse)'],
+33 -7
View File
@@ -1,4 +1,5 @@
import type { Access, BlockShape, CodeBlock, 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'
@@ -8,9 +9,11 @@ type Scope = 'module' | 'class' | 'other'
const CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/
const PARAMETER_SEPARATORS = new Set(['keyword_separator', 'positional_separator'])
const PATTERNS = new Set(['pattern_list', 'tuple_pattern', 'list_pattern'])
// 只有 # 行注释;函数的说明是主体开头的 docstring,不是写在前面的注释
const COMMENTS: CommentRules = { isLine: () => true, isDoc: () => false }
function statements(items: Node[], scope: Scope, afterRow: number, docstring: boolean): CodeBlock[] {
return listBlocks(items, afterRow, (item, first) => statementBlocks(item, scope, docstring && first), () => true)
return listBlocks(items, afterRow, (item, first) => statementBlocks(item, scope, docstring && first), COMMENTS)
}
/** 冒号后面的主体:body / consequence 字段,except / finally 子句里是不带字段名的 block。 */
@@ -69,15 +72,37 @@ function statementBlocks(node: Node, scope: Scope, docstring: boolean): CodeBloc
/** function_definition / class_definition;outer 是带装饰器的整体时头部从装饰器开始。 */
function definitionBlock(definition: Node, outer: Node, scope: Scope): CodeBlock {
const body = definition.childForFieldName('body')
if (definition.type === 'class_definition')
return { kind: 'class', ...bodyShape(outer, definition.childForFieldName('body'), 'class', true) }
return { kind: 'class', ...bodyShape(outer, body, 'class', true) }
const doc = docstringOf(body)
return {
kind: 'function',
...bodyShape(outer, definition.childForFieldName('body'), 'other', true),
facts: functionFacts(definition, outer, scope),
...(body && doc ? documentedShape(outer, body, doc) : bodyShape(outer, body, 'other')),
facts: functionFacts(definition, outer, scope, doc),
}
}
/** 主体开头的 docstring:第一条语句(注释不算)是单独一个字符串表达式时,返回那个字符串节点。 */
function docstringOf(body: Node | null): Node | undefined {
const first = named(body).find(child => child.type !== 'comment')
return first && docstringIn(first)
}
/** 语句是单独一个字符串表达式(docstring 的写法)时返回那个字符串节点。 */
function docstringIn(statement: Node): Node | undefined {
if (statement.type !== 'expression_statement')
return undefined
const [expression, ...rest] = named(statement)
return rest.length === 0 && (expression?.type === 'string' || expression?.type === 'concatenated_string') ? expression : undefined
}
/** 函数的 docstring 是它自己的说明:算进头部(连同它前面的注释),子块从它后面开始。 */
function documentedShape(outer: Node, body: Node, doc: Node): BlockShape {
const rest = named(body).filter(item => item.startIndex >= doc.endIndex)
return compoundShape(outer, doc.endIndex, rest, (list, afterRow) => statements(list, 'other', afterRow, false))
}
/** 单下划线开头的名字是私有(`_x`、`__x`),前后双下划线的特殊方法(`__init__`)是公共。 */
function nameAccess(name: string): Access {
const dunder = name.length > 4 && name.startsWith('__') && name.endsWith('__')
@@ -108,10 +133,11 @@ function scopeAccess(target: Node, scope: Scope): Access | undefined {
return names.every(name => nameAccess(name.text) === 'private') ? 'private' : 'public'
}
function functionFacts(definition: Node, outer: Node, scope: Scope): FunctionFacts {
function functionFacts(definition: Node, outer: Node, scope: Scope, doc: Node | undefined): FunctionFacts {
const name = definition.childForFieldName('name')
return {
name: name ?? undefined,
doc,
access: name ? scopeAccess(name, scope) : undefined,
async: children(definition).some(child => child.type === 'async'),
decorators: named(outer).filter(child => child.type === 'decorator'),
@@ -177,9 +203,9 @@ function isConstantTarget(target: Node | null): boolean {
/** 赋值 → 字段 / 常量 / 变量;主体开头的字符串是 docstring;其余表达式是语句。 */
function expressionBlock(node: Node, scope: Scope, docstring: boolean): CodeBlock {
const [expression, ...rest] = named(node)
if (docstring && rest.length === 0 && (expression?.type === 'string' || expression?.type === 'concatenated_string'))
if (docstring && docstringIn(node))
return leafBlock('comment', node)
const expression = named(node)[0]
const left = expression?.type === 'assignment' ? expression.childForFieldName('left') : null
if (!expression || !left)
return leafBlock('statement', node)
+44 -7
View File
@@ -1,4 +1,4 @@
import type { BlockShape, CodeBlock, Node, SourceSpan, TextKind } from '../contract'
import type { BlockShape, CodeBlock, FunctionBlock, Node, SourceSpan, TextKind } from '../contract'
/** 语言插件共用的建树工具:取子节点、算头部、把一串语句切成块。 */
@@ -85,9 +85,24 @@ export function compoundShape(outer: Node, bodyStart: number, items: Node[], lis
return { node: outer, header, children: list([...between, ...items], header.endRow) }
}
/** 语言怎么认注释。 */
export interface CommentRules {
/** 行注释:相邻几行合成一块。 */
isLine: (comment: Node) => boolean
/** 文档注释(JSDoc):紧贴在函数块前面时是这个函数的说明,见 FunctionFacts.doc。 */
isDoc: (comment: Node) => boolean
}
/** 函数块带上说明:说明进 facts.doc,头部往上延到它开头。 */
function documented(block: FunctionBlock, doc: Node): FunctionBlock {
return { ...extendHeader(block, doc), facts: { ...block.facts, doc } }
}
/**
* 一串语句 → 块。注释单独成块,相邻行的行注释合成一块;
* 和前一条语句(或头部)同一行的注释是行尾注释,跟着那一行显示,不单独成块。
* 文档注释和下一条语句之间只有空白、下一条语句是函数块时,它是这个函数的说明,不单独成块;
* 中间隔着的装饰器(不出块的语句)不算隔开。
* afterRow 是这串语句之前的最后一行(头部结尾行),模块顶层传 -1;
* toBlocks 的 first 表示它是这串里第一条非注释语句(Python 认 docstring 用)。
*/
@@ -95,20 +110,32 @@ export function listBlocks(
items: Node[],
afterRow: number,
toBlocks: (item: Node, first: boolean) => CodeBlock[],
isLineComment: (comment: Node) => boolean,
comments: CommentRules,
): CodeBlock[] {
const out: CodeBlock[] = []
let lastRow = afterRow
let lineRun: CodeBlock | undefined
let doc: Node | undefined
let first = true
for (const item of items) {
const flushDoc = (): void => {
if (doc)
out.push(leafBlock('comment', doc))
doc = undefined
}
items.forEach((item, i) => {
if (item.type === 'comment') {
if (item.startPosition.row === lastRow)
continue
const line = isLineComment(item)
return
flushDoc()
const line = comments.isLine(item)
const next = items[i + 1]
if (line && lineRun && item.startPosition.row === lineRun.header.endRow + 1) {
lineRun.header = { ...lineRun.header, endIndex: item.endIndex, endRow: item.endPosition.row }
}
else if (comments.isDoc(item) && next && next.type !== 'comment' && sameNode(item.nextSibling, next)) {
doc = item
lineRun = undefined
}
else {
const block = leafBlock('comment', item)
out.push(block)
@@ -116,12 +143,22 @@ export function listBlocks(
}
}
else {
out.push(...toBlocks(item, first))
const blocks = toBlocks(item, first)
const [head] = blocks
if (head?.kind === 'function' && doc) {
blocks[0] = documented(head, doc)
doc = undefined
}
else if (head) {
flushDoc()
}
out.push(...blocks)
first = false
lineRun = undefined
}
lastRow = item.endPosition.row
}
})
flushDoc()
return out
}
@@ -13,15 +13,14 @@ function facts(source: string, languageId = 'typescript'): Promise<Record<number
return withBlocks(adapter, languageId, source, factsByLine)
}
// 与原型 docs/prototype/code-reader/figma-plugin/code.js 的 CODE_TREE 一致
// 与原型 docs/prototype/code-reader/figma-plugin/code.js 的 CODE_TREE 一致,只是紧贴函数的 JSDoc 算进了函数头部
const ENV_LOCK: Outline[] = [
{ at: [1, 10], kind: 'comment' },
{ at: 12, kind: 'import' },
{ at: 13, kind: 'import' },
{ at: 15, kind: 'const' },
{ at: 16, kind: 'const' },
{ at: [18, 22], kind: 'comment' },
{ at: 23, kind: 'function', kids: [
{ at: [18, 23], kind: 'function', kids: [
{ at: 24, kind: 'variable' },
{ at: 25, kind: 'if', kids: [
{ at: 26, kind: 'return' },
@@ -49,12 +48,10 @@ const ENV_LOCK: Outline[] = [
] },
{ at: 50, kind: 'return' },
] },
{ at: 53, kind: 'comment' },
{ at: 54, kind: 'function', kids: [
{ at: [53, 54], kind: 'function', kids: [
{ at: 55, kind: 'return' },
] },
{ at: 58, kind: 'comment' },
{ at: 59, kind: 'function', kids: [
{ at: [58, 59], kind: 'function', kids: [
{ at: 60, kind: 'return' },
] },
]
@@ -66,8 +63,7 @@ const ISSUE_LOCK: Outline[] = [
{ at: 5, kind: 'field' },
{ at: 6, kind: 'field' },
] },
{ at: [9, 12], kind: 'comment' },
{ at: 13, kind: 'function', kids: [
{ at: [9, 13], kind: 'function', kids: [
{ at: 14, kind: 'variable' },
{ at: 15, kind: 'if', kids: [
{ at: 16, kind: 'return' },
@@ -230,14 +226,97 @@ describe('typeScript 代码块树', () => {
})
})
describe('typeScript 函数的 JSDoc', () => {
const source = [
'/** 普通 */',
'function a() {}',
'/** 隔一个空行也算紧贴 */',
'',
'export function b() {}',
'/** 默认导出 */',
'export default function () {}',
'/** 函数值的变量 */',
'const c = () => {',
' /** 嵌套 */',
' function d() {}',
'}',
'class K {',
' /** 方法,隔着装饰器 */',
' @memo()',
' m() {}',
' /** 字段 */',
' x = 1',
'}',
'/** 常量 */',
'const y = 1',
'/* 普通块注释 */',
'function e() {}',
'// 行注释',
'function f() {}',
'/** 中间隔着行注释 */',
'// 行注释',
'function g() {}',
'foo() /** 行尾 */',
'function h() {}',
'/**/',
'function i() {}',
].join('\n')
it('紧贴在函数块前面的算进函数头部、不单独成块;前面不是函数块、没紧贴、不是 JSDoc 的照旧是注释块', async () => {
expect(await tree(source)).toEqual([
{ at: [1, 2], kind: 'function' },
{ at: [3, 5], kind: 'function' },
{ at: [6, 7], kind: 'function' },
{ at: [8, 9], kind: 'function', kids: [{ at: [10, 11], kind: 'function' }] },
{ at: 13, kind: 'class', kids: [
{ at: [14, 16], kind: 'function' },
{ at: 17, kind: 'comment' },
{ at: 18, kind: 'field' },
] },
{ at: 20, kind: 'comment' },
{ at: 21, kind: 'const' },
{ at: 22, kind: 'comment' },
{ at: 23, kind: 'function' },
{ at: 24, kind: 'comment' },
{ at: 25, kind: 'function' },
{ at: 26, kind: 'comment' },
{ at: 27, kind: 'comment' },
{ at: 28, kind: 'function' },
{ at: 29, kind: 'statement' },
{ at: 30, kind: 'function' },
{ at: 31, kind: 'comment' },
{ at: 32, kind: 'function' },
])
})
it('进 facts.doc:是那个注释节点;没挂上说明的函数没有', async () => {
const found = await facts(source)
expect(found[1]).toMatchObject({ name: 'a', doc: '/** 普通 */' })
expect(found[3]).toMatchObject({ name: 'b', doc: '/** 隔一个空行也算紧贴 */' })
expect(found[6]).toMatchObject({ doc: '/** 默认导出 */' })
expect(found[8]).toMatchObject({ name: 'c', doc: '/** 函数值的变量 */' })
expect(found[10]).toMatchObject({ name: 'd', doc: '/** 嵌套 */' })
expect(found[14]).toMatchObject({ name: 'm', doc: '/** 方法,隔着装饰器 */', decorators: ['@memo()'] })
for (const line of [23, 25, 28, 30, 32])
expect(found[line], `L${line}`).not.toHaveProperty('doc')
})
})
describe('typeScript 属性表信息(facts)', () => {
it('envLock.ts:导入、常量、函数、局部变量', async () => {
const env = await facts(readFixture('envLock.ts.txt'))
expect(env[12]).toEqual({ sources: ['\'node:fs\''], typeOnly: false, items: [{ kind: 'named', name: 'promises', alias: 'fsp' }] })
expect(env[13]).toEqual({ sources: ['\'vscode\''], typeOnly: false, items: [{ kind: 'named', name: 'RelativePattern' }, { kind: 'named', name: 'workspace' }] })
expect(env[15]).toEqual({ access: 'private', declarators: [{ name: 'ENV_PATTERN', value: '\'**/.env*\'' }] })
expect(env[23]).toEqual({
expect(env[18]).toEqual({
name: 'findEnvFiles',
doc: [
'/**',
' * Scan the workspace folder rooted at `workspaceRoot` for all `.env*`',
' * files, skipping `node_modules`, `.git`, and `.claude`. Returns absolute',
' * fs paths. Empty array if the workspace folder isn\'t currently open.',
' */',
].join('\n'),
access: 'public',
async: true,
decorators: [],
@@ -1,4 +1,5 @@
import type { Access, BlockShape, CodeBlock, DeclaratorFacts, 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'
@@ -13,8 +14,10 @@ const FUNCTION_VALUES = new Set(['arrow_function', 'function_expression', 'funct
const LOOPS = new Set(['for_statement', 'for_in_statement', 'while_statement', 'do_statement'])
const CLASSES = new Set(['class_declaration', 'abstract_class_declaration', 'class'])
function isLineComment(comment: Node): boolean {
return comment.text.startsWith('//')
const COMMENTS: CommentRules = {
isLine: comment => comment.text.startsWith('//'),
// `/**/` 是空的块注释,不是 JSDoc
isDoc: comment => comment.text.startsWith('/**') && !comment.text.startsWith('/**/'),
}
/** 带语句块主体的函数值(箭头函数 / 函数表达式)的主体。 */
@@ -39,7 +42,7 @@ function callbackBody(expression: Node | null | undefined): Node | null {
}
function statements(items: Node[], top: boolean, afterRow: number): CodeBlock[] {
return listBlocks(items, afterRow, item => statementBlocks(item, top), isLineComment)
return listBlocks(items, afterRow, item => statementBlocks(item, top), COMMENTS)
}
/** 主体是语句块时子块来自它的语句,否则主体本身就是唯一的子语句(`if (x) return`)。 */
@@ -291,7 +294,7 @@ function classBlock(declaration: Node, outer: Node): CodeBlock {
const [from] = decorators
decorators = []
return from ? blocks.map((block, i) => i === 0 ? extendHeader(block, from) : block) : blocks
}, isLineComment)
}, COMMENTS)
}),
}
}
@@ -321,7 +324,7 @@ function membersBlock(declaration: Node, outer: Node): CodeBlock {
return {
kind: 'type',
...compoundShape(outer, body.startIndex, named(body), (list, afterRow) =>
listBlocks(list, afterRow, member => [leafBlock('field', member)], isLineComment)),
listBlocks(list, afterRow, member => [leafBlock('field', member)], COMMENTS)),
}
}
@@ -90,7 +90,6 @@ describe('typeScript 中文直译', () => {
expect(text(env.get(26)?.segments)).toBe('返回 空数组')
expect(text(env.get(41)?.segments)).toBe('遍历 (常量 f 取自 files)')
expect(text(env.get(35)?.segments)).toBe('failed: { path: 字符串, reason: 字符串 }[]')
expect(text(env.get(53)?.segments)).toBe('Set every `.env*` file in the workspace to 0o444 (read-only).')
expect(text(issue.get(20)?.segments)).toBe('返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }')
})
@@ -103,7 +102,6 @@ describe('typeScript 中文直译', () => {
expect(env[47]).toBe('result.failed.push({ path: f, reason: 如果 err 属于 Error 则 err.message 否则 String(err) })')
expect(env[1]).toBe('.env file lock / unlock helpers. Recursively scans the main workspace folder for `.env*` files (skipping `node_modules`, `.git`, `.claude`) and chmods them to 0o444 (locked) or 0o644 (unlocked) in bulk. Backs the toolbar Lock toggle in `PanelHeader`. No persistent watcher — re-scan happens on every toggle, so newly created `.env` files are only picked up the next time the user clicks the button.')
expect(issue[5]).toBe('prerequisiteNumber?: 数值')
expect(issue[9]).toBe('纯判定:工单被未完成的前置挡住才算锁定。快照里找不到工单或前置一律放行, 与 webview 的 `isIssueLocked` 保持同一语义。')
expect(issue[15]).toBe('如果 (非 issue 或者 issue.prerequisite 全等 未定义)')
})
@@ -212,7 +210,6 @@ describe('python 中文直译', () => {
const map = await translateAll(py, 'python', pythonZhTranslator, readFixture('sample.py'))
expect(texts(map)).toEqual({
1: 'Scan a workspace for .env files.',
11: 'Return every .env file under root.',
12: '跳过备份文件',
14: '如果 非 found',
15: '返回 空列表',
@@ -327,8 +324,9 @@ describe('属性表', () => {
const env = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')))
const issue = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')))
const routes = rowTexts(await describeAll(py, 'python', pythonZhTranslator, readFixture('routes.py')))
expect(env[23]).toEqual([
expect(env[18]).toEqual([
'名称: findEnvFiles',
'说明: Scan the workspace folder rooted at `workspaceRoot` for all `.env*`\nfiles, skipping `node_modules`, `.git`, and `.claude`. Returns absolute\nfs paths. Empty array if the workspace folder isn\'t currently open.',
'访问级别: 公共',
'异步: 是',
'输入参数: 1 个',
@@ -360,6 +358,7 @@ describe('属性表', () => {
])
expect(routes[29]).toEqual([
'名称: get_user',
'说明: 获取用户详情。\n\n管理员可查看任意用户,普通用户只能查看自己。',
'访问级别: 公共',
'异步: 是',
'装饰器: @router.get("/{user_id}", response_model=UserDetailResponse)',
@@ -373,21 +372,22 @@ describe('属性表', () => {
expect(routes[118]).toEqual(['名称: payload', '初始值: 等待 file.read()'])
const sample = rowTexts(await describeAll(py, 'python', pythonZhTranslator, readFixture('sample.py')))
expect(sample[10]).toEqual(['名称: find_env_files', '访问级别: 公共', '异步: 否', '输入参数: 1 个', ' root | 字符串 | ', '输出参数: 列表[字符串]'])
expect(sample[10]).toEqual(['名称: find_env_files', '说明: Return every .env file under root.', '访问级别: 公共', '异步: 否', '输入参数: 1 个', ' root | 字符串 | ', '输出参数: 列表[字符串]'])
const port = rowTexts(await describeAll(py, 'python', pythonZhTranslator, '_default_port = 5680 if _role == "beat" else 5679'))
expect(port[1]).toEqual(['名称: _default_port', '访问级别: 私有', '初始值: 如果 _role 等于 "beat" 则 5680 否则 5679'])
})
it('属性名是纯文字;值按词性着色,说明词(未标注、N 个、默认)用 hint', async () => {
const env = await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
const rows = colors(env.get(23) ?? [])
expect(rows.map(row => row.label)).toEqual(['名称', '访问级别', '异步', '输入参数', '输出参数'])
const rows = colors(env.get(18) ?? [])
expect(rows.map(row => row.label)).toEqual(['名称', '说明', '访问级别', '异步', '输入参数', '输出参数'])
expect(rows[0].value).toEqual([{ text: 'findEnvFiles', role: 'function' }])
expect(rows[1].value).toEqual([{ text: '公共', role: 'keyword' }])
expect(rows[2].value).toEqual([{ text: '是', role: 'storage' }])
expect(rows[3].value).toEqual([{ text: '1 个', role: 'hint' }])
expect(rows[3].items).toEqual([{ cells: [[{ text: 'workspaceRoot', role: 'variable' }], [{ text: '字符串', role: 'type' }], []] }])
expect(rows[4].value).toEqual([
expect(rows[1].value).toEqual([{ text: valueOf(env.get(18), '说明')[0].text, role: 'comment' }])
expect(rows[2].value).toEqual([{ text: '公共', role: 'keyword' }])
expect(rows[3].value).toEqual([{ text: '是', role: 'storage' }])
expect(rows[4].value).toEqual([{ text: '1 个', role: 'hint' }])
expect(rows[4].items).toEqual([{ cells: [[{ text: 'workspaceRoot', role: 'variable' }], [{ text: '字符串', role: 'type' }], []] }])
expect(rows[5].value).toEqual([
{ text: 'Promise', role: 'type' },
{ text: '<', role: 'plain' },
{ text: '字符串', role: 'type' },
@@ -410,6 +410,43 @@ describe('属性表', () => {
])
})
it('说明:紧跟名称,去掉注释符号、引号、共同缩进和首尾空行,保留换行和 @ 标签行;没有文字不显示', async () => {
const tsSource = [
'class K {',
' /**',
' * 读配置。',
' *',
' * 例子:',
' * load(\'a\')',
' * @param path 文件路径',
' * @returns 配置',
' */',
' load(path: string) {}',
' /** */',
' empty() {}',
'}',
].join('\n')
const tsRows = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource))
expect(tsRows[2].slice(0, 3)).toEqual(['名称: load', '说明: 读配置。\n\n例子:\n load(\'a\')\n@param path 文件路径\n@returns 配置', '访问级别: 公共'])
expect(tsRows[11].slice(0, 2)).toEqual(['名称: empty', '访问级别: 公共'])
const pySource = [
'def f():',
' \'\'\'',
' 第一段',
' 缩进的一行',
'',
' 第二段',
' \'\'\'',
'def g():',
' """"""',
' return 1',
].join('\n')
const pyRows = rowTexts(await describeAll(py, 'python', pythonZhTranslator, pySource))
expect(pyRows[1].slice(0, 3)).toEqual(['名称: f', '说明: 第一段\n 缩进的一行\n\n第二段', '访问级别: 公共'])
expect(pyRows[8].slice(0, 2)).toEqual(['名称: g', '访问级别: 公共'])
})
it('导入:默认导出、全部、整个模块、副作用导入;Python 没有「仅类型」', async () => {
const tsSource = [
'import d, { b as c } from \'y\'',
@@ -1,4 +1,5 @@
import type { Access, FactBlock, FunctionFacts, ImportFacts, ImportItem, Node, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract'
import { commonIndent, splitLines } from '../editText'
/** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */
export interface ContextualWord {
@@ -100,6 +101,16 @@ function oneLine(text: string): string {
return text.replace(/\s*\n\s*/g, ' ')
}
/**
* 说明文字排版:去掉行尾空白、首尾空行和共同缩进,保留换行(段落之间的空行也留着)。
* 第一行紧跟在引号 / 注释符号后面,本来就没有缩进,不参与算共同缩进(同 PEP 257 的做法)。
*/
function docText(raw: string): string {
const [first = '', ...rest] = splitLines(raw).map(line => line.trimEnd())
const indent = commonIndent(rest).length
return [first.trim(), ...rest.map(line => line.slice(indent))].join('\n').replace(/^\n+|\n+$/g, '')
}
/** 模板槽位引用的子节点;注释是语法之外的节点,不占具名子节点序号。 */
function slotChild(node: Node, slot: Exclude<TemplatePart, string>): Node | null | undefined {
if ('field' in slot)
@@ -287,6 +298,10 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
// 点分模块名逐节照抄,每一节各自能跳到定义,和 VS Code 里一样
module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node))] : verbatim(node, source, 'type'),
name: (node, role) => [segment(node.text, role, offsetOf(node))],
doc: (node) => {
const text = docText(table.commentText(node))
return text ? [{ text, role: 'comment' }] : []
},
}
switch (block.kind) {
case 'function':
@@ -347,6 +362,8 @@ interface Phrases {
module: (node: Node) => Segment[]
/** 源码里的一个名字原样照抄、按 role 着色(函数名称)。 */
name: (node: Node, role: TokenRole) => Segment[]
/** 函数的说明(JSDoc、docstring):去掉注释符号、引号和共同缩进,保留换行,按注释着色;没有文字时是空的。 */
doc: (node: Node) => Segment[]
}
const ACCESS_ZH: Record<Access, string> = { public: '公共', private: '私有', protected: '受保护' }
@@ -380,8 +397,10 @@ function aliased(item: Segment[], alias: Node | undefined, say: Phrases): Segmen
/** 函数:名称 / 访问级别 / 异步 / 装饰器 / 输入参数(每个参数一行:名称、类型、默认值)/ 输出参数。 */
function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] {
const decorators = facts.decorators.map(say.verbatim)
const doc = facts.doc ? say.doc(facts.doc) : []
return [
{ label: '名称', value: facts.name ? say.name(facts.name, 'function') : [hint('匿名')] },
...(doc.length > 0 ? [{ label: '说明', value: doc }] : []),
...accessRows(facts.access),
{ label: '异步', value: yesNo(facts.async) },
...(decorators.length > 0 ? [{ label: '装饰器', value: joined(decorators, '\n') }] : []),
+15 -8
View File
@@ -9,17 +9,18 @@ import { BlockLevel } from './BlockTree'
import { useDefinitionLinks } from './useDefinitionLinks'
import { useReveal } from './useReveal'
import { useScrollPosition } from './useScrollPosition'
import { collapsedIds } from './viewState'
import { onMessage, postMessage, viewState } from './vscode'
type BlockModes = Pick<ViewState, 'editing' | 'collapsed'>
type BlockModes = Pick<ViewState, 'editing' | 'folds'>
function toggled(ids: string[], id: string): string[] {
return ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id]
}
function savedBlockModes(): BlockModes {
const { editing, collapsed } = viewState.get()
return { editing, collapsed }
const { editing, folds } = viewState.get()
return { editing, folds }
}
export function App(): ReactElement | null {
@@ -28,10 +29,16 @@ export function App(): ReactElement | null {
const [focusId, setFocusId] = useState<string | null>(null)
/** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */
const [reveal, setReveal] = useState<Reveal | null>(null)
const expand = useCallback((ids: string[]) => setView(prev => ({ ...prev, collapsed: prev.collapsed.filter(id => !ids.includes(id)) })), [])
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])) },
})), [])
const expand = useCallback((ids: string[]) => setFolded(ids, false), [setFolded])
const revealed = useCallback(() => setReveal(null), [])
useScrollPosition(doc)
useReveal(doc, reveal, view.collapsed, expand, revealed)
useReveal(doc, reveal, collapsed, expand, revealed)
useDefinitionLinks(doc?.version)
useEffect(() => {
@@ -58,15 +65,15 @@ export function App(): ReactElement | null {
const tree = useMemo((): BlockTreeState | null => doc && {
version: doc.version,
editing: new Set(view.editing),
collapsed: new Set(view.collapsed),
collapsed,
focusId,
onFocused,
onToggleEdit: (id) => {
setFocusId(view.editing.includes(id) ? null : id)
setView(prev => ({ ...prev, editing: toggled(prev.editing, id) }))
},
onToggleFold: id => setView(prev => ({ ...prev, collapsed: toggled(prev.collapsed, id) })),
}, [doc, view, focusId, onFocused])
onToggleFold: id => setFolded([id], !collapsed.has(id)),
}, [doc, view, collapsed, focusId, onFocused, setFolded])
if (!doc || !tree)
return null
@@ -29,7 +29,7 @@ const KIND_LABEL: Record<BlockKind, string> = {
export interface BlockTreeState {
version: number
editing: ReadonlySet<string>
/** 折叠的块 id 和属性行 id(propertyRowId),默认全部展开 */
/** 眼下折叠着的块 id 和属性行 id(propertyRowId):用户的选择叠在默认上,见 collapsedIds */
collapsed: ReadonlySet<string>
/** 刚点了「编辑」、要把光标放进去的块 */
focusId: string | null
@@ -1,6 +1,7 @@
import type { BlockKind, BlockView, PropertyRow, Segment } from './types'
import { describe, expect, it } from 'vitest'
import { revealPlan, scrollTopToShow } from './reveal'
import { collapsedIds } from './viewState'
function name(text: string, offset: number): Segment {
return { text, role: 'variable', offset }
@@ -63,6 +64,14 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
expect(revealPlan(BLOCKS, 20, 999)).toEqual({ id: '2', expand: [] })
})
it('函数块默认折叠:目标在函数体里时,要展开的块里有这个函数;展开过一次就记住,不再挡着', () => {
const plan = revealPlan(BLOCKS, 4, 102)
const hidden = (folds: Record<string, boolean>): string[] => plan?.expand.filter(id => collapsedIds(BLOCKS, folds).has(id)) ?? []
expect(hidden({})).toEqual(['1'])
expect(hidden({ 1: false })).toEqual([])
expect(hidden({ '1': false, '1.0': true })).toEqual(['1.0'])
})
it('按行找:同一行有几块取外层那块;行在所有块之前取第一块;没有块时没有目标', () => {
const sameLine = [textBlock('0', 'if', 1, [word('如果')], [textBlock('0.0', 'return', 1, [word('返回')])])]
expect(revealPlan(sameLine, 1, 999)).toEqual({ id: '0', expand: [] })
@@ -30,7 +30,7 @@ function flash(el: HTMLElement): void {
export function useReveal(
doc: DocumentMessage | null,
reveal: Reveal | null,
collapsed: readonly string[],
collapsed: ReadonlySet<string>,
expand: (ids: string[]) => void,
done: () => void,
): void {
@@ -45,7 +45,7 @@ export function useReveal(
return
}
const plan = revealPlan(doc.blocks, reveal.line, reveal.definition)
const hidden = plan?.expand.filter(id => collapsed.includes(id)) ?? []
const hidden = plan?.expand.filter(id => collapsed.has(id)) ?? []
if (hidden.length > 0) {
expand(hidden)
return
@@ -1,38 +1,65 @@
import type { ViewState, ViewStateStorage } from './viewState'
import type { BlockKind, BlockView } from './types'
import type { ViewStateStorage } from './viewState'
import { describe, expect, it } from 'vitest'
import { createViewStateStore, propertyRowId } from './viewState'
import { collapsedIds, createViewStateStore, propertyRowId } from './viewState'
/** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回 */
function memoryStorage(): ViewStateStorage {
let saved: string | undefined
/** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回;也能塞进旧版本存的东西 */
function memoryStorage(initial?: unknown): ViewStateStorage {
let saved = initial === undefined ? undefined : JSON.stringify(initial)
return {
getState: () => saved === undefined ? undefined : JSON.parse(saved) as ViewState,
getState: () => saved === undefined ? undefined : JSON.parse(saved) as unknown,
setState: (state) => {
saved = JSON.stringify(state)
},
}
}
function block(id: string, kind: BlockKind, children: BlockView[] = []): BlockView {
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], children, translation: [] }
}
/**
* 0 import
* 1 function
* 1.0 if
* 1.0.0 function(嵌套函数)
* 2 class
* 2.0 function(方法)
*/
const BLOCKS = [
block('0', 'import'),
block('1', 'function', [block('1.0', 'if', [block('1.0.0', 'function', [block('1.0.0.0', 'return')])])]),
block('2', 'class', [block('2.0', 'function', [block('2.0.0', 'return')])]),
]
describe('webview 状态', () => {
it('第一次打开:没有编辑中、折叠的块,也没有滚动位置', () => {
expect(createViewStateStore(memoryStorage()).get()).toEqual({ editing: [], collapsed: [] })
it('第一次打开:没有编辑中的块、没点过折叠,也没有滚动位置', () => {
expect(createViewStateStore(memoryStorage()).get()).toEqual({ editing: [], folds: {} })
})
it('块状态和滚动位置各自更新,互不覆盖', () => {
const storage = memoryStorage()
const store = createViewStateStore(storage)
store.update({ editing: ['2'], collapsed: ['6.1'] })
store.update({ editing: ['2'], folds: { 6.1: true } })
store.update({ scroll: { line: 12, offset: 30, scrollTop: 480 } })
store.update({ editing: [], collapsed: ['6.1'] })
expect(storage.getState()).toEqual({ editing: [], collapsed: ['6.1'], scroll: { line: 12, offset: 30, scrollTop: 480 } })
store.update({ editing: [], folds: { 6.1: true } })
expect(storage.getState()).toEqual({ editing: [], folds: { 6.1: true }, scroll: { line: 12, offset: 30, scrollTop: 480 } })
})
it('页面重新加载后取回上次存的全部状态', () => {
const storage = memoryStorage()
const store = createViewStateStore(storage)
store.update({ collapsed: ['3'] })
store.update({ folds: { 3: true, 4: false } })
store.update({ scroll: { line: 40, offset: -6, scrollTop: 1210 } })
expect(createViewStateStore(storage).get()).toEqual({ editing: [], collapsed: ['3'], scroll: { line: 40, offset: -6, scrollTop: 1210 } })
expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: { 3: true, 4: false }, scroll: { line: 40, offset: -6, scrollTop: 1210 } })
})
it('旧版本存的、形状不对的项当没存过,认得的项照旧取回', () => {
expect(createViewStateStore(memoryStorage({ editing: ['1'], collapsed: ['2'] })).get()).toEqual({ editing: ['1'], folds: {} })
expect(createViewStateStore(memoryStorage({ editing: 'x', folds: ['2'], scroll: { line: 'a' } })).get()).toEqual({ editing: [], folds: {} })
expect(createViewStateStore(memoryStorage({ editing: [1, '2'], folds: { 1: true, 2: 'yes' } })).get()).toEqual({ editing: ['2'], folds: { 1: true } })
expect(createViewStateStore(memoryStorage(42)).get()).toEqual({ editing: [], folds: {} })
expect(createViewStateStore(memoryStorage(null)).get()).toEqual({ editing: [], folds: {} })
})
it('属性行的折叠 id 是块 id 加行序号,不会和块 id 撞上', () => {
@@ -41,9 +68,20 @@ describe('webview 状态', () => {
expect(propertyRowId('6.1', 0)).not.toBe(propertyRowId('6', 10))
})
it('属性行和块的折叠状态存在同一份 collapsed 里,页面重新加载后都还在', () => {
it('属性行和块的折叠选择存在同一份 folds 里,页面重新加载后都还在', () => {
const storage = memoryStorage()
createViewStateStore(storage).update({ collapsed: ['6.1', propertyRowId('6.1.0', 3)] })
expect(createViewStateStore(storage).get().collapsed).toEqual(['6.1', '6.1.0/p3'])
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 })
})
})
describe('眼下折叠着哪些', () => {
it('没点过:函数块(含方法、嵌套函数)折叠,其余块和属性行展开', () => {
expect([...collapsedIds(BLOCKS, {})].sort()).toEqual(['1', '1.0.0', '2.0'])
})
it('点过的按用户的:展开的函数保持展开,折叠的其他块、属性行保持折叠', () => {
const folds = { '1': false, '2': true, '1.0.0': false, [propertyRowId('0', 2)]: true, [propertyRowId('2.0', 1)]: false }
expect([...collapsedIds(BLOCKS, folds)].sort()).toEqual(['0/p2', '2', '2.0'])
})
})
+45 -5
View File
@@ -1,17 +1,20 @@
import type { ScrollAnchor } from './scrollPosition'
import type { BlockKind, BlockView } from './types'
/**
* 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、
* 哪些块和属性行折叠了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行 id 见 propertyRowId),以及滚到了哪。
* 用户把哪些块和属性行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行 id 见 propertyRowId),以及滚到了哪。
*/
export interface ViewState {
editing: string[]
collapsed: string[]
/** 用户自己点过折叠箭头的块和属性行:id → 折叠着没有。没点过的按默认,见 collapsedIds */
folds: Record<string, boolean>
scroll?: ScrollAnchor
}
export interface ViewStateStorage {
getState: () => ViewState | undefined
/** 存下来的东西可能是旧版本的形状,读的时候逐项核对 */
getState: () => unknown
setState: (state: ViewState) => void
}
@@ -23,7 +26,7 @@ export interface ViewStateStore {
/** 一个 webview 只有一份状态:块的状态和滚动位置由不同地方各自更新,存的时候合在一起。 */
export function createViewStateStore(storage: ViewStateStorage): ViewStateStore {
let current: ViewState = storage.getState() ?? { editing: [], collapsed: [] }
let current = readViewState(storage.getState())
return {
get: () => current,
update: (patch) => {
@@ -33,8 +36,45 @@ export function createViewStateStore(storage: ViewStateStorage): ViewStateStore
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isScrollAnchor(value: unknown): value is ScrollAnchor {
return isRecord(value) && [value.line, value.offset, value.scrollTop].every(field => typeof field === 'number')
}
/** 存下来的状态逐项核对,形状不对的项(包括旧版本存的)当没存过。 */
function readViewState(saved: unknown): ViewState {
const state = isRecord(saved) ? saved : {}
const editing = Array.isArray(state.editing) ? state.editing.filter((id): id is string => typeof id === 'string') : []
const folds = isRecord(state.folds)
? Object.fromEntries(Object.entries(state.folds).filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'))
: {}
return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds }
}
/** 块默认折叠吗:函数块收起主体(属性表照常显示),其余块展开。属性行默认都展开。 */
export function foldedByDefault(kind: BlockKind): boolean {
return kind === 'function'
}
/** 眼下折叠着的块和属性行:用户点过的按他点的,没点过的按 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 => {
for (const block of list) {
if (folds[block.id] === undefined && foldedByDefault(block.kind))
collapsed.add(block.id)
visit(block.children)
}
}
visit(blocks)
return collapsed
}
/**
* 属性表里带子行的那一行(输入参数、导入项)的折叠 id,和块 id 放在同一个 collapsed 里:
* 属性表里带子行的那一行(输入参数、导入项)的折叠 id,和块 id 放在同一份 folds 里:
* `<块 id>/p<行序号>`,例如 '6.1.0/p3'。块 id 只由数字和点组成,带 '/' 的不会和任何块 id 撞上。
*/
export function propertyRowId(blockId: string, rowIndex: number): string {