🐛 fix(vscode): 代码阅读编辑父卡片时显示整块代码(含子块)

- 块的编辑范围改成它在源码里的完整范围:头部连同各子孙块、收尾括号,保存时按整段写回;子卡片只有它自己连同子孙
- 范围由宿主统一算(blockSpans),各语言插件同一条路:else / elif / catch 这类展平的兄弟块按各块开头切开整条语句,最后一块到语句结尾(Shell 的 fi 归它)
- 库规则卡片的编辑范围并进同一套算法,去掉 claimedShape
- 块编辑时它的子孙都不画(代码都在编辑框里),点「编辑」时重叠的祖先、子孙、共用一行的兄弟块退出编辑
- 问 AI 的外层块开头改用新加的 headerEndLine,位置记号照旧按头部算,已有问答不受影响
- 着色改成整份源码算一次、各块按行取,编辑范围变大后排视图数据不变慢
This commit is contained in:
cruldra committed 2026-09-30 07:24:10 +08:00
1 parent af63d2bc5c
commit 70ea22543f
13 files changed
+251 -143

No files matched your search

+85 -21
View File
@@ -3,6 +3,7 @@ import type { LanguageAdapter, Translator } from './contract'
import { describe, expect, it } from 'vitest'
import { readBlockViews, readDefinitionKind, toBlockViews } from './blockView'
import { replaceLines } from './editText'
import { createBashAdapter } from './languages/bash'
import { createPythonAdapter } from './languages/python'
import { createTypeScriptAdapter } from './languages/typescript'
import { previewText, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from './testSupport'
@@ -11,6 +12,7 @@ import { typescriptZhTranslator } from './translators/typescriptZh'
const ts = createTypeScriptAdapter(WASM_DIR)
const py = createPythonAdapter(WASM_DIR)
const bash = createBashAdapter(WASM_DIR)
function views(adapter: LanguageAdapter, languageId: string, translator: Translator | undefined, source: string): Promise<BlockView[]> {
return withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, translator))
@@ -46,7 +48,7 @@ function translationText(view: BlockView): string {
}
describe('blockView', () => {
it.each(SAMPLES)('%#: codeTokens 拼起来等于 code,code 是 header 整行去共同缩进', async (adapter, languageId, translator, fixture) => {
it.each(SAMPLES)('%#: codeTokens 拼起来等于 code,code 是编辑范围整行去共同缩进', async (adapter, languageId, translator, fixture) => {
const source = readFixture(fixture)
const lines = source.split('\n')
for (const view of flatten(await views(adapter, languageId, translator, source))) {
@@ -68,20 +70,76 @@ 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.each(SAMPLES)('%#: 编辑范围是块的完整范围:盖住各子孙块,兄弟块之间最多共用一行,首尾不是空行,原样写回不变;头部是开头那几行', async (adapter, languageId, translator, fixture) => {
const source = readFixture(fixture)
const lines = source.split('\n')
const visit = (list: BlockView[], parent: BlockView | undefined): void => {
list.forEach((view, i) => {
expect(lines[view.startLine].trim(), view.id).not.toBe('')
expect(lines[view.endLine].trim(), view.id).not.toBe('')
expect(view.headerEndLine, view.id).toBeGreaterThanOrEqual(view.startLine)
expect(view.headerEndLine, view.id).toBeLessThanOrEqual(view.endLine)
if (parent) {
expect(view.startLine, `${parent.id} 盖住 ${view.id}`).toBeGreaterThanOrEqual(parent.startLine)
expect(view.endLine, `${parent.id} 盖住 ${view.id}`).toBeLessThanOrEqual(parent.endLine)
}
const next = list[i + 1]
if (next)
expect(view.endLine, `${view.id} / ${next.id}`).toBeLessThanOrEqual(next.startLine)
expect(replaceLines(source, view.startLine, view.endLine, view.code), view.id).toBe(source)
visit(view.children, view)
})
}
visit(await views(adapter, languageId, translator, source), undefined)
})
it('编辑父块时编辑框里是整块代码:头部、各子块、结尾都在,改完整块写回;子块的编辑框只有它自己连同它的子块', async () => {
const source = [
'/** 窗口顶部 40px.',
'侧栏开关只在侧栏顶部. */',
'export const DesktopTitlebar = ({ platform }: { platform: DesktopShell["platform"] }) => {',
' if (platform === "macos") {',
' return null',
' }',
' return <div data-tauri-drag-region="deep" />',
'}',
'',
].join('\n')
const list = await views(ts, 'typescriptreact', typescriptZhTranslator, source)
const bar = byId(list, '0')
expect(bar).toMatchObject({ kind: 'function', startLine: 0, headerEndLine: 2, endLine: 7, code: source.trimEnd() })
expect(byId(list, '0.0')).toMatchObject({ kind: 'if', startLine: 3, headerEndLine: 3, endLine: 5, code: 'if (platform === "macos") {\n return null\n}' })
expect(byId(list, '0.0.0')).toMatchObject({ kind: 'return', startLine: 4, endLine: 4, code: 'return null' })
expect(replaceLines(source, bar.startLine, bar.endLine, bar.code.replace('return null', 'return <span />'))).toBe(source.replace('return null', 'return <span />'))
})
it('展平成兄弟块的 else、elif、catch:各到下一块开头之前,最后一块到整条语句结尾(Shell 的 fi 归它);同一行写的 `} else {` 两块都有', async () => {
const code = async (adapter: LanguageAdapter, languageId: string, source: string): Promise<[string, number, number, string][]> =>
(await views(adapter, languageId, undefined, source)).map(view => [view.kind, view.startLine, view.endLine, view.code])
expect(await code(ts, 'typescript', 'if (a) {\n x()\n} else if (b) {\n y()\n}\nelse {\n z()\n}\ntry {\n x()\n}\ncatch {\n y()\n}\n')).toEqual([
['if', 0, 2, 'if (a) {\n x()\n} else if (b) {'],
['else', 2, 4, '} else if (b) {\n y()\n}'],
['else', 5, 7, 'else {\n z()\n}'],
['try', 8, 10, 'try {\n x()\n}'],
['catch', 11, 13, 'catch {\n y()\n}'],
])
expect(await code(py, 'python', 'if a:\n x()\nelif b:\n y()\nelse:\n z()\n')).toEqual([
['if', 0, 1, 'if a:\n x()'],
['else', 2, 3, 'elif b:\n y()'],
['else', 4, 5, 'else:\n z()'],
])
expect(await code(bash, 'shellscript', 'if [ -n "$a" ]; then\n x\nelif [ -n "$b" ]; then\n y\nelse\n z\nfi\n')).toEqual([
['if', 0, 1, 'if [ -n "$a" ]; then\n x'],
['else', 2, 3, 'elif [ -n "$b" ]; then\n y'],
['else', 4, 6, 'else\n z\nfi'],
])
})
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([
expect(byId(env, '5')).toMatchObject({ kind: 'function', startLine: 17, headerEndLine: 22, endLine: 29 })
expect(byId(env, '5').code.split('\n').slice(0, 6)).toEqual([
'/**',
' * Scan the workspace folder rooted at `workspaceRoot` for all `.env*`',
' * files, skipping `node_modules`, `.git`, and `.claude`. Returns absolute',
@@ -93,8 +151,8 @@ describe('blockView', () => {
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([
expect(getUser).toMatchObject({ startLine: 28, headerEndLine: 37 })
expect(getUser?.code.split('\n').slice(5, 10)).toEqual([
') -> UserDetailResponse:',
' """获取用户详情。',
'',
@@ -106,8 +164,8 @@ describe('blockView', () => {
it('类的说明同样算在类块的头部里:JSDoc 连着中间的装饰器,docstring 连着它前面的注释', async () => {
const repo = byId(await views(ts, 'typescript', typescriptZhTranslator, readFixture('classes.ts.txt')), '1')
expect(repo).toMatchObject({ kind: 'class', startLine: 2, endLine: 8 })
expect(repo.code.split('\n').slice(-3)).toEqual([
expect(repo).toMatchObject({ kind: 'class', startLine: 2, headerEndLine: 8 })
expect(repo.code.split('\n').slice(4, 7)).toEqual([
' */',
'@Injectable()',
'export abstract class Repository<K, T extends Entity = Entity> extends Base<string> implements Loader<T>, Disposable {',
@@ -115,8 +173,8 @@ describe('blockView', () => {
expect('properties' in repo && repo.properties[1]).toEqual({ label: '说明', value: { markdown: '仓库基类。\n\n子类实现 `load`。' } })
const notFound = byId(await views(py, 'python', pythonZhTranslator, readFixture('classes.py')), '3')
expect(notFound).toMatchObject({ kind: 'class', startLine: 17, endLine: 19 })
expect(notFound.code.split('\n')).toEqual([
expect(notFound).toMatchObject({ kind: 'class', startLine: 17, headerEndLine: 19 })
expect(notFound.code.split('\n').slice(0, 3)).toEqual([
'class NotFound(AppResponse): # 行尾',
' # 说明前面的注释',
' """找不到资源。"""',
@@ -157,7 +215,7 @@ describe('blockView', () => {
'值: "uq_case_library_deployment"',
],
},
{ id: '1', kind: 'class', lines: [4, 4], rows: ['名称: CaseStatus', '访问级别: 公共'] },
{ id: '1', kind: 'class', lines: [4, 10], rows: ['名称: CaseStatus', '访问级别: 公共'] },
{ id: '1.0', kind: 'variable', lines: [5, 7], rows: ['名称: ANALYZING', '说明: 正在分析源 deployment.', '访问级别: 公共', '初始值: "analyzing"'] },
{ id: '1.1', kind: 'field', lines: [8, 10], translation: 'title: 字符串 = ""', doc: { markdown: '案例标题,\n用户可编辑.' } },
])
@@ -179,8 +237,13 @@ describe('blockView', () => {
it('id 是块在树里的位置,行号 0 起,多行块去掉共同缩进', async () => {
const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
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) {' })
expect(byId(env, '5.1')).toMatchObject({ kind: 'if', startLine: 24, endLine: 25, code: 'if (!folder)\n return []' })
expect(byId(env, '7.2.1')).toMatchObject({
kind: 'catch',
startLine: 45,
endLine: 47,
code: 'catch (err) {\n result.failed.push({ path: f, reason: err instanceof Error ? err.message : String(err) })\n}',
})
const issue = await views(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt'))
expect(byId(issue, '2.4')).toMatchObject({
@@ -223,7 +286,8 @@ describe('blockView', () => {
const signature = byId(sample, '5').codeTokens
expect(signature).toContainEqual({ text: 'def', role: 'storage' })
expect(signature).toContainEqual({ text: 'find_env_files', role: 'function' })
expect(signature.filter(token => token.text === 'str')).toEqual([{ text: 'str', role: 'type' }, { text: 'str', role: 'type' }])
// 签名里注解的 str 是类型,函数体里调用的 str(err) 是函数
expect(signature.filter(token => token.text === 'str')).toEqual([{ text: 'str', role: 'type' }, { text: 'str', role: 'type' }, { text: 'str', role: 'function' }])
})
it('块自己的名字(defines):函数名、类名(赋给变量的类表达式取变量名)、只声明一个变量时的变量名(解构模式整个算);导入、一条语句几个变量、一行译文的块、匿名类没有', async () => {
@@ -239,7 +303,7 @@ describe('blockView', () => {
const source = 'function f() {\r\n\tif (a &&\r\n\t\tb) {\r\n\t\treturn 1\r\n\t}\r\n}\r\n'
const list = await views(ts, 'typescript', undefined, source)
const cond = byId(list, '0.0')
expect(cond).toMatchObject({ kind: 'if', startLine: 1, endLine: 2, code: 'if (a &&\n\tb) {', translation: [] })
expect(cond).toMatchObject({ kind: 'if', startLine: 1, headerEndLine: 2, endLine: 4, code: 'if (a &&\n\tb) {\n\treturn 1\n}', translation: [] })
expect(cond.codeTokens.map(token => token.text).join('')).toBe(cond.code)
expect(byId(list, '0')).toMatchObject({ kind: 'function', properties: [] })
})