feat: 0.8.0 文件夹对比深度增强与字符级逐组预算制 - 按扩展名多选过滤(第四维过滤与其他条件取交集、N/总数计数切换)、目录行双击聚合对比(差异导航限定子树内批量审查、双击文件回全局、空差异提示与折叠态自愈)、增量重扫(size+mtime指纹一致零IO沿用、语义开关翻转按semSame纯内存组合零IO、unreadable不缓存与截断弃缓存、重新扫描按钮)、字符级变更组逐组预算制(超限组仅行级配对输出不再牵连其余组、charModePartial部分降级标记与报告说明、全部组降级仍整体降级)、拖拽导入E2E(dropFiles合成DataTransfer覆盖decodeBuffer IPC全链路4用例)、文档与版本号同步

This commit is contained in:
2026-08-18 22:02:37 +08:00
parent 494b2ad6ea
commit fa13160848
23 changed files with 1691 additions and 116 deletions
+4 -4
View File
@@ -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
---
+7 -1
View File
@@ -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 为部分降级(特性而非回归,测试固化新行为)
- 拖拽导入 E2Ehelpers 增 dropFiles(真实 Chromium DataTransfer/DragEvent 合成拖放,覆盖 dropPane → file.arrayBuffer → decodeBuffer IPC → 编码探测全链路) · 4 用例:空面板拖入渲染、双侧拖入对比、非文本扩展名拒绝、多文件提示取首个
...
0.y.z ← 长期停留,永不进入 1.x
```
+18 -13
View File
@@ -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 并发场景)
---
+90 -1
View File
@@ -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 清掉历史残留,建立干净基线后再写入偏好
+27
View File
@@ -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 → 编码探测。
* 先派发 dragoverpreventDefault 激活 drop 目标)再派发 drop。
*/
export async function dropFiles(
window: Page,
selector: string,
files: { name: string; content: string; type?: string }[]
): Promise<void> {
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 }
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.7.1",
"version": "0.8.0",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
+207 -1
View File
@@ -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<string>
}
}
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<string> => {
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('语义开关翻转零 IOsemSame 非 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-samesemSame = 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')
// 第一次:语义关 → differentsemSame = 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.txtmtime 变化)+ 删除 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<string> => {
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 })
}
})
})
+154 -15
View File
@@ -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<string, number>; truncated: boolean; total: number }> {
): Promise<{ files: Map<string, FileFingerprint>; 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<string, number>()
const files = new Map<string, FileFingerprint>()
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<Buffer> {
* 需读内容的条目按 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<ScanCacheEntry | undefined> = new Array(rels.length)
const tasks: { idx: number; run: () => Promise<void> }[] = []
/** 双侧指纹与缓存条目一致(含单侧缺失形状一致;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[])
}
}
+41 -18
View File
@@ -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) => {
+23 -2
View File
@@ -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<FolderScanResult | FolderScanError> =>
ipcRenderer.invoke('folder:scan', leftDir, rightDir, semantic),
ipcRenderer.invoke('folder:scan', leftDir, rightDir, semantic, previous ?? null),
readByPath: (filePath: string): Promise<FileOpenResult> =>
ipcRenderer.invoke('file:read-by-path', filePath)
}
+77 -18
View File
@@ -188,6 +188,9 @@ export default function App(): ReactElement {
const [folderScanning, setFolderScanning] = useState(false)
// 从文件夹进入单文件对比时的当前条目 rel(差异文件导航的游标;返回/退出文件夹模式时清空)
const [folderEntryCursor, setFolderEntryCursor] = useState<string | null>(null)
// 目录聚合对比的导航范围(双击目录行进入:差异文件导航限定在该子树内;
// 双击任意文件条目回到全局序列;返回/退出文件夹模式时清空)
const [folderNavScope, setFolderNavScope] = useState<string | null>(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<void> => {
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<void> => {
async (entry: FolderEntry, navScope: string | null = null): Promise<void> => {
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 {
</button>
)}
{folderState && folderEntryCursor !== null && diffFileEntries.length > 0 && (
<div className="tool-group diff-file-nav" title="在文件夹的差异文件之间跳转(Alt+↓ / Alt+↑)">
<div
className="tool-group diff-file-nav"
title={
folderNavScope !== null
? `在目录「${folderNavScope}/」的差异文件之间跳转(Alt+↓ / Alt+↑);双击任意文件条目回到全局序列`
: '在文件夹的差异文件之间跳转(Alt+↓ / Alt+↑)'
}
>
<button
className="nav-btn"
title="上一个差异文件(Alt+↑)"
@@ -807,7 +858,9 @@ export default function App(): ReactElement {
>
</button>
<span className="diff-file-label"></span>
<span className="diff-file-label">
{folderNavScope !== null ? ` · ${folderNavScope}/` : ''}
</span>
</div>
)}
<button className="btn ghost" onClick={() => void openPane('left')}>
@@ -872,9 +925,14 @@ export default function App(): ReactElement {
semantic={folderSemantic}
onSemanticChange={handleSemanticChange}
onReopen={() => void startFolderCompare()}
onRescan={() => {
if (folderState)
void runFolderScan(folderState.leftDir, folderState.rightDir, folderSemantic)
}}
onExit={exitFolder}
onExport={(fmt) => void exportFolderReport(fmt)}
onOpenEntry={(e) => void openFolderEntry(e)}
onOpenDir={openDirScope}
/>
) : anyPane ? (
<>
@@ -903,6 +961,7 @@ export default function App(): ReactElement {
showToast(`${label}」已包含在字符级对比中,关闭字符级后可单独设置`)
}
charModeDowngraded={diff?.charModeDowngraded ?? false}
charModePartial={diff?.charModePartial ?? false}
/>
</>
) : (
+224
View File
@@ -774,6 +774,53 @@ describe('App - 字符级对比', () => {
expect(await screen.findByText('已降级')).toBeInTheDocument()
})
it('混合场景部分降级:小差异组字符级 + 超限组行级,标记与提示可见', async () => {
// mid 锚行分隔两组:cat↔cap 字符级;单行全差异超 5 万 → 行级(部分降级)
window.api = mockApi({
openFile: async (side) =>
side === 'left'
? { path: '/tmp/p1.txt', name: 'p1.txt', text: `head\ncat\nmid\n${'a'.repeat(25001)}`, encoding: 'UTF-8', binary: false }
: { path: '/tmp/p2.txt', name: 'p2.txt', text: `head\ncap\nmid\n${'b'.repeat(25001)}`, encoding: 'UTF-8', binary: false }
})
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByText('打开右侧'))
await screen.findAllByText('p2.txt')
fireEvent.click(screen.getByLabelText('字符级对比'))
// 无整体降级;出现部分降级标记与提示
await screen.findByText('有差异')
expect(screen.queryByText(/已自动降级为行级对比/)).not.toBeInTheDocument()
expect(screen.queryByText('已降级')).not.toBeInTheDocument()
expect(await screen.findByText('部分降级')).toBeInTheDocument()
expect(await screen.findByText(/部分差异块过大已按行级对比/)).toBeInTheDocument()
})
it('部分降级时导出报告携带部分降级说明', async () => {
let saved = ''
window.api = mockApi({
openFile: async (side) =>
side === 'left'
? { path: '/tmp/p1.txt', name: 'p1.txt', text: `head\ncat\nmid\n${'a'.repeat(25001)}`, encoding: 'UTF-8', binary: false }
: { path: '/tmp/p2.txt', name: 'p2.txt', text: `head\ncap\nmid\n${'b'.repeat(25001)}`, encoding: 'UTF-8', binary: false },
saveReport: async (content: string) => {
saved = content
return { ok: true, path: 'C:/docs/r.txt' }
}
})
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByText('打开右侧'))
await screen.findAllByText('p2.txt')
fireEvent.click(screen.getByLabelText('字符级对比'))
await screen.findByText('部分降级')
fireEvent.click(screen.getByText('导出报告'))
fireEvent.click(screen.getByText('纯文本'))
await screen.findByText(/报告已保存/)
expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行;部分差异过大的块按行级对比)')
})
it('大文件仅局部差异:字符级分块完成,不降级且精确定位差异', async () => {
// 2000 行 × 50 字符 ≈ 10 万归一化字符(远超单组 5 万上限),仅一行不同:
// 行锚定后唯一变更组只有该行,字符级正常完成(旧实现必降级)
@@ -1498,4 +1545,181 @@ describe('App - 差异文件导航', () => {
fireEvent.keyDown(window, { key: 'ArrowDown', altKey: true })
expect(screen.getByText('导出报告')).toBeInTheDocument()
})
})
describe('App - 增量重扫', () => {
const incEntries = [
{ rel: 'same.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
{ rel: 'diff.txt', status: 'different' as const, leftSize: 3, rightSize: 5 }
]
const incCache = [
{
rel: 'same.txt',
leftFp: { size: 3, mtime: 100 },
rightFp: { size: 3, mtime: 100 },
status: 'same' as const,
semSame: null
},
{
rel: 'diff.txt',
leftFp: { size: 3, mtime: 101 },
rightFp: { size: 5, mtime: 102 },
status: 'different' as const,
semSame: null
}
]
it('同目录重扫(语义切换/重新扫描)携带上次缓存;新目录不带', async () => {
const calls: Array<{ l: string; r: string; s: boolean; p: unknown }> = []
let pick = 0
const dirs = ['C:/l1', 'D:/r1', 'C:/l2', 'D:/r2']
window.api = mockApi({
pickFolder: async () => dirs[pick++] ?? null,
scanFolder: async (l: string, r: string, s: boolean, p?: unknown) => {
calls.push({ l, r, s, p: p ?? null })
return { entries: incEntries, truncated: false, total: 2, cache: incCache }
}
})
render(<App />)
// 首次扫描:无缓存
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('相同 1')
expect(calls).toHaveLength(1)
expect(calls[0].p).toBeNull()
// 语义切换重扫(同目录):携带上次返回的 cache
fireEvent.click(screen.getByLabelText('语义判等'))
await waitFor(() => expect(calls).toHaveLength(2))
expect(calls[1].p).toEqual(incCache)
// 重新扫描按钮(同目录):同样携带
fireEvent.click(screen.getByRole('button', { name: '重新扫描' }))
await waitFor(() => expect(calls).toHaveLength(3))
expect(calls[2].p).toEqual(incCache)
// 重新选择新目录:不携带缓存
fireEvent.click(screen.getByText('重新选择'))
await screen.findByText('相同 1')
await waitFor(() => expect(calls).toHaveLength(4))
expect(calls[3].p).toBeNull()
})
it('mock 结果未携带 cache 字段时重扫传 null(向后兼容)', async () => {
const calls: Array<unknown> = []
const dirs = ['C:/l1', 'D:/r1']
let pick = 0
window.api = mockApi({
pickFolder: async () => dirs[pick++] ?? null,
scanFolder: async (_l: unknown, _r: unknown, _s: unknown, p?: unknown) => {
calls.push(p ?? null)
// 模拟旧版扫描结果:无 cache 字段
return { entries: incEntries, truncated: false, total: 2 }
}
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('相同 1')
fireEvent.click(screen.getByRole('button', { name: '重新扫描' }))
await waitFor(() => expect(calls).toHaveLength(2))
expect(calls[1]).toBeNull()
})
})
describe('App - 目录聚合对比(双击目录行)', () => {
/** sub 内两个差异(b.txt/sub/x.ts+ 根层一个差异(c.txt+ sub 内相同与语义同 */
const scopeEntries = [
{ rel: 'a.txt', status: 'same' as const, leftSize: 1, rightSize: 1 },
{ rel: 'b.txt', status: 'different' as const, leftSize: 1, rightSize: 2 },
{ rel: 'sub/x.ts', status: 'different' as const, leftSize: 1, rightSize: 2 },
{ rel: 'sub/s.txt', status: 'semantic-same' as const, leftSize: 1, rightSize: 2 },
{ rel: 'sub/deep/y.md', status: 'different' as const, leftSize: 1, rightSize: 2 },
{ rel: 'c.txt', status: 'left-only' as const, leftSize: 1, rightSize: null },
{ rel: 'empty-dir-not-listed', status: 'same' as const, leftSize: 1, rightSize: 1 }
]
/** 进入文件夹模式并双击目录 sub(返回进入后的辅助) */
async function enterAndOpenDir(dirName: string) {
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: scopeEntries, truncated: false, total: 7 }),
readByPath: async (p: string) => ({
path: p,
name: p.split('/').pop() ?? 'f',
text: `content of ${p}`,
encoding: 'UTF-8',
binary: false
})
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText(dirName)
fireEvent.dblClick(screen.getByText(dirName))
// scope 激活后导航标签为「差异文件 · <dir>/」,用正则匹配两种形态
await screen.findByText(/差异文件/)
}
const leftPaneFile = (): string =>
Array.from(document.querySelectorAll('.pane-file')).map((e) => e.textContent)[0] ?? ''
it('双击目录行:导航限定子树(语义同不计入),打开首个差异文件并标注范围', async () => {
await enterAndOpenDir('sub')
// sub 子树差异 = x.ts 与 deep/y.mds 语义同不计入)→ 1/2,标签带范围
expect(screen.getByText('1 / 2')).toBeInTheDocument()
expect(screen.getByText(/差异文件 · sub\//)).toBeInTheDocument()
await waitFor(() => expect(leftPaneFile()).toBe('x.ts'))
// 根层差异 c.txt 不在序列内:下一个到 deep/y.md 后再下一个禁用
fireEvent.click(screen.getByTitle('下一个差异文件(Alt+↓)'))
await waitFor(() => expect(leftPaneFile()).toBe('y.md'))
expect(screen.getByText('2 / 2')).toBeInTheDocument()
expect(screen.getByTitle('下一个差异文件(Alt+↓)')).toBeDisabled()
})
it('聚合范围内 Alt+↓/Alt+↑ 导航保持范围不漂移', async () => {
await enterAndOpenDir('sub')
fireEvent.keyDown(window, { key: 'ArrowDown', altKey: true })
await waitFor(() => expect(leftPaneFile()).toBe('y.md'))
expect(screen.getByText('2 / 2')).toBeInTheDocument()
expect(screen.getByText(/差异文件 · sub\//)).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'ArrowUp', altKey: true })
await waitFor(() => expect(leftPaneFile()).toBe('x.ts'))
expect(screen.getByText('1 / 2')).toBeInTheDocument()
})
it('双击文件条目回到全局序列(范围清除)', async () => {
await enterAndOpenDir('sub')
expect(screen.getByText(/差异文件 · sub\//)).toBeInTheDocument()
// 返回文件夹列表,双击根层文件 c.txt:回全局序列(b/c/x/y = 4 个差异)
fireEvent.click(screen.getByText('← 返回文件夹对比'))
await screen.findByText('相同 2')
fireEvent.dblClick(screen.getByText('c.txt'))
await screen.findByText('差异文件')
await waitFor(() => expect(leftPaneFile()).toBe('c.txt'))
// 全局序列按 mock 条目顺序(真实扫描为字典序):b/x.ts/y.md/c.txt → c.txt 为第 4 个
expect(screen.getByText('4 / 4')).toBeInTheDocument()
// 标签不再带子目录范围
expect(screen.queryByText(/差异文件 · sub\//)).not.toBeInTheDocument()
})
it('目录内没有差异文件时提示且不进入单文件对比', async () => {
const entries = [
{ rel: 'b.txt', status: 'different' as const, leftSize: 1, rightSize: 2 },
{ rel: 'nodiff/a.txt', status: 'same' as const, leftSize: 1, rightSize: 1 },
{ rel: 'nodiff/s.txt', status: 'semantic-same' as const, leftSize: 1, rightSize: 2 }
]
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries, truncated: false, total: 3 })
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('b.txt')
// 默认仅看差异:nodiff 目录被剪枝不可见;关闭后可见
fireEvent.click(screen.getByLabelText('仅看差异'))
expect(screen.getByText('nodiff')).toBeInTheDocument()
fireEvent.dblClick(screen.getByText('nodiff'))
// 无差异文件:提示且停留在文件夹列表(不进入单文件对比)
expect(await screen.findByText(/目录「nodiff」内没有差异文件/)).toBeInTheDocument()
expect(screen.queryByText('← 返回文件夹对比')).not.toBeInTheDocument()
})
})
@@ -30,9 +30,11 @@ function renderView(
semantic={semantic}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
}
@@ -77,6 +79,42 @@ describe('FolderView - 树形渲染', () => {
expect(screen.getByText('diff.ts')).toBeInTheDocument()
})
it('双击目录行触发 onOpenDir(携带目录相对路径)', () => {
const onOpenDir = vi.fn()
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={6}
scanning={false}
onlyDiff={false}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={onOpenDir}
/>
)
fireEvent.dblClick(screen.getByText('sub'))
expect(onOpenDir).toHaveBeenCalledWith('sub')
})
it('双击目录行前该目录处于折叠态:进入时自动展开(消除双击 toggle 残留)', () => {
renderView(ENTRIES, false)
// 先折叠 sub
fireEvent.click(screen.getByText('sub'))
expect(screen.queryByText('diff.ts')).not.toBeInTheDocument()
// 双击 subonOpenDir 触发且目录恢复展开(返回列表后子行可见)
fireEvent.dblClick(screen.getByText('sub'))
expect(screen.getByText('diff.ts')).toBeInTheDocument()
})
it('双击文件条目触发 onOpenEntry(携带原始条目)', () => {
const onOpenEntry = vi.fn()
render(
@@ -92,9 +130,11 @@ describe('FolderView - 树形渲染', () => {
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={onOpenEntry}
onOpenDir={vi.fn()}
/>
)
fireEvent.dblClick(screen.getByText('b.txt'))
@@ -137,9 +177,11 @@ describe('FolderView - 统计徽章与过滤', () => {
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
expect(screen.getByText('a.txt')).toBeInTheDocument()
@@ -176,9 +218,11 @@ describe('FolderView - 统计徽章与过滤', () => {
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
fireEvent.click(screen.getByText('相同 1'))
@@ -237,6 +281,176 @@ describe('FolderView - 搜索', () => {
})
})
describe('FolderView - 扩展名过滤', () => {
const EXT_ENTRIES: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'b.txt', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'c.md', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'noext', status: 'left-only', leftSize: 1, rightSize: null }
]
it('下拉展示全量条目的扩展名分布与计数(字典序,无扩展名单列)', () => {
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={EXT_ENTRIES}
truncated={false}
total={4}
scanning={false}
onlyDiff={false}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
// 字典序:(无扩展名)/ .md / .txt,各带计数
expect(screen.getByText('(无扩展名)')).toBeInTheDocument()
const md = screen.getByText('.md')
expect(md.textContent).toContain('1')
const txt = screen.getByText('.txt')
expect(txt.textContent).toContain('2')
})
it('点选扩展名过滤条目:交集过滤生效,计数切换为 N / 总数', () => {
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={EXT_ENTRIES}
truncated={false}
total={4}
scanning={false}
onlyDiff={false}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
expect(screen.getByText('共 4 项')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
fireEvent.click(screen.getByText('.txt'))
// 只剩 txt 条目(a.txt 同状态被关掉 onlyDiff 后可见)
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.getByText('b.txt')).toBeInTheDocument()
expect(screen.queryByText('c.md')).not.toBeInTheDocument()
expect(screen.queryByText('noext')).not.toBeInTheDocument()
expect(screen.getByText('共 2 / 4 项')).toBeInTheDocument()
// 入口按钮显示已选数量
expect(screen.getByRole('button', { name: /扩展名 · 1/ })).toBeInTheDocument()
})
it('多选扩展名取并集,再次点击取消,清除按钮复位', () => {
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={EXT_ENTRIES}
truncated={false}
total={4}
scanning={false}
onlyDiff={false}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
fireEvent.click(screen.getByText('.txt'))
fireEvent.click(screen.getByText('.md'))
expect(screen.getByText('b.txt')).toBeInTheDocument()
expect(screen.getByText('c.md')).toBeInTheDocument()
expect(screen.queryByText('noext')).not.toBeInTheDocument()
expect(screen.getByText('共 3 / 4 项')).toBeInTheDocument()
// 取消 .txt
fireEvent.click(screen.getByText('.txt'))
expect(screen.queryByText('b.txt')).not.toBeInTheDocument()
expect(screen.getByText('c.md')).toBeInTheDocument()
// 清除按钮整体复位
fireEvent.click(screen.getByText('清除扩展名过滤'))
expect(screen.getByText('共 4 项')).toBeInTheDocument()
expect(screen.getByText('noext')).toBeInTheDocument()
})
it('与状态过滤交集:双激活时无结果的空态提示', () => {
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={EXT_ENTRIES}
truncated={false}
total={4}
scanning={false}
onlyDiff={false}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
// 状态过滤:只看相同(仅 a.txt)
fireEvent.click(screen.getByText('相同 1'))
// 扩展名过滤:.md(与 a.txt 无交集)
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
fireEvent.click(screen.getByText('.md'))
expect(screen.getByText('当前过滤条件下没有文件')).toBeInTheDocument()
})
it('点击外部区域关闭扩展名下拉(过滤状态保留)', () => {
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={EXT_ENTRIES}
truncated={false}
total={4}
scanning={false}
onlyDiff={false}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
fireEvent.click(screen.getByText('.txt'))
fireEvent.mouseDown(document.body)
expect(screen.queryByText('.md')).not.toBeInTheDocument()
// 过滤仍在生效
expect(screen.queryByText('c.md')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /扩展名 · 1/ })).toBeInTheDocument()
})
})
describe('FolderView - 其他', () => {
it('切换语义判等开关触发 onSemanticChange', () => {
const onSemanticChange = vi.fn()
@@ -253,15 +467,64 @@ describe('FolderView - 其他', () => {
semantic={true}
onSemanticChange={onSemanticChange}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
fireEvent.click(screen.getByLabelText('语义判等'))
expect(onSemanticChange).toHaveBeenCalledWith(false)
})
it('点击重新扫描触发 onRescan;扫描中禁用', () => {
const onRescan = vi.fn()
const { rerender } = render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={6}
scanning={false}
onlyDiff={true}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={onRescan}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: '重新扫描' }))
expect(onRescan).toHaveBeenCalledTimes(1)
rerender(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={6}
scanning={true}
onlyDiff={true}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={onRescan}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: '重新扫描' })).toBeDisabled()
})
it('导出报告菜单选择格式触发 onExport 并关闭菜单', () => {
const onExport = vi.fn()
render(
@@ -277,9 +540,11 @@ describe('FolderView - 其他', () => {
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={onExport}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
@@ -302,9 +567,11 @@ describe('FolderView - 其他', () => {
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: '导出报告' })).toBeDisabled()
@@ -324,9 +591,11 @@ describe('FolderView - 其他', () => {
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
@@ -363,9 +632,11 @@ describe('FolderView - 其他', () => {
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
expect(screen.getByText(/文件过多/)).toBeInTheDocument()
+98 -7
View File
@@ -11,7 +11,7 @@ import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
import { type ReportFormat } from '../diff/report'
import { countFolderStats, fmtSize } from '../diff/folderReport'
import { useDismiss } from '../hooks/useDismiss'
import { buildFolderTree, filterEntries, flattenFolderTree } from './folderTree'
import { buildFolderTree, filterEntries, flattenFolderTree, relExt } from './folderTree'
/** 文件夹列表虚拟行高(px),与 global.css 的 --folder-row-height 保持一致,改动需双向同步 */
export const FOLDER_ROW_HEIGHT = 26
@@ -37,12 +37,16 @@ export interface FolderViewProps {
onSemanticChange: (v: boolean) => void
/** 重新选择两侧文件夹 */
onReopen: () => void
/** 不重选目录直接重新扫描(增量:指纹一致的条目零 IO 沿用) */
onRescan: () => void
/** 返回文件对比模式(清空文件夹状态) */
onExit: () => void
/** 导出文件夹对比报告 */
onExport: (fmt: ReportFormat) => void
/** 双击条目进入单文件对比 */
onOpenEntry: (entry: FolderEntry) => void
/** 双击目录行进入该目录的聚合对比(差异文件导航限定在子树内) */
onOpenDir: (dirPath: string) => void
}
/** 导出格式清单(与单文件报告一致) */
@@ -83,9 +87,11 @@ export default function FolderView({
semantic,
onSemanticChange,
onReopen,
onRescan,
onExit,
onExport,
onOpenEntry
onOpenEntry,
onOpenDir
}: FolderViewProps): ReactElement {
// 导出下拉(点击外部 / Esc 关闭)
const [exportOpen, setExportOpen] = useState(false)
@@ -97,22 +103,45 @@ export default function FolderView({
const [query, setQuery] = useState('')
// 状态精确过滤(null = 不过滤;与 onlyDiff 互斥)
const [statusFilter, setStatusFilter] = useState<Set<FolderEntryStatus> | null>(null)
// 扩展名精确过滤(null = 不过滤;与其他过滤条件取交集)
const [extFilter, setExtFilter] = useState<Set<string> | null>(null)
// 扩展名下拉(点击外部 / Esc 关闭)
const [extOpen, setExtOpen] = useState(false)
const extWrapRef = useRef<HTMLDivElement>(null)
const closeExt = useCallback((): void => setExtOpen(false), [])
useDismiss(extWrapRef, closeExt)
// 折叠的目录集合(默认全展开;空集合 = 全部展开)
const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(new Set())
// 统计(条目集不变时不重算;口径与报告导出共用同一实现,始终基于全量条目)
const stats = useMemo(() => countFolderStats(entries), [entries])
/** 全量条目的扩展名分布(字典序),扩展名过滤下拉的数据源 */
const extStats = useMemo(() => {
const m = new Map<string, number>()
for (const e of entries) {
const x = relExt(e.rel)
m.set(x, (m.get(x) ?? 0) + 1)
}
return [...m.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))
}, [entries])
/** 过滤 → 建树(剪枝:过滤后无文件的目录不出现) */
const rows = useMemo(() => {
const filtered = filterEntries(entries, { onlyDiff, statusFilter, query })
const filtered = filterEntries(entries, { onlyDiff, statusFilter, query, exts: extFilter })
const tree = buildFolderTree(filtered)
const q = query.trim()
// 搜索激活时强制全部展开(命中路径完整可见);清空搜索恢复用户折叠状态
const isExpanded = (dirPath: string): boolean =>
q !== '' ? true : !collapsedDirs.has(dirPath)
return flattenFolderTree(tree, isExpanded)
}, [entries, onlyDiff, statusFilter, query, collapsedDirs])
}, [entries, onlyDiff, statusFilter, query, extFilter, collapsedDirs])
/** 过滤后的文件数("共 N / 总数"计数用,不含目录行) */
const filteredCount = useMemo(
() => filterEntries(entries, { onlyDiff, statusFilter, query, exts: extFilter }).length,
[entries, onlyDiff, statusFilter, query, extFilter]
)
const toggleDir = useCallback((path: string): void => {
setCollapsedDirs((prev) => {
@@ -146,6 +175,16 @@ export default function FolderView({
[onlyDiff, onOnlyDiffChange]
)
/** 扩展名下拉项点击:toggle 过滤集合(空集合回落 null = 不过滤) */
const handleExtToggle = useCallback((ext: string): void => {
setExtFilter((prev) => {
const next = new Set(prev ?? [])
if (next.has(ext)) next.delete(ext)
else next.add(ext)
return next.size === 0 ? null : next
})
}, [])
// 虚拟滚动:固定行高换算可见窗口(与 DiffView 同思路)
const scrollRef = useRef<HTMLDivElement>(null)
const [range, setRange] = useState({ start: 0, end: 0 })
@@ -188,6 +227,14 @@ export default function FolderView({
<span className="folder-path">{rightDir}</span>
</div>
<div className="folder-actions">
<button
className="btn ghost"
disabled={scanning}
title={scanning ? '正在扫描文件夹' : '重新扫描当前两个目录(增量:未变更的文件直接沿用判定)'}
onClick={onRescan}
>
</button>
<button className="btn ghost" onClick={onReopen}>
</button>
@@ -247,7 +294,41 @@ export default function FolderView({
</button>
)
})}
<span className="fstat muted"> {entries.length} </span>
<span className="fstat muted" title="条目总数(扩展名/状态过滤不影响统计徽章口径)">
{extFilter !== null || statusFilter !== null ? `${filteredCount} / ${entries.length}` : entries.length}
</span>
</div>
<div className="export-wrap" ref={extWrapRef}>
<button
className={'btn ghost' + (extFilter !== null ? ' filtered' : '')}
title="按扩展名过滤条目(可多选,与其他过滤条件取交集)"
onClick={() => setExtOpen((o) => !o)}
>
{extFilter !== null ? ` · ${extFilter.size}` : ''}
</button>
{extOpen && (
<div className="export-menu ext-menu">
{extStats.map(([ext, n]) => {
const on = extFilter?.has(ext) ?? false
return (
<button
key={ext}
className={'ctx-item' + (on ? ' active' : '')}
title={on ? `点击取消“${ext === '' ? '无扩展名' : '.' + ext}”过滤` : `点击只看“${ext === '' ? '无扩展名' : '.' + ext}”的文件`}
onClick={() => handleExtToggle(ext)}
>
{ext === '' ? '(无扩展名)' : `.${ext}`}
<span className="ext-count">{n}</span>
</button>
)
})}
{extFilter !== null && (
<button className="ctx-item ext-clear" onClick={() => setExtFilter(null)}>
</button>
)}
</div>
)}
</div>
<input
className="folder-search"
@@ -296,8 +377,18 @@ export default function FolderView({
<div
className={`folder-row dir ${meta.cls}`}
key={`dir-${n.path}`}
title={`${n.path}/ · ${n.fileCount ?? 0} 个文件`}
title={`${n.path}/ · ${n.fileCount ?? 0} 个文件 · 双击对比此目录内的差异文件`}
onClick={() => 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)
}}
>
<span className="folder-caret">{collapsed ? '▶' : '▼'}</span>
<span className="folder-rel">{n.name}</span>
@@ -331,7 +422,7 @@ export default function FolderView({
? `没有匹配“${q}”的文件`
: entries.length === 0
? '两个文件夹内都没有文件'
: statusFilter !== null
: statusFilter !== null || extFilter !== null
? '当前过滤条件下没有文件'
: '没有差异文件(两侧内容全部一致)'}
</div>
@@ -9,6 +9,7 @@ function setup(extra?: {
computing?: boolean
charMode?: boolean
charModeDowngraded?: boolean
charModePartial?: boolean
baseOptions?: Partial<import('../diff/diffEngine').DiffOptions>
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()
+12 -1
View File
@@ -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<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
const [exportOpen, setExportOpen] = useState(false)
@@ -145,6 +148,14 @@ export default function Toolbar({
</span>
)}
{options.charMode && !charModeDowngraded && charModePartial && (
<span
className="opt-badge warn"
title="部分差异过大的块已按行级对比,其余差异块仍为字符级高亮"
>
</span>
)}
</label>
</div>
+59 -1
View File
@@ -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)
+12 -1
View File
@@ -47,15 +47,26 @@ export interface EntryFilter {
statusFilter: Set<FolderEntryStatus> | null
/** 相对路径子串搜索(不区分大小写;空串不过滤) */
query: string
/** 扩展名精确过滤(null 为不过滤;非空时只保留集合内扩展名,与其他条件取交集) */
exts?: Set<string> | 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
})
}
+115 -3
View File
@@ -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_CHARS100000)→ 整体降级
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 配 modified25001 > 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()
// 锚行保持 unchangedhead 与 mid
expect(result.rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(2)
})
it('单组编辑距离超限:仅该组行级降级,其余组字符级(partial,预算未耗尽)', () => {
// charDiffMaxEdit 注入 10mid 锚行分隔:组 1(cat↔cap)编辑距离 1 成功;
// 组 2a*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 锚行分隔:组 1small↔small)字符级保底;
// 组 2 左 [x*15000, only] 右 [y*15000, r1, r2]——组流 3 万在限内但编辑距离
// 超 5000(x…↔y… 全替换)→ 行级配对:x…↔y… 与 only↔r1 配 modifiedr2 多余 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 输出', () => {
+170 -24
View File
@@ -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<number, number> | 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<Change[] | null> = []
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<number>()
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()
}
/**
+11
View File
@@ -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('报告头部比较选项说明', () => {
+14 -5
View File
@@ -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(
<header class="hero">
<div class="hero-title">🔍 DiffLens </div>
<div class="hero-time"> ${new Date().toLocaleString('zh-CN')}</div>
<div class="hero-opts">${esc(plainOptions(ctx.options))}</div>
<div class="hero-opts">${esc(plainOptions(ctx.options, { charModePartial: ctx.charModePartial }))}</div>
<div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainSummary(summary))}</div>
<div class="files">
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)}
+35
View File
@@ -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;