From 719681a366a4c9c96dba7ead4e941591eb146b86 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Wed, 19 Aug 2026 15:20:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=200.10.2=20=E5=A4=A7=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E4=BF=AE=E5=A4=8D=E3=80=81=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E9=98=B2=E6=8A=96=E4=B8=8E=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=E5=BC=B9=E6=A1=86=20-=20=E6=96=B0=E5=A2=9Esnapshot:read?= =?UTF-8?q?=E4=B8=93=E7=94=A8=E8=AF=BB=E5=8F=96IPC=EF=BC=88128MB=E4=B8=8A?= =?UTF-8?q?=E9=99=90=EF=BC=8C=E4=BF=AE=E5=A4=8D=E4=BF=9D=E5=AD=9832MB?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E4=B8=8A=E9=99=90=E4=B8=8E=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?10MB=E9=80=9A=E7=94=A8=E4=B8=8A=E9=99=90=E4=B8=8D=E5=AF=B9?= =?UTF-8?q?=E7=A7=B0=E5=AF=BC=E8=87=B4=E5=A4=A7=E5=BF=AB=E7=85=A7=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E6=88=90=E5=8A=9F=E5=8D=B4=E6=B0=B8=E8=BF=9C=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E5=8A=A0=E8=BD=BD=E7=9A=84=E8=87=AA=E7=9B=B8=E7=9F=9B?= =?UTF-8?q?=E7=9B=BE=EF=BC=8C=E9=A1=BA=E5=B8=A6=E6=B6=88=E9=99=A4=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=B1=A1=E6=9F=93lastDir=E8=AE=B0=E5=BF=86=EF=BC=89?= =?UTF-8?q?=E3=80=81=E5=BF=AB=E7=85=A7=E4=BF=9D=E5=AD=98=E4=B8=AD=E4=BF=9D?= =?UTF-8?q?=E5=AD=98/=E5=8A=A0=E8=BD=BD=E6=8C=89=E9=92=AEsaving=E7=A6=81?= =?UTF-8?q?=E7=94=A8=E4=B8=8EApp=E5=AE=88=E5=8D=AB=E5=8F=8C=E4=BF=9D?= =?UTF-8?q?=E9=99=A9=EF=BC=88computing-overlay=E4=B8=8D=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E5=8F=AF=E5=B9=B6=E5=8F=91=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E4=B8=A4=E8=BD=AE=E4=BF=9D=E5=AD=98=E5=AF=B9=E8=AF=9D=E6=A1=86?= =?UTF-8?q?=EF=BC=89=E3=80=81=E5=90=8C=E7=9B=AE=E5=BD=95=E9=98=B2=E5=91=86?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=BD=92=E4=B8=80=E5=8C=96=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=EF=BC=88=E5=A4=8D=E7=94=A8normDir=EF=BC=8CWindows=E5=BC=82?= =?UTF-8?q?=E5=86=99=E8=B7=AF=E5=BE=84=E4=B8=8D=E5=86=8D=E7=BB=95=E8=BF=87?= =?UTF-8?q?=EF=BC=89=E3=80=81=E5=85=B3=E4=BA=8E=E5=BC=B9=E6=A1=86=EF=BC=88?= =?UTF-8?q?header=E5=B8=B8=E9=A9=BB=E2=93=98=E6=8C=89=E9=92=AE=EF=BC=8Capp?= =?UTF-8?q?:get-version=E5=8F=96=E7=89=88=E6=9C=AC=E5=8F=B7=EF=BC=8C?= =?UTF-8?q?=E4=BD=9C=E8=80=85/=E5=8F=8C=E9=82=AE=E7=AE=B1/=E7=BB=84?= =?UTF-8?q?=E7=BB=87/=E5=BC=80=E6=BA=90=E5=9C=B0=E5=9D=80/=E8=AE=B8?= =?UTF-8?q?=E5=8F=AF=E8=AF=81=EF=BC=8C=E7=82=B9=E5=87=BB=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E8=B5=B0=E4=B8=BB=E8=BF=9B=E7=A8=8B=E5=89=AA=E8=B4=B4=E6=9D=BF?= =?UTF-8?q?=EF=BC=89=E3=80=81=E5=88=A0=E9=99=A4=E5=AD=A4=E5=84=BFCSS?= =?UTF-8?q?=E7=B1=BB.diff-empty-pane=E4=B8=8EfolderTree=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E8=A1=A5unreadable=EF=BC=8C=E6=96=B0=E5=A2=9E14=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=E4=B8=8E2=E6=9D=A1E2E=EF=BC=88626=E5=8D=95=E6=B5=8B+E?= =?UTF-8?q?2E=2029=E5=85=A8=E8=BF=87=E8=A6=86=E7=9B=96=E7=8E=8798.15/93.49?= =?UTF-8?q?/93.65/98.15=EF=BC=89=EF=BC=8C=E6=96=87=E6=A1=A3=E5=9B=9B?= =?UTF-8?q?=E4=BB=B6=E5=A5=97=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- docs/应用开发与版本迭代规范.md | 9 +- docs/测试策略与方案.md | 17 ++- e2e/app.spec.ts | 68 ++++++++++ package.json | 2 +- src/main/index.ts | 41 ++++++ src/preload/index.ts | 10 +- src/renderer/src/App.tsx | 74 +++++++--- src/renderer/src/__tests__/App.test.tsx | 96 ++++++++++--- src/renderer/src/__tests__/main.test.ts | 4 +- .../src/components/AboutModal.test.tsx | 93 +++++++++++++ src/renderer/src/components/AboutModal.tsx | 118 ++++++++++++++++ .../src/components/FolderView.test.tsx | 21 +++ src/renderer/src/components/FolderView.tsx | 23 +++- src/renderer/src/components/LogoIcon.tsx | 29 ++++ src/renderer/src/components/folderTree.ts | 4 +- src/renderer/src/diff/snapshotDiff.ts | 3 +- src/renderer/src/styles/global.css | 128 ++++++++++++++++-- 18 files changed, 686 insertions(+), 59 deletions(-) create mode 100644 src/renderer/src/components/AboutModal.test.tsx create mode 100644 src/renderer/src/components/AboutModal.tsx create mode 100644 src/renderer/src/components/LogoIcon.tsx diff --git a/README.md b/README.md index a86912e..f328c52 100644 --- a/README.md +++ b/README.md @@ -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 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 259fc23..7fc3211 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -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 专用 IPC(JSON 过滤对话框 + 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-pane(0.9.2 清理孤儿变量的漏网);folderTree 聚合优先级注释补 unreadable + - 测试与文档:新增 14 单测(AboutModal 9 / App 4:关于打开关闭·复制·文件夹模式可见·异写防呆 / FolderView 1:saving 禁用)与 2 条 E2E(关于弹框真实链路含版本号与复制 / 11MB 大快照加载成功固化上限修复),共 626 单测 + 29 E2E ... 0.y.z ← 长期停留,永不进入 1.x ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index cd972fb..821063d 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -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.0,0.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.0,0.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.0,0.9.1 增强,0.10.0 内容级对照):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章) - 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s;300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归) +- 关于弹框(0.10.2):点击 header ⓘ 打开弹框——版本号(app.getVersion 真实链路,0.x 形态正则)/作者/双邮箱/组织/开源地址/许可证断言;点击邮箱走主进程剪贴板并 toast;Esc 关闭 +- 大快照加载(0.10.2):手工构造 11MB+ 快照 JSON(contents 塞大字符串)→ snapshot:read 专用通道加载成功("已加载快照"出现且无 10MB 拦截提示)——固化保存/加载上限不对称修复 - 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData) - 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理) diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 3175d72..a1b6e04 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -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 退化。 diff --git a/package.json b/package.json index b621ff2..c18f5c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.10.1", + "version": "0.10.2", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index 18d87c8..c0162d8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index a6cc2a0..2ef0a5e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -132,7 +132,15 @@ const api = { ipcRenderer.invoke('file:read-by-path', filePath), /** 批量读取文本文件(快照保存收集差异条目内容用;单文件失败为错误对象不整体失败) */ readFilesByPaths: (paths: string[]): Promise => - ipcRenderer.invoke('folder:read-batch', paths) + ipcRenderer.invoke('folder:read-batch', paths), + /** + * 选择并读取快照文件(专用通道):读取上限 128MB(通用文件 10MB 上限会拦截 + * 自家保存的大快照),UTF-8 直读不做编码探测,不写 lastDir 记忆。 + * 返回结构与 openFile 一致(成功数据 / 失败原因 / 用户取消 null)。 + */ + readSnapshot: (): Promise => ipcRenderer.invoke('snapshot:read'), + /** 应用版本号(主进程 app.getVersion(),package.json 的 version 为唯一来源) */ + getVersion: (): Promise => ipcRenderer.invoke('app:get-version') } export type DiffLensApi = typeof api diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f07a2ae..488ce65 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 ( - - - - - - ) -} - function EmptyPane({ side, onOpen, @@ -251,11 +244,28 @@ export default function App(): ReactElement { const [toasts, setToasts] = useState([]) 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(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 => { - let data: Awaited> + let data: Awaited> 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 { )} + {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 && setMenu(null)} />} + {aboutOpen && ( + setAboutOpen(false)} + onCopy={(text, label) => { + void window.api.setClipboard(text) + showToast(`${label}已复制到剪贴板`) + }} + /> + )} {pasteSide && ( ): 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() 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() + // 两种模式常驻入口 + fireEvent.click(screen.getByTitle('关于 DiffLens')) + // 版本号来自 app:get-version(mock 固定 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() + 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() + 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() + fireEvent.click(screen.getByText('对比文件夹')) + expect(await screen.findByText(/两次选择的是同一个文件夹/)).toBeInTheDocument() + expect(scanFolder).not.toHaveBeenCalled() + expect(screen.queryByText('返回文件对比')).not.toBeInTheDocument() + }) }) \ No newline at end of file diff --git a/src/renderer/src/__tests__/main.test.ts b/src/renderer/src/__tests__/main.test.ts index 0b2932f..07d42cb 100644 --- a/src/renderer/src/__tests__/main.test.ts +++ b/src/renderer/src/__tests__/main.test.ts @@ -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 }) diff --git a/src/renderer/src/components/AboutModal.test.tsx b/src/renderer/src/components/AboutModal.test.tsx new file mode 100644 index 0000000..bf21a25 --- /dev/null +++ b/src/renderer/src/components/AboutModal.test.tsx @@ -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; onCopy: ReturnType } { + const onClose = vi.fn() + const onCopy = vi.fn() + render( + + ) + 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( + + ) + unmount() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/AboutModal.tsx b/src/renderer/src/components/AboutModal.tsx new file mode 100644 index 0000000..8fab6aa --- /dev/null +++ b/src/renderer/src/components/AboutModal.tsx @@ -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 ( +
{ + e.preventDefault() + onClose() + }} + > +
e.stopPropagation()}> + +
+ +
+
{ABOUT_INFO.name}
+
版本 {version !== '' ? version : '—'}
+

