feat: 0.10.2 大快照加载修复、快照保存防抖与关于弹框 - 新增snapshot:read专用读取IPC(128MB上限,修复保存32MB内容上限与加载10MB通用上限不对称导致大快照保存成功却永远无法加载的自相矛盾,顺带消除加载污染lastDir记忆)、快照保存中保存/加载按钮saving禁用与App守卫双保险(computing-overlay不拦截交互可并发触发两轮保存对话框)、同目录防呆路径归一化判定(复用normDir,Windows异写路径不再绕过)、关于弹框(header常驻ⓘ按钮,app:get-version取版本号,作者/双邮箱/组织/开源地址/许可证,点击复制走主进程剪贴板)、删除孤儿CSS类.diff-empty-pane与folderTree注释补unreadable,新增14单测与2条E2E(626单测+E2E 29全过覆盖率98.15/93.49/93.65/98.15),文档四件套同步

This commit is contained in:
2026-08-19 15:20:52 +08:00
parent 05a603ee68
commit 719681a366
18 changed files with 686 additions and 59 deletions
+3 -2
View File
@@ -12,7 +12,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**扫描进度反馈**(比对阶段实时显示"已比对 N / M")、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**忽略规则**(gitignore 简化子集含高级 glob`node_modules/` 目录整树剪枝、`*.log` 通配、`lo?g` 单字符、`*.[ch]` 字符类(范围与取反)、`src/temp` 多段路径、`build/**/cache` 跨段、`!keep.log` 否定规则(最后匹配优先);无效规则实时提示不再静默丢弃;规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫)
- **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**,保存期间批量读取实时显示"已读取 N / M"进度),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么");内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照)
- **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**,保存期间批量读取实时显示"已读取 N / M"进度,保存中防重复触发),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么");内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);**大快照专用读取通道**(加载上限 128MB,通用文件的 10MB 上限不拦截自家保存的大快照,且不污染文件对话框的目录记忆);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照)
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览)
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
@@ -39,6 +39,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **右键菜单键盘导航**:行级右键菜单支持 ↑/↓ 移动高亮(跳过禁用项、首尾循环)、Enter 触发、Esc 关闭,鼠标悬停与键盘高亮同步
- **滚动联动**:左右两个面板滚动位置自动同步
- **性能基准 E2E**5 万行差异计算与 300KB GBK 解码带耗时上限断言(真实 Electron 链路),性能回归在发布前即可拦截
- **关于弹框**:header 右侧 ⓘ 图标一键查看应用介绍、版本号、作者(thzxx)、联系邮箱、组织(MetonaTeam)、开源地址与许可证;邮箱/地址点击复制到剪贴板
---
@@ -116,7 +117,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
- 作者:thzxx
- 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.10.1
- 版本:0.10.2
---
+8 -1
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.10.1`**
当前基线版本:**`0.10.2`**
---
@@ -130,6 +130,13 @@ z = 补丁版本号(Patch
- 快照保存读取进度:保存快照批量读取差异条目内容期间遮罩显示"已读取 N / M"(文件数口径:去重后双侧路径总数,按 200/批推进,与扫描进度反馈对称,大差异目录不再无反馈干等);保存结束/失败进度随遮罩复位
- 差异文件导航游标修复:导航游标不在差异序列时导航组直接隐藏——此前显示"—"计数且"下一个"按钮可点无响应(goDiffFile 静默 return);触发路径为双击 same/语义同条目进入对比(0.7.0 遗留)与快照对照模式双击 fixed/regressed/new 等当前无差异条目进入内容对照(0.10.0 放大暴露面);双击差异条目即回序列、导航恢复
- 测试与文档:新增 16 用例(ignoreRules 字符通配 12 / folderScan 字符通配集成 1 / App 导航隐藏 2 与读取进度 1)并修复 0.10.0 遗留的 snapshotDiff.test 版本号类型错误(makeSnapshot 传 3/0 违反 1|2 字面量联合,改展开构造),共 612 单测 + 27 E2E
0.10.2 ← 大快照加载修复 · 快照保存防抖 · 关于弹框(已发布):
- 大快照加载修复(保存/加载上限不对称):保存侧快照内容总量上限 32M 字符(中文 JSON 化可达 ~96MB),但加载走 file:open 通用通道被 10MB 上限拦截——大差异目录的快照"保存成功却永远无法加载";新增 snapshot:read 专用 IPCJSON 过滤对话框 + 128MB 读取上限 + UTF-8 直读不做编码探测),顺带消除快照加载污染文件对话框 lastDir 记忆的副作用
- 快照保存中防抖:保存遮罩为纯视觉反馈(computing-overlay 的 pointer-events:none 不拦截交互),此前保存期间可再点"保存/加载快照"并发触发两轮保存对话框——现 FolderView 保存/加载按钮随 saving 禁用(与扫描中禁用同语言),App.loadSnapshot 增加保存中守卫双保险
- 同目录防呆归一化:两次选择同目录的判定从严格字符串比较改为路径归一化后比较(复用 snapshotDiff 导出的 normDir,与 scanCacheStore 同款逻辑)——Windows 异写路径(大小写/分隔符差异)不再绕过防呆
- 关于弹框:header 右侧常驻 ⓘ 图标按钮(两种模式可见)→ 弹框展示应用 Logo/介绍/版本号(app:get-version IPC 取 app.getVersion()package.json 为唯一来源,dev 与打包一致)/作者 thzxx/邮箱(stinanimz@gmail.com、1440196015@qq.com/组织 MetonaTeam/开源地址/许可证 MIT;Esc/遮罩/关闭按钮关闭,邮箱与开源地址点击复制(复用主进程剪贴板管线)
- 微清理:删除孤儿 CSS 类 .diff-empty-pane0.9.2 清理孤儿变量的漏网);folderTree 聚合优先级注释补 unreadable
- 测试与文档:新增 14 单测(AboutModal 9 / App 4:关于打开关闭·复制·文件夹模式可见·异写防呆 / FolderView 1saving 禁用)与 2 条 E2E(关于弹框真实链路含版本号与复制 / 11MB 大快照加载成功固化上限修复),共 626 单测 + 29 E2E
...
0.y.z ← 长期停留,永不进入 1.x
```
+12 -5
View File
@@ -45,7 +45,7 @@ vitest.config.ts # Vitest 配置(jsdom + 测试范围含
playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑)
e2e/
├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口/合成拖放)
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照/拖拽导入/偏好重启)
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照与大快照专用通道/关于弹框/拖拽导入/偏好重启)
src/shared/
├── ignoreRules.ts # 忽略规则匹配器纯逻辑(gitignore 简化子集含高级 glob 与段内字符通配——? 单字符/[abc] 字符类;主进程扫描剪枝与渲染端无效提示共用,段模式预编译 + DP 段序列匹配 + 否定仲裁)
└── ignoreRules.test.ts # 忽略规则单元测试(名字/目录/通配回归 + 多段/锚定/跨段/段内通配/否定仲裁/无效清单/上限/IPC 校验 + ? 与字符类专项)
@@ -85,13 +85,16 @@ src/renderer/src/
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放/搜索命中行内高亮)
│ ├── ErrorBoundary.tsx # 顶层渲染错误边界(错误面板 + 一键重载,防白屏死机)
│ ├── ErrorBoundary.test.tsx # 渲染错误兜底测试(抛错子组件 fallback/正常子组件透传)
│ ├── AboutModal.tsx # 「关于」弹框(应用介绍/版本/作者/邮箱/组织/开源地址/许可证;邮箱与地址点击复制)
│ ├── AboutModal.test.tsx # 关于弹框测试(元信息渲染/版本占位/复制回调/三种关闭方式/卸载监听清理)
│ ├── LogoIcon.tsx # 应用 Logo 图标(App 头部与关于弹框共用单一实现)
│ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤含扩展名/子树收集)
│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt)
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉含无效规则提示与进度遮罩/快照对照模式含变迁过滤归零复位与内容对照 title/目录双击/重新扫描与重新选择/双击/导出菜单/虚拟滚动/resize 重算)
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉含无效规则提示与进度遮罩/快照对照模式含变迁过滤归零复位与内容对照 title/目录双击/重新扫描与重新选择/快照保存中 saving 禁用/双击/导出菜单/虚拟滚动/resize 重算)
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记/搜索框计数与 Enter·Shift+Enter·Esc 键盘交互/ref 转发)
└── __tests__/
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索/Ctrl+F/文件夹模式进出与双击/语义判等链路/差异文件导航(含游标不在序列时导航隐藏)/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截/快照内容级对照(v2 保存含内容与读取进度·超限降级·双击基线vs当前·v1 兼容)/扫描进度显示与过期丢弃)
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索/Ctrl+F/文件夹模式进出与双击(含同目录异写归一化防呆)/语义判等链路/差异文件导航(含游标不在序列时导航隐藏)/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截(加载走 snapshot:read 专用通道)/快照内容级对照(v2 保存含内容与读取进度·超限降级·双击基线vs当前·v1 兼容)/扫描进度显示与过期丢弃/关于弹框(打开·元信息·复制·双模式可见·Esc 关闭)
└── main.test.ts # 入口挂载冒烟测试
```
@@ -147,8 +150,9 @@ src/renderer/src/
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
- 单文件视图搜索(0.9.3):输入关键字即时命中计数与首个命中行 active 定位 · Enter/Shift+Enter 命中行间推进回退(行号随动)· 首尾循环 · 大小写不敏感 · 无命中 0/0 且 Enter 不崩溃不推进 · Esc 清空复位 · Ctrl+F 聚焦搜索框 · 命中行被“仅看差异”折叠时计数仍统计全量行、跳转 toast 提示且不推进
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式
- 快照基线(0.9.00.9.1 收尾,0.9.2 对称防呆,0.10.0 内容级对照):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 当前扫描结果截断时加载快照同样被拦截(0.9.2,不弹文件选择——截断缺失文件会被误报已移除) · 对照模式中重扫触发截断自动退出对照并提示(0.9.2) · 快照语义判等口径与当前开关不一致时加载提示含口径差异、一致时无提示(0.9.2) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新 · 快照内容级对照(0.10.0):保存时差异条目双侧内容入 contents(批量读取按路径返回、same 条目不存、单文件超限侧不读该侧 null)/ 内容总量超限整体降级为纯状态快照并提示(vi.mock 压低上限构造)/ 对照模式双击含基线内容条目进入"快照基线 vs 当前"内容对比(左面板快照基线标注、右面板当前文件、有差异徽章)/ v1 快照双击走常规当前文件对比(兼容回归锚) · 保存读取分批进度遮罩"已读取 N / M"0.10.1,第二批挂起构造断言,结束复位) · 双击 same 条目 / 对照模式双击 fixed 条目进入对比后差异文件导航隐藏(0.10.1,游标不在差异序列;双击差异条目导航恢复)
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入(含 Windows 异写路径归一化判定,0.10.2) · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式
- 快照基线(0.9.00.9.1 收尾,0.9.2 对称防呆,0.10.0 内容级对照):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 当前扫描结果截断时加载快照同样被拦截(0.9.2,不弹文件选择——截断缺失文件会被误报已移除) · 对照模式中重扫触发截断自动退出对照并提示(0.9.2) · 快照语义判等口径与当前开关不一致时加载提示含口径差异、一致时无提示(0.9.2) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新 · 快照内容级对照(0.10.0):保存时差异条目双侧内容入 contents(批量读取按路径返回、same 条目不存、单文件超限侧不读该侧 null)/ 内容总量超限整体降级为纯状态快照并提示(vi.mock 压低上限构造)/ 对照模式双击含基线内容条目进入"快照基线 vs 当前"内容对比(左面板快照基线标注、右面板当前文件、有差异徽章)/ v1 快照双击走常规当前文件对比(兼容回归锚) · 保存读取分批进度遮罩"已读取 N / M"0.10.1,第二批挂起构造断言,结束复位) · 双击 same 条目 / 对照模式双击 fixed 条目进入对比后差异文件导航隐藏(0.10.1,游标不在差异序列;双击差异条目导航恢复) · 加载走 snapshot:read 专用读取通道(0.10.2,mock 随通道迁移;保存/加载上限不对称修复的通道基础)
- 关于弹框(0.10.2):点击 header ⓘ 按钮打开(版本/作者/双邮箱/组织/开源地址/许可证全量断言,版本号来自 app:get-version · Esc 关闭 · 点击邮箱触发剪贴板复制与 toast 反馈 · 文件夹模式下入口同样可见
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`
- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致
@@ -191,6 +195,7 @@ src/renderer/src/
- 扫描进度遮罩(0.10.0):progress props 非空时遮罩显示"已比对 N / M"null 时仅基础文案
- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动)
- 扫描中交互防抖(0.9.2):扫描进行中语义判等开关与忽略规则入口禁用(触发重扫的入口,点击不触发回调/不打开下拉);仅看差异开关与搜索仍可用(纯视图过滤不触发重扫);「重新选择」扫描中保持可用(0.9.3 固化:发起新会话作废在途扫描,与「返回文件对比」同类)
- 快照保存中防抖(0.10.2):saving=true 时保存/加载快照按钮禁用(点击不触发回调);重新扫描/重新选择/导出/返回不受影响(仅快照链路防抖)
- 目录行双击(0.8.0):触发 onOpenDir 携带目录相对路径;双击前折叠态自动展开(toggle 残留消除);重新扫描按钮触发 onRescan 且扫描中禁用
- 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果)
- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满
@@ -214,6 +219,8 @@ src/renderer/src/
- 增量缓存跨会话持久化(0.9.0):第一实例扫描后 userData/scan-cache.json 落盘(等待异步写盘,断言含条目与合法结构)→ 同 userData 第二实例重扫同目录结果正确(主进程注入盘上缓存)
- 快照基线(0.9.00.9.1 增强,0.10.0 内容级对照):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章)
- 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归)
- 关于弹框(0.10.2):点击 header ⓘ 打开弹框——版本号(app.getVersion 真实链路,0.x 形态正则)/作者/双邮箱/组织/开源地址/许可证断言;点击邮箱走主进程剪贴板并 toast;Esc 关闭
- 大快照加载(0.10.2):手工构造 11MB+ 快照 JSONcontents 塞大字符串)→ snapshot:read 专用通道加载成功("已加载快照"出现且无 10MB 拦截提示)——固化保存/加载上限不对称修复
- 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData)
- 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理)
+68
View File
@@ -768,6 +768,74 @@ test.describe('偏好持久化(跨实例重启)', () => {
})
})
test.describe('关于弹框(0.10.2', () => {
test('点击关于按钮展示应用信息,点击邮箱复制,Esc 关闭', async () => {
const { app, window } = await launchApp()
await window.getByTitle('关于 DiffLens').click()
// 版本号来自主进程 app.getVersion()package.json 为唯一来源,0.x 形态)
await expect(window.locator('.about-version')).toHaveText(/^版本 0\.\d+\.\d+$/)
await expect(window.locator('.about-modal')).toContainText('DiffLens')
await expect(window.locator('.about-modal')).toContainText('thzxx')
await expect(window.locator('.about-modal')).toContainText('stinanimz@gmail.com')
await expect(window.locator('.about-modal')).toContainText('1440196015@qq.com')
await expect(window.locator('.about-modal')).toContainText('MetonaTeam')
await expect(window.locator('.about-modal')).toContainText('https://git.metona.cn/MetonaTeam/DiffLens')
await expect(window.locator('.about-modal')).toContainText('MIT License')
// 点击邮箱复制到剪贴板(主进程剪贴板链路)+ toast 反馈
await window.getByText('stinanimz@gmail.com').click()
await expect(window.getByText(/邮箱已复制到剪贴板/)).toBeVisible()
// Esc 关闭弹框
await window.keyboard.press('Escape')
await expect(window.locator('.about-modal')).toHaveCount(0)
await app.close()
})
})
test.describe('大快照加载(0.10.2 专用读取通道)', () => {
test('超过 10MB 的快照可正常加载对照(通用文件上限不再拦截自家快照)', async () => {
const root = await makeTmpDir('difflens-e2e-bigsnap-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(leftDir, { recursive: true })
await mkdir(rightDir, { recursive: true })
await wf(join(leftDir, 'same.txt'), 'identical')
await wf(join(rightDir, 'same.txt'), 'identical')
// 手工构造 11MB+ 快照(contents 塞大字符串):旧通道 file:open 的 10MB 上限会拦截
const snap = {
version: 2,
leftDir,
rightDir,
semantic: true,
truncated: false,
total: 1,
entries: [
{ rel: 'same.txt', status: 'same', leftSize: 9, rightSize: 9 }
],
savedAt: Date.now(),
contents: { 'same.txt': { left: 'x'.repeat(11 * 1024 * 1024), right: null } }
}
const snapPath = join(root, 'big-snapshot.json')
await wf(snapPath, JSON.stringify(snap), 'utf-8')
const { app, window } = await launchApp()
// 对话框调用序:folder:pick 左 → folder:pick 右 → snapshot:read 快照
await stubOpenDialogSeq(app, [[leftDir], [rightDir], [snapPath]])
await window.getByRole('button', { name: '对比文件夹' }).click()
await expect(window.getByText('相同 1')).toBeVisible()
await window.getByRole('button', { name: '加载快照' }).click()
// 专用通道(128MB 上限)加载成功:出现对照 toast,而非"文件超过 10MB"拦截
await expect(window.getByText(/已加载快照/)).toBeVisible()
await expect(window.getByText(/文件超过 10MB/)).toHaveCount(0)
await app.close()
} finally {
await removeTmpDir(root)
}
})
})
test.describe('大文件性能基准(耗时上限断言)', () => {
// 口径说明:万级行 diff 在 worker 后台实测为秒级;上限按慢机约 4 倍余量放宽
// 防 CI flaky(用例超时 60s 兜底)。断言失败即性能回归,需排查引擎/IO 退化。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.10.1",
"version": "0.10.2",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
+41
View File
@@ -370,6 +370,47 @@ ipcMain.handle('file:read-by-path', async (_event, filePath: string) => {
return readTextFile(filePath)
})
/**
* 快照文件的读取上限(128MB):保存侧内容总量上限 32M 字符(中文 JSON 化后可达 ~96MB),
* 走通用读取管线的 10MB 上限会拦截自家保存的大快照(保存成功却无法加载的自相矛盾),
* 故快照加载走本专用通道;上限仍防御异常超大文件拖垮渲染进程。
*/
const SNAPSHOT_MAX_BYTES = 128 * 1024 * 1024
/**
* IPC: 选择并读取快照文件(专用通道,与 file:open 分离)。
* - 读取上限 128MB(快照为应用自有 JSON,可大于通用文件的 10MB);
* - 快照始终以 UTF-8 写盘,直接按 UTF-8 解码,无需编码探测;
* - 不写 lastDir(快照目录与常用文件目录语义不同,避免污染文件对话框记忆)。
*/
ipcMain.handle('snapshot:read', async () => {
const result = await dialog.showOpenDialog({
title: '选择快照文件',
properties: ['openFile'],
filters: [
{ name: '快照文件', extensions: ['json'] },
{ name: '所有文件', extensions: ['*'] }
]
})
if (result.canceled || result.filePaths.length === 0) return null
const filePath = result.filePaths[0]
const name = filePath.split(/[\\/]/).pop() ?? filePath
let buf: Buffer
try {
const st = await fs.promises.stat(filePath)
if (st.size > SNAPSHOT_MAX_BYTES) {
return { error: 'too-large' as const, name, size: st.size }
}
buf = await fs.promises.readFile(filePath)
} catch {
return { error: 'read-failed' as const, name }
}
return { path: filePath, name, text: buf.toString('utf-8'), encoding: 'UTF-8', binary: false }
})
/** IPC: 应用版本号(package.json 的 version 为唯一来源,dev 与打包行为一致) */
ipcMain.handle('app:get-version', () => app.getVersion())
/** 批量读取的路径数上限(渲染端快照保存按差异条目收集,超出说明异常输入,整体拒绝) */
const READ_BATCH_MAX_PATHS = 256
+9 -1
View File
@@ -132,7 +132,15 @@ const api = {
ipcRenderer.invoke('file:read-by-path', filePath),
/** 批量读取文本文件(快照保存收集差异条目内容用;单文件失败为错误对象不整体失败) */
readFilesByPaths: (paths: string[]): Promise<ReadBatchResult | { error: 'invalid' }> =>
ipcRenderer.invoke('folder:read-batch', paths)
ipcRenderer.invoke('folder:read-batch', paths),
/**
* 选择并读取快照文件(专用通道):读取上限 128MB(通用文件 10MB 上限会拦截
* 自家保存的大快照),UTF-8 直读不做编码探测,不写 lastDir 记忆。
* 返回结构与 openFile 一致(成功数据 / 失败原因 / 用户取消 null)。
*/
readSnapshot: (): Promise<FileOpenResult> => ipcRenderer.invoke('snapshot:read'),
/** 应用版本号(主进程 app.getVersion()package.json 的 version 为唯一来源) */
getVersion: (): Promise<string> => ipcRenderer.invoke('app:get-version')
}
export type DiffLensApi = typeof api
+57 -17
View File
@@ -18,6 +18,7 @@ import {
parseSnapshot,
diffSnapshot,
snapshotDirsMatch,
normDir,
isDiffStatus,
SNAPSHOT_VERSION,
SNAPSHOT_FILE_MAX_BYTES,
@@ -32,6 +33,8 @@ import { useDiff } from './hooks/useDiff'
import type { PaneMeta } from './components/DiffView'
import type { SideCell } from './diff/diffEngine'
import FolderView from './components/FolderView'
import AboutModal from './components/AboutModal'
import LogoIcon from './components/LogoIcon'
import type { FolderEntry, FolderScanResult } from '../../preload/index'
/** 生成默认报告文件名所需的时间戳:YYYYMMDD-HHmmss */
@@ -155,16 +158,6 @@ interface PaneState {
text: string
}
function LogoIcon(): ReactElement {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="6.2" stroke="#fff" strokeWidth="2.1" />
<line x1="15.5" y1="15.5" x2="20.5" y2="20.5" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" />
<path d="M5 11h3.4M5 14h2" stroke="#030712" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
function EmptyPane({
side,
onOpen,
@@ -251,11 +244,28 @@ export default function App(): ReactElement {
const [toasts, setToasts] = useState<ToastItem[]>([])
const [pasteSide, setPasteSide] = useState<'left' | 'right' | null>(null)
const [pasteOpen, setPasteOpen] = useState(false)
// 「关于」弹框开关与应用版本号(挂载时经 IPC 取一次,package.json 为唯一来源)
const [aboutOpen, setAboutOpen] = useState(false)
const [appVersion, setAppVersion] = useState('')
const pasteWrapRef = useRef<HTMLDivElement>(null)
const closePaste = useCallback((): void => setPasteOpen(false), [])
// 点击外部 / 按 Esc 关闭粘贴下拉
useDismiss(pasteWrapRef, closePaste)
// 挂载时获取应用版本号(关于弹框展示;失败静默显示占位)
useEffect(() => {
let alive = true
window.api
.getVersion()
.then((v) => {
if (alive && typeof v === 'string') setAppVersion(v)
})
.catch(() => {})
return () => {
alive = false
}
}, [])
// toast 队列:最多同时展示 3 条,各自独立计时互不清除
// (此前单条覆盖式会互顶:二进制乱码预警被行数预警立即顶掉,用户看不到前一条)
const toastSeq = useRef(0)
@@ -469,8 +479,9 @@ export default function App(): ReactElement {
return
}
if (!rightDir) return
// 同目录防呆:两侧相同只能得到"全部一致"的无意义结果
if (leftDir === rightDir) {
// 同目录防呆(路径归一化后比较,与 scanCacheStore/snapshotDiff 同款逻辑):
// Windows 异写路径(大小写/分隔符差异)不得绕过;两侧相同只能得到"全部一致"的无意义结果
if (normDir(leftDir) === normDir(rightDir)) {
showToast('两次选择的是同一个文件夹,请选择两个不同的文件夹')
return
}
@@ -654,22 +665,29 @@ export default function App(): ReactElement {
})()
}, [folderState, folderSemantic, showToast])
/** 加载快照文件并与当前结果对照:无当前结果 / 当前结果截断 / 目录对不一致 / 文件无效时拦截提示 */
/** 加载快照文件并与当前结果对照:无当前结果 / 保存中 / 当前结果截断 / 目录对不一致 / 文件无效时拦截提示 */
const loadSnapshot = useCallback((): void => {
if (!folderState) {
showToast('请先进行文件夹对比,再加载快照')
return
}
// 保存中防抖:保存遮罩不拦截交互(pointer-events:none 仅视觉反馈),此处守卫
// 防止保存与加载并发竞争(FolderView 侧按钮已同步禁用,此为双保险)
if (snapshotSaving) {
showToast('正在保存快照,请稍候')
return
}
// 当前结果截断:条目不完整,快照有而当前"缺失"的条目会被误报为已移除(与保存侧拦截对称)
if (folderState.truncated) {
showToast('当前扫描结果已截断(文件过多),条目不完整无法对照,请先缩小对比范围')
return
}
void (async (): Promise<void> => {
let data: Awaited<ReturnType<typeof window.api.openFile>>
let data: Awaited<ReturnType<typeof window.api.readSnapshot>>
try {
// 不指定侧别的文件选择(主进程 title 为通用"选择文本文件",仅读取内容)
data = await window.api.openFile()
// 快照专用读取通道:读取上限 128MB(通用文件 10MB 上限会拦截自家保存的大快照),
// 且不写 lastDir(不污染文件对话框的目录记忆)
data = await window.api.readSnapshot()
} catch {
showToast('快照读取失败,请重试')
return
@@ -698,7 +716,7 @@ export default function App(): ReactElement {
: '已加载快照,列表按状态变迁标注'
)
})()
}, [folderState, folderSemantic, showToast])
}, [folderState, folderSemantic, snapshotSaving, showToast])
/** 退出快照对照(清空基线与对照结果) */
const exitSnapshot = useCallback((): void => {
@@ -1272,6 +1290,17 @@ export default function App(): ReactElement {
</button>
</>
)}
<button
className="btn ghost about-btn"
title="关于 DiffLens"
onClick={() => setAboutOpen(true)}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.8" />
<circle cx="12" cy="7.6" r="1.35" fill="currentColor" />
<line x1="12" y1="10.6" x2="12" y2="17" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" />
</svg>
</button>
</header>
{mode === 'folder' && folderState ? (
@@ -1282,6 +1311,7 @@ export default function App(): ReactElement {
truncated={folderState.truncated}
total={folderState.total}
scanning={folderScanning}
saving={snapshotSaving}
progress={scanProgress}
onlyDiff={folderOnlyDiff}
onOnlyDiffChange={setFolderOnlyDiff}
@@ -1418,6 +1448,16 @@ export default function App(): ReactElement {
)}
{menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />}
{aboutOpen && (
<AboutModal
version={appVersion}
onClose={() => setAboutOpen(false)}
onCopy={(text, label) => {
void window.api.setClipboard(text)
showToast(`${label}已复制到剪贴板`)
}}
/>
)}
{pasteSide && (
<TextInputModal
title={`粘贴${pasteSide === 'left' ? '左侧' : '右侧'}文本`}
+80 -16
View File
@@ -33,7 +33,11 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
onScanProgress: (): (() => void) => () => {},
readByPath: async () => null,
// 批量读取默认空结果(快照保存的 contents 数据流由专项用例覆写)
readFilesByPaths: async () => ({ files: [] })
readFilesByPaths: async () => ({ files: [] }),
// 快照加载专用通道:默认用户取消(null);快照内容由各用例覆写
readSnapshot: async () => null,
// 应用版本号(关于弹框展示;测试固定值避免与真实版本耦合)
getVersion: async () => '9.9.9-test'
}
return { ...base, ...overrides } as typeof window.api
}
@@ -1989,19 +1993,19 @@ describe('App - 快照基线', () => {
})
}
/** 进入文件夹模式(当前结果 curEntries);可覆盖 openFile 返回快照内容 */
async function enterForSnapshot(openFileText?: string) {
/** 进入文件夹模式(当前结果 curEntries);可覆盖 readSnapshot 返回快照内容 */
async function enterForSnapshot(snapshotText?: string) {
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 3 }),
...(openFileText !== undefined
...(snapshotText !== undefined
? {
openFile: async () => ({
readSnapshot: async () => ({
path: '/tmp/snap.json',
name: 'snap.json',
text: openFileText,
text: snapshotText,
encoding: 'UTF-8',
binary: false
})
@@ -2081,7 +2085,7 @@ describe('App - 快照基线', () => {
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 3 }),
openFile: async () => ({
readSnapshot: async () => ({
path: '/tmp/snap.json',
name: 'snap.json',
text: makeSnapJson(),
@@ -2137,7 +2141,7 @@ describe('App - 快照基线', () => {
it('快照读取失败(error 返回):拦截提示', async () => {
await enterForSnapshot()
window.api = mockApi({
openFile: async () => ({ error: 'read-failed' as const, name: 'snap.json' })
readSnapshot: async () => ({ error: 'read-failed' as const, name: 'snap.json' })
})
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
expect(await screen.findByText(/快照读取失败,请重试/)).toBeInTheDocument()
@@ -2172,7 +2176,7 @@ describe('App - 快照基线', () => {
]
return { entries, truncated: false, total: entries.length }
},
openFile: async () => ({
readSnapshot: async () => ({
path: '/tmp/snap.json',
name: 'snap.json',
text: makeSnapJson({ entries: snapBase }),
@@ -2194,13 +2198,13 @@ describe('App - 快照基线', () => {
})
it('当前扫描结果截断时加载快照被拦截提示,不弹文件选择', async () => {
const openFile = vi.fn()
const readSnapshot = vi.fn()
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 }),
openFile
readSnapshot
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
@@ -2208,7 +2212,7 @@ describe('App - 快照基线', () => {
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
expect(await screen.findByText(/当前扫描结果已截断/)).toBeInTheDocument()
// 拦截发生在文件选择之前(对称防呆:截断的当前结果会把缺失文件误报为已移除)
expect(openFile).not.toHaveBeenCalled()
expect(readSnapshot).not.toHaveBeenCalled()
expect(screen.queryByText(/已加载快照/)).not.toBeInTheDocument()
})
@@ -2242,7 +2246,7 @@ describe('App - 快照基线', () => {
? { entries: curEntries, truncated: false, total: 3 }
: { entries: curEntries.slice(0, 2), truncated: true, total: 9999 }
},
openFile: async () => ({
readSnapshot: async () => ({
path: '/tmp/snap.json',
name: 'snap.json',
text: makeSnapJson(),
@@ -2391,7 +2395,7 @@ describe('App - 快照内容级对照(v2', () => {
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 2 }),
openFile: async () => ({
readSnapshot: async () => ({
path: '/tmp/snap.json',
name: 'snap.json',
text: snapJson,
@@ -2440,7 +2444,7 @@ describe('App - 快照内容级对照(v2', () => {
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 2 }),
openFile: async () => ({
readSnapshot: async () => ({
path: '/tmp/snap.json',
name: 'snap.json',
text: snapJson,
@@ -2493,7 +2497,7 @@ describe('App - 快照内容级对照(v2', () => {
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 2 }),
openFile: async () => ({
readSnapshot: async () => ({
path: '/tmp/snap.json',
name: 'snap.json',
text: snapJson,
@@ -2616,4 +2620,64 @@ describe('App - 扫描进度反馈', () => {
)
expect(screen.queryByText(/已比对/)).not.toBeInTheDocument()
})
})
describe('App - 关于弹框(0.10.2', () => {
it('点击 header 关于按钮打开弹框:版本/作者/邮箱/组织/开源地址可见,Esc 关闭', async () => {
render(<App />)
// 两种模式常驻入口
fireEvent.click(screen.getByTitle('关于 DiffLens'))
// 版本号来自 app:get-versionmock 固定 9.9.9-test
expect(await screen.findByText('版本 9.9.9-test')).toBeInTheDocument()
expect(screen.getByText('thzxx')).toBeInTheDocument()
expect(screen.getByText('stinanimz@gmail.com')).toBeInTheDocument()
expect(screen.getByText('1440196015@qq.com')).toBeInTheDocument()
expect(screen.getByText('MetonaTeam')).toBeInTheDocument()
expect(screen.getByText('https://git.metona.cn/MetonaTeam/DiffLens')).toBeInTheDocument()
expect(screen.getByText('MIT License')).toBeInTheDocument()
// Esc 关闭
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() =>
expect(screen.queryByText('版本 9.9.9-test')).not.toBeInTheDocument()
)
})
it('点击邮箱复制到剪贴板并 toast 反馈', async () => {
render(<App />)
fireEvent.click(screen.getByTitle('关于 DiffLens'))
fireEvent.click(await screen.findByText('stinanimz@gmail.com'))
expect(clipboardSpy).toHaveBeenCalledWith('stinanimz@gmail.com')
expect(await screen.findByText(/邮箱已复制到剪贴板/)).toBeInTheDocument()
})
it('文件夹模式下关于按钮同样可见', async () => {
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: [], truncated: false, total: 0 })
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('文件夹对比 · 双击条目进入单文件对比')
expect(screen.getByTitle('关于 DiffLens')).toBeInTheDocument()
})
})
describe('App - 同目录防呆归一化(0.10.2', () => {
it('Windows 异写路径(盘符大小写/分隔符/尾斜杠差异)的同一目录被拦截,不进入扫描', async () => {
// 注:normDir 有意保留路径段大小写(大小写敏感文件系统取舍),仅归一化盘符/分隔符/尾斜杠
const dirs = ['C:\\Foo', 'c:/Foo/']
let call = 0
const scanFolder = vi.fn(async () => ({ entries: [], truncated: false, total: 0 }))
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
expect(await screen.findByText(/两次选择的是同一个文件夹/)).toBeInTheDocument()
expect(scanFolder).not.toHaveBeenCalled()
expect(screen.queryByText('返回文件对比')).not.toBeInTheDocument()
})
})
+3 -1
View File
@@ -19,7 +19,9 @@ describe('main 入口', () => {
scanFolder: async () => ({ entries: [], truncated: false, total: 0 }),
onScanProgress: (): (() => void) => () => {},
readByPath: async () => null,
readFilesByPaths: async () => ({ files: [] })
readFilesByPaths: async () => ({ files: [] }),
readSnapshot: async () => null,
getVersion: async () => '0.0.0-test'
} as typeof window.api
})
@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import AboutModal, { ABOUT_INFO } from './AboutModal'
function setup(overrides?: { version?: string }): { onClose: ReturnType<typeof vi.fn>; onCopy: ReturnType<typeof vi.fn> } {
const onClose = vi.fn()
const onCopy = vi.fn()
render(
<AboutModal version={overrides?.version ?? '0.10.2'} onClose={onClose} onCopy={onCopy} />
)
return { onClose, onCopy }
}
describe('AboutModal - 渲染', () => {
it('展示应用名、版本与全部元信息(作者/邮箱/组织/开源地址/许可证)', () => {
setup()
expect(screen.getByText(ABOUT_INFO.name)).toBeInTheDocument()
expect(screen.getByText('版本 0.10.2')).toBeInTheDocument()
expect(screen.getByText(ABOUT_INFO.description)).toBeInTheDocument()
expect(screen.getByText(ABOUT_INFO.author)).toBeInTheDocument()
for (const m of ABOUT_INFO.emails) {
expect(screen.getByText(m)).toBeInTheDocument()
}
expect(screen.getByText(ABOUT_INFO.organization)).toBeInTheDocument()
expect(screen.getByText(ABOUT_INFO.repoUrl)).toBeInTheDocument()
expect(screen.getByText(ABOUT_INFO.license)).toBeInTheDocument()
expect(screen.getByText(/© 2026 MetonaTeam/)).toBeInTheDocument()
})
it('版本号未取得时显示占位(版本 —)', () => {
setup({ version: '' })
expect(screen.getByText('版本 —')).toBeInTheDocument()
})
})
describe('AboutModal - 复制交互', () => {
it('点击邮箱触发 onCopy(携带邮箱与标签)', () => {
const { onCopy } = setup()
fireEvent.click(screen.getByText('stinanimz@gmail.com'))
expect(onCopy).toHaveBeenCalledWith('stinanimz@gmail.com', '邮箱')
fireEvent.click(screen.getByText('1440196015@qq.com'))
expect(onCopy).toHaveBeenCalledWith('1440196015@qq.com', '邮箱')
})
it('点击开源地址/作者/组织触发 onCopy', () => {
const { onCopy } = setup()
fireEvent.click(screen.getByText(ABOUT_INFO.repoUrl))
expect(onCopy).toHaveBeenCalledWith(ABOUT_INFO.repoUrl, '开源地址')
fireEvent.click(screen.getByText(ABOUT_INFO.author))
expect(onCopy).toHaveBeenCalledWith(ABOUT_INFO.author, '作者')
fireEvent.click(screen.getByText(ABOUT_INFO.organization))
expect(onCopy).toHaveBeenCalledWith(ABOUT_INFO.organization, '组织')
})
it('许可证为纯展示不可点击(不触发 onCopy)', () => {
const { onCopy } = setup()
fireEvent.click(screen.getByText(ABOUT_INFO.license))
expect(onCopy).not.toHaveBeenCalled()
})
})
describe('AboutModal - 关闭交互', () => {
it('Esc 触发 onClose', () => {
const { onClose } = setup()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('点击遮罩触发 onClose;点击弹框内容区不关闭', () => {
const { onClose } = setup()
fireEvent.mouseDown(screen.getByText(ABOUT_INFO.name))
expect(onClose).not.toHaveBeenCalled()
fireEvent.mouseDown(document.querySelector('.modal-backdrop')!)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('点击右上角关闭按钮触发 onClose', () => {
const { onClose } = setup()
fireEvent.click(screen.getByRole('button', { name: '✕' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('卸载后 Esc 监听移除(不再触发 onClose', () => {
const onClose = vi.fn()
const onCopy = vi.fn()
const { unmount } = render(
<AboutModal version="0.10.2" onClose={onClose} onCopy={onCopy} />
)
unmount()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).not.toHaveBeenCalled()
})
})
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, type ReactElement } from 'react'
import LogoIcon from './LogoIcon'
interface AboutModalProps {
/** 应用版本号(App 经 app:get-version IPC 获取;未取得时传空串显示占位) */
version: string
onClose: () => void
/** 复制到剪贴板(App 实现 setClipboard + toast 反馈) */
onCopy: (text: string, label: string) => void
}
/** 关于弹框的展示信息(应用自有元信息,随版本演进同步维护) */
export const ABOUT_INFO = {
name: 'DiffLens',
/** 应用介绍(与 README 首段口径一致) */
description:
'精美酷炫的开源文本对比桌面应用——行级 + 词级 + 字符级差异定位、文件夹递归对比、快照基线对照,帮助你快速看穿两份文本之间的所有差异。',
author: 'thzxx',
emails: ['stinanimz@gmail.com', '1440196015@qq.com'],
organization: 'MetonaTeam',
repoUrl: 'https://git.metona.cn/MetonaTeam/DiffLens',
license: 'MIT License'
} as const
/**
* 「关于」弹框:应用介绍、版本、作者、邮箱、组织、开源地址与许可证。
* 关闭方式与粘贴弹窗同语言:Esc / 点击遮罩 / 右上角关闭按钮;
* 邮箱与开源地址点击复制到剪贴板(复用主进程剪贴板管线,不引入外部跳转)。
*/
export default function AboutModal({
version,
onClose,
onCopy
}: AboutModalProps): ReactElement {
// Esc 关闭
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div
className="modal-backdrop"
onMouseDown={onClose}
onContextMenu={(e) => {
e.preventDefault()
onClose()
}}
>
<div className="about-modal" onMouseDown={(e) => e.stopPropagation()}>
<button className="about-close" title="关闭" onClick={onClose}>
</button>
<div className="about-logo">
<LogoIcon size={40} />
</div>
<div className="about-name">{ABOUT_INFO.name}</div>
<div className="about-version"> {version !== '' ? version : '—'}</div>
<p className="about-desc">{ABOUT_INFO.description}</p>
<div className="about-rows">
<div className="about-row">
<span className="about-k"></span>
<button
className="about-v"
title="点击复制"
onClick={() => onCopy(ABOUT_INFO.author, '作者')}
>
{ABOUT_INFO.author}
</button>
</div>
<div className="about-row">
<span className="about-k"></span>
<span className="about-vs">
{ABOUT_INFO.emails.map((m) => (
<button
key={m}
className="about-v"
title="点击复制"
onClick={() => onCopy(m, '邮箱')}
>
{m}
</button>
))}
</span>
</div>
<div className="about-row">
<span className="about-k"></span>
<button
className="about-v"
title="点击复制"
onClick={() => onCopy(ABOUT_INFO.organization, '组织')}
>
{ABOUT_INFO.organization}
</button>
</div>
<div className="about-row">
<span className="about-k"></span>
<button
className="about-v"
title="点击复制"
onClick={() => onCopy(ABOUT_INFO.repoUrl, '开源地址')}
>
{ABOUT_INFO.repoUrl}
</button>
</div>
<div className="about-row">
<span className="about-k"></span>
<span className="about-v plain">{ABOUT_INFO.license}</span>
</div>
</div>
<div className="about-foot">© 2026 MetonaTeam · thzxx · DiffLens</div>
</div>
</div>
)
}
@@ -566,6 +566,27 @@ describe('FolderView - 其他', () => {
expect(onLoadSnapshot).toHaveBeenCalledTimes(1)
})
it('快照保存中禁用保存/加载快照按钮,其余操作不受影响(0.10.2 保存遮罩不拦截交互的按钮级防抖)', () => {
const onSaveSnapshot = vi.fn()
const onLoadSnapshot = vi.fn()
render(
<FolderView
{...fullProps(ENTRIES, { saving: true, onSaveSnapshot, onLoadSnapshot })}
/>
)
expect(screen.getByRole('button', { name: '保存快照' })).toBeDisabled()
expect(screen.getByRole('button', { name: '加载快照' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '保存快照' }))
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
expect(onSaveSnapshot).not.toHaveBeenCalled()
expect(onLoadSnapshot).not.toHaveBeenCalled()
// 保存中仅是快照链路防抖:重新扫描/重新选择/导出/返回不受影响
expect(screen.getByRole('button', { name: '重新扫描' })).toBeEnabled()
expect(screen.getByRole('button', { name: '重新选择' })).toBeEnabled()
expect(screen.getByRole('button', { name: '导出报告' })).toBeEnabled()
expect(screen.getByRole('button', { name: '返回文件对比' })).toBeEnabled()
})
it('导出报告菜单选择格式触发 onExport 并关闭菜单', () => {
const onExport = vi.fn()
render(<FolderView {...fullProps(ENTRIES, { onExport })} />)
+19 -4
View File
@@ -34,6 +34,8 @@ export interface FolderViewProps {
truncated: boolean
total: number
scanning: boolean
/** 快照保存中(批量读取差异条目内容期间):保存/加载快照入口禁用(保存遮罩不拦截交互,按钮级防抖) */
saving?: boolean
/** 比对进度(主进程推送;null = 尚无进度态,仅扫描中遮罩展示) */
progress?: { done: number; total: number } | null
/** 仅看差异(隐藏两侧一致的文件);与状态过滤互斥(后者开启时本开关自动关闭) */
@@ -112,6 +114,7 @@ export default function FolderView({
truncated,
total,
scanning,
saving = false,
progress,
onlyDiff,
onOnlyDiffChange,
@@ -342,16 +345,28 @@ export default function FolderView({
</button>
<button
className="btn ghost"
disabled={scanning}
title={scanning ? '正在扫描文件夹' : '保存当前对比结果为快照基线(JSON 文件),供日后加载对照'}
disabled={scanning || saving}
title={
scanning
? '正在扫描文件夹'
: saving
? '正在保存快照'
: '保存当前对比结果为快照基线(JSON 文件),供日后加载对照'
}
onClick={onSaveSnapshot}
>
</button>
<button
className="btn ghost"
disabled={scanning}
title={scanning ? '正在扫描文件夹' : '加载之前保存的快照,与当前结果对照(已修复 / 新增差异 / 新文件 / 已移除)'}
disabled={scanning || saving}
title={
scanning
? '正在扫描文件夹'
: saving
? '正在保存快照'
: '加载之前保存的快照,与当前结果对照(已修复 / 新增差异 / 新文件 / 已移除)'
}
onClick={onLoadSnapshot}
>
+29
View File
@@ -0,0 +1,29 @@
import type { ReactElement } from 'react'
/**
* 应用 Logo(放大镜 + 差异短横线的暗色科幻风格图标)。
* 尺寸由 size 控制(App 头部 18 / 关于弹框 40),描边随尺寸等比缩放。
*/
export default function LogoIcon({ size = 18 }: { size?: number }): ReactElement {
const s = size / 18
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="6.2" stroke="#fff" strokeWidth={2.1 * Math.max(s, 0.7)} />
<line
x1="15.5"
y1="15.5"
x2="20.5"
y2="20.5"
stroke="#fff"
strokeWidth={2.4 * Math.max(s, 0.7)}
strokeLinecap="round"
/>
<path
d="M5 11h3.4M5 14h2"
stroke="#030712"
strokeWidth={1.6 * Math.max(s, 0.7)}
strokeLinecap="round"
/>
</svg>
)
}
+2 -2
View File
@@ -16,7 +16,7 @@ export interface TreeNode {
entry?: FolderEntry
/** 子节点(目录节点独有;同层按名称字典序) */
children: TreeNode[]
/** 目录节点:子树叶子的状态聚合(优先级 different > only > semantic-same > same */
/** 目录节点:子树叶子的状态聚合(优先级 different > unreadable > only > semantic-same > same */
agg?: FolderEntryStatus
/** 目录节点:子树文件总数 */
fileCount?: number
@@ -29,7 +29,7 @@ export interface TreeRow {
depth: number
}
/** 状态聚合优先级:数值大者优先(目录徽章展示最值得关注的子树状态unreadable 需用户关注 */
/** 状态聚合优先级:数值大者优先(目录徽章展示最值得关注的子树状态different > unreadable > only > 语义同 > 相同 */
const AGG_RANK: Record<FolderEntryStatus, number> = {
different: 5,
unreadable: 4,
+2 -1
View File
@@ -180,8 +180,9 @@ export const SNAPSHOT_CHANGE_LABEL: Record<SnapshotChange, string> = {
* 单一路径归一化:反斜杠→斜杠、去尾分隔符、Windows 盘符小写(其余保留原大小写)。
* 与主进程 scanCacheStore.ts 的 normDir 保持完全一致(两进程无法共享模块,改动需双向同步):
* Windows 大小写不敏感文件系统下 C:\Foo 与 c:/Foo 为同一目录,逐字符比较会误判目录对不一致。
* 0.10.2 起导出供 App 的同目录防呆复用(startFolderCompare 的两侧目录判重)。
*/
function normDir(p: string): string {
export 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)
+120 -8
View File
@@ -263,14 +263,6 @@ button:disabled {
min-height: 0;
}
.diff-empty-pane {
flex: 1;
display: grid;
place-items: center;
color: var(--muted);
border-left: 1px solid var(--border);
}
.pane-mid {
display: flex;
flex-direction: column;
@@ -881,6 +873,126 @@ button:disabled {
border-top: 1px solid var(--border);
}
/* ============ 关于弹框 ============ */
/* header 右侧的关于图标按钮(紧凑正方形) */
.about-btn {
padding: 5px 7px;
}
.about-modal {
position: relative;
width: min(480px, 90vw);
background: rgba(16, 22, 34, 0.98);
border: 1px solid var(--border-strong);
border-radius: 14px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 30px rgba(124, 108, 255, 0.2);
overflow: hidden;
z-index: 1001;
padding: 26px 28px 18px;
text-align: center;
}
.about-close {
position: absolute;
top: 10px;
right: 12px;
width: 26px;
height: 26px;
border-radius: 7px;
display: grid;
place-items: center;
font-size: 13px;
color: var(--muted);
transition: all 0.15s ease;
}
.about-close:hover {
color: var(--text);
background: var(--surface-hover);
}
.about-logo {
width: 62px;
height: 62px;
margin: 0 auto 10px;
border-radius: 18px;
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: 0 0 24px rgba(124, 108, 255, 0.55);
}
.about-name {
font-size: 20px;
font-weight: 700;
letter-spacing: 1px;
background: linear-gradient(90deg, #c9bfff, var(--accent-2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.about-version {
margin-top: 3px;
font-size: 12px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.about-desc {
margin: 14px 4px 16px;
font-size: 12.5px;
line-height: 1.7;
color: var(--text);
text-align: left;
}
.about-rows {
display: flex;
flex-direction: column;
gap: 7px;
padding: 12px 14px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--border);
text-align: left;
}
.about-row {
display: flex;
align-items: center;
gap: 12px;
min-height: 24px;
}
.about-k {
flex: 0 0 64px;
font-size: 11px;
color: var(--muted);
letter-spacing: 1px;
}
.about-vs {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.about-v {
padding: 2px 8px;
margin-left: -8px;
border-radius: 6px;
font-family: var(--mono);
font-size: 12px;
color: var(--accent-2);
text-align: left;
word-break: break-all;
transition: background 0.13s ease;
}
button.about-v:hover {
background: var(--surface-hover);
}
.about-v.plain {
color: var(--text);
cursor: default;
}
.about-foot {
margin-top: 14px;
font-size: 11px;
color: var(--muted);
letter-spacing: 1px;
}
/* ============ 状态栏 ============ */
.status-bar {
flex: 0 0 auto;