fix: 字符级对比无界计算卡死修复——maxEditLength 封顶编辑距离、降级阈值收紧至 3 万字符(0.5.5)
This commit is contained in:
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
- 行切分:结尾换行不产生多余空行
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.5.4",
|
||||
"version": "0.5.5",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function Toolbar({
|
||||
/>
|
||||
字符级对比
|
||||
{options.charMode && charModeDowngraded && (
|
||||
<span className="opt-badge warn" title="内容较大,本次对比已自动降级为行级(忽略所有空白 + 忽略空行语义)">
|
||||
<span className="opt-badge warn" title="内容或差异过大,本次对比已自动降级为行级(忽略所有空白 + 忽略空行语义)">
|
||||
已降级
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<number, number> | 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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user