diff --git a/README.md b/README.md index 07b7a72..77fad78 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应 - **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径 - **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键 +- **视图内文本搜索**:单文件对比视图内搜索任意内容——Ctrl+F 聚焦、大小写不敏感即时命中计数、Enter / Shift+Enter 在命中行间循环跳转(虚拟滚动下精确定位)、行内命中子串高亮、Esc 清空;搜索基于全量行,与“仅看差异”视图过滤互不影响 - **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异 - **交换左右侧**:一键互换两侧内容与视角 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 @@ -114,7 +115,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主 - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.9.2 +- 版本:0.9.3 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 334fc32..4af2b47 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.9.2`** +当前基线版本:**`0.9.3`** --- @@ -115,6 +115,11 @@ z = 补丁版本号(Patch) - 扫描中交互防抖:扫描进行中禁用语义判等开关与忽略规则入口(二者触发重扫——竞态守卫本已保证结果正确,防抖消除无谓的叠加扫描;仅看差异/搜索/扩展名过滤为纯视图过滤不禁用,与"重新扫描/保存快照/加载快照/导出报告"的既有 disabled 风格对齐) - 右键菜单键盘导航:↑/↓ 在菜单项间移动高亮(跳过禁用项、首尾循环),Enter 触发高亮项,Esc 关闭不变;鼠标悬停与键盘高亮同步;全部禁用时方向键无操作不崩溃 - 微打磨与依赖:清理 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 ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 1c59758..927aa7c 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -81,16 +81,16 @@ src/renderer/src/ │ └── useDismiss.ts ├── components/ │ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出;↑/↓ 键盘导航跳过禁用项首尾循环、Enter 触发高亮项、悬停同步高亮) -│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) +│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放/搜索命中行内高亮) │ ├── ErrorBoundary.tsx # 顶层渲染错误边界(错误面板 + 一键重载,防白屏死机) │ ├── ErrorBoundary.test.tsx # 渲染错误兜底测试(抛错子组件 fallback/正常子组件透传) │ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤含扩展名/子树收集) │ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt) -│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉/快照对照模式/目录双击/重新扫描/双击/导出菜单/虚拟滚动/resize 重算) +│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉/快照对照模式含变迁过滤归零复位/目录双击/重新扫描与重新选择/双击/导出菜单/虚拟滚动/resize 重算) │ ├── TextInputModal.test.tsx # 粘贴文本弹窗 -│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记) +│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记/搜索框计数与 Enter·Shift+Enter·Esc 键盘交互/ref 转发) └── __tests__/ - ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截) + ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索/Ctrl+F/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截) └── main.test.ts # 入口挂载冒烟测试 ``` @@ -126,6 +126,7 @@ src/renderer/src/ ### 5.4 对比视图(`DiffView.tsx`) - 渲染:行内容、行号、词级高亮段、行尾空白标注 +- 搜索命中高亮(0.9.3):searchQuery 非空时命中子串包裹高亮 span(大小写不敏感、多处命中全部拆分、左右两侧各自渲染);词级 segs 行在 seg 文本内拆分;无命中或未传 query 不渲染 - 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满 - 内容最小宽度:探针测得字符宽后按最大列宽换算 minWidth(半角/全角列宽,超长行封顶);探针未测得时不设置 - 折叠提示行:默认文案与自定义文案(仅看差异视图) @@ -143,6 +144,7 @@ src/renderer/src/ - 偏好持久化(比较选项 + 仅看差异开关):启动时恢复上次保存的状态(选项勾选与视图折叠生效)/ 损坏 JSON 回退默认且不崩溃 / 非布尔字段防御性忽略 / 切换选项后写回存储(用例间 beforeEach 清理 localStorage 防串扰) - 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项 - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 +- 单文件视图搜索(0.9.3):输入关键字即时命中计数与首个命中行 active 定位 · Enter/Shift+Enter 命中行间推进回退(行号随动)· 首尾循环 · 大小写不敏感 · 无命中 0/0 且 Enter 不崩溃不推进 · Esc 清空复位 · Ctrl+F 聚焦搜索框 · 命中行被“仅看差异”折叠时计数仍统计全量行、跳转 toast 提示且不推进 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 - 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式 - 快照基线(0.9.0,0.9.1 收尾,0.9.2 对称防呆):保存快照落盘内容为合法快照 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.9.0):已有规则时入口标注数量并高亮;下拉打开以当前规则初始化草稿,逐行解析去空白去空行(截断至 50 条)后应用回调并关闭;清空规则回调空数组;点击外部关闭不触发回调 -- 快照对照模式(0.9.0):变迁统计徽章(已修复/新增差异/新文件/已移除/仍有差异/保持一致)与基线时间替换常规统计行;对照条目平铺全路径渲染、行尾变迁徽章、gone 回退快照数据;零计数变迁不占位;默认隐藏 stable、点击徽章单选过滤再点取消;双击当前条目进单文件对比、已移除条目双击无效;退出对照恢复常规视图;全 stable 空态提示 +- 快照对照模式(0.9.0):变迁统计徽章(已修复/新增差异/新文件/已移除/仍有差异/保持一致)与基线时间替换常规统计行;对照条目平铺全路径渲染、行尾变迁徽章、gone 回退快照数据;零计数变迁不占位;默认隐藏 stable、点击徽章单选过滤再点取消;双击当前条目进单文件对比、已移除条目双击无效;退出对照恢复常规视图;全 stable 空态提示;重扫刷新后过滤的变迁类型计数归零自动复位(0.9.3,残留过滤不再误显空态) - 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动) -- 扫描中交互防抖(0.9.2):扫描进行中语义判等开关与忽略规则入口禁用(触发重扫的入口,点击不触发回调/不打开下拉);仅看差异开关与搜索仍可用(纯视图过滤不触发重扫) +- 扫描中交互防抖(0.9.2):扫描进行中语义判等开关与忽略规则入口禁用(触发重扫的入口,点击不触发回调/不打开下拉);仅看差异开关与搜索仍可用(纯视图过滤不触发重扫);「重新选择」扫描中保持可用(0.9.3 固化:发起新会话作废在途扫描,与「返回文件对比」同类) - 目录行双击(0.8.0):触发 onOpenDir 携带目录相对路径;双击前折叠态自动展开(toggle 残留消除);重新扫描按钮触发 onRescan 且扫描中禁用 - 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果) - 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满 @@ -198,6 +200,7 @@ src/renderer/src/ - 比较选项:忽略大小写与忽略所有空白组合、字符级跨行重组判一致 - 滚动稳定性回归锚(0.7.0):真实文件(行数不等)→ 开字符级进 worker 重路径 → 滚到中部 → 切选项二次重算 → 断言 scrollTop 保持不归零、滚到底内容连续(0.6.3 回退缺陷的固化防线) - 导航与视图:F7 / Shift+F7、仅看差异折叠、交换左右后面板互换 +- 单文件视图搜索(0.9.3):粘贴两侧后 Ctrl+F 聚焦搜索框(真实键盘事件)→ 输入关键字即时计数与行内命中高亮 → Enter 跳转下一命中行(active 行号定位断言)→ Esc 清空复位计数 - 报告导出:stub 保存对话框 → 单文件 HTML 报告真实落盘且含结论与统计 - 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤 / 树形显示)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留) - 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目) diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index fc81690..5939d28 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -197,6 +197,30 @@ test.describe('差异导航与视图', () => { 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 () => { const dir = await makeTmpDir('difflens-e2e-swap-') try { diff --git a/package-lock.json b/package-lock.json index 2bad76a..751b974 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "DiffLens", - "version": "0.9.1", + "version": "0.9.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "DiffLens", - "version": "0.9.1", + "version": "0.9.3", "license": "MIT", "dependencies": { "@electron-toolkit/preload": "^3.0.1", @@ -4460,9 +4460,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.410", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.410.tgz", - "integrity": "sha512-Vq9DD7F4PKCKVmOoG6i1CQSYoF7IUtwTPEQjMXuqNs2S22H8HsojO9myaB81QuvvIKZaF6imSn3XAV1Su6rvXA==", + "version": "1.5.411", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz", + "integrity": "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==", "dev": true, "license": "ISC" }, diff --git a/package.json b/package.json index 636b2a0..f44dcaa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.9.2", + "version": "0.9.3", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index e5e29e1..d8cdb9d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -53,8 +53,16 @@ function loadScanCacheStore(): ScanCacheStoreData { /** * 落盘扫描缓存(读-改-写,异步 fire-and-forget): * 失败静默(目录只读/磁盘满等,跨会话增量记忆失效不影响本次结果)。 + * 写操作经内存 promise 链串行执行(每次追加到链尾):两次扫描近乎同时 + * 完成时各自的读-改-写按序落盘,避免后写整体覆盖先写丢失 LRU 目录对。 */ -async function persistScanCache( +let scanCacheWrites: Promise = Promise.resolve() + +function persistScanCache(leftDir: string, rightDir: string, cache: ScanCacheEntry[]): void { + scanCacheWrites = scanCacheWrites.then(() => writeScanCache(leftDir, rightDir, cache)) +} + +async function writeScanCache( leftDir: string, rightDir: string, cache: ScanCacheEntry[] @@ -331,7 +339,7 @@ ipcMain.handle( : {}) }) // 扫描成功且未截断:缓存异步落盘(LRU,失败静默;下次启动同目录对可增量重扫) - if (!result.truncated) void persistScanCache(leftDir, rightDir, result.cache) + if (!result.truncated) persistScanCache(leftDir, rightDir, result.cache) return result } catch { // 目录不存在/不可读等:转为错误标记,渲染端提示 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 76dbfa7..e964668 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,8 +7,8 @@ import { type ReactElement, type MouseEvent as ReactMouseEvent } from 'react' -import type { DiffOptions, DiffSummary } from './diff/diffEngine' -import DiffView, { type DisplayItem, type FoldMarker } from './components/DiffView' +import type { DiffOptions, DiffSummary, DiffRow } from './diff/diffEngine' +import DiffView, { type DisplayItem, type FoldMarker, isFold } from './components/DiffView' import Toolbar from './components/Toolbar' import ContextMenu, { type ContextMenuItem } from './components/ContextMenu' import TextInputModal from './components/TextInputModal' @@ -227,6 +227,10 @@ export default function App(): ReactElement { const [onlyDiff, setOnlyDiff] = useState(initialPrefs.onlyDiff) const [activeRowId, setActiveRowId] = useState(null) const [navIndex, setNavIndex] = useState(0) + // 视图内文本搜索(单文件模式):关键字 + 当前命中序号(会话级临时状态,不持久化) + const [searchQuery, setSearchQuery] = useState('') + const [searchIndex, setSearchIndex] = useState(0) + const searchInputRef = useRef(null) const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) const [toasts, setToasts] = useState([]) const [pasteSide, setPasteSide] = useState<'left' | 'right' | null>(null) @@ -777,6 +781,64 @@ export default function App(): ReactElement { return out }, [rows, onlyDiff, changedRows.length]) + // ===== 视图内文本搜索 ===== + // 命中行集(大小写不敏感,两侧任一命中即算;含未更改行,与“仅看差异”独立—— + // rows 为全量行,5 万行线性扫描毫秒级,无需 worker) + const searchHits = useMemo(() => { + 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 风格即时反馈); + // 仅依赖 searchQuery(rows 变化触发的 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 事件回调内自动批处理,两态一同更新) const swapPanes = useCallback((): void => { setPaneL(paneR) @@ -1107,6 +1169,7 @@ export default function App(): ReactElement { leftMeta={leftMeta} rightMeta={rightMeta} activeRowId={activeRowId} + searchQuery={searchQuery.trim()} onOpen={(s) => void openPane(s)} onRowContext={onRowContext} onDropFile={(s, f) => void dropPane(s, f)} @@ -1128,6 +1191,12 @@ export default function App(): ReactElement { } charModeDowngraded={diff?.charModeDowngraded ?? false} charModePartial={diff?.charModePartial ?? false} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + searchIndex={searchIndex} + searchCount={searchHits.length} + onSearchNav={goSearch} + searchInputRef={searchInputRef} /> ) : ( diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 9384344..ad50f59 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -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() + 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 / 0,Enter 不崩溃不推进', 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 - 状态栏行数', () => { // 文件名在 子元素内,getByText 只拼直接文本子节点无法跨元素匹配, // 用 container 取 .status-item 的完整 textContent 断言(含换行空白,正则放宽) diff --git a/src/renderer/src/components/DiffView.test.tsx b/src/renderer/src/components/DiffView.test.tsx index c72ffe2..856e3e1 100644 --- a/src/renderer/src/components/DiffView.test.tsx +++ b/src/renderer/src/components/DiffView.test.tsx @@ -9,13 +9,18 @@ beforeAll(() => { 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( { 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) + }) +}) diff --git a/src/renderer/src/components/DiffView.tsx b/src/renderer/src/components/DiffView.tsx index a529af8..8acd0a0 100644 --- a/src/renderer/src/components/DiffView.tsx +++ b/src/renderer/src/components/DiffView.tsx @@ -44,6 +44,8 @@ interface DiffViewProps { leftMeta: PaneMeta | null rightMeta: PaneMeta | null activeRowId: string | null + /** 视图内文本搜索(大小写不敏感,非空时行内命中子串高亮) */ + searchQuery?: string onOpen: (side: 'left' | 'right') => void onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => 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( + + {text.slice(i, i + query.length)} + + ) + 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) { return cell.segs.map((s, i) => ( - {s.text} + {query !== '' ? renderWithHits(s.text, query) : s.text} )) } if (cell.text === null) { return } - return {renderWithWs(cell.text)} + // 搜索激活时命中高亮优先(行尾空白标注与命中拆分不叠加,避免嵌套冲突) + const body = query !== '' ? renderWithHits(cell.text, query) : renderWithWs(cell.text) + return {body} } function SidePanel({ @@ -86,7 +114,8 @@ function SidePanel({ scrollRef, onScroll, activeRowId, - minWidth + minWidth, + searchQuery }: { items: DisplayItem[] side: 'left' | 'right' @@ -98,6 +127,7 @@ function SidePanel({ onScroll: () => void activeRowId: string | null minWidth: number | undefined + searchQuery: string }): ReactElement { // 当前渲染窗口 [start, end),随滚动位置按固定行高换算 const [range, setRange] = useState({ start: 0, end: 0 }) @@ -182,7 +212,7 @@ function SidePanel({
{cell.lineNo ?? ''} onRowContext(e, side, cell)}> - {renderCellText(cell)} + {renderCellText(cell, searchQuery)}
) @@ -198,6 +228,7 @@ export default function DiffView({ leftMeta, rightMeta, activeRowId, + searchQuery, onOpen, onRowContext, onDropFile @@ -277,6 +308,7 @@ export default function DiffView({ onScroll={makeScrollSync('left')} activeRowId={activeRowId} minWidth={leftMinW} + searchQuery={searchQuery ?? ''} /> diff --git a/src/renderer/src/components/FolderView.test.tsx b/src/renderer/src/components/FolderView.test.tsx index a2ba9f2..5645066 100644 --- a/src/renderer/src/components/FolderView.test.tsx +++ b/src/renderer/src/components/FolderView.test.tsx @@ -453,6 +453,28 @@ describe('FolderView - 快照对照模式', () => { expect(screen.queryByText(/已修复 \d/)).not.toBeInTheDocument() }) + it('重扫刷新后过滤的变迁类型计数归零:过滤自动复位显示全部(0.9.3)', () => { + const { rerender } = render( + + ) + // 过滤“已修复”:只剩 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( + + ) + // 残留的 fixed 过滤已自动复位:非 stable 条目重新可见 + //(new.txt 回归;a/b 此刻为 stable 被默认隐藏属正常行为) + expect(screen.getByText('new.txt')).toBeInTheDocument() + }) + it('对照条目全一致时空态提示', () => { // 双侧均无差异(same)→ 全部 stable → 默认隐藏 → 空态提示 const allSame: FolderEntry[] = [ @@ -489,6 +511,18 @@ describe('FolderView - 其他', () => { expect(screen.getByRole('button', { name: '加载快照' })).toBeDisabled() }) + it('扫描中重新选择保持可用(发起新会话并作废在途扫描,0.9.3 固化)', () => { + const onReopen = vi.fn() + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: '重新选择' })) + expect(onReopen).toHaveBeenCalledTimes(1) + // 扫描中仍可点击(竞态守卫丢弃迟到结果;大目录扫描慢时允许放弃改选) + rerender() + expect(screen.getByRole('button', { name: '重新选择' })).toBeEnabled() + fireEvent.click(screen.getByRole('button', { name: '重新选择' })) + expect(onReopen).toHaveBeenCalledTimes(2) + }) + it('保存/加载快照按钮触发回调', () => { const onSaveSnapshot = vi.fn() const onLoadSnapshot = vi.fn() diff --git a/src/renderer/src/components/FolderView.tsx b/src/renderer/src/components/FolderView.tsx index 75e1d49..68a4509 100644 --- a/src/renderer/src/components/FolderView.tsx +++ b/src/renderer/src/components/FolderView.tsx @@ -268,6 +268,14 @@ export default function FolderView({ setChangeFilter((prev) => (prev === change ? null : change)) }, []) + // 对照结果刷新(重扫)后过滤的变迁类型计数归零:过滤自动复位为全部 + // (徽章已不渲染,残留过滤会让列表显示空态——0.9.3 收尾) + useEffect(() => { + if (changeFilter !== null && snapshotDiff !== null && snapshotDiff.stats[changeFilter] === 0) { + setChangeFilter(null) + } + }, [snapshotDiff, changeFilter]) + // 虚拟滚动:固定行高换算可见窗口(与 DiffView 同思路;对照模式与树形模式共用窗口状态) const scrollRef = useRef(null) const [range, setRange] = useState({ start: 0, end: 0 }) @@ -335,7 +343,13 @@ export default function FolderView({ > 加载快照 -
diff --git a/src/renderer/src/components/Toolbar.test.tsx b/src/renderer/src/components/Toolbar.test.tsx index c36ebb3..5512d70 100644 --- a/src/renderer/src/components/Toolbar.test.tsx +++ b/src/renderer/src/components/Toolbar.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' +import { createRef } from 'react' import Toolbar from './Toolbar' import { DiffSummary } from '../diff/diffEngine' @@ -12,12 +13,19 @@ function setup(extra?: { charModePartial?: boolean baseOptions?: Partial onOptionBlocked?: (label: string) => void + searchQuery?: string + searchIndex?: number + searchCount?: number + onSearchNav?: (dir: 1 | -1) => void }) { const onOptionsChange = vi.fn() const onNav = vi.fn() const onExport = vi.fn() const onOnlyDiffChange = vi.fn() const onSwap = vi.fn() + const onSearchChange = vi.fn() + const onSearchNav = extra?.onSearchNav ?? vi.fn() + const searchInputRef = createRef() const utils = render( ) - return { onOptionsChange, onNav, onExport, onOnlyDiffChange, onSwap, ...utils } + return { + onOptionsChange, + onNav, + onExport, + onOnlyDiffChange, + onSwap, + onSearchChange, + onSearchNav, + searchInputRef, + ...utils + } } describe('Toolbar', () => { @@ -231,4 +255,56 @@ describe('Toolbar', () => { setup({ computing: true }) 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)')) + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index 47a1770..b713dcf 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -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 ReportFormat } from '../diff/report' import { useDismiss } from '../hooks/useDismiss' @@ -30,6 +30,17 @@ interface ToolbarProps { charModeDowngraded?: boolean /** 字符级对比部分降级(部分超限组按行级,其余组仍字符级;显示“部分降级”标记) */ charModePartial?: boolean + /** 视图内文本搜索关键字(空串 = 未搜索) */ + searchQuery: string + onSearchChange: (v: string) => void + /** 当前命中序号(0 基;无命中为 0) */ + searchIndex: number + /** 命中总数 */ + searchCount: number + /** 命中行之间跳转(1 下一个 / -1 上一个,循环) */ + onSearchNav: (dir: 1 | -1) => void + /** Ctrl+F 聚焦用:搜索输入框 ref(App 持有并转发到 input) */ + searchInputRef: RefObject } export default function Toolbar({ @@ -46,7 +57,13 @@ export default function Toolbar({ onSwap, onOptionBlocked, charModeDowngraded = false, - charModePartial = false + charModePartial = false, + searchQuery, + onSearchChange, + searchIndex, + searchCount, + onSearchNav, + searchInputRef }: ToolbarProps): ReactElement { const set = (patch: Partial): void => onOptionsChange({ ...options, ...patch }) const [exportOpen, setExportOpen] = useState(false) @@ -174,6 +191,33 @@ export default function Toolbar({
+
+ 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) + } + }} + /> + + {searchQuery.trim() === '' ? '' : `${searchCount > 0 ? searchIndex + 1 : 0} / ${searchCount}`} + +
+