feat: 0.9.3 单文件视图文本搜索与稳健性收尾 - 工具栏搜索框(Ctrl+F聚焦/大小写不敏感即时命中计数/Enter与Shift+Enter命中行循环跳转复用activeRowId受控定位/行内命中子串高亮含词级segs行/Esc清空;基于全量行与仅看差异独立、折叠命中行跳转toast提示不推进)、扫描缓存落盘promise链串行化(并发读改写不再覆盖丢LRU目录对)、快照对照变迁过滤计数归零自动复位、重新选择扫描中保持可用固化测试(与返回文件对比同类发起新会话作废在途扫描)、依赖patch级升级,新增21单测用例与E2E搜索链路用例(564单测+E2E 24全过,覆盖率98.27/93.47/94.01/98.27),文档四件套同步

This commit is contained in:
2026-08-19 11:07:15 +08:00
parent 42d2095de2
commit c1e63c3328
16 changed files with 561 additions and 28 deletions
+2 -1
View File
@@ -19,6 +19,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应 - **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应
- **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径 - **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径
- **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键 - **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键
- **视图内文本搜索**:单文件对比视图内搜索任意内容——Ctrl+F 聚焦、大小写不敏感即时命中计数、Enter / Shift+Enter 在命中行间循环跳转(虚拟滚动下精确定位)、行内命中子串高亮、Esc 清空;搜索基于全量行,与“仅看差异”视图过滤互不影响
- **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异 - **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异
- **交换左右侧**:一键互换两侧内容与视角 - **交换左右侧**:一键互换两侧内容与视角
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
@@ -114,7 +115,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
- 作者:thzxx - 作者:thzxx
- 组织:MetonaTeam - 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE) - 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.9.2 - 版本:0.9.3
--- ---
+6 -1
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。 > 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.9.2`** 当前基线版本:**`0.9.3`**
--- ---
@@ -115,6 +115,11 @@ z = 补丁版本号(Patch
- 扫描中交互防抖:扫描进行中禁用语义判等开关与忽略规则入口(二者触发重扫——竞态守卫本已保证结果正确,防抖消除无谓的叠加扫描;仅看差异/搜索/扩展名过滤为纯视图过滤不禁用,与"重新扫描/保存快照/加载快照/导出报告"的既有 disabled 风格对齐) - 扫描中交互防抖:扫描进行中禁用语义判等开关与忽略规则入口(二者触发重扫——竞态守卫本已保证结果正确,防抖消除无谓的叠加扫描;仅看差异/搜索/扩展名过滤为纯视图过滤不禁用,与"重新扫描/保存快照/加载快照/导出报告"的既有 disabled 风格对齐)
- 右键菜单键盘导航:↑/↓ 在菜单项间移动高亮(跳过禁用项、首尾循环),Enter 触发高亮项,Esc 关闭不变;鼠标悬停与键盘高亮同步;全部禁用时方向键无操作不崩溃 - 右键菜单键盘导航:↑/↓ 在菜单项间移动高亮(跳过禁用项、首尾循环),Enter 触发高亮项,Esc 关闭不变;鼠标悬停与键盘高亮同步;全部禁用时方向键无操作不崩溃
- 微打磨与依赖:清理 CSS 孤儿变量(--pink/--panel-2 定义未消费) · 依赖 patch 级升级(npm update 语义化范围内,核心依赖 electron/vitest/vite 版本不变,仅间接依赖小幅更新) - 微打磨与依赖:清理 CSS 孤儿变量(--pink/--panel-2 定义未消费) · 依赖 patch 级升级(npm update 语义化范围内,核心依赖 electron/vitest/vite 版本不变,仅间接依赖小幅更新)
0.9.3 ← 单文件视图搜索 · 扫描缓存落盘串行化 · 交互收尾(已发布):
- 单文件视图文本搜索(补齐文件夹模式有搜索而单文件模式没有的功能缺口):工具栏新增搜索框,Ctrl+F 聚焦(粘贴弹窗/右键菜单打开时不抢占)、大小写不敏感即时命中计数(x / y)、Enter/Shift+Enter 在命中行间循环跳转(复用差异导航的 activeRowId 受控定位,虚拟滚动下天然可用)、行内命中子串高亮(纯文本行与词级 segs 行均拆分标注,黄色系与增删改三色不冲突)、Esc 清空复位 · 搜索基于全量行(与"仅看差异"独立,被折叠的命中行仍计数;跳转目标被折叠时 toast 提示且不推进) · 线性扫描 5 万行毫秒级无需 worker,会话级临时状态不持久化
- 扫描缓存落盘串行化:persistScanCache 读-改-写经内存 promise 链按序执行(每次追加链尾,失败静默不断链)——两次扫描近乎同时完成时不再后写整体覆盖先写丢 LRU 目录对
- 交互收尾:快照对照的变迁类型过滤在重扫刷新后计数归零时自动复位(残留过滤会让列表误显空态) · 「重新选择」扫描中保持可用并固化测试(与「返回文件对比」同类:发起新会话作废在途扫描,竞态守卫保证迟到结果丢弃;大目录扫描慢时允许用户放弃改选——曾考虑按防抖一致性禁用,经既有用例证伪后放弃)
- 测试与依赖:新增 21 用例(App 8 / Toolbar 7 / DiffView 4 / FolderView 2)与 E2E 搜索链路用例 · 依赖 patch 级升级(npm update 语义化范围内)
... ...
0.y.z ← 长期停留,永不进入 1.x 0.y.z ← 长期停留,永不进入 1.x
``` ```
+9 -6
View File
@@ -81,16 +81,16 @@ src/renderer/src/
│ └── useDismiss.ts │ └── useDismiss.ts
├── components/ ├── components/
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出;↑/↓ 键盘导航跳过禁用项首尾循环、Enter 触发高亮项、悬停同步高亮) │ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出;↑/↓ 键盘导航跳过禁用项首尾循环、Enter 触发高亮项、悬停同步高亮)
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) │ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放/搜索命中行内高亮
│ ├── ErrorBoundary.tsx # 顶层渲染错误边界(错误面板 + 一键重载,防白屏死机) │ ├── ErrorBoundary.tsx # 顶层渲染错误边界(错误面板 + 一键重载,防白屏死机)
│ ├── ErrorBoundary.test.tsx # 渲染错误兜底测试(抛错子组件 fallback/正常子组件透传) │ ├── ErrorBoundary.test.tsx # 渲染错误兜底测试(抛错子组件 fallback/正常子组件透传)
│ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤含扩展名/子树收集) │ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤含扩展名/子树收集)
│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt) │ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt)
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉/快照对照模式/目录双击/重新扫描/双击/导出菜单/虚拟滚动/resize 重算) │ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉/快照对照模式含变迁过滤归零复位/目录双击/重新扫描与重新选择/双击/导出菜单/虚拟滚动/resize 重算)
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗 │ ├── TextInputModal.test.tsx # 粘贴文本弹窗
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记) │ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记/搜索框计数与 Enter·Shift+Enter·Esc 键盘交互/ref 转发
└── __tests__/ └── __tests__/
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截) ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索/Ctrl+F/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截)
└── main.test.ts # 入口挂载冒烟测试 └── main.test.ts # 入口挂载冒烟测试
``` ```
@@ -126,6 +126,7 @@ src/renderer/src/
### 5.4 对比视图(`DiffView.tsx` ### 5.4 对比视图(`DiffView.tsx`
- 渲染:行内容、行号、词级高亮段、行尾空白标注 - 渲染:行内容、行号、词级高亮段、行尾空白标注
- 搜索命中高亮(0.9.3):searchQuery 非空时命中子串包裹高亮 span(大小写不敏感、多处命中全部拆分、左右两侧各自渲染);词级 segs 行在 seg 文本内拆分;无命中或未传 query 不渲染
- 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满 - 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满
- 内容最小宽度:探针测得字符宽后按最大列宽换算 minWidth(半角/全角列宽,超长行封顶);探针未测得时不设置 - 内容最小宽度:探针测得字符宽后按最大列宽换算 minWidth(半角/全角列宽,超长行封顶);探针未测得时不设置
- 折叠提示行:默认文案与自定义文案(仅看差异视图) - 折叠提示行:默认文案与自定义文案(仅看差异视图)
@@ -143,6 +144,7 @@ src/renderer/src/
- 偏好持久化(比较选项 + 仅看差异开关):启动时恢复上次保存的状态(选项勾选与视图折叠生效)/ 损坏 JSON 回退默认且不崩溃 / 非布尔字段防御性忽略 / 切换选项后写回存储(用例间 beforeEach 清理 localStorage 防串扰) - 偏好持久化(比较选项 + 仅看差异开关):启动时恢复上次保存的状态(选项勾选与视图折叠生效)/ 损坏 JSON 回退默认且不崩溃 / 非布尔字段防御性忽略 / 切换选项后写回存储(用例间 beforeEach 清理 localStorage 防串扰)
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项 - 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
- 单文件视图搜索(0.9.3):输入关键字即时命中计数与首个命中行 active 定位 · Enter/Shift+Enter 命中行间推进回退(行号随动)· 首尾循环 · 大小写不敏感 · 无命中 0/0 且 Enter 不崩溃不推进 · Esc 清空复位 · Ctrl+F 聚焦搜索框 · 命中行被“仅看差异”折叠时计数仍统计全量行、跳转 toast 提示且不推进
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式 - 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式
- 快照基线(0.9.00.9.1 收尾,0.9.2 对称防呆):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 当前扫描结果截断时加载快照同样被拦截(0.9.2,不弹文件选择——截断缺失文件会被误报已移除) · 对照模式中重扫触发截断自动退出对照并提示(0.9.2) · 快照语义判等口径与当前开关不一致时加载提示含口径差异、一致时无提示(0.9.2) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(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) · 退出对照恢复常规视图 · 对照随重扫自动刷新
@@ -183,9 +185,9 @@ src/renderer/src/
- 树形交互:目录行点击折叠展开(默认全展开);搜索命中强制展开命中路径、清空恢复用户折叠;无命中提示 - 树形交互:目录行点击折叠展开(默认全展开);搜索命中强制展开命中路径、清空恢复用户折叠;无命中提示
- 扩展名过滤(0.8.0):下拉展示全量条目 ext→count(字典序、无扩展名单列);点选过滤与其他条件取交集、计数切 N / 总数;多选并集、再点取消、清除按钮复位;与状态过滤双激活空态提示;点击外部关闭且过滤状态保留 - 扩展名过滤(0.8.0):下拉展示全量条目 ext→count(字典序、无扩展名单列);点选过滤与其他条件取交集、计数切 N / 总数;多选并集、再点取消、清除按钮复位;与状态过滤双激活空态提示;点击外部关闭且过滤状态保留
- 忽略规则(0.9.0):已有规则时入口标注数量并高亮;下拉打开以当前规则初始化草稿,逐行解析去空白去空行(截断至 50 条)后应用回调并关闭;清空规则回调空数组;点击外部关闭不触发回调 - 忽略规则(0.9.0):已有规则时入口标注数量并高亮;下拉打开以当前规则初始化草稿,逐行解析去空白去空行(截断至 50 条)后应用回调并关闭;清空规则回调空数组;点击外部关闭不触发回调
- 快照对照模式(0.9.0):变迁统计徽章(已修复/新增差异/新文件/已移除/仍有差异/保持一致)与基线时间替换常规统计行;对照条目平铺全路径渲染、行尾变迁徽章、gone 回退快照数据;零计数变迁不占位;默认隐藏 stable、点击徽章单选过滤再点取消;双击当前条目进单文件对比、已移除条目双击无效;退出对照恢复常规视图;全 stable 空态提示 - 快照对照模式(0.9.0):变迁统计徽章(已修复/新增差异/新文件/已移除/仍有差异/保持一致)与基线时间替换常规统计行;对照条目平铺全路径渲染、行尾变迁徽章、gone 回退快照数据;零计数变迁不占位;默认隐藏 stable、点击徽章单选过滤再点取消;双击当前条目进单文件对比、已移除条目双击无效;退出对照恢复常规视图;全 stable 空态提示;重扫刷新后过滤的变迁类型计数归零自动复位(0.9.3,残留过滤不再误显空态)
- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动) - 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动)
- 扫描中交互防抖(0.9.2):扫描进行中语义判等开关与忽略规则入口禁用(触发重扫的入口,点击不触发回调/不打开下拉);仅看差异开关与搜索仍可用(纯视图过滤不触发重扫) - 扫描中交互防抖(0.9.2):扫描进行中语义判等开关与忽略规则入口禁用(触发重扫的入口,点击不触发回调/不打开下拉);仅看差异开关与搜索仍可用(纯视图过滤不触发重扫);「重新选择」扫描中保持可用(0.9.3 固化:发起新会话作废在途扫描,与「返回文件对比」同类)
- 目录行双击(0.8.0):触发 onOpenDir 携带目录相对路径;双击前折叠态自动展开(toggle 残留消除);重新扫描按钮触发 onRescan 且扫描中禁用 - 目录行双击(0.8.0):触发 onOpenDir 携带目录相对路径;双击前折叠态自动展开(toggle 残留消除);重新扫描按钮触发 onRescan 且扫描中禁用
- 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果) - 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果)
- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满 - 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满
@@ -198,6 +200,7 @@ src/renderer/src/
- 比较选项:忽略大小写与忽略所有空白组合、字符级跨行重组判一致 - 比较选项:忽略大小写与忽略所有空白组合、字符级跨行重组判一致
- 滚动稳定性回归锚(0.7.0):真实文件(行数不等)→ 开字符级进 worker 重路径 → 滚到中部 → 切选项二次重算 → 断言 scrollTop 保持不归零、滚到底内容连续(0.6.3 回退缺陷的固化防线) - 滚动稳定性回归锚(0.7.0):真实文件(行数不等)→ 开字符级进 worker 重路径 → 滚到中部 → 切选项二次重算 → 断言 scrollTop 保持不归零、滚到底内容连续(0.6.3 回退缺陷的固化防线)
- 导航与视图:F7 / Shift+F7、仅看差异折叠、交换左右后面板互换 - 导航与视图:F7 / Shift+F7、仅看差异折叠、交换左右后面板互换
- 单文件视图搜索(0.9.3):粘贴两侧后 Ctrl+F 聚焦搜索框(真实键盘事件)→ 输入关键字即时计数与行内命中高亮 → Enter 跳转下一命中行(active 行号定位断言)→ Esc 清空复位计数
- 报告导出:stub 保存对话框 → 单文件 HTML 报告真实落盘且含结论与统计 - 报告导出:stub 保存对话框 → 单文件 HTML 报告真实落盘且含结论与统计
- 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤 / 树形显示)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留) - 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤 / 树形显示)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留)
- 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目) - 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目)
+24
View File
@@ -197,6 +197,30 @@ test.describe('差异导航与视图', () => {
await app.close() await app.close()
}) })
test('单文件视图搜索:Ctrl+F 聚焦、计数跳转、行内高亮与 Esc 清空(0.9.3', async () => {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'alpha\nfind target one\ngamma\nfind target two')
await pasteText(window, 'right', 'alpha\nfind target one\ngamma\nfind target two')
await expect(window.getByText('两文件内容完全一致')).toBeVisible()
// Ctrl+F 聚焦搜索框
await window.keyboard.press('Control+f')
const input = window.getByPlaceholder('搜索内容… (Ctrl+F)')
await expect(input).toBeFocused()
// 输入关键字:即时计数与行内命中高亮
await input.fill('target')
await expect(window.locator('.search-count')).toHaveText('1 / 2')
await expect(window.locator('.search-hit').first()).toBeVisible()
// Enter 跳转下一个命中行(第一个命中行 active 定位)
await window.keyboard.press('Enter')
await expect(window.locator('.search-count')).toHaveText('2 / 2')
await expect(window.locator('.diff-row.active .ln').first()).toHaveText('4')
// Esc 清空关键字(计数复位消失)
await window.keyboard.press('Escape')
await expect(input).toHaveValue('')
await expect(window.locator('.search-count')).toHaveText('')
await app.close()
})
test('交换左右侧后面板文件互换', async () => { test('交换左右侧后面板文件互换', async () => {
const dir = await makeTmpDir('difflens-e2e-swap-') const dir = await makeTmpDir('difflens-e2e-swap-')
try { try {
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.9.1", "version": "0.9.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "DiffLens", "name": "DiffLens",
"version": "0.9.1", "version": "0.9.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
@@ -4460,9 +4460,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.410", "version": "1.5.411",
"resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.410.tgz", "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz",
"integrity": "sha512-Vq9DD7F4PKCKVmOoG6i1CQSYoF7IUtwTPEQjMXuqNs2S22H8HsojO9myaB81QuvvIKZaF6imSn3XAV1Su6rvXA==", "integrity": "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.9.2", "version": "0.9.3",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+10 -2
View File
@@ -53,8 +53,16 @@ function loadScanCacheStore(): ScanCacheStoreData {
/** /**
* 落盘扫描缓存(读-改-写,异步 fire-and-forget): * 落盘扫描缓存(读-改-写,异步 fire-and-forget):
* 失败静默(目录只读/磁盘满等,跨会话增量记忆失效不影响本次结果)。 * 失败静默(目录只读/磁盘满等,跨会话增量记忆失效不影响本次结果)。
* 写操作经内存 promise 链串行执行(每次追加到链尾):两次扫描近乎同时
* 完成时各自的读-改-写按序落盘,避免后写整体覆盖先写丢失 LRU 目录对。
*/ */
async function persistScanCache( let scanCacheWrites: Promise<void> = Promise.resolve()
function persistScanCache(leftDir: string, rightDir: string, cache: ScanCacheEntry[]): void {
scanCacheWrites = scanCacheWrites.then(() => writeScanCache(leftDir, rightDir, cache))
}
async function writeScanCache(
leftDir: string, leftDir: string,
rightDir: string, rightDir: string,
cache: ScanCacheEntry[] cache: ScanCacheEntry[]
@@ -331,7 +339,7 @@ ipcMain.handle(
: {}) : {})
}) })
// 扫描成功且未截断:缓存异步落盘(LRU,失败静默;下次启动同目录对可增量重扫) // 扫描成功且未截断:缓存异步落盘(LRU,失败静默;下次启动同目录对可增量重扫)
if (!result.truncated) void persistScanCache(leftDir, rightDir, result.cache) if (!result.truncated) persistScanCache(leftDir, rightDir, result.cache)
return result return result
} catch { } catch {
// 目录不存在/不可读等:转为错误标记,渲染端提示 // 目录不存在/不可读等:转为错误标记,渲染端提示
+71 -2
View File
@@ -7,8 +7,8 @@ import {
type ReactElement, type ReactElement,
type MouseEvent as ReactMouseEvent type MouseEvent as ReactMouseEvent
} from 'react' } from 'react'
import type { DiffOptions, DiffSummary } from './diff/diffEngine' import type { DiffOptions, DiffSummary, DiffRow } from './diff/diffEngine'
import DiffView, { type DisplayItem, type FoldMarker } from './components/DiffView' import DiffView, { type DisplayItem, type FoldMarker, isFold } from './components/DiffView'
import Toolbar from './components/Toolbar' import Toolbar from './components/Toolbar'
import ContextMenu, { type ContextMenuItem } from './components/ContextMenu' import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
import TextInputModal from './components/TextInputModal' import TextInputModal from './components/TextInputModal'
@@ -227,6 +227,10 @@ export default function App(): ReactElement {
const [onlyDiff, setOnlyDiff] = useState<boolean>(initialPrefs.onlyDiff) const [onlyDiff, setOnlyDiff] = useState<boolean>(initialPrefs.onlyDiff)
const [activeRowId, setActiveRowId] = useState<string | null>(null) const [activeRowId, setActiveRowId] = useState<string | null>(null)
const [navIndex, setNavIndex] = useState(0) const [navIndex, setNavIndex] = useState(0)
// 视图内文本搜索(单文件模式):关键字 + 当前命中序号(会话级临时状态,不持久化)
const [searchQuery, setSearchQuery] = useState('')
const [searchIndex, setSearchIndex] = useState(0)
const searchInputRef = useRef<HTMLInputElement>(null)
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null)
const [toasts, setToasts] = useState<ToastItem[]>([]) const [toasts, setToasts] = useState<ToastItem[]>([])
const [pasteSide, setPasteSide] = useState<'left' | 'right' | null>(null) const [pasteSide, setPasteSide] = useState<'left' | 'right' | null>(null)
@@ -777,6 +781,64 @@ export default function App(): ReactElement {
return out return out
}, [rows, onlyDiff, changedRows.length]) }, [rows, onlyDiff, changedRows.length])
// ===== 视图内文本搜索 =====
// 命中行集(大小写不敏感,两侧任一命中即算;含未更改行,与“仅看差异”独立——
// rows 为全量行,5 万行线性扫描毫秒级,无需 worker)
const searchHits = useMemo<DiffRow[]>(() => {
const q = searchQuery.trim().toLowerCase()
if (q === '') return []
return rows.filter(
(r) =>
(r.left.text !== null && r.left.text.toLowerCase().includes(q)) ||
(r.right.text !== null && r.right.text.toLowerCase().includes(q))
)
}, [rows, searchQuery])
// 关键字变化:命中序号归零并定位到第一个命中行(GitHub 风格即时反馈);
// 仅依赖 searchQueryrows 变化触发的 hits 重算不抢跳,由下方 clamp 兜底)
useEffect(() => {
setSearchIndex(0)
if (searchQuery.trim() !== '' && searchHits.length > 0) setActiveRowId(searchHits[0].id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery])
// diff 重算(切选项/交换/新文件)导致命中集变化:序号钳制回有效范围
useEffect(() => {
setSearchIndex((i) => Math.min(i, Math.max(0, searchHits.length - 1)))
}, [searchHits])
/** 命中行之间循环跳转(Enter / Shift+Enter);目标行被“仅看差异”折叠时提示且不推进 */
const goSearch = useCallback(
(dir: 1 | -1): void => {
const len = searchHits.length
if (len === 0) return
let ni = searchIndex + dir
if (ni >= len) ni = 0
if (ni < 0) ni = len - 1
const hit = searchHits[ni]
if (!displayRows.some((it) => !isFold(it) && it.id === hit.id)) {
showToast('命中行位于“仅看差异”折叠段内,关闭该开关后可定位')
return
}
setSearchIndex(ni)
setActiveRowId(hit.id)
},
[searchHits, searchIndex, displayRows, showToast]
)
// Ctrl+F 聚焦搜索框(单文件模式;粘贴弹窗/右键菜单打开时不抢占焦点)
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'f') return
if (mode !== 'file' || pasteSide !== null || menu !== null) return
e.preventDefault()
searchInputRef.current?.focus()
searchInputRef.current?.select()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [mode, pasteSide, menu])
// 交换左右两侧(React 18 事件回调内自动批处理,两态一同更新) // 交换左右两侧(React 18 事件回调内自动批处理,两态一同更新)
const swapPanes = useCallback((): void => { const swapPanes = useCallback((): void => {
setPaneL(paneR) setPaneL(paneR)
@@ -1107,6 +1169,7 @@ export default function App(): ReactElement {
leftMeta={leftMeta} leftMeta={leftMeta}
rightMeta={rightMeta} rightMeta={rightMeta}
activeRowId={activeRowId} activeRowId={activeRowId}
searchQuery={searchQuery.trim()}
onOpen={(s) => void openPane(s)} onOpen={(s) => void openPane(s)}
onRowContext={onRowContext} onRowContext={onRowContext}
onDropFile={(s, f) => void dropPane(s, f)} onDropFile={(s, f) => void dropPane(s, f)}
@@ -1128,6 +1191,12 @@ export default function App(): ReactElement {
} }
charModeDowngraded={diff?.charModeDowngraded ?? false} charModeDowngraded={diff?.charModeDowngraded ?? false}
charModePartial={diff?.charModePartial ?? false} charModePartial={diff?.charModePartial ?? false}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
searchIndex={searchIndex}
searchCount={searchHits.length}
onSearchNav={goSearch}
searchInputRef={searchInputRef}
/> />
</> </>
) : ( ) : (
+104
View File
@@ -900,6 +900,110 @@ describe('App - 字符级对比', () => {
}) })
}) })
describe('App - 单文件视图搜索', () => {
/** 两侧同文本:find 命中第 2、5 行;alpha 命中第 1、3 行 */
const SEARCH_TEXT = 'alpha\nfind here\nalpha\ngamma\nfind there\ndelta'
async function loadSearchFixture() {
window.api = mockApi({
openFile: async () => ({
path: '/tmp/a.txt',
name: 'a.txt',
text: SEARCH_TEXT,
encoding: 'UTF-8',
binary: false
})
})
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByText('打开右侧'))
await screen.findAllByText('两文件内容完全一致')
}
const searchInput = (): HTMLElement =>
screen.getByPlaceholderText('搜索内容… (Ctrl+F)')
it('输入关键字:命中计数显示且第一个命中行定位高亮', async () => {
await loadSearchFixture()
fireEvent.change(searchInput(), { target: { value: 'find' } })
expect(await screen.findByText('1 / 2')).toBeInTheDocument()
// 第一命中行 active(左右两栏各一行):左侧行号为 2
const actives = document.querySelectorAll('.diff-row.active')
expect(actives.length).toBe(2)
expect(actives[0].querySelector('.ln')?.textContent).toBe('2')
})
it('Enter 推进到下一命中(行号定位随动),Shift+Enter 回退', async () => {
await loadSearchFixture()
fireEvent.change(searchInput(), { target: { value: 'find' } })
await screen.findByText('1 / 2')
fireEvent.keyDown(searchInput(), { key: 'Enter' })
expect(await screen.findByText('2 / 2')).toBeInTheDocument()
expect(document.querySelector('.diff-row.active .ln')?.textContent).toBe('5')
fireEvent.keyDown(searchInput(), { key: 'Enter', shiftKey: true })
expect(await screen.findByText('1 / 2')).toBeInTheDocument()
expect(document.querySelector('.diff-row.active .ln')?.textContent).toBe('2')
})
it('最后一个命中再 Enter 循环回第一个', async () => {
await loadSearchFixture()
fireEvent.change(searchInput(), { target: { value: 'find' } })
await screen.findByText('1 / 2')
fireEvent.keyDown(searchInput(), { key: 'Enter' })
await screen.findByText('2 / 2')
fireEvent.keyDown(searchInput(), { key: 'Enter' })
expect(await screen.findByText('1 / 2')).toBeInTheDocument()
})
it('大小写不敏感匹配', async () => {
await loadSearchFixture()
fireEvent.change(searchInput(), { target: { value: 'ALPHA' } })
expect(await screen.findByText('1 / 2')).toBeInTheDocument()
})
it('无命中显示 0 / 0Enter 不崩溃不推进', async () => {
await loadSearchFixture()
fireEvent.change(searchInput(), { target: { value: 'zzz' } })
// 两侧完全一致时差异导航计数同为 0 / 0,用 selector 限定搜索计数
expect(
await screen.findByText('0 / 0', { selector: '.search-count' })
).toBeInTheDocument()
fireEvent.keyDown(searchInput(), { key: 'Enter' })
expect(screen.getByText('0 / 0', { selector: '.search-count' })).toBeInTheDocument()
})
it('Esc 清空关键字并复位计数', async () => {
await loadSearchFixture()
fireEvent.change(searchInput(), { target: { value: 'find' } })
await screen.findByText('1 / 2')
fireEvent.keyDown(searchInput(), { key: 'Escape' })
expect(searchInput()).toHaveValue('')
expect(screen.queryByText('1 / 2')).not.toBeInTheDocument()
})
it('Ctrl+F 聚焦搜索框', async () => {
await loadSearchFixture()
expect(document.activeElement).not.toBe(searchInput())
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
expect(document.activeElement).toBe(searchInput())
})
it('命中行被“仅看差异”折叠:计数仍统计全部行,跳转提示且不推进', async () => {
await loadSearchFixture()
// 两侧完全一致:开启仅看差异后全部行折叠
fireEvent.click(screen.getByLabelText('仅看差异'))
expect(screen.getAllByText(/已折叠/).length).toBeGreaterThan(0)
fireEvent.change(searchInput(), { target: { value: 'find' } })
// 折叠行仍计入计数(搜索与视图过滤独立)
expect(await screen.findByText('1 / 2')).toBeInTheDocument()
fireEvent.keyDown(searchInput(), { key: 'Enter' })
expect(await screen.findByText(/折叠段内/)).toBeInTheDocument()
// 计数不推进
expect(screen.getByText('1 / 2')).toBeInTheDocument()
})
})
describe('App - 状态栏行数', () => { describe('App - 状态栏行数', () => {
// 文件名在 <b> 子元素内,getByText 只拼直接文本子节点无法跨元素匹配, // 文件名在 <b> 子元素内,getByText 只拼直接文本子节点无法跨元素匹配,
// 用 container 取 .status-item 的完整 textContent 断言(含换行空白,正则放宽) // 用 container 取 .status-item 的完整 textContent 断言(含换行空白,正则放宽)
+82 -1
View File
@@ -9,13 +9,18 @@ beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn() Element.prototype.scrollIntoView = vi.fn()
}) })
function renderDiff(items: DisplayItem[], activeRowId: string | null = null) { function renderDiff(
items: DisplayItem[],
activeRowId: string | null = null,
searchQuery?: string
) {
return render( return render(
<DiffView <DiffView
items={items} items={items}
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }} leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
rightMeta={{ name: 'b.txt', encoding: 'GBK' }} rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
activeRowId={activeRowId} activeRowId={activeRowId}
searchQuery={searchQuery}
onOpen={vi.fn()} onOpen={vi.fn()}
onRowContext={vi.fn()} onRowContext={vi.fn()}
onDropFile={vi.fn()} onDropFile={vi.fn()}
@@ -305,3 +310,79 @@ describe('DiffView - 折叠提示行', () => {
expect(screen.getAllByText('两文件内容完全一致,已折叠全部未更改行').length).toBe(2) expect(screen.getAllByText('两文件内容完全一致,已折叠全部未更改行').length).toBe(2)
}) })
}) })
describe('DiffView - 搜索命中高亮', () => {
it('纯文本行命中子串高亮(大小写不敏感,两侧各自渲染)', () => {
const row: DiffRow = {
id: 's0',
rowKind: 'unchanged',
isChanged: false,
left: { lineNo: 1, text: 'find TARGET here', segs: null },
right: { lineNo: 1, text: 'target elsewhere', segs: null }
}
const { container } = renderDiff([row], null, 'target')
const hits = container.querySelectorAll('.search-hit')
// 左右各一处命中(左侧 TARGET 大写命中)
expect(hits).toHaveLength(2)
expect(hits[0].textContent).toBe('TARGET')
expect(hits[1].textContent).toBe('target')
})
it('同一行多处命中全部拆分高亮', () => {
const row: DiffRow = {
id: 's1',
rowKind: 'unchanged',
isChanged: false,
left: { lineNo: 1, text: 'ab ab ab', segs: null },
right: { lineNo: 1, text: 'ab ab ab', segs: null }
}
const { container } = renderDiff([row], null, 'ab')
// 每侧 3 处命中
expect(container.querySelectorAll('.search-hit')).toHaveLength(6)
})
it('词级 segs 行内命中同样高亮(seg 文本内拆分)', () => {
const modRow: DiffRow = {
id: 's2',
rowKind: 'modified',
isChanged: true,
left: {
lineNo: 1,
text: 'keep target old',
segs: [
{ text: 'keep ', kind: 'common' },
{ text: 'target', kind: 'delete' },
{ text: ' old', kind: 'common' }
]
},
right: {
lineNo: 1,
text: 'keep new old',
segs: [
{ text: 'keep ', kind: 'common' },
{ text: 'new', kind: 'insert' },
{ text: ' old', kind: 'common' }
]
}
}
const { container } = renderDiff([modRow], null, 'target')
const hits = container.querySelectorAll('.search-hit')
// 仅左侧 delete seg 内命中一处
expect(hits).toHaveLength(1)
expect(hits[0].textContent).toBe('target')
})
it('无命中不渲染高亮 span;未传 searchQuery 不高亮', () => {
const row: DiffRow = {
id: 's3',
rowKind: 'unchanged',
isChanged: false,
left: { lineNo: 1, text: 'plain', segs: null },
right: { lineNo: 1, text: 'plain', segs: null }
}
const miss = renderDiff([row], null, 'zzz')
expect(miss.container.querySelectorAll('.search-hit')).toHaveLength(0)
const none = renderDiff([row])
expect(none.container.querySelectorAll('.search-hit')).toHaveLength(0)
})
})
+38 -5
View File
@@ -44,6 +44,8 @@ interface DiffViewProps {
leftMeta: PaneMeta | null leftMeta: PaneMeta | null
rightMeta: PaneMeta | null rightMeta: PaneMeta | null
activeRowId: string | null activeRowId: string | null
/** 视图内文本搜索(大小写不敏感,非空时行内命中子串高亮) */
searchQuery?: string
onOpen: (side: 'left' | 'right') => void onOpen: (side: 'left' | 'right') => void
onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => void onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => void
onDropFile: (side: 'left' | 'right', files: File[]) => void onDropFile: (side: 'left' | 'right', files: File[]) => void
@@ -62,18 +64,44 @@ function renderWithWs(text: string): ReactNode[] {
] ]
} }
function renderCellText(cell: SideCell): ReactNode { /** 搜索命中子串包裹高亮 span(大小写不敏感,多处命中全部拆分);无命中返回原文本单节点 */
function renderWithHits(text: string, query: string): ReactNode[] {
if (query === '') return [text]
const lower = text.toLowerCase()
const lq = query.toLowerCase()
const out: ReactNode[] = []
let from = 0
let i = lower.indexOf(lq)
let key = 0
while (i >= 0) {
if (i > from) out.push(text.slice(from, i))
out.push(
<span className="search-hit" key={`hit-${key++}`}>
{text.slice(i, i + query.length)}
</span>
)
from = i + query.length
i = lower.indexOf(lq, from)
}
if (out.length === 0) return [text]
if (from < text.length) out.push(text.slice(from))
return out
}
function renderCellText(cell: SideCell, query: string): ReactNode {
if (cell.segs) { if (cell.segs) {
return cell.segs.map((s, i) => ( return cell.segs.map((s, i) => (
<span key={i} className={'seg seg-' + s.kind}> <span key={i} className={'seg seg-' + s.kind}>
{s.text} {query !== '' ? renderWithHits(s.text, query) : s.text}
</span> </span>
)) ))
} }
if (cell.text === null) { if (cell.text === null) {
return <span className="plain-empty">&#8203;</span> return <span className="plain-empty">&#8203;</span>
} }
return <span className="plain">{renderWithWs(cell.text)}</span> // 搜索激活时命中高亮优先(行尾空白标注与命中拆分不叠加,避免嵌套冲突)
const body = query !== '' ? renderWithHits(cell.text, query) : renderWithWs(cell.text)
return <span className="plain">{body}</span>
} }
function SidePanel({ function SidePanel({
@@ -86,7 +114,8 @@ function SidePanel({
scrollRef, scrollRef,
onScroll, onScroll,
activeRowId, activeRowId,
minWidth minWidth,
searchQuery
}: { }: {
items: DisplayItem[] items: DisplayItem[]
side: 'left' | 'right' side: 'left' | 'right'
@@ -98,6 +127,7 @@ function SidePanel({
onScroll: () => void onScroll: () => void
activeRowId: string | null activeRowId: string | null
minWidth: number | undefined minWidth: number | undefined
searchQuery: string
}): ReactElement { }): ReactElement {
// 当前渲染窗口 [start, end),随滚动位置按固定行高换算 // 当前渲染窗口 [start, end),随滚动位置按固定行高换算
const [range, setRange] = useState({ start: 0, end: 0 }) const [range, setRange] = useState({ start: 0, end: 0 })
@@ -182,7 +212,7 @@ function SidePanel({
<div className={cls} data-row={row.id} key={row.id + '-' + side}> <div className={cls} data-row={row.id} key={row.id + '-' + side}>
<span className={'ln ' + side}>{cell.lineNo ?? ''}</span> <span className={'ln ' + side}>{cell.lineNo ?? ''}</span>
<span className="tx" onContextMenu={(e) => onRowContext(e, side, cell)}> <span className="tx" onContextMenu={(e) => onRowContext(e, side, cell)}>
{renderCellText(cell)} {renderCellText(cell, searchQuery)}
</span> </span>
</div> </div>
) )
@@ -198,6 +228,7 @@ export default function DiffView({
leftMeta, leftMeta,
rightMeta, rightMeta,
activeRowId, activeRowId,
searchQuery,
onOpen, onOpen,
onRowContext, onRowContext,
onDropFile onDropFile
@@ -277,6 +308,7 @@ export default function DiffView({
onScroll={makeScrollSync('left')} onScroll={makeScrollSync('left')}
activeRowId={activeRowId} activeRowId={activeRowId}
minWidth={leftMinW} minWidth={leftMinW}
searchQuery={searchQuery ?? ''}
/> />
<SidePanel <SidePanel
items={items} items={items}
@@ -289,6 +321,7 @@ export default function DiffView({
onScroll={makeScrollSync('right')} onScroll={makeScrollSync('right')}
activeRowId={activeRowId} activeRowId={activeRowId}
minWidth={rightMinW} minWidth={rightMinW}
searchQuery={searchQuery ?? ''}
/> />
</div> </div>
</div> </div>
@@ -453,6 +453,28 @@ describe('FolderView - 快照对照模式', () => {
expect(screen.queryByText(/已修复 \d/)).not.toBeInTheDocument() expect(screen.queryByText(/已修复 \d/)).not.toBeInTheDocument()
}) })
it('重扫刷新后过滤的变迁类型计数归零:过滤自动复位显示全部(0.9.3)', () => {
const { rerender } = render(
<FolderView {...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: snapDiff })} />
)
// 过滤“已修复”:只剩 a.txt
fireEvent.click(screen.getByText('已修复 1'))
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.queryByText('new.txt')).not.toBeInTheDocument()
// 重扫后的新对照:基线全部无差异 → fixed 归零(new / gone 仍在)
const noFixedBase: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 2, rightSize: 2 },
{ rel: 'b.txt', status: 'same', leftSize: 1, rightSize: 1 }
]
const refreshed = diffSnapshot(noFixedBase, CUR_ENTRIES)
rerender(
<FolderView {...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: refreshed })} />
)
// 残留的 fixed 过滤已自动复位:非 stable 条目重新可见
//new.txt 回归;a/b 此刻为 stable 被默认隐藏属正常行为)
expect(screen.getByText('new.txt')).toBeInTheDocument()
})
it('对照条目全一致时空态提示', () => { it('对照条目全一致时空态提示', () => {
// 双侧均无差异(same)→ 全部 stable → 默认隐藏 → 空态提示 // 双侧均无差异(same)→ 全部 stable → 默认隐藏 → 空态提示
const allSame: FolderEntry[] = [ const allSame: FolderEntry[] = [
@@ -489,6 +511,18 @@ describe('FolderView - 其他', () => {
expect(screen.getByRole('button', { name: '加载快照' })).toBeDisabled() expect(screen.getByRole('button', { name: '加载快照' })).toBeDisabled()
}) })
it('扫描中重新选择保持可用(发起新会话并作废在途扫描,0.9.3 固化)', () => {
const onReopen = vi.fn()
const { rerender } = render(<FolderView {...fullProps(ENTRIES, { onReopen })} />)
fireEvent.click(screen.getByRole('button', { name: '重新选择' }))
expect(onReopen).toHaveBeenCalledTimes(1)
// 扫描中仍可点击(竞态守卫丢弃迟到结果;大目录扫描慢时允许放弃改选)
rerender(<FolderView {...fullProps(ENTRIES, { scanning: true, onReopen })} />)
expect(screen.getByRole('button', { name: '重新选择' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '重新选择' }))
expect(onReopen).toHaveBeenCalledTimes(2)
})
it('保存/加载快照按钮触发回调', () => { it('保存/加载快照按钮触发回调', () => {
const onSaveSnapshot = vi.fn() const onSaveSnapshot = vi.fn()
const onLoadSnapshot = vi.fn() const onLoadSnapshot = vi.fn()
+15 -1
View File
@@ -268,6 +268,14 @@ export default function FolderView({
setChangeFilter((prev) => (prev === change ? null : change)) setChangeFilter((prev) => (prev === change ? null : change))
}, []) }, [])
// 对照结果刷新(重扫)后过滤的变迁类型计数归零:过滤自动复位为全部
// (徽章已不渲染,残留过滤会让列表显示空态——0.9.3 收尾)
useEffect(() => {
if (changeFilter !== null && snapshotDiff !== null && snapshotDiff.stats[changeFilter] === 0) {
setChangeFilter(null)
}
}, [snapshotDiff, changeFilter])
// 虚拟滚动:固定行高换算可见窗口(与 DiffView 同思路;对照模式与树形模式共用窗口状态) // 虚拟滚动:固定行高换算可见窗口(与 DiffView 同思路;对照模式与树形模式共用窗口状态)
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const [range, setRange] = useState({ start: 0, end: 0 }) const [range, setRange] = useState({ start: 0, end: 0 })
@@ -335,7 +343,13 @@ export default function FolderView({
> >
</button> </button>
<button className="btn ghost" onClick={onReopen}> {/* 重新选择保持可用(与“返回文件对比”同类:发起新会话并作废在途扫描,
竞态守卫保证迟到的旧结果被丢弃;大目录扫描慢时允许用户放弃改选) */}
<button
className="btn ghost"
title="重新选择两个文件夹进行对比(扫描中点击将放弃当前扫描)"
onClick={onReopen}
>
</button> </button>
<div className="export-wrap" ref={exportWrapRef}> <div className="export-wrap" ref={exportWrapRef}>
+77 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react' import { render, screen, fireEvent } from '@testing-library/react'
import { createRef } from 'react'
import Toolbar from './Toolbar' import Toolbar from './Toolbar'
import { DiffSummary } from '../diff/diffEngine' import { DiffSummary } from '../diff/diffEngine'
@@ -12,12 +13,19 @@ function setup(extra?: {
charModePartial?: boolean charModePartial?: boolean
baseOptions?: Partial<import('../diff/diffEngine').DiffOptions> baseOptions?: Partial<import('../diff/diffEngine').DiffOptions>
onOptionBlocked?: (label: string) => void onOptionBlocked?: (label: string) => void
searchQuery?: string
searchIndex?: number
searchCount?: number
onSearchNav?: (dir: 1 | -1) => void
}) { }) {
const onOptionsChange = vi.fn() const onOptionsChange = vi.fn()
const onNav = vi.fn() const onNav = vi.fn()
const onExport = vi.fn() const onExport = vi.fn()
const onOnlyDiffChange = vi.fn() const onOnlyDiffChange = vi.fn()
const onSwap = vi.fn() const onSwap = vi.fn()
const onSearchChange = vi.fn()
const onSearchNav = extra?.onSearchNav ?? vi.fn()
const searchInputRef = createRef<HTMLInputElement>()
const utils = render( const utils = render(
<Toolbar <Toolbar
options={{ options={{
@@ -41,9 +49,25 @@ function setup(extra?: {
onOptionBlocked={extra?.onOptionBlocked} onOptionBlocked={extra?.onOptionBlocked}
charModeDowngraded={extra?.charModeDowngraded ?? false} charModeDowngraded={extra?.charModeDowngraded ?? false}
charModePartial={extra?.charModePartial ?? false} charModePartial={extra?.charModePartial ?? false}
searchQuery={extra?.searchQuery ?? ''}
onSearchChange={onSearchChange}
searchIndex={extra?.searchIndex ?? 0}
searchCount={extra?.searchCount ?? 0}
onSearchNav={onSearchNav}
searchInputRef={searchInputRef}
/> />
) )
return { onOptionsChange, onNav, onExport, onOnlyDiffChange, onSwap, ...utils } return {
onOptionsChange,
onNav,
onExport,
onOnlyDiffChange,
onSwap,
onSearchChange,
onSearchNav,
searchInputRef,
...utils
}
} }
describe('Toolbar', () => { describe('Toolbar', () => {
@@ -232,3 +256,55 @@ describe('Toolbar', () => {
expect(screen.getByText('导出报告')).toBeDisabled() expect(screen.getByText('导出报告')).toBeDisabled()
}) })
}) })
describe('Toolbar - 视图内搜索框', () => {
it('未搜索时不显示命中计数', () => {
setup()
expect(screen.getByPlaceholderText('搜索内容… (Ctrl+F)')).toHaveValue('')
expect(screen.getByPlaceholderText('搜索内容… (Ctrl+F)')).toBeVisible()
// 搜索计数为空串(差异导航计数与此无关,限定 .search-count 断言)
expect(document.querySelector('.search-count')?.textContent).toBe('')
})
it('搜索中显示当前序号与命中总数', () => {
setup({ searchQuery: 'alpha', searchIndex: 1, searchCount: 5 })
expect(screen.getByText('2 / 5')).toBeInTheDocument()
})
it('无命中显示 0 / N', () => {
setup({ searchQuery: 'zzz', searchCount: 0 })
expect(screen.getByText('0 / 0')).toBeInTheDocument()
})
it('输入触发 onSearchChange', () => {
const { onSearchChange } = setup()
fireEvent.change(screen.getByPlaceholderText('搜索内容… (Ctrl+F)'), {
target: { value: 'target' }
})
expect(onSearchChange).toHaveBeenCalledWith('target')
})
it('Enter 触发 onSearchNav(1)Shift+Enter 触发 onSearchNav(-1)', () => {
const { onSearchNav } = setup({ searchQuery: 'a', searchCount: 2 })
const input = screen.getByPlaceholderText('搜索内容… (Ctrl+F)')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSearchNav).toHaveBeenCalledWith(1)
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
expect(onSearchNav).toHaveBeenCalledWith(-1)
})
it('Esc 清空关键字并失焦', () => {
const { onSearchChange } = setup({ searchQuery: 'stale' })
const input = screen.getByPlaceholderText('搜索内容… (Ctrl+F)')
input.focus()
expect(document.activeElement).toBe(input)
fireEvent.keyDown(input, { key: 'Escape' })
expect(onSearchChange).toHaveBeenCalledWith('')
expect(document.activeElement).not.toBe(input)
})
it('searchInputRef 转发到输入框(Ctrl+F 聚焦用)', () => {
const { searchInputRef } = setup()
expect(searchInputRef.current).toBe(screen.getByPlaceholderText('搜索内容… (Ctrl+F)'))
})
})
+46 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState, type ReactElement } from 'react' import { useCallback, useRef, useState, type ReactElement, type RefObject } from 'react'
import type { DiffOptions, DiffSummary } from '../diff/diffEngine' import type { DiffOptions, DiffSummary } from '../diff/diffEngine'
import { type ReportFormat } from '../diff/report' import { type ReportFormat } from '../diff/report'
import { useDismiss } from '../hooks/useDismiss' import { useDismiss } from '../hooks/useDismiss'
@@ -30,6 +30,17 @@ interface ToolbarProps {
charModeDowngraded?: boolean charModeDowngraded?: boolean
/** 字符级对比部分降级(部分超限组按行级,其余组仍字符级;显示“部分降级”标记) */ /** 字符级对比部分降级(部分超限组按行级,其余组仍字符级;显示“部分降级”标记) */
charModePartial?: boolean charModePartial?: boolean
/** 视图内文本搜索关键字(空串 = 未搜索) */
searchQuery: string
onSearchChange: (v: string) => void
/** 当前命中序号(0 基;无命中为 0) */
searchIndex: number
/** 命中总数 */
searchCount: number
/** 命中行之间跳转(1 下一个 / -1 上一个,循环) */
onSearchNav: (dir: 1 | -1) => void
/** Ctrl+F 聚焦用:搜索输入框 refApp 持有并转发到 input */
searchInputRef: RefObject<HTMLInputElement>
} }
export default function Toolbar({ export default function Toolbar({
@@ -46,7 +57,13 @@ export default function Toolbar({
onSwap, onSwap,
onOptionBlocked, onOptionBlocked,
charModeDowngraded = false, charModeDowngraded = false,
charModePartial = false charModePartial = false,
searchQuery,
onSearchChange,
searchIndex,
searchCount,
onSearchNav,
searchInputRef
}: ToolbarProps): ReactElement { }: ToolbarProps): ReactElement {
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch }) const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
const [exportOpen, setExportOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false)
@@ -174,6 +191,33 @@ export default function Toolbar({
</button> </button>
</div> </div>
<div className="tool-group search-group">
<input
ref={searchInputRef}
className="diff-search"
type="text"
value={searchQuery}
placeholder="搜索内容… (Ctrl+F)"
spellCheck={false}
title="在两侧全部行内搜索(大小写不敏感):Enter 下一个 / Shift+Enter 上一个 / Esc 清空"
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
// Esc 清空并交还焦点(仅当有内容时阻止默认避免冒泡副作用)
if (searchQuery !== '') e.preventDefault()
onSearchChange('')
e.currentTarget.blur()
} else if (e.key === 'Enter') {
e.preventDefault()
onSearchNav(e.shiftKey ? -1 : 1)
}
}}
/>
<span className="search-count">
{searchQuery.trim() === '' ? '' : `${searchCount > 0 ? searchIndex + 1 : 0} / ${searchCount}`}
</span>
</div>
<div className="tool-group"> <div className="tool-group">
<button <button
className="nav-btn" className="nav-btn"
+37
View File
@@ -1038,6 +1038,43 @@ button.fstat.active {
color: var(--muted); color: var(--muted);
} }
/* ============ 单文件视图搜索(Ctrl+F ============ */
/* 输入框与文件夹搜索同款视觉语言 */
.diff-search {
width: 170px;
padding: 5px 12px;
border-radius: 8px;
font-size: 12px;
font-family: var(--mono);
color: var(--text);
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
outline: none;
transition: border-color 0.15s ease;
}
.diff-search:focus {
border-color: var(--accent);
}
.diff-search::placeholder {
color: var(--muted);
}
/* 命中计数(x / y,等宽数字;未搜索时为空不占宽) */
.search-count {
font-size: 12px;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-width: 46px;
text-align: center;
}
/* 行内命中子串高亮(黄色系,与增删改三色及选中青色不冲突) */
.search-hit {
background: rgba(250, 204, 21, 0.30);
border-radius: 2px;
color: #fde68a;
}
.folder-scroll { .folder-scroll {
flex: 1; flex: 1;
overflow: auto; overflow: auto;