历史记录:每条可展开 diff,连续保存合并进 5 秒窗
备份存的是「改动前」,所以一条记录的 diff 两端是它自己和更新一份的 状态(最新一条对比当前文件)。行级 diff 先掐公共前后缀再做 LCS,一次 改动通常只碰几行,表撑得住;大面积不同时退化成整段删增。 合并窗做在服务端而不是前端防抖:普通保存(opts.merge)发现最新备份 还在窗内就不新建,连点删除共享一条历史、一步撤回。窗锚定在这一串的 第一下,不滚雪球;还原、导入、撤回重做不合并,各占一条。合并时没有 备份文件,重载失败的还原改用刚读出的原文写回。顺带消除了备份名只精 确到秒、同秒保存互相覆盖的隐患。
This commit is contained in:
@@ -13,3 +13,17 @@ export function backupTime(name: string): string | null {
|
||||
if (!m) return null
|
||||
return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}`
|
||||
}
|
||||
|
||||
/** 备份文件名里的时间戳解析成 Date(服务端本地时间)。名字不合预期时返回 null。 */
|
||||
export function backupDate(name: string): Date | null {
|
||||
const m = STAMP.exec(name)
|
||||
if (!m) return null
|
||||
return new Date(
|
||||
Number(m[1]),
|
||||
Number(m[2]) - 1,
|
||||
Number(m[3]),
|
||||
Number(m[4]),
|
||||
Number(m[5]),
|
||||
Number(m[6]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { diffHunks, diffLines } from './diff.ts'
|
||||
|
||||
const lines = (rows: ReturnType<typeof diffLines>) => rows.map((r) => `${r.kind}:${r.text}`)
|
||||
|
||||
describe('行级 diff', () => {
|
||||
it('相同的内容全是 same,折叠后没有片段', () => {
|
||||
const rows = diffLines('a\nb\nc', 'a\nb\nc')
|
||||
expect(rows.every((r) => r.kind === 'same')).toBe(true)
|
||||
expect(diffHunks(rows)).toEqual([])
|
||||
})
|
||||
|
||||
it('中间插入一行', () => {
|
||||
expect(lines(diffLines('a\nc', 'a\nb\nc'))).toEqual(['same:a', 'add:b', 'same:c'])
|
||||
})
|
||||
|
||||
it('中间删除一行', () => {
|
||||
expect(lines(diffLines('a\nb\nc', 'a\nc'))).toEqual(['same:a', 'del:b', 'same:c'])
|
||||
})
|
||||
|
||||
it('改一行等于删旧行加新行', () => {
|
||||
expect(lines(diffLines('a\nx\nc', 'a\ny\nc'))).toEqual(['same:a', 'del:x', 'add:y', 'same:c'])
|
||||
})
|
||||
|
||||
it('从空到有、从有到空', () => {
|
||||
expect(lines(diffLines('', 'a'))).toEqual(['del:', 'add:a'])
|
||||
expect(lines(diffLines('a\nb', ''))).toEqual(['del:a', 'del:b', 'add:'])
|
||||
})
|
||||
|
||||
it('公共前后缀之外的多处改动都找得到', () => {
|
||||
const before = ['p1', 'p2', 'x', 'm', 'y', 's1'].join('\n')
|
||||
const after = ['p1', 'p2', 'x2', 'm', 'y2', 's1'].join('\n')
|
||||
expect(lines(diffLines(before, after))).toEqual([
|
||||
'same:p1',
|
||||
'same:p2',
|
||||
'del:x',
|
||||
'add:x2',
|
||||
'same:m',
|
||||
'del:y',
|
||||
'add:y2',
|
||||
'same:s1',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('折叠成片段', () => {
|
||||
it('改动行带前后上下文,远处的未改动行不进片段', () => {
|
||||
const rows = diffLines(
|
||||
['1', '2', '3', '4', '5', '6', '7', '8', '9'].join('\n'),
|
||||
['1', '2', '3', 'x', '5', '6', '7', '8', '9'].join('\n'),
|
||||
)
|
||||
const hunks = diffHunks(rows, 1)
|
||||
expect(hunks).toHaveLength(1)
|
||||
expect(hunks[0]!.map((r) => r.text)).toEqual(['3', '4', 'x', '5'])
|
||||
})
|
||||
|
||||
it('相距很远的两处改动分成两个片段,挨得近就合并成一个', () => {
|
||||
const before = ['a', '1', '2', '3', '4', '5', '6', '7', 'b'].join('\n')
|
||||
const afterFar = ['a2', '1', '2', '3', '4', '5', '6', '7', 'b2'].join('\n')
|
||||
expect(diffHunks(diffLines(before, afterFar), 1)).toHaveLength(2)
|
||||
const afterNear = ['a2', '1', '2', '3', '4', '5', '6', '7b', 'b'].join('\n')
|
||||
expect(diffHunks(diffLines(before, afterNear), 4)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 行级 diff,给历史记录的「这次改动改了什么」用。
|
||||
*
|
||||
* 先掐掉公共前后缀再对中段做 LCS:配置文件的一次改动通常只碰几行,中段一般
|
||||
* 很小,LCS 的 O(n×m) 表因此撑得住。真遇到两份大面积不同的文件(中段乘积超过
|
||||
* 上限),退化成「整段删除 + 整段新增」——依旧正确,只是不够精细。
|
||||
*/
|
||||
|
||||
export interface DiffLine {
|
||||
kind: 'same' | 'add' | 'del'
|
||||
text: string
|
||||
}
|
||||
|
||||
const LCS_LIMIT = 1_000_000
|
||||
|
||||
export function diffLines(before: string, after: string): DiffLine[] {
|
||||
const a = before.split('\n')
|
||||
const b = after.split('\n')
|
||||
|
||||
let start = 0
|
||||
while (start < a.length && start < b.length && a[start] === b[start]) start += 1
|
||||
let endA = a.length
|
||||
let endB = b.length
|
||||
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
||||
endA -= 1
|
||||
endB -= 1
|
||||
}
|
||||
|
||||
return [
|
||||
...a.slice(0, start).map((text): DiffLine => ({ kind: 'same', text })),
|
||||
...diffMiddle(a.slice(start, endA), b.slice(start, endB)),
|
||||
...a.slice(endA).map((text): DiffLine => ({ kind: 'same', text })),
|
||||
]
|
||||
}
|
||||
|
||||
function diffMiddle(a: string[], b: string[]): DiffLine[] {
|
||||
const allDel = a.map((text): DiffLine => ({ kind: 'del', text }))
|
||||
const allAdd = b.map((text): DiffLine => ({ kind: 'add', text }))
|
||||
if (a.length === 0 || b.length === 0 || a.length * b.length > LCS_LIMIT) {
|
||||
return [...allDel, ...allAdd]
|
||||
}
|
||||
|
||||
// LCS 长度表倒着填,回溯时从头正着走
|
||||
const w = b.length + 1
|
||||
const table = new Uint32Array((a.length + 1) * w)
|
||||
for (let i = a.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = b.length - 1; j >= 0; j -= 1) {
|
||||
table[i * w + j] =
|
||||
a[i] === b[j]
|
||||
? table[(i + 1) * w + j + 1]! + 1
|
||||
: Math.max(table[(i + 1) * w + j]!, table[i * w + j + 1]!)
|
||||
}
|
||||
}
|
||||
|
||||
const rows: DiffLine[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < a.length && j < b.length) {
|
||||
if (a[i] === b[j]) {
|
||||
rows.push({ kind: 'same', text: a[i]! })
|
||||
i += 1
|
||||
j += 1
|
||||
} else if (table[(i + 1) * w + j]! >= table[i * w + j + 1]!) {
|
||||
rows.push({ kind: 'del', text: a[i]! })
|
||||
i += 1
|
||||
} else {
|
||||
rows.push({ kind: 'add', text: b[j]! })
|
||||
j += 1
|
||||
}
|
||||
}
|
||||
while (i < a.length) rows.push({ kind: 'del', text: a[i++]! })
|
||||
while (j < b.length) rows.push({ kind: 'add', text: b[j++]! })
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* 折叠成只含改动的片段:每个片段是改动行加前后 context 行上下文,片段之间的
|
||||
* 未改动区域丢弃(渲染端画省略号)。两份内容相同时返回空数组。
|
||||
*/
|
||||
export function diffHunks(rows: DiffLine[], context = 2): DiffLine[][] {
|
||||
const keep = new Array<boolean>(rows.length).fill(false)
|
||||
rows.forEach((row, index) => {
|
||||
if (row.kind === 'same') return
|
||||
const from = Math.max(0, index - context)
|
||||
const to = Math.min(rows.length - 1, index + context)
|
||||
for (let k = from; k <= to; k += 1) keep[k] = true
|
||||
})
|
||||
|
||||
const hunks: DiffLine[][] = []
|
||||
let current: DiffLine[] | null = null
|
||||
rows.forEach((row, index) => {
|
||||
if (!keep[index]) {
|
||||
current = null
|
||||
return
|
||||
}
|
||||
if (!current) {
|
||||
current = []
|
||||
hunks.push(current)
|
||||
}
|
||||
current.push(row)
|
||||
})
|
||||
return hunks
|
||||
}
|
||||
Reference in New Issue
Block a user