From 38512d7882ee141a88e20373c9be3dab70fd0fa1 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Wed, 19 Aug 2026 09:20:28 +0800 Subject: [PATCH] =?UTF-8?q?fix:=200.9.1=20=E5=BF=AB=E7=85=A7=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=E4=BF=AE=E5=A4=8D=E4=B8=8E=E7=A8=B3=E5=81=A5=E6=80=A7?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=20-=20=E5=BF=AB=E7=85=A7=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=AF=B9=E5=BD=92=E4=B8=80=E5=8C=96=E5=8C=B9=E9=85=8D=EF=BC=88?= =?UTF-8?q?Windows=E5=90=8C=E7=9B=AE=E5=BD=95=E5=BC=82=E5=86=99=E4=B8=8D?= =?UTF-8?q?=E8=AF=AF=E6=8B=A6=E3=80=81=E4=B8=8EscanCacheStore=E5=90=8C?= =?UTF-8?q?=E6=AC=BE=E9=80=BB=E8=BE=91=E5=8F=8C=E5=90=91=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=EF=BC=89=E3=80=81=E6=88=AA=E6=96=AD=E7=BB=93=E6=9E=9C=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=BF=AB=E7=85=A7=E6=8B=A6=E6=88=AA=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=EF=BC=88=E9=98=B2=E5=AF=B9=E7=85=A7=E6=96=B0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=AF=AF=E6=8A=A5=EF=BC=89=E3=80=81folder:scan=20IPC=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E6=8A=BDscanIpc=E7=BA=AF=E9=80=BB=E8=BE=91=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=8D=95=E6=B5=8B=E5=85=A8=E8=A6=86=E7=9B=96=EF=BC=88?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=BA=90=E9=80=89=E6=8B=A9=E6=83=B0=E6=80=A7?= =?UTF-8?q?=E8=AF=BB=E7=9B=98=EF=BC=89=E3=80=81=E5=AF=B9=E7=85=A7=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E5=AF=BC=E5=87=BA=E6=8A=A5=E5=91=8A=E9=99=84=E5=8F=98?= =?UTF-8?q?=E8=BF=81=E5=AF=B9=E7=85=A7=E8=A1=A8=EF=BC=88=E4=B8=89=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=BD=93=E5=89=8D/=E5=9F=BA=E7=BA=BF=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=B8=8E=E5=A4=A7=E5=B0=8F=E3=80=81=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E6=94=B6=E6=95=9BSNAPSHOT=5FCHANGE=5FLABEL=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E3=80=81E2E=E5=AF=B9=E7=85=A7=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=86=99=E7=9B=98=E6=96=AD=E8=A8=80=EF=BC=89=E3=80=81?= =?UTF-8?q?=E9=A1=B6=E5=B1=82ErrorBoundary=E6=B8=B2=E6=9F=93=E5=85=9C?= =?UTF-8?q?=E5=BA=95=EF=BC=88=E9=94=99=E8=AF=AF=E9=9D=A2=E6=9D=BF=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E9=87=8D=E8=BD=BD=E9=98=B2=E7=99=BD=E5=B1=8F=EF=BC=89?= =?UTF-8?q?=E3=80=81=E6=96=87=E6=A1=A3=E5=9B=9B=E4=BB=B6=E5=A5=97=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- docs/应用开发与版本迭代规范.md | 8 +- docs/测试策略与方案.md | 24 ++- e2e/app.spec.ts | 14 +- package.json | 2 +- src/main/index.ts | 28 +--- src/main/scanIpc.test.ts | 99 +++++++++++++ src/main/scanIpc.ts | 56 +++++++ src/renderer/src/App.tsx | 11 +- src/renderer/src/__tests__/App.test.tsx | 22 +++ .../src/components/ErrorBoundary.test.tsx | 36 +++++ src/renderer/src/components/ErrorBoundary.tsx | 42 ++++++ src/renderer/src/components/FolderView.tsx | 15 +- src/renderer/src/diff/folderReport.test.ts | 73 +++++++++ src/renderer/src/diff/folderReport.ts | 139 ++++++++++++++---- src/renderer/src/diff/snapshotDiff.test.ts | 10 ++ src/renderer/src/diff/snapshotDiff.ts | 26 +++- src/renderer/src/main.tsx | 5 +- src/renderer/src/styles/global.css | 29 ++++ 19 files changed, 571 insertions(+), 73 deletions(-) create mode 100644 src/main/scanIpc.test.ts create mode 100644 src/main/scanIpc.ts create mode 100644 src/renderer/src/components/ErrorBoundary.test.tsx create mode 100644 src/renderer/src/components/ErrorBoundary.tsx diff --git a/README.md b/README.md index a2c586a..38aa3df 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 - **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**忽略规则**(gitignore 简化子集:`node_modules/` 目录整树剪枝、`*.log` 扩展名通配、`temp` 名字匹配,被忽略项不枚举不计数,规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫) -- **快照基线**:把当前文件夹对比结果保存为快照(JSON 文件),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新,文件夹报告可附快照对照口径说明 +- **快照基线**:把当前文件夹对比结果保存为快照(JSON 文件),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照(防对照误报) - **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成 - **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览) - **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅 @@ -31,6 +31,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **原生暗色适配**:系统标题栏等原生控件跟随应用暗色科幻主题 - **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 +- **渲染异常兜底**:任何组件渲染错误由顶层 ErrorBoundary 捕获,显示可复制的错误信息与一键重载按钮,不白屏死机 - **差异统计徽章**:实时统计新增 / 删除 / 修改行数 - **导出一键报告**:单文件与文件夹对比均可导出 HTML / 纯文本 / Markdown 三种格式。单文件报告始终包含全量行,HTML 为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(点击展开零丢失),并支持打印与窄屏;文件夹报告含判定口径说明与全量条目清单 - **拖拽与按钮双入口**:支持文件拖拽导入(E2E 覆盖真实拖放链路),也可通过界面按钮选择文件或粘贴文本 @@ -112,7 +113,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主 - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.9.0 +- 版本:0.9.1 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 5dc3d5e..afd62ea 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.9.0`** +当前基线版本:**`0.9.1`** --- @@ -104,6 +104,12 @@ z = 补丁版本号(Patch) - 快照基线: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.9.1 ← 快照收尾修复 · IPC 校验单测化 · 对照报告变迁明细 · 渲染兜底(已发布): + - 快照目录对归一化匹配(修复):snapshotDirsMatch 此前逐字符比较,Windows 下 C:\Foo 与 c:/Foo 同目录异写会被误判"目录对不一致"而拦截对照——归一化(反斜杠→斜杠/去尾分隔符/盘符小写)后比较,与主进程 scanCacheStore.pairKey 同款逻辑(两进程无法共享模块,双向同步标注);大小写敏感文件系统的路径段大小写差异仍正确判不一致 + - 截断结果保存快照拦截(修复):扫描结果截断时条目不完整(仅前 1 万个),此前可照常保存快照——日后对照时快照缺失的文件被全部误判为"新文件";现保存前检查 truncated,toast 拦截提示"快照不完整无法对照"(对齐同目录防呆、目录不一致拦截的既有防呆风格) + - folder:scan IPC 校验单测化:此前 handler 内联的 previous 形态校验 / ignoreRules 校验 / 盘上缓存注入组合逻辑位于覆盖率排除的主进程入口(零单测),抽为 scanIpc 纯逻辑模块(isScanCacheEntries 形态校验 + resolveScanArgs 缓存源选择——内存缓存优先含空数组、非法回退盘上缓存、惰性读盘保证携带有效缓存时不产生读盘 IO),index.ts 变薄壳,单测全覆盖(测试策略第 9 节演进方向落地) + - 对照模式导出报告附变迁明细:此前对照模式导出只附一行变迁统计说明,条目表仍是普通扫描结果(看不出逐条变迁)——现提供 snapshotEntries 时三格式条目表整体替换为变迁对照表(变迁/相对路径/当前状态/基线状态/双侧大小,gone 回退基线大小;HTML 按变迁配色含打印适配),全量含 stable(报告信息零丢失口径);变迁标签收敛为 snapshotDiff 导出的 SNAPSHOT_CHANGE_LABEL 共享映射(FolderView 徽章与报告共用单一实现);E2E 快照用例补对照导出真实写盘断言 + - 顶层 ErrorBoundary 防白屏:任何组件渲染期抛错由边界捕获,显示错误信息(可复制)+ 一键重载按钮,替代整窗白屏死机;事件回调内异常不属于 ErrorBoundary 捕获范围(由各回调自身 try/catch 防御,注释标注边界) ... 0.y.z ← 长期停留,永不进入 1.x ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 6a20a71..8c7aa8e 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -57,6 +57,8 @@ src/main/ ├── ignoreRules.test.ts # 忽略规则单元测试(目录剪枝/通配/名字匹配/大小写/非法输入/条数上限/IPC 校验) ├── scanCacheStore.ts # 扫描缓存跨会话持久化纯数据(目录对键归一化/LRU/防御性解析) ├── scanCacheStore.test.ts # 缓存存储单元测试(解析回退/条目过滤/键归一化/LRU 淘汰/序列化往返) +├── scanIpc.ts # folder:scan IPC 入参校验与缓存源选择纯逻辑(无 electron 依赖) +├── scanIpc.test.ts # IPC 校验单元测试(previous 形态校验/缓存源选择与惰性读盘/忽略规则校验) ├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖) └── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸) src/renderer/src/ @@ -68,10 +70,10 @@ src/renderer/src/ │ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化) │ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退 │ ├── report.test.ts # 单文件报告生成测试 -│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注) -│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable) -│ ├── snapshotDiff.ts # 快照基线纯逻辑(结构/解析校验/目录对匹配/六类变迁对照) -│ ├── snapshotDiff.test.ts # 快照单元测试(解析防御/变迁判定/双侧空集/unreadable 语义) +│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注;对照模式条目表替换为变迁对照表) +│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable/快照变迁对照表) +│ ├── snapshotDiff.ts # 快照基线纯逻辑(结构/解析校验/目录对归一化匹配/六类变迁对照/共享变迁标签映射) +│ ├── snapshotDiff.test.ts # 快照单元测试(解析防御/变迁判定/双侧空集/unreadable 语义/目录对归一化匹配) │ └── textUtils.test.ts # 文本扩展名与行数统计测试 ├── hooks/ │ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理/字符级大字符量重路径/选项变化保留旧结果防滚动塌陷) @@ -80,6 +82,8 @@ src/renderer/src/ ├── components/ │ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出) │ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) +│ ├── ErrorBoundary.tsx # 顶层渲染错误边界(错误面板 + 一键重载,防白屏死机) +│ ├── ErrorBoundary.test.tsx # 渲染错误兜底测试(抛错子组件 fallback/正常子组件透传) │ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤含扩展名/子树收集) │ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt) │ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉/快照对照模式/目录双击/重新扫描/双击/导出菜单/虚拟滚动/resize 重算) @@ -141,6 +145,7 @@ src/renderer/src/ - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 - 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式 +- 快照基线(0.9.0,0.9.1 收尾):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新 ### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`) - 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致 @@ -199,11 +204,18 @@ src/renderer/src/ - 拖拽导入(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,0.9.1 增强):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复 - 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s;300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归) - 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData) - 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理) +### 5.12 folder:scan IPC 校验(`src/main/scanIpc.ts`,0.9.1) +- isScanCacheEntries 形态校验:合法缓存数组(rel/status 为字符串)通过;非数组 / 含非对象 / rel 或 status 非字符串拒绝 +- resolveScanArgs 缓存源选择:渲染端携带合法内存缓存原样传递且惰性不读盘 / 内存缓存为空数组(合法形态)同样优先(阻止注入可能过期的盘上缓存)/ 未携带与形态非法回退盘上缓存(惰性读盘恰好一次)/ 盘上缓存为 null 或空数组不注入(全量扫描) +- 忽略规则校验:复用 isIgnoreRulesArray(合法字符串数组与空数组传递;非数组 / 含非字符串 / 超条数整体忽略) +- 组合场景:内存缓存非法 + 忽略规则合法 + 盘上缓存有效时两者分别正确解析 +- 主进程 index.ts 的 handler 壳(IPC 注册与真实读写盘)由 E2E 文件夹用例覆盖 + --- ## 6. 运行命令 @@ -244,7 +256,7 @@ npm run build ## 9. 演进方向(后续迭代) - 文件夹对比:快照 vs 当前的逐行文本对照(快照保存内容或按需读盘,对照视图内差异明细) · 忽略规则高级 glob(`**` 跨段、否定规则 `!`) -- 主进程 IPC 层补充集成测试(folder:scan 并发场景) +- 主进程 IPC 并发场景集成测试(入参校验与缓存注入逻辑已于 0.9.1 单测化:scanIpc) - mac 构建实际验证(entitlements 配置就绪但从未打包跑通) --- diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 4ee48b9..fc81690 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -513,9 +513,21 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => { 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() + // 对照模式导出报告:条目表为变迁对照表(真实写盘断言变迁明细) + const cmpPath = join(root, 'compare-report.txt') + await stubSaveDialog(app, cmpPath) + await window.getByRole('button', { name: '导出报告' }).click() + await window.getByRole('button', { name: '纯文本' }).click() + await expect(window.getByText(/报告已保存/)).toBeVisible() + const cmp = await readFile(cmpPath, 'utf-8') + expect(cmp).toContain('快照对照:修复 1') + expect(cmp).toContain('已修复') + expect(cmp).toContain('新文件') + expect(cmp).toContain('保持一致') + // 退出对照恢复 await window.getByRole('button', { name: '退出对照' }).click() await expect(window.getByText('相同 3')).toBeVisible() await app.close() diff --git a/package.json b/package.json index 85134eb..3cdae63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.9.0", + "version": "0.9.1", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index 027e0dd..e5e29e1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,7 +6,7 @@ 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 { resolveScanArgs } from './scanIpc' import { parseScanCacheStore, putScanCache, @@ -313,28 +313,12 @@ ipcMain.handle( ignoreRules?: unknown ) => { try { - // 增量缓存防御性校验:仅接受数组形态(渲染端从上次扫描结果携带,损坏即不沿用) - const cache = - Array.isArray(previous) && previous.every( - (c) => - typeof c === 'object' && - c !== null && - typeof (c as ScanCacheEntry).rel === 'string' && - typeof (c as ScanCacheEntry).status === 'string' - ) - ? (previous as ScanCacheEntry[]) - : undefined - // 渲染端未携带内存缓存时(跨会话首次扫描),尝试注入 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 + // 入参防御性校验与增量缓存源选择(内存缓存优先,未携带/非法回退 userData 落盘缓存,惰性读盘) + const args = resolveScanArgs(previous, ignoreRules, () => + getScanCache(loadScanCacheStore(), leftDir, rightDir) + ) const result = await scanFolders(leftDir, rightDir, { - ...(prev ? { previous: prev } : {}), - ...(ignore ? { ignore } : {}), + ...args, ...(semantic ? { // 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线) diff --git a/src/main/scanIpc.test.ts b/src/main/scanIpc.test.ts new file mode 100644 index 0000000..654e319 --- /dev/null +++ b/src/main/scanIpc.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import type { ScanCacheEntry } from './folderScan' +import { isScanCacheEntries, resolveScanArgs } from './scanIpc' + +/** 构造一条合法缓存条目(IPC 形态校验只看 rel/status 字符串字段) */ +function entry(rel: string): ScanCacheEntry { + return { rel, leftFp: { size: 1, mtime: 100 }, rightFp: { size: 1, mtime: 100 }, status: 'same', semSame: null } +} + +/** 盘上缓存读取 stub:返回指定缓存并记录调用 */ +function storedLoader(cache: ScanCacheEntry[] | null): (() => ScanCacheEntry[] | null) & { calls: number } { + const f = (): ScanCacheEntry[] | null => { + f.calls++ + return cache + } + f.calls = 0 + return f +} + +describe('isScanCacheEntries - IPC 形态校验', () => { + it('合法缓存数组通过(rel/status 为字符串)', () => { + expect(isScanCacheEntries([])).toBe(true) + expect(isScanCacheEntries([entry('a.txt'), entry('b.txt')])).toBe(true) + }) + + it('非数组 / 含非对象 / rel 或 status 非字符串拒绝', () => { + expect(isScanCacheEntries(null)).toBe(false) + expect(isScanCacheEntries(undefined)).toBe(false) + expect(isScanCacheEntries('cache')).toBe(false) + expect(isScanCacheEntries([entry('a.txt'), null])).toBe(false) + expect(isScanCacheEntries([entry('a.txt'), 'garbage'])).toBe(false) + expect(isScanCacheEntries([{ rel: 42, status: 'same' }])).toBe(false) + expect(isScanCacheEntries([{ rel: 'a.txt', status: 1 }])).toBe(false) + expect(isScanCacheEntries([{ rel: 'a.txt' }])).toBe(false) + }) +}) + +describe('resolveScanArgs - 缓存源选择与忽略规则校验', () => { + it('渲染端携带合法内存缓存:原样传递且不读盘(惰性)', () => { + const stored = storedLoader([entry('disk.txt')]) + const mem = [entry('mem.txt')] + const r = resolveScanArgs(mem, undefined, stored) + expect(r.previous).toBe(mem) + expect(stored.calls).toBe(0) + }) + + it('内存缓存为空数组(合法形态):使用空数组,同样不注入盘上缓存', () => { + // 空数组是同目录上次扫描的最新形态(如空目录对),应优先于可能过期的盘上缓存 + const stored = storedLoader([entry('disk.txt')]) + const r = resolveScanArgs([], undefined, stored) + expect(r.previous).toEqual([]) + expect(stored.calls).toBe(0) + }) + + it('未携带 / 形态非法的内存缓存:回退盘上缓存(惰性读盘一次)', () => { + const disk = [entry('disk.txt')] + for (const bad of [undefined, null, 'x', [{ rel: 1, status: 'same' }]] as unknown[]) { + const stored = storedLoader(disk) + const r = resolveScanArgs(bad, undefined, stored) + expect(r.previous).toBe(disk) + expect(stored.calls).toBe(1) + } + }) + + it('盘上缓存为 null 或空数组:不注入 previous(全量扫描)', () => { + expect(resolveScanArgs(undefined, undefined, storedLoader(null)).previous).toBeUndefined() + expect(resolveScanArgs(undefined, undefined, storedLoader([])).previous).toBeUndefined() + }) + + it('忽略规则合法字符串数组传递(空数组合法)', () => { + expect(resolveScanArgs(undefined, ['node_modules/', '*.log'], storedLoader(null)).ignore).toEqual([ + 'node_modules/', + '*.log' + ]) + expect(resolveScanArgs(undefined, [], storedLoader(null)).ignore).toEqual([]) + }) + + it('忽略规则非法形态(非数组/含非字符串/超条数)整体忽略', () => { + expect(resolveScanArgs(undefined, null, storedLoader(null)).ignore).toBeUndefined() + expect(resolveScanArgs(undefined, 'node_modules/', storedLoader(null)).ignore).toBeUndefined() + expect(resolveScanArgs(undefined, ['a', 42], storedLoader(null)).ignore).toBeUndefined() + expect( + resolveScanArgs( + undefined, + Array.from({ length: 51 }, () => 'a'), + storedLoader(null) + ).ignore + ).toBeUndefined() + }) + + it('组合:内存缓存非法 + 忽略规则合法 + 盘上缓存有效', () => { + const disk = [entry('disk.txt')] + const stored = storedLoader(disk) + const r = resolveScanArgs([{ rel: 'bad' }], ['dist/'], stored) + expect(r.previous).toBe(disk) + expect(r.ignore).toEqual(['dist/']) + expect(stored.calls).toBe(1) + }) +}) diff --git a/src/main/scanIpc.ts b/src/main/scanIpc.ts new file mode 100644 index 0000000..f9a54ba --- /dev/null +++ b/src/main/scanIpc.ts @@ -0,0 +1,56 @@ +import type { ScanCacheEntry } from './folderScan' +import { isIgnoreRulesArray } from './ignoreRules' + +/** + * folder:scan IPC 入参的防御性校验与增量缓存源选择(纯逻辑,无 electron/fs 依赖)。 + * 渲染端经 IPC 传入的数据不可信(跨进程边界),逐字段校验后由主进程 handler 组装扫描参数; + * 此前该逻辑内联在 index.ts(覆盖率排除),抽出后可被 vitest 直接测试。 + */ + +/** previous(增量重扫缓存)是否为可接受的 IPC 形态:数组且每条含字符串 rel/status。 + * 只做形态级校验(渲染端数据来自上次扫描结果或损坏的偏好存储);字段级合法性 + * (指纹形态等)由 scanFolders 的指纹比对与 scanCacheStore 的解析校验兜底 */ +export function isScanCacheEntries(v: unknown): v is ScanCacheEntry[] { + return ( + Array.isArray(v) && + v.every( + (c) => + typeof c === 'object' && + c !== null && + typeof (c as ScanCacheEntry).rel === 'string' && + typeof (c as ScanCacheEntry).status === 'string' + ) + ) +} + +export interface ResolvedScanArgs { + /** 增量重扫缓存:渲染端携带的内存缓存优先(含空数组——同目录上次扫描的最新形态, + * 阻止注入可能过期的盘上缓存);形态非法/未携带时回退盘上持久化缓存;两者皆无则缺省 */ + previous?: ScanCacheEntry[] + /** 忽略规则:仅接受合法字符串数组形态(isIgnoreRulesArray 校验,空数组合法),非法即忽略 */ + ignore?: string[] +} + +/** + * 组合 folder:scan 的入参校验与缓存源选择: + * - previous 形态异常(非数组 / 条目缺字符串 rel/status)一律不沿用; + * - previous 未携带或非法时经 loadStored 惰性读取盘上持久化缓存(非空才注入), + * 惰性求值保证渲染端携带有效内存缓存时不产生读盘 IO; + * - ignoreRules 复用 isIgnoreRulesArray(数组形态 + 条数/单条长度上限)。 + */ +export function resolveScanArgs( + previous: unknown, + ignoreRules: unknown, + loadStored: () => ScanCacheEntry[] | null +): ResolvedScanArgs { + const mem = isScanCacheEntries(previous) ? previous : undefined + const fallback = (): ScanCacheEntry[] | undefined => { + const stored = loadStored() + return stored !== null && stored.length > 0 ? stored : undefined + } + const previousOut = mem !== undefined ? mem : fallback() + return { + ...(previousOut !== undefined ? { previous: previousOut } : {}), + ...(isIgnoreRulesArray(ignoreRules) ? { ignore: ignoreRules } : {}) + } +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 064e008..41bdb46 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -480,7 +480,9 @@ export default function App(): ReactElement { ? { 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')})` + ).toLocaleString('zh-CN')})`, + // 对照模式导出:条目表替换为变迁对照表(含当前/基线状态与大小) + snapshotEntries: snapshotDiff.entries } : {}) }, @@ -505,9 +507,14 @@ export default function App(): ReactElement { [folderState, folderSemantic, snapshotDiff, snapshot, showToast] ) - /** 保存当前对比结果为快照基线(JSON 落盘,复用报告保存管线) */ + /** 保存当前对比结果为快照基线(JSON 落盘,复用报告保存管线)。 + * 截断结果(文件过多)条目不完整:快照缺失的文件在日后对照中会被误判为"新文件",拦截保存 */ const saveSnapshot = useCallback((): void => { if (!folderState) return + if (folderState.truncated) { + showToast('扫描结果已截断(文件过多),快照不完整无法对照,请先缩小对比范围') + return + } const snap: FolderSnapshot = { version: 1, leftDir: folderState.leftDir, diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 70eacef..238e006 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -1892,6 +1892,24 @@ describe('App - 快照基线', () => { expect(snap!.entries.map((e) => e.rel)).toEqual(['a.txt', 'b.txt', 'c.txt']) }) + it('扫描结果截断时保存快照被拦截提示,不落盘', async () => { + const saveReport = vi.fn(async () => ({ ok: true, path: 'C:/docs/snap.json' })) + const dirs = ['C:/left-dir', 'D:/right-dir'] + let call = 0 + window.api = mockApi({ + pickFolder: async () => dirs[call++] ?? null, + scanFolder: async () => ({ entries: curEntries, truncated: true, total: 9999 }), + saveReport + }) + render() + fireEvent.click(screen.getByText('对比文件夹')) + await screen.findByText(/文件过多/) + fireEvent.click(screen.getByRole('button', { name: '保存快照' })) + expect(await screen.findByText(/快照不完整无法对照/)).toBeInTheDocument() + expect(saveReport).not.toHaveBeenCalled() + expect(screen.queryByText(/快照已保存/)).not.toBeInTheDocument() + }) + it('加载快照:对照模式渲染变迁统计与标注', async () => { await enterForSnapshot(makeSnapJson()) fireEvent.click(screen.getByRole('button', { name: '加载快照' })) @@ -1935,6 +1953,10 @@ describe('App - 快照基线', () => { fireEvent.click(screen.getByText('纯文本')) await screen.findByText(/报告已保存/) expect(saved).toContain('快照对照:修复 0 · 新增差异 1 · 新文件 1 · 已移除 0 · 仍有差异 0') + // 对照模式导出:条目表为变迁对照表(含变迁标签与当前/基线状态) + expect(saved).toContain('新增差异') + expect(saved).toContain('新文件') + expect(saved).toContain('保持一致') }) it('退出对照恢复常规视图', async () => { diff --git a/src/renderer/src/components/ErrorBoundary.test.tsx b/src/renderer/src/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..639db94 --- /dev/null +++ b/src/renderer/src/components/ErrorBoundary.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import ErrorBoundary from './ErrorBoundary' + +// React 抛渲染错误时向 console.error 输出组件栈噪音,测试中静音 +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +/** 渲染期必抛错的子组件 */ +function Boom(): never { + throw new Error('boom in render') +} + +describe('ErrorBoundary', () => { + it('子组件渲染抛错时显示错误面板与重载按钮(不白屏)', () => { + render( + + + + ) + expect(screen.getByText('界面渲染出现异常')).toBeInTheDocument() + expect(screen.getByText('boom in render')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '重新加载应用' })).toBeInTheDocument() + }) + + it('子组件正常渲染时原样透传(零侵入)', () => { + render( + +
ok-content
+
+ ) + expect(screen.getByText('ok-content')).toBeInTheDocument() + expect(screen.queryByText('界面渲染出现异常')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/ErrorBoundary.tsx b/src/renderer/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..51eaa0c --- /dev/null +++ b/src/renderer/src/components/ErrorBoundary.tsx @@ -0,0 +1,42 @@ +import { Component, type ReactNode } from 'react' + +interface ErrorBoundaryProps { + children: ReactNode +} + +interface ErrorBoundaryState { + error: Error | null +} + +/** + * 顶层渲染错误兜底:任何子组件渲染期抛错时显示错误面板与一键重载, + * 替代整窗白屏死机(用户至少能自助恢复)。 + * 捕获范围:渲染与生命周期阶段的错误(React ErrorBoundary 的固有边界); + * 事件回调内的异常不经过此边界,由各回调自身的 try/catch 防御。 + */ +export default class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error } + } + + componentDidCatch(error: Error): void { + console.error('渲染错误(ErrorBoundary 捕获):', error) + } + + render(): ReactNode { + if (this.state.error !== null) { + return ( +
+

界面渲染出现异常

+

{this.state.error.message || String(this.state.error)}

+ +
+ ) + } + return this.props.children + } +} diff --git a/src/renderer/src/components/FolderView.tsx b/src/renderer/src/components/FolderView.tsx index c343eaf..b26703a 100644 --- a/src/renderer/src/components/FolderView.tsx +++ b/src/renderer/src/components/FolderView.tsx @@ -11,6 +11,7 @@ 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 { SNAPSHOT_CHANGE_LABEL } from '../diff/snapshotDiff' import { useDismiss } from '../hooks/useDismiss' import { buildFolderTree, filterEntries, flattenFolderTree, relExt } from './folderTree' @@ -91,14 +92,14 @@ const BADGES: { status: FolderEntryStatus; countKey: keyof ReturnType { expect(md).toContain('两个文件夹内都没有文件') }) }) + +describe('buildFolderReport - 快照对照表(snapshotEntries)', () => { + const SNAP: FolderEntry[] = [ + { rel: 'fixed.txt', status: 'different', leftSize: 1, rightSize: 2 }, + { rel: 'gone.txt', status: 'different', leftSize: 1, rightSize: 2 }, + { rel: 'stable.txt', status: 'same', leftSize: 3, rightSize: 3 } + ] + const CUR: FolderEntry[] = [ + { rel: 'fixed.txt', status: 'same', leftSize: 2, rightSize: 2 }, + { rel: 'new.txt', status: 'right-only', leftSize: null, rightSize: 5 }, + { rel: 'stable.txt', status: 'same', leftSize: 3, rightSize: 3 } + ] + const snapshotEntries = diffSnapshot(SNAP, CUR).entries + const SNAP_CTX = { + ...CTX, + snapshotNote: '修复 1 · 新增差异 0 · 新文件 1 · 已移除 1 · 仍有差异 0(基线 2026/1/1)', + snapshotEntries + } + + it('HTML:表头切换为变迁对照列,行含变迁标签与当前/基线状态配色', () => { + const html = buildFolderReport(CUR, SNAP_CTX, 'html') + expect(html).toContain('变迁') + expect(html).toContain('当前状态') + expect(html).toContain('基线状态') + // 行级:fixed(不同→相同)与 stable(相同→相同) + expect(html).toContain('chg-fixed') + expect(html).toContain('已修复') + expect(html).toContain('chg-stable') + expect(html).toContain('保持一致') + // 普通条目表头不再出现 + expect(html).not.toContain('状态') + }) + + it('HTML:gone 条目当前状态为 —,大小回退基线数据;new 条目基线状态为 —', () => { + const html = buildFolderReport(CUR, SNAP_CTX, 'html') + // 按表格行分块断言(tr 与单元格内容不在同一物理行) + const trOf = (rel: string): string => html.split(' c.includes(rel)) ?? '' + const goneRow = trOf('gone.txt') + expect(goneRow).toContain('chg-gone') + expect(goneRow).toContain('已移除') + expect(goneRow).toContain('—') + // gone 大小取基线(1 B / 2 B) + expect(goneRow).toContain('1 B') + expect(goneRow).toContain('2 B') + const newRow = trOf('new.txt') + expect(newRow).toContain('新文件') + expect(newRow).toContain('—') + }) + + it('纯文本:行含变迁标签与当前/基线状态,快照口径说明保留', () => { + const txt = buildFolderReport(CUR, SNAP_CTX, 'txt') + expect(txt).toContain('快照对照:修复 1') + expect(txt).toContain('已修复 相同 不同') + expect(txt.split('\n').some((l) => l.includes('gone.txt'))).toBe(true) + // gone 行:当前状态为 —(基线仍为 不同) + expect(txt.split('\n').some((l) => l.includes('已移除') && l.includes('—'))).toBe(true) + }) + + it('Markdown:表格切换为变迁列结构', () => { + const md = buildFolderReport(CUR, SNAP_CTX, 'md') + expect(md).toContain('| 变迁 | 相对路径 | 当前状态 | 基线状态 | 左侧大小 | 右侧大小 |') + expect(md).toContain('| 已修复 |') + expect(md).toContain('| 已移除 |') + expect(md).not.toContain('| 状态 | 相对路径 | 左侧大小 | 右侧大小 |') + }) + + it('不传 snapshotEntries 时保持普通条目表(回归锚)', () => { + const html = buildFolderReport(ENTRIES, CTX, 'html') + expect(html).toContain('状态') + expect(html).not.toContain('变迁') + }) +}) diff --git a/src/renderer/src/diff/folderReport.ts b/src/renderer/src/diff/folderReport.ts index 069806b..625df02 100644 --- a/src/renderer/src/diff/folderReport.ts +++ b/src/renderer/src/diff/folderReport.ts @@ -1,9 +1,12 @@ import type { FolderEntry, FolderEntryStatus } from '../../../preload/index' import { esc, REPORT_CSS, REPORT_EXT, type ReportFormat } from './report' +import { SNAPSHOT_CHANGE_LABEL, type SnapshotDiffEntry } from './snapshotDiff' /** * 文件夹对比报告生成器(HTML / 纯文本 / Markdown 三格式)。 * 与单文件报告共用主题样式(REPORT_CSS)与转义工具;语义判等关闭时统计中语义同恒为 0。 + * 快照对照模式导出(snapshotEntries 提供)时条目表替换为变迁对照表(全量含 stable, + * 与界面默认隐藏 stable 不同——报告口径为信息零丢失)。 */ export type { ReportFormat } @@ -20,6 +23,8 @@ export interface FolderReportContext { total: number /** 快照对照说明(快照对照模式导出时附注变迁统计与基线时间) */ snapshotNote?: string + /** 快照对照条目(对照模式导出时提供:条目表替换为变迁对照表) */ + snapshotEntries?: SnapshotDiffEntry[] } /** 六类状态统计 */ @@ -96,6 +101,7 @@ const FOLDER_EXTRA_CSS = ` font-family:Consolas,'JetBrains Mono',monospace; font-size:12.5px; border:1px solid rgba(148,163,255,.14); border-radius:12px; overflow:hidden; } .f-table col.c-st { width:76px; } + .f-table col.c-chg { width:88px; } .f-table col.c-ls { width:88px; } .f-table col.c-rs { width:88px; } .f-table thead td { font-family:'Segoe UI','Microsoft YaHei',sans-serif; font-size:11px; color:#74809a; @@ -113,6 +119,12 @@ const FOLDER_EXTRA_CSS = ` .f-table tr.st-left td.st { color:#c9bfff; background:rgba(124,108,255,.12); } .f-table tr.st-right td.st { color:#c9bfff; background:rgba(124,108,255,.12); } .f-table tr.st-unread td.st { color:#f6bd87; background:rgba(245,158,11,.10); } + .f-table td.chg { text-align:center; font-family:'Segoe UI','Microsoft YaHei',sans-serif; + font-weight:700; font-size:12px; border-radius:4px; } + .f-table tr.chg-fixed td.chg, .f-table tr.chg-new td.chg { color:#7ee2a8; background:rgba(34,197,94,.10); } + .f-table tr.chg-regressed td.chg { color:#f7a9a9; background:rgba(248,113,113,.10); } + .f-table tr.chg-changed td.chg { color:#f6bd87; background:rgba(245,158,11,.10); } + .f-table tr.chg-gone td.chg, .f-table tr.chg-stable td.chg { color:#74809a; background:rgba(148,163,255,.06); } .f-table tr:hover { background:rgba(34,211,238,.05); } .trunc-note { color:#f6bd87; font-size:12.5px; padding:8px 14px; border-radius:10px; background:rgba(245,158,11,.10); border:1px solid rgba(245,158,11,.35); margin-bottom:14px; } @@ -121,9 +133,28 @@ const FOLDER_EXTRA_CSS = ` .f-table td { border-color:#eee; } .f-table thead td { background:#f3f3f6; color:#666; } .trunc-note { color:#8a5a00; background:#fff7e6; border-color:#e8c07a; } + .f-table tr.chg-fixed td.chg, .f-table tr.chg-new td.chg { color:#15803d; background:#e9f9ef; } + .f-table tr.chg-regressed td.chg { color:#b91c1c; background:#fdeeee; } + .f-table tr.chg-changed td.chg { color:#b45309; background:#fdf4e5; } + .f-table tr.chg-gone td.chg, .f-table tr.chg-stable td.chg { color:#888; background:#f3f3f6; } } ` +/** 变迁对照表的行 HTML(当前/基线状态缺失显示 —;大小取 current ?? baseline 与界面对照行一致) */ +function snapshotRowHtml(e: SnapshotDiffEntry): string { + const display = e.current ?? e.baseline! + const cur = e.current ? STATUS_LABEL[e.current.status] : '—' + const base = e.baseline ? STATUS_LABEL[e.baseline.status] : '—' + return ` + ${SNAPSHOT_CHANGE_LABEL[e.change]} + ${esc(e.rel)} + ${cur} + ${base} + ${fmtSize(display.leftSize)} + ${fmtSize(display.rightSize)} +` +} + function buildFolderHtml( entries: FolderEntry[], ctx: FolderReportContext, @@ -133,30 +164,40 @@ function buildFolderHtml( const semLine = ctx.semantic ? '判定口径:语义判等开启(空白/换行差异计为语义同,不计入不同)' : '判定口径:字节级严格比对(任何字节差异均计为不同)' + // 对照模式:条目表替换为变迁对照表(全量含 stable),列结构随之切换 + const snaps = ctx.snapshotEntries + const cols = snaps + ? '' + : '' + const headRow = snaps + ? '变迁相对路径当前状态基线状态左侧大小右侧大小' + : '状态相对路径左侧大小右侧大小' - const rowsHtml = entries - .map((e) => { - const cls = - e.status === 'same' - ? 'st-same' - : e.status === 'semantic-same' - ? 'st-sem' - : e.status === 'different' - ? 'st-diff' - : e.status === 'left-only' - ? 'st-left' - : e.status === 'right-only' - ? 'st-right' - : 'st-unread' - const approx = e.approximate ? ' ' : '' - return ` + const rowsHtml = snaps + ? snaps.map(snapshotRowHtml).join('\n') + : entries + .map((e) => { + const cls = + e.status === 'same' + ? 'st-same' + : e.status === 'semantic-same' + ? 'st-sem' + : e.status === 'different' + ? 'st-diff' + : e.status === 'left-only' + ? 'st-left' + : e.status === 'right-only' + ? 'st-right' + : 'st-unread' + const approx = e.approximate ? ' ' : '' + return ` ${STATUS_LABEL[e.status]} ${esc(e.rel)}${approx} ${fmtSize(e.leftSize)} ${fmtSize(e.rightSize)} ` - }) - .join('\n') + }) + .join('\n') return ` @@ -197,10 +238,18 @@ function buildFolderHtml( ${ctx.truncated ? `
⚠ 文件数量超出上限(已发现 ${ctx.total}+ 个),以下仅包含部分条目,请人工确认完整性
` : ''} - - +${cols} +${headRow} -${entries.length === 0 ? '' : rowsHtml} +${ + snaps + ? snaps.length === 0 + ? '' + : rowsHtml + : entries.length === 0 + ? '' + : rowsHtml +}
状态相对路径左侧大小右侧大小
两个文件夹内都没有文件
快照与当前结果都没有条目
两个文件夹内都没有文件
由 DiffLens 生成 · 语义同 = 字节不同但剔除空白/换行后内容一致(双击条目可查看单文件差异明细)
@@ -230,7 +279,24 @@ function buildFolderTxt( lines.push(`⚠ 文件过多(${ctx.total}+),以下仅包含部分条目,请人工确认完整性`) } lines.push('----------------------------------') - if (entries.length === 0) { + const snaps = ctx.snapshotEntries + if (snaps) { + // 变迁对照表:变迁 / 当前状态 / 基线状态 / 相对路径 / 大小(全量含 stable) + if (snaps.length === 0) { + lines.push('快照与当前结果都没有条目') + } else { + const width = Math.min(60, Math.max(...snaps.map((e) => e.rel.length))) + for (const e of snaps) { + const display = e.current ?? e.baseline! + const rel = e.rel.length > width ? e.rel.slice(0, width - 1) + '…' : e.rel.padEnd(width) + const cur = e.current ? STATUS_LABEL[e.current.status] : '—' + const base = e.baseline ? STATUS_LABEL[e.baseline.status] : '—' + lines.push( + `${SNAPSHOT_CHANGE_LABEL[e.change].padEnd(4)} ${cur.padEnd(3)} ${base.padEnd(3)} ${rel} ${fmtSize(display.leftSize).padStart(9)} ${fmtSize(display.rightSize).padStart(9)}` + ) + } + } + } else if (entries.length === 0) { lines.push('两个文件夹内都没有文件') } else { // 路径列宽对齐(封顶 60,超长路径截断保持可读) @@ -269,13 +335,30 @@ function buildFolderMd( if (ctx.truncated) { out.push(`> ⚠ 文件过多(${ctx.total}+),以下仅包含部分条目,请人工确认完整性`, '') } - out.push('| 状态 | 相对路径 | 左侧大小 | 右侧大小 |', '| --- | --- | --- | --- |') - if (entries.length === 0) { - out.push('| (空) | 两个文件夹内都没有文件 | — | — |') + const snaps = ctx.snapshotEntries + if (snaps) { + // 变迁对照表(全量含 stable) + out.push('| 变迁 | 相对路径 | 当前状态 | 基线状态 | 左侧大小 | 右侧大小 |', '| --- | --- | --- | --- | --- | --- |') + if (snaps.length === 0) { + out.push('| (空) | 快照与当前结果都没有条目 | — | — | — | — |') + } else { + for (const e of snaps) { + const display = e.current ?? e.baseline! + const rel = e.rel.replace(/\|/g, '\\|') + const cur = e.current ? STATUS_LABEL[e.current.status] : '—' + const base = e.baseline ? STATUS_LABEL[e.baseline.status] : '—' + out.push(`| ${SNAPSHOT_CHANGE_LABEL[e.change]} | \`${rel}\` | ${cur} | ${base} | ${fmtSize(display.leftSize)} | ${fmtSize(display.rightSize)} |`) + } + } } else { - for (const e of entries) { - const rel = e.rel.replace(/\|/g, '\\|') - out.push(`| ${STATUS_LABEL[e.status]} | \`${rel}\`${e.approximate ? ' ≈' : ''} | ${fmtSize(e.leftSize)} | ${fmtSize(e.rightSize)} |`) + out.push('| 状态 | 相对路径 | 左侧大小 | 右侧大小 |', '| --- | --- | --- | --- |') + if (entries.length === 0) { + out.push('| (空) | 两个文件夹内都没有文件 | — | — |') + } else { + for (const e of entries) { + const rel = e.rel.replace(/\|/g, '\\|') + out.push(`| ${STATUS_LABEL[e.status]} | \`${rel}\`${e.approximate ? ' ≈' : ''} | ${fmtSize(e.leftSize)} | ${fmtSize(e.rightSize)} |`) + } } } out.push('', '> 生成于 DiffLens') diff --git a/src/renderer/src/diff/snapshotDiff.test.ts b/src/renderer/src/diff/snapshotDiff.test.ts index b67fd3f..9d37271 100644 --- a/src/renderer/src/diff/snapshotDiff.test.ts +++ b/src/renderer/src/diff/snapshotDiff.test.ts @@ -93,6 +93,16 @@ describe('isDiffStatus / snapshotDirsMatch', () => { expect(snapshotDirsMatch(snap, 'C:/other', 'D:/right')).toBe(false) expect(snapshotDirsMatch(snap, 'C:/left', 'D:/other')).toBe(false) }) + + it('路径归一化后比较:盘符大小写/反斜杠/尾分隔符的同目录异写视为一致', () => { + const snap = makeSnapshot() + // Windows 大小写不敏感文件系统:C:\left 与 c:/left/ 为同一目录 + expect(snapshotDirsMatch(snap, 'c:/left/', 'd:\\right\\')).toBe(true) + expect(snapshotDirsMatch(snap, 'C:\\left', 'D:\\right')).toBe(true) + // 大小写敏感文件系统:路径段大小写不同仍是不同目录(不误判一致) + expect(snapshotDirsMatch(snap, 'C:/Left', 'D:/right')).toBe(false) + expect(snapshotDirsMatch(snap, 'C:/left', 'D:/Right')).toBe(false) + }) }) describe('diffSnapshot - 状态变迁对照', () => { diff --git a/src/renderer/src/diff/snapshotDiff.ts b/src/renderer/src/diff/snapshotDiff.ts index 1c4ef3f..ec56e17 100644 --- a/src/renderer/src/diff/snapshotDiff.ts +++ b/src/renderer/src/diff/snapshotDiff.ts @@ -120,13 +120,35 @@ export interface SnapshotDiffResult { stats: SnapshotDiffStats } -/** 快照与当前扫描的目录对是否一致(不一致时对照无意义,调用方拦截) */ +/** 变迁类型的人话标签(FolderView 徽章与文件夹报告共用同一实现) */ +export const SNAPSHOT_CHANGE_LABEL: Record = { + fixed: '已修复', + regressed: '新增差异', + new: '新文件', + gone: '已移除', + changed: '仍有差异', + stable: '保持一致' +} + +/** + * 单一路径归一化:反斜杠→斜杠、去尾分隔符、Windows 盘符小写(其余保留原大小写)。 + * 与主进程 scanCacheStore.ts 的 normDir 保持完全一致(两进程无法共享模块,改动需双向同步): + * Windows 大小写不敏感文件系统下 C:\Foo 与 c:/Foo 为同一目录,逐字符比较会误判目录对不一致。 + */ +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 snapshotDirsMatch( baseline: FolderSnapshot, leftDir: string, rightDir: string ): boolean { - return baseline.leftDir === leftDir && baseline.rightDir === rightDir + return normDir(baseline.leftDir) === normDir(leftDir) && normDir(baseline.rightDir) === normDir(rightDir) } /** diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 8116094..1e8b5e6 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,10 +1,13 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' +import ErrorBoundary from './components/ErrorBoundary' import './styles/global.css' ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - + + + ) \ No newline at end of file diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 1350365..74f1642 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -1233,4 +1233,33 @@ button.fstat.active { to { transform: rotate(360deg); } +} + +/* ============ 顶层渲染错误兜底面板(ErrorBoundary) ============ */ +.error-boundary { + height: 100%; + display: grid; + place-content: center; + justify-items: center; + gap: 14px; + padding: 30px; + text-align: center; +} +.error-boundary h2 { + color: var(--mod-fg); + font-size: 16px; + letter-spacing: 1px; +} +.error-boundary .error-message { + max-width: 70vw; + padding: 10px 14px; + border-radius: 10px; + background: var(--del-bg); + border: 1px solid rgba(248, 113, 113, 0.3); + color: var(--del-fg); + font-family: var(--mono); + font-size: 12.5px; + word-break: break-all; + /* 错误信息需可复制上报(应用全局 user-select: none 的例外) */ + user-select: text; } \ No newline at end of file