历史记录:每条可展开 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()
})
})
+55 -6
View File
@@ -30,6 +30,7 @@ import {
} from '../vendor/config-editor/index.ts'
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
import { restoreInlineCommentAlignment } from '../shared/yaml-cosmetics.ts'
import { backupDate } from '../shared/backups.ts'
import { withMarker } from '../shared/config-marker.ts'
import { kernelVersion, validateConfigFile, reloadConfig } from './kernel.ts'
import type { Env } from './env.ts'
@@ -37,6 +38,22 @@ import type { Env } from './env.ts'
/** 备份保留的份数。再多没有意义——真要回到很久以前的版本,那是版本控制的活。 */
const KEEP_BACKUPS = 10
/**
* 界面上的连续保存合并成一条历史的时间窗:最新备份还在窗内就不再新建,
* 这一串操作共享一条记录、一步撤回。连点删除不再刷出一屏历史。
* 顺带解决了备份名只精确到秒的隐患——窗内不建新备份,同秒的覆盖不会发生。
*/
const MERGE_WINDOW_MS = 5_000
/** 最新备份是否还在合并窗内。时间戳从备份名里解析,时钟往回跳时当不在窗内。 */
async function hasRecentBackup(env: Env): Promise<boolean> {
const [newest] = await listBackups(env)
const at = newest ? backupDate(newest) : null
if (!at) return false
const age = Date.now() - at.getTime()
return age >= 0 && age < MERGE_WINDOW_MS
}
export interface ConfigSnapshot {
/** 解析出来的配置数据 */
data: ConfigObject
@@ -110,12 +127,15 @@ export function rewriteYaml(currentYaml: string, nextData: ConfigObject): string
* `opts.restoreFrom`:撤回/重做专用。给了它就不再新建备份(否则备份链会被
* 撤回本身污染,撤两次等于原地打转),重载失败时用它当还原源;同时不清空
* 重做栈——只有「正常的新改动」才让已撤回的未来失效。
*
* `opts.merge`:界面上的普通保存专用。最新备份还在合并窗内时不新建备份,
* 连续操作共享一条历史。还原备份、上传导入这类独立动作不传,各占一条。
*/
export async function applyConfig(
env: Env,
nextData: ConfigObject,
baseRevision?: string,
opts?: { restoreFrom?: string },
opts?: { restoreFrom?: string; merge?: boolean },
): Promise<ApplyOutcome> {
const current = await readDocument(env)
@@ -189,9 +209,11 @@ export async function applyConfig(
}
}
// 校验过了才动真文件。备份放在写入之前。
const backupPath = opts?.restoreFrom ?? (await createBackup(env))
const backupTiming = opts?.restoreFrom ? '' : lap('备份')
// 校验过了才动真文件。备份放在写入之前;合并窗内跳过新建,重载失败的还原
// 不受影响——没有备份文件时用刚读出的原文写回(见下面的还原分支)。
const merged = opts?.merge === true && opts.restoreFrom === undefined && (await hasRecentBackup(env))
const backupPath = opts?.restoreFrom ?? (merged ? null : await createBackup(env))
const backupTiming = backupPath !== null && opts?.restoreFrom === undefined ? lap('备份') : ''
try {
await writeFile(env.configPath, nextYaml, 'utf8')
} catch (error) {
@@ -226,9 +248,11 @@ export async function applyConfig(
if (!reloaded.ok) {
// 文件已经写进去了但内核没接受。把文件还原,再让内核载回原样,
// 避免留下「文件是新的、内核跑的是旧的」这种最难排查的状态。
// 合并窗内没有备份文件,还原源用刚读出的原文。
let restored = false
try {
await copyFile(backupPath, env.configPath)
if (backupPath !== null) await copyFile(backupPath, env.configPath)
else await writeFile(env.configPath, current.yaml, 'utf8')
await reloadConfig(env, env.configPath)
restored = true
} catch {
@@ -240,7 +264,9 @@ export async function applyConfig(
message: reloaded.message,
hint: restored
? '已经把配置还原成改动前的样子,内核也载回去了。改动没有生效。'
: `还原也失败了。改动前的配置留在 ${backupPath},需要手动拷回 ${env.configPath}。`,
: backupPath !== null
? `还原也失败了。改动前的配置留在 ${backupPath},需要手动拷回 ${env.configPath}。`
: '还原也失败了。历史记录里最新的备份和改动前至多差几秒内的操作,可以从它还原。',
restored,
}
}
@@ -443,6 +469,29 @@ export async function redoConfig(env: Env): Promise<ApplyOutcome> {
return result
}
/**
* 一条历史记录展开时要对比的两份原文。
*
* 备份存的是「改动前」,所以这次改动的对比对象是更新一份的状态:列表里
* 它前面那份备份,最新一条则对比当前文件。名字对着 listBackups 校验,
* 同 restoreBackup,防路径穿越。没有这个备份时返回 null。
*/
export async function backupDiff(
env: Env,
name: string,
): Promise<{ before: string; after: string } | null> {
const backups = await listBackups(env)
const index = backups.indexOf(name)
if (index < 0) return null
const dir = await backupDir(env)
const before = await readFile(join(dir, name), 'utf8')
const after =
index === 0
? await readFile(env.configPath, 'utf8')
: await readFile(join(dir, backups[index - 1]!), 'utf8')
return { before, after }
}
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
export async function restoreBackup(env: Env, name: string): Promise<ApplyOutcome> {
const dir = await backupDir(env)
+11 -1
View File
@@ -14,6 +14,7 @@ import listenerFields from '../shared/listener-fields.json' with { type: 'json'
import { isProviderFilePath } from '../shared/provider-files.ts'
import {
applyConfig,
backupDiff,
importConfig,
listBackups,
listRedo,
@@ -186,7 +187,8 @@ function makeRoute(env: Env, token: string) {
if (!body.data || typeof body.data !== 'object') {
return json({ message: '请求缺少 data 字段' }, 400)
}
const result = await applyConfig(env, body.data, body.baseRevision)
// merge:界面上的连续保存在时间窗内合并成一条历史。还原、导入不走这里
const result = await applyConfig(env, body.data, body.baseRevision, { merge: true })
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
}
@@ -231,6 +233,14 @@ function makeRoute(env: Env, token: string) {
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
}
// 一条历史记录的对比原文:这份备份(改动前)和它之后的状态(改动后)
const diffMatch = /^\/api\/config\/backups\/([^/]+)\/diff$/.exec(path)
if (diffMatch && req.method === 'GET') {
const pair = await backupDiff(env, decodeURIComponent(diffMatch[1]!))
if (!pair) return json({ message: '没有这个备份', hint: '刷新一下历史记录再试。' }, 404)
return json(pair)
}
const restoreMatch = /^\/api\/config\/backups\/([^/]+)\/restore$/.exec(path)
if (restoreMatch && req.method === 'POST') {
const name = decodeURIComponent(restoreMatch[1]!)
+14
View File
@@ -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]),
)
}
+64
View File
@@ -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
View File
@@ -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
}
+7
View File
@@ -173,6 +173,13 @@ export const api = {
restoreBackup: (name: string) =>
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
/** 一条历史记录的对比原文:这份备份(改动前)和它之后的状态(改动后)。 */
backupDiff: (name: string) =>
request<{ before: string; after: string }>(
'GET',
`/api/config/backups/${encodeURIComponent(name)}/diff`,
),
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
/**
+114 -13
View File
@@ -2,12 +2,16 @@
/**
* 备份链(历史记录)的弹窗:列出每次改动前留下的备份,点还原直接跳回去。
* 还原走服务端同一条保护链路,当前状态会先自动备份,所以不需要再套一层确认。
* 点一行展开这次改动的 diff:改动前是备份本身,改动后是更新一份的状态。
*/
import { ref, watch } from 'vue'
import { backupTime } from '@shared/backups.ts'
import { diffHunks, diffLines } from '@shared/diff.ts'
import type { DiffLine } from '@shared/diff.ts'
import { api } from '../api'
import { toast, toastError } from '../toast'
import IconClose from '~icons/ri/close-line'
import IconRight from '~icons/ri/arrow-right-s-line'
const props = defineProps<{ open: boolean }>()
@@ -18,12 +22,19 @@ const dialog = ref<HTMLDialogElement | null>(null)
const backups = ref<string[] | null>(null)
const busy = ref(false)
/** 展开着的那条。同时只开一条,diff 是逐条看的东西,摊开一串没法读 */
const expanded = ref<string | null>(null)
/** 已取回并算好的 diff 片段。null 表示取失败了 */
const diffs = ref<Record<string, DiffLine[][] | null>>({})
watch(
() => props.open,
(open) => {
if (open) {
dialog.value?.showModal()
backups.value = null
expanded.value = null
diffs.value = {}
void api
.listBackups()
.then((r) => (backups.value = r.backups))
@@ -34,6 +45,19 @@ watch(
},
)
async function toggleDiff(name: string) {
expanded.value = expanded.value === name ? null : name
if (expanded.value !== name || name in diffs.value) return
try {
const { before, after } = await api.backupDiff(name)
diffs.value = { ...diffs.value, [name]: diffHunks(diffLines(before, after)) }
} catch {
diffs.value = { ...diffs.value, [name]: null }
}
}
const sign = (kind: DiffLine['kind']) => (kind === 'add' ? '+' : kind === 'del' ? '−' : ' ')
async function restore(name: string) {
if (busy.value) return
busy.value = true
@@ -75,9 +99,35 @@ function onBackdrop(event: MouseEvent) {
没有备份。每次保存都会把改动前的状态留在这里,最多 10 份。
</p>
<ul v-else class="list">
<li v-for="name in backups" :key="name" class="item" :title="name">
<span class="mono time">{{ backupTime(name) ?? name }}</span>
<button type="button" class="btn" :disabled="busy" @click="restore(name)">还原</button>
<li v-for="name in backups" :key="name" class="item">
<div
class="row"
role="button"
tabindex="0"
:title="name"
@click="toggleDiff(name)"
@keydown.enter="toggleDiff(name)"
>
<IconRight :class="['chev', expanded === name && 'is-open']" aria-hidden="true" />
<span class="mono time">{{ backupTime(name) ?? name }}</span>
<button type="button" class="btn" :disabled="busy" @click.stop="restore(name)">
还原
</button>
</div>
<div v-if="expanded === name" class="diff mono">
<p v-if="!(name in diffs)" class="faint">载入中</p>
<p v-else-if="diffs[name] === null" class="faint">对比内容读取失败</p>
<p v-else-if="diffs[name]!.length === 0" class="faint">和之后的状态没有差异</p>
<template v-else>
<template v-for="(hunk, hi) in diffs[name]" :key="hi">
<div v-if="hi > 0" class="gap" aria-hidden="true">⋯</div>
<div v-for="(line, li) in hunk" :key="li" :class="['dl', line.kind]">
<span class="dsign">{{ sign(line.kind) }}</span>{{ line.text }}
</div>
</template>
</template>
</div>
</li>
</ul>
</div>
@@ -86,7 +136,7 @@ function onBackdrop(event: MouseEvent) {
<style scoped>
.dlg {
width: min(420px, calc(100vw - 2 * var(--s-5)));
width: min(640px, calc(100vw - 2 * var(--s-5)));
padding: 0;
border: 1px solid var(--c-border-strong);
border-radius: var(--r-lg);
@@ -140,25 +190,37 @@ function onBackdrop(event: MouseEvent) {
list-style: none;
margin: 0;
padding: 0;
max-height: 50vh;
max-height: 60vh;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--c-border-strong) transparent;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
padding: var(--s-2) 0;
}
.item + .item {
border-top: 1px solid var(--c-border);
}
.row {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2) 0;
cursor: pointer;
}
.chev {
flex: none;
width: 16px;
height: 16px;
color: var(--c-text-faint);
transition: transform 0.15s ease;
}
.chev.is-open {
transform: rotate(90deg);
}
.time {
flex: 1;
font-size: var(--t-sm);
}
@@ -178,4 +240,43 @@ function onBackdrop(event: MouseEvent) {
color: var(--c-text-faint);
cursor: not-allowed;
}
.diff {
margin: 0 0 var(--s-3);
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border);
border-radius: var(--r-sm);
background: var(--c-bg);
max-height: 40vh;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: var(--c-border-strong) transparent;
font-size: var(--t-xs);
line-height: 1.7;
}
.dl {
white-space: pre;
}
.dl.add {
color: var(--c-ok);
}
.dl.del {
color: var(--c-danger);
}
.dl.same {
color: var(--c-text-faint);
}
.dsign {
display: inline-block;
width: 1.2em;
user-select: none;
}
.gap {
padding: var(--s-1) 0;
color: var(--c-text-faint);
user-select: none;
}
</style>