{ABOUT_INFO.description}

+
+
+ 作者 + +
+
+ 邮箱 + + {ABOUT_INFO.emails.map((m) => ( + + ))} + +
+
+ 组织 + +
+
+ 开源地址 + +
+
+ 许可证 + {ABOUT_INFO.license} +
+
+
© 2026 MetonaTeam · thzxx · DiffLens
+
+
+ ) +} diff --git a/src/renderer/src/components/FolderView.test.tsx b/src/renderer/src/components/FolderView.test.tsx index fc9e7d6..4fb2ab2 100644 --- a/src/renderer/src/components/FolderView.test.tsx +++ b/src/renderer/src/components/FolderView.test.tsx @@ -566,6 +566,27 @@ describe('FolderView - 其他', () => { expect(onLoadSnapshot).toHaveBeenCalledTimes(1) }) + it('快照保存中禁用保存/加载快照按钮,其余操作不受影响(0.10.2 保存遮罩不拦截交互的按钮级防抖)', () => { + const onSaveSnapshot = vi.fn() + const onLoadSnapshot = vi.fn() + render( + + ) + 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() diff --git a/src/renderer/src/components/FolderView.tsx b/src/renderer/src/components/FolderView.tsx index dc1cdb8..7744aeb 100644 --- a/src/renderer/src/components/FolderView.tsx +++ b/src/renderer/src/components/FolderView.tsx @@ -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({