diff --git a/README.md b/README.md index 1df782b..73410b3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 -- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑)批量审查,一键返回文件夹列表 +- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新) - **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成 - **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览) - **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅 @@ -22,7 +22,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **交换左右侧**:一键互换两侧内容与视角 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景 -- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;**相同内容自动锚定跳过计算(行锚定 + 变更组分块),大文件不再整篇受限**;差异过大时自动降级行级对比并提示(编辑距离封顶,不会卡死);重算期间保留旧结果撑住滚动位置,**切换选项不再回弹到顶部** +- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;**相同内容自动锚定跳过计算(行锚定 + 变更组分块),大文件不再整篇受限**;**变更组逐组预算制**——单组过大 / 编辑距离超限 / 预算耗尽的块仅按行级配对输出(部分降级,界面与报告标注),不再牵连其余块;全部块降级时自动降级行级对比并提示(编辑距离封顶,不会卡死);重算期间保留旧结果撑住滚动位置,**切换选项不再回弹到顶部** - **选项联动透明**:开启字符级对比时,被包含的空白选项灰显勾选并标注“已包含”(点击有解释提示,关闭字符级即恢复独立设置);差异过大自动降级时开关旁标注“已降级”,状态一目了然 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 - **偏好记忆**:比较选项与"仅看差异"开关自动保存,窗口尺寸/位置与上次打开文件目录一并记忆,下次启动自动恢复(损坏数据自动回退默认) @@ -32,7 +32,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 - **差异统计徽章**:实时统计新增 / 删除 / 修改行数 - **导出一键报告**:单文件与文件夹对比均可导出 HTML / 纯文本 / Markdown 三种格式。单文件报告始终包含全量行,HTML 为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(点击展开零丢失),并支持打印与窄屏;文件夹报告含判定口径说明与全量条目清单 -- **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本 +- **拖拽与按钮双入口**:支持文件拖拽导入(E2E 覆盖真实拖放链路),也可通过界面按钮选择文件或粘贴文本 - **滚动联动**:左右两个面板滚动位置自动同步 --- @@ -110,7 +110,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主 - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.7.1 +- 版本:0.8.0 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 1cf086f..400a134 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.7.1`** +当前基线版本:**`0.8.0`** --- @@ -92,6 +92,12 @@ z = 补丁版本号(Patch) - 字符级对比分块锚定(恢复 0.6.3 算法):行归一化锚定 + 变更组分块字符流两阶段——相同内容不进入 diffChars,大文件字符级不再整篇受限(组上限 5 万字符/编辑距离 5000/组总流 10 万,任一超限降级行级);引擎输出经真机验证精确无误(0.6.3 事故根因不在引擎) - 滚动回弹修复(0.6.3 事故真因,0.5.3 起即存在):worker 重路径计算期间保留旧结果撑住虚拟滚动容器高度(快路径结果兜底首次重算),items 不再塌陷——scrollTop 不被浏览器钳制归零;文本变化(打开新文件/交换左右)仍清空防过期差异误导;E2E 新增滚动稳定性回归锚(真实文件+行数不等+开字符级+切选项重算断言位置保持) 0.7.1 ← 文件夹扫描竞态守卫与边角修复(已发布):文件夹扫描会话序号丢弃过期结果(语义切换触发重扫与重新选择新目录的并发扫描按序号判定,慢的旧扫描后完成不再覆盖新扫描的目录与条目、不误弹失败提示、不误清扫描态;退出文件夹对比作废在途扫描,迟到的结果不把界面拉回文件夹模式、遮罩不残留) · FolderView 补窗口尺寸变化监听(拉伸窗口变高后虚拟滚动窗口即时补齐,底部不再出现空白区,对齐 DiffView 行为) · 行号列改不透明等效合成背景(真机证实横向滚动时行文字透过 sticky 半透明行号列叠印,修复后完全遮盖,视觉色值为原叠加的预合成) · 最大化/全屏关闭改存 getNormalBounds 普通状态(本次会话调整过的窗口位置不因最大化关闭丢失,最小化仍不记录) · 行 id 生成统一为 rows.length(消除 flushRemoved 与 commitModified 双轨) · fmtSize 收敛为 folderReport 导出的单一实现(报告与列表共用) · 文档与版本号同步 +0.8.0 ← 文件夹对比深度增强 · 字符级逐组预算制 · 拖拽 E2E(已发布): + - 按扩展名过滤:folderTree 纯逻辑层增 relExt/EntryFilter.exts(第四维过滤,与其他条件取交集;无扩展名文件归空串类目) · FolderView 扩展名多选下拉(动态收集全量条目 ext→count 字典序,chips 激活态、清除按钮、useDismiss 关闭) · 过滤激活时"共 N / 总数"计数切换,统计徽章口径保持全量不变 + - 目录行双击聚合对比:双击目录行进入该子树的差异文件批量审查(导航序列限定子树内,语义同不计入;标签标注当前范围,Alt+↓/Alt+↑ 在范围内不漂移) · 双击任意文件条目回全局序列 · 目录内无差异文件 toast 提示不进入 · 双击前的两次单击 toggle 残留折叠态由进入时自动展开消除 · openFolderEntry 增 navScope 参数(导航与聚合入口保持范围,手动双击清除) + - 增量重扫:folderScan 枚举 stat 保留 size+mtimeMs 指纹,扫描结果携带增量缓存(ScanCacheEntry:双侧指纹 + 状态 + semSame 语义判定参与记录) · 重扫携带上次缓存时指纹一致的条目零 IO 沿用;语义判等开关翻转按 semSame 纯内存组合(different ↔ semantic-same 零 IO),仅"上次未参与语义而本次需要"的条目读内容补判 · unreadable 不进缓存(重扫重试)、截断忽略缓存且 cache 产出为空(条目不完整不可信) · IPC folder:scan 增 previous 参数(主进程防御性校验数组形态) · FolderView 增「重新扫描」按钮(不重选目录直接刷新) · 已知取舍:mtime 未变视为内容未变(rsync/make 同款,手动改回时间戳不防) + - 字符级逐组预算制(变更组粒度放宽):第二阶段从"任一超限整体降级"改为逐组判定——组流 ≤ 5 万且编辑距离 ≤ 5000 且在总预算(10 万字符)内的组走字符级,超限/预算耗尽的组仅按行级配对输出(emitGroupLineLevel:行归一化 LCS + 相邻块配 modified 词级高亮 + 多余侧 removed/added,与行级路径同构含超长行词级防护) · DiffResult 增 charModePartial 部分降级标志(与 charModeDowngraded 互斥:全部组降级仍整体降级) · Toolbar「部分降级」标记、toast 提示、报告头部选项说明(plainOptions 增 partial 描述) · 行为变更:0.7.x 整体降级的多组混合输入在 0.8.0 为部分降级(特性而非回归,测试固化新行为) + - 拖拽导入 E2E:helpers 增 dropFiles(真实 Chromium DataTransfer/DragEvent 合成拖放,覆盖 dropPane → file.arrayBuffer → decodeBuffer IPC → 编码探测全链路) · 4 用例:空面板拖入渲染、双侧拖入对比、非文本扩展名拒绝、多文件提示取首个 ... 0.y.z ← 长期停留,永不进入 1.x ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index fe0451d..39d84d6 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -44,8 +44,8 @@ vitest.config.ts # Vitest 配置(jsdom + 测试范围 + 覆盖率 80% 硬门禁) playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑) e2e/ -├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口) -└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/偏好重启) +├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口/合成拖放) +└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/拖拽导入/偏好重启) src/main/ ├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,真实链路由 E2E 覆盖) ├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用) @@ -74,13 +74,13 @@ src/renderer/src/ ├── components/ │ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出) │ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) -│ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤/子树收集) -│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索) -│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/双击/导出菜单/虚拟滚动/resize 重算) +│ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤含扩展名/子树收集) +│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt) +│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/目录双击/重新扫描/双击/导出菜单/虚拟滚动/resize 重算) │ ├── TextInputModal.test.tsx # 粘贴文本弹窗 -│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级标记) +│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记) └── __tests__/ - ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果) + ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级) └── main.test.ts # 入口挂载冒烟测试 ``` @@ -96,7 +96,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 不重复行);降级组(归一化流超 3 万字符自动降级行级并带 `charModeDowngraded` 标志 / 编辑距离超限快速降级(内部参数 charDiffMaxEdit 注入小阈值毫秒级构造)/ 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志 / 全部组超限整体降级不出现 partial);逐组预算制部分降级组(0.8.0):组总流超预算时预算内组字符级、预算外组行级配对(`charModePartial` 置位、segs 拼接无损、超长行触发词级防护 segs 为 null、行号单调)/ 单组流超上限仅该组行级其余组字符级精确定位 / 单组编辑距离超限仅该组行级(charDiffMaxEdit 注入)/ 行级降级组内行数不等按序配对多余侧 added - 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs - 行切分:结尾换行不产生多余空行 @@ -163,12 +163,15 @@ src/renderer/src/ - 递归子目录按相对路径对齐(统一 / 分隔);二进制内容字节级判定;symlink 跳过;条目字典序排序;total 取两侧较大值 - 边界与选项:两个空目录 / maxFiles 截断(truncated + total + 条目数受限;截断后不再深入子目录,total 为已遍历下限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different - 受控并发:跨多个并发批次(> 2×SCAN_CONCURRENCY)的混合状态全部判定正确、条目排序与并发执行顺序无关 +- 增量重扫(0.8.0,previous 缓存):指纹一致(size+mtime 未变)零 IO 沿用判定(decode 计数零增长)/ 语义开关翻转按 semSame 纯内存组合(different ↔ semantic-same 零 IO,开→关→开三轮)/ 上次未参与语义(semSame null)而本次开启读内容补判 / 指纹变化(内容修改)重新判定且文件增删正确 / 单侧缺失形状变化不误沿用 / 本次截断忽略 previous 且 cache 产出为空 / unreadable 不进缓存(故障恢复后重扫正确判定) ### 5.10 文件夹视图(`FolderView.tsx`)与条目树(`folderTree.ts`) -- 条目树纯逻辑:按路径分段聚合目录/文件(同层字典序);目录聚合子树状态(different > unreadable > only > 语义同 > 相同)与文件计数;折叠目录子树隐藏;过滤(onlyDiff / statusFilter / query 子串不区分大小写);空目录剪枝由“过滤后建树”天然保证 -- 渲染:目录路径卡片 / 重新选择、导出报告与返回按钮 / 统计徽章(相同/语义同/不同/仅左/仅右/无法读取/总数;语义关闭隐藏语义同、无失败隐藏无法读取) +- 条目树纯逻辑:按路径分段聚合目录/文件(同层字典序);目录聚合子树状态(different > unreadable > only > 语义同 > 相同)与文件计数;折叠目录子树隐藏;过滤(onlyDiff / statusFilter / query 子串不区分大小写 / exts 扩展名集合);空目录剪枝由“过滤后建树”天然保证;relExt 取文件名段扩展名(小写、无扩展名空串、不误取目录名中的点) +- 渲染:目录路径卡片 / 重新扫描、重新选择、导出报告与返回按钮 / 统计徽章(相同/语义同/不同/仅左/仅右/无法读取/总数;语义关闭隐藏语义同、无失败隐藏无法读取) - 树形交互:目录行点击折叠展开(默认全展开);搜索命中强制展开命中路径、清空恢复用户折叠;无命中提示 +- 扩展名过滤(0.8.0):下拉展示全量条目 ext→count(字典序、无扩展名单列);点选过滤与其他条件取交集、计数切 N / 总数;多选并集、再点取消、清除按钮复位;与状态过滤双激活空态提示;点击外部关闭且过滤状态保留 - 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动) +- 目录行双击(0.8.0):触发 onOpenDir 携带目录相对路径;双击前折叠态自动展开(toggle 残留消除);重新扫描按钮触发 onRescan 且扫描中禁用 - 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果) - 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满 - 窗口尺寸变化:resize 后按新视口高度补齐渲染窗口(stub clientHeight 模拟拉高,DOM 行数随之增加) @@ -183,6 +186,8 @@ src/renderer/src/ - 报告导出:stub 保存对话框 → 单文件 HTML 报告真实落盘且含结论与统计 - 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤 / 树形显示)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留) - 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目) +- 增量重扫(0.8.0):真实目录修改文件后点「重新扫描」状态正确更新(相同→不同→改回相同,真实 mtime 变化走指纹判定) +- 拖拽导入(0.8.0):dropFiles 合成 DataTransfer 拖放——空面板拖入渲染(arrayBuffer → decodeBuffer IPC 全链路)、双侧拖入对比、非文本扩展名拒绝、多文件提示取首个 - 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData) - 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理) @@ -225,9 +230,9 @@ npm run build ## 9. 演进方向(后续迭代) -- 文件夹对比深度增强:目录行双击聚合对比 / 按扩展名过滤 / 文件夹对比结果的增量重扫(记忆上次结果只扫变更) -- 字符级对比能力扩展:变更组粒度的进一步放宽(大改文件分块提示而非整体降级) -- E2E 覆盖扩展:拖拽导入链路 / 大文件性能基准 +- 文件夹对比:增量缓存跨会话持久化(userData 落盘,重启后仍可增量) · 文件夹对比快照基线(保存/加载上次结果对比) · 忽略规则(排除 node_modules 等目录/文件过滤器) +- 字符级对比能力扩展:变更组预算制的自适应(按输入规模动态放宽预算) +- E2E 覆盖扩展:大文件性能基准(万级行计算耗时上限断言) - 主进程 IPC 层补充集成测试(folder:scan 并发场景) --- diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index fc36495..3c9584b 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -8,7 +8,8 @@ import { makeTmpDir, removeTmpDir, writeTextFixture, - pasteText + pasteText, + dropFiles } from './helpers' /** @@ -292,6 +293,42 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => { } }) + test('增量重扫:修改文件后点重新扫描,状态正确更新', async () => { + const root = await makeTmpDir('difflens-e2e-rescan-') + try { + const leftDir = join(root, 'left') + const rightDir = join(root, 'right') + const { mkdir, writeFile: wf } = await import('fs/promises') + await mkdir(leftDir, { recursive: true }) + await mkdir(rightDir, { recursive: true }) + await wf(join(leftDir, 'same.txt'), 'identical') + await wf(join(rightDir, 'same.txt'), 'identical') + await wf(join(leftDir, 'diff.txt'), 'left v1') + await wf(join(rightDir, 'diff.txt'), 'right v1') + + const { app, window } = await launchApp() + await stubOpenDialogSeq(app, [[leftDir], [rightDir]]) + await window.getByRole('button', { name: '对比文件夹' }).click() + await expect(window.getByText('相同 1')).toBeVisible() + await expect(window.getByText('不同 1')).toBeVisible() + + // 修改右侧 same.txt(真实 mtime 变化)→ 重新扫描(增量)→ 判定更新 + await wf(join(rightDir, 'same.txt'), 'identical changed') + await window.getByRole('button', { name: '重新扫描' }).click() + await expect(window.getByText('相同 0')).toBeVisible() + await expect(window.getByText('不同 2')).toBeVisible() + + // 改回一致 → 再扫 → 恢复 + await wf(join(rightDir, 'same.txt'), 'identical') + await window.getByRole('button', { name: '重新扫描' }).click() + await expect(window.getByText('相同 1')).toBeVisible() + await expect(window.getByText('不同 1')).toBeVisible() + await app.close() + } finally { + await removeTmpDir(root) + } + }) + test('语义判等、树形折叠、搜索与文件夹报告导出', async () => { const root = await makeTmpDir('difflens-e2e-folder2-') try { @@ -353,6 +390,58 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => { }) }) +test.describe('拖拽导入(真实 Chromium DataTransfer 链路)', () => { + test('空面板拖入文件:内容渲染与文件名显示', async () => { + const { app, window } = await launchApp() + await dropFiles(window, '.pane-empty', [ + { name: 'dropped-left.txt', content: 'drag line 1\ndrag line 2' } + ]) + // 文件名出现在面板头,内容渲染(file.arrayBuffer → decodeBuffer IPC → 编码探测全链路) + await expect(window.locator('.pane-file').filter({ hasText: 'dropped-left.txt' })).toBeVisible() + await expect(window.getByText('drag line 1').first()).toBeVisible() + await app.close() + }) + + test('双侧拖入形成对比:差异渲染', async () => { + const { app, window } = await launchApp() + await dropFiles(window, '.pane-empty', [ + { name: 'a.txt', content: 'same\nleft-unique' } + ]) + // 左侧装载后进入对比视图:右侧为 DiffView 面板(.pane-right),不再是空态 + await dropFiles(window, '.pane-right', [ + { name: 'b.txt', content: 'same\nright-unique' } + ]) + await expect(window.getByText('有差异')).toBeVisible() + await expect(window.getByText('left-unique').first()).toBeVisible() + await expect(window.getByText('right-unique').first()).toBeVisible() + await app.close() + }) + + test('拖入非文本扩展名被拒绝并提示', async () => { + const { app, window } = await launchApp() + await dropFiles(window, '.pane-empty', [ + { name: 'image.png', content: 'fake-binary', type: 'image/png' } + ]) + await expect(window.getByText(/仅支持文本文件:image\.png/)).toBeVisible() + // 保持空态(无导出入口) + await expect(window.getByRole('button', { name: '导出报告' })).toHaveCount(0) + await app.close() + }) + + test('多文件拖入提示已取第一个并加载首个文件', async () => { + const { app, window } = await launchApp() + await dropFiles(window, '.pane-empty', [ + { name: 'first.txt', content: 'first content' }, + { name: 'second.txt', content: 'second content' } + ]) + await expect(window.getByText(/一次只能导入一个文件/)).toBeVisible() + await expect( + window.locator('.pane-file').filter({ hasText: 'first.txt' }) + ).toBeVisible() + await app.close() + }) +}) + test.describe('偏好持久化(跨实例重启)', () => { test('重启后恢复比较选项与视图开关', async () => { // 第一实例 fresh 清掉历史残留,建立干净基线后再写入偏好 diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 56e2a7c..a5d805e 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -93,3 +93,30 @@ export async function pasteText(window: Page, side: 'left' | 'right', text: stri await window.getByPlaceholder(/粘贴或输入/).fill(text) await window.getByRole('button', { name: '开始对比' }).click() } + +/** + * 向指定选择器元素派发合成拖放(真实 Chromium 支持 DataTransfer/DragEvent 构造)。 + * 覆盖渲染进程拖拽导入全链路:dropPane 走 file.arrayBuffer → decodeBuffer IPC → 编码探测。 + * 先派发 dragover(preventDefault 激活 drop 目标)再派发 drop。 + */ +export async function dropFiles( + window: Page, + selector: string, + files: { name: string; content: string; type?: string }[] +): Promise { + await window.evaluate( + ({ sel, specs }) => { + const el = document.querySelector(sel) + if (!el) throw new Error(`dropFiles: selector not found: ${sel}`) + const dt = new DataTransfer() + for (const f of specs) { + dt.items.add(new File([f.content], f.name, { type: f.type ?? 'text/plain' })) + } + el.dispatchEvent( + new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }) + ) + el.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt })) + }, + { sel: selector, specs: files } + ) +} diff --git a/package.json b/package.json index 02e29c5..76cf736 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.7.1", + "version": "0.8.0", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/folderScan.test.ts b/src/main/folderScan.test.ts index b7bac69..1da6350 100644 --- a/src/main/folderScan.test.ts +++ b/src/main/folderScan.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' -import { scanFolders, semanticEquals, SEMANTIC_MAX_BYTES, type FolderEntry } from './folderScan' +import { + scanFolders, + semanticEquals, + SEMANTIC_MAX_BYTES, + type FolderEntry +} from './folderScan' /** 以临时目录为根构造两个待对比文件夹 */ let leftDir: string @@ -375,3 +380,204 @@ describe('scanFolders - 受控并发内容比对', () => { } }) }) + +describe('scanFolders - 增量重扫(previous 缓存)', () => { + /** 语义判等注入:同步解码 + 计数器(零 IO 断言的核心观察点) */ + const semWithCounter = (): { + opts: { + semantic: { + maxBytes: number + isTextFile: (rel: string) => boolean + decode: (b: Buffer) => Promise + } + } + count: () => number + } => { + let decoded = 0 + return { + opts: { + semantic: { + maxBytes: SEMANTIC_MAX_BYTES, + isTextFile: (rel: string): boolean => { + const ext = rel.slice(rel.lastIndexOf('.') + 1).toLowerCase() + return ['txt', 'md', 'json', 'ts'].includes(ext) + }, + decode: async (b: Buffer): Promise => { + decoded++ + return b.toString('utf-8') + } + } + }, + count: () => decoded + } + } + + it('指纹一致零 IO:same 与语义同条目沿用判定,decode 计数零增长', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-inc1-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-inc1-r-')) + try { + // same(字节一致)+ semantic-same(仅空白差异,需解码判定) + await writeFile(join(l, 'same.txt'), 'identical\n') + await writeFile(join(r, 'same.txt'), 'identical\n') + await writeFile(join(l, 'sem.txt'), 'a b\nc\n') + await writeFile(join(r, 'sem.txt'), 'ab\nc\n') + const sem = semWithCounter() + const first = await scanFolders(l, r, sem.opts) + expect(byRel(first.entries, 'same.txt')?.status).toBe('same') + expect(byRel(first.entries, 'sem.txt')?.status).toBe('semantic-same') + expect(sem.count()).toBe(2) // 首次:sem.txt 双侧各解码一次 + // 二次扫描携带缓存:指纹全部一致 → 全部零 IO 沿用 + const second = await scanFolders(l, r, { ...sem.opts, previous: first.cache }) + expect(byRel(second.entries, 'same.txt')?.status).toBe('same') + expect(byRel(second.entries, 'sem.txt')?.status).toBe('semantic-same') + expect(sem.count()).toBe(2) // 零增长 + // 缓存条目被原样再次产出(链式增量可用) + expect(second.cache.length).toBe(2) + } finally { + await rm(l, { recursive: true, force: true }) + await rm(r, { recursive: true, force: true }) + } + }) + + it('语义开关翻转零 IO:semSame 非 null 条目纯内存组合 different ↔ semantic-same', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-inc2-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-inc2-r-')) + try { + await writeFile(join(l, 'sem.txt'), 'a b\nc\n') + await writeFile(join(r, 'sem.txt'), 'ab\nc\n') + // 第一次:语义开 → semantic-same(semSame = true 入缓存) + const semOn = semWithCounter() + const on1 = await scanFolders(l, r, semOn.opts) + expect(byRel(on1.entries, 'sem.txt')?.status).toBe('semantic-same') + expect(semOn.count()).toBe(2) + // 第二次:语义关 + 缓存 → different(字节不同),零 IO + const off = await scanFolders(l, r, { previous: on1.cache }) + expect(byRel(off.entries, 'sem.txt')?.status).toBe('different') + expect(semOn.count()).toBe(2) + // 第三次:语义再开 + 新缓存 → semantic-same,仍零 IO + const semOn2 = semWithCounter() + const on2 = await scanFolders(l, r, { ...semOn2.opts, previous: off.cache }) + expect(byRel(on2.entries, 'sem.txt')?.status).toBe('semantic-same') + expect(semOn2.count()).toBe(0) + } finally { + await rm(l, { recursive: true, force: true }) + await rm(r, { recursive: true, force: true }) + } + }) + + it('上次未参与语义(semSame null)而本次开启:读内容补判', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-inc3-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-inc3-r-')) + try { + await writeFile(join(l, 'sem.txt'), 'a b\n') + await writeFile(join(r, 'sem.txt'), 'ab\n') + // 第一次:语义关 → different(semSame = null) + const off = await scanFolders(l, r) + expect(byRel(off.entries, 'sem.txt')?.status).toBe('different') + // 第二次:语义开 + 缓存 → sem.txt 需补判(decode 2 次) + const sem = semWithCounter() + const on = await scanFolders(l, r, { ...sem.opts, previous: off.cache }) + expect(byRel(on.entries, 'sem.txt')?.status).toBe('semantic-same') + expect(sem.count()).toBe(2) + } finally { + await rm(l, { recursive: true, force: true }) + await rm(r, { recursive: true, force: true }) + } + }) + + it('指纹变化(内容修改)重新判定,文件增删正确处理', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-inc4-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-inc4-r-')) + try { + await writeFile(join(l, 'a.txt'), 'v1\n') + await writeFile(join(r, 'a.txt'), 'v1\n') + await writeFile(join(l, 'gone.txt'), 'x\n') + const first = await scanFolders(l, r) + expect(byRel(first.entries, 'a.txt')?.status).toBe('same') + expect(byRel(first.entries, 'gone.txt')?.status).toBe('left-only') + // 修改右侧 a.txt(mtime 变化)+ 删除 gone.txt + 新增 new.txt + await writeFile(join(r, 'a.txt'), 'v2 changed\n') + await rm(join(l, 'gone.txt')) + await writeFile(join(r, 'new.txt'), 'n\n') + const second = await scanFolders(l, r, { previous: first.cache }) + expect(byRel(second.entries, 'a.txt')?.status).toBe('different') + expect(second.entries.some((e) => e.rel === 'gone.txt')).toBe(false) + expect(byRel(second.entries, 'new.txt')?.status).toBe('right-only') + } finally { + await rm(l, { recursive: true, force: true }) + await rm(r, { recursive: true, force: true }) + } + }) + + it('mtime 相同但指纹形状变化(单侧缺失变化)不误沿用', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-inc5-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-inc5-r-')) + try { + await writeFile(join(l, 'a.txt'), 'v1\n') + await writeFile(join(r, 'a.txt'), 'v1\n') + const first = await scanFolders(l, r) + expect(byRel(first.entries, 'a.txt')?.status).toBe('same') + // 删除右侧文件:same → left-only(缓存形状不一致,必须重判) + await rm(join(r, 'a.txt')) + const second = await scanFolders(l, r, { previous: first.cache }) + expect(byRel(second.entries, 'a.txt')?.status).toBe('left-only') + } finally { + await rm(l, { recursive: true, force: true }) + await rm(r, { recursive: true, force: true }) + } + }) + + it('本次截断忽略 previous 且 cache 产出为空(条目不完整不可信)', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-inc6-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-inc6-r-')) + try { + for (let i = 0; i < 5; i++) { + await writeFile(join(l, `f${i}.txt`), String(i)) + await writeFile(join(r, `f${i}.txt`), String(i)) + } + const first = await scanFolders(l, r) + expect(first.cache.length).toBe(5) + // 截断扫描:previous 被忽略(等同全量),cache 为空 + const second = await scanFolders(l, r, { maxFiles: 2, previous: first.cache }) + expect(second.truncated).toBe(true) + expect(second.cache).toEqual([]) + } finally { + await rm(l, { recursive: true, force: true }) + await rm(r, { recursive: true, force: true }) + } + }) + + it('unreadable 不进缓存:故障恢复后重扫得到正确判定', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-inc7-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-inc7-r-')) + try { + await writeFile(join(l, 'ok.txt'), 'a\n') + await writeFile(join(r, 'ok.txt'), 'b\n') + await writeFile(join(l, 'locked.txt'), 'x y\n') + await writeFile(join(r, 'locked.txt'), 'xy\n') + // 第一次:locked.txt 解码抛异常(模拟 Windows 文件锁)→ unreadable + const sem = semWithCounter() + const patched = { + semantic: { + ...sem.opts.semantic, + decode: async (b: Buffer): Promise => { + if (b.toString('utf-8').includes('x')) throw new Error('EBUSY') + return b.toString('utf-8') + } + } + } + const first = await scanFolders(l, r, patched) + expect(byRel(first.entries, 'ok.txt')?.status).toBe('different') + expect(byRel(first.entries, 'locked.txt')?.status).toBe('unreadable') + // unreadable 不进缓存(cache 仅 1 条 ok.txt) + expect(first.cache.map((c) => c.rel)).toEqual(['ok.txt']) + // 第二次:故障恢复(正常 decode)+ 缓存 → locked.txt 补判 semantic-same + const second = await scanFolders(l, r, { ...sem.opts, previous: first.cache }) + expect(byRel(second.entries, 'ok.txt')?.status).toBe('different') + expect(byRel(second.entries, 'locked.txt')?.status).toBe('semantic-same') + } finally { + await rm(l, { recursive: true, force: true }) + await rm(r, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/folderScan.ts b/src/main/folderScan.ts index c18df0f..f6913cd 100644 --- a/src/main/folderScan.ts +++ b/src/main/folderScan.ts @@ -28,6 +28,30 @@ export interface FolderEntry { approximate?: boolean } +/** 单侧文件指纹(size + mtimeMs):增量重扫的"内容是否变化"判定依据。 + * 与 rsync/make 同款取舍:mtime 未变即视为内容未变(手动改回时间戳的极端场景不防) */ +export interface FileFingerprint { + size: number + mtime: number +} + +/** + * 增量重扫的缓存条目:上次扫描的单条判定结果(按双侧指纹沿用,避免重复 IO)。 + * - status 为 same(含采样近似)/ only 类:指纹一致即可零 IO 沿用; + * - 字节不同类(different / semantic-same):semSame 非 null 时语义开关翻转可纯内存 + * 组合出新状态(different ↔ semantic-same 零 IO),null 表示上次未参与语义判定; + * - unreadable(被锁/权限)不进缓存:下次重扫重试。 + */ +export interface ScanCacheEntry { + rel: string + leftFp: FileFingerprint | null + rightFp: FileFingerprint | null + status: 'same' | 'semantic-same' | 'different' | 'left-only' | 'right-only' + approximate?: boolean + /** 上次是否参与过语义判等及其结果(null = 未参与:语义关闭 / 非文本 / 超大小上限) */ + semSame: boolean | null +} + export interface ScanResult { /** 全部条目(按相对路径字典序排序) */ entries: FolderEntry[] @@ -35,6 +59,8 @@ export interface ScanResult { truncated: boolean /** 枚举发现的文件总数(截断时为已遍历下限:凑满上限即停止深入,不再统计剩余子树) */ total: number + /** 本次结果生成的增量重扫缓存(unreadable 不含;截断时为空数组——条目不完整不可信) */ + cache: ScanCacheEntry[] } export interface ScanOptions { @@ -44,6 +70,8 @@ export interface ScanOptions { maxContentBytes?: number /** 语义判等(可选):字节级判 different 的文本文件二次判等,剔除空白后一致 → semantic-same */ semantic?: SemanticOptions + /** 增量重扫缓存(可选):上次扫描返回的 cache,指纹一致的条目零 IO 沿用判定 */ + previous?: ScanCacheEntry[] } /** @@ -93,7 +121,7 @@ export const SCAN_CONCURRENCY = 16 async function listFiles( root: string, maxFiles: number -): Promise<{ files: Map; truncated: boolean; total: number }> { +): Promise<{ files: Map; truncated: boolean; total: number }> { const rels: string[] = [] let total = 0 let truncated = false @@ -124,12 +152,14 @@ async function listFiles( if (truncated) break } // 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束); - // 单文件 stat 失败(被锁/权限)跳过该条目,不整体失败 + // 保留 size + mtimeMs 作为增量重扫指纹;单文件 stat 失败(被锁/权限)跳过该条目,不整体失败 const stats = await Promise.allSettled(rels.map((rel) => stat(join(root, rel)))) - const files = new Map() + const files = new Map() rels.forEach((rel, i) => { const s = stats[i] - if (s.status === 'fulfilled') files.set(rel, s.value.size) + if (s.status === 'fulfilled') { + files.set(rel, { size: s.value.size, mtime: s.value.mtimeMs }) + } }) return { files, truncated, total } } @@ -154,6 +184,11 @@ async function readHead(path: string, bytes: number): Promise { * 需读内容的条目按 SCAN_CONCURRENCY 受控并发执行(输出与顺序和串行实现完全一致); * 单文件/单目录 IO 失败(被锁/权限,如 Windows Defender 占用)降级处理—— * 文件标记 unreadable、目录跳过,不整体失败(大目录下单点锁不应丢弃全部结果)。 + * + * 增量重扫(options.previous,本次枚举未截断时启用):双侧指纹(size+mtime)与上次 + * 一致的条目零 IO 沿用判定——same/only 直接沿用;字节不同类在语义开关翻转时按缓存 + * semSame 纯内存组合(different ↔ semantic-same),仅"上次未参与语义判定而本次需要" + * 的条目读内容补判。上次或本次截断、unreadable 条目不享用缓存(全量重判/重试)。 */ export async function scanFolders( leftDir: string, @@ -173,21 +208,91 @@ export async function scanFolders( rSize <= semantic.maxBytes && semantic.isTextFile(rel) + /** 本次或上次截断:条目集不完整,缓存不可信(本次产出的 cache 也置空) */ + const useCache = options.previous !== undefined && !left.truncated && !right.truncated + const prevMap = new Map((useCache ? options.previous! : []).map((c) => [c.rel, c])) + const rels = [...new Set([...left.files.keys(), ...right.files.keys()])].sort() - // 预分配结果槽位:无 IO 的条目直接定案,需读内容的条目收集为任务并发执行 + // 预分配结果槽位:缓存命中/无 IO 的条目直接定案,需读内容的条目收集为任务并发执行 const entries: FolderEntry[] = new Array(rels.length) + const cache: Array = new Array(rels.length) const tasks: { idx: number; run: () => Promise }[] = [] + + /** 双侧指纹与缓存条目一致(含单侧缺失形状一致;mtime 未变视为内容未变) */ + const fpHit = ( + prev: ScanCacheEntry, + lFp: FileFingerprint | undefined, + rFp: FileFingerprint | undefined + ): boolean => { + if ((prev.leftFp === null) !== (lFp === undefined)) return false + if ((prev.rightFp === null) !== (rFp === undefined)) return false + if (lFp !== undefined && (prev.leftFp!.size !== lFp.size || prev.leftFp!.mtime !== lFp.mtime)) + return false + if (rFp !== undefined && (prev.rightFp!.size !== rFp.size || prev.rightFp!.mtime !== rFp.mtime)) + return false + return true + } + rels.forEach((rel, idx) => { - const lSize = left.files.get(rel) - const rSize = right.files.get(rel) - if (lSize === undefined) { - entries[idx] = { rel, status: 'right-only', leftSize: null, rightSize: rSize ?? null } - } else if (rSize === undefined) { + const lFp = left.files.get(rel) + const rFp = right.files.get(rel) + const lSize = lFp !== undefined ? lFp.size : null + const rSize = rFp !== undefined ? rFp.size : null + const prev = prevMap.get(rel) + + // ===== 增量缓存命中:零 IO 组合判定 ===== + if (prev !== undefined && fpHit(prev, lFp, rFp)) { + if (prev.status === 'same') { + entries[idx] = { + rel, + status: 'same', + leftSize: lSize, + rightSize: rSize, + ...(prev.approximate ? { approximate: true } : {}) + } + cache[idx] = prev + return + } + if (prev.status === 'left-only' || prev.status === 'right-only') { + entries[idx] = { rel, status: prev.status, leftSize: lSize, rightSize: rSize } + cache[idx] = prev + return + } + // 字节不同类:语义关闭或不适用 → different;开启且上次参与过 → 按 semSame 组合; + // 开启但上次未参与(semSame null)→ 落到全量判定补判 + const semActive = + semantic !== undefined && semanticEligible(rel, lSize ?? 0, rSize ?? 0) + if (!semActive) { + entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize } + cache[idx] = { ...prev, status: 'different' } + return + } + if (prev.semSame !== null) { + const status = prev.semSame ? 'semantic-same' : 'different' + entries[idx] = { rel, status, leftSize: lSize, rightSize: rSize } + cache[idx] = { ...prev, status } + return + } + } + + // ===== 全量判定(缓存未命中 / 语义补判)===== + if (lFp === undefined) { + entries[idx] = { rel, status: 'right-only', leftSize: null, rightSize: rSize } + cache[idx] = { + rel, + leftFp: null, + rightFp: rFp ?? null, + status: 'right-only', + semSame: null + } + } else if (rFp === undefined) { entries[idx] = { rel, status: 'left-only', leftSize: lSize, rightSize: null } - } else if (lSize !== rSize && !semanticEligible(rel, lSize, rSize)) { + cache[idx] = { rel, leftFp: lFp, rightFp: null, status: 'left-only', semSame: null } + } else if (lFp.size !== rFp.size && !semanticEligible(rel, lFp.size, rFp.size)) { // 大小不同且不参与语义判等(未开启/超上限/非文本):无需读内容直接定案 entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize } - } else if (lSize === rSize && lSize > maxContentBytes) { + cache[idx] = { rel, leftFp: lFp, rightFp: rFp, status: 'different', semSame: null } + } else if (lFp.size === rFp.size && lFp.size > maxContentBytes) { // 大小一致但超过全量比对上限:头部采样近似判定,结果带 approximate 标注; // 单文件读取失败(被锁/权限,如 Windows Defender 占用)标记 unreadable,不整体失败 tasks.push({ @@ -198,9 +303,18 @@ export async function scanFolders( readHead(join(leftDir, rel), SAMPLE_BYTES), readHead(join(rightDir, rel), SAMPLE_BYTES) ]) - entries[idx] = lHead.equals(rHead) + const same = lHead.equals(rHead) + entries[idx] = same ? { rel, status: 'same', leftSize: lSize, rightSize: rSize, approximate: true } : { rel, status: 'different', leftSize: lSize, rightSize: rSize } + cache[idx] = { + rel, + leftFp: lFp, + rightFp: rFp, + status: same ? 'same' : 'different', + ...(same ? { approximate: true } : {}), + semSame: null + } } catch { entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize } } @@ -220,16 +334,34 @@ export async function scanFolders( ]) if (lBuf.equals(rBuf)) { entries[idx] = { rel, status: 'same', leftSize: lSize, rightSize: rSize } + cache[idx] = { rel, leftFp: lFp, rightFp: rFp, status: 'same', semSame: null } return } - if (semanticEligible(rel, lSize, rSize)) { + if (semanticEligible(rel, lFp!.size, rFp!.size)) { const [lt, rt] = await Promise.all([semantic!.decode(lBuf), semantic!.decode(rBuf)]) if (semanticEquals(lt, rt)) { entries[idx] = { rel, status: 'semantic-same', leftSize: lSize, rightSize: rSize } + cache[idx] = { + rel, + leftFp: lFp, + rightFp: rFp, + status: 'semantic-same', + semSame: true + } return } + entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize } + cache[idx] = { + rel, + leftFp: lFp, + rightFp: rFp, + status: 'different', + semSame: false + } + return } entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize } + cache[idx] = { rel, leftFp: lFp, rightFp: rFp, status: 'different', semSame: null } } catch { entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize } } @@ -241,5 +373,12 @@ export async function scanFolders( for (let i = 0; i < tasks.length; i += SCAN_CONCURRENCY) { await Promise.all(tasks.slice(i, i + SCAN_CONCURRENCY).map((t) => t.run())) } - return { entries, truncated: left.truncated || right.truncated, total: Math.max(left.total, right.total) } + const truncated = left.truncated || right.truncated + return { + entries, + truncated, + total: Math.max(left.total, right.total), + // 截断时条目不完整:cache 置空防止下次误沿用(unreadable 槽位本就为 undefined) + cache: truncated ? [] : (cache.filter((c) => c !== undefined) as ScanCacheEntry[]) + } } diff --git a/src/main/index.ts b/src/main/index.ts index 255b7c0..dd9ea5d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,7 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils' import fs from 'fs' import { parseState, clampBounds, type AppState } from './windowState' import { decodeText, type DecodeResult } from './decode' -import { scanFolders, SEMANTIC_MAX_BYTES } from './folderScan' +import { scanFolders, SEMANTIC_MAX_BYTES, type ScanCacheEntry } from './folderScan' /** * 文本类扩展名清单(小写,不含点): @@ -264,24 +264,47 @@ ipcMain.handle('folder:pick', async () => { return dirPath }) -/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等;异步 fs 与解码 worker 不阻塞主进程) */ -ipcMain.handle('folder:scan', async (_event, leftDir: string, rightDir: string, semantic: boolean) => { - try { - return await scanFolders(leftDir, rightDir, semantic - ? { - // 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线) - semantic: { - maxBytes: SEMANTIC_MAX_BYTES, - isTextFile: isTextRel, - decode: async (buf) => (await decodeMaybeWorker(buf)).text - } - } - : {}) - } catch { - // 目录不存在/不可读等:转为错误标记,渲染端提示 - return { error: 'scan-failed' as const } +/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等 + 增量缓存;异步 fs 与解码 worker 不阻塞主进程) */ +ipcMain.handle( + 'folder:scan', + async ( + _event, + leftDir: string, + rightDir: string, + semantic: boolean, + previous?: unknown + ) => { + try { + // 增量缓存防御性校验:仅接受数组形态(渲染端从上次扫描结果携带,损坏即全量重扫) + const cache = + Array.isArray(previous) && previous.every( + (c) => + typeof c === 'object' && + c !== null && + typeof (c as ScanCacheEntry).rel === 'string' && + typeof (c as ScanCacheEntry).status === 'string' + ) + ? (previous as ScanCacheEntry[]) + : undefined + return await scanFolders(leftDir, rightDir, { + ...(cache ? { previous: cache } : {}), + ...(semantic + ? { + // 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线) + semantic: { + maxBytes: SEMANTIC_MAX_BYTES, + isTextFile: isTextRel, + decode: async (buf) => (await decodeMaybeWorker(buf)).text + } + } + : {}) + }) + } catch { + // 目录不存在/不可读等:转为错误标记,渲染端提示 + return { error: 'scan-failed' as const } + } } -}) +) /** IPC: 按绝对路径读取文本文件(文件夹对比双击进入单文件对比;与 file:open 同一读取管线) */ ipcMain.handle('file:read-by-path', async (_event, filePath: string) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 408923e..70f2051 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -45,6 +45,26 @@ export interface FolderScanResult { entries: FolderEntry[] truncated: boolean total: number + /** 增量重扫缓存(与主进程 folderScan 的 ScanCacheEntry 对齐;截断时为空数组)。 + * 下次扫描同目录时携带可零 IO 沿用指纹一致的判定 */ + cache?: FolderScanCacheEntry[] +} + +/** 单侧文件指纹(size + mtimeMs),增量重扫的变更判定依据 */ +export interface FolderFileFingerprint { + size: number + mtime: number +} + +/** 增量重扫缓存条目(与主进程 folderScan 对齐;unreadable 不进缓存) */ +export interface FolderScanCacheEntry { + rel: string + leftFp: FolderFileFingerprint | null + rightFp: FolderFileFingerprint | null + status: 'same' | 'semantic-same' | 'different' | 'left-only' | 'right-only' + approximate?: boolean + /** 上次是否参与过语义判等及其结果(null = 未参与);语义开关翻转时非 null 可零 IO 组合 */ + semSame: boolean | null } /** 文件夹扫描失败(目录不存在/不可读) */ @@ -69,9 +89,10 @@ const api = { scanFolder: ( leftDir: string, rightDir: string, - semantic: boolean + semantic: boolean, + previous?: FolderScanCacheEntry[] | null ): Promise => - ipcRenderer.invoke('folder:scan', leftDir, rightDir, semantic), + ipcRenderer.invoke('folder:scan', leftDir, rightDir, semantic, previous ?? null), readByPath: (filePath: string): Promise => ipcRenderer.invoke('file:read-by-path', filePath) } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c46aff0..0fb43b9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -188,6 +188,9 @@ export default function App(): ReactElement { const [folderScanning, setFolderScanning] = useState(false) // 从文件夹进入单文件对比时的当前条目 rel(差异文件导航的游标;返回/退出文件夹模式时清空) const [folderEntryCursor, setFolderEntryCursor] = useState(null) + // 目录聚合对比的导航范围(双击目录行进入:差异文件导航限定在该子树内; + // 双击任意文件条目回到全局序列;返回/退出文件夹模式时清空) + const [folderNavScope, setFolderNavScope] = useState(null) // 偏好只读一次盘:全部初始 state 共享同一次 loadPrefs 结果(原实现挂载期读取两次) const [initialPrefs] = useState(() => loadPrefs()) // 文件夹模式的「仅看差异」:与单文件模式的 onlyDiff 独立(文件夹场景默认关心差异文件) @@ -360,13 +363,22 @@ export default function App(): ReactElement { */ const scanSeqRef = useRef(0) - /** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式;语义判等开关随扫描参数下发) */ + /** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式;语义判等开关随扫描参数下发)。 + * 目录与上次一致且未截断时携带增量缓存——指纹一致的条目零 IO 沿用判定, + * 语义开关切换触发的重扫从全量 IO 变为近乎零 IO */ const runFolderScan = useCallback( async (leftDir: string, rightDir: string, semantic: boolean): Promise => { const seq = ++scanSeqRef.current setFolderScanning(true) try { - const res = await window.api.scanFolder(leftDir, rightDir, semantic) + const prev = + folderState && + folderState.leftDir === leftDir && + folderState.rightDir === rightDir && + !folderState.truncated + ? (folderState.cache ?? null) + : null + const res = await window.api.scanFolder(leftDir, rightDir, semantic, prev) // 过期扫描(期间已发起新扫描或已退出文件夹对比):结果整体丢弃 if (seq !== scanSeqRef.current) return if ('error' in res) { @@ -383,7 +395,7 @@ export default function App(): ReactElement { if (seq === scanSeqRef.current) setFolderScanning(false) } }, - [showToast] + [showToast, folderState] ) /** 入口:连续选择左右两个文件夹后开始对比 */ @@ -450,10 +462,13 @@ export default function App(): ReactElement { [folderState, folderSemantic, showToast] ) - /** 双击文件夹条目:按路径读取存在侧进入单文件对比(保留文件夹状态供返回) */ + /** 双击文件夹条目:按路径读取存在侧进入单文件对比(保留文件夹状态供返回)。 + * navScope 为本次进入后的导航范围:手动双击文件条目传 null(回全局序列), + * 目录聚合入口与差异文件导航传当前 scope(保持子树范围不被清除) */ const openFolderEntry = useCallback( - async (entry: FolderEntry): Promise => { + async (entry: FolderEntry, navScope: string | null = null): Promise => { if (!folderState) return + setFolderNavScope(navScope) // 清空指定侧面板:双击新条目的任一读取失败路径都必须清掉旧内容, // 否则成功侧与旧内容组成“名不符实”的对比(面板头还是旧文件名) const clearSide = (side: 'left' | 'right'): void => { @@ -499,35 +514,56 @@ export default function App(): ReactElement { [folderState, applyFileData, showToast] ) - /** 差异文件序列(不同/仅左/仅右;语义同不计入),批量审查时的导航范围 */ + /** 差异文件序列(不同/仅左/仅右;语义同不计入),批量审查时的导航范围; + * 目录聚合对比激活时限定在该子树内(folderNavScope) */ const diffFileEntries = useMemo(() => { if (!folderState) return [] - return folderState.entries.filter( + const all = folderState.entries.filter( (e) => e.status === 'different' || e.status === 'left-only' || e.status === 'right-only' ) - }, [folderState]) + if (folderNavScope === null) return all + const prefix = folderNavScope + '/' + return all.filter((e) => e.rel.startsWith(prefix)) + }, [folderState, folderNavScope]) - /** 差异文件导航:跳转到上/下一个差异文件(不循环,越界不动) */ + /** 双击目录行进入聚合对比:差异导航限定在该子树,打开其中第一个差异文件 */ + const openDirScope = useCallback( + (dirPath: string): void => { + if (!folderState) return + const inScope = diffFileEntries.filter((e) => + e.rel.startsWith(dirPath + '/') + ) + if (inScope.length === 0) { + showToast(`目录「${dirPath}」内没有差异文件`) + return + } + void openFolderEntry(inScope[0], dirPath) + }, + [folderState, diffFileEntries, openFolderEntry, showToast] + ) + + /** 差异文件导航:跳转到上/下一个差异文件(不循环,越界不动;导航不改变当前聚合范围) */ const goDiffFile = useCallback( (dir: 1 | -1): void => { if (diffFileEntries.length === 0) return const idx = diffFileEntries.findIndex((e) => e.rel === folderEntryCursor) const next = idx + dir if (idx < 0 || next < 0 || next >= diffFileEntries.length) return - void openFolderEntry(diffFileEntries[next]) + void openFolderEntry(diffFileEntries[next], folderNavScope) }, - [diffFileEntries, folderEntryCursor, openFolderEntry] + [diffFileEntries, folderEntryCursor, folderNavScope, openFolderEntry] ) - /** 从单文件对比返回文件夹列表(清空两侧面板与导航游标,文件夹扫描结果保留) */ + /** 从单文件对比返回文件夹列表(清空两侧面板与导航游标/聚合范围,文件夹扫描结果保留) */ const backToFolder = useCallback((): void => { setPaneL(null) setPaneR(null) setFolderEntryCursor(null) + setFolderNavScope(null) setMode('folder') }, []) - /** 退出文件夹对比(清空文件夹状态、导航游标与两侧面板,回到单文件空态) */ + /** 退出文件夹对比(清空文件夹状态、导航游标/聚合范围与两侧面板,回到单文件空态) */ const exitFolder = useCallback((): void => { // 作废在途扫描并复位扫描态:迟到的扫描结果不得把界面拉回文件夹模式, // 也不得让扫描遮罩残留到下一次文件夹会话 @@ -537,16 +573,20 @@ export default function App(): ReactElement { setPaneL(null) setPaneR(null) setFolderEntryCursor(null) + setFolderNavScope(null) setMode('file') }, []) // diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径 const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options) - // 字符级对比因内容超限或差异过大自动降级为行级时提示(依赖 diff 对象,同一结果只提示一次) + // 字符级对比因内容超限或差异过大自动降级为行级时提示(依赖 diff 对象,同一结果只提示一次); + // 部分降级(部分超限组行级配对、其余组仍字符级)单独提示 useEffect(() => { if (diff?.charModeDowngraded) { showToast('内容或差异过大,字符级对比已自动降级为行级对比') + } else if (diff?.charModePartial) { + showToast('部分差异块过大已按行级对比,其余差异仍为字符级高亮') } }, [diff, showToast]) @@ -695,7 +735,8 @@ export default function App(): ReactElement { async (fmt: ReportFormat) => { // 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御 if (!diff) return - // 字符级对比降级时,报告头部按实际生效的行级语义描述(与引擎降级分支的选项构造完全一致) + // 字符级对比降级时,报告头部按实际生效语义描述: + // 整体降级按行级语义;部分降级保持字符级语义并标注部分块按行级 const reportOptions: DiffOptions = diff.charModeDowngraded ? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true, trimWhitespace: false } : options @@ -706,7 +747,10 @@ export default function App(): ReactElement { rightEncoding: paneR?.meta.encoding ?? '', leftPath: paneL?.path || undefined, rightPath: paneR?.path || undefined, - options: reportOptions + options: reportOptions, + ...(diff.charModePartial && !diff.charModeDowngraded + ? { charModePartial: true } + : {}) } const content = buildReport(rows, ctx, summary, fmt) const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}` @@ -779,7 +823,14 @@ export default function App(): ReactElement { )} {folderState && folderEntryCursor !== null && diffFileEntries.length > 0 && ( -
+
- 差异文件 + + 差异文件{folderNavScope !== null ? ` · ${folderNavScope}/` : ''} +
)}
+ @@ -247,7 +294,41 @@ export default function FolderView({ ) })} - 共 {entries.length} 项 + + 共 {extFilter !== null || statusFilter !== null ? `${filteredCount} / ${entries.length}` : entries.length} 项 + +
+
+ + {extOpen && ( +
+ {extStats.map(([ext, n]) => { + const on = extFilter?.has(ext) ?? false + return ( + + ) + })} + {extFilter !== null && ( + + )} +
+ )}
toggleDir(n.path)} + onDoubleClick={() => { + // 双击前两次单击 toggle 会回到原折叠态:进入聚合对比前确保该目录展开 + setCollapsedDirs((prev) => { + if (!prev.has(n.path)) return prev + const next = new Set(prev) + next.delete(n.path) + return next + }) + onOpenDir(n.path) + }} > {collapsed ? '▶' : '▼'} {n.name} @@ -331,7 +422,7 @@ export default function FolderView({ ? `没有匹配“${q}”的文件` : entries.length === 0 ? '两个文件夹内都没有文件' - : statusFilter !== null + : statusFilter !== null || extFilter !== null ? '当前过滤条件下没有文件' : '没有差异文件(两侧内容全部一致)'} diff --git a/src/renderer/src/components/Toolbar.test.tsx b/src/renderer/src/components/Toolbar.test.tsx index a38d5aa..c36ebb3 100644 --- a/src/renderer/src/components/Toolbar.test.tsx +++ b/src/renderer/src/components/Toolbar.test.tsx @@ -9,6 +9,7 @@ function setup(extra?: { computing?: boolean charMode?: boolean charModeDowngraded?: boolean + charModePartial?: boolean baseOptions?: Partial onOptionBlocked?: (label: string) => void }) { @@ -39,6 +40,7 @@ function setup(extra?: { onSwap={onSwap} onOptionBlocked={extra?.onOptionBlocked} charModeDowngraded={extra?.charModeDowngraded ?? false} + charModePartial={extra?.charModePartial ?? false} /> ) return { onOptionsChange, onNav, onExport, onOnlyDiffChange, onSwap, ...utils } @@ -133,6 +135,25 @@ describe('Toolbar', () => { expect(screen.getByText('已降级')).toBeInTheDocument() }) + it('字符级部分降级时显示“部分降级”标记(不出现整体降级标记)', () => { + setup({ charMode: true, charModePartial: true }) + expect(screen.getByText('部分降级')).toBeInTheDocument() + expect(screen.queryByText('已降级')).not.toBeInTheDocument() + }) + + it('整体降级优先呈现:同时标记时只显示“已降级”', () => { + setup({ charMode: true, charModePartial: true, charModeDowngraded: true }) + expect(screen.getByText('已降级')).toBeInTheDocument() + expect(screen.queryByText('部分降级')).not.toBeInTheDocument() + }) + + it('字符级关闭或未降级时不显示“部分降级”标记', () => { + setup({ charMode: true }) + expect(screen.queryByText('部分降级')).not.toBeInTheDocument() + setup({ charModePartial: true }) + expect(screen.queryByText('部分降级')).not.toBeInTheDocument() + }) + it('未降级或字符级关闭时不显示“已降级”标记', () => { setup({ charMode: true }) expect(screen.queryByText('已降级')).not.toBeInTheDocument() diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index 78e5882..0dbbca4 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -28,6 +28,8 @@ interface ToolbarProps { onOptionBlocked?: (label: string) => void /** 字符级对比因内容超限自动降级为行级(开关旁显示“已降级”标记) */ charModeDowngraded?: boolean + /** 字符级对比部分降级(部分超限组按行级,其余组仍字符级;显示“部分降级”标记) */ + charModePartial?: boolean } export default function Toolbar({ @@ -43,7 +45,8 @@ export default function Toolbar({ onOnlyDiffChange, onSwap, onOptionBlocked, - charModeDowngraded = false + charModeDowngraded = false, + charModePartial = false }: ToolbarProps): ReactElement { const set = (patch: Partial): void => onOptionsChange({ ...options, ...patch }) const [exportOpen, setExportOpen] = useState(false) @@ -145,6 +148,14 @@ export default function Toolbar({ 已降级 )} + {options.charMode && !charModeDowngraded && charModePartial && ( + + 部分降级 + + )} diff --git a/src/renderer/src/components/folderTree.test.ts b/src/renderer/src/components/folderTree.test.ts index 32634e8..d2e9598 100644 --- a/src/renderer/src/components/folderTree.test.ts +++ b/src/renderer/src/components/folderTree.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from 'vitest' import type { FolderEntry } from '../../../preload/index' -import { buildFolderTree, filterEntries, flattenFolderTree, collectEntries } from './folderTree' +import { + buildFolderTree, + filterEntries, + flattenFolderTree, + collectEntries, + relExt +} from './folderTree' const E: FolderEntry[] = [ { rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 }, @@ -108,6 +114,58 @@ describe('filterEntries', () => { }) }) +describe('filterEntries - 扩展名过滤', () => { + const X: FolderEntry[] = [ + { rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 }, + { rel: 'b.TXT', status: 'different', leftSize: 1, rightSize: 2 }, + { rel: 'sub/x.ts', status: 'different', leftSize: 1, rightSize: 2 }, + { rel: 'noext', status: 'left-only', leftSize: 1, rightSize: null }, + { rel: 'd/noext2', status: 'right-only', leftSize: null, rightSize: 1 } + ] + + it('relExt 取文件名段扩展名(小写;无扩展名为空串;不误取目录名中的点)', () => { + expect(relExt('a.txt')).toBe('txt') + expect(relExt('A.TXT')).toBe('txt') + expect(relExt('sub/x.ts')).toBe('ts') + expect(relExt('noext')).toBe('') + expect(relExt('v1.2.3/md')).toBe('') + expect(relExt('archive.tar.gz')).toBe('gz') + }) + + it('exts 非空时只保留集合内扩展名(大小写不敏感,无扩展名归空串类目)', () => { + const out = filterEntries(X, { + onlyDiff: false, + statusFilter: null, + query: '', + exts: new Set(['txt']) + }) + expect(out.map((e) => e.rel)).toEqual(['a.txt', 'b.TXT']) + const noExt = filterEntries(X, { + onlyDiff: false, + statusFilter: null, + query: '', + exts: new Set(['']) + }) + expect(noExt.map((e) => e.rel)).toEqual(['noext', 'd/noext2']) + }) + + it('多选扩展名取并集,与其他条件取交集(onlyDiff + exts)', () => { + const out = filterEntries(X, { + onlyDiff: true, + statusFilter: null, + query: '', + exts: new Set(['txt', 'ts']) + }) + // onlyDiff 过滤掉 same 的 a.txt,剩 b.TXT 与 sub/x.ts + expect(out.map((e) => e.rel)).toEqual(['b.TXT', 'sub/x.ts']) + }) + + it('exts 为 null 或缺省时不过滤(向后兼容)', () => { + expect(filterEntries(X, { onlyDiff: false, statusFilter: null, query: '', exts: null })).toHaveLength(5) + expect(filterEntries(X, { onlyDiff: false, statusFilter: null, query: '' })).toHaveLength(5) + }) +}) + describe('collectEntries', () => { it('收集子树全部文件条目', () => { const root = buildFolderTree(E) diff --git a/src/renderer/src/components/folderTree.ts b/src/renderer/src/components/folderTree.ts index 5bb37a5..e3f9213 100644 --- a/src/renderer/src/components/folderTree.ts +++ b/src/renderer/src/components/folderTree.ts @@ -47,15 +47,26 @@ export interface EntryFilter { statusFilter: Set | null /** 相对路径子串搜索(不区分大小写;空串不过滤) */ query: string + /** 扩展名精确过滤(null 为不过滤;非空时只保留集合内扩展名,与其他条件取交集) */ + exts?: Set | null } -/** 按视图条件过滤条目(文件层过滤,树构建之前执行) */ +/** 条目的扩展名(小写不含点;仅取文件名段,无扩展名为空串——无扩展名文件归独立类目) */ +export function relExt(rel: string): string { + const name = rel.slice(rel.lastIndexOf('/') + 1) + const dot = name.lastIndexOf('.') + if (dot < 0) return '' + return name.slice(dot + 1).toLowerCase() +} + +/** 按视图条件过滤条目(文件层过滤,树构建之前执行;全部条件取交集) */ export function filterEntries(entries: FolderEntry[], f: EntryFilter): FolderEntry[] { const q = f.query.trim().toLowerCase() return entries.filter((e) => { if (f.onlyDiff && (e.status === 'same' || e.status === 'semantic-same')) return false if (f.statusFilter !== null && !f.statusFilter.has(e.status)) return false if (q !== '' && !e.rel.toLowerCase().includes(q)) return false + if (f.exts != null && !f.exts.has(relExt(e.rel))) return false return true }) } diff --git a/src/renderer/src/diff/diffEngine.test.ts b/src/renderer/src/diff/diffEngine.test.ts index 9cd2a45..c85b258 100644 --- a/src/renderer/src/diff/diffEngine.test.ts +++ b/src/renderer/src/diff/diffEngine.test.ts @@ -564,9 +564,9 @@ describe('computeDiff - 字符级对比(分块锚定)', () => { expect([...rNos].sort((a, b) => a - b)).toEqual(rNos) }) - it('变更组总流超全局上限仍降级(每组在限内但合计超限)', () => { - // 8 个变更组,每组流 6500 + 6501 = 13001(在单组上限内,编辑距离 1), - // 合计 104008 > CHAR_DIFF_GROUP_TOTAL_CHARS(100000)→ 整体降级 + it('组总流超预算:预算内组字符级、预算外组行级降级(部分降级,不再整体降级)', async () => { + // 8 个变更组,每组流 6500 + 6501 = 13001(在单组上限内,编辑距离 1): + // 前 7 组消耗 91007,第 8 组 13001 > 剩余 8993 → 行级配对(partial) const build = (withX: boolean): string => { const lines: string[] = [] for (let i = 0; i < 8; i++) { @@ -576,7 +576,119 @@ describe('computeDiff - 字符级对比(分块锚定)', () => { return lines.join('\n') } const result = computeDiff(build(false), build(true), { charMode: true }) + // 部分降级:不再整体降级 + expect(result.charModeDowngraded).toBeUndefined() + expect(result.charModePartial).toBe(true) + // 8 个组各 1:1 行配对输出 modified:前 7 组字符级(segs 字符粒度), + // 第 8 组行级配对(超长行 > 5000 触发词级防护,segs 为 null,行仍 modified) + expect(result.summary.modified).toBe(8) + expect(result.summary.deleted).toBe(0) + expect(result.summary.inserted).toBe(0) + const mods = result.rows.filter((r) => r.rowKind === 'modified') + expect(mods).toHaveLength(8) + // 前 7 组字符级 segs 拼接无损;第 8 组词级防护 segs 为 null + const noSegs = mods.filter((m) => m.left.segs === null) + expect(noSegs).toHaveLength(1) + for (const m of mods) { + if (m.left.segs === null) continue + expect(joinSegs(m.left.segs)).toBe(m.left.text) + expect(joinSegs(m.right.segs)).toBe(m.right.text) + } + // 行号单调 + const lNos = result.rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null) + const rNos = result.rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null) + expect([...lNos].sort((a, b) => a - b)).toEqual(lNos) + expect([...rNos].sort((a, b) => a - b)).toEqual(rNos) + }) + + it('单组流超上限:仅该组行级降级,其余组字符级(partial)', () => { + // mid 锚行分隔两个变更组:组 1(cat↔cap)流小字符级;组 2 单行全差异 50002 > 上限 → 行级 + const left = ['head', 'cat', 'mid', 'a'.repeat(25001)] + const right = ['head', 'cap', 'mid', 'b'.repeat(25001)] + const result = computeDiff(left.join('\n'), right.join('\n'), { charMode: true }) + expect(result.charModeDowngraded).toBeUndefined() + expect(result.charModePartial).toBe(true) + // 组 1(cat↔cap)字符级精确定位:1 个 modified,字符粒度高亮 + const cat = result.rows.find( + (r) => r.rowKind === 'modified' && r.left.text === 'cat' + )! + expect(cat.right.text).toBe('cap') + expect(cat.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('t'))).toBe(true) + expect(cat.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('p'))).toBe(true) + // 组 2 行级配对:a…a ↔ b…b 配 modified(25001 > 5000 触发词级防护,segs 为 null) + const big = result.rows.find( + (r) => r.rowKind === 'modified' && (r.left.text ?? '').startsWith('a') + )! + expect(big.right.text).toBe('b'.repeat(25001)) + expect(big.left.segs).toBeNull() + // 锚行保持 unchanged(head 与 mid) + expect(result.rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(2) + }) + + it('单组编辑距离超限:仅该组行级降级,其余组字符级(partial,预算未耗尽)', () => { + // charDiffMaxEdit 注入 10;mid 锚行分隔:组 1(cat↔cap)编辑距离 1 成功; + // 组 2(a*2000 ↔ b*2000)编辑距离 4000 超限 → 行级 + const left = ['x', 'cat', 'mid', 'a'.repeat(2000)] + const right = ['x', 'cap', 'mid', 'b'.repeat(2000)] + const result = computeDiff(left.join('\n'), right.join('\n'), { + charMode: true, + charDiffMaxEdit: 10 + }) + expect(result.charModeDowngraded).toBeUndefined() + expect(result.charModePartial).toBe(true) + // 组 1 字符级完成(cat↔cap 1 个 modified) + const cat = result.rows.find((r) => r.rowKind === 'modified' && r.left.text === 'cat')! + expect(cat.right.text).toBe('cap') + expect(cat.left.segs!.some((s) => s.kind === 'delete')).toBe(true) + // 组 2 行级配对 modified(词级 segs) + const big = result.rows.find( + (r) => r.rowKind === 'modified' && (r.left.text ?? '').startsWith('a') + )! + expect(big.right.text).toBe('b'.repeat(2000)) + }) + + it('全部组超限:整体降级行级(charModeDowngraded,不出现 partial)', () => { + // 两个组都单行全差异超 5 万 → 零组完成字符级 → 整体降级 + const half = 25001 + const left = ['anchor', 'a'.repeat(half), 'mid', 'c'.repeat(half)] + const right = ['anchor', 'b'.repeat(half), 'mid', 'd'.repeat(half)] + const result = computeDiff(left.join('\n'), right.join('\n'), { charMode: true }) expect(result.charModeDowngraded).toBe(true) + expect(result.charModePartial).toBeUndefined() + // 降级结果与等效行级选项直接计算一致 + const direct = computeDiff(left.join('\n'), right.join('\n'), { + ignoreAllWhitespace: true, + ignoreBlankLines: true + }) + expect(result.summary).toEqual(direct.summary) + }) + + it('行级降级组内行数不等:相邻块按序配对,多余侧 added/removed', () => { + // mid 锚行分隔:组 1(small↔small)字符级保底; + // 组 2 左 [x*15000, only] 右 [y*15000, r1, r2]——组流 3 万在限内但编辑距离 + // 超 5000(x…↔y… 全替换)→ 行级配对:x…↔y… 与 only↔r1 配 modified,r2 多余 added + const left = ['head', 'small-l', 'mid', 'x'.repeat(15000), 'only-left-line'] + const right = ['head', 'small-r', 'mid', 'y'.repeat(15000), 'r-line-1', 'r-line-2'] + const result = computeDiff(left.join('\n'), right.join('\n'), { charMode: true }) + expect(result.charModeDowngraded).toBeUndefined() + expect(result.charModePartial).toBe(true) + const mods = result.rows.filter((r) => r.rowKind === 'modified') + // 组 1 small 配对 + 组 2 两对(x…↔y…、only↔r1) + expect(mods).toHaveLength(3) + expect(mods.some((m) => m.left.text === 'small-l' && m.right.text === 'small-r')).toBe(true) + expect(mods.some((m) => (m.left.text ?? '').startsWith('x'))).toBe(true) + expect(mods.some((m) => m.left.text === 'only-left-line' && m.right.text === 'r-line-1')).toBe(true) + // 多余右侧 r2 → added;无 removed + const added = result.rows.filter((r) => r.rowKind === 'added') + expect(added.map((r) => r.right.text)).toEqual(['r-line-2']) + expect(result.summary.deleted).toBe(0) + // 锚行 head/mid unchanged + expect(result.rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(2) + // 行号单调 + const lNos = result.rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null) + const rNos = result.rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null) + expect([...lNos].sort((a, b) => a - b)).toEqual(lNos) + expect([...rNos].sort((a, b) => a - b)).toEqual(rNos) }) it('跨行重组落在变更组内:组内字符流判等,锚行 zip 输出', () => { diff --git a/src/renderer/src/diff/diffEngine.ts b/src/renderer/src/diff/diffEngine.ts index f4cd68a..1e6d569 100644 --- a/src/renderer/src/diff/diffEngine.ts +++ b/src/renderer/src/diff/diffEngine.ts @@ -63,6 +63,12 @@ export interface DiffResult { summary: DiffSummary /** 字符级对比因内容超限自动降级为行级对比时为 true(供界面与报告提示) */ charModeDowngraded?: boolean + /** + * 字符级对比部分降级:存在完成字符级计算的变更组,也有超限按行级配对的变更组 + * (逐组预算制:单组超 CHAR_DIFF_MAX_CHARS / 编辑距离超限 / 总预算耗尽的组行级降级, + * 其余组仍字符级)。与 charModeDowngraded 互斥(全部组降级时只置后者)。 + */ + charModePartial?: boolean } function emptyLine(): SideCell { @@ -309,8 +315,9 @@ export const CHAR_DIFF_MAX_CHARS = 50000 export const CHAR_DIFF_MAX_EDIT = 5000 /** - * 全部变更组归一化流的总字符上限(两侧合计):兜底“组数多且总量大”的累计耗时 - * (每组最坏约 5 秒,2 组满载 ≈ 10 秒,在 worker 内计算不冻结界面)。 + * 变更组字符级计算的总预算(全部成功组两侧归一化流合计):预算制约束累计耗时 + * (每组最坏约 5 秒,预算内组先到先得),耗尽后的组按行级配对输出(部分降级), + * 不再牵连其余组。全部组都降级时才整体降级行级。 */ export const CHAR_DIFF_GROUP_TOTAL_CHARS = CHAR_DIFF_MAX_CHARS * 2 @@ -406,10 +413,10 @@ function charSegs(text: string, stat: Map | undefined): Seg[] { * 因此归一化流总量不再受单次 diffChars 上限约束(大文件字符级不再整篇受限)。 * * 第二阶段:连续的 removed/added 行块合为一个“变更组”,组内左右行拼接为归一化 - * 字符流送 diffChars(差异以字符粒度回映射到行)。约束:单个变更组流不超过 - * CHAR_DIFF_MAX_CHARS、编辑距离不超过 CHAR_DIFF_MAX_EDIT、全部变更组流总量不超过 - * CHAR_DIFF_GROUP_TOTAL_CHARS——任一超限返回 null,由调用方降级行级(整篇重排或 - * 大改场景,字符高亮本就失去可读性)。 + * 字符流送 diffChars(差异以字符粒度回映射到行)。逐组判定 + 预算制:组流不超过 + * CHAR_DIFF_MAX_CHARS、编辑距离不超过 CHAR_DIFF_MAX_EDIT 且在总预算内的组走字符级; + * 超限/预算耗尽的组按行级配对输出(部分降级,置 charModePartial);全部组降级 + * 才返回 null 由调用方整体降级(置 charModeDowngraded)。 * * 无任何锚行时(两侧行归一化完全无交集)唯一变更组即全文,行为与整篇流对比同构。 */ @@ -469,7 +476,7 @@ function computeCharDiff( } } - // ===== 第二阶段:变更组字符流 diff(先全部预检,任一超限整体降级)===== + // ===== 第二阶段:变更组字符流 diff(逐组判定 + 预算制部分降级)===== const streams = groups.map((g) => ({ ls: groupStream(leftOrig, g.l, normCh), rs: groupStream(rightOrig, g.r, normCh), @@ -477,25 +484,37 @@ function computeCharDiff( gl: new Set(g.l.map((i) => i + 1)), gr: new Set(g.r.map((i) => i + 1)) })) - let totalStream = 0 + /** + * 逐组判定 + 预算制:组流 ≤ CHAR_DIFF_MAX_CHARS 且在剩余预算内、编辑距离 ≤ 上限的组 + * 走 diffChars(字符级);单组超限 / 编辑距离超限 / 预算耗尽的组仅按行级配对输出 + * (不再牵连其余组)。预算只被完成字符级计算的组消耗,最坏总耗时仍有界。 + * 全部组都降级时返回 null 由调用方整体降级(等价旧行为)。 + */ + const groupParts: Array = [] + let budget = CHAR_DIFF_GROUP_TOTAL_CHARS + let anyChar = false for (const { ls, rs } of streams) { - totalStream += ls.text.length + rs.text.length - } - if (totalStream > CHAR_DIFF_GROUP_TOTAL_CHARS) return null - const groupParts: Change[][] = [] - for (const { ls, rs } of streams) { - if (ls.text.length + rs.text.length > CHAR_DIFF_MAX_CHARS) return null - // maxEditLength 封顶 Myers 迭代轮数(编辑距离),防止 O(ND) 无界计算卡死; - // jsdiff 超限时运行时返回 undefined(类型签名未标注,真值判断兜底) - const parts = diffChars(ls.text, rs.text, { - maxEditLength: options.charDiffMaxEdit ?? CHAR_DIFF_MAX_EDIT - }) - if (!parts) return null - groupParts.push(parts) + const n = ls.text.length + rs.text.length + if (n <= CHAR_DIFF_MAX_CHARS && n <= budget) { + // maxEditLength 封顶 Myers 迭代轮数(编辑距离),防止 O(ND) 无界计算卡死; + // jsdiff 超限时运行时返回 undefined(类型签名未标注,真值判断兜底) + const parts = diffChars(ls.text, rs.text, { + maxEditLength: options.charDiffMaxEdit ?? CHAR_DIFF_MAX_EDIT + }) + if (parts) { + groupParts.push(parts) + budget -= n + anyChar = true + continue + } + } + groupParts.push(null) } + if (groups.length > 0 && !anyChar) return null - // ===== 按时间线输出:锚行 + 变更组回映射 ===== + // ===== 按时间线输出:锚行 + 变更组回映射(字符级组 / 行级降级组分发)===== const rows: DiffRow[] = [] + const lineLevelGroups = new Set() for (const item of timeline) { if (item.kind === 'anchor') { rows.push({ @@ -507,7 +526,13 @@ function computeCharDiff( }) } else { const { ls, rs, gl, gr } = streams[item.g] - emitGroupRows(ls, rs, leftOrig, rightOrig, groupParts[item.g], rows, gl, gr) + const parts = groupParts[item.g] + if (parts) { + emitGroupRows(ls, rs, leftOrig, rightOrig, parts, rows, gl, gr) + } else { + lineLevelGroups.add(item.g) + emitGroupLineLevel(groups[item.g], leftOrig, rightOrig, normLine, rows) + } } } @@ -518,7 +543,128 @@ function computeCharDiff( modified: rows.filter((r) => r.rowKind === 'modified').length } - return { rows, summary } + return { + rows, + summary, + // 存在行级降级组且也有字符级组:部分降级(供界面与报告提示) + ...(lineLevelGroups.size > 0 ? { charModePartial: true } : {}) + } +} + +/** + * 行级降级组的输出:组内左右行以行归一化(字符级语义:剔除全部空白 + 可选小写) + * 做行级 LCS 配对——判等行配 unchanged;相邻删除/新增块配 modified(词级高亮, + * 与行级路径的 commitModified 同构,含超长行跳过词级防护);多余侧降级 removed/added。 + * 供字符级对比的逐组预算制降级(超限组不再牵连其余组,也不整体降级)。 + */ +function emitGroupLineLevel( + g: { l: number[]; r: number[] }, + leftOrig: string[], + rightOrig: string[], + normLine: (s: string) => string, + rows: DiffRow[] +): void { + const leftLines = g.l.map((i) => ({ text: leftOrig[i], lineNo: i + 1 })) + const rightLines = g.r.map((i) => ({ text: rightOrig[i], lineNo: i + 1 })) + const parts = diffArrays( + leftLines.map((p) => normLine(p.text)), + rightLines.map((p) => normLine(p.text)) + ) + let li = 0 + let ri = 0 + let queuedRemoved: NumberedLine[] = [] + + const flushRemoved = (): void => { + for (const r of queuedRemoved) { + rows.push({ + id: `r${rows.length}`, + rowKind: 'removed', + isChanged: true, + left: { lineNo: r.lineNo, text: r.text, segs: null }, + right: emptyLine() + }) + } + queuedRemoved = [] + } + + const commitModified = (addedRows: NumberedLine[]): void => { + const count = Math.max(queuedRemoved.length, addedRows.length) + for (let i = 0; i < count; i++) { + const l = queuedRemoved[i] + const r = addedRows[i] + if (l && r) { + const skipWordDiff = + l.text.length > WORD_DIFF_MAX_LEN || r.text.length > WORD_DIFF_MAX_LEN + const { left: lSegs, right: rSegs } = skipWordDiff + ? { left: null, right: null } + : wordSegments(l.text, r.text) + rows.push({ + id: `r${rows.length}`, + rowKind: 'modified', + isChanged: true, + left: { lineNo: l.lineNo, text: l.text, segs: lSegs }, + right: { lineNo: r.lineNo, text: r.text, segs: rSegs } + }) + } else if (l) { + rows.push({ + id: `r${rows.length}`, + rowKind: 'removed', + isChanged: true, + left: { lineNo: l.lineNo, text: l.text, segs: null }, + right: emptyLine() + }) + } else if (r) { + rows.push({ + id: `r${rows.length}`, + rowKind: 'added', + isChanged: true, + left: emptyLine(), + right: { lineNo: r.lineNo, text: r.text, segs: null } + }) + } + } + queuedRemoved = [] + } + + for (const part of parts) { + const value = part.value as string[] + if (part.removed) { + for (let i = 0; i < value.length; i++) queuedRemoved.push(leftLines[li + i]) + li += value.length + } else if (part.added) { + const added: NumberedLine[] = [] + for (let i = 0; i < value.length; i++) added.push(rightLines[ri + i]) + ri += value.length + if (queuedRemoved.length > 0) commitModified(added) + else { + for (const r of added) { + rows.push({ + id: `r${rows.length}`, + rowKind: 'added', + isChanged: true, + left: emptyLine(), + right: { lineNo: r.lineNo, text: r.text, segs: null } + }) + } + } + } else { + if (queuedRemoved.length > 0) flushRemoved() + for (let i = 0; i < value.length; i++) { + const lp = leftLines[li + i] + const rp = rightLines[ri + i] + rows.push({ + id: `r${rows.length}`, + rowKind: 'unchanged', + isChanged: false, + left: { lineNo: lp.lineNo, text: lp.text, segs: null }, + right: { lineNo: rp.lineNo, text: rp.text, segs: null } + }) + } + li += value.length + ri += value.length + } + } + if (queuedRemoved.length > 0) flushRemoved() } /** diff --git a/src/renderer/src/diff/report.test.ts b/src/renderer/src/diff/report.test.ts index 851c1eb..f0e34d5 100644 --- a/src/renderer/src/diff/report.test.ts +++ b/src/renderer/src/diff/report.test.ts @@ -195,6 +195,17 @@ describe('plainOptions - 比较选项描述', () => { '字符级对比(忽略全部空白与换行,大内容自动分块计算)、忽略大小写' ) }) + + it('字符级部分降级时描述标注部分块按行级对比', () => { + expect(plainOptions({ charMode: true }, { charModePartial: true })).toBe( + '字符级对比(忽略全部空白与换行;部分差异过大的块按行级对比)' + ) + expect(plainOptions({ charMode: true, ignoreCase: true }, { charModePartial: true })).toBe( + '字符级对比(忽略全部空白与换行;部分差异过大的块按行级对比)、忽略大小写' + ) + // 未开启字符级时 partial 参数无效果 + expect(plainOptions({}, { charModePartial: true })).toBe('无(严格逐字符对比)') + }) }) describe('报告头部比较选项说明', () => { diff --git a/src/renderer/src/diff/report.ts b/src/renderer/src/diff/report.ts index 2ad312d..25a9975 100644 --- a/src/renderer/src/diff/report.ts +++ b/src/renderer/src/diff/report.ts @@ -17,6 +17,8 @@ export interface ReportContext { rightPath?: string /** 本次对比生效的比较选项(用于报告头部说明,可选) */ options?: DiffOptions + /** 字符级对比部分降级(部分超限块按行级配对,其余仍字符级;头部说明标注) */ + charModePartial?: boolean } /** HTML 转义(文件夹报告复用) */ @@ -120,11 +122,18 @@ export function plainSummary(summary: DiffSummary): string { } /** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */ -export function plainOptions(options?: DiffOptions): string { +export function plainOptions( + options?: DiffOptions, + extra?: { charModePartial?: boolean } +): string { // 字符级对比为最强空白语义(全部空白与换行都不参与判等),单独描述并遮蔽空白类子选项; - // 大内容按“行锚定 + 变更组分块”计算,不再整篇受字符量上限约束 + // 大内容按“行锚定 + 变更组分块”计算,不再整篇受字符量上限约束; + // 部分降级(逐组预算制)时标注部分差异过大的块按行级对比 if (options?.charMode) { - const on = ['字符级对比(忽略全部空白与换行,大内容自动分块计算)'] + const base = extra?.charModePartial + ? '字符级对比(忽略全部空白与换行;部分差异过大的块按行级对比)' + : '字符级对比(忽略全部空白与换行,大内容自动分块计算)' + const on = [base] if (options.ignoreCase) on.push('忽略大小写') return on.join('、') } @@ -149,7 +158,7 @@ const headLines = ( ctx.rightPath ? ` [${ctx.rightPath}]` : '' }`, `对比时间:${new Date().toLocaleString('zh-CN')}`, - `比较选项:${plainOptions(ctx.options)}`, + `比较选项:${plainOptions(ctx.options, { charModePartial: ctx.charModePartial })}`, `结论:${plainSummary(summary)}`, `变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}` ] @@ -481,7 +490,7 @@ export function buildHtmlReport(
🔍 DiffLens 差异报告
生成于 ${new Date().toLocaleString('zh-CN')}
-
比较选项:${esc(plainOptions(ctx.options))}
+
比较选项:${esc(plainOptions(ctx.options, { charModePartial: ctx.charModePartial }))}
${esc(plainSummary(summary))}
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)} diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 7c1a88a..f9d1a5b 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -640,6 +640,41 @@ button:disabled { bottom: calc(100% + 6px); } +/* ============ 扩展名过滤下拉(folder-toolbar 内,向下弹出) ============ */ +.export-menu.ext-menu { + min-width: 190px; + max-height: 320px; + overflow: auto; +} + +/* 激活(已选入过滤集合)的扩展名项:与状态徽章激活态同语言 */ +.ctx-item.active { + color: #fff; + background: var(--surface-hover); +} + +/* 扩展名项内右侧的计数(等宽数字,弱化) */ +.ctx-item .ext-count { + float: right; + color: var(--muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.ctx-item.ext-clear { + color: var(--accent-2); + border-top: 1px solid var(--border); + border-radius: 0 0 6px 6px; + margin-top: 3px; +} + +/* 过滤激活时的入口按钮强调(与常规 ghost 区分,提示过滤在生效) */ +.btn.ghost.filtered { + color: var(--accent-2); + border-color: rgba(34, 211, 238, 0.4); + background: rgba(34, 211, 238, 0.07); +} + /* ============ toast 队列(最多 3 条堆叠,各自独立计时) ============ */ .toast-wrap { position: fixed;