fix: v0.5.2 稳健性收尾与偏好记忆 - diff worker 崩溃 onerror 兜底(终止实例回退主线程同步计算、effect 已取消/jobId 已推进的迟到错误不回填)、主进程 will-navigate 导航白名单加固、nativeTheme 暗色标题栏、比较选项与仅看差异开关 localStorage 持久化(损坏数据防御性回退默认)、字符级降级报告选项构造与引擎降级分支对齐
This commit is contained in:
@@ -20,6 +20,8 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
|||||||
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
|
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
|
||||||
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮,超 20 万字符自动降级行级对比并提示
|
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮,超 20 万字符自动降级行级对比并提示
|
||||||
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
|
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
|
||||||
|
- **偏好记忆**:比较选项与"仅看差异"开关自动保存,下次启动自动恢复(损坏数据自动回退默认)
|
||||||
|
- **原生暗色适配**:系统标题栏等原生控件跟随应用暗色科幻主题
|
||||||
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
|
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
|
||||||
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
||||||
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
|
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
|
||||||
@@ -97,7 +99,7 @@ src/
|
|||||||
- 作者:thzxx
|
- 作者:thzxx
|
||||||
- 组织:MetonaTeam
|
- 组织:MetonaTeam
|
||||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||||
- 版本:0.5.1
|
- 版本:0.5.2
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch)
|
|||||||
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
||||||
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
||||||
|
|
||||||
当前基线版本:**`0.5.1`**
|
当前基线版本:**`0.5.2`**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -71,7 +71,8 @@ z = 补丁版本号(Patch)
|
|||||||
0.4.2 ← 导出菜单溢出修复与报告可读性重设计(已发布):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论
|
0.4.2 ← 导出菜单溢出修复与报告可读性重设计(已发布):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论
|
||||||
0.4.3 ← 忽略所有空白比较选项与报告选项说明(已发布):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标)
|
0.4.3 ← 忽略所有空白比较选项与报告选项说明(已发布):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标)
|
||||||
0.5.0 ← 字符级对比模式(已发布):新增“字符级对比”开关(空白与换行结构全部不参与判等,两侧归一化为字符流 diff,跨行重组也能判等) · 字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow,视图/报告/导航零改动) · 归一化流超 20 万字符自动降级行级对比并提示(报告头部按实际语义描述) · 字符级开启时三个空白选项置灰(语义已包含)
|
0.5.0 ← 字符级对比模式(已发布):新增“字符级对比”开关(空白与换行结构全部不参与判等,两侧归一化为字符流 diff,跨行重组也能判等) · 字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow,视图/报告/导航零改动) · 归一化流超 20 万字符自动降级行级对比并提示(报告头部按实际语义描述) · 字符级开启时三个空白选项置灰(语义已包含)
|
||||||
0.5.1 ← 字符级同行多块变更重复输出修复(当前):字符换位专项测试暴露并修复两处回映射缺陷——同行内多处变更(ne-eq-ne 同行,如换位、同行两处替换)不再重复输出多个 modified · 回拉跨界判定与对齐行判定锚定同一 eq 块,多行换位不再把全 common 行误判为 added · 新增字符换位专项测试组(对齐任意性下的不变量固化)
|
0.5.1 ← 字符级同行多块变更重复输出修复(已发布):字符换位专项测试暴露并修复两处回映射缺陷——同行内多处变更(ne-eq-ne 同行,如换位、同行两处替换)不再重复输出多个 modified · 回拉跨界判定与对齐行判定锚定同一 eq 块,多行换位不再把全 common 行误判为 added · 新增字符换位专项测试组(对齐任意性下的不变量固化)
|
||||||
|
0.5.2 ← 稳健性收尾与偏好记忆(当前):diff worker 运行崩溃 onerror 兜底(终止实例回退主线程同步计算,effect 取消/jobId 已推进的迟到错误不回填) · 主进程 will-navigate 导航白名单加固(仅放行回到应用首页) · nativeTheme 暗色主题(Windows 标题栏等系统控件跟随应用风格) · 比较选项与仅看差异开关持久化(localStorage 记忆用户偏好,损坏数据防御性回退默认) · 字符级降级报告选项构造与引擎降级分支对齐(补 trimWhitespace 置否)
|
||||||
0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
|
0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
|
||||||
...
|
...
|
||||||
0.y.z ← 长期停留,永不进入 1.x
|
0.y.z ← 长期停留,永不进入 1.x
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ src/renderer/src/
|
|||||||
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
|
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
|
||||||
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
|
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
|
||||||
- 交换左右侧后面板文件互换
|
- 交换左右侧后面板文件互换
|
||||||
|
- 偏好持久化(比较选项 + 仅看差异开关):启动时恢复上次保存的状态(选项勾选与视图折叠生效)/ 损坏 JSON 回退默认且不崩溃 / 非布尔字段防御性忽略 / 切换选项后写回存储(用例间 beforeEach 清理 localStorage 防串扰)
|
||||||
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项禁用;内容超限自动降级并 toast 提示;导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
|
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项禁用;内容超限自动降级并 toast 提示;导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
|
||||||
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
|
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
|
||||||
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
|
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
|
||||||
@@ -119,6 +120,7 @@ src/renderer/src/
|
|||||||
- 输入变更:已派发的旧 worker 终止重建(等价取消);去抖期内变更则旧任务未创建即取消(零创建与计算开销)
|
- 输入变更:已派发的旧 worker 终止重建(等价取消);去抖期内变更则旧任务未创建即取消(零创建与计算开销)
|
||||||
- worker 不可用(环境缺失/工厂返回 null):去抖到期后主线程同步回退
|
- worker 不可用(环境缺失/工厂返回 null):去抖到期后主线程同步回退
|
||||||
- 卸载:去抖期内卸载则 worker 从未创建;去抖到期后卸载则终止 worker,去抖定时器均被清理
|
- 卸载:去抖期内卸载则 worker 从未创建;去抖到期后卸载则终止 worker,去抖定时器均被清理
|
||||||
|
- worker 运行崩溃(onerror 兜底):终止实例并回退主线程同步计算(computing 不再永久卡死);迟到错误(effect 已取消 / jobId 已推进)只终止不回填,新任务结果正常生效
|
||||||
- 工厂:无 Worker 环境返回 null;可用环境返回实例
|
- 工厂:无 Worker 环境返回 null;可用环境返回实例
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
+10
-1
@@ -1,5 +1,5 @@
|
|||||||
import { join } from 'path'
|
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 { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import iconv from 'iconv-lite'
|
import iconv from 'iconv-lite'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
@@ -33,6 +33,12 @@ function createWindow(): void {
|
|||||||
return { action: 'deny' }
|
return { action: 'deny' }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 窗口内导航白名单:仅放行回到当前应用地址(dev 首页 / 打包产物首页,等价于刷新),
|
||||||
|
// 阻断其余一切页面内跳转(新窗口已在上方拦截,此处补齐页面内导航防线)
|
||||||
|
mainWindow.webContents.on('will-navigate', (e, url) => {
|
||||||
|
if (url !== mainWindow.webContents.getURL()) e.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
// 开发模式加载 dev server,生产加载打包后的 html
|
// 开发模式加载 dev server,生产加载打包后的 html
|
||||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
mainWindow.loadURL(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(() => {
|
app.whenReady().then(() => {
|
||||||
electronApp.setAppUserModelId('com.metonateam.difflens')
|
electronApp.setAppUserModelId('com.metonateam.difflens')
|
||||||
|
|
||||||
|
// 强制暗色系统主题:Windows 标题栏/滚动条等系统控件跟随应用暗色科幻风格
|
||||||
|
nativeTheme.themeSource = 'dark'
|
||||||
|
|
||||||
app.on('browser-window-created', (_, window) => {
|
app.on('browser-window-created', (_, window) => {
|
||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
|
|||||||
+55
-10
@@ -31,6 +31,47 @@ function stamp(): string {
|
|||||||
/** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */
|
/** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
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 }
|
const EMPTY_SUMMARY: DiffSummary = { changedLines: 0, inserted: 0, deleted: 0, modified: 0 }
|
||||||
|
|
||||||
@@ -91,15 +132,10 @@ function EmptyPane({
|
|||||||
export default function App(): ReactElement {
|
export default function App(): ReactElement {
|
||||||
const [paneL, setPaneL] = useState<PaneState | null>(null)
|
const [paneL, setPaneL] = useState<PaneState | null>(null)
|
||||||
const [paneR, setPaneR] = useState<PaneState | null>(null)
|
const [paneR, setPaneR] = useState<PaneState | null>(null)
|
||||||
const [options, setOptions] = useState<DiffOptions>({
|
// 比较选项与视图开关:初始值从持久化偏好恢复(惰性求值,每次挂载重新读取)
|
||||||
trimWhitespace: false,
|
const [options, setOptions] = useState<DiffOptions>(() => loadPrefs().options)
|
||||||
ignoreCase: false,
|
|
||||||
ignoreBlankLines: false,
|
|
||||||
ignoreAllWhitespace: false,
|
|
||||||
charMode: false
|
|
||||||
})
|
|
||||||
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
|
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
|
||||||
const [onlyDiff, setOnlyDiff] = useState(false)
|
const [onlyDiff, setOnlyDiff] = useState<boolean>(() => loadPrefs().onlyDiff)
|
||||||
const [activeRowId, setActiveRowId] = useState<string | null>(null)
|
const [activeRowId, setActiveRowId] = useState<string | null>(null)
|
||||||
const [navIndex, setNavIndex] = useState(0)
|
const [navIndex, setNavIndex] = useState(0)
|
||||||
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null)
|
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 化,均不阻塞界面)
|
// 超多行预警:行数超过阈值时提示后台计算耗时(渲染已虚拟化、计算已 worker 化,均不阻塞界面)
|
||||||
const warnHeavyLines = useCallback(
|
const warnHeavyLines = useCallback(
|
||||||
(text: string): void => {
|
(text: string): void => {
|
||||||
@@ -322,9 +367,9 @@ export default function App(): ReactElement {
|
|||||||
async (fmt: ReportFormat) => {
|
async (fmt: ReportFormat) => {
|
||||||
// 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御
|
// 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御
|
||||||
if (!diff) return
|
if (!diff) return
|
||||||
// 字符级对比降级时,报告头部按实际生效的行级语义描述(全部空白忽略 + 空行忽略)
|
// 字符级对比降级时,报告头部按实际生效的行级语义描述(与引擎降级分支的选项构造完全一致)
|
||||||
const reportOptions: DiffOptions = diff.charModeDowngraded
|
const reportOptions: DiffOptions = diff.charModeDowngraded
|
||||||
? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true }
|
? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true, trimWhitespace: false }
|
||||||
: options
|
: options
|
||||||
const ctx: ReportContext = {
|
const ctx: ReportContext = {
|
||||||
leftName: paneL?.meta.name ?? '',
|
leftName: paneL?.meta.name ?? '',
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
window.api = mockApi()
|
window.api = mockApi()
|
||||||
|
// 清除偏好持久化(比较选项 + 视图开关),避免用例间互相污染初始状态
|
||||||
|
window.localStorage.clear()
|
||||||
// jsdom 未实现滚动定位,导航定位(虚拟化受控 scrollTop)需要打桩
|
// jsdom 未实现滚动定位,导航定位(虚拟化受控 scrollTop)需要打桩
|
||||||
Element.prototype.scrollIntoView = vi.fn()
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
Element.prototype.scrollTo = vi.fn()
|
Element.prototype.scrollTo = vi.fn()
|
||||||
@@ -694,3 +696,76 @@ describe('App - 交换左右侧', () => {
|
|||||||
expect(files()).toEqual(['b.txt', 'a.txt'])
|
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 { computeDiff, type DiffResult, type DiffOptions } from '../diff/diffEngine'
|
||||||
import type { DiffWorkerFactory } from '../diff/createDiffWorker'
|
import type { DiffWorkerFactory } from '../diff/createDiffWorker'
|
||||||
|
|
||||||
/** 模拟 Worker:记录派发请求,可手动回传结果;terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */
|
/** 模拟 Worker:记录派发请求,可手动回传结果或触发崩溃;terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */
|
||||||
class FakeWorker {
|
class FakeWorker {
|
||||||
onmessage: ((e: { data: { jobId: number; result: DiffResult } }) => void) | null = null
|
onmessage: ((e: { data: { jobId: number; result: DiffResult } }) => void) | null = null
|
||||||
|
onerror: (() => void) | null = null
|
||||||
terminated = false
|
terminated = false
|
||||||
posted: { jobId: number }[] = []
|
posted: { jobId: number }[] = []
|
||||||
postMessage(data: { jobId: number }): void {
|
postMessage(data: { jobId: number }): void {
|
||||||
@@ -19,6 +20,11 @@ class FakeWorker {
|
|||||||
if (this.terminated) return
|
if (this.terminated) return
|
||||||
this.onmessage?.({ data: { jobId, result } })
|
this.onmessage?.({ data: { jobId, result } })
|
||||||
}
|
}
|
||||||
|
/** 模拟运行中崩溃:触发 onerror(终止后不再触发,对齐真实 worker) */
|
||||||
|
fail(): void {
|
||||||
|
if (this.terminated) return
|
||||||
|
this.onerror?.()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 每次调用产出新 FakeWorker 的工厂,并记录全部实例 */
|
/** 每次调用产出新 FakeWorker 的工厂,并记录全部实例 */
|
||||||
@@ -188,4 +194,51 @@ describe('useDiff - worker 后台计算', () => {
|
|||||||
hook.unmount()
|
hook.unmount()
|
||||||
expect(w.terminated).toBe(true)
|
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)
|
setAsyncDiff(null)
|
||||||
setComputing(true)
|
setComputing(true)
|
||||||
|
// effect 世代标志:cleanup 后置位,迟到回调(含去抖期内输入已变、jobId 尚未推进的窗口)一律丢弃
|
||||||
|
let cancelled = false
|
||||||
let w: Worker | null = null
|
let w: Worker | null = null
|
||||||
// worker 延迟到去抖到期才创建:内联 worker 实例化含 base64 解码与脚本编译,
|
// worker 延迟到去抖到期才创建:内联 worker 实例化含 base64 解码与脚本编译,
|
||||||
// 去抖期内输入再变时旧任务未创建即取消,零创建与计算开销
|
// 去抖期内输入再变时旧任务未创建即取消,零创建与计算开销
|
||||||
@@ -65,13 +67,22 @@ export function useDiff(
|
|||||||
const jobId = ++jobIdRef.current
|
const jobId = ++jobIdRef.current
|
||||||
w.onmessage = (e: MessageEvent<DiffWorkerResponse>): void => {
|
w.onmessage = (e: MessageEvent<DiffWorkerResponse>): void => {
|
||||||
// 丢弃过期任务:输入已变化时旧计算结果不再生效
|
// 丢弃过期任务:输入已变化时旧计算结果不再生效
|
||||||
if (e.data.jobId !== jobId) return
|
if (e.data.jobId !== jobId || cancelled) return
|
||||||
setAsyncDiff(e.data.result)
|
setAsyncDiff(e.data.result)
|
||||||
setComputing(false)
|
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 })
|
w.postMessage({ jobId, leftText: left, rightText: right, options })
|
||||||
}, DEBOUNCE_MS)
|
}, DEBOUNCE_MS)
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true
|
||||||
window.clearTimeout(timer)
|
window.clearTimeout(timer)
|
||||||
w?.terminate()
|
w?.terminate()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user