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({