fix: 0.9.1 快照收尾修复与稳健性增强 - 快照目录对归一化匹配(Windows同目录异写不误拦、与scanCacheStore同款逻辑双向同步)、截断结果保存快照拦截提示(防对照新文件误报)、folder:scan IPC校验抽scanIpc纯逻辑模块单测全覆盖(缓存源选择惰性读盘)、对照模式导出报告附变迁对照表(三格式当前/基线状态与大小、标签收敛SNAPSHOT_CHANGE_LABEL共享映射、E2E对照导出写盘断言)、顶层ErrorBoundary渲染兜底(错误面板一键重载防白屏)、文档四件套同步

This commit is contained in:
2026-08-19 09:20:28 +08:00
parent 35fa81df9d
commit 38512d7882
19 changed files with 571 additions and 73 deletions
+3 -2
View File
@@ -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
View File
@@ -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/savedAtparseSnapshot 全字段防御校验绝不部分加载;diffSnapshot 按 rel 对齐产出六类变迁——fixed/regressed/new/gone/changed/stable,以 isDiffStatus 为主轴;snapshotDirsMatch 目录对一致性校验) · 「保存快照」复用报告保存管线落盘 JSON(默认名带时间戳) · 「加载快照」走打开文件对话框读取(目录对不一致/文件无效/读取失败/无当前结果四类拦截提示) · 对照模式接管 FolderView 列表渲染:变迁统计徽章(点击单选过滤)替换常规统计行、平铺全路径行 + 变迁徽章(gone 回退快照数据展示)、默认隐藏 stable、搜索共用、退出对照恢复 · 对照随重扫自动刷新(目录对仍一致时),退出文件夹对比清空 · 对照模式导出报告附「快照对照」口径行(三格式)
- 字符级预算自适应:charGroupBudget(totalGroupChars) = clamp(总组流/2, 下限 10 万, 上限 100 万)——下限与 0.8.0 固定值一致(小输入行为与已固化测试零变化),大输入但变更组普遍较小的场景按比例放宽(上限封顶最坏总耗时仍有界,单组 5 万/编辑距离 5000 不变) · plainOptions 字案与 Toolbar title 同步("预算随规模自适应") · 行为固化测试:400 组跨行重组总流 48 万 → 预算 24 万 → 200 组字符级(固定预算仅 83 组)
- 性能基准 E2E:5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s(万级行实测秒级,慢机 4 倍余量防 flaky,断言失败即性能回归) · 300KB GBK 15000 行——打开到状态栏行数渲染 < 10s · 新功能 E2E:忽略规则目录剪枝重扫、增量缓存跨重启(scan-cache.json 落盘断言 + 第二实例重扫正确)、快照保存→修复差异→加载对照变迁
0.9.1 ← 快照收尾修复 · IPC 校验单测化 · 对照报告变迁明细 · 渲染兜底(已发布):
- 快照目录对归一化匹配(修复):snapshotDirsMatch 此前逐字符比较,Windows 下 C:\Foo 与 c:/Foo 同目录异写会被误判"目录对不一致"而拦截对照——归一化(反斜杠→斜杠/去尾分隔符/盘符小写)后比较,与主进程 scanCacheStore.pairKey 同款逻辑(两进程无法共享模块,双向同步标注);大小写敏感文件系统的路径段大小写差异仍正确判不一致
- 截断结果保存快照拦截(修复):扫描结果截断时条目不完整(仅前 1 万个),此前可照常保存快照——日后对照时快照缺失的文件被全部误判为"新文件";现保存前检查 truncatedtoast 拦截提示"快照不完整无法对照"(对齐同目录防呆、目录不一致拦截的既有防呆风格)
- 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
View File
@@ -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.00.9.1 增强):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复
- 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s300KB 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
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.9.0",
"version": "0.9.1",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
+6 -22
View File
@@ -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(大缓冲后台解码,与文件打开共用同一管线)
+99
View File
@@ -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)
})
})
+56
View File
@@ -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 } : {})
}
}
+9 -2
View File
@@ -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,
+22
View File
@@ -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
}
}
+8 -7
View File
@@ -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>')
})
})
+111 -28
View File
@@ -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 - 状态变迁对照', () => {
+24 -2
View File
@@ -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)
}
/**
+4 -1
View File
@@ -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>
)
+29
View File
@@ -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;
}