From 42d2095de29116ce465a6a96b07ede9a82c77343 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Wed, 19 Aug 2026 10:06:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=200.9.2=20=E5=BF=AB=E7=85=A7=E5=AF=B9?= =?UTF-8?q?=E7=85=A7=E5=AF=B9=E7=A7=B0=E7=BC=BA=E9=99=B7=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B8=8E=E4=BA=A4=E4=BA=92=E6=89=93=E7=A3=A8=20-=20=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=BF=AB=E7=85=A7=E6=8B=A6=E6=88=AA=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E6=88=AA=E6=96=AD=EF=BC=880.9.1=E5=8F=AA?= =?UTF-8?q?=E6=8B=A6=E4=BF=9D=E5=AD=98=E4=BE=A7=EF=BC=8C=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=E4=B8=8D=E5=AE=8C=E6=95=B4=E4=BC=9A=E6=8A=8A?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E7=BC=BA=E5=A4=B1=E6=96=87=E4=BB=B6=E8=AF=AF?= =?UTF-8?q?=E6=8A=A5=E5=B7=B2=E7=A7=BB=E9=99=A4=EF=BC=89=E3=80=81=E5=AF=B9?= =?UTF-8?q?=E7=85=A7=E6=A8=A1=E5=BC=8F=E4=B8=AD=E9=87=8D=E6=89=AB=E6=88=AA?= =?UTF-8?q?=E6=96=AD=E8=87=AA=E5=8A=A8=E9=80=80=E5=87=BA=E5=AF=B9=E7=85=A7?= =?UTF-8?q?=E3=80=81=E5=BF=AB=E7=85=A7=E8=AF=AD=E4=B9=89=E5=88=A4=E7=AD=89?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E4=B8=8E=E5=BD=93=E5=89=8D=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E4=B8=8D=E4=B8=80=E8=87=B4=E6=97=B6=E6=8F=90=E7=A4=BA=E5=90=AB?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E5=B7=AE=E5=BC=82=EF=BC=88=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E5=8F=98=E5=8C=96=E4=B8=8D=E5=86=8D=E8=A2=AB=E5=BD=93=E6=88=90?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E5=8F=98=E5=8C=96=EF=BC=89=E3=80=81=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E4=B8=AD=E7=A6=81=E7=94=A8=E8=AF=AD=E4=B9=89=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E4=B8=8E=E5=BF=BD=E7=95=A5=E8=A7=84=E5=88=99=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=88=E8=A7=A6=E5=8F=91=E9=87=8D=E6=89=AB=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E9=98=B2=E6=8A=96=EF=BC=8C=E7=BA=AF=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E4=B8=8D=E7=A6=81=E7=94=A8=EF=BC=89=E3=80=81?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E2=86=91/=E2=86=93?= =?UTF-8?q?=E9=94=AE=E7=9B=98=E5=AF=BC=E8=88=AA=E8=B7=B3=E8=BF=87=E7=A6=81?= =?UTF-8?q?=E7=94=A8=E9=A1=B9=E9=A6=96=E5=B0=BE=E5=BE=AA=E7=8E=AFEnter?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E6=82=AC=E5=81=9C=E5=90=8C=E6=AD=A5=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=E3=80=81=E6=B8=85=E7=90=86CSS=E5=AD=A4=E5=84=BF?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E4=B8=8E=E4=BE=9D=E8=B5=96patch=E7=BA=A7?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=EF=BC=8C=E6=96=B0=E5=A2=9E12=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B=EF=BC=88543=E5=8D=95=E6=B5=8B+E2E?= =?UTF-8?q?=2023=E5=85=A8=E8=BF=87=EF=BC=89=EF=BC=8C=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=9B=9B=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 | 7 +- docs/测试策略与方案.md | 5 +- package-lock.json | 62 +++++++-------- package.json | 2 +- src/renderer/src/App.tsx | 28 +++++-- src/renderer/src/__tests__/App.test.tsx | 71 +++++++++++++++++ .../src/components/ContextMenu.test.tsx | 76 ++++++++++++++++++- src/renderer/src/components/ContextMenu.tsx | 37 ++++++++- .../src/components/FolderView.test.tsx | 43 +++++++++++ src/renderer/src/components/FolderView.tsx | 16 +++- src/renderer/src/styles/global.css | 2 - 12 files changed, 302 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 38aa3df..07b7a72 100644 --- a/README.md +++ b/README.md @@ -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 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index afd62ea..334fc32 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -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 ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 8c7aa8e..1c59758 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -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 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果) - 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满 diff --git a/package-lock.json b/package-lock.json index 9d5b347..2bad76a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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, diff --git a/package.json b/package.json index 3cdae63..636b2a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.9.1", + "version": "0.9.2", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 41bdb46..76dbfa7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 => { let data: Awaited> 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(回全局序列), diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 238e006..9384344 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -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() + 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() + 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() + ) + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/ContextMenu.test.tsx b/src/renderer/src/components/ContextMenu.test.tsx index 08c60d5..9b8dd17 100644 --- a/src/renderer/src/components/ContextMenu.test.tsx +++ b/src/renderer/src/components/ContextMenu.test.tsx @@ -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() + // 无高亮时按下 → 第一项 + 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() + fireEvent.keyDown(document, { key: 'ArrowUp' }) + expect(screen.getByText('C')).toHaveClass('active') + }) + + it('首尾循环导航', () => { + const items = mkItems([{ label: 'A' }, { label: 'B' }]) + render() + 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() + 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() + // 无高亮: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() + fireEvent.mouseEnter(screen.getByText('B')) + expect(screen.getByText('B')).toHaveClass('active') + }) + + it('全部禁用时方向键不高亮任何项(不崩溃)', () => { + const items = mkItems([{ label: 'A', disabled: true }, { label: 'B', disabled: true }]) + render() + fireEvent.keyDown(document, { key: 'ArrowDown' }) + fireEvent.keyDown(document, { key: 'ArrowUp' }) + expect(screen.getByText('A')).not.toHaveClass('active') + expect(screen.getByText('B')).not.toHaveClass('active') + }) + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/ContextMenu.tsx b/src/renderer/src/components/ContextMenu.tsx index c781bff..18967df 100644 --- a/src/renderer/src/components/ContextMenu.tsx +++ b/src/renderer/src/components/ContextMenu.tsx @@ -21,15 +21,43 @@ export default function ContextMenu({ }: ContextMenuProps): ReactElement { const menuRef = useRef(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) => (