diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 3aea044..a7f88fe 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -80,9 +80,13 @@ interface PageCardBase { export type PageCard = PageCardBase & ({ translation: PreviewText, items?: PropertyItem[], table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] }) -/** 发给页面的一块(见 pageBlocks):卡片的内容,在源码里占的地方(相对的,见 BlockSpan),子块。内容没变、只是挪了位置的块,发出去一模一样。 */ +/** + * 发给页面的一块(见 pageBlocks):卡片的内容,在源码里占的地方(相对的,见 BlockSpan),子块。内容没变、只是挪了位置的块,发出去一模一样。 + * hash 是这些(连子块)的内容指纹:页面按它认出没变的块,不用逐块比内容。 + */ export type PageBlock = PageCard & { span: BlockSpan + hash: string children: PageBlock[] } diff --git a/vscode/src/codeReader/editor/pageBlocks.test.ts b/vscode/src/codeReader/editor/pageBlocks.test.ts new file mode 100644 index 0000000..38d7da9 --- /dev/null +++ b/vscode/src/codeReader/editor/pageBlocks.test.ts @@ -0,0 +1,69 @@ +import type { BlockView } from '../blockView' +import type { PageBlock } from './messages' +import { describe, expect, it } from 'vitest' +import { toBlockViews } from '../blockView' +import { createPythonAdapter } from '../languages/python' +import { WASM_DIR, withBlocks } from '../testSupport' +import { pythonZhTranslator } from '../translators/pythonZh' +import { pageBlocks } from './pageBlocks' + +const py = createPythonAdapter(WASM_DIR) + +async function page(source: string): Promise { + const views: BlockView[] = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator)) + return pageBlocks(views, source) +} + +/** 各块的内容指纹,按块 id */ +function hashes(blocks: PageBlock[]): Map { + const out = new Map() + const walk = (list: PageBlock[]): void => list.forEach((block) => { + out.set(block.id, block.hash) + walk(block.children) + }) + walk(blocks) + return out +} + +/** 指纹和 before 里同一块不一样的块 id */ +function changed(before: Map, after: Map): string[] { + return [...after.keys()].filter(id => after.get(id) !== before.get(id)) +} + +const SOURCE = [ + 'import os', + '', + 'def helper(value):', + ' return value + 1', + '', + 'class Store:', + ' def load(self, key):', + ' if key:', + ' return os.getenv(key)', + ' return None', + '', + 'LIMIT = 3', +].join('\n') + +describe('发给页面的块带内容指纹:连子块、在源码里占的地方一起算,页面按它认出没变的块', () => { + it('同一份源码算两次指纹一样,各块的指纹互不相同', async () => { + const first = hashes(await page(SOURCE)) + expect(hashes(await page(SOURCE))).toEqual(first) + expect(new Set(first.values()).size).toBe(first.size) + }) + + it('靠前的一块里改了一个字:那块和包着它的块的指纹变了,后面的块不变', async () => { + const before = hashes(await page(SOURCE)) + expect(changed(before, hashes(await page(SOURCE.replace('value + 1', 'value + 10'))))).toEqual(['1', '1.0']) + }) + + it('上面多了几行:只有离前一块远了的那块指纹变了;里面的子块离它的开头没变,不变', async () => { + const before = hashes(await page(SOURCE)) + expect(changed(before, hashes(await page(SOURCE.replace('import os\n', 'import os\n\n\n'))))).toEqual(['1']) + }) + + it('深处的子块改了:一路往上包着它的块指纹都变了', async () => { + const before = hashes(await page(SOURCE)) + expect(changed(before, hashes(await page(SOURCE.replace('os.getenv(key)', 'os.getenv(name)'))))).toEqual(['2', '2.0', '2.0.0', '2.0.0.0']) + }) +}) diff --git a/vscode/src/codeReader/editor/pageBlocks.ts b/vscode/src/codeReader/editor/pageBlocks.ts index d417389..8653503 100644 --- a/vscode/src/codeReader/editor/pageBlocks.ts +++ b/vscode/src/codeReader/editor/pageBlocks.ts @@ -2,6 +2,7 @@ import type { BlockView } from '../blockView' import type { Inline, PreviewTable, PreviewText, PropertyItem, PropertyRow, Segment, SourceRange } from '../contract' import type { BlockMarks, ItemDiff, ItemsDiff, RowDiff, RowsDiff, TextDiff } from '../diff/viewDiff' import type { BlockSpan, PageBlock, PageCard } from './messages' +import { createHash } from 'node:crypto' /** * 扩展端的块(位置都是文件里的)→ 发给页面的块(见 PageBlock):块在源码里占的地方按相对的量记(BlockSpan), @@ -101,9 +102,17 @@ export function pageCard(block: BlockView, base: number): PageCard { /** 文件里的一处:行号、下标 */ interface Point { line: number, offset: number } +/** 一块的内容指纹:卡片的内容、span、各子块的指纹一起摘要,取前 16 个字符(96 位) */ +function fingerprint(card: PageCard, span: BlockSpan, children: readonly PageBlock[]): string { + const hash = createHash('sha1').update(JSON.stringify(card)).update(JSON.stringify(span)) + for (const child of children) + hash.update(child.hash) + return hash.digest('base64url').slice(0, 16) +} + /** * 一串兄弟块发给页面的样子:每块的开头是它第一行的行首、结尾是最后一行的行尾,离前一块的结尾(第一块从 from 算)隔多远、自己多长记成 span, - * 子块从这块的开头算起。 + * 子块从这块的开头算起;带上内容指纹(见 PageBlock.hash)。 */ function pageList(blocks: readonly BlockView[], lines: SourceLines, from: Point): PageBlock[] { let cursor = from @@ -112,7 +121,9 @@ function pageList(blocks: readonly BlockView[], lines: SourceLines, from: Point) const end = { line: block.endLine, offset: lines.end(block.endLine) } const span: BlockSpan = { lineGap: start.line - cursor.line, gap: start.offset - cursor.offset, lines: end.line - start.line, length: end.offset - start.offset } cursor = end - return { ...pageCard(block, start.offset), span, children: pageList(block.children, lines, start) } + const card = pageCard(block, start.offset) + const children = pageList(block.children, lines, start) + return { ...card, span, hash: fingerprint(card, span, children), children } }) } diff --git a/vscode/webview-ui/src/codeReader/App.test.tsx b/vscode/webview-ui/src/codeReader/App.test.tsx index af2ec16..cdd3db7 100644 --- a/vscode/webview-ui/src/codeReader/App.test.tsx +++ b/vscode/webview-ui/src/codeReader/App.test.tsx @@ -1,13 +1,14 @@ // @vitest-environment happy-dom import type { Root } from 'react-dom/client' -import type { BlockView, CallTree, DocumentMessage, ExtensionToWebview, WebviewToExtension } from './types' +import type { UnhashedBlock } from './testBlocks' +import type { CallTree, DocumentMessage, ExtensionToWebview, WebviewToExtension } from './types' import type { ViewState, ViewStateStore } from './viewState' import { act } from 'react' import { createRoot } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { App } from './App' -import { NO_SPAN } from './testBlocks' +import { hashed, NO_SPAN } from './testBlocks' import { createViewStateStore } from './viewState' /** @@ -113,7 +114,7 @@ async function deliver(msg: ExtensionToWebview): Promise { }) } -function block(id: string, anchor: string): BlockView { +function block(id: string, anchor: string): UnhashedBlock { return { id, anchor, @@ -127,8 +128,8 @@ function block(id: string, anchor: string): BlockView { } } -function documentOf(version: number, blocks: BlockView[]): DocumentMessage { - return { type: 'document', version, languageId: 'python', blocks, source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] } +function documentOf(version: number, blocks: UnhashedBlock[]): DocumentMessage { + return { type: 'document', version, languageId: 'python', blocks: hashed(blocks), source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] } } function tree(id: string, name: string): CallTree { @@ -224,7 +225,7 @@ describe('代码配色跟着当前颜色主题', () => { describe('一条消息只重画受影响的卡片', () => { /** 三张卡片,译文里各有一个带下标的名字(下标 10、20、30) */ - function named(id: string, name: string, offset: number, text = '调用'): BlockView { + function named(id: string, name: string, offset: number, text = '调用'): UnhashedBlock { return { ...block(id, name), translation: [{ text, role: 'keyword' }, { text: name, role: 'function', offset }] } } const DOC = documentOf(3, [named('0', 'load', 10), named('1', 'save', 20), named('2', 'drop', 30)]) diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index e337f29..08ee894 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -22,7 +22,7 @@ import { createPageStore } from './pageState' import { NO_PLACEMENT, placementOf } from './placement' import { chatOf, PATH_ASK_ANCHOR, sameChat } from './qa' import { TextMarksContext } from './SegmentText' -import { shareUnchanged } from './sharing' +import { shareBlocks, shareUnchanged } from './sharing' import { StickyLines } from './StickyLines' import { createStore, useStore } from './store' import { useChangeNav } from './useChangeNav' @@ -151,8 +151,8 @@ export function App(): ReactElement | null { case 'document': { const restoring = restoreCall.current restoreCall.current = undefined - // 和上一份内容一样的块还是原来的对象:没变的卡片不重画 - const next = shareUnchanged(page.get().doc, msg) + // 指纹没变的块还是原来的对象:没变的卡片不重画 + const next = { ...msg, blocks: shareBlocks(page.get().doc?.blocks, msg.blocks) } const reopened = restoring && openingCall(next, restoring.anchor, restoring.entry) marks.set(current => ({ ...current, names: nameMap(msg.names), placement: placementOf(next.blocks) })) page.update(current => ({ diff --git a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx index 948cd40..c1afea1 100644 --- a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx +++ b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx @@ -11,7 +11,7 @@ import { BlockTree, treeState } from './BlockTree' import { CallTreeDialog, splitWhere } from './CallTreeDialog' import { NO_PLACEMENT } from './placement' import { staticStore } from './store' -import { NO_SPAN } from './testBlocks' +import { hashed, NO_SPAN } from './testBlocks' vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } })) @@ -36,7 +36,7 @@ async function render(element: ReactElement): Promise { } function block(kind: string, id = kind, callTree?: BlockView['callTree']): BlockView { - return { + return hashed([{ id, anchor: id, kind, @@ -46,7 +46,7 @@ function block(kind: string, id = kind, callTree?: BlockView['callTree']): Block concepts: [{ id: kind, label: kind, icon: kind === 'class' ? 'box' : 'braces' }], children: [], translation: [{ text: id, role: 'plain' }], - } + }])[0] } /** 块树:callTree 是各块的棵数、开着哪一块的弹框和点按钮时做什么;edit 是 false 时是只读的树 */ diff --git a/vscode/webview-ui/src/codeReader/TextPreview.test.tsx b/vscode/webview-ui/src/codeReader/TextPreview.test.tsx index 4e157f3..2071fec 100644 --- a/vscode/webview-ui/src/codeReader/TextPreview.test.tsx +++ b/vscode/webview-ui/src/codeReader/TextPreview.test.tsx @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DefinitionPeeks, linkPeekSource } from './definitionPeek' import { DiffText } from './DiffText' import { commentLinkOf } from './repoLink' -import { NO_SPAN } from './testBlocks' +import { hashed, NO_SPAN } from './testBlocks' import { MarkdownKindContext, TextPreview } from './TextPreview' let container: HTMLDivElement @@ -135,7 +135,7 @@ describe.each(['TextPreview', 'DiffText'] as const)('%s 的 Markdown 页面元 const peek: DefinitionPeek = { path: 'client.py', line: 1, - block: { id: '1', anchor: '1', kind: 'class', span: NO_SPAN, attributes: { kind: 'class' }, concepts: [], children: [], translation: [] }, + block: hashed([{ id: '1', anchor: '1', kind: 'class', span: NO_SPAN, attributes: { kind: 'class' }, concepts: [], children: [], translation: [] }])[0], names: [], } await act(async () => controller.answer(1, peek)) diff --git a/vscode/webview-ui/src/codeReader/pageState.ts b/vscode/webview-ui/src/codeReader/pageState.ts index 5b4d106..be8f986 100644 --- a/vscode/webview-ui/src/codeReader/pageState.ts +++ b/vscode/webview-ui/src/codeReader/pageState.ts @@ -92,7 +92,7 @@ function memoizeOne(compute: (...args: A) => R): (...arg } export function createPageStore(initial: Pick): PageStore { - // 按 doc.blocks 而不是 doc 认:同一版重发、只改了版本号时块是原来那份(见 shareUnchanged),算出来的也还是原来的 + // 按 doc.blocks 而不是 doc 认:同一版重发、只改了版本号时块是原来那份(见 shareBlocks),算出来的也还是原来的 const changesOf = memoizeOne((blocks: BlockView[], version: number, error: boolean, diff: DocumentDiff | null, excluded: Excluded) => error ? undefined // 删掉的块按块筛选同样藏 diff --git a/vscode/webview-ui/src/codeReader/placement.test.ts b/vscode/webview-ui/src/codeReader/placement.test.ts index 09046e1..d6667b6 100644 --- a/vscode/webview-ui/src/codeReader/placement.test.ts +++ b/vscode/webview-ui/src/codeReader/placement.test.ts @@ -22,8 +22,8 @@ function byId(blocks: BlockView[]): Map { return new Map(flatten(blocks).map(block => [block.id, block])) } -/** 一块自己的样子(不连子块):子块变了不算它变了 */ -function own({ children: _, ...rest }: BlockView): Omit { +/** 一块自己的样子(不连子块和连子块算的指纹):子块变了不算它变了 */ +function own({ children: _, hash: __, ...rest }: BlockView): Omit { return rest } diff --git a/vscode/webview-ui/src/codeReader/sharing.test.ts b/vscode/webview-ui/src/codeReader/sharing.test.ts index 460ef07..9bef0a6 100644 --- a/vscode/webview-ui/src/codeReader/sharing.test.ts +++ b/vscode/webview-ui/src/codeReader/sharing.test.ts @@ -1,5 +1,43 @@ +import type { UnhashedBlock } from './testBlocks' import { describe, expect, it } from 'vitest' -import { shareUnchanged } from './sharing' +import { shareBlocks, shareUnchanged } from './sharing' +import { hashed, NO_SPAN } from './testBlocks' + +describe('新文档里指纹没变的块沿用上一份的对象', () => { + function card(id: string, text: string, children: UnhashedBlock[] = []): UnhashedBlock { + return { id, anchor: id, kind: 'statement', span: NO_SPAN, attributes: {}, concepts: [], children, translation: [{ text, role: 'plain' }] } + } + const PREV = hashed([card('0', 'a'), card('1', 'b', [card('1.0', 'c'), card('1.1', 'd')]), card('2', 'e')]) + + it('整份一样:交回上一份本身;没有上一份时原样交回', () => { + const same = hashed([card('0', 'a'), card('1', 'b', [card('1.0', 'c'), card('1.1', 'd')]), card('2', 'e')]) + expect(shareBlocks(PREV, same)).toBe(PREV) + expect(shareBlocks(undefined, same)).toBe(same) + }) + + it('子块改了:它和包着它的块是新的,别的块(连同改了的块旁边没变的子块)还是原来的对象', () => { + const next = hashed([card('0', 'a'), card('1', 'b', [card('1.0', 'c'), card('1.1', 'D')]), card('2', 'e')]) + const shared = shareBlocks(PREV, next) + expect(shared).toEqual(next) + expect(shared).not.toBe(PREV) + expect(shared[0]).toBe(PREV[0]) + expect(shared[2]).toBe(PREV[2]) + expect(shared[1]).not.toBe(PREV[1]) + expect(shared[1].children[0]).toBe(PREV[1].children[0]) + expect(shared[1].children[1]).not.toBe(PREV[1].children[1]) + }) + + it('多了、少了块:那一层是新的数组,没变的块照样沿用', () => { + const longer = shareBlocks(PREV, hashed([card('0', 'a'), card('1', 'b', [card('1.0', 'c'), card('1.1', 'd')]), card('2', 'e'), card('3', 'f')])) + expect(longer).not.toBe(PREV) + expect(longer.slice(0, 3)).toEqual(PREV) + longer.slice(0, 3).forEach((block, index) => expect(block).toBe(PREV[index])) + const shorter = shareBlocks(PREV, hashed([card('0', 'a'), card('1', 'b', [card('1.0', 'c')]), card('2', 'e')])) + expect(shorter[1]).not.toBe(PREV[1]) + expect(shorter[1].children[0]).toBe(PREV[1].children[0]) + expect(shorter[2]).toBe(PREV[2]) + }) +}) describe('新消息里没变的部分沿用上一份的对象', () => { const prev = { diff --git a/vscode/webview-ui/src/codeReader/sharing.ts b/vscode/webview-ui/src/codeReader/sharing.ts index 15ab93a..8fb9208 100644 --- a/vscode/webview-ui/src/codeReader/sharing.ts +++ b/vscode/webview-ui/src/codeReader/sharing.ts @@ -1,7 +1,9 @@ +import type { BlockView } from './types' + /** * 新收到的一份数据里,和上一份内容一样的部分换回上一份里的那个对象(整份一样就交回上一份本身), - * 同 TanStack Query 的 replaceEqualDeep:扩展端每次发来的都是新对象,换回去之后没变的块、行还是原来那个,按卡片订阅的组件看得出没变、不重画。 - * 只认消息里那样的数据:普通对象、数组、原始值。 + * 同 TanStack Query 的 replaceEqualDeep:扩展端每次发来的都是新对象(差异、问答的消息),换回去之后没变的部分还是原来那个,按卡片订阅的组件看得出没变、不重画。 + * 只认消息里那样的数据:普通对象、数组、原始值。文档的块树带内容指纹,见 shareBlocks。 */ export function shareUnchanged(prev: unknown, next: T): T { if (Object.is(prev, next)) @@ -29,6 +31,30 @@ export function shareUnchanged(prev: unknown, next: T): T { return next } +/** + * 新收到的块树里,内容指纹(BlockView.hash)和上一份里某块一样的块换回上一份里的那个对象(连子块整棵沿用),某一层的块全都沿用时交回上一份的那一层; + * 整份一样就交回上一份本身。指纹由扩展端按块的内容(连子块、在源码里占的地方)算好,这边不用逐块比内容。 + */ +export function shareBlocks(prev: BlockView[] | undefined, next: BlockView[]): BlockView[] { + if (!prev) + return next + const byHash = new Map() + const byId = new Map() + const collect = (list: readonly BlockView[]): void => { + for (const block of list) { + byHash.set(block.hash, block) + byId.set(block.id, block) + collect(block.children) + } + } + collect(prev) + const share = (list: BlockView[], before: BlockView[] | undefined): BlockView[] => { + const shared = list.map(block => byHash.get(block.hash) ?? { ...block, children: share(block.children, byId.get(block.id)?.children) }) + return before && before.length === shared.length && shared.every((block, index) => block === before[index]) ? before : shared + } + return share(next, prev) +} + function isPlainObject(value: unknown): value is Record { if (typeof value !== 'object' || value === null) return false diff --git a/vscode/webview-ui/src/codeReader/testBlocks.ts b/vscode/webview-ui/src/codeReader/testBlocks.ts index 2b96914..8e79bf2 100644 --- a/vscode/webview-ui/src/codeReader/testBlocks.ts +++ b/vscode/webview-ui/src/codeReader/testBlocks.ts @@ -1,20 +1,38 @@ import type { BlockSpan, BlockView } from './types' +/** 逐个去掉联合类型各支里的键(Omit 会把译文、属性表两种卡片并成一种) */ +type Without = T extends unknown ? Omit : never + +/** 测试里手写的块,还没有内容指纹(hashed 补上) */ +export type UnhashedBlock = Without & { children: UnhashedBlock[] } + /** * 测试里手写的块:照文件里的位置写,下标就是文件里的(各块的开头都在下标 0),lines 是块在文件里的 [首行, 尾行](不写是接着前一块的尾行)。 */ -export type WrittenBlock = Omit & { lines?: [number, number], children: WrittenBlock[] } +export type WrittenBlock = Without & { lines?: [number, number], children: WrittenBlock[] } /** 下标、行号都不挪:块里的位置就是文件里的 */ export const NO_SPAN: BlockSpan = { lineGap: 0, gap: 0, lines: 0, length: 0 } +/** 补上内容指纹:扩展端是按内容算的摘要(见 pageBlocks.ts),测试里直接拿内容本身当指纹,内容一样指纹就一样。 */ +export function hashed(blocks: readonly UnhashedBlock[]): BlockView[] { + return blocks.map(({ children, ...rest }) => { + const kids = hashed(children) + return { ...rest, hash: JSON.stringify([rest, kids.map(kid => kid.hash)]), children: kids } as BlockView + }) +} + /** 手写的块排成扩展端发来的样子:span 按各块的首尾行累加(见 placement.ts),各块的开头都在下标 0,块里的下标就是文件里的。 */ export function placed(blocks: readonly WrittenBlock[], from = 0): BlockView[] { + return hashed(spanned(blocks, from)) +} + +function spanned(blocks: readonly WrittenBlock[], from: number): UnhashedBlock[] { let cursor = from return blocks.map(({ lines, children, ...rest }) => { const [start, end] = lines ?? [cursor, cursor] const span: BlockSpan = { lineGap: start - cursor, gap: 0, lines: end - start, length: 0 } cursor = end - return { ...rest, span, children: placed(children, start) } as BlockView + return { ...rest, span, children: spanned(children, start) } as UnhashedBlock }) } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index ebb7cce..9709393 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -186,10 +186,11 @@ export type CardView = CardViewBase & ({ translation: PreviewText, items?: Prope /** * 一块:卡片的内容,在源码里占的地方(span,相对的;块编辑时编辑框里是它的整行源码,子孙都不画,见 coveredByEditing),子块。 - * 内容没变、只是挪了位置的块,扩展端发来的一模一样 + * 内容没变、只是挪了位置的块,扩展端发来的一模一样;hash 是扩展端按这些(连子块)算的内容指纹,页面按它认出没变的块(见 shareBlocks) */ export type BlockView = CardView & { span: BlockSpan + hash: string children: BlockView[] }