From 714d2329d41a8fe6d1b817cde4fbdecb24648f9d Mon Sep 17 00:00:00 2001
From: thzxx <1440196015@qq.com>
Date: Tue, 18 Aug 2026 16:01:21 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AD=97=E7=AC=A6=E7=BA=A7=E5=AF=B9?=
=?UTF-8?q?=E6=AF=94=E5=88=86=E5=9D=97=E9=94=9A=E5=AE=9A=E5=A2=9E=E5=BC=BA?=
=?UTF-8?q?=20-=20=E8=A1=8C=E5=BD=92=E4=B8=80=E5=8C=96=E9=94=9A=E5=AE=9A+?=
=?UTF-8?q?=E5=8F=98=E6=9B=B4=E7=BB=84=E5=AD=97=E7=AC=A6=E6=B5=81=E4=B8=A4?=
=?UTF-8?q?=E9=98=B6=E6=AE=B5=E7=AE=97=E6=B3=95=E4=BD=BF=E5=A4=A7=E6=96=87?=
=?UTF-8?q?=E4=BB=B6=E4=B8=8D=E5=86=8D=E6=95=B4=E7=AF=87=E5=8F=97=E9=99=90?=
=?UTF-8?q?=E3=80=81=E5=8F=8C=E9=99=90=E9=98=88=E5=80=BC=E6=94=BE=E5=AE=BD?=
=?UTF-8?q?=E8=87=B35=E4=B8=87/5000=E5=B9=B6=E6=96=B0=E5=A2=9E=E7=BB=84?=
=?UTF-8?q?=E6=80=BB=E6=B5=81=E4=B8=8A=E9=99=9010=E4=B8=87=E3=80=81?=
=?UTF-8?q?=E9=87=8D=E8=BE=93=E5=85=A5=E9=98=88=E5=80=BC=E9=9A=8F=E5=8A=A8?=
=?UTF-8?q?=E3=80=81=E5=BC=80=E5=85=B3=E4=B8=8E=E6=8A=A5=E5=91=8A=E8=AF=B4?=
=?UTF-8?q?=E6=98=8E=E5=90=8C=E6=AD=A5=E5=88=86=E5=9D=97=E8=AF=AD=E4=B9=89?=
=?UTF-8?q?=EF=BC=880.6.3=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 4 +-
docs/应用开发与版本迭代规范.md | 5 +-
docs/测试策略与方案.md | 4 +-
package-lock.json | 4 +-
package.json | 2 +-
src/renderer/src/__tests__/App.test.tsx | 54 +++--
src/renderer/src/components/Toolbar.tsx | 2 +-
src/renderer/src/diff/diffEngine.test.ts | 134 ++++++++++--
src/renderer/src/diff/diffEngine.ts | 246 +++++++++++++++++------
src/renderer/src/diff/report.test.ts | 4 +-
src/renderer/src/diff/report.ts | 5 +-
src/renderer/src/hooks/useDiff.test.ts | 12 +-
12 files changed, 361 insertions(+), 115 deletions(-)
diff --git a/README.md b/README.md
index e0300a7..e2ee286 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **交换左右侧**:一键互换两侧内容与视角
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
-- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;内容超 3 万字符或差异过大时自动降级行级对比并提示(编辑距离封顶,最坏约 2 秒出结果,不会卡死)
+- **字符级对比**:空白与换行结构全部不参与判等,跨行重组(如排版重排、换行位置变化)也能判等;相同内容自动行锚定跳过计算、差异按变更组分块对比——大文件不再整篇受字符量限制;字符差异精确回映射到行内高亮;仅差异过大(单组超 5 万字符、编辑距离超限或变更总量过大)时自动降级行级对比并提示(编辑距离封顶,最坏耗时锁定在秒级,后台计算不卡界面)
- **选项联动透明**:开启字符级对比时,被包含的空白选项灰显勾选并标注“已包含”(点击有解释提示,关闭字符级即恢复独立设置);内容或差异过大自动降级时开关旁标注“已降级”,状态一目了然
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
- **偏好记忆**:比较选项与"仅看差异"开关自动保存,窗口尺寸/位置与上次打开文件目录一并记忆,下次启动自动恢复(损坏数据自动回退默认)
@@ -108,7 +108,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
- 作者:thzxx
- 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE))
-- 版本:0.6.2
+- 版本:0.6.3
---
diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md
index 2acddb9..bb3d32e 100644
--- a/docs/应用开发与版本迭代规范.md
+++ b/docs/应用开发与版本迭代规范.md
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch)
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
-当前基线版本:**`0.6.2`**
+当前基线版本:**`0.6.3`**
---
@@ -81,7 +81,8 @@ z = 补丁版本号(Patch)
- Playwright E2E 测试体系建立:对 build 产物启动真实 Electron 验收(复用项目自带 Electron,无需下载浏览器) · 首批 11 用例覆盖 jsdom 无法触达的主进程真实链路(GBK/BOM/大文件 worker 解码、报告写盘、剪贴板、偏好跨实例重启、文件夹真实扫描) · 主进程 dialog stub(showOpenDialog/showSaveDialog 可自动化) · 用例间清空 localStorage 隔离偏好污染 · npm run test:e2e 一键构建+验收,纳入发布 checklist
- 文件夹对比(MVP):新增「对比文件夹」入口(依次选择两侧目录) · 递归扫描按相对路径对齐,文件级状态判定(相同/不同/仅左/仅右;大小不同即不同,大小一致做字节级全量比对,超 10MB 采样头部 8KB 近似判定并标注 ≈) · symlink 跳过防环,单侧文件数上限 10000(超限截断提示) · FolderView 虚拟滚动列表(统计徽章/仅看差异默认开/大小列示) · 双击条目进入单文件对比复用全部 diff 能力,一键返回文件夹列表(扫描结果保留) · file:open 与 file:read-by-path 共用同一读取管线(10MB 上限/编码探测/二进制预警一致)
0.6.1 ← 文件夹对比收尾打磨(已发布):文件夹扫描内容比对受控并发化(大小一致条目按 16 并发分批读取比对,批内 Promise.all 批间串行,大目录不再逐文件串行等 IO,输出与失败语义和串行实现完全一致) · 「返回文件对比」清空两侧面板(修复先有文件对比再进文件夹退出后残留旧对比,对齐文档的单文件空态) · 两次选择同一文件夹防呆提示(不进入扫描) · ScanResult.total 注释修正(真实枚举总数,原注释误写为上限值) · README 版本号补同步 0.6.0 遗漏(元信息维护表要求随版本同步)
-0.6.2 ← 文件夹对比稳健性与性能修复(当前):双击文件夹条目任一侧读取失败时清空该侧面板(修复失败侧残留旧对比内容与旧文件名组成“名不符实”对比的缺陷,readByPath 异常/空返回/error 三条失败路径全覆盖) · 文件夹枚举改手写 BFS 遍历凑满即停(替换 readdir recursive 无法提前终止的实现,误选超大目录如 node_modules 不再枚举耗时数秒;截断时 total 为已遍历下限,界面提示改“文件过多(N+)”)
+0.6.2 ← 文件夹对比稳健性与性能修复(已发布):双击文件夹条目任一侧读取失败时清空该侧面板(修复失败侧残留旧对比内容与旧文件名组成“名不符实”对比的缺陷,readByPath 异常/空返回/error 三条失败路径全覆盖) · 文件夹枚举改手写 BFS 遍历凑满即停(替换 readdir recursive 无法提前终止的实现,误选超大目录如 node_modules 不再枚举耗时数秒;截断时 total 为已遍历下限,界面提示改“文件过多(N+)”)
+0.6.3 ← 字符级对比分块锚定增强(当前):对比改两阶段——每行剔除全部空白判等找安全锚行(相同内容不进入 diffChars),连续增删行块合为“变更组”、组内归一化字符流 diffChars——归一化流总量不再受单次上限约束,大文件(总量远超上限)只要变更集中在组内限额之下即可字符级完成(输出仍为 DiffRow,视图/报告/导航零改动;无锚行时唯一组即全文,与旧整篇路径同构) · 双限阈值放宽(单组字符量 3 万→5 万、编辑距离 3000→5000)并新增变更组总流上限 10 万兜底累计耗时(超限整体降级语义不变) · 重输入 worker 阈值随动(原始字符 7500→5000,同步快路径最坏耗时口径不变) · 字符级开关提示与报告选项说明同步分块语义
...
0.y.z ← 长期停留,永不进入 1.x
```
diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md
index 0b4595f..f536678 100644
--- a/docs/测试策略与方案.md
+++ b/docs/测试策略与方案.md
@@ -92,7 +92,7 @@ src/renderer/src/
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`)
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
- 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines`、`ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文
-- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);降级组(归一化流超 3 万字符自动降级行级并带 `charModeDowngraded` 标志 / 编辑距离超限快速降级(内部参数 charDiffMaxEdit 注入小阈值毫秒级构造)/ 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志)
+- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);分块锚定组(大文件全同内容行锚定不降级 / 大文件局部差异精确定位 modified 与锚行 unchanged / 多变更组穿插行号全局单调 / 变更组总流超全局上限整体降级 / 跨行重组落在组内锚行 zip 输出);降级组(变更组流超单组上限自动降级行级并带 `charModeDowngraded` 标志 / 编辑距离超限快速降级(内部参数 charDiffMaxEdit 注入小阈值毫秒级构造)/ 降级结果与等效行级选项一致 / 组流恰好阈值不降级 / 未开启不携带标志)
- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs
- 行切分:结尾换行不产生多余空行
@@ -127,7 +127,7 @@ src/renderer/src/
- 清空二次确认:有内容时第一次点击仅提示不清空 / 确认窗口内再点执行清空 / 超时自动复位(再点仍先提示)/ 空态点击不弹确认
- 状态栏行数:单侧/双侧加载显示对应行数(切分与引擎一致)/ 清空后随空态消失
- 偏好持久化(比较选项 + 仅看差异开关):启动时恢复上次保存的状态(选项勾选与视图折叠生效)/ 损坏 JSON 回退默认且不崩溃 / 非布尔字段防御性忽略 / 切换选项后写回存储(用例间 beforeEach 清理 localStorage 防串扰)
-- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
+- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;差异过大(变更组流超限)自动降级并 toast 提示(开关旁常显"已降级"标记);大文件仅局部差异时分块完成不降级且精确定位(无降级提示与标记);导出报告携带字符级说明(含分块语义);降级后报告按实际生效的行级语义描述选项
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入扫描 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比)
diff --git a/package-lock.json b/package-lock.json
index 4cc4bad..1af617f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "DiffLens",
- "version": "0.5.5",
+ "version": "0.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "DiffLens",
- "version": "0.5.5",
+ "version": "0.6.3",
"license": "MIT",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
diff --git a/package.json b/package.json
index 04e34dd..7368895 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
- "version": "0.6.2",
+ "version": "0.6.3",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx
index 7834ded..cda04be 100644
--- a/src/renderer/src/__tests__/App.test.tsx
+++ b/src/renderer/src/__tests__/App.test.tsx
@@ -754,28 +754,52 @@ describe('App - 字符级对比', () => {
expect(await screen.findByText(/「忽略所有空白」已包含在字符级对比中/)).toBeInTheDocument()
})
- it('内容超限时自动降级行级并给出提示', async () => {
- // 1900 行 × 106 字符 ≈ 201,400 归一化字符(超过 20 万阈值);
- // 总行数 1901 未超过快路径阈值,同步计算即时降级
- const big = Array.from({ length: 1900 }, () => 'a'.repeat(106)).join('\n')
+ it('差异过大时自动降级行级并给出提示', async () => {
+ // 单行全差异构造:变更组流 = 25001 + 25001 > 5 万上限;
+ // 总行数 1 未超过快路径阈值,同步计算即时降级
window.api = mockApi({
- openFile: async () => ({
- path: '/tmp/big.txt',
- name: 'big.txt',
- text: big,
- encoding: 'UTF-8',
- binary: false
- })
+ openFile: async (side) =>
+ side === 'left'
+ ? { path: '/tmp/big.txt', name: 'big.txt', text: 'a'.repeat(25001), encoding: 'UTF-8', binary: false }
+ : { path: '/tmp/big2.txt', name: 'big2.txt', text: 'b'.repeat(25001), encoding: 'UTF-8', binary: false }
})
render()
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
+ fireEvent.click(screen.getByText('打开右侧'))
+ await screen.findAllByText('big2.txt')
fireEvent.click(screen.getByLabelText('字符级对比'))
expect(await screen.findByText(/已自动降级为行级对比/)).toBeInTheDocument()
// 降级状态在工具栏可见:字符级开关旁出现“已降级”标记
expect(await screen.findByText('已降级')).toBeInTheDocument()
})
+ it('大文件仅局部差异:字符级分块完成,不降级且精确定位差异', async () => {
+ // 2000 行 × 50 字符 ≈ 10 万归一化字符(远超单组 5 万上限),仅一行不同:
+ // 行锚定后唯一变更组只有该行,字符级正常完成(旧实现必降级)
+ const base = Array.from({ length: 2000 }, (_, i) => `line-${i}-${'x'.repeat(40)}`)
+ const modified = [...base]
+ modified[1000] = `line-1000-${'y'.repeat(40)}`
+ window.api = mockApi({
+ openFile: async (side) =>
+ side === 'left'
+ ? { path: '/tmp/a.txt', name: 'a.txt', text: base.join('\n'), encoding: 'UTF-8', binary: false }
+ : { path: '/tmp/b.txt', name: 'b.txt', text: modified.join('\n'), encoding: 'UTF-8', binary: false }
+ })
+ render()
+ fireEvent.click(screen.getByText('打开左侧'))
+ await screen.findByText('导出报告')
+ fireEvent.click(screen.getByText('打开右侧'))
+ await screen.findAllByText('b.txt')
+ fireEvent.click(screen.getByLabelText('字符级对比'))
+ // 无降级提示与标记(等去抖到期、后台回退同步计算完成)
+ await screen.findByText('有差异')
+ expect(screen.queryByText(/已自动降级/)).not.toBeInTheDocument()
+ expect(screen.queryByText('已降级')).not.toBeInTheDocument()
+ // 差异统计精确:一处修改
+ expect(screen.getByText('~1')).toBeInTheDocument()
+ })
+
it('导出报告携带字符级对比说明', async () => {
let saved = ''
window.api = mockApi({
@@ -798,17 +822,17 @@ describe('App - 字符级对比', () => {
fireEvent.click(screen.getByText('导出报告'))
fireEvent.click(screen.getByText('纯文本'))
await screen.findByText(/报告已保存/)
- expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行)')
+ expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行,大内容自动分块计算)')
})
it('降级后导出报告按实际生效的行级语义描述选项', async () => {
let saved = ''
- const big = Array.from({ length: 1900 }, () => 'a'.repeat(106)).join('\n')
+ // 单行全差异(变更组流超 5 万上限):字符级降级为行级语义
window.api = mockApi({
openFile: async (side) =>
side === 'left'
- ? { path: '/tmp/big.txt', name: 'big.txt', text: big, encoding: 'UTF-8', binary: false }
- : { path: '/tmp/b.txt', name: 'b.txt', text: 'b', encoding: 'UTF-8', binary: false },
+ ? { path: '/tmp/big.txt', name: 'big.txt', text: 'a'.repeat(25001), encoding: 'UTF-8', binary: false }
+ : { path: '/tmp/b.txt', name: 'b.txt', text: 'b'.repeat(25001), encoding: 'UTF-8', binary: false },
saveReport: async (content: string) => {
saved = content
return { ok: true, path: 'C:/docs/r.txt' }
diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx
index a08096c..78e5882 100644
--- a/src/renderer/src/components/Toolbar.tsx
+++ b/src/renderer/src/components/Toolbar.tsx
@@ -132,7 +132,7 @@ export default function Toolbar({