fix: 0.9.2 快照对照对称缺陷修复与交互打磨 - 加载快照拦截当前结果截断(0.9.1只拦保存侧,当前条目不完整会把截断缺失文件误报已移除)、对照模式中重扫截断自动退出对照、快照语义判等口径与当前开关不一致时提示含口径差异(口径变化不再被当成内容变化)、扫描中禁用语义开关与忽略规则入口(触发重扫入口防抖,纯视图过滤不禁用)、右键菜单↑/↓键盘导航跳过禁用项首尾循环Enter触发悬停同步高亮、清理CSS孤儿变量与依赖patch级升级,新增12测试用例(543单测+E2E 23全过),文档四件套同步
This commit is contained in:
@@ -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
@@ -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
@@ -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.0,0.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 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果)
|
||||
- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满
|
||||
|
||||
Generated
+31
-31
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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(回全局序列),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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: '导出报告' }))
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
语义判等
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user