From 35fa81df9d270165165c0ef3524857450782312c Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Tue, 18 Aug 2026 22:58:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=200.9.0=20=E5=BF=BD=E7=95=A5=E8=A7=84?= =?UTF-8?q?=E5=88=99=E3=80=81=E5=A2=9E=E9=87=8F=E7=BC=93=E5=AD=98=E8=B7=A8?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=8C=81=E4=B9=85=E5=8C=96=E3=80=81=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=9F=BA=E7=BA=BF=E3=80=81=E5=AD=97=E7=AC=A6=E7=BA=A7?= =?UTF-8?q?=E9=A2=84=E7=AE=97=E8=87=AA=E9=80=82=E5=BA=94=E4=B8=8E=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E5=9F=BA=E5=87=86E2E=20-=20=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E8=A7=84=E5=88=99=EF=BC=88gitignore=E7=AE=80=E5=8C=96=E5=AD=90?= =?UTF-8?q?=E9=9B=86=EF=BC=9A=E7=9B=AE=E5=BD=95=E6=95=B4=E6=A0=91=E5=89=AA?= =?UTF-8?q?=E6=9E=9D=E4=B8=8D=E6=9E=9A=E4=B8=BE=E4=B8=8D=E8=AE=A1=E6=95=B0?= =?UTF-8?q?=E3=80=81=E6=89=A9=E5=B1=95=E5=90=8D=E9=80=9A=E9=85=8D=E4=B8=8E?= =?UTF-8?q?=E5=90=8D=E5=AD=97=E5=8C=B9=E9=85=8D=E3=80=81=E9=9A=8F=E5=81=8F?= =?UTF-8?q?=E5=A5=BD=E6=8C=81=E4=B9=85=E5=8C=96=EF=BC=89=E3=80=81=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E5=A2=9E=E9=87=8F=E7=BC=93=E5=AD=98=E8=90=BD=E7=9B=98?= =?UTF-8?q?userData=E8=B7=A8=E4=BC=9A=E8=AF=9D=E8=87=AA=E5=8A=A8=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=EF=BC=88LRU=208=E5=AF=B9=E3=80=81=E9=98=B2=E5=BE=A1?= =?UTF-8?q?=E6=80=A7=E8=A7=A3=E6=9E=90=E3=80=81=E6=B8=B2=E6=9F=93=E7=AB=AF?= =?UTF-8?q?=E9=9B=B6=E6=94=B9=E5=8A=A8=EF=BC=89=E3=80=81=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=EF=BC=88=E4=BF=9D=E5=AD=98/=E5=8A=A0?= =?UTF-8?q?=E8=BD=BDJSON=E5=AF=B9=E7=85=A7=E5=85=AD=E7=B1=BB=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=8F=98=E8=BF=81=E7=BB=9F=E8=AE=A1=E4=B8=8E=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E6=B3=A8=E3=80=81=E5=AF=B9=E7=85=A7=E9=9A=8F?= =?UTF-8?q?=E9=87=8D=E6=89=AB=E5=88=B7=E6=96=B0=E3=80=81=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E9=99=84=E5=BF=AB=E7=85=A7=E5=8F=A3=E5=BE=84=EF=BC=89=E3=80=81?= =?UTF-8?q?=E5=AD=97=E7=AC=A6=E7=BA=A7=E5=8F=98=E6=9B=B4=E7=BB=84=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E9=9A=8F=E8=BE=93=E5=85=A5=E8=A7=84=E6=A8=A1=E8=87=AA?= =?UTF-8?q?=E9=80=82=E5=BA=94=EF=BC=88=E4=B8=8B=E9=99=90=E4=B8=8E=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E5=80=BC=E4=B8=80=E8=87=B4=E5=B0=8F=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E9=9B=B6=E5=8F=98=E5=8C=96=E3=80=81=E5=A4=A7=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=8C=89=E6=80=BB=E7=BB=84=E6=B5=81=E5=8D=8A=E6=95=B0=E6=94=BE?= =?UTF-8?q?=E5=AE=BD=E4=B8=8A=E9=99=90100=E4=B8=87=E5=B0=81=E9=A1=B6?= =?UTF-8?q?=EF=BC=89=E3=80=81=E5=A4=A7=E6=96=87=E4=BB=B6=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E5=9F=BA=E5=87=86E2E=EF=BC=885=E4=B8=87=E8=A1=8C<15s=E4=B8=8E3?= =?UTF-8?q?00KB=20GBK<10s=E8=80=97=E6=97=B6=E6=96=AD=E8=A8=80=EF=BC=89+?= =?UTF-8?q?=E6=96=B0=E5=8A=9F=E8=83=BDE2E=E3=80=81=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E4=B8=8E=E7=89=88=E6=9C=AC=E5=8F=B7=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- docs/应用开发与版本迭代规范.md | 8 +- docs/测试策略与方案.md | 30 +- e2e/app.spec.ts | 192 ++++++ package.json | 2 +- src/main/folderScan.test.ts | 113 ++++ src/main/folderScan.ts | 23 +- src/main/ignoreRules.test.ts | 133 ++++ src/main/ignoreRules.ts | 91 +++ src/main/index.ts | 60 +- src/main/scanCacheStore.test.ts | 151 +++++ src/main/scanCacheStore.ts | 139 +++++ src/preload/index.ts | 6 +- src/renderer/src/App.tsx | 181 +++++- src/renderer/src/__tests__/App.test.tsx | 301 ++++++++- .../src/components/FolderView.test.tsx | 578 ++++++++---------- src/renderer/src/components/FolderView.tsx | 510 +++++++++++----- src/renderer/src/components/Toolbar.tsx | 2 +- src/renderer/src/diff/diffEngine.test.ts | 92 ++- src/renderer/src/diff/diffEngine.ts | 41 +- src/renderer/src/diff/folderReport.ts | 5 + src/renderer/src/diff/report.test.ts | 4 +- src/renderer/src/diff/report.ts | 4 +- src/renderer/src/diff/snapshotDiff.test.ts | 158 +++++ src/renderer/src/diff/snapshotDiff.ts | 160 +++++ src/renderer/src/styles/global.css | 83 +++ 26 files changed, 2525 insertions(+), 550 deletions(-) create mode 100644 src/main/ignoreRules.test.ts create mode 100644 src/main/ignoreRules.ts create mode 100644 src/main/scanCacheStore.test.ts create mode 100644 src/main/scanCacheStore.ts create mode 100644 src/renderer/src/diff/snapshotDiff.test.ts create mode 100644 src/renderer/src/diff/snapshotDiff.ts diff --git a/README.md b/README.md index 73410b3..a2c586a 100644 --- a/README.md +++ b/README.md @@ -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 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 400a134..5dc3d5e 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -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 为部分降级(特性而非回归,测试固化新行为) - 拖拽导入 E2E:helpers 增 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/savedAt,parseSnapshot 全字段防御校验绝不部分加载;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 ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 39d84d6..6a20a71 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -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 处差异——打开右侧到「有差异」出现耗时 < 15s;300KB 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 配置就绪但从未打包跑通) --- diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 3c9584b..4ee48b9 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -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) + } + }) +}) diff --git a/package.json b/package.json index 76cf736..85134eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.8.0", + "version": "0.9.0", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/folderScan.test.ts b/src/main/folderScan.test.ts index 1da6350..4367371 100644 --- a/src/main/folderScan.test.ts +++ b/src/main/folderScan.test.ts @@ -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 }) + } + }) +}) diff --git a/src/main/folderScan.ts b/src/main/folderScan.ts index f6913cd..74df70a 100644 --- a/src/main/folderScan.ts +++ b/src/main/folderScan.ts @@ -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; 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 => diff --git a/src/main/ignoreRules.test.ts b/src/main/ignoreRules.test.ts new file mode 100644 index 0000000..2ae51ee --- /dev/null +++ b/src/main/ignoreRules.test.ts @@ -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) + }) +}) diff --git a/src/main/ignoreRules.ts b/src/main/ignoreRules.ts new file mode 100644 index 0000000..52a297d --- /dev/null +++ b/src/main/ignoreRules.ts @@ -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) + } +} diff --git a/src/main/index.ts b/src/main/index.ts index dd9ea5d..027e0dd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { + 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 } diff --git a/src/main/scanCacheStore.test.ts b/src/main/scanCacheStore.test.ts new file mode 100644 index 0000000..a553416 --- /dev/null +++ b/src/main/scanCacheStore.test.ts @@ -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) + } + // 刷新最旧的 l0(savedAt 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')]) + }) +}) diff --git a/src/main/scanCacheStore.ts b/src/main/scanCacheStore.ts new file mode 100644 index 0000000..49bc272 --- /dev/null +++ b/src/main/scanCacheStore.ts @@ -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 +} + +/** 空存储(解析失败/文件不存在时的回退) */ +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 + 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 + 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 + 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 = {} + for (const [key, val] of Object.entries(obj.pairs as Record)) { + if (typeof val !== 'object' || val === null) continue + const p = val as Record + 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 = { ...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 +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 70f2051..87da353 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -90,9 +90,11 @@ const api = { leftDir: string, rightDir: string, semantic: boolean, - previous?: FolderScanCacheEntry[] | null + previous?: FolderScanCacheEntry[] | null, + /** 忽略规则(gitignore 简化子集;命中的目录整树剪枝、文件跳过) */ + ignoreRules?: string[] | null ): Promise => - ipcRenderer.invoke('folder:scan', leftDir, rightDir, semantic, previous ?? null), + ipcRenderer.invoke('folder:scan', leftDir, rightDir, semantic, previous ?? null, ignoreRules ?? null), readByPath: (filePath: string): Promise => ipcRenderer.invoke('file:read-by-path', filePath) } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0fb43b9..064e008 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(initialPrefs.folderOnlyDiff) // 文件夹模式的「语义判等」:字节不同但剔除空白后一致计为语义同(默认开,消除换行符误报) const [folderSemantic, setFolderSemantic] = useState(initialPrefs.folderSemantic) + // 文件夹扫描的忽略规则(gitignore 简化子集;随偏好持久化,应用后触发重扫) + const [folderIgnoreRules, setFolderIgnoreRules] = useState(initialPrefs.folderIgnoreRules) + // 快照基线与对照结果(null = 非对照模式;加载快照后列表按状态变迁标注) + const [snapshot, setSnapshot] = useState(null) + const [snapshotDiff, setSnapshotDiff] = useState(null) // 比较选项与视图开关:初始值从持久化偏好恢复 const [options, setOptions] = useState(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 => { + async (leftDir: string, rightDir: string, semantic: boolean, ignoreRules: string[]): Promise => { 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 => { 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 => { + 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 => { + let data: Awaited> + 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)} diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 4f24cb9..70eacef 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -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 @@ -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 () => { @@ -1722,4 +1723,302 @@ describe('App - 目录聚合对比(双击目录行)', () => { expect(await screen.findByText(/目录「nodiff」内没有差异文件/)).toBeInTheDocument() 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() + 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 { + 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() + 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() + 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() + 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() + // 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() + 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() + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/FolderView.test.tsx b/src/renderer/src/components/FolderView.test.tsx index 27009be..8ecaf9c 100644 --- a/src/renderer/src/components/FolderView.test.tsx +++ b/src/renderer/src/components/FolderView.test.tsx @@ -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) { + 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 | 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 { - return render( - - ) + return render() } 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( - - ) + render() fireEvent.dblClick(screen.getByText('sub')) expect(onOpenDir).toHaveBeenCalledWith('sub') }) @@ -117,26 +112,7 @@ describe('FolderView - 树形渲染', () => { it('双击文件条目触发 onOpenEntry(携带原始条目)', () => { const onOpenEntry = vi.fn() - render( - - ) + render() 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( - - ) + rerender() 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( - - ) + render() fireEvent.click(screen.getByText('相同 1')) expect(onOnlyDiffChange).toHaveBeenCalledWith(false) }) @@ -290,26 +228,7 @@ describe('FolderView - 扩展名过滤', () => { ] it('下拉展示全量条目的扩展名分布与计数(字典序,无扩展名单列)', () => { - render( - - ) + render() fireEvent.click(screen.getByRole('button', { name: /扩展名/ })) // 字典序:(无扩展名)/ .md / .txt,各带计数 expect(screen.getByText('(无扩展名)')).toBeInTheDocument() @@ -320,26 +239,7 @@ describe('FolderView - 扩展名过滤', () => { }) it('点选扩展名过滤条目:交集过滤生效,计数切换为 N / 总数', () => { - render( - - ) + render() expect(screen.getByText('共 4 项')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /扩展名/ })) fireEvent.click(screen.getByText('.txt')) @@ -354,26 +254,7 @@ describe('FolderView - 扩展名过滤', () => { }) it('多选扩展名取并集,再次点击取消,清除按钮复位', () => { - render( - - ) + render() fireEvent.click(screen.getByRole('button', { name: /扩展名/ })) fireEvent.click(screen.getByText('.txt')) fireEvent.click(screen.getByText('.md')) @@ -392,26 +273,7 @@ describe('FolderView - 扩展名过滤', () => { }) it('与状态过滤交集:双激活时无结果的空态提示', () => { - render( - - ) + render() // 状态过滤:只看相同(仅 a.txt) fireEvent.click(screen.getByText('相同 1')) // 扩展名过滤:.md(与 a.txt 无交集) @@ -421,26 +283,7 @@ describe('FolderView - 扩展名过滤', () => { }) it('点击外部区域关闭扩展名下拉(过滤状态保留)', () => { - render( - - ) + render() 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() + expect(screen.getByRole('button', { name: '忽略规则 · 2' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '忽略规则 · 2' }).className).toContain('filtered') + }) + + it('下拉打开时以当前规则初始化草稿,逐行解析后应用并关闭', () => { + const onIgnoreRulesChange = vi.fn() + render( + + ) + 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( + + ) + 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() + 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() + 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( + + ) + 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( + + ) + // 全路径显示(非树形文件名) + 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( + + ) + 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( + + ) + 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( + + ) + fireEvent.click(screen.getByRole('button', { name: '退出对照' })) + expect(onExitSnapshot).toHaveBeenCalledTimes(1) + // App 清空状态后传 null:恢复常规统计行 + rerender() + 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( + + ) + expect(screen.getByText('与快照基线完全一致,没有任何变迁')).toBeInTheDocument() + }) +}) + describe('FolderView - 其他', () => { it('切换语义判等开关触发 onSemanticChange', () => { const onSemanticChange = vi.fn() - render( - - ) + render() fireEvent.click(screen.getByLabelText('语义判等')) expect(onSemanticChange).toHaveBeenCalledWith(false) }) it('点击重新扫描触发 onRescan;扫描中禁用', () => { const onRescan = vi.fn() - const { rerender } = render( - - ) + const { rerender } = render() fireEvent.click(screen.getByRole('button', { name: '重新扫描' })) expect(onRescan).toHaveBeenCalledTimes(1) - rerender( - - ) + rerender() 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( + + ) + 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( - - ) + render() fireEvent.click(screen.getByRole('button', { name: '导出报告' })) fireEvent.click(screen.getByText('HTML 报告')) expect(onExport).toHaveBeenCalledWith('html') @@ -554,50 +511,12 @@ describe('FolderView - 其他', () => { }) it('扫描中禁用导出按钮', () => { - render( - - ) + render() expect(screen.getByRole('button', { name: '导出报告' })).toBeDisabled() }) it('点击外部区域关闭导出菜单', () => { - render( - - ) + render() fireEvent.click(screen.getByRole('button', { name: '导出报告' })) expect(screen.getByText('纯文本')).toBeInTheDocument() fireEvent.mouseDown(document.body) @@ -620,24 +539,7 @@ describe('FolderView - 其他', () => { it('截断时展示文件过多提示', () => { render( - + ) expect(screen.getByText(/文件过多/)).toBeInTheDocument() }) diff --git a/src/renderer/src/components/FolderView.tsx b/src/renderer/src/components/FolderView.tsx index d88d774..c343eaf 100644 --- a/src/renderer/src/components/FolderView.tsx +++ b/src/renderer/src/components/FolderView.tsx @@ -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 setExportOpen(false), []) useDismiss(exportWrapRef, closeExport) - // 搜索(相对路径子串,不区分大小写) + // 搜索(相对路径子串,不区分大小写;对照模式同样生效) const [query, setQuery] = useState('') // 状态精确过滤(null = 不过滤;与 onlyDiff 互斥) const [statusFilter, setStatusFilter] = useState | null>(null) @@ -113,6 +146,34 @@ export default function FolderView({ // 折叠的目录集合(默认全展开;空集合 = 全部展开) const [collapsedDirs, setCollapsedDirs] = useState>(new Set()) + // 忽略规则下拉(草稿文本域;打开时以当前规则初始化,应用时解析为规则数组并触发重扫) + const [ignoreOpen, setIgnoreOpen] = useState(false) + const [ignoreDraft, setIgnoreDraft] = useState('') + const ignoreWrapRef = useRef(null) + const closeIgnore = useCallback((): void => setIgnoreOpen(false), []) + useDismiss(ignoreWrapRef, closeIgnore) + + // 快照对照模式的变迁类型过滤(null = 不过滤;单选 toggle) + const [changeFilter, setChangeFilter] = useState(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(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({ > 重新扫描 + + @@ -270,161 +369,266 @@ export default function FolderView({ - {/* 工具行:统计(可点击过滤)+ 搜索 + 开关 */} -
-
- {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 ( - - ) - })} - - 共 {extFilter !== null || statusFilter !== null ? `${filteredCount} / ${entries.length}` : entries.length} 项 - -
-
- - {extOpen && ( -
- {extStats.map(([ext, n]) => { - const on = extFilter?.has(ext) ?? false - return ( - - ) - })} - {extFilter !== null && ( - - )} -
+ ) + })} + + 共 {snapList !== null ? `${snapList.length} / ${snapshotDiff.entries.length}` : snapshotDiff.entries.length} 项 + +
+ + 基线 {new Date(snapshot.savedAt).toLocaleString('zh-CN')} + {snapshot.semantic ? ' · 语义判等' : ''} + + setQuery(e.target.value)} + /> + +
+ ) : ( +
+
+ {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 ( + + ) + })} + + 共 {extFilter !== null || statusFilter !== null ? `${filteredCount} / ${entries.length}` : entries.length} 项 + +
+
+ + {extOpen && ( +
+ {extStats.map(([ext, n]) => { + const on = extFilter?.has(ext) ?? false + return ( + + ) + })} + {extFilter !== null && ( + + )} +
+ )} +
+
+ + {ignoreOpen && ( +
+