✨ feat(vscode): 代码阅读预览按侧栏筛选算代码差异(未提交的更改对比上一次提交、审查对比 PR 目标分支;兄弟块按名字 / 相似度对齐,卡片内按行 / 值 / 词比对),算好后补发给预览
This commit is contained in:
1 parent
3db44f84ae
commit
2a5806c6b5
23 files changed
+1527
-31
No files matched your search
@@ -0,0 +1,234 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { SourcedBlocks } from './blockDiff'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readBlockViews } from '../blockView'
|
||||
import { createPythonAdapter } from '../languages/python'
|
||||
import { runtime, WASM_DIR } from '../testSupport'
|
||||
import { pythonZhTranslator } from '../translators/pythonZh'
|
||||
import { alignSiblings, diffBlockTrees, similarity } from './blockDiff'
|
||||
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
|
||||
async function python(source: string): Promise<SourcedBlocks> {
|
||||
const { blocks } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source)
|
||||
return { blocks, source }
|
||||
}
|
||||
|
||||
/** 手写的一块:只填比对用得到的几样。 */
|
||||
function view(anchor: string, kind: string, code: string, extra: Partial<BlockView> = {}): BlockView {
|
||||
return {
|
||||
id: anchor,
|
||||
anchor,
|
||||
kind,
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
headerEndLine: 0,
|
||||
code,
|
||||
codeTokens: [],
|
||||
attributes: { kind },
|
||||
children: [],
|
||||
translation: [{ text: code, role: 'plain' }],
|
||||
...extra,
|
||||
} as BlockView
|
||||
}
|
||||
|
||||
/** 名字写在源码开头:defines 指向它。 */
|
||||
function named(anchor: string, kind: string, name: string, code: string, source: { text: string }): BlockView {
|
||||
const startIndex = source.text.length
|
||||
source.text += name
|
||||
return view(anchor, kind, code, { defines: { startIndex, endIndex: startIndex + name.length } })
|
||||
}
|
||||
|
||||
const RELEASE_LUA = `_RELEASE_LUA = """
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('DEL', KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
"""`
|
||||
|
||||
const BEFORE = `"""Redis 互斥锁: SET NX EX 抢锁, Lua 比对 token 再删."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
${RELEASE_LUA}
|
||||
|
||||
|
||||
async def try_acquire(redis: Any, key: str, ttl_sec: int) -> bool:
|
||||
"""抢锁; 抢到返回持有者 token, 已被占用返回 None."""
|
||||
token = uuid.uuid4().hex
|
||||
ok = await redis.set(key, token, nx=True, ex=ttl_sec)
|
||||
return token if ok else None
|
||||
|
||||
|
||||
async def _is_owner(redis: Any, key: str, token: str) -> bool:
|
||||
"""锁仍归 token 时返回真."""
|
||||
return await redis.get(key) == token
|
||||
|
||||
|
||||
async def release(redis: Any, key: str, token: str) -> bool:
|
||||
"""只在锁仍归 token 时删掉; 返回是否真的删了."""
|
||||
result = await redis.eval(_RELEASE_LUA, 1, key)
|
||||
return int(result) == 1
|
||||
`
|
||||
|
||||
const AFTER = `"""Redis 互斥锁: SET NX EX 抢锁, Lua 比对 token 再续期 / 再删."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
${RELEASE_LUA}
|
||||
|
||||
_RENEW_LUA = """
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('EXPIRE', KEYS[1], ARGV[2])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
"""
|
||||
|
||||
|
||||
async def try_acquire(redis: Any, key: str, ttl_sec: int, token_prefix: str = "") -> str | None:
|
||||
"""抢锁; 抢到返回持有者 token, 已被占用返回 None."""
|
||||
token = f"{token_prefix}{uuid.uuid4().hex}"
|
||||
ok = await redis.set(key, token, nx=True, ex=ttl_sec)
|
||||
return token if ok else None
|
||||
|
||||
|
||||
async def renew(redis: Any, key: str, token: str, ttl_sec: int) -> bool:
|
||||
"""只在锁仍归 token 时把 TTL 续回 ttl_sec; 返回是否续上."""
|
||||
result = await redis.eval(_RENEW_LUA, 1, key, token, str(ttl_sec))
|
||||
return int(result) == 1
|
||||
|
||||
|
||||
async def release(redis: Any, key: str, token: str) -> bool:
|
||||
"""只在锁仍归 token 时删掉; 返回是否真的删了."""
|
||||
result = await redis.eval(_RELEASE_LUA, 1, key, token)
|
||||
return int(result) == 1
|
||||
`
|
||||
|
||||
describe('similarity', () => {
|
||||
it('一样是 1,毫不相干是 0,改了一个词在中间', () => {
|
||||
expect(similarity('a = 1', 'a = 1')).toBe(1)
|
||||
expect(similarity('foo(bar)', 'x')).toBe(0)
|
||||
const score = similarity('print(a, b)', 'print(a, c)')
|
||||
expect(score).toBeGreaterThan(0.5)
|
||||
expect(score).toBeLessThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('alignSiblings', () => {
|
||||
it('有名字的按种类 + 名字配,挪了位置也配得上;改名算一删一增', () => {
|
||||
const source = { text: '' }
|
||||
const before = [named('f', 'function', 'f', 'def f(): pass', source), named('g', 'function', 'g', 'def g(): pass', source)]
|
||||
const after = [named('g2', 'function', 'g', 'def g(): return 1', source), named('h', 'function', 'h', 'def f(): pass', source)]
|
||||
const { toOld, toNew } = alignSiblings(before, source.text, after, source.text)
|
||||
expect(toOld).toEqual([1, undefined])
|
||||
expect(toNew).toEqual([undefined, 0])
|
||||
})
|
||||
|
||||
it('同名的照先后配', () => {
|
||||
const source = { text: '' }
|
||||
const before = [named('a', 'function', 'f', 'one', source), named('b', 'function', 'f', 'two', source)]
|
||||
const after = [named('c', 'function', 'f', 'one', source), named('d', 'function', 'f', 'two!', source)]
|
||||
expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('种类不同的同名块不配', () => {
|
||||
const source = { text: '' }
|
||||
const before = [named('a', 'const', 'x', 'const x = 1', source)]
|
||||
const after = [named('b', 'variable', 'x', 'let x = 1', source)]
|
||||
expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('没名字的:源码没变的按最长公共子序列配,中间的按相似度配成修改,不像的算一删一增', () => {
|
||||
const before = [view('1', 'statement', 'print(a)'), view('2', 'statement', 'call(alpha, beta, gamma)'), view('3', 'statement', 'print(b)')]
|
||||
const after = [
|
||||
view('1', 'statement', 'print(a)'),
|
||||
view('x', 'statement', 'totally_new()'),
|
||||
view('2', 'statement', 'call(alpha, beta, delta)'),
|
||||
view('3', 'statement', 'print(b)'),
|
||||
]
|
||||
expect(alignSiblings(before, '', after, '').toOld).toEqual([0, undefined, 1, 2])
|
||||
})
|
||||
|
||||
it('没名字的块种类不同就不按相似度配', () => {
|
||||
const before = [view('1', 'statement', 'call(alpha, beta)')]
|
||||
const after = [view('2', 'return', 'call(alpha, beta, gamma)')]
|
||||
expect(alignSiblings(before, '', after, '').toOld).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('有名字的块不按相似度配:改名算一删一增', () => {
|
||||
const source = { text: '' }
|
||||
const before = [named('a', 'function', 'old_name', 'def old_name(x): return x + 1', source)]
|
||||
const after = [named('b', 'function', 'new_name', 'def new_name(x): return x + 1', source)]
|
||||
expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffBlockTrees', () => {
|
||||
it('原型的 mutex.py:新增 2 · 修改 3 · 删除 1,只数最外层', async () => {
|
||||
const diff = diffBlockTrees(await python(BEFORE), await python(AFTER))
|
||||
expect(diff.stats).toEqual({ add: 2, mod: 3, del: 1 })
|
||||
})
|
||||
|
||||
it('新增的块连同子孙都算新增,只有最外面那块挂小标', async () => {
|
||||
const after = await python(AFTER)
|
||||
const diff = diffBlockTrees(await python(BEFORE), after)
|
||||
const renew = after.blocks.find(block => block.anchor === 'function renew')!
|
||||
expect(diff.blocks[renew.anchor]).toEqual({ change: 'add', outermost: true })
|
||||
for (const child of renew.children)
|
||||
expect(diff.blocks[child.anchor]).toEqual({ change: 'add' })
|
||||
})
|
||||
|
||||
it('修改的块:自己的内容改了带标记,子块照样比;只有子块改了的父块不算自己改了', async () => {
|
||||
const after = await python(AFTER)
|
||||
const diff = diffBlockTrees(await python(BEFORE), after)
|
||||
const tryAcquire = after.blocks.find(block => block.anchor === 'function try_acquire')!
|
||||
expect(diff.blocks[tryAcquire.anchor]).toMatchObject({ change: 'mod', own: true })
|
||||
expect(diff.blocks[tryAcquire.anchor].marks?.rows).toBeDefined()
|
||||
const [token, ok, back] = tryAcquire.children
|
||||
expect(diff.blocks[token.anchor]).toMatchObject({ change: 'mod', own: true })
|
||||
expect(diff.blocks[ok.anchor]).toBeUndefined()
|
||||
expect(diff.blocks[back.anchor]).toBeUndefined()
|
||||
|
||||
const release = after.blocks.find(block => block.anchor === 'function release')!
|
||||
expect(diff.blocks[release.anchor]).toEqual({ change: 'mod' })
|
||||
expect(diff.blocks[release.children[0].anchor]).toMatchObject({ change: 'mod', own: true })
|
||||
})
|
||||
|
||||
it('删掉的块放回原来的位置:前面配上的兄弟之后,再越过紧跟着的新增块;只带它自己的内容', async () => {
|
||||
const after = await python(AFTER)
|
||||
const diff = diffBlockTrees(await python(BEFORE), after)
|
||||
expect(diff.removed).toHaveLength(1)
|
||||
const [removed] = diff.removed
|
||||
expect(removed).toMatchObject({ parent: '', after: 'function renew' })
|
||||
expect(removed.block.anchor).toBe('function _is_owner')
|
||||
expect(removed.block.children).toEqual([])
|
||||
expect(removed.block.defines).toBeUndefined()
|
||||
const renew = after.blocks.find(block => block.anchor === 'function renew')!
|
||||
expect(removed.line).toBe(renew.endLine + 1)
|
||||
const names = JSON.stringify(removed.block)
|
||||
expect(names).not.toContain('"offset"')
|
||||
})
|
||||
|
||||
it('没变的文件没有改动', async () => {
|
||||
const before = await python(BEFORE)
|
||||
expect(diffBlockTrees(before, await python(BEFORE))).toEqual({ blocks: {}, removed: [], stats: { add: 0, mod: 0, del: 0 } })
|
||||
})
|
||||
|
||||
it('子块里删掉的块挂在父块下面,排在最前面时没有 after', async () => {
|
||||
const before = await python('def f():\n a = 1\n b = 2\n')
|
||||
const after = await python('def f():\n b = 2\n')
|
||||
const diff = diffBlockTrees(before, after)
|
||||
expect(diff.stats).toEqual({ add: 0, mod: 1, del: 0 })
|
||||
expect(diff.removed).toHaveLength(1)
|
||||
expect(diff.removed[0]).toMatchObject({ parent: 'function f', line: 1 })
|
||||
expect(diff.removed[0].after).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { BlockMarks } from './viewDiff'
|
||||
import { diffArrays, diffWordsWithSpace } from 'diff'
|
||||
import { blockMarks, removedView } from './viewDiff'
|
||||
|
||||
/**
|
||||
* 一份文件改之前、改之后的两棵块树比出哪些块新增、修改、删除。同一父块下的兄弟块对齐:
|
||||
* 有名字的块按「种类 + 名字」配对,没名字的按位置和内容相似度配对(最长公共子序列找出没变的,夹在中间的按相似度配),
|
||||
* 配不上的旧有新无算删除、旧无新有算新增;改名算一删一增。配上的块整段源码一样算没变,否则算修改、接着比子块。
|
||||
* 这里不碰 VS Code。
|
||||
*/
|
||||
|
||||
/** 块的改动:新增、修改;删掉的块另列(RemovedBlock)。 */
|
||||
export type BlockChange = 'add' | 'mod'
|
||||
|
||||
export interface ChangedBlock {
|
||||
change: BlockChange
|
||||
/** 新增的最外面那块(挂「新增」小标);它的子孙跟着算新增、不挂 */
|
||||
outermost?: true
|
||||
/** 修改的块自己(不算子块)有变:卡片里的内容变了,或源码变了、子块却都没变 */
|
||||
own?: true
|
||||
/** 修改的块自己的内容改了哪些(见 blockMarks) */
|
||||
marks?: BlockMarks
|
||||
}
|
||||
|
||||
/**
|
||||
* 删掉的一块,放回它原来的位置:旧版本里它前面最近一个配上的兄弟所配的新块后面,
|
||||
* 再越过紧跟在那块后面的新增块(新增的在前、删掉的在后,都夹在原来前后两个邻居中间)。
|
||||
*/
|
||||
export interface RemovedBlock {
|
||||
/** 在哪一层:父块的记号(BlockView.anchor),顶层是 '' */
|
||||
parent: string
|
||||
/** 排在这一层哪块后面(新块的记号);排在最前面时没有 */
|
||||
after?: string
|
||||
/** 旧版本里的这块:只带它自己的内容,词不带源码下标(见 removedView) */
|
||||
block: BlockView
|
||||
/** 它在新文件里的位置(0 起的行号):总览条上的刻度画在这 */
|
||||
line: number
|
||||
}
|
||||
|
||||
/** 「新增 N · 修改 N · 删除 N」:只数最外层带标记的块,新增块的子块、修改块里改动的子块不再数。 */
|
||||
export interface DiffStats { add: number, mod: number, del: number }
|
||||
|
||||
export interface TreeDiff {
|
||||
/** 按新块的记号;没变的块不写 */
|
||||
blocks: Record<string, ChangedBlock>
|
||||
removed: RemovedBlock[]
|
||||
stats: DiffStats
|
||||
}
|
||||
|
||||
/** 一份源码的块树和源码本身(块的名字按 defines 从源码里取)。 */
|
||||
export interface SourcedBlocks {
|
||||
blocks: readonly BlockView[]
|
||||
source: string
|
||||
}
|
||||
|
||||
/** 没名字的两块内容相似度(0~1)到这么多才配成修改,否则算一删一增。 */
|
||||
const SIMILAR = 0.5
|
||||
/** 没名字的块往后最多找这么多块配相似的,改动很大的文件不至于两两比个遍。 */
|
||||
const SIMILAR_WINDOW = 40
|
||||
|
||||
/** 块自己的名字:defines 那一段源码(函数名、类名、只声明一个变量时的变量名、规则卡片讲的名字);没名字的块没有。 */
|
||||
function nameOf(view: BlockView, source: string): string | undefined {
|
||||
return view.defines && source.slice(view.defines.startIndex, view.defines.endIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* 两段源码有多像(0~1):2 × 没变的字数 ÷ 两边字数之和,按词比(中文按字、标点各算一个)。
|
||||
* 一边比另一边长三倍以上时没变的字不可能过半,直接当不像。
|
||||
*/
|
||||
export function similarity(a: string, b: string): number {
|
||||
if (a === b)
|
||||
return 1
|
||||
if (Math.max(a.length, b.length) > 3 * Math.min(a.length, b.length))
|
||||
return 0
|
||||
const kept = diffWordsWithSpace(a, b).filter(change => !change.added && !change.removed).reduce((sum, change) => sum + change.value.length, 0)
|
||||
return 2 * kept / (a.length + b.length)
|
||||
}
|
||||
|
||||
/** 同一父块下的兄弟块怎么对上:新的第几块配旧的第几块(没配上是 undefined),反过来也记一份。 */
|
||||
export interface Alignment {
|
||||
toOld: (number | undefined)[]
|
||||
toNew: (number | undefined)[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一父块下的兄弟块对齐:
|
||||
* ① 有名字的按「种类 + 名字」配,同名的照先后,挪了位置也配得上;
|
||||
* ② 以此为锚,最长公共子序列找出种类、源码都没变的没名字的块;
|
||||
* ③ 夹在相邻两个锚中间、还没配上的没名字的块,照先后找种类相同、内容最像且相似度到 SIMILAR 的配上(算修改)。
|
||||
* 有名字却没配上的(改了名、删了、新加的)不按相似度配:改名算一删一增。
|
||||
*/
|
||||
export function alignSiblings(before: readonly BlockView[], beforeSource: string, after: readonly BlockView[], afterSource: string): Alignment {
|
||||
const toOld: (number | undefined)[] = after.map(() => undefined)
|
||||
const toNew: (number | undefined)[] = before.map(() => undefined)
|
||||
const pair = (old: number, now: number): void => {
|
||||
toOld[now] = old
|
||||
toNew[old] = now
|
||||
}
|
||||
const oldNames = before.map(view => nameOf(view, beforeSource))
|
||||
const newNames = after.map(view => nameOf(view, afterSource))
|
||||
const waiting = new Map<string, number[]>()
|
||||
before.forEach((view, old) => {
|
||||
const name = oldNames[old]
|
||||
if (name !== undefined)
|
||||
waiting.set(`${view.kind}\u0000${name}`, [...waiting.get(`${view.kind}\u0000${name}`) ?? [], old])
|
||||
})
|
||||
after.forEach((view, now) => {
|
||||
const name = newNames[now]
|
||||
const old = name === undefined ? undefined : waiting.get(`${view.kind}\u0000${name}`)?.shift()
|
||||
if (old !== undefined)
|
||||
pair(old, now)
|
||||
})
|
||||
|
||||
const unnamedSame = (old: number, now: number): boolean => oldNames[old] === undefined && newNames[now] === undefined
|
||||
&& before[old].kind === after[now].kind && before[old].code === after[now].code
|
||||
const changes = diffArrays(before.map((_, i) => i), after.map((_, i) => i), { comparator: (old, now) => toNew[old] === now || unnamedSame(old, now) })
|
||||
const anchors: [number, number][] = []
|
||||
let old = 0
|
||||
let now = 0
|
||||
for (const change of changes) {
|
||||
if (!change.added && !change.removed) {
|
||||
for (let k = 0; k < change.count; k++) {
|
||||
if (toNew[old + k] === undefined)
|
||||
pair(old + k, now + k)
|
||||
anchors.push([old + k, now + k])
|
||||
}
|
||||
}
|
||||
if (!change.added)
|
||||
old += change.count
|
||||
if (!change.removed)
|
||||
now += change.count
|
||||
}
|
||||
|
||||
let lastOld = -1
|
||||
let lastNew = -1
|
||||
for (const [anchorOld, anchorNew] of [...anchors, [before.length, after.length]]) {
|
||||
let from = lastNew + 1
|
||||
for (let o = lastOld + 1; o < anchorOld; o++) {
|
||||
if (toNew[o] !== undefined || oldNames[o] !== undefined)
|
||||
continue
|
||||
let best: number | undefined
|
||||
let bestScore = 0
|
||||
for (let n = from; n < Math.min(anchorNew, from + SIMILAR_WINDOW); n++) {
|
||||
if (toOld[n] !== undefined || newNames[n] !== undefined || after[n].kind !== before[o].kind)
|
||||
continue
|
||||
const score = similarity(before[o].code, after[n].code)
|
||||
if (score >= SIMILAR && score > bestScore) {
|
||||
best = n
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
if (best !== undefined) {
|
||||
pair(o, best)
|
||||
from = best + 1
|
||||
}
|
||||
}
|
||||
lastOld = anchorOld
|
||||
lastNew = anchorNew
|
||||
}
|
||||
return { toOld, toNew }
|
||||
}
|
||||
|
||||
/** 新增的一块连同子孙:最外面那块挂「新增」小标,子孙跟着算新增。 */
|
||||
function markAdded(view: BlockView, outermost: boolean, blocks: Record<string, ChangedBlock>): void {
|
||||
blocks[view.anchor] = { change: 'add', ...(outermost && { outermost: true as const }) }
|
||||
for (const child of view.children)
|
||||
markAdded(child, false, blocks)
|
||||
}
|
||||
|
||||
/**
|
||||
* 两棵块树比:新增、修改的块按新块的记号记下,删掉的块放回原来的位置。
|
||||
* 统计只数最外层带标记的块(见 DiffStats)。
|
||||
*/
|
||||
export function diffBlockTrees(before: SourcedBlocks, after: SourcedBlocks): TreeDiff {
|
||||
const blocks: Record<string, ChangedBlock> = {}
|
||||
const removed: RemovedBlock[] = []
|
||||
const stats: DiffStats = { add: 0, mod: 0, del: 0 }
|
||||
|
||||
/**
|
||||
* 比一层兄弟块:parent 是这一层所属的新块(顶层没有),inside 表示外层已经带了标记(这一层的改动不再计数)。
|
||||
* @returns 这一层有没有块新增、修改、删除
|
||||
*/
|
||||
const level = (old: readonly BlockView[], now: readonly BlockView[], parent: BlockView | undefined, inside: boolean): boolean => {
|
||||
const { toOld, toNew } = alignSiblings(old, before.source, now, after.source)
|
||||
let changed = false
|
||||
now.forEach((view, index) => {
|
||||
const paired = toOld[index]
|
||||
if (paired === undefined) {
|
||||
markAdded(view, true, blocks)
|
||||
changed = true
|
||||
if (!inside)
|
||||
stats.add += 1
|
||||
return
|
||||
}
|
||||
const previous = old[paired]
|
||||
if (previous.code === view.code)
|
||||
return
|
||||
const marks = blockMarks(previous, view)
|
||||
const childrenChanged = level(previous.children, view.children, view, true)
|
||||
blocks[view.anchor] = { change: 'mod', ...((marks || !childrenChanged) && { own: true as const }), ...(marks && { marks }) }
|
||||
changed = true
|
||||
if (!inside)
|
||||
stats.mod += 1
|
||||
})
|
||||
// 删掉的块排在它前面最近一个配上的兄弟所配的新块后面,再越过紧跟着的新增块:落在原来前后两个邻居中间,新增的在前、删掉的在后
|
||||
const throughAdded = (from: number): BlockView | undefined => {
|
||||
let end = from
|
||||
while (end + 1 < now.length && toOld[end + 1] === undefined)
|
||||
end += 1
|
||||
return end >= 0 ? now[end] : undefined
|
||||
}
|
||||
let last = throughAdded(-1)
|
||||
old.forEach((view, index) => {
|
||||
const paired = toNew[index]
|
||||
if (paired !== undefined) {
|
||||
last = throughAdded(paired)
|
||||
return
|
||||
}
|
||||
removed.push({
|
||||
parent: parent?.anchor ?? '',
|
||||
...(last && { after: last.anchor }),
|
||||
block: removedView(view),
|
||||
line: last ? last.endLine + 1 : parent ? parent.headerEndLine + 1 : 0,
|
||||
})
|
||||
changed = true
|
||||
if (!inside)
|
||||
stats.del += 1
|
||||
})
|
||||
return changed
|
||||
}
|
||||
|
||||
level(before.blocks, after.blocks, undefined, false)
|
||||
return { blocks, removed, stats }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 对比基准:侧栏只列改动的文件时(「未提交的更改」对比上一次提交,「选工单」审查对比 PR 的目标分支),
|
||||
* 预览拿它取这个文件改之前的样子,和现在的比出差异。由开着的那种侧栏筛选给(TreeFilter.diffBase)。
|
||||
*/
|
||||
export interface DiffBase {
|
||||
/** 筛选栏上「对比:」后面的字:「上一次提交」「PR 目标分支 master」 */
|
||||
label: string
|
||||
/** 同一个基准里的同一个文件 key 一样:取过的旧内容按它记着,基准挪了(提交后 HEAD 动了)key 跟着变 */
|
||||
key: string
|
||||
/** 基准里这个文件的内容;基准里没有这个文件是 null */
|
||||
read: () => Promise<string | null>
|
||||
/** 基准里没有这个文件时横幅上的一句说明 */
|
||||
missing: string
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { DiffBase } from './diffBase'
|
||||
import type { DocumentDiff, OldDocument, SentDocument } from './previewDiff'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fileAttributes } from '../attributes'
|
||||
import { readBlockViews } from '../blockView'
|
||||
import { createPythonAdapter } from '../languages/python'
|
||||
import { runtime, WASM_DIR } from '../testSupport'
|
||||
import { pythonZhTranslator } from '../translators/pythonZh'
|
||||
import { PreviewDiff } from './previewDiff'
|
||||
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
|
||||
async function parse(source: string): Promise<OldDocument> {
|
||||
const { blocks } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source)
|
||||
return { blocks, attributes: fileAttributes(blocks, pythonZhTranslator.attributeNames) }
|
||||
}
|
||||
|
||||
async function sent(source: string, version = 1): Promise<SentDocument> {
|
||||
return { version, source, blocks: (await parse(source)).blocks }
|
||||
}
|
||||
|
||||
function base(text: string | null, key = 'k'): DiffBase & { read: ReturnType<typeof vi.fn> } {
|
||||
return { label: '上一次提交', key, read: vi.fn(async () => text), missing: '上一次提交里没有这个文件' }
|
||||
}
|
||||
|
||||
function setup(current: { base: DiffBase | undefined }) {
|
||||
const posted: (DocumentDiff | null)[] = []
|
||||
const reports: string[] = []
|
||||
const diff = new PreviewDiff(() => current.base, parse, value => posted.push(value), message => reports.push(message))
|
||||
return { diff, posted, reports }
|
||||
}
|
||||
|
||||
describe('previewDiff', () => {
|
||||
it('比出差异发出去:统计、删掉的块带种类的名字、新文件一共几行', async () => {
|
||||
const current = { base: base('def f():\n return 1\n\n\ndef g():\n pass\n') as DiffBase | undefined }
|
||||
const { diff, posted } = setup(current)
|
||||
await diff.update(await sent('def f():\n return 2\n'))
|
||||
expect(posted).toHaveLength(1)
|
||||
expect(posted[0]).toMatchObject({ base: '上一次提交', version: 1, stats: { add: 0, mod: 1, del: 1 }, lineCount: 3 })
|
||||
expect(posted[0]?.removed[0]).toMatchObject({ kindName: '函数', after: 'function f' })
|
||||
})
|
||||
|
||||
it('基准里没有这个文件:整份是新文件,不逐块标', async () => {
|
||||
const { diff, posted } = setup({ base: base(null) })
|
||||
await diff.update(await sent('x = 1\n'))
|
||||
expect(posted).toEqual([{ base: '上一次提交', version: 1, lineCount: 2, newFile: '上一次提交里没有这个文件', blocks: {}, removed: [], stats: { add: 0, mod: 0, del: 0 } }])
|
||||
})
|
||||
|
||||
it('没有基准时不发;发过差异后基准没了发一次 null', async () => {
|
||||
const current: { base: DiffBase | undefined } = { base: undefined }
|
||||
const { diff, posted } = setup(current)
|
||||
const doc = await sent('x = 1\n')
|
||||
await diff.update(doc)
|
||||
expect(posted).toEqual([])
|
||||
current.base = base('x = 1\n')
|
||||
await diff.update(doc)
|
||||
expect(posted).toHaveLength(1)
|
||||
current.base = undefined
|
||||
await diff.update(doc)
|
||||
await diff.update(doc)
|
||||
expect(posted.at(-1)).toBeNull()
|
||||
expect(posted).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('旧内容按基准的 key 记着:文档改了不再取,key 变了重取', async () => {
|
||||
const first = base('x = 1\n', 'a')
|
||||
const current: { base: DiffBase | undefined } = { base: first }
|
||||
const { diff } = setup(current)
|
||||
await diff.update(await sent('x = 2\n', 1))
|
||||
await diff.update(await sent('x = 3\n', 2))
|
||||
expect(first.read).toHaveBeenCalledTimes(1)
|
||||
const second = base('x = 1\n', 'b')
|
||||
current.base = second
|
||||
await diff.update(await sent('x = 3\n', 2))
|
||||
expect(second.read).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('同一份文档、同一个基准不再比', async () => {
|
||||
const { diff, posted } = setup({ base: base('x = 1\n') })
|
||||
const doc = await sent('x = 2\n')
|
||||
await diff.update(doc)
|
||||
await diff.update(doc)
|
||||
expect(posted).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('取不到旧内容:记一笔,清掉发过的差异,下次重取', async () => {
|
||||
const failing: DiffBase = { label: '上一次提交', key: 'k', read: vi.fn().mockRejectedValueOnce(new Error('git 挂了')).mockResolvedValue('x = 1\n'), missing: '' }
|
||||
const current: { base: DiffBase | undefined } = { base: base('x = 1\n', 'ok') }
|
||||
const { diff, posted, reports } = setup(current)
|
||||
const doc = await sent('x = 2\n')
|
||||
await diff.update(doc)
|
||||
current.base = failing
|
||||
await diff.update(doc)
|
||||
expect(reports[0]).toContain('git 挂了')
|
||||
expect(posted.at(-1)).toBeNull()
|
||||
await diff.update(doc)
|
||||
expect(failing.read).toHaveBeenCalledTimes(2)
|
||||
expect(posted.at(-1)).toMatchObject({ stats: { mod: 1 } })
|
||||
})
|
||||
|
||||
it('慢的旧结果不盖掉新的', async () => {
|
||||
let release: (text: string) => void = () => {}
|
||||
const slow: DiffBase = { label: '上一次提交', key: 'slow', read: () => new Promise(resolve => release = resolve), missing: '' }
|
||||
const current: { base: DiffBase | undefined } = { base: slow }
|
||||
const { diff, posted } = setup(current)
|
||||
const pending = diff.update(await sent('x = 2\n', 1))
|
||||
current.base = base('x = 1\n', 'fast')
|
||||
await diff.update(await sent('x = 2\n', 2))
|
||||
release('x = 1\n')
|
||||
await pending
|
||||
expect(posted.map(value => value?.version)).toEqual([2])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { FileAttribute } from '../attributes'
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { ChangedBlock, DiffStats, RemovedBlock } from './blockDiff'
|
||||
import type { DiffBase } from './diffBase'
|
||||
import { splitLines } from '../editText'
|
||||
import { diffBlockTrees } from './blockDiff'
|
||||
|
||||
/** 删掉的一块(见 RemovedBlock),带上种类的名字:旧版本里才有的种类,新文件的筛选属性里查不到它叫什么。 */
|
||||
export interface RemovedView extends RemovedBlock {
|
||||
kindName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 发给预览的差异:这份文件相比对比基准(DiffBase)改了什么。块按记号(BlockView.anchor)认,文档刚改、差异还没重算时
|
||||
* 没动过的块照样对得上;卡片里的标记按下标认,只在页面上正是 version 这一版时画。
|
||||
*/
|
||||
export interface DocumentDiff {
|
||||
/** 「对比:」后面的字 */
|
||||
base: string
|
||||
/** 算的是哪一版文档 */
|
||||
version: number
|
||||
/** 基准里没有这个文件时横幅上的说明;这时整份都是新增,不逐块标 */
|
||||
newFile?: string
|
||||
/** 新增、修改的块,按新块的记号 */
|
||||
blocks: Record<string, ChangedBlock>
|
||||
removed: RemovedView[]
|
||||
stats: DiffStats
|
||||
/** 新文件一共几行:总览条按它排刻度 */
|
||||
lineCount: number
|
||||
}
|
||||
|
||||
/** 发出去的一份文档:版本、块树、源码。 */
|
||||
export interface SentDocument {
|
||||
version: number
|
||||
blocks: BlockView[]
|
||||
source: string
|
||||
}
|
||||
|
||||
/** 旧版本解析出来的块树和筛选属性(种类的名字从这取)。 */
|
||||
export interface OldDocument {
|
||||
blocks: BlockView[]
|
||||
attributes: FileAttribute[]
|
||||
}
|
||||
|
||||
const NO_CHANGES: DiffStats = { add: 0, mod: 0, del: 0 }
|
||||
|
||||
/** 旧版本里各种类叫什么:筛选属性 kind 的各个值的名字。 */
|
||||
function kindNames(attributes: readonly FileAttribute[]): Map<string, string> {
|
||||
return new Map(attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name]))
|
||||
}
|
||||
|
||||
/** 按基准比出来的差异:基准里没有这个文件时整份是新文件,否则两棵块树比(见 diffBlockTrees)。 */
|
||||
export function documentDiff(base: DiffBase, sent: SentDocument, old: { source: string, document: OldDocument } | null): DocumentDiff {
|
||||
const common = { base: base.label, version: sent.version, lineCount: splitLines(sent.source).length }
|
||||
if (!old)
|
||||
return { ...common, newFile: base.missing, blocks: {}, removed: [], stats: NO_CHANGES }
|
||||
const { blocks, removed, stats } = diffBlockTrees({ blocks: old.document.blocks, source: old.source }, sent)
|
||||
const kinds = kindNames(old.document.attributes)
|
||||
return { ...common, blocks, removed: removed.map(item => ({ ...item, kindName: kinds.get(item.block.kind) ?? item.block.kind })), stats }
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个打开着的预览的差异:每发出一份文档之后、对比基准变了之后按眼下的基准重新比,比完再发,预览先照常画、不等它。
|
||||
* 取过的旧内容按基准的 key 记着(编辑时不用每次去问 git),旧版本每次重新解析:规则查到的定义、调用的种类这些晚到的答案两边要一样。
|
||||
* 这里不碰 VS Code。
|
||||
*/
|
||||
export class PreviewDiff {
|
||||
/** 取过的旧内容;key 相同不再取,取失败了下次重取 */
|
||||
private old: { key: string, text: Promise<string | null> } | undefined
|
||||
/** 只发最后一次比的结果:慢的旧结果不能盖掉新的 */
|
||||
private latest = 0
|
||||
/** 上一次比的是哪份文档、哪个基准:都没变就不再比(git 状态变了、基准没挪) */
|
||||
private compared: { sent: SentDocument, base: string | undefined } | undefined
|
||||
/** 发过差异(不是 null):基准没了时要发一次 null 清掉 */
|
||||
private showing = false
|
||||
|
||||
constructor(
|
||||
/** 眼下对比的基准;没有是不比(普通文件树) */
|
||||
private readonly baseOf: () => DiffBase | undefined,
|
||||
/** 用这份文档的语言、翻译插件和库规则解析旧版本;解析失败是 undefined */
|
||||
private readonly readOld: (source: string) => Promise<OldDocument | undefined>,
|
||||
private readonly post: (diff: DocumentDiff | null) => void,
|
||||
private readonly report: (message: string) => void,
|
||||
) {}
|
||||
|
||||
/** 按眼下的基准比 sent 这份文档,比完发出去;取不到旧内容、旧版本解析失败时记一笔,发 null。 */
|
||||
async update(sent: SentDocument): Promise<void> {
|
||||
const base = this.baseOf()
|
||||
const signature = base && [base.key, base.label, base.missing].join('\u0000')
|
||||
if (this.compared?.sent === sent && this.compared.base === signature)
|
||||
return
|
||||
this.compared = { sent, base: signature }
|
||||
const request = ++this.latest
|
||||
if (!base) {
|
||||
this.clear()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const text = await this.oldText(base)
|
||||
const document = text === null ? undefined : await this.readOld(text)
|
||||
if (request !== this.latest)
|
||||
return
|
||||
if (text !== null && !document) {
|
||||
this.report(`没能解析${base.label}里的旧版本,不标差异`)
|
||||
this.clear()
|
||||
return
|
||||
}
|
||||
this.showing = true
|
||||
this.post(documentDiff(base, sent, text === null || !document ? null : { source: text, document }))
|
||||
}
|
||||
catch (err) {
|
||||
if (request !== this.latest)
|
||||
return
|
||||
this.report(`没能取到${base.label}里的旧版本,不标差异:${err instanceof Error ? err.message : String(err)}`)
|
||||
this.compared = undefined
|
||||
this.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private clear(): void {
|
||||
if (!this.showing)
|
||||
return
|
||||
this.showing = false
|
||||
this.post(null)
|
||||
}
|
||||
|
||||
private oldText(base: DiffBase): Promise<string | null> {
|
||||
if (this.old?.key !== base.key) {
|
||||
const text = base.read()
|
||||
this.old = { key: base.key, text }
|
||||
text.catch(() => {
|
||||
if (this.old?.text === text)
|
||||
this.old = undefined
|
||||
})
|
||||
}
|
||||
return this.old.text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { Inline, PropertyItem, PropertyRow, Segment, TokenRole } from '../contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { blockMarks, inlineDiff, itemsDiff, markdownDiff, pairByKey, removedItems, removedView, rowsDiff, textDiff, withoutSource } from './viewDiff'
|
||||
|
||||
function seg(text: string, role: TokenRole = 'plain', offset?: number): Segment {
|
||||
return { text, role, ...(offset !== undefined && { offset }) }
|
||||
}
|
||||
|
||||
function item(...cells: string[]): PropertyItem {
|
||||
return { cells: cells.map(cell => [seg(cell, 'variable')]) }
|
||||
}
|
||||
|
||||
function row(label: string, value: Inline[] | { markdown: string }, items?: PropertyItem[]): PropertyRow {
|
||||
return { label, value, ...(items && { items }) }
|
||||
}
|
||||
|
||||
function textView(translation: Inline[] | { markdown: string }, extra: Partial<BlockView> = {}): BlockView {
|
||||
return { id: '0', anchor: 'a', kind: 'statement', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes: {}, children: [], translation, ...extra } as BlockView
|
||||
}
|
||||
|
||||
function propertyView(properties: PropertyRow[]): BlockView {
|
||||
return { id: '0', anchor: 'a', kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, code: '', codeTokens: [], attributes: {}, children: [], properties }
|
||||
}
|
||||
|
||||
describe('inlineDiff', () => {
|
||||
it('一样(只是源码下标不同)时没有改动', () => {
|
||||
expect(inlineDiff([seg('a', 'variable', 1)], [seg('a', 'variable', 9)])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('大部分没变时逐截标:没变的、新加的照新版本(带下标),删掉的取旧版本、去掉下标', () => {
|
||||
const before = [seg('call', 'function', 0), seg('('), seg('key', 'variable', 5), seg(')')]
|
||||
const after = [seg('call', 'function', 0), seg('('), seg('key', 'variable', 5), seg(', '), seg('token', 'variable', 10), seg(')')]
|
||||
expect(inlineDiff(before, after)).toEqual([
|
||||
{ segments: [seg('call', 'function', 0), seg('('), seg('key', 'variable', 5)] },
|
||||
{ segments: [seg(', '), seg('token', 'variable', 10)], change: 'add' },
|
||||
{ segments: [seg(')')] },
|
||||
])
|
||||
})
|
||||
|
||||
it('改得多时整段写成旧值 → 新值,旧值去掉下标', () => {
|
||||
expect(inlineDiff([seg('布尔', 'type', 3)], [seg('字符串', 'type'), seg(' | '), seg('空', 'storage')])).toEqual([
|
||||
{ segments: [seg('布尔', 'type')], change: 'del' },
|
||||
{ segments: [seg('字符串', 'type'), seg(' | '), seg('空', 'storage')], change: 'add' },
|
||||
])
|
||||
})
|
||||
|
||||
it('框按样式、框头和框里的词比', () => {
|
||||
const group = (label: string): Inline => ({ style: 'iteration', label, segments: [seg('x', 'variable', 2)] })
|
||||
expect(inlineDiff([group('列表')], [group('列表')])).toBeUndefined()
|
||||
expect(inlineDiff([group('列表')], [group('生成器')])).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('markdownDiff', () => {
|
||||
it('按词比,中文按字', () => {
|
||||
expect(markdownDiff('Lua 比对 token 再删.', 'Lua 比对 token 再续期 / 再删.')).toEqual([
|
||||
{ text: 'Lua 比对 token 再' },
|
||||
{ text: '续期 / 再', change: 'add' },
|
||||
{ text: '删.' },
|
||||
])
|
||||
})
|
||||
|
||||
it('删掉的字取旧版本', () => {
|
||||
expect(markdownDiff('放锁前必须比对', '放锁都必须比对')).toEqual([
|
||||
{ text: '放锁' },
|
||||
{ text: '前', change: 'del' },
|
||||
{ text: '都', change: 'add' },
|
||||
{ text: '必须比对' },
|
||||
])
|
||||
})
|
||||
|
||||
it('一样时没有改动', () => {
|
||||
expect(markdownDiff('同样', '同样')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('textDiff', () => {
|
||||
it('着色的词逐截比,Markdown 逐词比,一种换成另一种时按字逐词比', () => {
|
||||
expect(textDiff([seg('a')], [seg('b')])).toHaveProperty('inline')
|
||||
expect(textDiff({ markdown: 'a' }, { markdown: 'b' })).toHaveProperty('markdown')
|
||||
expect(textDiff([seg('无', 'hint')], { markdown: '说明' })).toEqual({ markdown: [{ text: '无', change: 'del' }, { text: '说明', change: 'add' }] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('pairByKey / removedItems', () => {
|
||||
it('同 key 照先后配;没配上的旧项排在它前面最近配上的那项所配的新项后面', () => {
|
||||
const before = ['a', 'x', 'b', 'y']
|
||||
const after = ['b', 'a', 'c']
|
||||
const { toOld, toNew } = pairByKey(before, after, value => value)
|
||||
expect(toOld).toEqual([2, 0, undefined])
|
||||
expect(toNew).toEqual([1, undefined, 0, undefined])
|
||||
expect(removedItems(before, toNew, value => value)).toEqual([{ after: 1, value: 'x' }, { after: 0, value: 'y' }])
|
||||
})
|
||||
|
||||
it('最前面没配上的排在最前面', () => {
|
||||
const { toNew } = pairByKey(['x', 'a'], ['a'], value => value)
|
||||
expect(removedItems(['x', 'a'], toNew, value => value)).toEqual([{ after: -1, value: 'x' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('itemsDiff', () => {
|
||||
it('按第一列配:新参数算加行,没了的参数算删行,配上的逐格比', () => {
|
||||
const before = [item('redis', 'Any'), item('ttl', '整数')]
|
||||
const after = [item('redis', 'Any'), item('ttl_sec', '整数'), item('prefix', '字符串')]
|
||||
const diff = itemsDiff(before, after)
|
||||
expect(diff?.changed).toEqual({ 1: { added: true }, 2: { added: true } })
|
||||
expect(diff?.removed).toEqual([{ after: 0, value: item('ttl', '整数') }])
|
||||
})
|
||||
|
||||
it('配上的行某一格变了只标那一格', () => {
|
||||
const diff = itemsDiff([item('key', '字符串')], [item('key', '字节串')])
|
||||
expect(Object.keys(diff?.changed[0].cells ?? {})).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('新加的行下面各层也是新加的', () => {
|
||||
const added: PropertyItem = { cells: [[seg('k')]], items: [item('inner')], table: { columns: ['名称'], items: [item('arg')] } }
|
||||
expect(itemsDiff([], [added])?.changed[0]).toEqual({
|
||||
added: true,
|
||||
items: { changed: { 0: { added: true } }, removed: [] },
|
||||
table: { changed: { 0: { added: true } }, removed: [] },
|
||||
})
|
||||
})
|
||||
|
||||
it('都没有、或一样时没有改动', () => {
|
||||
expect(itemsDiff(undefined, undefined)).toBeUndefined()
|
||||
expect(itemsDiff([item('a')], [item('a')])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rowsDiff', () => {
|
||||
it('按属性名配:值变了逐截标,多出来的行算加行,没了的行算删行', () => {
|
||||
const before = [row('名称', [seg('f', 'function')]), row('输出参数', [seg('无', 'hint')]), row('装饰器', [seg('@x')])]
|
||||
const after = [row('名称', [seg('f', 'function')]), row('异步', [seg('是')]), row('输出参数', [seg('布尔', 'type')])]
|
||||
const diff = rowsDiff(before, after)
|
||||
expect(diff?.changed[1]).toEqual({ added: true })
|
||||
expect(diff?.changed[2].value).toBeDefined()
|
||||
expect(diff?.removed).toEqual([{ after: 2, value: row('装饰器', [seg('@x')]) }])
|
||||
})
|
||||
|
||||
it('子行改了时只标子行,值(子行的汇总)不另标', () => {
|
||||
const before = [row('输入参数', [seg('1 个', 'hint')], [item('a')])]
|
||||
const after = [row('输入参数', [seg('2 个', 'hint')], [item('a'), item('b')])]
|
||||
expect(rowsDiff(before, after)?.changed[0]).toEqual({ items: { changed: { 1: { added: true } }, removed: [] } })
|
||||
})
|
||||
|
||||
it('新加的行带着子行时,子行也都是新加的', () => {
|
||||
const diff = rowsDiff([], [row('输入参数', [seg('1 个')], [item('a')])])
|
||||
expect(diff?.changed[0]).toEqual({ added: true, items: { changed: { 0: { added: true } }, removed: [] } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('blockMarks', () => {
|
||||
it('一行译文、摊开实参的表、说明各比各的', () => {
|
||||
const before = textView([seg('调用 f')], { doc: { markdown: '旧说明' } } as Partial<BlockView>)
|
||||
const after = textView([seg('调用 g')], { table: { columns: ['实参'], items: [item('x')] } } as Partial<BlockView>)
|
||||
const marks = blockMarks(before, after)
|
||||
expect(marks?.translation).toBeDefined()
|
||||
expect(marks?.table?.changed).toEqual({ 0: { added: true } })
|
||||
expect(marks?.doc).toEqual({ markdown: [{ text: '旧说明', change: 'del' }] })
|
||||
})
|
||||
|
||||
it('一块是属性表、一块是一行译文时没法逐行比', () => {
|
||||
expect(blockMarks(textView([seg('a')]), propertyView([row('名称', [seg('a')])]))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('内容一样时没有标记', () => {
|
||||
expect(blockMarks(propertyView([row('名称', [seg('a', 'variable', 1)])]), propertyView([row('名称', [seg('a', 'variable', 7)])]))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removedView / withoutSource', () => {
|
||||
it('删掉的块只带自己的内容,词不带源码下标,没有名字的范围', () => {
|
||||
const view = propertyView([row('名称', [seg('f', 'function', 4)], [{ cells: [[{ ...seg('x', 'variable', 9), refers: { startIndex: 1, endIndex: 2 } }]] }])])
|
||||
const removed = removedView({ ...view, defines: { startIndex: 4, endIndex: 5 }, children: [textView([seg('child', 'plain', 1)])] })
|
||||
expect(removed.children).toEqual([])
|
||||
expect(removed.defines).toBeUndefined()
|
||||
expect(JSON.stringify(removed)).not.toMatch(/"offset"|"refers"/)
|
||||
})
|
||||
|
||||
it('框里的词也去掉下标,颜色留着', () => {
|
||||
const color = { dark: '#fff', light: '#000' }
|
||||
expect(withoutSource([{ style: 'iteration', label: '列表', segments: [{ ...seg('x', 'variable', 3), color }] }])).toEqual([
|
||||
{ style: 'iteration', label: '列表', segments: [{ text: 'x', role: 'variable', color }] },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { Inline, PreviewTable, PreviewText, PropertyItem, PropertyRow } from '../contract'
|
||||
import { diffArrays, diffWordsWithSpace } from 'diff'
|
||||
|
||||
/**
|
||||
* 一块卡片里的内容改了哪些:在渲染用的视图数据这一层比(翻译好的属性表、一行译文、摊开实参的表、说明),
|
||||
* 所有语言、库规则的卡片走同一条路,不认识任何语言。这里不碰 VS Code。
|
||||
*/
|
||||
|
||||
/** 一截是新加的还是删掉的;没变的不写。 */
|
||||
export type PartChange = 'add' | 'del'
|
||||
|
||||
/** 着色的词里的一截:没变的、新加的(照新版本,带源码下标),或删掉的(取自旧版本,去掉了指向源码的下标)。 */
|
||||
export interface InlinePart { segments: Inline[], change?: PartChange }
|
||||
|
||||
/** Markdown 说明里的一截:按词切,中文按字。 */
|
||||
export interface MarkdownPart { text: string, change?: PartChange }
|
||||
|
||||
/** 一段预览文字改成了什么样:着色的词逐截标(改得多就是整段旧值 → 新值),Markdown 说明逐词 / 字标。 */
|
||||
export type TextDiff = { inline: InlinePart[] } | { markdown: MarkdownPart[] }
|
||||
|
||||
/** 删掉的一行 / 子行:旧的那行,和它排在新的第几行后面(-1 是最前面)。 */
|
||||
export interface Removed<T> { after: number, value: T }
|
||||
|
||||
/** 一层子行(参数表、导入项、字典的各项、摊开实参的表)的改动。 */
|
||||
export interface ItemsDiff {
|
||||
/** 按新子行的下标;没变的不写 */
|
||||
changed: Record<number, ItemDiff>
|
||||
removed: Removed<PropertyItem>[]
|
||||
}
|
||||
|
||||
export interface ItemDiff {
|
||||
/** 整行是新加的(它下面各层也都是) */
|
||||
added?: true
|
||||
/** 按列:这一格改成了什么样 */
|
||||
cells?: Record<number, InlinePart[]>
|
||||
items?: ItemsDiff
|
||||
table?: ItemsDiff
|
||||
}
|
||||
|
||||
/** 属性表各行的改动。 */
|
||||
export interface RowsDiff {
|
||||
/** 按新属性表的行下标;没变的不写 */
|
||||
changed: Record<number, RowDiff>
|
||||
removed: Removed<PropertyRow>[]
|
||||
}
|
||||
|
||||
export interface RowDiff {
|
||||
/** 整行是新加的(它下面的子行也都是) */
|
||||
added?: true
|
||||
value?: TextDiff
|
||||
items?: ItemsDiff
|
||||
}
|
||||
|
||||
/** 一块自己的内容(不算子块)改了哪些:属性表逐行,一行译文逐截,摊开实参的表逐行,说明逐词。 */
|
||||
export interface BlockMarks {
|
||||
rows?: RowsDiff
|
||||
translation?: TextDiff
|
||||
table?: ItemsDiff
|
||||
doc?: TextDiff
|
||||
}
|
||||
|
||||
/** 共同的部分少于长的那边的这么多,就不逐截标,整段写成旧值 → 新值。 */
|
||||
const PARTS_MIN_SHARE = 0.4
|
||||
|
||||
/** 旧版本里的词:去掉指向源码的下标(offset、refers、reference),旧的下标对不上现在的文件,不能拿去跳定义、高亮。 */
|
||||
export function withoutSource(pieces: readonly Inline[]): Inline[] {
|
||||
return pieces.map((piece) => {
|
||||
if (!('role' in piece))
|
||||
return { ...piece, segments: withoutSource(piece.segments) }
|
||||
const { text, role, color } = piece
|
||||
return { text, role, ...(color && { color }) }
|
||||
})
|
||||
}
|
||||
|
||||
function textWithoutSource(text: PreviewText): PreviewText {
|
||||
return 'markdown' in text ? text : withoutSource(text)
|
||||
}
|
||||
|
||||
function itemWithoutSource(item: PropertyItem): PropertyItem {
|
||||
return {
|
||||
cells: item.cells.map(withoutSource),
|
||||
...(item.items && { items: item.items.map(itemWithoutSource) }),
|
||||
...(item.table && { table: tableWithoutSource(item.table) }),
|
||||
}
|
||||
}
|
||||
|
||||
function tableWithoutSource(table: PreviewTable): PreviewTable {
|
||||
return { columns: table.columns, items: table.items.map(itemWithoutSource) }
|
||||
}
|
||||
|
||||
function rowWithoutSource(row: PropertyRow): PropertyRow {
|
||||
return {
|
||||
label: row.label,
|
||||
value: textWithoutSource(row.value),
|
||||
...(row.items && { items: row.items.map(itemWithoutSource) }),
|
||||
...(row.columns && { columns: row.columns }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧版本里的一块,拿来画删掉的块:只留它自己的内容(子块去掉),词去掉指向源码的下标,也不带名字的范围(defines)。
|
||||
*/
|
||||
export function removedView(view: BlockView): BlockView {
|
||||
const { defines: _defines, children: _children, ...rest } = view
|
||||
const base = { ...rest, children: [] }
|
||||
if ('properties' in view)
|
||||
return { ...base, properties: view.properties.map(rowWithoutSource) }
|
||||
return {
|
||||
...base,
|
||||
translation: textWithoutSource(view.translation),
|
||||
...(view.table && { table: tableWithoutSource(view.table) }),
|
||||
...(view.doc && { doc: view.doc }),
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个词比较用的样子:词性和字(框是样式、框头和框里的词);源码下标不算,两个版本里同样的词下标不一样。 */
|
||||
function pieceKey(piece: Inline): string {
|
||||
return 'role' in piece ? `${piece.role}\u0000${piece.text}` : `${piece.style}\u0000${piece.label}\u0000[${piece.segments.map(pieceKey).join('\u0001')}]`
|
||||
}
|
||||
|
||||
/** 着色的词拼成的字(框只算框里的词)。 */
|
||||
export function inlineText(pieces: readonly Inline[]): string {
|
||||
return pieces.map(piece => 'role' in piece ? piece.text : inlineText(piece.segments)).join('')
|
||||
}
|
||||
|
||||
function sameInline(a: readonly Inline[], b: readonly Inline[]): boolean {
|
||||
return a.length === b.length && a.every((piece, i) => pieceKey(piece) === pieceKey(b[i]))
|
||||
}
|
||||
|
||||
/** 挨着的同一种截并成一截,空的截去掉。 */
|
||||
function mergeParts(parts: readonly InlinePart[]): InlinePart[] {
|
||||
const merged: InlinePart[] = []
|
||||
for (const part of parts) {
|
||||
if (part.segments.length === 0)
|
||||
continue
|
||||
const last = merged.at(-1)
|
||||
if (last && last.change === part.change)
|
||||
last.segments = [...last.segments, ...part.segments]
|
||||
else
|
||||
merged.push({ segments: [...part.segments], ...(part.change && { change: part.change }) })
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* 着色的词前后两版比:按词(一段着色的词是一个)对齐,没变的照新版本,删掉的取旧版本(去掉下标)、新加的照新版本;
|
||||
* 没变的字少于长的那边的 PARTS_MIN_SHARE 时不逐截标,整段写成旧值 → 新值。一样时是 undefined。
|
||||
*/
|
||||
export function inlineDiff(before: readonly Inline[], after: readonly Inline[]): InlinePart[] | undefined {
|
||||
if (sameInline(before, after))
|
||||
return undefined
|
||||
const changes = diffArrays([...before], [...after], { comparator: (a, b) => pieceKey(a) === pieceKey(b) })
|
||||
const kept = changes.filter(change => !change.added && !change.removed).reduce((sum, change) => sum + inlineText(change.value).length, 0)
|
||||
const longer = Math.max(inlineText(before).length, inlineText(after).length)
|
||||
if (kept < longer * PARTS_MIN_SHARE)
|
||||
return mergeParts([{ segments: withoutSource(before), change: 'del' }, { segments: [...after], change: 'add' }])
|
||||
return mergeParts(changes.map((change) => {
|
||||
if (change.removed)
|
||||
return { segments: withoutSource(change.value), change: 'del' }
|
||||
return change.added ? { segments: change.value, change: 'add' } : { segments: change.value }
|
||||
}))
|
||||
}
|
||||
|
||||
/** Markdown 说明前后两版比:按词切(中文按字、标点各算一个),删掉的取旧版本的字。一样时是 undefined。 */
|
||||
export function markdownDiff(before: string, after: string): MarkdownPart[] | undefined {
|
||||
if (before === after)
|
||||
return undefined
|
||||
return diffWordsWithSpace(before, after).map((change) => {
|
||||
if (change.removed)
|
||||
return { text: change.value, change: 'del' }
|
||||
return change.added ? { text: change.value, change: 'add' } : { text: change.value }
|
||||
})
|
||||
}
|
||||
|
||||
/** 一段预览文字前后两版比:都是着色的词逐截比,都是 Markdown 逐词比;一种换成了另一种时按字逐词比。一样时是 undefined。 */
|
||||
export function textDiff(before: PreviewText, after: PreviewText): TextDiff | undefined {
|
||||
if (!('markdown' in before) && !('markdown' in after)) {
|
||||
const inline = inlineDiff(before, after)
|
||||
return inline && { inline }
|
||||
}
|
||||
const plain = (text: PreviewText): string => 'markdown' in text ? text.markdown : inlineText(text)
|
||||
const markdown = markdownDiff(plain(before), plain(after))
|
||||
return markdown && { markdown }
|
||||
}
|
||||
|
||||
/** 两串东西配上的:新的第几个配旧的第几个(没配上是 undefined),反过来也记一份。 */
|
||||
export interface Pairing {
|
||||
toOld: (number | undefined)[]
|
||||
toNew: (number | undefined)[]
|
||||
}
|
||||
|
||||
/** 按 key 配对:key 一样的照先后一一配上(两个同名参数,第一个配第一个)。 */
|
||||
export function pairByKey<T>(before: readonly T[], after: readonly T[], key: (item: T) => string): Pairing {
|
||||
const waiting = new Map<string, number[]>()
|
||||
before.forEach((item, index) => waiting.set(key(item), [...waiting.get(key(item)) ?? [], index]))
|
||||
const toOld = after.map(item => waiting.get(key(item))?.shift())
|
||||
const toNew: (number | undefined)[] = before.map(() => undefined)
|
||||
toOld.forEach((old, index) => {
|
||||
if (old !== undefined)
|
||||
toNew[old] = index
|
||||
})
|
||||
return { toOld, toNew }
|
||||
}
|
||||
|
||||
/** 旧的里没配上的,各排在旧版本里它前面最近一个配上的那项所配的新项后面(前面没有配上的就排最前面,after 是 -1)。 */
|
||||
export function removedItems<T>(before: readonly T[], toNew: readonly (number | undefined)[], strip: (item: T) => T): Removed<T>[] {
|
||||
const removed: Removed<T>[] = []
|
||||
let after = -1
|
||||
before.forEach((item, index) => {
|
||||
const paired = toNew[index]
|
||||
if (paired === undefined)
|
||||
removed.push({ after, value: strip(item) })
|
||||
else
|
||||
after = paired
|
||||
})
|
||||
return removed
|
||||
}
|
||||
|
||||
/** 整层子行都是新加的(新加的行下面各层也是)。 */
|
||||
function allAdded(items: readonly PropertyItem[] | undefined): ItemsDiff | undefined {
|
||||
if (!items?.length)
|
||||
return undefined
|
||||
return { changed: Object.fromEntries(items.map((item, index) => [index, addedItem(item)])), removed: [] }
|
||||
}
|
||||
|
||||
function addedItem(item: PropertyItem): ItemDiff {
|
||||
const items = allAdded(item.items)
|
||||
const table = allAdded(item.table?.items)
|
||||
return { added: true, ...(items && { items }), ...(table && { table }) }
|
||||
}
|
||||
|
||||
/** 配上的两个子行比:各格逐截比,下一层、套着的表逐行比。一样时是 undefined。 */
|
||||
function itemDiff(before: PropertyItem, after: PropertyItem): ItemDiff | undefined {
|
||||
const cells: Record<number, InlinePart[]> = {}
|
||||
after.cells.forEach((cell, index) => {
|
||||
const parts = inlineDiff(before.cells[index] ?? [], cell)
|
||||
if (parts)
|
||||
cells[index] = parts
|
||||
})
|
||||
const items = itemsDiff(before.items, after.items)
|
||||
const table = itemsDiff(before.table?.items, after.table?.items)
|
||||
const diff: ItemDiff = {
|
||||
...(Object.keys(cells).length > 0 && { cells }),
|
||||
...(items && { items }),
|
||||
...(table && { table }),
|
||||
}
|
||||
return Object.keys(diff).length > 0 ? diff : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 一层子行前后两版比:按第一列的字配对(参数按名字、导入项按名称、字典按键),配上的逐格比,
|
||||
* 新版本多出来的算新加的行,旧版本里没配上的算删掉的行、排回原来的位置。一样时是 undefined。
|
||||
*/
|
||||
export function itemsDiff(before: readonly PropertyItem[] | undefined, after: readonly PropertyItem[] | undefined): ItemsDiff | undefined {
|
||||
const old = before ?? []
|
||||
const now = after ?? []
|
||||
const { toOld, toNew } = pairByKey(old, now, item => inlineText(item.cells[0] ?? []))
|
||||
const changed: Record<number, ItemDiff> = {}
|
||||
now.forEach((item, index) => {
|
||||
const paired = toOld[index]
|
||||
const diff = paired === undefined ? addedItem(item) : itemDiff(old[paired], item)
|
||||
if (diff)
|
||||
changed[index] = diff
|
||||
})
|
||||
const removed = removedItems(old, toNew, itemWithoutSource)
|
||||
return Object.keys(changed).length > 0 || removed.length > 0 ? { changed, removed } : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 配上的两行比:下面的子行逐行比,值逐截 / 逐词比。子行改了时只标子行:带子行的行,值是子行的汇总(「3 个」「字典 2 项」),
|
||||
* 跟着子行变,不另标。一样时是 undefined。
|
||||
*/
|
||||
function rowDiff(before: PropertyRow, after: PropertyRow): RowDiff | undefined {
|
||||
const items = itemsDiff(before.items, after.items)
|
||||
if (items)
|
||||
return { items }
|
||||
const value = textDiff(before.value, after.value)
|
||||
return value && { value }
|
||||
}
|
||||
|
||||
/** 属性表前后两版比:按属性名配对,配上的比值和子行,多出来的行算新加的,没了的行算删掉的、排回原来的位置。一样时是 undefined。 */
|
||||
export function rowsDiff(before: readonly PropertyRow[], after: readonly PropertyRow[]): RowsDiff | undefined {
|
||||
const { toOld, toNew } = pairByKey(before, after, row => row.label)
|
||||
const changed: Record<number, RowDiff> = {}
|
||||
after.forEach((row, index) => {
|
||||
const paired = toOld[index]
|
||||
const items = paired === undefined ? allAdded(row.items) : undefined
|
||||
const diff = paired === undefined ? { added: true as const, ...(items && { items }) } : rowDiff(before[paired], row)
|
||||
if (diff)
|
||||
changed[index] = diff
|
||||
})
|
||||
const removed = removedItems(before, toNew, rowWithoutSource)
|
||||
return Object.keys(changed).length > 0 || removed.length > 0 ? { changed, removed } : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 配上的两块自己的内容(不算子块)比:属性表逐行,一行译文逐截,摊开实参的表逐行,说明逐词。
|
||||
* 一样(或一块是属性表、一块是一行译文,没法逐行比)时是 undefined。
|
||||
*/
|
||||
export function blockMarks(before: BlockView, after: BlockView): BlockMarks | undefined {
|
||||
if ('properties' in before || 'properties' in after) {
|
||||
const rows = 'properties' in before && 'properties' in after ? rowsDiff(before.properties, after.properties) : undefined
|
||||
return rows && { rows }
|
||||
}
|
||||
const translation = textDiff(before.translation, after.translation)
|
||||
const table = itemsDiff(before.table?.items, after.table?.items)
|
||||
const doc = textDiff(before.doc ?? { markdown: '' }, after.doc ?? { markdown: '' })
|
||||
const marks: BlockMarks = { ...(translation && { translation }), ...(table && { table }), ...(doc && { doc }) }
|
||||
return Object.keys(marks).length > 0 ? marks : undefined
|
||||
}
|
||||
Reference in new issue
Block a user