历史记录:每条可展开 diff,连续保存合并进 5 秒窗

备份存的是「改动前」,所以一条记录的 diff 两端是它自己和更新一份的
状态(最新一条对比当前文件)。行级 diff 先掐公共前后缀再做 LCS,一次
改动通常只碰几行,表撑得住;大面积不同时退化成整段删增。

合并窗做在服务端而不是前端防抖:普通保存(opts.merge)发现最新备份
还在窗内就不新建,连点删除共享一条历史、一步撤回。窗锚定在这一串的
第一下,不滚雪球;还原、导入、撤回重做不合并,各占一条。合并时没有
备份文件,重载失败的还原改用刚读出的原文写回。顺带消除了备份名只精
确到秒、同秒保存互相覆盖的隐患。
This commit is contained in:
2026-08-12 13:17:11 +08:00
parent 48e97bd91f
commit a40bd6a01b
8 changed files with 449 additions and 20 deletions
+81
View File
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
applyConfig,
backupDiff,
importConfig,
listBackups,
listRedo,
@@ -253,3 +254,83 @@ describe('落盘保护链路(内核在线)', () => {
expect(backups).not.toContain('config.yaml.bak-20200101-000010')
})
})
describe('连续保存的合并窗(opts.merge)', () => {
const stampName = (at: Date) => {
const p = (n: number) => String(n).padStart(2, '0')
return (
`config.yaml.bak-${String(at.getFullYear())}${p(at.getMonth() + 1)}${p(at.getDate())}` +
`-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}`
)
}
it('窗内的第二次保存不新建备份,一步撤回回到这一串之前', async () => {
const env = makeEnv()
const first = await readConfig(env)
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision, { merge: true })
const second = await readConfig(env)
await applyConfig(env, { ...second.data, 'mixed-port': 7892 }, second.revision, { merge: true })
expect(await listBackups(env)).toHaveLength(1)
const undone = await undoConfig(env)
expect(undone.ok).toBe(true)
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
})
it('最新备份出了窗就正常新建', async () => {
const env = makeEnv()
const dir = join(env.configPath, '..', '.patchbay-backups')
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, stampName(new Date(Date.now() - 60_000))), 'old', 'utf8')
const { data, revision } = await readConfig(env)
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision, { merge: true })
expect(await listBackups(env)).toHaveLength(2)
})
it('不带 merge 的保存(还原、导入)不合并,窗内照样各留一条', async () => {
const env = makeEnv()
const first = await readConfig(env)
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision, { merge: true })
const [backup] = await listBackups(env)
const result = await restoreBackup(env, backup!)
expect(result.ok).toBe(true)
// 还原自己也备份了「还原前」的状态。名字按秒生成,和上一份同秒时会覆盖而不是
// 新增,所以这里断言的是「至少还有一份、且文件回到了原样」
expect((await listBackups(env)).length).toBeGreaterThanOrEqual(1)
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
})
})
describe('历史记录的对比原文', () => {
it('最新一条对比当前文件,改动前后各归各', async () => {
const env = makeEnv()
const { data, revision } = await readConfig(env)
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
const [newest] = await listBackups(env)
const pair = await backupDiff(env, newest!)
expect(pair).not.toBeNull()
expect(pair!.before).toBe(ORIGINAL)
expect(pair!.after).toContain('mixed-port: 7891')
})
it('更老的一条对比它前面那份备份', async () => {
const env = makeEnv()
const dir = join(env.configPath, '..', '.patchbay-backups')
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'config.yaml.bak-20200101-000001'), '第一份\n', 'utf8')
writeFileSync(join(dir, 'config.yaml.bak-20200101-000002'), '第二份\n', 'utf8')
const pair = await backupDiff(env, 'config.yaml.bak-20200101-000001')
expect(pair).toEqual({ before: '第一份\n', after: '第二份\n' })
})
it('名字不在备份列表里返回 null', async () => {
const env = makeEnv()
expect(await backupDiff(env, '../../etc/passwd')).toBeNull()
})
})