fix: 0.9.1 快照收尾修复与稳健性增强 - 快照目录对归一化匹配(Windows同目录异写不误拦、与scanCacheStore同款逻辑双向同步)、截断结果保存快照拦截提示(防对照新文件误报)、folder:scan IPC校验抽scanIpc纯逻辑模块单测全覆盖(缓存源选择惰性读盘)、对照模式导出报告附变迁对照表(三格式当前/基线状态与大小、标签收敛SNAPSHOT_CHANGE_LABEL共享映射、E2E对照导出写盘断言)、顶层ErrorBoundary渲染兜底(错误面板一键重载防白屏)、文档四件套同步
This commit is contained in:
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-1
@@ -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
|
||||
```
|
||||
|
||||
+18
-6
@@ -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 配置就绪但从未打包跑通)
|
||||
|
||||
---
|
||||
|
||||
+13
-1
@@ -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()
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
+6
-22
@@ -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(大缓冲后台解码,与文件打开共用同一管线)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(<App />)
|
||||
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 () => {
|
||||
|
||||
@@ -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(
|
||||
<ErrorBoundary>
|
||||
<Boom />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
expect(screen.getByText('界面渲染出现异常')).toBeInTheDocument()
|
||||
expect(screen.getByText('boom in render')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '重新加载应用' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('子组件正常渲染时原样透传(零侵入)', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>ok-content</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
expect(screen.getByText('ok-content')).toBeInTheDocument()
|
||||
expect(screen.queryByText('界面渲染出现异常')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
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 (
|
||||
<div className="error-boundary">
|
||||
<h2>界面渲染出现异常</h2>
|
||||
<p className="error-message">{this.state.error.message || String(this.state.error)}</p>
|
||||
<button className="btn primary" onClick={() => window.location.reload()}>
|
||||
重新加载应用
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -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<typeof cou
|
||||
{ status: 'unreadable', countKey: 'unreadable', text: '无法读取' }
|
||||
]
|
||||
|
||||
/** 快照变迁类型的展示配置(顺序即徽章渲染顺序) */
|
||||
/** 快照变迁类型的展示配置(顺序即徽章渲染顺序;标签复用 snapshotDiff 的共享映射) */
|
||||
const SNAP_CHANGES: { key: SnapshotChange; text: string }[] = [
|
||||
{ key: 'fixed', text: '已修复' },
|
||||
{ key: 'regressed', text: '新增差异' },
|
||||
{ key: 'new', text: '新文件' },
|
||||
{ key: 'gone', text: '已移除' },
|
||||
{ key: 'changed', text: '仍有差异' },
|
||||
{ key: 'stable', text: '保持一致' }
|
||||
{ key: 'fixed', text: SNAPSHOT_CHANGE_LABEL.fixed },
|
||||
{ key: 'regressed', text: SNAPSHOT_CHANGE_LABEL.regressed },
|
||||
{ key: 'new', text: SNAPSHOT_CHANGE_LABEL.new },
|
||||
{ key: 'gone', text: SNAPSHOT_CHANGE_LABEL.gone },
|
||||
{ key: 'changed', text: SNAPSHOT_CHANGE_LABEL.changed },
|
||||
{ key: 'stable', text: SNAPSHOT_CHANGE_LABEL.stable }
|
||||
]
|
||||
|
||||
export default function FolderView({
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
countFolderStats,
|
||||
plainFolderSummary
|
||||
} from './folderReport'
|
||||
import { diffSnapshot } from './snapshotDiff'
|
||||
|
||||
const ENTRIES: FolderEntry[] = [
|
||||
{ rel: 'a.txt', status: 'same', leftSize: 5, rightSize: 5 },
|
||||
@@ -180,3 +181,75 @@ describe('buildFolderReport - Markdown', () => {
|
||||
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('<td>变迁</td>')
|
||||
expect(html).toContain('<td>当前状态</td>')
|
||||
expect(html).toContain('<td>基线状态</td>')
|
||||
// 行级:fixed(不同→相同)与 stable(相同→相同)
|
||||
expect(html).toContain('chg-fixed')
|
||||
expect(html).toContain('已修复')
|
||||
expect(html).toContain('chg-stable')
|
||||
expect(html).toContain('保持一致')
|
||||
// 普通条目表头不再出现
|
||||
expect(html).not.toContain('<td>状态</td>')
|
||||
})
|
||||
|
||||
it('HTML:gone 条目当前状态为 —,大小回退基线数据;new 条目基线状态为 —', () => {
|
||||
const html = buildFolderReport(CUR, SNAP_CTX, 'html')
|
||||
// 按表格行分块断言(tr 与单元格内容不在同一物理行)
|
||||
const trOf = (rel: string): string => html.split('<tr').find((c) => 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('<td>状态</td>')
|
||||
expect(html).not.toContain('<td>变迁</td>')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 `<tr class="chg-${e.change}">
|
||||
<td class="chg">${SNAPSHOT_CHANGE_LABEL[e.change]}</td>
|
||||
<td class="rel">${esc(e.rel)}</td>
|
||||
<td class="st">${cur}</td>
|
||||
<td class="st">${base}</td>
|
||||
<td class="sz">${fmtSize(display.leftSize)}</td>
|
||||
<td class="sz">${fmtSize(display.rightSize)}</td>
|
||||
</tr>`
|
||||
}
|
||||
|
||||
function buildFolderHtml(
|
||||
entries: FolderEntry[],
|
||||
ctx: FolderReportContext,
|
||||
@@ -133,30 +164,40 @@ function buildFolderHtml(
|
||||
const semLine = ctx.semantic
|
||||
? '判定口径:语义判等开启(空白/换行差异计为语义同,不计入不同)'
|
||||
: '判定口径:字节级严格比对(任何字节差异均计为不同)'
|
||||
// 对照模式:条目表替换为变迁对照表(全量含 stable),列结构随之切换
|
||||
const snaps = ctx.snapshotEntries
|
||||
const cols = snaps
|
||||
? '<colgroup><col class="c-chg"><col><col class="c-st"><col class="c-st"><col class="c-ls"><col class="c-rs"></colgroup>'
|
||||
: '<colgroup><col class="c-st"><col><col class="c-ls"><col class="c-rs"></colgroup>'
|
||||
const headRow = snaps
|
||||
? '<tr><td>变迁</td><td>相对路径</td><td>当前状态</td><td>基线状态</td><td style="text-align:right">左侧大小</td><td style="text-align:right">右侧大小</td></tr>'
|
||||
: '<tr><td>状态</td><td>相对路径</td><td style="text-align:right">左侧大小</td><td style="text-align:right">右侧大小</td></tr>'
|
||||
|
||||
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 ? ' <i title="超大文件,头部采样近似判定">≈</i>' : ''
|
||||
return `<tr class="${cls}">
|
||||
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 ? ' <i title="超大文件,头部采样近似判定">≈</i>' : ''
|
||||
return `<tr class="${cls}">
|
||||
<td class="st">${STATUS_LABEL[e.status]}</td>
|
||||
<td class="rel">${esc(e.rel)}${approx}</td>
|
||||
<td class="sz">${fmtSize(e.leftSize)}</td>
|
||||
<td class="sz">${fmtSize(e.rightSize)}</td>
|
||||
</tr>`
|
||||
})
|
||||
.join('\n')
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
@@ -197,10 +238,18 @@ function buildFolderHtml(
|
||||
</header>
|
||||
${ctx.truncated ? `<div class="trunc-note">⚠ 文件数量超出上限(已发现 ${ctx.total}+ 个),以下仅包含部分条目,请人工确认完整性</div>` : ''}
|
||||
<table class="f-table">
|
||||
<colgroup><col class="c-st"><col><col class="c-ls"><col class="c-rs"></colgroup>
|
||||
<thead><tr><td>状态</td><td>相对路径</td><td style="text-align:right">左侧大小</td><td style="text-align:right">右侧大小</td></tr></thead>
|
||||
${cols}
|
||||
<thead>${headRow}</thead>
|
||||
<tbody>
|
||||
${entries.length === 0 ? '<tr><td colspan="4" style="text-align:center;color:#74809a;padding:24px">两个文件夹内都没有文件</td></tr>' : rowsHtml}
|
||||
${
|
||||
snaps
|
||||
? snaps.length === 0
|
||||
? '<tr><td colspan="6" style="text-align:center;color:#74809a;padding:24px">快照与当前结果都没有条目</td></tr>'
|
||||
: rowsHtml
|
||||
: entries.length === 0
|
||||
? '<tr><td colspan="4" style="text-align:center;color:#74809a;padding:24px">两个文件夹内都没有文件</td></tr>'
|
||||
: rowsHtml
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="foot">由 DiffLens 生成 · 语义同 = 字节不同但剔除空白/换行后内容一致(双击条目可查看单文件差异明细)</div>
|
||||
@@ -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')
|
||||
|
||||
@@ -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 - 状态变迁对照', () => {
|
||||
|
||||
@@ -120,13 +120,35 @@ export interface SnapshotDiffResult {
|
||||
stats: SnapshotDiffStats
|
||||
}
|
||||
|
||||
/** 快照与当前扫描的目录对是否一致(不一致时对照无意义,调用方拦截) */
|
||||
/** 变迁类型的人话标签(FolderView 徽章与文件夹报告共用同一实现) */
|
||||
export const SNAPSHOT_CHANGE_LABEL: Record<SnapshotChange, string> = {
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -1234,3 +1234,32 @@ button.fstat.active {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user