diff --git a/README.md b/README.md index 7486bd6..db291c6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **交换左右侧**:一键互换两侧内容与视角 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景 -- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮,超 20 万字符自动降级行级对比并提示 +- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;内容超 3 万字符或差异过大时自动降级行级对比并提示(编辑距离封顶,最坏约 2 秒出结果,不会卡死) +- **选项联动透明**:开启字符级对比时,被包含的空白选项灰显勾选并标注“已包含”(点击有解释提示,关闭字符级即恢复独立设置);内容或差异过大自动降级时开关旁标注“已降级”,状态一目了然 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 - **偏好记忆**:比较选项与"仅看差异"开关自动保存,窗口尺寸/位置与上次打开文件目录一并记忆,下次启动自动恢复(损坏数据自动回退默认) - **防误触清空**:清空前二次确认(提示窗口内再次点击才执行),粘贴输入的手动文本不因误点丢失 @@ -100,7 +101,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.5.4 +- 版本:0.5.5 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index d5425b2..79d41ca 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.5.4`** +当前基线版本:**`0.5.5`** --- @@ -74,7 +74,8 @@ z = 补丁版本号(Patch) 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.5.3 ← 稳健性补漏与偏好记忆收尾(已发布):字符级对比少行大字符量输入改走 worker 后台计算(重输入判定补入字符量维度,行数防不住 diffChars O(ND) 耗时失控) · toast 队列化(最多 3 条堆叠、各自独立计时,二进制预警不再被行数预警顶掉) · 清空按钮二次确认(toast 提示 + 3 秒确认窗口,超时自动复位) · 窗口尺寸/位置记忆(关闭保存 bounds,启动恢复并钳制回工作区,损坏数据回退默认) · 文件对话框记忆上次打开目录 · 状态栏显示两侧行数 · 偏好读取挂载期单次化 · 主进程测试基建建立(windowState 纯逻辑模块 + vitest include 扩展至 src/main) -0.5.4 ← 比较选项联动可视化与状态透明(当前):修复禁用开关零视觉反馈的样式缺陷(原 CSS 仅有 button:disabled,checkbox 禁用后外观不变,用户不知哪些可选) · 字符级开启时三个空白选项灰显勾选(循系统惯例表达“语义已包含且生效”,仅为视觉呈现不改写用户原状态,关闭字符级即复原) · 常显“已包含”徽标(原仅 hover title 可知禁用原因) · 点击被包含选项弹 toast 解释而非无反应 · 字符级因内容超限自动降级时开关旁常显“已降级”标记(状态透明) +0.5.4 ← 比较选项联动可视化与状态透明(已发布):修复禁用开关零视觉反馈的样式缺陷(原 CSS 仅有 button:disabled,checkbox 禁用后外观不变,用户不知哪些可选) · 字符级开启时三个空白选项灰显勾选(循系统惯例表达“语义已包含且生效”,仅为视觉呈现不改写用户原状态,关闭字符级即复原) · 常显“已包含”徽标(原仅 hover title 可知禁用原因) · 点击被包含选项弹 toast 解释而非无反应 · 字符级因内容超限自动降级时开关旁常显“已降级”标记(状态透明) +0.5.5 ← 字符级对比无界计算卡死修复(当前):diffChars 携 maxEditLength=3000 封顶 Myers 迭代轮数(编辑距离超限立即放弃并降级行级,根治大字符量+大差异输入 O(ND) 无界计算导致遮罩永久卡死——实测 3 万字符全不同无上限需数十分钟) · 字符量降级阈值 20 万收紧至 3 万(双限配合将最坏耗时锁在约 2 秒,worker 内计算界面不冻结) · 重输入 worker 派发阈值随动收紧(原始字符 7500,同步快路径最坏冻结压至 0.5 秒内) · 降级提示文案改“内容或差异过大”覆盖两种降级原因 · DiffOptions 增内部测试参数 charDiffMaxEdit(测试以小阈值毫秒级构造降级场景) 0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比) ... 0.y.z ← 长期停留,永不进入 1.x diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index d58d1f8..0f99030 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -82,7 +82,7 @@ src/renderer/src/ - 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`) - 词级:同一删除/新增配对为 `modified` 并产生左右 `segs` - 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines`、`ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文 -- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);降级组(归一化流超 20 万字符自动降级行级并带 `charModeDowngraded` 标志 / 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志) +- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);降级组(归一化流超 3 万字符自动降级行级并带 `charModeDowngraded` 标志 / 编辑距离超限快速降级(内部参数 charDiffMaxEdit 注入小阈值毫秒级构造)/ 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志) - 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs - 行切分:结尾换行不产生多余空行 diff --git a/package.json b/package.json index b688113..1fbf2dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.5.4", + "version": "0.5.5", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index da73b39..b11eb08 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -34,7 +34,7 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024 /** 用户偏好(比较选项 + 视图开关)持久化键 */ const PREFS_KEY = 'difflens.prefs.v1' -/** 全部比较选项的默认值(严格对比) */ +/** 全部比较选项的默认值(严格对比;charDiffMaxEdit 为内部数值参数,不属于偏好) */ const DEFAULT_OPTIONS: DiffOptions = { trimWhitespace: false, ignoreCase: false, @@ -43,6 +43,15 @@ const DEFAULT_OPTIONS: DiffOptions = { charMode: false } +/** 布尔型偏好键:持久化恢复只遍历这些字段(数值型内部参数不持久化);as const 收窄保证赋值类型为 boolean */ +const PREF_BOOL_KEYS = [ + 'trimWhitespace', + 'ignoreCase', + 'ignoreBlankLines', + 'ignoreAllWhitespace', + 'charMode' +] as const satisfies (keyof DiffOptions)[] + /** * 读取持久化偏好:只接受已知字段的严格 boolean 值; * 数据损坏(JSON 非法 / 字段类型异常 / 存储不可用)一律回退默认,保证启动不受影响。 @@ -63,7 +72,7 @@ function loadPrefs(): { options: DiffOptions; onlyDiff: boolean } { } const bool = (v: unknown): boolean => v === true const options: DiffOptions = { ...DEFAULT_OPTIONS } - for (const k of Object.keys(DEFAULT_OPTIONS) as (keyof DiffOptions)[]) { + for (const k of PREF_BOOL_KEYS) { options[k] = bool(obj.options?.[k]) } return { options, onlyDiff: bool(obj.onlyDiff) } @@ -294,10 +303,10 @@ export default function App(): ReactElement { // diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径 const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options) - // 字符级对比因内容超限自动降级为行级时提示(依赖 diff 对象,同一结果只提示一次) + // 字符级对比因内容超限或差异过大自动降级为行级时提示(依赖 diff 对象,同一结果只提示一次) useEffect(() => { if (diff?.charModeDowngraded) { - showToast('内容较大,字符级对比已自动降级为行级对比') + showToast('内容或差异过大,字符级对比已自动降级为行级对比') } }, [diff, showToast]) diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index 0b7b2cd..a08096c 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -141,7 +141,7 @@ export default function Toolbar({ /> 字符级对比 {options.charMode && charModeDowngraded && ( - + 已降级 )} diff --git a/src/renderer/src/diff/diffEngine.test.ts b/src/renderer/src/diff/diffEngine.test.ts index 7a50b82..c05afa3 100644 --- a/src/renderer/src/diff/diffEngine.test.ts +++ b/src/renderer/src/diff/diffEngine.test.ts @@ -445,6 +445,28 @@ describe('computeDiff - 字符级对比(超限降级)', () => { expect(result.summary.changedLines).toBe(0) }) + it('编辑距离超限快速降级(总量未超但差异过大)', () => { + // 内部参数注入小阈值(10):生产默认 3000,触发截断需跑满上限轮数迭代,测试以小值毫秒级构造 + const left = 'a'.repeat(2000) + const right = 'b'.repeat(2000) + const result = computeDiff(left, right, { charMode: true, charDiffMaxEdit: 10 }) + expect(result.charModeDowngraded).toBe(true) + // 降级结果与等效行级选项直接计算一致 + const direct = computeDiff(left, right, { + ignoreAllWhitespace: true, + ignoreBlankLines: true + }) + expect(result.summary).toEqual(direct.summary) + }) + + it('编辑距离在限内正常完成字符级对比', () => { + const left = 'x'.repeat(4000) + const right = 'y'.repeat(100) + 'x'.repeat(3900) + const { summary, charModeDowngraded } = computeDiff(left, right, { charMode: true }) + expect(charModeDowngraded).toBeUndefined() + expect(summary.changedLines).toBeGreaterThan(0) + }) + it('降级结果与等效行级选项直接计算一致', () => { const left = 'a'.repeat(100001) + '\nb' const right = 'a'.repeat(100001) + '\nc' diff --git a/src/renderer/src/diff/diffEngine.ts b/src/renderer/src/diff/diffEngine.ts index de69303..7c8ceb8 100644 --- a/src/renderer/src/diff/diffEngine.ts +++ b/src/renderer/src/diff/diffEngine.ts @@ -46,9 +46,16 @@ export interface DiffOptions { /** * 字符级对比:全部空白与换行结构都不参与判等,两侧全文归一化为字符流做 diff, * 差异以字符粒度回映射到行(跨行重组也能判等)。隐含 ignoreAllWhitespace 的全部语义; - * 归一化流超限(CHAR_DIFF_MAX_CHARS)时自动降级为行级对比并置 charModeDowngraded。 + * 归一化流超限(CHAR_DIFF_MAX_CHARS)或编辑距离超限(CHAR_DIFF_MAX_EDIT)时 + * 自动降级为行级对比并置 charModeDowngraded。 */ charMode?: boolean + /** + * 字符级对比的编辑距离上限覆盖值(内部参数,测试注入用): + * 生产不传,使用 CHAR_DIFF_MAX_EDIT 默认值。触发截断需跑满上限轮数的 + * Myers 迭代,测试以小阈值毫秒级构造降级场景。 + */ + charDiffMaxEdit?: number } export interface DiffResult { @@ -112,7 +119,7 @@ export function computeDiff( if (options.charMode) { const charResult = computeCharDiff(leftText, rightText, options) if (charResult) return charResult - // 归一化流超限:降级为行级对比,选项取字符级语义的最接近子集 + // 内容超限或差异过大:降级为行级对比,选项取字符级语义的最接近子集 // (全部空白忽略 + 空行忽略;换行结构差异是行级架构无法消除的边界) const result = computeLineDiff(leftText, rightText, { ...options, @@ -287,14 +294,23 @@ function computeLineDiff( /** * 字符级对比的字符总量上限(两侧归一化流合计): - * diffChars 为 O(ND),超限后计算可能耗时失控,自动降级为行级对比。 + * diffChars 为 O(ND),字符量与编辑距离任一超限都可能导致耗时失控, + * 超限自动降级为行级对比。3 万字符按实测将最坏耗时控制在约 2 秒内。 */ -export const CHAR_DIFF_MAX_CHARS = 200000 +export const CHAR_DIFF_MAX_CHARS = 30000 + +/** + * 字符级对比的编辑距离上限:diffChars 以 maxEditLength 迭代轮数封顶, + * 编辑距离超限立即放弃(返回 undefined)并降级为行级对比—— + * 差异过大时字符级高亮已失去可读性,行级语义足够。 + * 实测 3000 轮 × 3 万字符 ≈ 1.8 秒(worker 内计算,界面不冻结)。 + */ +export const CHAR_DIFF_MAX_EDIT = 3000 /** * 字符级对比的重输入阈值(两侧原始字符总量):超过即走 worker 后台计算。 - * 按 CHAR_DIFF_MAX_CHARS / 4 取值——降级上限之内仍可能出现编辑距离极大、 - * O(ND) 耗时失控的输入,且此类输入往往行数很少(压缩 JSON / base64 单行), + * 按 CHAR_DIFF_MAX_CHARS / 4 取值(留 4 倍余量给空白占比)——降级上限之内仍可能出现 + * 编辑距离极大、O(ND) 耗时失控的输入,且此类输入往往行数很少(压缩 JSON / base64 单行), * 行数维度的重输入判定防不住,需以字符量兜底。 */ export const CHAR_HEAVY_INPUT_CHARS = CHAR_DIFF_MAX_CHARS / 4 @@ -375,7 +391,8 @@ function charSegs(text: string, stat: Map | undefined): Seg[] { * - 变更组之间的行(含纯空白行)为“锚行”,左右按序 zip 配对输出 unchanged,短侧空槽仍标 unchanged * (字符级语义下纯空白行不构成差异)。 * - * 内容超限(CHAR_DIFF_MAX_CHARS)返回 null,由调用方降级行级。 + * 内容超限(CHAR_DIFF_MAX_CHARS)或编辑距离超限(CHAR_DIFF_MAX_EDIT,diffChars + * 携 maxEditLength 超限返回 undefined)均返回 null,由调用方降级行级。 */ function computeCharDiff( leftText: string, @@ -395,7 +412,12 @@ function computeCharDiff( const right = buildCharStream(rightOrig, normCh) if (left.text.length + right.text.length > CHAR_DIFF_MAX_CHARS) return null - const parts = diffChars(left.text, right.text) + // maxEditLength 封顶 Myers 迭代轮数(编辑距离),防止 O(ND) 无界计算卡死; + // jsdiff 超限时运行时返回 undefined(类型签名未标注,真值判断兜底) + const parts = diffChars(left.text, right.text, { + maxEditLength: options.charDiffMaxEdit ?? CHAR_DIFF_MAX_EDIT + }) + if (!parts) return null // 流区间块序列:common 为 eq 块,连续 removed/added 合并为 ne 块(与行级 parts 循环同构) interface Block { diff --git a/src/renderer/src/hooks/useDiff.test.ts b/src/renderer/src/hooks/useDiff.test.ts index 917e427..eeb84ac 100644 --- a/src/renderer/src/hooks/useDiff.test.ts +++ b/src/renderer/src/hooks/useDiff.test.ts @@ -116,12 +116,12 @@ describe('useDiff - 字符级大字符量重路径', () => { it('恰好阈值不触发重路径,超过阈值触发', () => { const factory = makeFactory() - // 两侧各 25000 字符 = 恰好 50000(CHAR_HEAVY_INPUT_CHARS):不触发 - const at = 'a'.repeat(25000) + // 两侧各 3750 字符 = 恰好 7500(CHAR_HEAVY_INPUT_CHARS = CHAR_DIFF_MAX_CHARS/4):不触发 + const at = 'a'.repeat(3750) const hook1 = renderHook(() => useDiff(at, at, CHAR_ON, factory)) expect(hook1.result.current.computing).toBe(false) - // 25001 + 25000 = 50001:触发 - const over = 'a'.repeat(25001) + // 3751 + 3750 = 7501:触发 + const over = 'a'.repeat(3751) const hook2 = renderHook(() => useDiff(over, at, CHAR_ON, factory)) expect(hook2.result.current.computing).toBe(true) })