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