fix: v0.5.2 稳健性收尾与偏好记忆 - diff worker 崩溃 onerror 兜底(终止实例回退主线程同步计算、effect 已取消/jobId 已推进的迟到错误不回填)、主进程 will-navigate 导航白名单加固、nativeTheme 暗色标题栏、比较选项与仅看差异开关 localStorage 持久化(损坏数据防御性回退默认)、字符级降级报告选项构造与引擎降级分支对齐

This commit is contained in:
2026-08-18 11:39:26 +08:00
parent bac885a4a0
commit 77dff98425
9 changed files with 215 additions and 17 deletions
+10 -1
View File
@@ -1,5 +1,5 @@
import { join } from 'path'
import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard } from 'electron'
import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme } from 'electron'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import iconv from 'iconv-lite'
import fs from 'fs'
@@ -33,6 +33,12 @@ function createWindow(): void {
return { action: 'deny' }
})
// 窗口内导航白名单:仅放行回到当前应用地址(dev 首页 / 打包产物首页,等价于刷新),
// 阻断其余一切页面内跳转(新窗口已在上方拦截,此处补齐页面内导航防线)
mainWindow.webContents.on('will-navigate', (e, url) => {
if (url !== mainWindow.webContents.getURL()) e.preventDefault()
})
// 开发模式加载 dev server,生产加载打包后的 html
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
@@ -154,6 +160,9 @@ ipcMain.handle('clipboard:write', (_event, text: string) => {
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.metonateam.difflens')
// 强制暗色系统主题:Windows 标题栏/滚动条等系统控件跟随应用暗色科幻风格
nativeTheme.themeSource = 'dark'
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
+55 -10
View File
@@ -31,6 +31,47 @@ function stamp(): string {
/** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */
const MAX_FILE_SIZE = 10 * 1024 * 1024
/** 用户偏好(比较选项 + 视图开关)持久化键 */
const PREFS_KEY = 'difflens.prefs.v1'
/** 全部比较选项的默认值(严格对比) */
const DEFAULT_OPTIONS: DiffOptions = {
trimWhitespace: false,
ignoreCase: false,
ignoreBlankLines: false,
ignoreAllWhitespace: false,
charMode: false
}
/**
* 读取持久化偏好:只接受已知字段的严格 boolean 值;
* 数据损坏(JSON 非法 / 字段类型异常 / 存储不可用)一律回退默认,保证启动不受影响。
*/
function loadPrefs(): { options: DiffOptions; onlyDiff: boolean } {
const fallback = { options: { ...DEFAULT_OPTIONS }, onlyDiff: false }
let raw: string | null = null
try {
raw = window.localStorage.getItem(PREFS_KEY)
} catch {
return fallback
}
if (!raw) return fallback
try {
const obj = JSON.parse(raw) as {
options?: Partial<Record<keyof DiffOptions, unknown>>
onlyDiff?: unknown
}
const bool = (v: unknown): boolean => v === true
const options: DiffOptions = { ...DEFAULT_OPTIONS }
for (const k of Object.keys(DEFAULT_OPTIONS) as (keyof DiffOptions)[]) {
options[k] = bool(obj.options?.[k])
}
return { options, onlyDiff: bool(obj.onlyDiff) }
} catch {
return fallback
}
}
/** 计算中/无内容时的空统计(保持状态栏与徽章渲染稳定) */
const EMPTY_SUMMARY: DiffSummary = { changedLines: 0, inserted: 0, deleted: 0, modified: 0 }
@@ -91,15 +132,10 @@ function EmptyPane({
export default function App(): ReactElement {
const [paneL, setPaneL] = useState<PaneState | null>(null)
const [paneR, setPaneR] = useState<PaneState | null>(null)
const [options, setOptions] = useState<DiffOptions>({
trimWhitespace: false,
ignoreCase: false,
ignoreBlankLines: false,
ignoreAllWhitespace: false,
charMode: false
})
// 比较选项与视图开关:初始值从持久化偏好恢复(惰性求值,每次挂载重新读取)
const [options, setOptions] = useState<DiffOptions>(() => loadPrefs().options)
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
const [onlyDiff, setOnlyDiff] = useState(false)
const [onlyDiff, setOnlyDiff] = useState<boolean>(() => loadPrefs().onlyDiff)
const [activeRowId, setActiveRowId] = useState<string | null>(null)
const [navIndex, setNavIndex] = useState(0)
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null)
@@ -130,6 +166,15 @@ export default function App(): ReactElement {
}
}, [])
// 偏好写回:比较选项与视图开关变更即持久化(存储不可用时静默忽略,不影响本次会话)
useEffect(() => {
try {
window.localStorage.setItem(PREFS_KEY, JSON.stringify({ options, onlyDiff }))
} catch {
// 忽略写入失败(隐私模式/配额满等)
}
}, [options, onlyDiff])
// 超多行预警:行数超过阈值时提示后台计算耗时(渲染已虚拟化、计算已 worker 化,均不阻塞界面)
const warnHeavyLines = useCallback(
(text: string): void => {
@@ -322,9 +367,9 @@ export default function App(): ReactElement {
async (fmt: ReportFormat) => {
// 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御
if (!diff) return
// 字符级对比降级时,报告头部按实际生效的行级语义描述(全部空白忽略 + 空行忽略
// 字符级对比降级时,报告头部按实际生效的行级语义描述(与引擎降级分支的选项构造完全一致
const reportOptions: DiffOptions = diff.charModeDowngraded
? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true }
? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true, trimWhitespace: false }
: options
const ctx: ReportContext = {
leftName: paneL?.meta.name ?? '',
+75
View File
@@ -26,6 +26,8 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
beforeEach(() => {
vi.restoreAllMocks()
window.api = mockApi()
// 清除偏好持久化(比较选项 + 视图开关),避免用例间互相污染初始状态
window.localStorage.clear()
// jsdom 未实现滚动定位,导航定位(虚拟化受控 scrollTop)需要打桩
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.scrollTo = vi.fn()
@@ -693,4 +695,77 @@ describe('App - 交换左右侧', () => {
fireEvent.click(screen.getByText('⇄ 交换左右'))
expect(files()).toEqual(['b.txt', 'a.txt'])
})
})
describe('App - 偏好持久化(比较选项 + 仅看差异)', () => {
const KEY = 'difflens.prefs.v1'
it('启动时恢复上次保存的比较选项与仅看差异开关', async () => {
window.localStorage.setItem(
KEY,
JSON.stringify({
options: {
trimWhitespace: false,
ignoreCase: false,
ignoreBlankLines: false,
ignoreAllWhitespace: true,
charMode: false
},
onlyDiff: true
})
)
window.api = mockApi({
openFile: async (side) =>
side === 'left'
? { path: '/tmp/a.txt', name: 'a.txt', text: 'same\ndiffL', encoding: 'UTF-8', binary: false }
: { path: '/tmp/b.txt', name: 'b.txt', text: 'same\ndiffR', encoding: 'UTF-8', binary: false }
})
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByText('打开右侧'))
await screen.findAllByText('b.txt')
// 比较选项恢复:忽略所有空白已勾选
expect(screen.getByLabelText('忽略所有空白')).toBeChecked()
// 仅看差异恢复:未更改行被折叠(same 不再展示)
expect(screen.queryAllByText('same')).toHaveLength(0)
// 关闭开关后恢复全量视图
fireEvent.click(screen.getByLabelText('仅看差异'))
expect(screen.getAllByText('same').length).toBeGreaterThan(0)
})
it('损坏的持久化数据回退默认选项且不崩溃', async () => {
window.localStorage.setItem(KEY, '{broken json!!')
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
expect(await screen.findByText('导出报告')).toBeInTheDocument()
expect(screen.getByLabelText('忽略所有空白')).not.toBeChecked()
expect(screen.getByLabelText('字符级对比')).not.toBeChecked()
expect(screen.getByLabelText('仅看差异')).not.toBeChecked()
})
it('非布尔字段被防御性忽略(回退关闭)', async () => {
window.localStorage.setItem(
KEY,
JSON.stringify({ options: { ignoreCase: 'yes', charMode: 1 }, onlyDiff: 'true' })
)
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
expect(await screen.findByText('导出报告')).toBeInTheDocument()
expect(screen.getByLabelText('忽略大小写')).not.toBeChecked()
expect(screen.getByLabelText('字符级对比')).not.toBeChecked()
expect(screen.getByLabelText('仅看差异')).not.toBeChecked()
})
it('切换选项后写回持久化存储', async () => {
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByLabelText('忽略大小写'))
const saved1 = JSON.parse(window.localStorage.getItem(KEY) ?? '{}')
expect(saved1.options.ignoreCase).toBe(true)
fireEvent.click(screen.getByLabelText('仅看差异'))
const saved2 = JSON.parse(window.localStorage.getItem(KEY) ?? '{}')
expect(saved2.onlyDiff).toBe(true)
})
})
+54 -1
View File
@@ -4,9 +4,10 @@ import { useDiff } from './useDiff'
import { computeDiff, type DiffResult, type DiffOptions } from '../diff/diffEngine'
import type { DiffWorkerFactory } from '../diff/createDiffWorker'
/** 模拟 Worker:记录派发请求,可手动回传结果;terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */
/** 模拟 Worker:记录派发请求,可手动回传结果或触发崩溃terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */
class FakeWorker {
onmessage: ((e: { data: { jobId: number; result: DiffResult } }) => void) | null = null
onerror: (() => void) | null = null
terminated = false
posted: { jobId: number }[] = []
postMessage(data: { jobId: number }): void {
@@ -19,6 +20,11 @@ class FakeWorker {
if (this.terminated) return
this.onmessage?.({ data: { jobId, result } })
}
/** 模拟运行中崩溃:触发 onerror(终止后不再触发,对齐真实 worker) */
fail(): void {
if (this.terminated) return
this.onerror?.()
}
}
/** 每次调用产出新 FakeWorker 的工厂,并记录全部实例 */
@@ -188,4 +194,51 @@ describe('useDiff - worker 后台计算', () => {
hook.unmount()
expect(w.terminated).toBe(true)
})
it('worker 运行中崩溃:终止实例并回退主线程同步计算', () => {
const factory = makeFactory()
const left = heavyText(2100)
const { result } = renderHook(() => useDiff(left, 'x', NO_OPTIONS, factory))
act(() => {
vi.advanceTimersByTime(250)
})
const w = factory.workers[0]
expect(result.current.computing).toBe(true)
act(() => {
w.fail()
})
expect(w.terminated).toBe(true)
expect(result.current.diff).toEqual(computeDiff(left, 'x', {}))
expect(result.current.computing).toBe(false)
})
it('迟到错误(输入已变化)只终止不回填,新任务结果正常生效', () => {
const factory = makeFactory()
const hook = renderHook((p: string) => useDiff(p, 'x', NO_OPTIONS, factory), {
initialProps: heavyText(2100)
})
act(() => {
vi.advanceTimersByTime(250)
})
const w1 = factory.workers[0]
hook.rerender(heavyText(2300))
// 模拟 terminate 前已排入事件队列的迟到 error(绕过 terminated 守卫直接触发)
act(() => {
w1.onerror?.()
})
// 过期错误不回填:仍处于 computing 且无结果
expect(hook.result.current.diff).toBeNull()
expect(hook.result.current.computing).toBe(true)
// 新任务正常完成
act(() => {
vi.advanceTimersByTime(250)
})
const w2 = factory.workers[1]
const res = computeDiff('m', 'n', {})
act(() => {
w2.respond(w2.posted[0].jobId, res)
})
expect(hook.result.current.diff).toEqual(res)
expect(hook.result.current.computing).toBe(false)
})
})
+12 -1
View File
@@ -51,6 +51,8 @@ export function useDiff(
// 重输入先清空旧结果:避免交换左右等场景下展示与新面板不对应的过期差异
setAsyncDiff(null)
setComputing(true)
// effect 世代标志:cleanup 后置位,迟到回调(含去抖期内输入已变、jobId 尚未推进的窗口)一律丢弃
let cancelled = false
let w: Worker | null = null
// worker 延迟到去抖到期才创建:内联 worker 实例化含 base64 解码与脚本编译,
// 去抖期内输入再变时旧任务未创建即取消,零创建与计算开销
@@ -65,13 +67,22 @@ export function useDiff(
const jobId = ++jobIdRef.current
w.onmessage = (e: MessageEvent<DiffWorkerResponse>): void => {
// 丢弃过期任务:输入已变化时旧计算结果不再生效
if (e.data.jobId !== jobId) return
if (e.data.jobId !== jobId || cancelled) return
setAsyncDiff(e.data.result)
setComputing(false)
}
// worker 运行中崩溃(未捕获异常/极端输入):终止实例后回退主线程同步计算,
// 避免 computing 永久卡死;迟到错误(effect 已取消或 jobId 已推进)只终止不回填
w.onerror = (): void => {
w?.terminate()
if (cancelled || jobIdRef.current !== jobId) return
setAsyncDiff(computeDiff(left, right, options))
setComputing(false)
}
w.postMessage({ jobId, leftText: left, rightText: right, options })
}, DEBOUNCE_MS)
return () => {
cancelled = true
window.clearTimeout(timer)
w?.terminate()
}