feat: 0.9.0 忽略规则、增量缓存跨会话持久化、快照基线、字符级预算自适应与性能基准E2E - 忽略规则(gitignore简化子集:目录整树剪枝不枚举不计数、扩展名通配与名字匹配、随偏好持久化)、扫描增量缓存落盘userData跨会话自动注入(LRU 8对、防御性解析、渲染端零改动)、快照基线(保存/加载JSON对照六类状态变迁统计与条目标注、对照随重扫刷新、报告附快照口径)、字符级变更组预算随输入规模自适应(下限与固定值一致小输入零变化、大输入按总组流半数放宽上限100万封顶)、大文件性能基准E2E(5万行<15s与300KB GBK<10s耗时断言)+新功能E2E、文档与版本号同步

This commit is contained in:
2026-08-18 22:58:00 +08:00
parent fa13160848
commit 35fa81df9d
26 changed files with 2525 additions and 550 deletions
+5 -3
View File
@@ -11,7 +11,8 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新)
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**忽略规则**gitignore 简化子集:`node_modules/` 目录整树剪枝、`*.log` 扩展名通配、`temp` 名字匹配,被忽略项不枚举不计数,规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫)
- **快照基线**:把当前文件夹对比结果保存为快照(JSON 文件),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新,文件夹报告可附快照对照口径说明
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览)
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
@@ -22,7 +23,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **交换左右侧**:一键互换两侧内容与视角
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;**相同内容自动锚定跳过计算(行锚定 + 变更组分块),大文件不再整篇受限**;**变更组逐组预算制**——单组过大 / 编辑距离超限 / 预算耗尽的块仅按行级配对输出(部分降级,界面与报告标注),不再牵连其余块;全部块降级时自动降级行级对比并提示(编辑距离封顶,不会卡死);重算期间保留旧结果撑住滚动位置,**切换选项不再回弹到顶部**
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;**相同内容自动锚定跳过计算(行锚定 + 变更组分块),大文件不再整篇受限**;**变更组逐组预算制(预算随输入规模自适应)**——单组过大 / 编辑距离超限 / 预算耗尽的块仅按行级配对输出(部分降级,界面与报告标注),不再牵连其余块;预算下限与历史固定值一致(小输入行为不变),大输入按总组流比例放宽(上限封顶,最坏总耗时仍有界);全部块降级时自动降级行级对比并提示(编辑距离封顶,不会卡死);重算期间保留旧结果撑住滚动位置,**切换选项不再回弹到顶部**
- **选项联动透明**:开启字符级对比时,被包含的空白选项灰显勾选并标注“已包含”(点击有解释提示,关闭字符级即恢复独立设置);差异过大自动降级时开关旁标注“已降级”,状态一目了然
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
- **偏好记忆**:比较选项与"仅看差异"开关自动保存,窗口尺寸/位置与上次打开文件目录一并记忆,下次启动自动恢复(损坏数据自动回退默认)
@@ -34,6 +35,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **导出一键报告**:单文件与文件夹对比均可导出 HTML / 纯文本 / Markdown 三种格式。单文件报告始终包含全量行,HTML 为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(点击展开零丢失),并支持打印与窄屏;文件夹报告含判定口径说明与全量条目清单
- **拖拽与按钮双入口**:支持文件拖拽导入(E2E 覆盖真实拖放链路),也可通过界面按钮选择文件或粘贴文本
- **滚动联动**:左右两个面板滚动位置自动同步
- **性能基准 E2E**5 万行差异计算与 300KB GBK 解码带耗时上限断言(真实 Electron 链路),性能回归在发布前即可拦截
---
@@ -110,7 +112,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
- 作者:thzxx
- 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.8.0
- 版本:0.9.0
---
+7 -1
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.8.0`**
当前基线版本:**`0.9.0`**
---
@@ -98,6 +98,12 @@ z = 补丁版本号(Patch
- 增量重扫: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.9.0 ← 忽略规则 · 增量缓存跨会话持久化 · 快照基线 · 字符级预算自适应 · 性能基准 E2E(已发布):
- 忽略规则:ignoreRules 纯逻辑模块(gitignore 简化子集——`dir/` 目录名匹配整树剪枝(不枚举不计数,不占用 maxFiles)、`*.ext` 扩展名通配、`name` 名字精确匹配;大小写不敏感;`#` 注释与空白行跳过;多段路径与非法通配整体忽略;上限 50 条/单条 500 字符双重防御) · folderScan listFiles BFS 内剪枝(双侧共用同一 matcher) · 被忽略项不进结果/缓存/total/truncated,规则变化后缓存按指纹自然失效(未命中走全量判定) · FolderView「忽略规则」下拉(每行一条文本域草稿、应用并重扫、清空规则,入口标注规则数) · 规则随偏好 localStorage 持久化(启动恢复并随扫描下发,损坏字段防御性过滤) · IPC folder:scan 增 ignoreRules 参数(isIgnoreRulesArray 校验)
- 增量缓存跨会话持久化:scanCacheStore 纯数据模块(userData/scan-cache.json 落盘;目录对键归一化——反斜杠转斜杠/去尾分隔符/Windows 盘符小写;LRU 上限 8 对按 savedAt 淘汰,当前写入对永不淘汰;解析全部防御性校验——JSON 非法/版本不符整体回退空存储,单条 entry 异常仅丢弃该条) · 主进程 folder:scan 渲染端未携带内存缓存时自动注入盘上缓存(跨会话首次扫描增量),扫描成功且未截断异步落盘(读-改-写,失败静默) · 渲染端链路零改动(内存缓存优先逻辑保留)
- 快照基线:snapshotDiff 纯逻辑模块(快照结构 version/leftDir/rightDir/semantic/truncated/total/entries/savedAtparseSnapshot 全字段防御校验绝不部分加载;diffSnapshot 按 rel 对齐产出六类变迁——fixed/regressed/new/gone/changed/stable,以 isDiffStatus 为主轴;snapshotDirsMatch 目录对一致性校验) · 「保存快照」复用报告保存管线落盘 JSON(默认名带时间戳) · 「加载快照」走打开文件对话框读取(目录对不一致/文件无效/读取失败/无当前结果四类拦截提示) · 对照模式接管 FolderView 列表渲染:变迁统计徽章(点击单选过滤)替换常规统计行、平铺全路径行 + 变迁徽章(gone 回退快照数据展示)、默认隐藏 stable、搜索共用、退出对照恢复 · 对照随重扫自动刷新(目录对仍一致时),退出文件夹对比清空 · 对照模式导出报告附「快照对照」口径行(三格式)
- 字符级预算自适应:charGroupBudget(totalGroupChars) = clamp(总组流/2, 下限 10 万, 上限 100 万)——下限与 0.8.0 固定值一致(小输入行为与已固化测试零变化),大输入但变更组普遍较小的场景按比例放宽(上限封顶最坏总耗时仍有界,单组 5 万/编辑距离 5000 不变) · plainOptions 字案与 Toolbar title 同步("预算随规模自适应") · 行为固化测试:400 组跨行重组总流 48 万 → 预算 24 万 → 200 组字符级(固定预算仅 83 组)
- 性能基准 E2E:5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s(万级行实测秒级,慢机 4 倍余量防 flaky,断言失败即性能回归) · 300KB GBK 15000 行——打开到状态栏行数渲染 < 10s · 新功能 E2E:忽略规则目录剪枝重扫、增量缓存跨重启(scan-cache.json 落盘断言 + 第二实例重扫正确)、快照保存→修复差异→加载对照变迁
...
0.y.z ← 长期停留,永不进入 1.x
```
+21 -9
View File
@@ -51,8 +51,12 @@ src/main/
├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用)
├── decode.test.ts # 解码单元测试(BOM 三种/严格 UTF-8/GBK 回退/二进制启发式)
├── decodeWorker.ts # 解码 worker_threads 入口(覆盖率排除,逻辑由 decode 测试覆盖)
├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对/语义判等/单点失败降级)
├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似/语义判等/目录降级/unreadable
├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对/语义判等/单点失败降级/忽略规则剪枝
├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似/语义判等/目录降级/unreadable/忽略规则
├── ignoreRules.ts # 忽略规则纯逻辑(gitignore 简化子集编译为 matcher,无 electron 依赖)
├── ignoreRules.test.ts # 忽略规则单元测试(目录剪枝/通配/名字匹配/大小写/非法输入/条数上限/IPC 校验)
├── scanCacheStore.ts # 扫描缓存跨会话持久化纯数据(目录对键归一化/LRU/防御性解析)
├── scanCacheStore.test.ts # 缓存存储单元测试(解析回退/条目过滤/键归一化/LRU 淘汰/序列化往返)
├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖)
└── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸)
src/renderer/src/
@@ -64,8 +68,10 @@ src/renderer/src/
│ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化)
│ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退
│ ├── report.test.ts # 单文件报告生成测试
│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式)
│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注
│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable
│ ├── snapshotDiff.ts # 快照基线纯逻辑(结构/解析校验/目录对匹配/六类变迁对照)
│ ├── snapshotDiff.test.ts # 快照单元测试(解析防御/变迁判定/双侧空集/unreadable 语义)
│ └── textUtils.test.ts # 文本扩展名与行数统计测试
├── hooks/
│ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理/字符级大字符量重路径/选项变化保留旧结果防滚动塌陷)
@@ -76,11 +82,11 @@ src/renderer/src/
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
│ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤含扩展名/子树收集)
│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt)
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/目录双击/重新扫描/双击/导出菜单/虚拟滚动/resize 重算)
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉/快照对照模式/目录双击/重新扫描/双击/导出菜单/虚拟滚动/resize 重算)
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记)
└── __tests__/
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级)
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截
└── main.test.ts # 入口挂载冒烟测试
```
@@ -96,7 +102,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 注入小阈值毫秒级构造)/ 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志 / 全部组超限整体降级不出现 partial);逐组预算制部分降级组(0.8.0):组总流超预算时预算内组字符级、预算外组行级配对(`charModePartial` 置位、segs 拼接无损、超长行触发词级防护 segs 为 null、行号单调)/ 单组流超上限仅该组行级其余组字符级精确定位 / 单组编辑距离超限仅该组行级(charDiffMaxEdit 注入)/ 行级降级组内行数不等按序配对多余侧 added
- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);降级组(归一化流超 3 万字符自动降级行级并带 `charModeDowngraded` 标志 / 编辑距离超限快速降级(内部参数 charDiffMaxEdit 注入小阈值毫秒级构造)/ 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志 / 全部组超限整体降级不出现 partial);逐组预算制部分降级组(0.8.0):组总流超预算时预算内组字符级、预算外组行级配对(`charModePartial` 置位、segs 拼接无损、超长行触发词级防护 segs 为 null、行号单调)/ 单组流超上限仅该组行级其余组字符级精确定位 / 单组编辑距离超限仅该组行级(charDiffMaxEdit 注入)/ 行级降级组内行数不等按序配对多余侧 added;预算自适应组(0.9.0):charGroupBudget 函数边界(小输入取下限 / 大输入按总组流一半向上取整 / 上限封顶 100 万 / 非有限输入回退下限)/ 大输入跨行重组 400 组总流 48 万 → 预算 24 万 → 200 组字符级(固定预算仅 83 组,行为放宽固化)/ 总流未超下限两倍时与历史固定预算行为完全一致(回归锚)
- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs
- 行切分:结尾换行不产生多余空行
@@ -164,12 +170,15 @@ src/renderer/src/
- 边界与选项:两个空目录 / 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 不进缓存(故障恢复后重扫正确判定)
- 忽略规则(0.9.0):目录规则整树剪枝(深层子树不枚举、不计数、不进结果与缓存)/ 扩展名通配与名字匹配跳过文件(大小写不敏感)/ 被忽略文件不占用 maxFiles(原本截断的场景忽略后完整扫描)/ 规则变化后重扫缓存未命中的重新纳入条目走全量判定 / 空规则数组与全非法规则不产生任何剪枝
### 5.10 文件夹视图(`FolderView.tsx`)与条目树(`folderTree.ts`
- 条目树纯逻辑:按路径分段聚合目录/文件(同层字典序);目录聚合子树状态(different > unreadable > only > 语义同 > 相同)与文件计数;折叠目录子树隐藏;过滤(onlyDiff / statusFilter / query 子串不区分大小写 / exts 扩展名集合);空目录剪枝由“过滤后建树”天然保证;relExt 取文件名段扩展名(小写、无扩展名空串、不误取目录名中的点)
- 渲染:目录路径卡片 / 重新扫描、重新选择、导出报告与返回按钮 / 统计徽章(相同/语义同/不同/仅左/仅右/无法读取/总数;语义关闭隐藏语义同、无失败隐藏无法读取)
- 树形交互:目录行点击折叠展开(默认全展开);搜索命中强制展开命中路径、清空恢复用户折叠;无命中提示
- 扩展名过滤(0.8.0):下拉展示全量条目 ext→count(字典序、无扩展名单列);点选过滤与其他条件取交集、计数切 N / 总数;多选并集、再点取消、清除按钮复位;与状态过滤双激活空态提示;点击外部关闭且过滤状态保留
- 忽略规则(0.9.0):已有规则时入口标注数量并高亮;下拉打开以当前规则初始化草稿,逐行解析去空白去空行(截断至 50 条)后应用回调并关闭;清空规则回调空数组;点击外部关闭不触发回调
- 快照对照模式(0.9.0):变迁统计徽章(已修复/新增差异/新文件/已移除/仍有差异/保持一致)与基线时间替换常规统计行;对照条目平铺全路径渲染、行尾变迁徽章、gone 回退快照数据;零计数变迁不占位;默认隐藏 stable、点击徽章单选过滤再点取消;双击当前条目进单文件对比、已移除条目双击无效;退出对照恢复常规视图;全 stable 空态提示
- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动)
- 目录行双击(0.8.0):触发 onOpenDir 携带目录相对路径;双击前折叠态自动展开(toggle 残留消除);重新扫描按钮触发 onRescan 且扫描中禁用
- 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果)
@@ -188,6 +197,10 @@ src/renderer/src/
- 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目)
- 增量重扫(0.8.0):真实目录修改文件后点「重新扫描」状态正确更新(相同→不同→改回相同,真实 mtime 变化走指纹判定)
- 拖拽导入(0.8.0):dropFiles 合成 DataTransfer 拖放——空面板拖入渲染(arrayBuffer → decodeBuffer IPC 全链路)、双侧拖入对比、非文本扩展名拒绝、多文件提示取首个
- 忽略规则(0.9.0):真实目录含 node_modules 子树——无规则时差异参与对比,输入规则应用重扫后整树剪枝(不同归零、空态一致提示、被忽略条目消失)
- 增量缓存跨会话持久化(0.9.0):第一实例扫描后 userData/scan-cache.json 落盘(等待异步写盘,断言含条目与合法结构)→ 同 userData 第二实例重扫同目录结果正确(主进程注入盘上缓存)
- 快照基线(0.9.0):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 退出对照恢复
- 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归)
- 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData)
- 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理)
@@ -230,10 +243,9 @@ npm run build
## 9. 演进方向(后续迭代)
- 文件夹对比:增量缓存跨会话持久化(userData 落盘,重启后仍可增量) · 文件夹对比快照基线(保存/加载上次结果对比) · 忽略规则(排除 node_modules 等目录/文件过滤器
- 字符级对比能力扩展:变更组预算制的自适应(按输入规模动态放宽预算)
- E2E 覆盖扩展:大文件性能基准(万级行计算耗时上限断言)
- 文件夹对比:快照 vs 当前的逐行文本对照(快照保存内容或按需读盘,对照视图内差异明细) · 忽略规则高级 glob(`**` 跨段、否定规则 `!`
- 主进程 IPC 层补充集成测试(folder:scan 并发场景)
- mac 构建实际验证(entitlements 配置就绪但从未打包跑通)
---
+192
View File
@@ -388,6 +388,141 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
await removeTmpDir(root)
}
})
test('忽略规则:目录整树剪枝不参与对比(应用后重扫)', async () => {
const root = await makeTmpDir('difflens-e2e-ignore-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(join(leftDir, 'node_modules', 'pkg'), { recursive: true })
await mkdir(join(rightDir, 'node_modules', 'pkg'), { recursive: true })
await wf(join(leftDir, 'keep.txt'), 'identical')
await wf(join(rightDir, 'keep.txt'), 'identical')
await wf(join(leftDir, 'node_modules', 'pkg', 'x.js'), 'left')
await wf(join(rightDir, 'node_modules', 'pkg', 'x.js'), 'right')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
await window.getByRole('button', { name: '对比文件夹' }).click()
// 无规则:node_modules 内差异文件参与对比
await expect(window.getByText('不同 1')).toBeVisible()
await expect(window.getByText('x.js')).toBeVisible()
// 应用忽略规则:node_modules/ 整树剪枝
await window.getByRole('button', { name: '忽略规则' }).click()
await window.getByPlaceholder(/每行一条/).fill('node_modules/')
await window.getByText('应用并重扫').click()
// 重扫完成:无差异(keep.txt 一致,node_modules 被忽略)
await expect(window.getByText('不同 0')).toBeVisible()
await expect(window.getByText('没有差异文件(两侧内容全部一致)')).toBeVisible()
await expect(window.getByText('x.js')).toHaveCount(0)
await app.close()
} finally {
await removeTmpDir(root)
}
})
test('增量缓存跨会话持久化:落盘文件存在且重启后重扫正确', async () => {
const root = await makeTmpDir('difflens-e2e-cachestore-')
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')
// 第一实例:扫描后缓存异步落盘 userData/scan-cache.json
const first = await launchApp()
await stubOpenDialogSeq(first.app, [[leftDir], [rightDir]])
await first.window.getByRole('button', { name: '对比文件夹' }).click()
await expect(first.window.getByText('相同 1')).toBeVisible()
const cachePath = await first.app.evaluate(({ app }) => app.getPath('userData')).then(
(userData) => join(userData, 'scan-cache.json')
)
await first.app.close()
// 落盘文件存在且含本目录对的缓存条目(等待异步写盘完成)
let raw = ''
for (let i = 0; i < 20; i++) {
raw = await readFile(cachePath, 'utf-8').catch(() => '')
if (raw.includes('same.txt')) break
await new Promise((r) => setTimeout(r, 200))
}
expect(raw).toContain('same.txt')
expect(raw).toContain('diff.txt')
// 结构为合法存储(版本 + 目录对键)
const stored = JSON.parse(raw)
expect(stored.version).toBe(1)
expect(Object.keys(stored.pairs).length).toBeGreaterThanOrEqual(1)
// 第二实例(同 userData,渲染端无内存缓存):主进程注入盘上缓存,重扫结果正确
const second = await launchApp({ fresh: false })
await stubOpenDialogSeq(second.app, [[leftDir], [rightDir]])
await second.window.getByRole('button', { name: '对比文件夹' }).click()
await expect(second.window.getByText('相同 1')).toBeVisible()
await expect(second.window.getByText('不同 1')).toBeVisible()
await second.app.close()
} finally {
await removeTmpDir(root)
}
})
test('快照基线:保存后修复差异并新增文件,加载快照对照变迁', async () => {
const root = await makeTmpDir('difflens-e2e-snapshot-')
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()
// 保存快照(真实写盘)
const snapPath = join(root, 'baseline.json')
await stubSaveDialog(app, snapPath)
await window.getByRole('button', { name: '保存快照' }).click()
await expect(window.getByText(/快照已保存/)).toBeVisible()
const snapRaw = await readFile(snapPath, 'utf-8')
expect(snapRaw).toContain('"version": 1')
expect(snapRaw).toContain('diff.txt')
// 修复 diff.txt(两侧一致)+ 新增 new.txt → 重扫
await wf(join(rightDir, 'diff.txt'), 'left v1')
await wf(join(leftDir, 'new.txt'), 'n')
await wf(join(rightDir, 'new.txt'), 'n')
await window.getByRole('button', { name: '重新扫描' }).click()
await expect(window.getByText('相同 3')).toBeVisible()
// 加载快照对照:diff.txt 已修复、new.txt 新文件
await stubOpenDialogSeq(app, [[snapPath]])
await window.getByRole('button', { name: '加载快照' }).click()
await expect(window.getByText(/已加载快照/)).toBeVisible()
await expect(window.getByText('已修复 1')).toBeVisible()
await expect(window.getByText('新文件 1')).toBeVisible()
// 对照条目行(平铺)与变迁标注可见;退出对照恢复
await expect(window.getByText('diff.txt')).toBeVisible()
await expect(window.getByText('new.txt')).toBeVisible()
await window.getByRole('button', { name: '退出对照' }).click()
await expect(window.getByText('相同 3')).toBeVisible()
await app.close()
} finally {
await removeTmpDir(root)
}
})
})
test.describe('拖拽导入(真实 Chromium DataTransfer 链路)', () => {
@@ -462,3 +597,60 @@ test.describe('偏好持久化(跨实例重启)', () => {
await second.app.close()
})
})
test.describe('大文件性能基准(耗时上限断言)', () => {
// 口径说明:万级行 diff 在 worker 后台实测为秒级;上限按慢机约 4 倍余量放宽
// 防 CI flaky(用例超时 60s 兜底)。断言失败即性能回归,需排查引擎/IO 退化。
test('5 万行多差异文件:后台计算完成在耗时上限内', async () => {
const dir = await makeTmpDir('difflens-e2e-perf-diff-')
try {
const n = 50000
const pad = (i: number): string => String(i).padStart(5, '0')
const left = Array.from({ length: n }, (_, i) => `L${pad(i)}-${'x'.repeat(30)}`)
const right = [...left]
// 20 处分散修改(每 2500 行一处)
for (let i = 0; i < n; i += 2500) right[i] = `R${pad(i)}-${'y'.repeat(30)}`
const lp = await writeTextFixture(dir, 'big-left.txt', left.join('\n'))
const rp = await writeTextFixture(dir, 'big-right.txt', right.join('\n'))
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[lp], [rp]])
await window.getByRole('button', { name: '打开左侧' }).click()
await window.getByText('L00000-').first().waitFor()
// 计时:打开右侧 → worker 重路径计算完成(有差异徽章出现)
const t0 = Date.now()
await window.getByRole('button', { name: '打开右侧' }).click()
await window.getByText('有差异').first().waitFor()
const elapsed = Date.now() - t0
expect(elapsed).toBeLessThan(15000)
// 完整性:双侧 5 万行均已加载
await expect(window.locator('.status-item').filter({ hasText: '50000 行' })).toHaveCount(2)
await app.close()
} finally {
await removeTmpDir(dir)
}
})
test('大体积 GBK 文件:worker 后台解码渲染在耗时上限内', async () => {
const dir = await makeTmpDir('difflens-e2e-perf-gbk-')
try {
// 约 300KB(≥ 解码 worker 快路径阈值 256KB),15000 行
const bigLines = Array.from({ length: 15000 }, (_, i) => `中文大文件行${i}数据`).join('\n')
const bigPath = await writeTextFixture(dir, 'big-gbk.txt', bigLines, 'gbk')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[bigPath]])
const t0 = Date.now()
await window.getByRole('button', { name: '打开左侧' }).click()
// 完整渲染以状态栏总行数为准(末行在虚拟滚动视口外)
await expect(
window.locator('.status-item').filter({ hasText: '15000 行' })
).toBeVisible()
const elapsed = Date.now() - t0
expect(elapsed).toBeLessThan(10000)
await expect(window.getByText('中文大文件行0数据')).toBeVisible()
await app.close()
} finally {
await removeTmpDir(dir)
}
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.8.0",
"version": "0.9.0",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
+113
View File
@@ -581,3 +581,116 @@ describe('scanFolders - 增量重扫(previous 缓存)', () => {
}
})
})
describe('scanFolders - 忽略规则', () => {
it('目录规则整树剪枝:子树不枚举、不计数、不出现在结果与缓存', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign1-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign1-r-'))
try {
await writeFile(join(l, 'keep.txt'), 'a')
await writeFile(join(r, 'keep.txt'), 'a')
// 深层子树:node_modules/pkg/dist/x.js(全部应被剪枝)
for (const d of [l, r]) {
await mkdir(join(d, 'node_modules', 'pkg', 'dist'), { recursive: true })
await writeFile(join(d, 'node_modules', 'pkg', 'dist', 'x.js'), 'x')
await writeFile(join(d, 'node_modules', 'pkg', 'y.js'), 'y')
}
const res = await scanFolders(l, r, { ignore: ['node_modules/'] })
expect(res.entries.map((e) => e.rel)).toEqual(['keep.txt'])
expect(res.total).toBe(1)
expect(res.truncated).toBe(false)
expect(res.cache.map((c) => c.rel)).toEqual(['keep.txt'])
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('扩展名通配与名字匹配跳过文件(大小写不敏感)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign2-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign2-r-'))
try {
for (const d of [l, r]) {
await writeFile(join(d, 'keep.txt'), 'a')
await writeFile(join(d, 'debug.LOG'), 'x')
await writeFile(join(d, 'temp'), 't')
await writeFile(join(d, 'other.txt'), 'o')
}
const res = await scanFolders(l, r, { ignore: ['*.log', 'temp'] })
expect(res.entries.map((e) => e.rel).sort()).toEqual(['keep.txt', 'other.txt'])
expect(res.total).toBe(2)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('被忽略文件不占用 maxFiles:原本截断的场景忽略后完整扫描', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign3-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign3-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))
}
for (const d of [l, r]) {
await mkdir(join(d, 'dist'), { recursive: true })
for (let i = 0; i < 5; i++) await writeFile(join(d, 'dist', `b${i}.txt`), 'b')
}
// 无忽略 + maxFiles=6:截断
const noIgnore = await scanFolders(l, r, { maxFiles: 6 })
expect(noIgnore.truncated).toBe(true)
// 忽略 dist/ 后仅 5 个文件:完整扫描
const res = await scanFolders(l, r, { maxFiles: 6, ignore: ['dist/'] })
expect(res.truncated).toBe(false)
expect(res.total).toBe(5)
expect(res.entries).toHaveLength(5)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('忽略规则变化后重扫:缓存未命中的重新纳入条目走全量判定', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign4-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign4-r-'))
try {
await writeFile(join(l, 'a.txt'), 'a')
await writeFile(join(r, 'a.txt'), 'a')
await mkdir(join(l, 'dist'), { recursive: true })
await mkdir(join(r, 'dist'), { recursive: true })
await writeFile(join(l, 'dist/x.txt'), 'x')
await writeFile(join(r, 'dist/x.txt'), 'x')
// 第一次:忽略 dist/
const first = await scanFolders(l, r, { ignore: ['dist/'] })
expect(first.entries.map((e) => e.rel)).toEqual(['a.txt'])
// 第二次:去掉忽略规则 + 携带缓存 → dist/x.txt 未命中缓存,全量判定 same
const second = await scanFolders(l, r, { previous: first.cache })
expect(byRel(second.entries, 'a.txt')?.status).toBe('same')
expect(byRel(second.entries, 'dist/x.txt')?.status).toBe('same')
expect(second.cache.map((c) => c.rel).sort()).toEqual(['a.txt', 'dist/x.txt'])
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('空规则数组与全非法规则不产生任何剪枝(行为与无规则一致)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign5-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign5-r-'))
try {
for (const d of [l, r]) {
await writeFile(join(d, 'a.txt'), 'a')
await mkdir(join(d, 'node_modules'), { recursive: true })
await writeFile(join(d, 'node_modules', 'x.js'), 'x')
}
const empty = await scanFolders(l, r, { ignore: [] })
expect(empty.entries).toHaveLength(2)
const invalid = await scanFolders(l, r, { ignore: ['# note', 'a/b', '**'] })
expect(invalid.entries).toHaveLength(2)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
})
+20 -3
View File
@@ -1,5 +1,6 @@
import { readdir, stat, readFile, open } from 'fs/promises'
import { join } from 'path'
import { compileIgnoreRules, type IgnoreMatcher } from './ignoreRules'
/**
* 文件夹对比的纯逻辑模块(仅依赖 node:fs,vitest 以真实临时目录直接测试)。
@@ -72,6 +73,12 @@ export interface ScanOptions {
semantic?: SemanticOptions
/** 增量重扫缓存(可选):上次扫描返回的 cache,指纹一致的条目零 IO 沿用判定 */
previous?: ScanCacheEntry[]
/**
* 忽略规则(gitignore 简化子集,可选):命中的目录整树剪枝(不枚举不计数,
* 被忽略项不占用 maxFiles 也不计入 total/truncated)、命中的文件跳过。
* 规则变化的缓存失效由指纹机制天然覆盖:未命中条目走全量判定。
*/
ignore?: string[]
}
/**
@@ -120,7 +127,8 @@ export const SCAN_CONCURRENCY = 16
*/
async function listFiles(
root: string,
maxFiles: number
maxFiles: number,
ignore?: IgnoreMatcher
): Promise<{ files: Map<string, FileFingerprint>; truncated: boolean; total: number }> {
const rels: string[] = []
let total = 0
@@ -138,8 +146,12 @@ async function listFiles(
for (const d of dirents) {
// Dirent 为 lstat 语义:symlink 既非 file 也非 directory,天然跳过(防环)
if (d.isDirectory()) {
// 忽略规则命中目录:整树剪枝(不入队不计数,被忽略子树完全不枚举)
if (ignore?.matchesDir(d.name)) continue
queue.push({ dir: join(dir, d.name), prefix: prefix === '' ? d.name : `${prefix}/${d.name}` })
} else if (d.isFile()) {
// 忽略规则命中文件:跳过(不计数,不占用 maxFiles)
if (ignore?.matchesFile(d.name)) continue
total++
if (rels.length >= maxFiles) {
truncated = true
@@ -198,8 +210,13 @@ export async function scanFolders(
const maxFiles = options.maxFiles ?? FOLDER_MAX_FILES
const maxContentBytes = options.maxContentBytes ?? FOLDER_MAX_CONTENT_BYTES
const semantic = options.semantic
const left = await listFiles(leftDir, maxFiles)
const right = await listFiles(rightDir, maxFiles)
// 忽略规则编译一次双侧共用;空数组(无有效规则)不构造匹配器,走原有路径
const ignore =
options.ignore !== undefined && options.ignore.length > 0
? compileIgnoreRules(options.ignore)
: undefined
const left = await listFiles(leftDir, maxFiles, ignore)
const right = await listFiles(rightDir, maxFiles, ignore)
/** 条目是否参与语义判等:双侧存在、均在大小上限内、文本扩展名 */
const semanticEligible = (rel: string, lSize: number, rSize: number): boolean =>
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest'
import {
compileIgnoreRules,
isIgnoreRulesArray,
IGNORE_RULES_MAX,
IGNORE_RULE_MAX_LEN
} from './ignoreRules'
describe('compileIgnoreRules - 目录规则(尾部 /', () => {
it('目录名任意层级命中,文件不命中', () => {
const m = compileIgnoreRules(['node_modules/'])
expect(m.matchesDir('node_modules')).toBe(true)
expect(m.matchesFile('node_modules')).toBe(false)
expect(m.matchesFile('a.txt')).toBe(false)
})
it('大小写不敏感(双侧)', () => {
const m = compileIgnoreRules(['NODE_MODULES/'])
expect(m.matchesDir('node_modules')).toBe(true)
expect(m.matchesDir('Node_Modules')).toBe(true)
})
it('扩展名通配与目录专用组合(*.log/)仅匹配以 .log 结尾的目录名', () => {
const m = compileIgnoreRules(['*.log/'])
expect(m.matchesDir('debug.log')).toBe(true)
expect(m.matchesDir('logs')).toBe(false)
expect(m.matchesFile('debug.log')).toBe(false)
})
})
describe('compileIgnoreRules - 扩展名通配(*.ext', () => {
it('匹配任意层级的文件名后缀', () => {
const m = compileIgnoreRules(['*.log'])
expect(m.matchesFile('app.log')).toBe(true)
expect(m.matchesFile('a.LOG')).toBe(true)
expect(m.matchesFile('app.log.txt')).toBe(false)
expect(m.matchesFile('a.txt')).toBe(false)
})
it('* 至少匹配一个字符(裸 .log 不命中)', () => {
const m = compileIgnoreRules(['*.log'])
expect(m.matchesFile('.log')).toBe(false)
})
it('非目录专用的通配规则同样可命中目录名', () => {
const m = compileIgnoreRules(['*.log'])
expect(m.matchesDir('dump.log')).toBe(true)
expect(m.matchesDir('src')).toBe(false)
})
})
describe('compileIgnoreRules - 名字精确匹配', () => {
it('文件与目录均命中', () => {
const m = compileIgnoreRules(['temp'])
expect(m.matchesDir('temp')).toBe(true)
expect(m.matchesFile('temp')).toBe(true)
expect(m.matchesFile('temp.txt')).toBe(false)
expect(m.matchesDir('temporary')).toBe(false)
})
it('多条规则任一命中即可', () => {
const m = compileIgnoreRules(['temp', 'dist/', '*.tmp'])
expect(m.matchesDir('dist')).toBe(true)
expect(m.matchesFile('a.tmp')).toBe(true)
expect(m.matchesFile('temp')).toBe(true)
expect(m.matchesFile('keep.txt')).toBe(false)
})
})
describe('compileIgnoreRules - 非法与边界输入', () => {
it('空白行与 # 注释跳过', () => {
const m = compileIgnoreRules(['', ' ', '# 构建产物', 'dist/'])
expect(m.matchesDir('dist')).toBe(true)
expect(m.matchesDir('#')).toBe(false)
})
it('多段路径规则(含 /)整体忽略', () => {
const m = compileIgnoreRules(['a/b', 'build/out/'])
expect(m.matchesDir('a')).toBe(false)
expect(m.matchesDir('b')).toBe(false)
expect(m.matchesDir('out')).toBe(false)
})
it('非 *.ext 前缀的通配(*x / x* / a*b)整体忽略', () => {
const m = compileIgnoreRules(['*cache', 'temp*', 'a*b'])
expect(m.matchesFile('*cache')).toBe(false)
expect(m.matchesFile('temp*')).toBe(false)
expect(m.matchesFile('a*b')).toBe(false)
})
it('规则首尾空白被 trim', () => {
const m = compileIgnoreRules([' node_modules/ '])
expect(m.matchesDir('node_modules')).toBe(true)
})
it('空数组与全非法输入产出永不匹配的匹配器', () => {
const empty = compileIgnoreRules([])
expect(empty.matchesDir('node_modules')).toBe(false)
expect(empty.matchesFile('a.log')).toBe(false)
const invalid = compileIgnoreRules(['#', 'a/b', '**'])
expect(invalid.matchesDir('anything')).toBe(false)
})
it('超长单条规则(> 500 字符)被忽略', () => {
const m = compileIgnoreRules(['x'.repeat(IGNORE_RULE_MAX_LEN + 1)])
expect(m.matchesDir('x'.repeat(IGNORE_RULE_MAX_LEN + 1))).toBe(false)
})
it('条数超上限截断(第 51 条起不生效)', () => {
const rules = Array.from({ length: IGNORE_RULES_MAX + 5 }, (_, i) => `d${i}/`)
const m = compileIgnoreRules(rules)
// 前 50 条生效
expect(m.matchesDir('d0')).toBe(true)
expect(m.matchesDir(`d${IGNORE_RULES_MAX - 1}`)).toBe(true)
// 第 51 条起被截断
expect(m.matchesDir(`d${IGNORE_RULES_MAX}`)).toBe(false)
})
})
describe('isIgnoreRulesArray - IPC 防御性校验', () => {
it('合法字符串数组通过', () => {
expect(isIgnoreRulesArray([])).toBe(true)
expect(isIgnoreRulesArray(['node_modules/', '*.log'])).toBe(true)
})
it('非数组 / 含非字符串 / 超条数 / 超长条目拒绝', () => {
expect(isIgnoreRulesArray(null)).toBe(false)
expect(isIgnoreRulesArray('node_modules/')).toBe(false)
expect(isIgnoreRulesArray(['a', 42])).toBe(false)
expect(isIgnoreRulesArray(Array.from({ length: IGNORE_RULES_MAX + 1 }, () => 'a'))).toBe(false)
expect(isIgnoreRulesArray(['x'.repeat(IGNORE_RULE_MAX_LEN + 1)])).toBe(false)
})
})
+91
View File
@@ -0,0 +1,91 @@
/**
* 文件夹对比忽略规则的纯逻辑模块(无 electron/fs 依赖,vitest 直接测试)。
* gitignore 简化子集,仅支持单段名字规则:
* - `node_modules/`:目录名匹配(任意层级命中整树剪枝,不匹配文件)
* - `*.log`:扩展名通配(文件与目录名均可命中)
* - `temp`:名字精确匹配(文件与目录均命中)
* 大小写不敏感(Windows 文件系统语义);`#` 开头注释与空白行跳过;
* 含多段路径(`a/b`)或非法通配(`*x`)的规则整体忽略(宁缺毋滥,防误匹配全部)。
*/
/** 规则条数上限(UI 与主进程 IPC 双重防御) */
export const IGNORE_RULES_MAX = 50
/** 单条规则长度上限 */
export const IGNORE_RULE_MAX_LEN = 500
export interface IgnoreMatcher {
/** 目录名是否命中(命中的目录整树剪枝,不枚举不计数) */
matchesDir: (name: string) => boolean
/** 文件名是否命中(命中的文件跳过,不参与枚举与判定) */
matchesFile: (name: string) => boolean
}
interface CompiledRule {
/** 规则名(已小写;*.ext 形式按扩展名通配处理) */
raw: string
/** 目录专用(尾部 / */
dirOnly: boolean
/** 扩展名通配(*.log → 'log');null = 名字精确匹配 */
ext: string | null
}
/** 编译单条规则文本(调用方已做 trim);无法解析返回 null */
function compileOne(rule: string): CompiledRule | null {
if (rule.length === 0 || rule.length > IGNORE_RULE_MAX_LEN) return null
if (rule.startsWith('#')) return null
let body = rule
let dirOnly = false
if (body.endsWith('/')) {
dirOnly = true
body = body.slice(0, -1)
}
if (body.length === 0 || body.includes('/')) return null
const lower = body.toLowerCase()
// *.ext 形式:提取扩展名做后缀通配
if (lower.startsWith('*.')) {
const ext = lower.slice(2)
if (ext.length === 0 || ext.includes('*')) return null
return { raw: lower, dirOnly, ext }
}
// 名字内不允许残留通配符(简化子集仅支持 *.ext 前缀形式)
if (lower.includes('*')) return null
return { raw: lower, dirOnly, ext: null }
}
/** 规则数组是否为可接受的 IPC 形态(folder:scan 防御性校验共用) */
export function isIgnoreRulesArray(v: unknown): v is string[] {
return (
Array.isArray(v) &&
v.length <= IGNORE_RULES_MAX &&
v.every((r) => typeof r === 'string' && r.length <= IGNORE_RULE_MAX_LEN)
)
}
/** 编译规则串数组为匹配器(空数组 / 全部非法 → 永不匹配的空匹配器) */
export function compileIgnoreRules(rules: readonly string[]): IgnoreMatcher {
const compiled: CompiledRule[] = []
for (const r of rules) {
if (compiled.length >= IGNORE_RULES_MAX) break
if (typeof r !== 'string') continue
const c = compileOne(r.trim())
if (c) compiled.push(c)
}
const nameHit = (name: string, forDir: boolean): boolean => {
const lower = name.toLowerCase()
for (const c of compiled) {
// 目录专用规则(尾部 /)不匹配文件
if (c.dirOnly && !forDir) continue
if (c.ext !== null) {
// *.ext:名字以 .ext 结尾且 * 至少匹配一个字符
if (lower.endsWith('.' + c.ext) && lower.length > c.ext.length + 1) return true
continue
}
if (lower === c.raw) return true
}
return false
}
return {
matchesDir: (name) => nameHit(name, true),
matchesFile: (name) => nameHit(name, false)
}
}
+55 -5
View File
@@ -6,6 +6,14 @@ import fs from 'fs'
import { parseState, clampBounds, type AppState } from './windowState'
import { decodeText, type DecodeResult } from './decode'
import { scanFolders, SEMANTIC_MAX_BYTES, type ScanCacheEntry } from './folderScan'
import { isIgnoreRulesArray } from './ignoreRules'
import {
parseScanCacheStore,
putScanCache,
getScanCache,
emptyScanCacheStore,
type ScanCacheStoreData
} from './scanCacheStore'
/**
* 文本类扩展名清单(小写,不含点):
@@ -30,6 +38,35 @@ function isTextRel(rel: string): boolean {
/** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */
const stateFile = (): string => join(app.getPath('userData'), 'window-state.json')
/** 文件夹扫描增量缓存的跨会话持久化文件,位于 userData 目录 */
const scanCacheFile = (): string => join(app.getPath('userData'), 'scan-cache.json')
/** 读取持久化扫描缓存存储:文件不存在/损坏回退空存储,不影响扫描 */
function loadScanCacheStore(): ScanCacheStoreData {
try {
return parseScanCacheStore(fs.readFileSync(scanCacheFile(), 'utf-8'))
} catch {
return emptyScanCacheStore()
}
}
/**
* 落盘扫描缓存(读-改-写,异步 fire-and-forget):
* 失败静默(目录只读/磁盘满等,跨会话增量记忆失效不影响本次结果)。
*/
async function persistScanCache(
leftDir: string,
rightDir: string,
cache: ScanCacheEntry[]
): Promise<void> {
try {
const next = putScanCache(loadScanCacheStore(), leftDir, rightDir, cache, Date.now())
await fs.promises.writeFile(scanCacheFile(), JSON.stringify(next), 'utf-8')
} catch {
// 忽略写入失败
}
}
/** 当前生效的应用状态(启动时从磁盘读取,运行中增量更新) */
let appState: AppState = {}
@@ -264,7 +301,7 @@ ipcMain.handle('folder:pick', async () => {
return dirPath
})
/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等 + 增量缓存;异步 fs 与解码 worker 不阻塞主进程) */
/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等 + 忽略规则 + 增量缓存跨会话持久化;异步 fs 与解码 worker 不阻塞主进程) */
ipcMain.handle(
'folder:scan',
async (
@@ -272,10 +309,11 @@ ipcMain.handle(
leftDir: string,
rightDir: string,
semantic: boolean,
previous?: unknown
previous?: unknown,
ignoreRules?: unknown
) => {
try {
// 增量缓存防御性校验:仅接受数组形态(渲染端从上次扫描结果携带,损坏即全量重扫
// 增量缓存防御性校验:仅接受数组形态(渲染端从上次扫描结果携带,损坏即不沿用
const cache =
Array.isArray(previous) && previous.every(
(c) =>
@@ -286,8 +324,17 @@ ipcMain.handle(
)
? (previous as ScanCacheEntry[])
: undefined
return await scanFolders(leftDir, rightDir, {
...(cache ? { previous: cache } : {}),
// 渲染端未携带内存缓存时(跨会话首次扫描),尝试注入 userData 落盘的持久化缓存
let prev = cache
if (prev === undefined) {
const stored = getScanCache(loadScanCacheStore(), leftDir, rightDir)
if (stored !== null && stored.length > 0) prev = stored
}
// 忽略规则防御性校验:仅接受字符串数组形态(渲染端从偏好携带,损坏即忽略)
const ignore = isIgnoreRulesArray(ignoreRules) ? ignoreRules : undefined
const result = await scanFolders(leftDir, rightDir, {
...(prev ? { previous: prev } : {}),
...(ignore ? { ignore } : {}),
...(semantic
? {
// 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线)
@@ -299,6 +346,9 @@ ipcMain.handle(
}
: {})
})
// 扫描成功且未截断:缓存异步落盘(LRU,失败静默;下次启动同目录对可增量重扫)
if (!result.truncated) void persistScanCache(leftDir, rightDir, result.cache)
return result
} catch {
// 目录不存在/不可读等:转为错误标记,渲染端提示
return { error: 'scan-failed' as const }
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest'
import type { ScanCacheEntry } from './folderScan'
import {
parseScanCacheStore,
putScanCache,
getScanCache,
pairKey,
emptyScanCacheStore,
SCAN_CACHE_PAIRS_MAX,
SCAN_CACHE_VERSION
} from './scanCacheStore'
/** 构造一条合法缓存条目 */
function entry(rel: string, status: ScanCacheEntry['status'] = 'same'): ScanCacheEntry {
return { rel, leftFp: { size: 1, mtime: 100 }, rightFp: { size: 1, mtime: 100 }, status, semSame: null }
}
describe('pairKey - 目录对键归一化', () => {
it('反斜杠转斜杠、去尾分隔符', () => {
expect(pairKey('C:\\left\\', 'D:\\right\\')).toBe('c:/left|d:/right')
})
it('Windows 盘符大小写归一(其余路径段保留原大小写)', () => {
expect(pairKey('d:/Foo', 'D:/Foo')).toBe('d:/Foo|d:/Foo')
expect(pairKey('D:\\Foo', 'd:/Foo')).toBe('d:/Foo|d:/Foo')
// 大小写敏感文件系统的不同路径不撞键
expect(pairKey('/home/A', '/home/B')).toBe('/home/A|/home/B')
expect(pairKey('/home/A', '/home/a')).not.toBe(pairKey('/home/A', '/home/A'))
})
})
describe('parseScanCacheStore - 防御性解析', () => {
it('空输入 / 非法 JSON / 版本不符回退空存储', () => {
expect(parseScanCacheStore(null)).toEqual(emptyScanCacheStore())
expect(parseScanCacheStore('')).toEqual(emptyScanCacheStore())
expect(parseScanCacheStore('{broken json!!')).toEqual(emptyScanCacheStore())
expect(parseScanCacheStore(JSON.stringify({ version: 999, pairs: {} }))).toEqual(
emptyScanCacheStore()
)
})
it('顶层结构异常(pairs 非对象)回退空存储', () => {
expect(parseScanCacheStore(JSON.stringify({ version: 1, pairs: [] }))).toEqual(
emptyScanCacheStore()
)
expect(parseScanCacheStore(JSON.stringify({ version: 1, pairs: 'x' }))).toEqual(
emptyScanCacheStore()
)
})
it('形态异常的 pair 被丢弃,合法 pair 保留', () => {
const raw = JSON.stringify({
version: 1,
pairs: {
'a|b': { cache: [entry('x.txt')], savedAt: 100 },
bad: { cache: 'not-array', savedAt: 100 },
bad2: { cache: [entry('y.txt')], savedAt: 'no-time' },
bad3: null
}
})
const data = parseScanCacheStore(raw)
expect(Object.keys(data.pairs)).toEqual(['a|b'])
expect(getScanCache(data, 'a', 'b')).toEqual([entry('x.txt')])
})
it('单条异常 entry 被过滤,其余条目保留', () => {
const good = entry('keep.txt')
const raw = JSON.stringify({
version: 1,
pairs: {
'a|b': {
cache: [
good,
{ rel: 'x', status: 'unknown-status', leftFp: null, rightFp: null, semSame: null },
{ rel: '', status: 'same', leftFp: null, rightFp: null, semSame: null },
{ rel: 'y', status: 'same', leftFp: { size: 'big', mtime: 1 }, rightFp: null, semSame: null },
{ rel: 'z', status: 'same', leftFp: null, rightFp: null, semSame: 'maybe' },
'garbage'
],
savedAt: 200
}
}
})
const data = parseScanCacheStore(raw)
expect(getScanCache(data, 'a', 'b')).toEqual([good])
})
})
describe('putScanCache / getScanCache - 写入读取与 LRU', () => {
it('写入后可按归一化键读取(盘上路径写法差异同键)', () => {
let data = emptyScanCacheStore()
data = putScanCache(data, 'C:\\left', 'D:\\right', [entry('a.txt')], 1000)
expect(getScanCache(data, 'c:/left', 'd:/right')).toEqual([entry('a.txt')])
})
it('未写入的目录对返回 null', () => {
const data = putScanCache(emptyScanCacheStore(), 'a', 'b', [entry('a.txt')], 1000)
expect(getScanCache(data, 'x', 'y')).toBeNull()
})
it('写入条目为深拷贝(源数组后续修改不影响存储)', () => {
const src = [entry('a.txt')]
const data = putScanCache(emptyScanCacheStore(), 'a', 'b', src, 1000)
src[0].rel = 'mutated.txt'
expect(getScanCache(data, 'a', 'b')![0].rel).toBe('a.txt')
})
it('同目录对重复写入覆盖旧缓存', () => {
let data = putScanCache(emptyScanCacheStore(), 'a', 'b', [entry('old.txt')], 1000)
data = putScanCache(data, 'a', 'b', [entry('new.txt')], 2000)
expect(getScanCache(data, 'a', 'b')!.map((c) => c.rel)).toEqual(['new.txt'])
expect(Object.keys(data.pairs)).toHaveLength(1)
})
it(`超上限(${SCAN_CACHE_PAIRS_MAX} 对)按 savedAt 淘汰最旧,当前写入对保留`, () => {
let data = emptyScanCacheStore()
// 先写满 8 对,时间递增
for (let i = 0; i < SCAN_CACHE_PAIRS_MAX; i++) {
data = putScanCache(data, `l${i}`, `r${i}`, [entry(`f${i}.txt`)], 1000 + i)
}
// 第 9 对写入:最旧的 l0|r0 被淘汰
data = putScanCache(data, 'newL', 'newR', [entry('new.txt')], 9000)
expect(Object.keys(data.pairs)).toHaveLength(SCAN_CACHE_PAIRS_MAX)
expect(getScanCache(data, 'l0', 'r0')).toBeNull()
expect(getScanCache(data, 'l1', 'r1')).not.toBeNull()
expect(getScanCache(data, 'newL', 'newR')).not.toBeNull()
})
it('重写旧目录对更新其 savedAt:下次淘汰跳过它', () => {
let data = emptyScanCacheStore()
for (let i = 0; i < SCAN_CACHE_PAIRS_MAX; i++) {
data = putScanCache(data, `l${i}`, `r${i}`, [entry(`f${i}.txt`)], 1000 + i)
}
// 刷新最旧的 l0savedAt 1000 → 8000
data = putScanCache(data, 'l0', 'r0', [entry('fresh.txt')], 8000)
// 新对写入:淘汰的应是最旧的 l1(1001)
data = putScanCache(data, 'newL', 'newR', [entry('new.txt')], 9000)
expect(getScanCache(data, 'l0', 'r0')![0].rel).toBe('fresh.txt')
expect(getScanCache(data, 'l1', 'r1')).toBeNull()
})
})
describe('putScanCache 产出的存储可被 parseScanCacheStore 往返', () => {
it('序列化 → 解析后条目与键完整保留', () => {
let data = emptyScanCacheStore()
data = putScanCache(data, 'C:\\l', 'D:\\r', [entry('a.txt', 'different')], 12345)
const parsed = parseScanCacheStore(JSON.stringify(data))
expect(parsed.version).toBe(SCAN_CACHE_VERSION)
expect(getScanCache(parsed, 'c:/l', 'd:/r')).toEqual([entry('a.txt', 'different')])
})
})
+139
View File
@@ -0,0 +1,139 @@
import type { ScanCacheEntry } from './folderScan'
/**
* 文件夹扫描增量缓存的跨会话持久化纯数据模块(无 electron/fs 依赖,vitest 直接测试)。
* 磁盘读写由主进程调用方完成(userData/scan-cache.json)。
*
* 结构:{ version, pairs: { [目录对键]: { cache, savedAt } } }
* - 目录对键归一化:反斜杠→斜杠、去尾分隔符、Windows 盘符小写
* D:\Foo 与 d:/Foo 同键;大小写敏感文件系统的路径不强制小写,避免误撞键);
* - LRU 上限 8 对,按 savedAt 淘汰最旧(正在写入的目录对永不淘汰);
* - 解析全部防御性校验:JSON 非法 / 版本不符 → 整体回退空存储;
* 单条缓存条目形态异常 → 丢弃该条(pair 内其余条目保留)。
*/
/** 存储结构版本(不兼容变更时递增,旧文件整体作废) */
export const SCAN_CACHE_VERSION = 1
/** 保存的目录对数量上限(超出按 savedAt 淘汰最旧) */
export const SCAN_CACHE_PAIRS_MAX = 8
export interface StoredScanCache {
cache: ScanCacheEntry[]
/** 写入时间戳(LRU 淘汰依据) */
savedAt: number
}
export interface ScanCacheStoreData {
version: number
pairs: Record<string, StoredScanCache>
}
/** 空存储(解析失败/文件不存在时的回退) */
export const emptyScanCacheStore = (): ScanCacheStoreData => ({
version: SCAN_CACHE_VERSION,
pairs: {}
})
/** 单一路径归一化:反斜杠→斜杠、去尾分隔符、盘符小写(其余保留原大小写) */
function normDir(p: string): string {
let s = p.replace(/\\/g, '/')
while (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1)
if (s.length >= 2 && s[1] === ':') s = s[0].toLowerCase() + s.slice(1)
return s
}
/** 目录对键:两个归一化路径以 | 拼接(| 不出现在路径合法字符中) */
export function pairKey(leftDir: string, rightDir: string): string {
return `${normDir(leftDir)}|${normDir(rightDir)}`
}
/** 缓存条目的合法状态值 */
const CACHE_STATUSES = ['same', 'semantic-same', 'different', 'left-only', 'right-only']
function isFingerprint(v: unknown): v is { size: number; mtime: number } {
if (typeof v !== 'object' || v === null) return false
const f = v as Record<string, unknown>
return (
typeof f.size === 'number' &&
Number.isFinite(f.size) &&
typeof f.mtime === 'number' &&
Number.isFinite(f.mtime)
)
}
/** 单条缓存条目形态校验(盘上数据不可信,逐字段防御) */
function isCacheEntry(v: unknown): v is ScanCacheEntry {
if (typeof v !== 'object' || v === null) return false
const e = v as Record<string, unknown>
if (typeof e.rel !== 'string' || e.rel === '') return false
if (typeof e.status !== 'string' || !CACHE_STATUSES.includes(e.status)) return false
if (e.leftFp !== null && !isFingerprint(e.leftFp)) return false
if (e.rightFp !== null && !isFingerprint(e.rightFp)) return false
if (e.semSame !== null && typeof e.semSame !== 'boolean') return false
if (e.approximate !== undefined && typeof e.approximate !== 'boolean') return false
return true
}
/**
* 解析持久化文本为存储对象:JSON 非法 / 版本不符 / 顶层结构异常回退空存储;
* 单个 pair 或单条 entry 异常仅丢弃该部分,其余保留。
*/
export function parseScanCacheStore(raw: string | null | undefined): ScanCacheStoreData {
if (!raw) return emptyScanCacheStore()
try {
const obj = JSON.parse(raw) as Record<string, unknown>
if (obj.version !== SCAN_CACHE_VERSION) return emptyScanCacheStore()
if (typeof obj.pairs !== 'object' || obj.pairs === null || Array.isArray(obj.pairs)) {
return emptyScanCacheStore()
}
const pairs: Record<string, StoredScanCache> = {}
for (const [key, val] of Object.entries(obj.pairs as Record<string, unknown>)) {
if (typeof val !== 'object' || val === null) continue
const p = val as Record<string, unknown>
if (!Array.isArray(p.cache) || typeof p.savedAt !== 'number' || !Number.isFinite(p.savedAt)) {
continue
}
const cache = p.cache.filter(isCacheEntry)
pairs[key] = { cache, savedAt: p.savedAt }
}
return { version: SCAN_CACHE_VERSION, pairs }
} catch {
return emptyScanCacheStore()
}
}
/**
* 写入/更新一对目录的缓存(LRU 上限淘汰):
* 深拷贝条目(防调用方后续复用引用);超上限按 savedAt 升序淘汰最旧,
* 当前写入的目录对(savedAt = now,最新)不在淘汰之列。
*/
export function putScanCache(
data: ScanCacheStoreData,
leftDir: string,
rightDir: string,
cache: ScanCacheEntry[],
now: number
): ScanCacheStoreData {
const pairs: Record<string, StoredScanCache> = { ...data.pairs }
const key = pairKey(leftDir, rightDir)
pairs[key] = { cache: cache.map((c) => ({ ...c })), savedAt: now }
const keys = Object.keys(pairs)
if (keys.length > SCAN_CACHE_PAIRS_MAX) {
const others = keys
.filter((k) => k !== key)
.sort((a, b) => pairs[a].savedAt - pairs[b].savedAt)
const overflow = keys.length - SCAN_CACHE_PAIRS_MAX
for (let i = 0; i < overflow && i < others.length; i++) delete pairs[others[i]]
}
return { version: SCAN_CACHE_VERSION, pairs }
}
/** 读取一对目录的缓存(不存在返回 null,调用方按无缓存全量扫描) */
export function getScanCache(
data: ScanCacheStoreData,
leftDir: string,
rightDir: string
): ScanCacheEntry[] | null {
const hit = data.pairs[pairKey(leftDir, rightDir)]
return hit !== undefined ? hit.cache : null
}
+4 -2
View File
@@ -90,9 +90,11 @@ const api = {
leftDir: string,
rightDir: string,
semantic: boolean,
previous?: FolderScanCacheEntry[] | null
previous?: FolderScanCacheEntry[] | null,
/** 忽略规则(gitignore 简化子集;命中的目录整树剪枝、文件跳过) */
ignoreRules?: string[] | null
): Promise<FolderScanResult | FolderScanError> =>
ipcRenderer.invoke('folder:scan', leftDir, rightDir, semantic, previous ?? null),
ipcRenderer.invoke('folder:scan', leftDir, rightDir, semantic, previous ?? null, ignoreRules ?? null),
readByPath: (filePath: string): Promise<FileOpenResult> =>
ipcRenderer.invoke('file:read-by-path', filePath)
}
+161 -20
View File
@@ -14,6 +14,13 @@ import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
import TextInputModal from './components/TextInputModal'
import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report'
import { buildFolderReport } from './diff/folderReport'
import {
parseSnapshot,
diffSnapshot,
snapshotDirsMatch,
type FolderSnapshot,
type SnapshotDiffResult
} from './diff/snapshotDiff'
import { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils'
import { useDismiss } from './hooks/useDismiss'
import { useDiff } from './hooks/useDiff'
@@ -55,7 +62,7 @@ const PREF_BOOL_KEYS = [
'charMode'
] as const satisfies (keyof DiffOptions)[]
/** 持久化偏好的完整结构(比较选项 + 单文件/文件夹视图开关) */
/** 持久化偏好的完整结构(比较选项 + 单文件/文件夹视图开关 + 文件夹忽略规则 */
interface Prefs {
options: DiffOptions
onlyDiff: boolean
@@ -63,10 +70,15 @@ interface Prefs {
folderOnlyDiff: boolean
/** 文件夹模式「语义判等」(默认开:换行符/空白差异不再计为不同) */
folderSemantic: boolean
/** 文件夹扫描忽略规则(默认空;条目为字符串,上限与主进程一致) */
folderIgnoreRules: string[]
}
/** 忽略规则条数上限(与主进程 ignoreRules.ts / FolderView 保持一致,改动需三方同步) */
const IGNORE_RULES_MAX = 50
/**
* 读取持久化偏好:只接受已知字段的严格 boolean 值;
* 读取持久化偏好:只接受已知字段的严格 boolean 值与合法忽略规则数组
* 数据损坏(JSON 非法 / 字段类型异常 / 存储不可用)一律回退默认,保证启动不受影响。
*/
function loadPrefs(): Prefs {
@@ -74,7 +86,8 @@ function loadPrefs(): Prefs {
options: { ...DEFAULT_OPTIONS },
onlyDiff: false,
folderOnlyDiff: true,
folderSemantic: true
folderSemantic: true,
folderIgnoreRules: []
}
let raw: string | null = null
try {
@@ -89,17 +102,23 @@ function loadPrefs(): Prefs {
onlyDiff?: unknown
folderOnlyDiff?: unknown
folderSemantic?: unknown
folderIgnoreRules?: unknown
}
const bool = (v: unknown, def: boolean): boolean => (v === undefined ? def : v === true)
const options: DiffOptions = { ...DEFAULT_OPTIONS }
for (const k of PREF_BOOL_KEYS) {
options[k] = bool(obj.options?.[k], false)
}
const ignoreRaw = obj.folderIgnoreRules
const folderIgnoreRules = Array.isArray(ignoreRaw)
? ignoreRaw.filter((r): r is string => typeof r === 'string').slice(0, IGNORE_RULES_MAX)
: []
return {
options,
onlyDiff: bool(obj.onlyDiff, false),
folderOnlyDiff: bool(obj.folderOnlyDiff, true),
folderSemantic: bool(obj.folderSemantic, true)
folderSemantic: bool(obj.folderSemantic, true),
folderIgnoreRules
}
} catch {
return fallback
@@ -197,6 +216,11 @@ export default function App(): ReactElement {
const [folderOnlyDiff, setFolderOnlyDiff] = useState<boolean>(initialPrefs.folderOnlyDiff)
// 文件夹模式的「语义判等」:字节不同但剔除空白后一致计为语义同(默认开,消除换行符误报)
const [folderSemantic, setFolderSemantic] = useState<boolean>(initialPrefs.folderSemantic)
// 文件夹扫描的忽略规则(gitignore 简化子集;随偏好持久化,应用后触发重扫)
const [folderIgnoreRules, setFolderIgnoreRules] = useState<string[]>(initialPrefs.folderIgnoreRules)
// 快照基线与对照结果(null = 非对照模式;加载快照后列表按状态变迁标注)
const [snapshot, setSnapshot] = useState<FolderSnapshot | null>(null)
const [snapshotDiff, setSnapshotDiff] = useState<SnapshotDiffResult | null>(null)
// 比较选项与视图开关:初始值从持久化偏好恢复
const [options, setOptions] = useState<DiffOptions>(initialPrefs.options)
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
@@ -252,17 +276,17 @@ export default function App(): ReactElement {
}
}, [])
// 偏好写回:比较选项与全部视图开关变更即持久化(存储不可用时静默忽略,不影响本次会话)
// 偏好写回:比较选项与全部视图开关、忽略规则变更即持久化(存储不可用时静默忽略,不影响本次会话)
useEffect(() => {
try {
window.localStorage.setItem(
PREFS_KEY,
JSON.stringify({ options, onlyDiff, folderOnlyDiff, folderSemantic })
JSON.stringify({ options, onlyDiff, folderOnlyDiff, folderSemantic, folderIgnoreRules })
)
} catch {
// 忽略写入失败(隐私模式/配额满等)
}
}, [options, onlyDiff, folderOnlyDiff, folderSemantic])
}, [options, onlyDiff, folderOnlyDiff, folderSemantic, folderIgnoreRules])
// 超多行预警:行数超过阈值时提示后台计算耗时(渲染已虚拟化、计算已 worker 化,均不阻塞界面)
const warnHeavyLines = useCallback(
@@ -363,11 +387,12 @@ export default function App(): ReactElement {
*/
const scanSeqRef = useRef(0)
/** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式;语义判等开关随扫描参数下发)。
/** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式;语义判等开关与忽略规则随扫描参数下发)。
* 目录与上次一致且未截断时携带增量缓存——指纹一致的条目零 IO 沿用判定,
* 语义开关切换触发的重扫从全量 IO 变为近乎零 IO */
* 语义判等开关切换触发的重扫从全量 IO 变为近乎零 IO
* 未携带缓存时(跨会话首次扫描)主进程自动注入 userData 落盘的持久化缓存 */
const runFolderScan = useCallback(
async (leftDir: string, rightDir: string, semantic: boolean): Promise<void> => {
async (leftDir: string, rightDir: string, semantic: boolean, ignoreRules: string[]): Promise<void> => {
const seq = ++scanSeqRef.current
setFolderScanning(true)
try {
@@ -378,7 +403,7 @@ export default function App(): ReactElement {
!folderState.truncated
? (folderState.cache ?? null)
: null
const res = await window.api.scanFolder(leftDir, rightDir, semantic, prev)
const res = await window.api.scanFolder(leftDir, rightDir, semantic, prev, ignoreRules)
// 过期扫描(期间已发起新扫描或已退出文件夹对比):结果整体丢弃
if (seq !== scanSeqRef.current) return
if ('error' in res) {
@@ -416,19 +441,30 @@ export default function App(): ReactElement {
showToast('两次选择的是同一个文件夹,请选择两个不同的文件夹')
return
}
await runFolderScan(leftDir, rightDir, folderSemantic)
}, [showToast, runFolderScan, folderSemantic])
await runFolderScan(leftDir, rightDir, folderSemantic, folderIgnoreRules)
}, [showToast, runFolderScan, folderSemantic, folderIgnoreRules])
/** 语义判等开关切换:记忆开关并对当前结果重新扫描(状态判定随语义变化) */
const handleSemanticChange = useCallback(
(v: boolean): void => {
setFolderSemantic(v)
if (folderState) void runFolderScan(folderState.leftDir, folderState.rightDir, v)
if (folderState)
void runFolderScan(folderState.leftDir, folderState.rightDir, v, folderIgnoreRules)
},
[folderState, runFolderScan]
[folderState, runFolderScan, folderIgnoreRules]
)
/** 导出文件夹对比报告(三格式;报告含全量条目,与界面过滤无关 */
/** 忽略规则应用:记忆规则并对当前目录重新扫描(被忽略的目录/文件不再枚举 */
const handleIgnoreRulesChange = useCallback(
(rules: string[]): void => {
setFolderIgnoreRules(rules)
if (folderState)
void runFolderScan(folderState.leftDir, folderState.rightDir, folderSemantic, rules)
},
[folderState, runFolderScan, folderSemantic]
)
/** 导出文件夹对比报告(三格式;报告含全量条目,与界面过滤无关;对照模式附快照口径说明) */
const exportFolderReport = useCallback(
async (fmt: ReportFormat): Promise<void> => {
if (!folderState) return
@@ -439,7 +475,14 @@ export default function App(): ReactElement {
rightDir: folderState.rightDir,
semantic: folderSemantic,
truncated: folderState.truncated,
total: folderState.total
total: folderState.total,
...(snapshotDiff !== null
? {
snapshotNote: `修复 ${snapshotDiff.stats.fixed} · 新增差异 ${snapshotDiff.stats.regressed} · 新文件 ${snapshotDiff.stats.new} · 已移除 ${snapshotDiff.stats.gone} · 仍有差异 ${snapshotDiff.stats.changed}(基线 ${new Date(
snapshot?.savedAt ?? Date.now()
).toLocaleString('zh-CN')}`
}
: {})
},
fmt
)
@@ -459,9 +502,93 @@ export default function App(): ReactElement {
})
}
},
[folderState, folderSemantic, showToast]
[folderState, folderSemantic, snapshotDiff, snapshot, showToast]
)
/** 保存当前对比结果为快照基线(JSON 落盘,复用报告保存管线) */
const saveSnapshot = useCallback((): void => {
if (!folderState) return
const snap: FolderSnapshot = {
version: 1,
leftDir: folderState.leftDir,
rightDir: folderState.rightDir,
semantic: folderSemantic,
truncated: folderState.truncated,
total: folderState.total,
entries: folderState.entries,
savedAt: Date.now()
}
const defaultName = `DiffLens-snapshot-${stamp()}.json`
void (async (): Promise<void> => {
let res: { ok: boolean; path: string | null }
try {
res = await window.api.saveReport(JSON.stringify(snap, null, 2), defaultName)
} catch {
showToast('快照保存失败,请重试')
return
}
if (res.ok && res.path) {
const p = res.path
showToast(`快照已保存:${p}`, {
label: '打开文件夹',
onClick: () => void window.api.showInFolder(p)
})
}
})()
}, [folderState, folderSemantic, showToast])
/** 加载快照文件并与当前结果对照:目录对不一致 / 文件无效 / 无当前结果时拦截提示 */
const loadSnapshot = useCallback((): void => {
if (!folderState) {
showToast('请先进行文件夹对比,再加载快照')
return
}
void (async (): Promise<void> => {
let data: Awaited<ReturnType<typeof window.api.openFile>>
try {
// 不指定侧别的文件选择(主进程 title 为通用"选择文本文件",仅读取内容)
data = await window.api.openFile()
} catch {
showToast('快照读取失败,请重试')
return
}
if (!data) return
if ('error' in data) {
showToast('快照读取失败,请重试')
return
}
const snap = parseSnapshot(data.text)
if (!snap) {
showToast('快照文件无效或已损坏')
return
}
if (!snapshotDirsMatch(snap, folderState.leftDir, folderState.rightDir)) {
showToast('快照的对比目录与当前不一致,无法对照')
return
}
setSnapshot(snap)
setSnapshotDiff(diffSnapshot(snap.entries, folderState.entries))
showToast('已加载快照,列表按状态变迁标注')
})()
}, [folderState, showToast])
/** 退出快照对照(清空基线与对照结果) */
const exitSnapshot = useCallback((): void => {
setSnapshot(null)
setSnapshotDiff(null)
}, [])
// 快照加载后扫描结果变化(重新扫描/语义切换):目录对仍一致则刷新对照,否则清空对照
useEffect(() => {
if (snapshot === null || folderState === null) return
if (!snapshotDirsMatch(snapshot, folderState.leftDir, folderState.rightDir)) {
setSnapshot(null)
setSnapshotDiff(null)
return
}
setSnapshotDiff(diffSnapshot(snapshot.entries, folderState.entries))
}, [folderState, snapshot])
/** 双击文件夹条目:按路径读取存在侧进入单文件对比(保留文件夹状态供返回)。
* navScope 为本次进入后的导航范围:手动双击文件条目传 null(回全局序列),
* 目录聚合入口与差异文件导航传当前 scope(保持子树范围不被清除) */
@@ -563,7 +690,7 @@ export default function App(): ReactElement {
setMode('folder')
}, [])
/** 退出文件夹对比(清空文件夹状态、导航游标/聚合范围与两侧面板,回到单文件空态) */
/** 退出文件夹对比(清空文件夹状态、导航游标/聚合范围、快照对照与两侧面板,回到单文件空态) */
const exitFolder = useCallback((): void => {
// 作废在途扫描并复位扫描态:迟到的扫描结果不得把界面拉回文件夹模式,
// 也不得让扫描遮罩残留到下一次文件夹会话
@@ -574,6 +701,8 @@ export default function App(): ReactElement {
setPaneR(null)
setFolderEntryCursor(null)
setFolderNavScope(null)
setSnapshot(null)
setSnapshotDiff(null)
setMode('file')
}, [])
@@ -924,10 +1053,22 @@ export default function App(): ReactElement {
onOnlyDiffChange={setFolderOnlyDiff}
semantic={folderSemantic}
onSemanticChange={handleSemanticChange}
ignoreRules={folderIgnoreRules}
onIgnoreRulesChange={handleIgnoreRulesChange}
snapshot={snapshot}
snapshotDiff={snapshotDiff}
onSaveSnapshot={saveSnapshot}
onLoadSnapshot={loadSnapshot}
onExitSnapshot={exitSnapshot}
onReopen={() => void startFolderCompare()}
onRescan={() => {
if (folderState)
void runFolderScan(folderState.leftDir, folderState.rightDir, folderSemantic)
void runFolderScan(
folderState.leftDir,
folderState.rightDir,
folderSemantic,
folderIgnoreRules
)
}}
onExit={exitFolder}
onExport={(fmt) => void exportFolderReport(fmt)}
+300 -1
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
import App from '../App'
import { parseSnapshot } from '../diff/snapshotDiff'
let clipboardSpy: ReturnType<typeof vi.fn>
@@ -869,7 +870,7 @@ describe('App - 字符级对比', () => {
fireEvent.click(screen.getByText('导出报告'))
fireEvent.click(screen.getByText('纯文本'))
await screen.findByText(/报告已保存/)
expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行,大内容自动分块计算)')
expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行,大内容自动分块计算,预算随规模自适应')
})
it('降级后导出报告按实际生效的行级语义描述选项', async () => {
@@ -1723,3 +1724,301 @@ describe('App - 目录聚合对比(双击目录行)', () => {
expect(screen.queryByText('← 返回文件夹对比')).not.toBeInTheDocument()
})
})
describe('App - 忽略规则', () => {
const ignEntries = [
{ rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
{ rel: 'b.txt', status: 'different' as const, leftSize: 3, rightSize: 5 }
]
/** 进入文件夹模式,记录 scanFolder 全部调用参数(含忽略规则) */
async function enterWithIgnore(calls: Array<{ l: string; r: string; s: boolean; p: unknown; ig: unknown }>) {
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async (
l: string,
r: string,
s: boolean,
p?: unknown,
ig?: unknown
): Promise<{ entries: typeof ignEntries; truncated: boolean; total: number }> => {
calls.push({ l, r, s, p: p ?? null, ig: ig ?? null })
return { entries: ignEntries, truncated: false, total: 2 }
}
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('b.txt')
}
it('首次扫描携带忽略规则参数(默认空数组)', async () => {
const calls: Array<{ l: string; r: string; s: boolean; p: unknown; ig: unknown }> = []
await enterWithIgnore(calls)
expect(calls).toHaveLength(1)
expect(calls[0].ig).toEqual([])
})
it('应用规则后触发重扫并携带规则数组;规则持久化写回', async () => {
const calls: Array<{ l: string; r: string; s: boolean; p: unknown; ig: unknown }> = []
await enterWithIgnore(calls)
fireEvent.click(screen.getByRole('button', { name: '忽略规则' }))
fireEvent.change(screen.getByPlaceholderText(/每行一条/), {
target: { value: 'node_modules/\n*.log' }
})
fireEvent.click(screen.getByText('应用并重扫'))
await waitFor(() => expect(calls).toHaveLength(2))
expect(calls[1].ig).toEqual(['node_modules/', '*.log'])
// 偏好写回
await waitFor(() => {
const saved = JSON.parse(window.localStorage.getItem('difflens.prefs.v1') ?? '{}')
expect(saved.folderIgnoreRules).toEqual(['node_modules/', '*.log'])
})
// 入口按钮标注规则数
expect(screen.getByRole('button', { name: '忽略规则 · 2' })).toBeInTheDocument()
})
it('偏好中的忽略规则在启动时恢复并随扫描下发', async () => {
window.localStorage.setItem(
'difflens.prefs.v1',
JSON.stringify({ folderIgnoreRules: ['dist/'] })
)
const calls: Array<{ l: string; r: string; s: boolean; p: unknown; ig: unknown }> = []
await enterWithIgnore(calls)
expect(calls[0].ig).toEqual(['dist/'])
// 入口按钮直接标注恢复的规则数
expect(screen.getByRole('button', { name: '忽略规则 · 1' })).toBeInTheDocument()
})
it('清空规则后重扫携带空数组', async () => {
window.localStorage.setItem(
'difflens.prefs.v1',
JSON.stringify({ folderIgnoreRules: ['dist/'] })
)
const calls: Array<{ l: string; r: string; s: boolean; p: unknown; ig: unknown }> = []
await enterWithIgnore(calls)
fireEvent.click(screen.getByRole('button', { name: '忽略规则 · 1' }))
fireEvent.click(screen.getByText('清空规则'))
await waitFor(() => expect(calls).toHaveLength(2))
expect(calls[1].ig).toEqual([])
})
it('损坏的偏好规则字段回退空数组', async () => {
window.localStorage.setItem(
'difflens.prefs.v1',
JSON.stringify({ folderIgnoreRules: ['ok/', 42, null] })
)
const calls: Array<{ l: string; r: string; s: boolean; p: unknown; ig: unknown }> = []
await enterWithIgnore(calls)
expect(calls[0].ig).toEqual(['ok/'])
})
})
describe('App - 快照基线', () => {
const snapEntries = [
{ rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
{ rel: 'b.txt', status: 'same' as const, leftSize: 3, rightSize: 5 }
]
const curEntries = [
{ rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
{ rel: 'b.txt', status: 'different' as const, leftSize: 3, rightSize: 5 },
{ rel: 'c.txt', status: 'right-only' as const, leftSize: null, rightSize: 2 }
]
function makeSnapJson(overrides?: Record<string, unknown>): string {
return JSON.stringify({
version: 1,
leftDir: 'C:/left-dir',
rightDir: 'D:/right-dir',
semantic: true,
truncated: false,
total: 2,
entries: snapEntries,
savedAt: 1700000000000,
...overrides
})
}
/** 进入文件夹模式(当前结果 curEntries);可覆盖 openFile 返回快照内容 */
async function enterForSnapshot(openFileText?: string) {
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 3 }),
...(openFileText !== undefined
? {
openFile: async () => ({
path: '/tmp/snap.json',
name: 'snap.json',
text: openFileText,
encoding: 'UTF-8',
binary: false
})
}
: {})
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
// 关闭仅看差异让全部条目可见后再断言 c.txt
await screen.findByText('b.txt')
}
it('保存快照:落盘内容为合法快照 JSON 且含当前结果与目录对', async () => {
let saved = ''
let savedName = ''
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 3 }),
saveReport: async (content: string, name: string) => {
saved = content
savedName = name
return { ok: true, path: 'C:/docs/snap.json' }
}
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('b.txt')
fireEvent.click(screen.getByRole('button', { name: '保存快照' }))
expect(await screen.findByText(/快照已保存/)).toBeInTheDocument()
expect(savedName).toMatch(/^DiffLens-snapshot-\d{8}-\d{6}\.json$/)
const snap = parseSnapshot(saved)
expect(snap).not.toBeNull()
expect(snap!.leftDir).toBe('C:/left-dir')
expect(snap!.rightDir).toBe('D:/right-dir')
expect(snap!.entries.map((e) => e.rel)).toEqual(['a.txt', 'b.txt', 'c.txt'])
})
it('加载快照:对照模式渲染变迁统计与标注', async () => {
await enterForSnapshot(makeSnapJson())
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
expect(await screen.findByText(/已加载快照/)).toBeInTheDocument()
// 变迁统计徽章:b.txt 快照 same → 当前 different(新增差异)、c.txt 新文件
expect(screen.getByText('新增差异 1')).toBeInTheDocument()
expect(screen.getByText('新文件 1')).toBeInTheDocument()
// 变迁标注的条目行(平铺):b.txt 与 c.txt 可见,a.txt stable 默认隐藏
expect(screen.getByText('b.txt')).toBeInTheDocument()
expect(screen.getByText('c.txt')).toBeInTheDocument()
expect(screen.queryByText('a.txt')).not.toBeInTheDocument()
// 基线信息与退出按钮
expect(screen.getByRole('button', { name: '退出对照' })).toBeInTheDocument()
})
it('对照模式下导出报告携带快照口径说明', async () => {
let saved = ''
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 3 }),
openFile: async () => ({
path: '/tmp/snap.json',
name: 'snap.json',
text: makeSnapJson(),
encoding: 'UTF-8',
binary: false
}),
saveReport: async (content: string) => {
saved = content
return { ok: true, path: 'C:/docs/folder-report.txt' }
}
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('b.txt')
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
await screen.findByText(/已加载快照/)
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
fireEvent.click(screen.getByText('纯文本'))
await screen.findByText(/报告已保存/)
expect(saved).toContain('快照对照:修复 0 · 新增差异 1 · 新文件 1 · 已移除 0 · 仍有差异 0')
})
it('退出对照恢复常规视图', async () => {
await enterForSnapshot(makeSnapJson())
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
await screen.findByText(/已加载快照/)
fireEvent.click(screen.getByRole('button', { name: '退出对照' }))
// 恢复常规统计徽章(当前结果:相同 1 / 不同 1 / 仅右 1)
await waitFor(() => expect(screen.getByText('相同 1')).toBeInTheDocument())
expect(screen.queryByText(/新增差异 \d/)).not.toBeInTheDocument()
})
it('快照目录对与当前不一致:拦截提示且不进入对照', async () => {
await enterForSnapshot(
makeSnapJson({ leftDir: 'C:/other-left', rightDir: 'D:/right-dir' })
)
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
expect(await screen.findByText(/快照的对比目录与当前不一致/)).toBeInTheDocument()
expect(screen.queryByText(/已加载快照/)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '退出对照' })).not.toBeInTheDocument()
})
it('快照文件无效(版本不符):拦截提示', async () => {
await enterForSnapshot(makeSnapJson({ version: 99 }))
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
expect(await screen.findByText(/快照文件无效或已损坏/)).toBeInTheDocument()
})
it('快照读取失败(error 返回):拦截提示', async () => {
await enterForSnapshot()
window.api = mockApi({
openFile: async () => ({ error: 'read-failed' as const, name: 'snap.json' })
})
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
expect(await screen.findByText(/快照读取失败,请重试/)).toBeInTheDocument()
})
it('未进行文件夹对比时加载快照:拦截提示', async () => {
render(<App />)
// header 无加载快照入口(仅 FolderView 内)——空态下按钮不存在,行为由回调守卫保证
expect(screen.queryByRole('button', { name: '加载快照' })).not.toBeInTheDocument()
})
it('对照模式随重扫刷新:重新扫描后变迁按新结果更新', async () => {
let scanCall = 0
const dirs = ['C:/left-dir', 'D:/right-dir']
let pick = 0
// 快照基线:a same、b different(仍有差异可被修复)
const snapBase = [
{ rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
{ rel: 'b.txt', status: 'different' as const, leftSize: 3, rightSize: 5 }
]
window.api = mockApi({
pickFolder: async () => dirs[pick++] ?? null,
scanFolder: async () => {
scanCall++
// 第一次:b different(仍有差异);第二次(重扫):b same(已修复)
const entries =
scanCall === 1
? snapBase
: [
{ rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
{ rel: 'b.txt', status: 'same' as const, leftSize: 3, rightSize: 5 }
]
return { entries, truncated: false, total: entries.length }
},
openFile: async () => ({
path: '/tmp/snap.json',
name: 'snap.json',
text: makeSnapJson({ entries: snapBase }),
encoding: 'UTF-8',
binary: false
})
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('b.txt')
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
await screen.findByText(/已加载快照/)
// 快照 b different ↔ 当前 b different → 仍有差异 1
expect(screen.getByText('仍有差异 1')).toBeInTheDocument()
// 修复 b.txt 后重扫:仍有差异归零,已修复 +1
fireEvent.click(screen.getByRole('button', { name: '重新扫描' }))
await waitFor(() => expect(screen.getByText('已修复 1')).toBeInTheDocument())
expect(screen.queryByText(/仍有差异 \d/)).not.toBeInTheDocument()
})
})
+240 -338
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import FolderView, { FOLDER_ROW_HEIGHT } from './FolderView'
import type { FolderEntry } from '../../../preload/index'
import { diffSnapshot, type FolderSnapshot } from '../diff/snapshotDiff'
const ENTRIES: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 5, rightSize: 5 },
@@ -12,31 +13,42 @@ const ENTRIES: FolderEntry[] = [
{ rel: 'sub/deep/diff.ts', status: 'different', leftSize: 2, rightSize: 4 }
]
/** 构造 FolderView 全量 props(覆盖缺省值,测试按需覆写) */
function fullProps(entries: FolderEntry[] = ENTRIES, overrides?: Record<string, unknown>) {
return {
leftDir: 'C:/left',
rightDir: 'D:/right',
entries,
truncated: false,
total: entries.length,
scanning: false,
onlyDiff: true,
onOnlyDiffChange: vi.fn(),
semantic: true,
onSemanticChange: vi.fn(),
ignoreRules: [] as string[],
onIgnoreRulesChange: vi.fn(),
snapshot: null as FolderSnapshot | null,
snapshotDiff: null as ReturnType<typeof diffSnapshot> | null,
onSaveSnapshot: vi.fn(),
onLoadSnapshot: vi.fn(),
onExitSnapshot: vi.fn(),
onReopen: vi.fn(),
onRescan: vi.fn(),
onExit: vi.fn(),
onExport: vi.fn(),
onOpenEntry: vi.fn(),
onOpenDir: vi.fn(),
...overrides
}
}
function renderView(
entries: FolderEntry[] = ENTRIES,
onlyDiff = true,
semantic = true
): ReturnType<typeof render> {
return render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={entries}
truncated={false}
total={entries.length}
scanning={false}
onlyDiff={onlyDiff}
onOnlyDiffChange={vi.fn()}
semantic={semantic}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
return render(<FolderView {...fullProps(entries, { onlyDiff, semantic })} />)
}
describe('FolderView - 树形渲染', () => {
@@ -46,6 +58,8 @@ describe('FolderView - 树形渲染', () => {
expect(screen.getAllByText('D:/right').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: '重新选择' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '返回文件对比' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '保存快照' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '加载快照' })).toBeInTheDocument()
})
it('目录聚合为树形:根层文件与目录混排(字典序),目录行显示聚合状态与计数', () => {
@@ -81,26 +95,7 @@ describe('FolderView - 树形渲染', () => {
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}
/>
)
render(<FolderView {...fullProps(ENTRIES, { onlyDiff: false, onOpenDir })} />)
fireEvent.dblClick(screen.getByText('sub'))
expect(onOpenDir).toHaveBeenCalledWith('sub')
})
@@ -117,26 +112,7 @@ describe('FolderView - 树形渲染', () => {
it('双击文件条目触发 onOpenEntry(携带原始条目)', () => {
const onOpenEntry = vi.fn()
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={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={onOpenEntry}
onOpenDir={vi.fn()}
/>
)
render(<FolderView {...fullProps(ENTRIES, { onOpenEntry })} />)
fireEvent.dblClick(screen.getByText('b.txt'))
expect(onOpenEntry).toHaveBeenCalledWith(ENTRIES[2])
})
@@ -164,26 +140,7 @@ describe('FolderView - 统计徽章与过滤', () => {
expect(screen.queryByText('sem.txt')).not.toBeInTheDocument()
expect(screen.getByText('b.txt')).toBeInTheDocument()
expect(screen.getByText('sub')).toBeInTheDocument()
rerender(
<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={vi.fn()}
/>
)
rerender(<FolderView {...fullProps(ENTRIES, { onlyDiff: false })} />)
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.getByText('sem.txt')).toBeInTheDocument()
})
@@ -205,26 +162,7 @@ describe('FolderView - 统计徽章与过滤', () => {
it('仅看差异开启时点击徽章:自动关闭仅看差异(回调传 false)', () => {
const onOnlyDiffChange = vi.fn()
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={6}
scanning={false}
onlyDiff={true}
onOnlyDiffChange={onOnlyDiffChange}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
render(<FolderView {...fullProps(ENTRIES, { onOnlyDiffChange })} />)
fireEvent.click(screen.getByText('相同 1'))
expect(onOnlyDiffChange).toHaveBeenCalledWith(false)
})
@@ -290,26 +228,7 @@ describe('FolderView - 扩展名过滤', () => {
]
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()}
/>
)
render(<FolderView {...fullProps(EXT_ENTRIES, { onlyDiff: false })} />)
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
// 字典序:(无扩展名)/ .md / .txt,各带计数
expect(screen.getByText('(无扩展名)')).toBeInTheDocument()
@@ -320,26 +239,7 @@ describe('FolderView - 扩展名过滤', () => {
})
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()}
/>
)
render(<FolderView {...fullProps(EXT_ENTRIES, { onlyDiff: false })} />)
expect(screen.getByText('共 4 项')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
fireEvent.click(screen.getByText('.txt'))
@@ -354,26 +254,7 @@ describe('FolderView - 扩展名过滤', () => {
})
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()}
/>
)
render(<FolderView {...fullProps(EXT_ENTRIES, { onlyDiff: false })} />)
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
fireEvent.click(screen.getByText('.txt'))
fireEvent.click(screen.getByText('.md'))
@@ -392,26 +273,7 @@ describe('FolderView - 扩展名过滤', () => {
})
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()}
/>
)
render(<FolderView {...fullProps(EXT_ENTRIES, { onlyDiff: false })} />)
// 状态过滤:只看相同(仅 a.txt)
fireEvent.click(screen.getByText('相同 1'))
// 扩展名过滤:.md(与 a.txt 无交集)
@@ -421,26 +283,7 @@ describe('FolderView - 扩展名过滤', () => {
})
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()}
/>
)
render(<FolderView {...fullProps(EXT_ENTRIES, { onlyDiff: false })} />)
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
fireEvent.click(screen.getByText('.txt'))
fireEvent.mouseDown(document.body)
@@ -451,102 +294,216 @@ describe('FolderView - 扩展名过滤', () => {
})
})
describe('FolderView - 忽略规则', () => {
it('已有规则时入口按钮标注数量并高亮', () => {
render(<FolderView {...fullProps(ENTRIES, { ignoreRules: ['node_modules/', '*.log'] })} />)
expect(screen.getByRole('button', { name: '忽略规则 · 2' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '忽略规则 · 2' }).className).toContain('filtered')
})
it('下拉打开时以当前规则初始化草稿,逐行解析后应用并关闭', () => {
const onIgnoreRulesChange = vi.fn()
render(
<FolderView
{...fullProps(ENTRIES, { ignoreRules: ['dist/'], onIgnoreRulesChange })}
/>
)
fireEvent.click(screen.getByRole('button', { name: '忽略规则 · 1' }))
const ta = screen.getByPlaceholderText(/每行一条/) as HTMLTextAreaElement
expect(ta.value).toBe('dist/')
fireEvent.change(ta, { target: { value: 'node_modules/\n\n *.log \ntemp' } })
fireEvent.click(screen.getByText('应用并重扫'))
// 空行与空白被清理
expect(onIgnoreRulesChange).toHaveBeenCalledWith(['node_modules/', '*.log', 'temp'])
expect(screen.queryByText('应用并重扫')).not.toBeInTheDocument()
})
it('清空规则回调空数组并关闭', () => {
const onIgnoreRulesChange = vi.fn()
render(
<FolderView
{...fullProps(ENTRIES, { ignoreRules: ['dist/'], onIgnoreRulesChange })}
/>
)
fireEvent.click(screen.getByRole('button', { name: '忽略规则 · 1' }))
fireEvent.click(screen.getByText('清空规则'))
expect(onIgnoreRulesChange).toHaveBeenCalledWith([])
expect(screen.queryByText('清空规则')).not.toBeInTheDocument()
})
it('点击外部区域关闭忽略规则下拉(不触发回调)', () => {
const onIgnoreRulesChange = vi.fn()
render(<FolderView {...fullProps(ENTRIES, { onIgnoreRulesChange })} />)
fireEvent.click(screen.getByRole('button', { name: '忽略规则' }))
expect(screen.getByText('应用并重扫')).toBeInTheDocument()
fireEvent.mouseDown(document.body)
expect(screen.queryByText('应用并重扫')).not.toBeInTheDocument()
expect(onIgnoreRulesChange).not.toHaveBeenCalled()
})
it('规则条数超上限截断至 50 条', () => {
const onIgnoreRulesChange = vi.fn()
render(<FolderView {...fullProps(ENTRIES, { onIgnoreRulesChange })} />)
fireEvent.click(screen.getByRole('button', { name: '忽略规则' }))
const many = Array.from({ length: 60 }, (_, i) => `d${i}/`).join('\n')
fireEvent.change(screen.getByPlaceholderText(/每行一条/), { target: { value: many } })
fireEvent.click(screen.getByText('应用并重扫'))
expect(onIgnoreRulesChange).toHaveBeenCalledWith(
Array.from({ length: 50 }, (_, i) => `d${i}/`)
)
})
})
describe('FolderView - 快照对照模式', () => {
/** 快照基线:a.txt different、b.txt same、gone.txt different(当前已移除) */
const SNAP_ENTRIES: FolderEntry[] = [
{ rel: 'a.txt', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'b.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'gone.txt', status: 'different', leftSize: 1, rightSize: 2 }
]
const CUR_ENTRIES: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 2, rightSize: 2 },
{ rel: 'b.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'new.txt', status: 'right-only', leftSize: null, rightSize: 3 }
]
const snap: FolderSnapshot = {
version: 1,
leftDir: 'C:/left',
rightDir: 'D:/right',
semantic: true,
truncated: false,
total: 3,
entries: SNAP_ENTRIES,
savedAt: 1700000000000
}
const snapDiff = diffSnapshot(SNAP_ENTRIES, CUR_ENTRIES)
// 变迁:a.txt fixed、gone.txt gone、new.txt new、b.txt stable
it('对照模式渲染变迁统计徽章与基线信息,替换常规统计行', () => {
render(
<FolderView {...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: snapDiff })} />
)
expect(screen.getByText('已修复 1')).toBeInTheDocument()
expect(screen.getByText('新文件 1')).toBeInTheDocument()
expect(screen.getByText('已移除 1')).toBeInTheDocument()
expect(screen.getByText(/保持一致 1/)).toBeInTheDocument()
expect(screen.getByText(/基线 /)).toBeInTheDocument()
expect(screen.getByRole('button', { name: '退出对照' })).toBeInTheDocument()
// 常规统计行被替换
expect(screen.queryByText(/相同 \d/)).not.toBeInTheDocument()
expect(screen.queryByLabelText('仅看差异')).not.toBeInTheDocument()
})
it('对照条目平铺全路径渲染,行尾带变迁徽章;零计数变迁不占位', () => {
render(
<FolderView {...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: snapDiff })} />
)
// 全路径显示(非树形文件名)
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.getByText('new.txt')).toBeInTheDocument()
// 已移除条目仍可见(当前不存在,回退快照数据)并带徽章
expect(screen.getByText('gone.txt')).toBeInTheDocument()
expect(screen.getByText('已移除')).toBeInTheDocument()
expect(screen.getByText('已修复')).toBeInTheDocument()
// 零计数变迁不渲染
expect(screen.queryByText(/新增差异 \d/)).not.toBeInTheDocument()
expect(screen.queryByText(/仍有差异 \d/)).not.toBeInTheDocument()
})
it('点击变迁徽章过滤该类条目,再点取消', () => {
render(
<FolderView {...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: snapDiff })} />
)
expect(screen.getByText('a.txt')).toBeInTheDocument()
fireEvent.click(screen.getByText('已修复 1'))
// 只剩 fixed 类(a.txt
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.queryByText('new.txt')).not.toBeInTheDocument()
expect(screen.queryByText('gone.txt')).not.toBeInTheDocument()
// 再次点击取消过滤
fireEvent.click(screen.getByText('已修复 1'))
expect(screen.getByText('new.txt')).toBeInTheDocument()
})
it('双击当前存在的对照条目触发 onOpenEntry;已移除条目双击无效', () => {
const onOpenEntry = vi.fn()
render(
<FolderView
{...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: snapDiff, onOpenEntry })}
/>
)
fireEvent.dblClick(screen.getByText('a.txt'))
expect(onOpenEntry).toHaveBeenCalledWith(CUR_ENTRIES[0])
fireEvent.dblClick(screen.getByText('gone.txt'))
expect(onOpenEntry).toHaveBeenCalledTimes(1)
})
it('退出对照恢复常规视图', () => {
const onExitSnapshot = vi.fn()
const { rerender } = render(
<FolderView
{...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: snapDiff, onExitSnapshot })}
/>
)
fireEvent.click(screen.getByRole('button', { name: '退出对照' }))
expect(onExitSnapshot).toHaveBeenCalledTimes(1)
// App 清空状态后传 null:恢复常规统计行
rerender(<FolderView {...fullProps(CUR_ENTRIES, { snapshot: null, snapshotDiff: null })} />)
expect(screen.getByText('相同 2')).toBeInTheDocument()
expect(screen.queryByText(/已修复 \d/)).not.toBeInTheDocument()
})
it('对照条目全一致时空态提示', () => {
// 双侧均无差异(same)→ 全部 stable → 默认隐藏 → 空态提示
const allSame: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'b.txt', status: 'same', leftSize: 1, rightSize: 1 }
]
const stableDiff = diffSnapshot(allSame, allSame)
render(
<FolderView
{...fullProps(allSame, { snapshot: snap, snapshotDiff: stableDiff })}
/>
)
expect(screen.getByText('与快照基线完全一致,没有任何变迁')).toBeInTheDocument()
})
})
describe('FolderView - 其他', () => {
it('切换语义判等开关触发 onSemanticChange', () => {
const onSemanticChange = vi.fn()
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={6}
scanning={false}
onlyDiff={true}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={onSemanticChange}
onReopen={vi.fn()}
onRescan={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
render(<FolderView {...fullProps(ENTRIES, { onSemanticChange })} />)
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()}
/>
)
const { rerender } = render(<FolderView {...fullProps(ENTRIES, { onRescan })} />)
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()}
/>
)
rerender(<FolderView {...fullProps(ENTRIES, { scanning: true, onRescan })} />)
expect(screen.getByRole('button', { name: '重新扫描' })).toBeDisabled()
// 快照按钮扫描中同样禁用
expect(screen.getByRole('button', { name: '保存快照' })).toBeDisabled()
expect(screen.getByRole('button', { name: '加载快照' })).toBeDisabled()
})
it('保存/加载快照按钮触发回调', () => {
const onSaveSnapshot = vi.fn()
const onLoadSnapshot = vi.fn()
render(
<FolderView {...fullProps(ENTRIES, { onSaveSnapshot, onLoadSnapshot })} />
)
fireEvent.click(screen.getByRole('button', { name: '保存快照' }))
expect(onSaveSnapshot).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
expect(onLoadSnapshot).toHaveBeenCalledTimes(1)
})
it('导出报告菜单选择格式触发 onExport 并关闭菜单', () => {
const onExport = vi.fn()
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={vi.fn()}
onExit={vi.fn()}
onExport={onExport}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
render(<FolderView {...fullProps(ENTRIES, { onExport })} />)
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
fireEvent.click(screen.getByText('HTML 报告'))
expect(onExport).toHaveBeenCalledWith('html')
@@ -554,50 +511,12 @@ describe('FolderView - 其他', () => {
})
it('扫描中禁用导出按钮', () => {
render(
<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={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
render(<FolderView {...fullProps(ENTRIES, { scanning: true })} />)
expect(screen.getByRole('button', { name: '导出报告' })).toBeDisabled()
})
it('点击外部区域关闭导出菜单', () => {
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={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
onOpenDir={vi.fn()}
/>
)
render(<FolderView {...fullProps(ENTRIES)} />)
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
expect(screen.getByText('纯文本')).toBeInTheDocument()
fireEvent.mouseDown(document.body)
@@ -620,24 +539,7 @@ describe('FolderView - 其他', () => {
it('截断时展示文件过多提示', () => {
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES.slice(0, 2)}
truncated={true}
total={9999}
scanning={false}
onlyDiff={true}
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()}
/>
<FolderView {...fullProps(ENTRIES.slice(0, 2), { truncated: true, total: 9999 })} />
)
expect(screen.getByText(/文件过多/)).toBeInTheDocument()
})
+357 -153
View File
@@ -10,6 +10,7 @@ import {
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
import { type ReportFormat } from '../diff/report'
import { countFolderStats, fmtSize } from '../diff/folderReport'
import type { FolderSnapshot, SnapshotChange, SnapshotDiffResult } from '../diff/snapshotDiff'
import { useDismiss } from '../hooks/useDismiss'
import { buildFolderTree, filterEntries, flattenFolderTree, relExt } from './folderTree'
@@ -21,6 +22,8 @@ const OVERSCAN = 8
const FALLBACK_VIEWPORT = 600
/** 树形缩进步进(px / 深度) */
const TREE_INDENT = 16
/** 忽略规则条数上限(与主进程 ignoreRules.ts 的 IGNORE_RULES_MAX 保持一致,改动需双向同步) */
const IGNORE_RULES_MAX = 50
export interface FolderViewProps {
leftDir: string
@@ -35,6 +38,19 @@ export interface FolderViewProps {
/** 语义判等开关(字节不同但剔除空白后一致的文件计为“语义同”);切换后重新扫描 */
semantic: boolean
onSemanticChange: (v: boolean) => void
/** 忽略规则(gitignore 简化子集:`dir/` 目录剪枝、`*.ext` 通配、`name` 名字匹配);应用后重新扫描 */
ignoreRules: string[]
onIgnoreRulesChange: (rules: string[]) => void
/** 快照基线(null = 非对照模式;对照模式接管列表渲染) */
snapshot: FolderSnapshot | null
/** 快照对照结果(null = 非对照模式) */
snapshotDiff: SnapshotDiffResult | null
/** 保存当前对比结果为快照基线 */
onSaveSnapshot: () => void
/** 加载快照文件并与当前结果对照 */
onLoadSnapshot: () => void
/** 退出快照对照 */
onExitSnapshot: () => void
/** 重新选择两侧文件夹 */
onReopen: () => void
/** 不重选目录直接重新扫描(增量:指纹一致的条目零 IO 沿用) */
@@ -75,6 +91,16 @@ const BADGES: { status: FolderEntryStatus; countKey: keyof ReturnType<typeof cou
{ status: 'unreadable', countKey: 'unreadable', text: '无法读取' }
]
/** 快照变迁类型的展示配置(顺序即徽章渲染顺序) */
const SNAP_CHANGES: { key: SnapshotChange; text: string }[] = [
{ key: 'fixed', text: '已修复' },
{ key: 'regressed', text: '新增差异' },
{ key: 'new', text: '新文件' },
{ key: 'gone', text: '已移除' },
{ key: 'changed', text: '仍有差异' },
{ key: 'stable', text: '保持一致' }
]
export default function FolderView({
leftDir,
rightDir,
@@ -86,6 +112,13 @@ export default function FolderView({
onOnlyDiffChange,
semantic,
onSemanticChange,
ignoreRules,
onIgnoreRulesChange,
snapshot,
snapshotDiff,
onSaveSnapshot,
onLoadSnapshot,
onExitSnapshot,
onReopen,
onRescan,
onExit,
@@ -99,7 +132,7 @@ export default function FolderView({
const closeExport = useCallback((): void => setExportOpen(false), [])
useDismiss(exportWrapRef, closeExport)
// 搜索(相对路径子串,不区分大小写)
// 搜索(相对路径子串,不区分大小写;对照模式同样生效
const [query, setQuery] = useState('')
// 状态精确过滤(null = 不过滤;与 onlyDiff 互斥)
const [statusFilter, setStatusFilter] = useState<Set<FolderEntryStatus> | null>(null)
@@ -113,6 +146,34 @@ export default function FolderView({
// 折叠的目录集合(默认全展开;空集合 = 全部展开)
const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(new Set())
// 忽略规则下拉(草稿文本域;打开时以当前规则初始化,应用时解析为规则数组并触发重扫)
const [ignoreOpen, setIgnoreOpen] = useState(false)
const [ignoreDraft, setIgnoreDraft] = useState('')
const ignoreWrapRef = useRef<HTMLDivElement>(null)
const closeIgnore = useCallback((): void => setIgnoreOpen(false), [])
useDismiss(ignoreWrapRef, closeIgnore)
// 快照对照模式的变迁类型过滤(null = 不过滤;单选 toggle
const [changeFilter, setChangeFilter] = useState<SnapshotChange | null>(null)
/** 应用忽略规则草稿:逐行解析、去空白、去空行,截断至上限后回调重扫 */
const applyIgnoreDraft = useCallback((): void => {
const rules = ignoreDraft
.split('\n')
.map((r) => r.trim())
.filter((r) => r !== '')
.slice(0, IGNORE_RULES_MAX)
setIgnoreOpen(false)
onIgnoreRulesChange(rules)
}, [ignoreDraft, onIgnoreRulesChange])
/** 清空全部忽略规则(立即生效并重扫) */
const clearIgnore = useCallback((): void => {
setIgnoreDraft('')
setIgnoreOpen(false)
onIgnoreRulesChange([])
}, [onIgnoreRulesChange])
// 统计(条目集不变时不重算;口径与报告导出共用同一实现,始终基于全量条目)
const stats = useMemo(() => countFolderStats(entries), [entries])
@@ -137,6 +198,22 @@ export default function FolderView({
return flattenFolderTree(tree, isExpanded)
}, [entries, onlyDiff, statusFilter, query, extFilter, collapsedDirs])
/**
* stable"保持一致" */
const snapList = useMemo(() => {
if (snapshotDiff === null) return null
const q = query.trim().toLowerCase()
return snapshotDiff.entries.filter((e) => {
if (changeFilter === null) {
if (e.change === 'stable') return false
} else if (e.change !== changeFilter) {
return false
}
if (q !== '' && !e.rel.toLowerCase().includes(q)) return false
return true
})
}, [snapshotDiff, query, changeFilter])
/** 过滤后的文件数("共 N / 总数"计数用,不含目录行) */
const filteredCount = useMemo(
() => filterEntries(entries, { onlyDiff, statusFilter, query, exts: extFilter }).length,
@@ -185,23 +262,29 @@ export default function FolderView({
})
}, [])
// 虚拟滚动:固定行高换算可见窗口(与 DiffView 同思路)
/** 快照变迁徽章点击:单选 toggle(再点取消) */
const handleSnapBadge = useCallback((change: SnapshotChange): void => {
setChangeFilter((prev) => (prev === change ? null : change))
}, [])
// 虚拟滚动:固定行高换算可见窗口(与 DiffView 同思路;对照模式与树形模式共用窗口状态)
const scrollRef = useRef<HTMLDivElement>(null)
const [range, setRange] = useState({ start: 0, end: 0 })
const listCount = snapList !== null ? snapList.length : rows.length
const updateRange = (): void => {
const el = scrollRef.current
if (!el) return
const vh = el.clientHeight || FALLBACK_VIEWPORT
const start = Math.max(0, Math.floor(el.scrollTop / FOLDER_ROW_HEIGHT) - OVERSCAN)
const end = Math.min(rows.length, Math.ceil((el.scrollTop + vh) / FOLDER_ROW_HEIGHT) + OVERSCAN)
const end = Math.min(listCount, Math.ceil((el.scrollTop + vh) / FOLDER_ROW_HEIGHT) + OVERSCAN)
setRange((prev) => (prev.start === start && prev.end === end ? prev : { start, end }))
}
useLayoutEffect(() => {
updateRange()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows])
}, [listCount])
// 窗口尺寸变化时按新视口高度补齐渲染窗口(与 DiffView 同思路;
// 缺失该监听时拉伸窗口变高后底部出现空白区,滚动一下才恢复)
@@ -210,7 +293,7 @@ export default function FolderView({
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows])
}, [listCount])
const q = query.trim()
@@ -235,6 +318,22 @@ export default function FolderView({
>
</button>
<button
className="btn ghost"
disabled={scanning}
title={scanning ? '正在扫描文件夹' : '保存当前对比结果为快照基线(JSON 文件),供日后加载对照'}
onClick={onSaveSnapshot}
>
</button>
<button
className="btn ghost"
disabled={scanning}
title={scanning ? '正在扫描文件夹' : '加载之前保存的快照,与当前结果对照(已修复 / 新增差异 / 新文件 / 已移除)'}
onClick={onLoadSnapshot}
>
</button>
<button className="btn ghost" onClick={onReopen}>
</button>
@@ -270,161 +369,266 @@ export default function FolderView({
</div>
</div>
{/* 工具行:统计(可点击过滤)+ 搜索 + 开关 */}
<div className="folder-toolbar">
<div className="folder-stats">
{BADGES.map((b) => {
// 语义关闭时语义同恒为 0,徽章隐藏(与旧版一致)
if (b.status === 'semantic-same' && !semantic) return null
// 无法读取为异常态:仅在出现时展示,平时不占位
if (b.status === 'unreadable' && stats.unreadable === 0) return null
const active = statusFilter?.has(b.status) ?? false
return (
<button
key={b.status}
className={`fstat ${STATUS_META[b.status].cls}${active ? ' active' : ''}`}
title={
active
? `点击取消“${STATUS_META[b.status].label}”过滤`
: `点击只看“${STATUS_META[b.status].label}”的文件`
}
onClick={() => handleBadge(b.status)}
>
{b.text} {stats[b.countKey]}
</button>
)
})}
<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)}>
{/* 工具行:对照模式渲染变迁统计,普通模式渲染状态统计 + 搜索 + 开关 */}
{snapshotDiff !== null && snapshot !== null ? (
<div className="folder-toolbar">
<div className="folder-stats">
{SNAP_CHANGES.map((c) => {
const n = snapshotDiff.stats[c.key]
// 零计数与保持一致的常态不占位(保持一致仅在存在时展示)
if (n === 0) return null
const active = changeFilter === c.key
return (
<button
key={c.key}
className={`fstat snap-${c.key}${active ? ' active' : ''}`}
title={
active
? `点击取消“${c.text}”过滤`
: `点击只看“${c.text}”的条目(基于 ${new Date(snapshot.savedAt).toLocaleString('zh-CN')} 的快照基线)`
}
onClick={() => handleSnapBadge(c.key)}
>
{c.text} {n}
</button>
)}
</div>
)
})}
<span className="fstat muted" title="对照条目总数(快照与当前结果的并集)">
{snapList !== null ? `${snapList.length} / ${snapshotDiff.entries.length}` : snapshotDiff.entries.length}
</span>
</div>
<span className="fstat muted" title="快照基线的保存时间与判定口径">
线 {new Date(snapshot.savedAt).toLocaleString('zh-CN')}
{snapshot.semantic ? ' · 语义判等' : ''}
</span>
<input
className="folder-search"
type="text"
value={query}
placeholder="搜索文件名…"
spellCheck={false}
onChange={(e) => setQuery(e.target.value)}
/>
<button className="btn ghost" onClick={onExitSnapshot} title="退出快照对照,恢复常规视图">
退
</button>
</div>
) : (
<div className="folder-toolbar">
<div className="folder-stats">
{BADGES.map((b) => {
// 语义关闭时语义同恒为 0,徽章隐藏(与旧版一致)
if (b.status === 'semantic-same' && !semantic) return null
// 无法读取为异常态:仅在出现时展示,平时不占位
if (b.status === 'unreadable' && stats.unreadable === 0) return null
const active = statusFilter?.has(b.status) ?? false
return (
<button
key={b.status}
className={`fstat ${STATUS_META[b.status].cls}${active ? ' active' : ''}`}
title={
active
? `点击取消“${STATUS_META[b.status].label}”过滤`
: `点击只看“${STATUS_META[b.status].label}”的文件`
}
onClick={() => handleBadge(b.status)}
>
{b.text} {stats[b.countKey]}
</button>
)
})}
<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>
<div className="export-wrap" ref={ignoreWrapRef}>
<button
className={'btn ghost' + (ignoreRules.length > 0 ? ' filtered' : '')}
title="忽略规则(gitignore 简化子集):node_modules/ 目录整树剪枝、*.log 扩展名通配、temp 名字匹配;应用后重新扫描"
onClick={() => {
setIgnoreDraft(ignoreRules.join('\n'))
setIgnoreOpen((o) => !o)
}}
>
{ignoreRules.length > 0 ? ` · ${ignoreRules.length}` : ''}
</button>
{ignoreOpen && (
<div className="export-menu ignore-menu">
<textarea
className="ignore-input"
value={ignoreDraft}
rows={6}
spellCheck={false}
placeholder={'每行一条(gitignore 简化子集):\nnode_modules/ 目录整树剪枝\n*.log 扩展名通配\ntemp 名字匹配'}
onChange={(e) => setIgnoreDraft(e.target.value)}
/>
<button className="ctx-item" onClick={applyIgnoreDraft}>
</button>
<button className="ctx-item ext-clear" onClick={clearIgnore}>
</button>
</div>
)}
</div>
<input
className="folder-search"
type="text"
value={query}
placeholder="搜索文件名…"
spellCheck={false}
onChange={(e) => setQuery(e.target.value)}
/>
<label
className="switch"
title="字节不同但剔除空白(换行符/缩进/空行)后内容一致的文件计为“语义同”,不计入差异;切换后重新扫描"
>
<input
type="checkbox"
checked={semantic}
onChange={(e) => onSemanticChange(e.target.checked)}
/>
</label>
<label className="switch" title="隐藏两侧内容一致的文件(含语义同),专注差异">
<input
type="checkbox"
checked={onlyDiff}
onChange={(e) => handleOnlyDiff(e.target.checked)}
/>
</label>
{truncated && (
<span className="fstat warn" title="文件数量超出上限,以下仅展示部分条目">
{total}+ {entries.length}
</span>
)}
</div>
<input
className="folder-search"
type="text"
value={query}
placeholder="搜索文件名…"
spellCheck={false}
onChange={(e) => setQuery(e.target.value)}
/>
<label
className="switch"
title="字节不同但剔除空白(换行符/缩进/空行)后内容一致的文件计为“语义同”,不计入差异;切换后重新扫描"
>
<input
type="checkbox"
checked={semantic}
onChange={(e) => onSemanticChange(e.target.checked)}
/>
</label>
<label className="switch" title="隐藏两侧内容一致的文件(含语义同),专注差异">
<input
type="checkbox"
checked={onlyDiff}
onChange={(e) => handleOnlyDiff(e.target.checked)}
/>
</label>
{truncated && (
<span className="fstat warn" title="文件数量超出上限,以下仅展示部分条目">
{total}+ {entries.length}
</span>
)}
</div>
)}
{/* 条目列表目录树 + 虚拟滚动 */}
{/* 条目列表:对照模式平铺对照条目,普通模式目录树 + 虚拟滚动 */}
<div className="folder-scroll" ref={scrollRef} onScroll={updateRange}>
<div style={{ height: rows.length * FOLDER_ROW_HEIGHT }}>
{rows.slice(range.start, range.end).map((row) => {
const n = row.node
const indent = { paddingLeft: 18 + row.depth * TREE_INDENT }
if (n.isDir) {
const collapsed = q === '' && collapsedDirs.has(n.path)
const meta = STATUS_META[n.agg ?? 'same']
return (
<div
className={`folder-row dir ${meta.cls}`}
key={`dir-${n.path}`}
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>
<span className={`folder-agg ${meta.cls}`}>{meta.label}</span>
<span className="folder-dircount">{n.fileCount ?? 0} </span>
</div>
)
}
const e = n.entry!
const meta = STATUS_META[e.status]
return (
<div
className={'folder-row ' + meta.cls}
key={e.rel}
style={indent}
title={`双击对比:${e.rel}${e.status === 'semantic-same' ? '(语义同:仅空白/换行差异)' : ''}${e.approximate ? '(超大文件,近似判定)' : ''}`}
onDoubleClick={() => onOpenEntry(e)}
>
<span className="folder-st-icon">{meta.icon}</span>
<span className="folder-rel">{n.name}</span>
{e.approximate && <span className="folder-approx"></span>}
<span className="folder-size">{fmtSize(e.leftSize)}</span>
<span className="folder-size">{fmtSize(e.rightSize)}</span>
</div>
)
})}
<div style={{ height: listCount * FOLDER_ROW_HEIGHT }}>
{snapList !== null
? snapList.slice(range.start, range.end).map((e) => {
// 展示条目:当前结果优先,已移除条目回退快照数据
const display = e.current ?? e.baseline!
const meta = STATUS_META[display.status]
const sc = SNAP_CHANGES.find((c) => c.key === e.change)!
return (
<div
className={'folder-row ' + meta.cls}
key={e.rel}
style={{ paddingLeft: 18 }}
title={`${e.rel} · 快照对照:${sc.text}${e.current ? '' : '(当前已不存在,大小为快照值)'}`}
onDoubleClick={() => {
if (e.current) onOpenEntry(e.current)
}}
>
<span className="folder-st-icon">{meta.icon}</span>
<span className="folder-rel">{e.rel}</span>
<span className={`snap-badge snap-b-${e.change}`}>{sc.text}</span>
<span className="folder-size">{fmtSize(display.leftSize)}</span>
<span className="folder-size">{fmtSize(display.rightSize)}</span>
</div>
)
})
: rows.slice(range.start, range.end).map((row) => {
const n = row.node
const indent = { paddingLeft: 18 + row.depth * TREE_INDENT }
if (n.isDir) {
const collapsed = q === '' && collapsedDirs.has(n.path)
const meta = STATUS_META[n.agg ?? 'same']
return (
<div
className={`folder-row dir ${meta.cls}`}
key={`dir-${n.path}`}
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>
<span className={`folder-agg ${meta.cls}`}>{meta.label}</span>
<span className="folder-dircount">{n.fileCount ?? 0} </span>
</div>
)
}
const e = n.entry!
const meta = STATUS_META[e.status]
return (
<div
className={'folder-row ' + meta.cls}
key={e.rel}
style={indent}
title={`双击对比:${e.rel}${e.status === 'semantic-same' ? '(语义同:仅空白/换行差异)' : ''}${e.approximate ? '(超大文件,近似判定)' : ''}`}
onDoubleClick={() => onOpenEntry(e)}
>
<span className="folder-st-icon">{meta.icon}</span>
<span className="folder-rel">{n.name}</span>
{e.approximate && <span className="folder-approx"></span>}
<span className="folder-size">{fmtSize(e.leftSize)}</span>
<span className="folder-size">{fmtSize(e.rightSize)}</span>
</div>
)
})}
</div>
{!scanning && rows.length === 0 && (
{!scanning && listCount === 0 && (
<div className="folder-empty-note">
{q !== ''
? `没有匹配“${q}”的文件`
: entries.length === 0
? '两个文件夹内都没有文件'
: statusFilter !== null || extFilter !== null
? '当前过滤条件下没有文件'
: '没有差异文件(两侧内容全部一致)'}
{snapList !== null
? q !== ''
? `没有匹配“${q}”的条目`
: changeFilter !== null
? `没有“${SNAP_CHANGES.find((c) => c.key === changeFilter)!.text}”的条目`
: '与快照基线完全一致,没有任何变迁'
: q !== ''
? `没有匹配“${q}”的文件`
: entries.length === 0
? '两个文件夹内都没有文件'
: statusFilter !== null || extFilter !== null
? '当前过滤条件下没有文件'
: '没有差异文件(两侧内容全部一致)'}
</div>
)}
</div>
+1 -1
View File
@@ -135,7 +135,7 @@ export default function Toolbar({
</label>
<label
className="switch"
title="空白与换行结构全部不参与判等,差异以字符粒度定位(跨行重组也能判等);相同内容自动锚定跳过计算,大文件不再整篇受限;差异过大时降级行级对比"
title="空白与换行结构全部不参与判等,差异以字符粒度定位(跨行重组也能判等);相同内容自动锚定跳过计算,大文件不再整篇受限;变更组预算随输入规模自适应;差异过大时降级行级对比"
>
<input
type="checkbox"
+90 -2
View File
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest'
import { computeDiff, CHAR_DIFF_MAX_CHARS, type Seg } from './diffEngine'
import {
computeDiff,
charGroupBudget,
CHAR_DIFF_MAX_CHARS,
CHAR_DIFF_GROUP_TOTAL_CHARS,
CHAR_DIFF_BUDGET_MAX,
type Seg
} from './diffEngine'
/** 拼接 segs 文本(信息无损校验:拼接结果必须等于原始行文本) */
const joinSegs = (segs: Seg[] | null): string => (segs ?? []).map((s) => s.text).join('')
@@ -565,7 +572,8 @@ describe('computeDiff - 字符级对比(分块锚定)', () => {
})
it('组总流超预算:预算内组字符级、预算外组行级降级(部分降级,不再整体降级)', async () => {
// 8 个变更组,每组流 6500 + 6501 = 13001(在单组上限内,编辑距离 1):
// 8 个变更组,每组流 6500 + 6501 = 13001(在单组上限内,编辑距离 1),总流 104008
// 自适应预算 = max(10 万, 52004) = 10 万(小输入取下限,与历史固定预算行为一致),
// 前 7 组消耗 91007,第 8 组 13001 > 剩余 8993 → 行级配对(partial
const build = (withX: boolean): string => {
const lines: string[] = []
@@ -818,3 +826,83 @@ describe('computeDiff - 字符级对比(字符换位专项)', () => {
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
})
})
describe('charGroupBudget - 预算自适应(函数边界)', () => {
it('小输入与空输入取下限(与历史固定预算一致)', () => {
expect(charGroupBudget(0)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
expect(charGroupBudget(1000)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
expect(charGroupBudget(CHAR_DIFF_GROUP_TOTAL_CHARS)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
expect(charGroupBudget(CHAR_DIFF_GROUP_TOTAL_CHARS * 2)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
})
it('大输入按总组流一半放宽(向上取整)', () => {
// 总流 200002 → ceil(100001)
expect(charGroupBudget(200002)).toBe(100001)
// 总流 480800 → 240400
expect(charGroupBudget(480800)).toBe(240400)
})
it('超上限封顶', () => {
expect(charGroupBudget(CHAR_DIFF_BUDGET_MAX * 2)).toBe(CHAR_DIFF_BUDGET_MAX)
expect(charGroupBudget(100_000_000)).toBe(CHAR_DIFF_BUDGET_MAX)
})
it('非有限输入回退下限', () => {
expect(charGroupBudget(NaN)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
expect(charGroupBudget(Infinity)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
expect(charGroupBudget(-5)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
})
})
describe('computeDiff - 预算自适应行为固化', () => {
it('大输入跨行重组组:预算随总流放宽,完成字符级的组多于固定预算', () => {
// 每组左右字符流完全一致但行结构不同(左 1 行 / 右 2 行):
// - 字符级组:组内流全等 → 无变更字符 → 全部行按锚行输出(unchanged,0 变更行)
// - 行级组:行归一化无判等行 → 左 1 行与右 2 行配对 → modified 1 + added 1
// 组流 = (600+1)×2 = 1202400 组总流 480800 → 自适应预算 240400 →
// 前 200 组(240400/1202=200 整)字符级,第 201 组起行级。
// 历史固定预算 10 万只能完成 83 组——行为放宽由此固化。
const build = (side: 'left' | 'right'): string => {
const lines: string[] = []
for (let i = 0; i < 400; i++) {
lines.push(`same-${i}`)
if (side === 'left') {
lines.push('a'.repeat(600) + 'K')
} else {
lines.push('a'.repeat(300))
lines.push('a'.repeat(300) + 'K')
}
}
return lines.join('\n')
}
const result = computeDiff(build('left'), build('right'), { charMode: true })
expect(result.charModeDowngraded).toBeUndefined()
expect(result.charModePartial).toBe(true)
// 200 个行级组各产出 1 modified + 1 added;字符级组 0 变更行
expect(result.summary.modified).toBe(200)
expect(result.summary.inserted).toBe(200)
expect(result.summary.changedLines).toBe(400)
// 行号单调
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('总流未超下限两倍:行为与历史固定预算完全一致(回归锚)', () => {
// 总流 104008 < 20 万:预算仍为下限 10 万,部分降级切分与 0.8.0 固化行为一致
const build = (withX: boolean): string => {
const lines: string[] = []
for (let i = 0; i < 8; i++) {
lines.push(`same-${i}`)
lines.push('a'.repeat(6500) + (withX ? 'X' : ''))
}
return lines.join('\n')
}
const result = computeDiff(build(false), build(true), { charMode: true })
expect(result.charModePartial).toBe(true)
expect(
result.rows.filter((r) => r.rowKind === 'modified' && r.left.segs === null)
).toHaveLength(1)
})
})
+34 -7
View File
@@ -47,7 +47,8 @@ export interface DiffOptions {
* diff
* ignoreAllWhitespace
* CHAR_DIFF_MAX_CHARSCHAR_DIFF_MAX_EDIT
* charModeDowngraded
* charModeDowngraded
* charGroupBudget
*/
charMode?: boolean
/**
@@ -315,12 +316,33 @@ export const CHAR_DIFF_MAX_CHARS = 50000
export const CHAR_DIFF_MAX_EDIT = 5000
/**
*
* 5
*
*
*
*/
export const CHAR_DIFF_GROUP_TOTAL_CHARS = CHAR_DIFF_MAX_CHARS * 2
/**
* ×
* worker
*/
export const CHAR_DIFF_BUDGET_MAX = CHAR_DIFF_MAX_CHARS * 20
/**
*
* [CHAR_DIFF_GROUP_TOTAL_CHARS, CHAR_DIFF_BUDGET_MAX]
*
*
*/
export function charGroupBudget(totalGroupChars: number): number {
if (!Number.isFinite(totalGroupChars) || totalGroupChars <= 0) {
return CHAR_DIFF_GROUP_TOTAL_CHARS
}
return Math.min(
CHAR_DIFF_BUDGET_MAX,
Math.max(CHAR_DIFF_GROUP_TOTAL_CHARS, Math.ceil(totalGroupChars / 2))
)
}
/**
* worker
* CHAR_DIFF_MAX_EDIT ×
@@ -487,11 +509,16 @@ function computeCharDiff(
/**
* + CHAR_DIFF_MAX_CHARS
* diffChars / /
*
* null
* charGroupBudget
* null
*
*/
const groupParts: Array<Change[] | null> = []
let budget = CHAR_DIFF_GROUP_TOTAL_CHARS
const totalGroupChars = streams.reduce(
(acc, s) => acc + s.ls.text.length + s.rs.text.length,
0
)
let budget = charGroupBudget(totalGroupChars)
let anyChar = false
for (const { ls, rs } of streams) {
const n = ls.text.length + rs.text.length
+5
View File
@@ -18,6 +18,8 @@ export interface FolderReportContext {
truncated: boolean
/** 枚举总数(截断时为已遍历下限) */
total: number
/** 快照对照说明(快照对照模式导出时附注变迁统计与基线时间) */
snapshotNote?: string
}
/** 六类状态统计 */
@@ -170,6 +172,7 @@ function buildFolderHtml(
<div class="hero-title">📁 DiffLens </div>
<div class="hero-time"> ${new Date().toLocaleString('zh-CN')}</div>
<div class="hero-opts">${esc(semLine)}</div>
${ctx.snapshotNote ? `<div class="hero-opts">快照对照:${esc(ctx.snapshotNote)}</div>` : ''}
<div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainFolderSummary(stats, entries.length))}</div>
<div class="files">
<div class="file-card left">
@@ -219,6 +222,7 @@ function buildFolderTxt(
`右侧目录:${ctx.rightDir}`,
`对比时间:${new Date().toLocaleString('zh-CN')}`,
`判定口径:${ctx.semantic ? '语义判等开启(空白/换行差异计为语义同)' : '字节级严格比对'}`,
...(ctx.snapshotNote ? [`快照对照:${ctx.snapshotNote}`] : []),
`结论:${plainFolderSummary(stats, entries.length)}`,
`统计:相同 ${stats.same} 语义同 ${stats.semanticSame} 不同 ${stats.different} 仅左 ${stats.leftOnly} 仅右 ${stats.rightOnly}${stats.unreadable > 0 ? ` 无法读取 ${stats.unreadable}` : ''} ${entries.length}`
]
@@ -257,6 +261,7 @@ function buildFolderMd(
`> 右侧目录:${ctx.rightDir}`,
`> 对比时间:${new Date().toLocaleString('zh-CN')}`,
`> 判定口径:${ctx.semantic ? '语义判等开启(空白/换行差异计为语义同)' : '字节级严格比对'}`,
...(ctx.snapshotNote ? [`> 快照对照:${ctx.snapshotNote}`] : []),
`> 结论:${plainFolderSummary(stats, entries.length)}`,
`> 统计:相同 ${stats.same} 语义同 ${stats.semanticSame} 不同 ${stats.different} 仅左 ${stats.leftOnly} 仅右 ${stats.rightOnly}${stats.unreadable > 0 ? ` 无法读取 ${stats.unreadable}` : ''} ${entries.length}`,
''
+2 -2
View File
@@ -187,12 +187,12 @@ describe('plainOptions - 比较选项描述', () => {
it('字符级对比单独描述并遮蔽全部空白类选项', () => {
expect(
plainOptions({ charMode: true, trimWhitespace: true, ignoreAllWhitespace: true, ignoreBlankLines: true })
).toBe('字符级对比(忽略全部空白与换行,大内容自动分块计算)')
).toBe('字符级对比(忽略全部空白与换行,大内容自动分块计算,预算随规模自适应')
})
it('字符级对比与忽略大小写组合列出', () => {
expect(plainOptions({ charMode: true, ignoreCase: true })).toBe(
'字符级对比(忽略全部空白与换行,大内容自动分块计算)、忽略大小写'
'字符级对比(忽略全部空白与换行,大内容自动分块计算,预算随规模自适应)、忽略大小写'
)
})
+2 -2
View File
@@ -127,12 +127,12 @@ export function plainOptions(
extra?: { charModePartial?: boolean }
): string {
// 字符级对比为最强空白语义(全部空白与换行都不参与判等),单独描述并遮蔽空白类子选项;
// 大内容按“行锚定 + 变更组分块”计算,不再整篇受字符量上限约束;
// 大内容按“行锚定 + 变更组分块”计算,不再整篇受字符量上限约束,总预算随输入规模自适应
// 部分降级(逐组预算制)时标注部分差异过大的块按行级对比
if (options?.charMode) {
const base = extra?.charModePartial
? '字符级对比(忽略全部空白与换行;部分差异过大的块按行级对比)'
: '字符级对比(忽略全部空白与换行,大内容自动分块计算)'
: '字符级对比(忽略全部空白与换行,大内容自动分块计算,预算随规模自适应'
const on = [base]
if (options.ignoreCase) on.push('忽略大小写')
return on.join('、')
+158
View File
@@ -0,0 +1,158 @@
import { describe, it, expect } from 'vitest'
import type { FolderEntry } from '../../../preload/index'
import {
parseSnapshot,
diffSnapshot,
snapshotDirsMatch,
isDiffStatus,
type FolderSnapshot
} from './snapshotDiff'
/** 构造合法快照 */
function makeSnapshot(overrides?: Partial<FolderSnapshot>): FolderSnapshot {
return {
version: 1,
leftDir: 'C:/left',
rightDir: 'D:/right',
semantic: true,
truncated: false,
total: 2,
entries: [
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'b.txt', status: 'different', leftSize: 1, rightSize: 2 }
],
savedAt: 1700000000000,
...overrides
}
}
describe('parseSnapshot - 解析校验', () => {
it('合法快照完整解析', () => {
const snap = makeSnapshot()
const parsed = parseSnapshot(JSON.stringify(snap))
expect(parsed).toEqual(snap)
})
it('非法 JSON / 版本不符 / 顶层字段异常回退 null', () => {
expect(parseSnapshot('{broken json!!')).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), version: 2 }))).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), leftDir: 42 }))).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), semantic: 'yes' }))).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), savedAt: 'no-time' }))).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), total: null }))).toBeNull()
})
it('任一条目形态异常整体回退 null(绝不部分加载)', () => {
const bad1 = JSON.stringify({
...makeSnapshot(),
entries: [
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'b.txt', status: 'unknown', leftSize: 1, rightSize: 2 }
]
})
expect(parseSnapshot(bad1)).toBeNull()
const bad2 = JSON.stringify({
...makeSnapshot(),
entries: [{ rel: '', status: 'same', leftSize: 1, rightSize: 1 }]
})
expect(parseSnapshot(bad2)).toBeNull()
const bad3 = JSON.stringify({
...makeSnapshot(),
entries: [{ rel: 'a', status: 'same', leftSize: 'big', rightSize: 1 }]
})
expect(parseSnapshot(bad3)).toBeNull()
})
it('条目 approximate 可选字段类型校验', () => {
const ok = JSON.stringify({
...makeSnapshot(),
entries: [{ rel: 'big.bin', status: 'same', leftSize: 1, rightSize: 1, approximate: true }]
})
expect(parseSnapshot(ok)).not.toBeNull()
const bad = JSON.stringify({
...makeSnapshot(),
entries: [{ rel: 'big.bin', status: 'same', leftSize: 1, rightSize: 1, approximate: 'yes' }]
})
expect(parseSnapshot(bad)).toBeNull()
})
})
describe('isDiffStatus / snapshotDirsMatch', () => {
it('different / only / unreadable 计为有差异,same / semantic-same 不计', () => {
expect(isDiffStatus('different')).toBe(true)
expect(isDiffStatus('left-only')).toBe(true)
expect(isDiffStatus('right-only')).toBe(true)
expect(isDiffStatus('unreadable')).toBe(true)
expect(isDiffStatus('same')).toBe(false)
expect(isDiffStatus('semantic-same')).toBe(false)
})
it('目录对一致才可对照', () => {
const snap = makeSnapshot()
expect(snapshotDirsMatch(snap, 'C:/left', 'D:/right')).toBe(true)
expect(snapshotDirsMatch(snap, 'C:/other', 'D:/right')).toBe(false)
expect(snapshotDirsMatch(snap, 'C:/left', 'D:/other')).toBe(false)
})
})
describe('diffSnapshot - 状态变迁对照', () => {
const base: FolderEntry[] = [
{ rel: 'fixed.txt', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'regr.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'gone.txt', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'changed.txt', status: 'left-only', leftSize: 1, rightSize: null },
{ rel: 'stable.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'sem-fixed.txt', status: 'different', leftSize: 1, rightSize: 2 }
]
const cur: FolderEntry[] = [
{ rel: 'fixed.txt', status: 'same', leftSize: 2, rightSize: 2 },
{ rel: 'regr.txt', status: 'different', leftSize: 1, rightSize: 9 },
{ rel: 'new.txt', status: 'right-only', leftSize: null, rightSize: 3 },
{ rel: 'changed.txt', status: 'different', leftSize: 1, rightSize: 5 },
{ rel: 'stable.txt', status: 'semantic-same', leftSize: 1, rightSize: 2 },
{ rel: 'sem-fixed.txt', status: 'semantic-same', leftSize: 1, rightSize: 2 }
]
it('六类变迁全部正确判定并统计', () => {
const r = diffSnapshot(base, cur)
expect(r.stats).toEqual({
fixed: 2, // fixed.txt、sem-fixed.txt(→ semantic-same 也算无差异)
regressed: 1, // regr.txt
new: 1, // new.txt
gone: 1, // gone.txt
changed: 1, // changed.txtleft-only → different
stable: 1 // stable.txtsame → semantic-same 双方均无差异)
})
})
it('条目按 rel 字典序排序并携带双侧原始条目', () => {
const r = diffSnapshot(base, cur)
expect(r.entries.map((e) => e.rel)).toEqual([...r.entries.map((e) => e.rel)].sort())
const gone = r.entries.find((e) => e.rel === 'gone.txt')!
expect(gone.current).toBeNull()
expect(gone.baseline?.status).toBe('different')
const added = r.entries.find((e) => e.rel === 'new.txt')!
expect(added.baseline).toBeNull()
expect(added.current?.status).toBe('right-only')
const both = r.entries.find((e) => e.rel === 'stable.txt')!
expect(both.current?.status).toBe('semantic-same')
expect(both.baseline?.status).toBe('same')
})
it('双侧空集 / 单侧空集', () => {
expect(diffSnapshot([], []).entries).toEqual([])
const onlyCur = diffSnapshot([], cur)
expect(onlyCur.stats.new).toBe(cur.length)
expect(onlyCur.stats.stable).toBe(0)
const onlyBase = diffSnapshot(base, [])
expect(onlyBase.stats.gone).toBe(base.length)
})
it('unreadable 计为有差异(恢复读取后判 same → fixed', () => {
const r = diffSnapshot(
[{ rel: 'locked.txt', status: 'unreadable', leftSize: 1, rightSize: 1 }],
[{ rel: 'locked.txt', status: 'same', leftSize: 1, rightSize: 1 }]
)
expect(r.entries[0].change).toBe('fixed')
})
})
+160
View File
@@ -0,0 +1,160 @@
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
/**
* 线
*
*
*/
/** 快照结构版本(不兼容变更时递增并让旧文件作废) */
export const SNAPSHOT_VERSION = 1
/** 快照文件结构(JSON 落盘,与扫描结果同构 + 目录对与时间戳) */
export interface FolderSnapshot {
version: 1
leftDir: string
rightDir: string
/** 保存时的语义判等开关(对照口径说明用) */
semantic: boolean
truncated: boolean
total: number
entries: FolderEntry[]
/** 保存时间戳 */
savedAt: number
}
/** 条目状态的合法值(解析校验共用) */
const ENTRY_STATUSES: FolderEntryStatus[] = [
'same',
'semantic-same',
'different',
'left-only',
'right-only',
'unreadable'
]
function isEntry(v: unknown): v is FolderEntry {
if (typeof v !== 'object' || v === null) return false
const e = v as Record<string, unknown>
if (typeof e.rel !== 'string' || e.rel === '') return false
if (typeof e.status !== 'string' || !ENTRY_STATUSES.includes(e.status as FolderEntryStatus)) {
return false
}
if (e.leftSize !== null && typeof e.leftSize !== 'number') return false
if (e.rightSize !== null && typeof e.rightSize !== 'number') return false
if (e.approximate !== undefined && typeof e.approximate !== 'boolean') return false
return true
}
/**
* JSON JSON / /
* 退 null
*/
export function parseSnapshot(raw: string): FolderSnapshot | null {
try {
const obj = JSON.parse(raw) as Record<string, unknown>
if (obj.version !== SNAPSHOT_VERSION) return null
if (typeof obj.leftDir !== 'string' || typeof obj.rightDir !== 'string') return null
if (typeof obj.semantic !== 'boolean' || typeof obj.truncated !== 'boolean') return null
if (typeof obj.total !== 'number' || !Number.isFinite(obj.total)) return null
if (typeof obj.savedAt !== 'number' || !Number.isFinite(obj.savedAt)) return null
if (!Array.isArray(obj.entries) || !obj.entries.every(isEntry)) return null
return {
version: 1,
leftDir: obj.leftDir,
rightDir: obj.rightDir,
semantic: obj.semantic,
truncated: obj.truncated,
total: obj.total,
entries: obj.entries,
savedAt: obj.savedAt
}
} catch {
return null
}
}
/** 状态是否计为"有差异"(不同 / 仅一侧 / 无法读取) */
export function isDiffStatus(status: FolderEntryStatus): boolean {
return (
status === 'different' || status === 'left-only' || status === 'right-only' || status === 'unreadable'
)
}
/** 状态变迁类型 */
export type SnapshotChange =
/** 快照有差异 → 现在无差异(same / semantic-same */
| 'fixed'
/** 快照无差异 → 现在有差异 */
| 'regressed'
/** 快照不存在 → 现在存在 */
| 'new'
/** 快照存在 → 现在不存在 */
| 'gone'
/** 快照有差异 → 现在仍有差异 */
| 'changed'
/** 双方都无差异 */
| 'stable'
export interface SnapshotDiffEntry {
rel: string
change: SnapshotChange
/** 当前条目(gone 时为 null */
current: FolderEntry | null
/** 快照条目(new 时为 null */
baseline: FolderEntry | null
}
export interface SnapshotDiffStats {
fixed: number
regressed: number
new: number
gone: number
changed: number
stable: number
}
export interface SnapshotDiffResult {
/** 全部对照条目(按 rel 字典序) */
entries: SnapshotDiffEntry[]
stats: SnapshotDiffStats
}
/** 快照与当前扫描的目录对是否一致(不一致时对照无意义,调用方拦截) */
export function snapshotDirsMatch(
baseline: FolderSnapshot,
leftDir: string,
rightDir: string
): boolean {
return baseline.leftDir === leftDir && baseline.rightDir === rightDir
}
/**
* 线 rel
* "是否有差异"isDiffStatus
* fixed / regressed changed
* new / gone stable
*/
export function diffSnapshot(
baseline: FolderEntry[],
current: FolderEntry[]
): SnapshotDiffResult {
const baseMap = new Map(baseline.map((e) => [e.rel, e]))
const curMap = new Map(current.map((e) => [e.rel, e]))
const rels = [...new Set([...baseMap.keys(), ...curMap.keys()])].sort()
const stats: SnapshotDiffStats = { fixed: 0, regressed: 0, new: 0, gone: 0, changed: 0, stable: 0 }
const entries = rels.map((rel) => {
const b = baseMap.get(rel) ?? null
const c = curMap.get(rel) ?? null
let change: SnapshotChange
if (b === null) change = 'new'
else if (c === null) change = 'gone'
else if (isDiffStatus(b.status) && !isDiffStatus(c.status)) change = 'fixed'
else if (!isDiffStatus(b.status) && isDiffStatus(c.status)) change = 'regressed'
else if (isDiffStatus(b.status) && isDiffStatus(c.status)) change = 'changed'
else change = 'stable'
stats[change]++
return { rel, change, current: c, baseline: b }
})
return { entries, stats }
}
+83
View File
@@ -647,6 +647,89 @@ button:disabled {
overflow: auto;
}
/* ============ 忽略规则下拉(每行一条的文本域 + 操作按钮) ============ */
.export-menu.ignore-menu {
min-width: 300px;
padding: 8px;
}
.ignore-input {
display: block;
width: 100%;
min-height: 130px;
padding: 8px 10px;
margin-bottom: 6px;
border-radius: 8px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
color: var(--text);
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
outline: none;
resize: vertical;
}
.ignore-input:focus {
border-color: var(--accent);
}
.ignore-input::placeholder {
color: var(--muted);
}
/* ============ 快照对照(变迁统计徽章与条目标注) ============ */
.fstat.snap-fixed {
color: var(--add-fg);
border-color: rgba(34, 197, 94, 0.3);
}
.fstat.snap-regressed {
color: var(--del-fg);
border-color: rgba(248, 113, 113, 0.3);
}
.fstat.snap-new {
color: var(--add-fg);
}
.fstat.snap-gone {
color: var(--muted);
}
.fstat.snap-changed {
color: var(--mod-fg);
border-color: rgba(245, 158, 11, 0.3);
}
.fstat.snap-stable {
color: var(--muted);
font-weight: 500;
}
/* 条目行内的变迁徽章 */
.snap-badge {
flex: none;
font-size: 10.5px;
padding: 0 7px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
font-weight: 500;
white-space: nowrap;
}
.snap-badge.snap-b-fixed {
color: var(--add-fg);
}
.snap-badge.snap-b-regressed {
color: var(--del-fg);
}
.snap-badge.snap-b-new {
color: var(--add-fg);
}
.snap-badge.snap-b-gone {
color: var(--muted);
}
.snap-badge.snap-b-changed {
color: var(--mod-fg);
}
.snap-badge.snap-b-stable {
color: var(--muted);
}
/* 激活(已选入过滤集合)的扩展名项:与状态徽章激活态同语言 */
.ctx-item.active {
color: #fff;