fix: 0.9.2 快照对照对称缺陷修复与交互打磨 - 加载快照拦截当前结果截断(0.9.1只拦保存侧,当前条目不完整会把截断缺失文件误报已移除)、对照模式中重扫截断自动退出对照、快照语义判等口径与当前开关不一致时提示含口径差异(口径变化不再被当成内容变化)、扫描中禁用语义开关与忽略规则入口(触发重扫入口防抖,纯视图过滤不禁用)、右键菜单↑/↓键盘导航跳过禁用项首尾循环Enter触发悬停同步高亮、清理CSS孤儿变量与依赖patch级升级,新增12测试用例(543单测+E2E 23全过),文档四件套同步

This commit is contained in:
2026-08-19 10:06:27 +08:00
parent 38512d7882
commit 42d2095de2
12 changed files with 302 additions and 52 deletions
+3 -2
View File
@@ -12,7 +12,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**忽略规则**(gitignore 简化子集:`node_modules/` 目录整树剪枝、`*.log` 扩展名通配、`temp` 名字匹配,被忽略项不枚举不计数,规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫)
- **快照基线**:把当前文件夹对比结果保存为快照(JSON 文件),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照(防对照误报)
- **快照基线**:把当前文件夹对比结果保存为快照(JSON 文件),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览)
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
@@ -35,6 +35,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
- **导出一键报告**:单文件与文件夹对比均可导出 HTML / 纯文本 / Markdown 三种格式。单文件报告始终包含全量行,HTML 为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(点击展开零丢失),并支持打印与窄屏;文件夹报告含判定口径说明与全量条目清单
- **拖拽与按钮双入口**:支持文件拖拽导入(E2E 覆盖真实拖放链路),也可通过界面按钮选择文件或粘贴文本
- **右键菜单键盘导航**:行级右键菜单支持 ↑/↓ 移动高亮(跳过禁用项、首尾循环)、Enter 触发、Esc 关闭,鼠标悬停与键盘高亮同步
- **滚动联动**:左右两个面板滚动位置自动同步
- **性能基准 E2E**5 万行差异计算与 300KB GBK 解码带耗时上限断言(真实 Electron 链路),性能回归在发布前即可拦截
@@ -113,7 +114,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
- 作者:thzxx
- 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.9.1
- 版本:0.9.2
---
+6 -1
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.9.1`**
当前基线版本:**`0.9.2`**
---
@@ -110,6 +110,11 @@ z = 补丁版本号(Patch
- 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.9.2 ← 快照对照对称缺陷修复 · 扫描中交互防抖 · 右键菜单键盘导航(已发布):
- 快照对照对称缺陷修复(快照基线稳健性收尾):加载快照拦截当前扫描结果截断(0.9.1 只拦了保存侧——当前条目不完整时对照会把截断缺失的文件误报为"已移除",与保存侧"快照缺失误报新文件"对称的同类误报;拦截发生在文件选择之前不弹对话框) · 对照模式中重扫触发截断自动退出对照并提示(不残留过期对照) · 快照语义判等口径与当前开关不一致时加载提示"对照结果含口径差异"(口径变化不再被静默当成内容变化——原语义同文件换口径重判后显示"新增差异"类误导;提示不拦截对照,基线行仍标注快照口径)
- 扫描中交互防抖:扫描进行中禁用语义判等开关与忽略规则入口(二者触发重扫——竞态守卫本已保证结果正确,防抖消除无谓的叠加扫描;仅看差异/搜索/扩展名过滤为纯视图过滤不禁用,与"重新扫描/保存快照/加载快照/导出报告"的既有 disabled 风格对齐)
- 右键菜单键盘导航:↑/↓ 在菜单项间移动高亮(跳过禁用项、首尾循环),Enter 触发高亮项,Esc 关闭不变;鼠标悬停与键盘高亮同步;全部禁用时方向键无操作不崩溃
- 微打磨与依赖:清理 CSS 孤儿变量(--pink/--panel-2 定义未消费) · 依赖 patch 级升级(npm update 语义化范围内,核心依赖 electron/vitest/vite 版本不变,仅间接依赖小幅更新)
...
0.y.z ← 长期停留,永不进入 1.x
```
+3 -2
View File
@@ -80,7 +80,7 @@ src/renderer/src/
│ ├── useDiff.ts
│ └── useDismiss.ts
├── components/
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出)
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出;↑/↓ 键盘导航跳过禁用项首尾循环、Enter 触发高亮项、悬停同步高亮
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
│ ├── ErrorBoundary.tsx # 顶层渲染错误边界(错误面板 + 一键重载,防白屏死机)
│ ├── ErrorBoundary.test.tsx # 渲染错误兜底测试(抛错子组件 fallback/正常子组件透传)
@@ -145,7 +145,7 @@ src/renderer/src/
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式
- 快照基线(0.9.0,0.9.1 收尾):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新
- 快照基线(0.9.00.9.1 收尾0.9.2 对称防呆):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 当前扫描结果截断时加载快照同样被拦截(0.9.2,不弹文件选择——截断缺失文件会被误报已移除) · 对照模式中重扫触发截断自动退出对照并提示(0.9.2) · 快照语义判等口径与当前开关不一致时加载提示含口径差异、一致时无提示(0.9.2) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`
- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致
@@ -185,6 +185,7 @@ src/renderer/src/
- 忽略规则(0.9.0):已有规则时入口标注数量并高亮;下拉打开以当前规则初始化草稿,逐行解析去空白去空行(截断至 50 条)后应用回调并关闭;清空规则回调空数组;点击外部关闭不触发回调
- 快照对照模式(0.9.0):变迁统计徽章(已修复/新增差异/新文件/已移除/仍有差异/保持一致)与基线时间替换常规统计行;对照条目平铺全路径渲染、行尾变迁徽章、gone 回退快照数据;零计数变迁不占位;默认隐藏 stable、点击徽章单选过滤再点取消;双击当前条目进单文件对比、已移除条目双击无效;退出对照恢复常规视图;全 stable 空态提示
- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动)
- 扫描中交互防抖(0.9.2):扫描进行中语义判等开关与忽略规则入口禁用(触发重扫的入口,点击不触发回调/不打开下拉);仅看差异开关与搜索仍可用(纯视图过滤不触发重扫)
- 目录行双击(0.8.0):触发 onOpenDir 携带目录相对路径;双击前折叠态自动展开(toggle 残留消除);重新扫描按钮触发 onRescan 且扫描中禁用
- 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果)
- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满
+31 -31
View File
@@ -1,12 +1,12 @@
{
"name": "DiffLens",
"version": "0.7.0",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "DiffLens",
"version": "0.7.0",
"version": "0.9.1",
"license": "MIT",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
@@ -3046,9 +3046,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.14",
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
"version": "2.11.15",
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
"integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3488,7 +3488,7 @@
}
},
"node_modules/chromium-pickle-js": {
"version": "0.2.2",
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz",
"integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==",
"dev": true,
@@ -4246,7 +4246,7 @@
}
},
"node_modules/eastasianwidth": {
"version": "0.2.2",
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
@@ -4460,9 +4460,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.408",
"resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.408.tgz",
"integrity": "sha512-SLoprcYpJ/OH2v2ps0+N5biv9H4/KBT3+YmmDew64TwK5y9j2wv7pMOFY7IorVkyMtEyLSCRlXKLsNlakeAlPw==",
"version": "1.5.410",
"resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.410.tgz",
"integrity": "sha512-Vq9DD7F4PKCKVmOoG6i1CQSYoF7IUtwTPEQjMXuqNs2S22H8HsojO9myaB81QuvvIKZaF6imSn3XAV1Su6rvXA==",
"dev": true,
"license": "ISC"
},
@@ -4897,9 +4897,9 @@
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -5498,7 +5498,7 @@
"license": "MIT"
},
"node_modules/is-unicode-supported": {
"version": "0.2.2",
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
"dev": true,
@@ -6386,7 +6386,7 @@
"optional": true
},
"node_modules/node-api-version": {
"version": "0.2.2",
"version": "0.2.1",
"resolved": "https://registry.npmmirror.com/node-api-version/-/node-api-version-0.2.1.tgz",
"integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==",
"dev": true,
@@ -6764,21 +6764,6 @@
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.1.tgz",
@@ -8175,6 +8160,21 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/vitest/-/vitest-2.1.9.tgz",
@@ -8469,7 +8469,7 @@
}
},
"node_modules/yocto-queue": {
"version": "0.2.2",
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.9.1",
"version": "0.9.2",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
+23 -5
View File
@@ -544,12 +544,17 @@ export default function App(): ReactElement {
})()
}, [folderState, folderSemantic, showToast])
/** 加载快照文件并与当前结果对照:目录对不一致 / 文件无效 / 无当前结果时拦截提示 */
/** 加载快照文件并与当前结果对照:无当前结果 / 当前结果截断 / 目录对不一致 / 文件无效时拦截提示 */
const loadSnapshot = useCallback((): void => {
if (!folderState) {
showToast('请先进行文件夹对比,再加载快照')
return
}
// 当前结果截断:条目不完整,快照有而当前"缺失"的条目会被误报为已移除(与保存侧拦截对称)
if (folderState.truncated) {
showToast('当前扫描结果已截断(文件过多),条目不完整无法对照,请先缩小对比范围')
return
}
void (async (): Promise<void> => {
let data: Awaited<ReturnType<typeof window.api.openFile>>
try {
@@ -575,9 +580,15 @@ export default function App(): ReactElement {
}
setSnapshot(snap)
setSnapshotDiff(diffSnapshot(snap.entries, folderState.entries))
showToast('已加载快照,列表按状态变迁标注')
// 快照保存时的语义判等口径与当前开关不一致:原语义同文件会被重新判定,
// 口径变化混入状态变迁(如误显示"新增差异"),提示用户甄别
showToast(
snap.semantic !== folderSemantic
? '已加载快照,列表按状态变迁标注(注意:快照的语义判等口径与当前开关不一致,对照结果含口径差异)'
: '已加载快照,列表按状态变迁标注'
)
})()
}, [folderState, showToast])
}, [folderState, folderSemantic, showToast])
/** 退出快照对照(清空基线与对照结果) */
const exitSnapshot = useCallback((): void => {
@@ -585,7 +596,8 @@ export default function App(): ReactElement {
setSnapshotDiff(null)
}, [])
// 快照加载后扫描结果变化(重新扫描/语义切换):目录对仍一致则刷新对照,否则清空对照
// 快照加载后扫描结果变化(重新扫描/语义切换):目录对仍一致则刷新对照,否则清空对照
// 重扫后当前结果截断时同样清空对照(条目不完整会把截断缺失的文件误报为已移除)
useEffect(() => {
if (snapshot === null || folderState === null) return
if (!snapshotDirsMatch(snapshot, folderState.leftDir, folderState.rightDir)) {
@@ -593,8 +605,14 @@ export default function App(): ReactElement {
setSnapshotDiff(null)
return
}
if (folderState.truncated) {
setSnapshot(null)
setSnapshotDiff(null)
showToast('当前扫描结果已截断,快照对照已退出(条目不完整会误报已移除)')
return
}
setSnapshotDiff(diffSnapshot(snapshot.entries, folderState.entries))
}, [folderState, snapshot])
}, [folderState, snapshot, showToast])
/** 双击文件夹条目:按路径读取存在侧进入单文件对比(保留文件夹状态供返回)。
* navScope 为本次进入后的导航范围:手动双击文件条目传 null(回全局序列),
+71
View File
@@ -2043,4 +2043,75 @@ describe('App - 快照基线', () => {
await waitFor(() => expect(screen.getByText('已修复 1')).toBeInTheDocument())
expect(screen.queryByText(/仍有差异 \d/)).not.toBeInTheDocument()
})
it('当前扫描结果截断时加载快照被拦截提示,不弹文件选择', async () => {
const openFile = 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
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText(/文件过多/)
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
expect(await screen.findByText(/当前扫描结果已截断/)).toBeInTheDocument()
// 拦截发生在文件选择之前(对称防呆:截断的当前结果会把缺失文件误报为已移除)
expect(openFile).not.toHaveBeenCalled()
expect(screen.queryByText(/已加载快照/)).not.toBeInTheDocument()
})
it('快照语义判等口径与当前开关不一致:加载提示含口径差异', async () => {
// 当前语义判等默认开启;快照保存时为关闭口径 → 提示对照结果含口径差异
await enterForSnapshot(makeSnapJson({ semantic: false }))
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
expect(await screen.findByText(/口径差异/)).toBeInTheDocument()
// 口径不一致不拦截对照(基线信息行仍展示快照口径)
expect(screen.getByRole('button', { name: '退出对照' })).toBeInTheDocument()
})
it('快照语义判等口径与当前一致:无口径差异提示', async () => {
// 快照 semantic: true 与当前默认开关一致,提示为常规文案
await enterForSnapshot(makeSnapJson())
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
expect(await screen.findByText(/已加载快照/)).toBeInTheDocument()
expect(screen.queryByText(/口径差异/)).not.toBeInTheDocument()
})
it('对照模式中重扫结果截断:自动退出对照并提示', async () => {
let scanCall = 0
const dirs = ['C:/left-dir', 'D:/right-dir']
let pick = 0
window.api = mockApi({
pickFolder: async () => dirs[pick++] ?? null,
scanFolder: async () => {
scanCall++
// 第二次(重扫)结果截断:条目不完整,对照会把截断缺失的文件误报为已移除
return scanCall === 1
? { entries: curEntries, truncated: false, total: 3 }
: { entries: curEntries.slice(0, 2), truncated: true, total: 9999 }
},
openFile: async () => ({
path: '/tmp/snap.json',
name: 'snap.json',
text: makeSnapJson(),
encoding: 'UTF-8',
binary: false
})
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('b.txt')
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
await screen.findByText(/已加载快照/)
expect(screen.getByRole('button', { name: '退出对照' })).toBeInTheDocument()
// 重扫触发截断:对照自动退出(回到常规视图与截断警示),不残留过期对照
fireEvent.click(screen.getByRole('button', { name: '重新扫描' }))
await screen.findByText(/快照对照已退出/)
await waitFor(() =>
expect(screen.queryByRole('button', { name: '退出对照' })).not.toBeInTheDocument()
)
})
})
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
import ContextMenu, { type ContextMenuItem } from './ContextMenu'
describe('ContextMenu', () => {
@@ -96,4 +96,78 @@ describe('ContextMenu', () => {
expect(menu.style.left).toBe('10px')
expect(menu.style.top).toBe('10px')
})
describe('键盘导航', () => {
const mkItems = (actions: { label: string; disabled?: boolean }[]) =>
actions.map((a) => ({ label: a.label, disabled: a.disabled, action: vi.fn() }))
it('ArrowDown 依次高亮(首项起步);ArrowUp 从末项起步', () => {
const items = mkItems([{ label: 'A' }, { label: 'B' }, { label: 'C' }])
render(<ContextMenu x={10} y={10} items={items} onClose={vi.fn()} />)
// 无高亮时按下 → 第一项
fireEvent.keyDown(document, { key: 'ArrowDown' })
expect(screen.getByText('A')).toHaveClass('active')
// 再按下 → 第二项
fireEvent.keyDown(document, { key: 'ArrowDown' })
expect(screen.getByText('B')).toHaveClass('active')
expect(screen.getByText('A')).not.toHaveClass('active')
// 无高亮语义验证:重新渲染后按上 → 末项
cleanup()
render(<ContextMenu x={10} y={10} items={mkItems([{ label: 'A' }, { label: 'B' }, { label: 'C' }])} onClose={vi.fn()} />)
fireEvent.keyDown(document, { key: 'ArrowUp' })
expect(screen.getByText('C')).toHaveClass('active')
})
it('首尾循环导航', () => {
const items = mkItems([{ label: 'A' }, { label: 'B' }])
render(<ContextMenu x={10} y={10} items={items} onClose={vi.fn()} />)
fireEvent.keyDown(document, { key: 'ArrowDown' }) // → A(索引 0
fireEvent.keyDown(document, { key: 'ArrowUp' }) // 循环回末项 B
expect(screen.getByText('B')).toHaveClass('active')
fireEvent.keyDown(document, { key: 'ArrowDown' }) // 循环回首项 A
expect(screen.getByText('A')).toHaveClass('active')
})
it('方向键跳过禁用项', () => {
const items = mkItems([{ label: 'A' }, { label: 'B', disabled: true }, { label: 'C' }])
render(<ContextMenu x={10} y={10} items={items} onClose={vi.fn()} />)
fireEvent.keyDown(document, { key: 'ArrowDown' }) // → A
fireEvent.keyDown(document, { key: 'ArrowDown' }) // B 禁用 → 跳到 C
expect(screen.getByText('C')).toHaveClass('active')
expect(screen.getByText('B')).not.toHaveClass('active')
})
it('Enter 触发高亮项并关闭;无高亮时 Enter 不触发', () => {
const action = vi.fn()
const onClose = vi.fn()
// 被测 spy 挂在第二项:两次 ArrowDown 高亮到该项后 Enter 触发
const items: ContextMenuItem[] = [{ label: 'A', action: vi.fn() }, { label: 'B', action }]
render(<ContextMenu x={10} y={10} items={items} onClose={onClose} />)
// 无高亮:Enter 无操作
fireEvent.keyDown(document, { key: 'Enter' })
expect(action).not.toHaveBeenCalled()
// 高亮第二项后 Enter 触发
fireEvent.keyDown(document, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'Enter' })
expect(action).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('鼠标悬停同步高亮', () => {
const items = mkItems([{ label: 'A' }, { label: 'B' }])
render(<ContextMenu x={10} y={10} items={items} onClose={vi.fn()} />)
fireEvent.mouseEnter(screen.getByText('B'))
expect(screen.getByText('B')).toHaveClass('active')
})
it('全部禁用时方向键不高亮任何项(不崩溃)', () => {
const items = mkItems([{ label: 'A', disabled: true }, { label: 'B', disabled: true }])
render(<ContextMenu x={10} y={10} items={items} onClose={vi.fn()} />)
fireEvent.keyDown(document, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'ArrowUp' })
expect(screen.getByText('A')).not.toHaveClass('active')
expect(screen.getByText('B')).not.toHaveClass('active')
})
})
})
+33 -4
View File
@@ -21,15 +21,43 @@ export default function ContextMenu({
}: ContextMenuProps): ReactElement {
const menuRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ left: x, top: y })
// 键盘导航高亮项(-1 = 无高亮;disabled 项不可选中)
const [active, setActive] = useState(-1)
// Esc 关闭菜单
// Esc 关闭菜单;↑/↓ 在可选项间移动高亮(首尾循环、跳过禁用项);Enter 触发高亮项
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
if (e.key === 'Escape') {
onClose()
return
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
const dir = e.key === 'ArrowDown' ? 1 : -1
setActive((prev) => {
if (items.length === 0) return prev
// 无高亮时按方向从首项/末项出发;有高亮从当前项出发循环找下一个可选项
const start = prev < 0 ? (dir === 1 ? -1 : items.length) : prev
let n = start
for (let i = 0; i < items.length; i++) {
n = (n + dir + items.length) % items.length
if (!items[n].disabled) return n
}
return prev // 全部禁用:保持不动
})
return
}
if (e.key === 'Enter') {
const it = active >= 0 ? items[active] : undefined
if (it && !it.disabled) {
it.action()
onClose()
}
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
}, [items, active, onClose])
// 渲染后实测菜单尺寸,溢出视口时收回边界内(右/下各留 8px 边距)
useLayoutEffect(() => {
@@ -64,8 +92,9 @@ export default function ContextMenu({
{items.map((it, i) => (
<button
key={i}
className={'ctx-item' + (it.disabled ? ' disabled' : '')}
className={'ctx-item' + (active === i ? ' active' : '') + (it.disabled ? ' disabled' : '')}
disabled={it.disabled}
onMouseEnter={() => setActive(i)}
onClick={() => {
if (!it.disabled) {
it.action()
@@ -515,6 +515,49 @@ describe('FolderView - 其他', () => {
expect(screen.getByRole('button', { name: '导出报告' })).toBeDisabled()
})
it('扫描中禁用语义判等开关(触发重扫的入口防抖)', () => {
const onSemanticChange = vi.fn()
const { rerender } = render(
<FolderView {...fullProps(ENTRIES, { scanning: true, onSemanticChange })} />
)
const sw = screen.getByLabelText('语义判等') as HTMLInputElement
// 浏览器保证 disabled 表单控件不响应点击(jsdom 会派发事件与浏览器不一致,
// 断言以 disabled 属性为准——与 0.5.4 被包含选项的同款处理)
expect(sw).toBeDisabled()
// 开关容器呈禁用视觉态(与被包含选项同款样式)
expect(sw.closest('label')).toHaveClass('disabled')
// 非扫描态对照:开关可用且触发回调(防抖仅在扫描中生效)
rerender(<FolderView {...fullProps(ENTRIES, { scanning: false, onSemanticChange })} />)
expect(screen.getByLabelText('语义判等')).not.toBeDisabled()
fireEvent.click(screen.getByLabelText('语义判等'))
expect(onSemanticChange).toHaveBeenCalledWith(false)
})
it('扫描中禁用忽略规则入口(应用规则会触发重扫)', () => {
const onIgnoreRulesChange = vi.fn()
render(
<FolderView {...fullProps(ENTRIES, { scanning: true, onIgnoreRulesChange })} />
)
const btn = screen.getByRole('button', { name: /忽略规则/ })
expect(btn).toBeDisabled()
// 禁用按钮点击不打开规则下拉、不触发回调
fireEvent.click(btn)
expect(screen.queryByText('应用并重扫')).not.toBeInTheDocument()
expect(onIgnoreRulesChange).not.toHaveBeenCalled()
})
it('扫描中仅看差异开关与搜索仍可用(纯视图过滤不触发重扫)', () => {
const onOnlyDiffChange = vi.fn()
render(
<FolderView {...fullProps(ENTRIES, { scanning: true, onOnlyDiffChange })} />
)
expect(screen.getByLabelText('仅看差异')).not.toBeDisabled()
const search = screen.getByPlaceholderText('搜索文件名…')
expect(search).not.toBeDisabled()
fireEvent.click(screen.getByLabelText('仅看差异'))
expect(onOnlyDiffChange).toHaveBeenCalledTimes(1)
})
it('点击外部区域关闭导出菜单', () => {
render(<FolderView {...fullProps(ENTRIES)} />)
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
+13 -3
View File
@@ -477,7 +477,12 @@ export default function FolderView({
<div className="export-wrap" ref={ignoreWrapRef}>
<button
className={'btn ghost' + (ignoreRules.length > 0 ? ' filtered' : '')}
title="忽略规则(gitignore 简化子集):node_modules/ 目录整树剪枝、*.log 扩展名通配、temp 名字匹配;应用后重新扫描"
disabled={scanning}
title={
scanning
? '正在扫描文件夹'
: '忽略规则(gitignore 简化子集):node_modules/ 目录整树剪枝、*.log 扩展名通配、temp 名字匹配;应用后重新扫描'
}
onClick={() => {
setIgnoreDraft(ignoreRules.join('\n'))
setIgnoreOpen((o) => !o)
@@ -513,12 +518,17 @@ export default function FolderView({
onChange={(e) => setQuery(e.target.value)}
/>
<label
className="switch"
title="字节不同但剔除空白(换行符/缩进/空行)后内容一致的文件计为“语义同”,不计入差异;切换后重新扫描"
className={'switch' + (scanning ? ' disabled' : '')}
title={
scanning
? '正在扫描文件夹'
: '字节不同但剔除空白(换行符/缩进/空行)后内容一致的文件计为“语义同”,不计入差异;切换后重新扫描'
}
>
<input
type="checkbox"
checked={semantic}
disabled={scanning}
onChange={(e) => onSemanticChange(e.target.checked)}
/>
-2
View File
@@ -3,7 +3,6 @@
--bg: #0a0e17;
--bg-deep: #070b12;
--panel: #0e1420;
--panel-2: #101828;
--surface: rgba(148, 163, 255, 0.06);
--surface-hover: rgba(124, 108, 255, 0.14);
--border: rgba(148, 163, 255, 0.14);
@@ -13,7 +12,6 @@
--muted: #74809a;
--accent: #8b7cff;
--accent-2: #22d3ee;
--pink: #f472b6;
/* diff 语义色 */
--add-bg: rgba(34, 197, 94, 0.13);