diff --git a/README.md b/README.md index 9ad68a5..6ee580a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 -- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**扫描进度反馈**(比对阶段实时显示"已比对 N / M")、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**忽略规则**(gitignore 简化子集含高级 glob:`node_modules/` 目录整树剪枝、`*.log` 通配、`lo?g` 单字符、`*.[ch]` 字符类(范围与取反)、`src/temp` 多段路径、`build/**/cache` 跨段、`!keep.log` 否定规则(最后匹配优先);无效规则实时提示不再静默丢弃;规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫) +- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**扫描进度反馈**(比对阶段实时显示"已比对 N / M")、**单行紧凑头部**(操作横排 + 快照归组下拉,列表区最大化)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**(Ctrl+F 聚焦 / Esc 清空)与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**换目录自动重置视图状态**(搜索/过滤/折叠不残留到新目录)、**忽略规则**(gitignore 简化子集含高级 glob:`node_modules/` 目录整树剪枝、`*.log` 通配、`lo?g` 单字符、`*.[ch]` 字符类(范围与取反)、`src/temp` 多段路径、`build/**/cache` 跨段、`!keep.log` 否定规则(最后匹配优先);无效规则实时提示不再静默丢弃;规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹缓存,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫) - **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**,保存期间批量读取实时显示"已读取 N / M"进度,保存中防重复触发),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么");内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);**大快照专用读取通道**(加载上限 128MB,通用文件的 10MB 上限不拦截自家保存的大快照,且不污染文件对话框的目录记忆);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照、自身截断的快照同样禁止加载(三侧防对照误报),保存期间退出文件夹对比自动终止在途保存(不弹名不符实的保存对话框),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照) - **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成 - **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览,文件大小 B/KB/MB/GB/TB 人类可读) @@ -19,12 +19,12 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应 - **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径 - **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键 -- **视图内文本搜索**:单文件对比视图内搜索任意内容——Ctrl+F 聚焦、大小写不敏感即时命中计数、Enter / Shift+Enter 在命中行间循环跳转(虚拟滚动下精确定位)、行内命中子串高亮、Esc 清空;搜索基于全量行,与“仅看差异”视图过滤互不影响;切换比较选项/更换文件触发重算后,命中计数与高亮行保持一致(自动钳制重定位) +- **视图内文本搜索**:单文件对比视图内搜索任意内容——Ctrl+F 聚焦、大小写不敏感即时命中计数、Enter / Shift+Enter 在命中行间循环跳转(虚拟滚动下精确定位)、行内命中子串高亮、Esc 清空、有内容时 × 一键清空;搜索基于全量行,与“仅看差异”视图过滤互不影响;切换比较选项/更换文件触发重算后,命中计数与高亮行保持一致(自动钳制重定位);文件夹模式 Ctrl+F 同样聚焦文件名搜索框(跨模式一致) - **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异 - **交换左右侧**:一键互换两侧内容与视角 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景 -- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;**相同内容自动锚定跳过计算(行锚定 + 变更组分块),大文件不再整篇受限**;**变更组逐组预算制(预算随输入规模自适应)**——单组过大 / 编辑距离超限 / 预算耗尽的块仅按行级配对输出(部分降级,界面与报告标注),不再牵连其余块;预算下限与历史固定值一致(小输入行为不变),大输入按总组流比例放宽(上限封顶,最坏总耗时仍有界);全部块降级时自动降级行级对比并提示(编辑距离封顶,不会卡死);重算期间保留旧结果撑住滚动位置,**切换选项不再回弹到顶部** +- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;**相同内容自动锚定跳过计算(行锚定 + 变更组分块),大文件不再整篇受限**;**变更组逐组预算制(预算随输入规模自适应)**——单组过大 / 编辑距离超限 / 预算耗尽的块仅按行级配对输出(部分降级,界面与报告标注),不再牵连其余块;预算下限与历史固定值一致(小输入行为不变),大输入按总组流比例放宽(上限封顶,最坏总耗时仍有界);全部块降级时自动降级行级对比并提示(编辑距离封顶,不会卡死);**字符粒度增删统计(+N 字 / −M 字徽章与报告统计行,码点计数 emoji 不撕裂)**;重算期间保留旧结果撑住滚动位置,**切换选项不再回弹到顶部** - **选项联动透明**:开启字符级对比时,被包含的空白选项灰显勾选并标注“已包含”(点击有解释提示,关闭字符级即恢复独立设置);差异过大自动降级时开关旁标注“已降级”,状态一目了然 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 - **偏好记忆**:比较选项与"仅看差异"开关自动保存,窗口尺寸/位置与上次打开文件目录一并记忆,下次启动自动恢复(损坏数据自动回退默认) @@ -117,7 +117,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主 - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.10.4 +- 版本:0.10.5 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 0655187..a730505 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.10.4`** +当前基线版本:**`0.10.5`** --- @@ -150,6 +150,12 @@ z = 补丁版本号(Patch) - 快照保存中断(保存遮罩不拦截交互哲学的收尾):保存批量读取期间退出文件夹对比,此前保存流程会继续跑完并在单文件空态界面上突然弹出保存对话框(保存的目录对上下文已消失,名不符实);现以保存世代号(snapshotSaveSeqRef,与扫描会话序号同款模式)作废在途保存——每批读取前与 saveReport 调用前检查,世代号推进即静默终止(finally 复位遮罩幂等兜底);退出/返回时遮罩即时复位,保存按钮防抖(0.10.2)与遮罩不拦截交互(pointer-events:none)的既有设计均不变 - TB 档大小显示(0.10.3 档位延续):fmtSize 补 TB 档——TB 级文件(虚拟机镜像/备份归档)此前显示为 1024.0 GB,与 GB 修复同源的档位逐级补全 - 测试与文档:新增 5 单测(快照截断拦截 1 / 搜索重定位与归零回归锚 2 / 保存中断 1 / fmtSize TB 档修复锚 1,另 TB 断言并入各档换算用例)与 1 条 E2E(手工构造截断快照加载拦截),共 639 单测 + 30 E2E · 依赖 patch 级升级(npm update 语义化范围内,本轮无新版本,零变更) +0.10.5 ← 文件夹顶部布局重构 · 字符粒度统计 · 换目录状态重置 · 跨模式搜索一致(已发布): + - 文件夹顶部布局重构(头部约 210px → 约 50px 单行):操作按钮区此前 6 按钮竖排一列把头部撑高约 200px,最小窗口高度下条目列表区仅剩约 300px——现改为横排单行(重新扫描 / 重新选择 / 快照 / 导出报告 / 返回文件对比),快照两操作(保存/加载)归组进「快照」下拉(沿用导出菜单的弹层语言与点击外部/Esc 关闭,菜单项随 scanning/saving 禁用),路径卡片压缩高度;条目列表可视高度增加约 6 行 + - 字符粒度增删统计(字符级对比增强):DiffSummary 新增 charsAdded/charsDeleted(码点计数,emoji/CJK 扩展不撕裂代理对)——added 行计右侧整行、removed 行计左侧整行、modified 行计词级/字符级 segs 的新增/删除段(行级与字符级路径口径一致,超长行防护 segs 为 null 不计);行级与字符级两路径的 summary 构造收敛为 summarize 单一实现;字符级模式开启时工具栏统计区追加「+N 字 / −M 字」徽章(青色系与行级徽章区分),三格式报告头部统计行与 HTML 统计卡同步追加(仅字符级模式);引擎输出结构变更经滚动稳定性 E2E 回归锚验证无回退 + - 换目录视图状态重置(确定性缺陷修复):重新选择新目录对后,旧搜索词/状态过滤/扩展名过滤/折叠集合/变迁过滤此前全部残留并静默生效(旧目录的折叠路径让新目录同名目录意外折叠、旧关键字继续过滤新列表)——现目录对变化即重置全部视图层状态;同目录重扫(leftDir/rightDir 不变)不触发,保留用户视图 + - 跨模式搜索一致性:Ctrl+F 此前仅单文件模式生效——现文件夹模式聚焦文件名搜索框(App 分流 ref);文件夹搜索框补齐 Esc 清空与失焦(与单文件搜索框同款行为);两个搜索框均有内容时显示 × 一键清空按钮(清空后聚焦回输入框,鼠标流与键盘流双入口) + - 测试与文档:新增 19 单测(diffEngine 字符统计 7 / Toolbar 徽章与清空按钮 4 / FolderView 快照下拉与重置、Esc、ref、清空 5 / report 字符统计行 1 / App 集成 2,另有既有快照用例的下拉两步化改写)与 E2E 字符徽章断言(快照用例同步改写下拉交互),共 658 单测 + 30 E2E ... 0.y.z ← 长期停留,永不进入 1.x ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index f3e4920..4ae3d74 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -45,7 +45,7 @@ vitest.config.ts # Vitest 配置(jsdom + 测试范围含 playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑) e2e/ ├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口/合成拖放) -└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照与大快照专用通道与截断快照拦截/关于弹框/拖拽导入/偏好重启) +└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项含字符粒度统计徽章/导航/导出写盘/文件夹对比含单行头部与快照归组下拉/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照与大快照专用通道与截断快照拦截/关于弹框/拖拽导入/偏好重启) src/shared/ ├── ignoreRules.ts # 忽略规则匹配器纯逻辑(gitignore 简化子集含高级 glob 与段内字符通配——? 单字符/[abc] 字符类;主进程扫描剪枝与渲染端无效提示共用,段模式预编译 + DP 段序列匹配 + 否定仲裁) ├── ignoreRules.test.ts # 忽略规则单元测试(名字/目录/通配回归 + 多段/锚定/跨段/段内通配/否定仲裁/无效清单/上限/IPC 校验 + ? 与字符类专项) @@ -115,6 +115,7 @@ src/renderer/src/ - 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines`、`ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文 - 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);降级组(归一化流超 3 万字符自动降级行级并带 `charModeDowngraded` 标志 / 编辑距离超限快速降级(内部参数 charDiffMaxEdit 注入小阈值毫秒级构造)/ 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志 / 全部组超限整体降级不出现 partial);逐组预算制部分降级组(0.8.0):组总流超预算时预算内组字符级、预算外组行级配对(`charModePartial` 置位、segs 拼接无损、超长行触发词级防护 segs 为 null、行号单调)/ 单组流超上限仅该组行级其余组字符级精确定位 / 单组编辑距离超限仅该组行级(charDiffMaxEdit 注入)/ 行级降级组内行数不等按序配对多余侧 added;预算自适应组(0.9.0):charGroupBudget 函数边界(小输入取下限 / 大输入按总组流一半向上取整 / 上限封顶 100 万 / 非有限输入回退下限)/ 大输入跨行重组 400 组总流 48 万 → 预算 24 万 → 200 组字符级(固定预算仅 83 组,行为放宽固化)/ 总流未超下限两倍时与历史固定预算行为完全一致(回归锚) - 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs +- 字符粒度增删统计(0.10.5):charsAdded/charsDeleted 码点计数——行级 added/removed 整行计数 / modified 按 segs 增删段计数(common 不计)/ 全角与 emoji 按码点(代理对不拆分)/ 字符级模式回映射 segs 同口径 / 超长防护行(segs null)不计 / 完全一致为零 / 行级与字符级路径统计口径一致 - 行切分:结尾换行不产生多余空行 ### 5.2 报告生成(`report.ts`) @@ -124,6 +125,7 @@ src/renderer/src/ - 差异分块(`buildSegments`):空行集 / 无变更单段全展示 / 上下文 3 行与边界收敛 / 相邻区间合并 / 远距分两段省略数正确 / 自定义 context - 人话总结(`plainSummary`):无差异一致结论 / 为 0 项省略 / 三类齐全完整罗列 - 比较选项描述(`plainOptions`)与头部说明行:无选项严格对比文案 / 生效项罗列 / 忽略所有空白遮蔽行首尾空白;TXT / Markdown / HTML 三格式头部含“比较选项”行 +- 字符粒度统计行(0.10.5):字符级模式(ctx.options.charMode)时统计行追加「| 字符 +A / −D」、HTML 追加「字符增删」统计卡;行级模式不追加 - 格式分发与扩展名映射(`REPORT_EXT`) ### 5.3 文本工具(`textUtils.ts`) @@ -152,6 +154,7 @@ src/renderer/src/ - 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项 - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 - 单文件视图搜索(0.9.3):输入关键字即时命中计数与首个命中行 active 定位 · Enter/Shift+Enter 命中行间推进回退(行号随动)· 首尾循环 · 大小写不敏感 · 无命中 0/0 且 Enter 不崩溃不推进 · Esc 清空复位 · Ctrl+F 聚焦搜索框 · 命中行被“仅看差异”折叠时计数仍统计全量行、跳转 toast 提示且不推进 · 切选项/换文件重算后命中计数与 active 高亮行自动钳制重定位保持一致(0.10.4,rows 重建行 id 重新编号不再脱节;命中归零清空高亮) +- 文件夹模式搜索与换目录状态(0.10.5):Ctrl+F 聚焦文件名搜索框并 Esc 清空(跨模式一致)· 重新选择新目录对后旧搜索词不残留(视图状态重置生效) - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 - 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入(含 Windows 异写路径归一化判定,0.10.2) · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式 - 快照基线(0.9.0,0.9.1 收尾,0.9.2 对称防呆,0.10.0 内容级对照):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 当前扫描结果截断时加载快照同样被拦截(0.9.2,不弹文件选择——截断缺失文件会被误报已移除) · 对照模式中重扫触发截断自动退出对照并提示(0.9.2) · 快照语义判等口径与当前开关不一致时加载提示含口径差异、一致时无提示(0.9.2) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新 · 快照内容级对照(0.10.0):保存时差异条目双侧内容入 contents(批量读取按路径返回、same 条目不存、单文件超限侧不读该侧 null)/ 内容总量超限整体降级为纯状态快照并提示(vi.mock 压低上限构造)/ 对照模式双击含基线内容条目进入"快照基线 vs 当前"内容对比(左面板快照基线标注、右面板当前文件、有差异徽章)/ v1 快照双击走常规当前文件对比(兼容回归锚) · 保存读取分批进度遮罩"已读取 N / M"(0.10.1,第二批挂起构造断言,结束复位) · 双击 same 条目 / 对照模式双击 fixed 条目进入对比后差异文件导航隐藏(0.10.1,游标不在差异序列;双击差异条目导航恢复) · 加载走 snapshot:read 专用读取通道(0.10.2,mock 随通道迁移;保存/加载上限不对称修复的通道基础) · 快照自身截断(手工构造形态 truncated: true)同样被拦截且不进入对照(0.10.4,与保存侧/当前侧对称的三侧防呆收官) · 保存批量读取期间退出文件夹对比:在途保存静默终止、saveReport 不被调用、遮罩即时消失(0.10.4,保存世代号作废;正常保存路径回归锚由读取进度用例覆盖) @@ -195,6 +198,9 @@ src/renderer/src/ - 扩展名过滤(0.8.0):下拉展示全量条目 ext→count(字典序、无扩展名单列);点选过滤与其他条件取交集、计数切 N / 总数;多选并集、再点取消、清除按钮复位;与状态过滤双激活空态提示;点击外部关闭且过滤状态保留 - 忽略规则(0.9.0,0.10.0 高级 glob 与无效提示,0.10.1 字符通配文案):已有规则时入口标注数量并高亮;下拉打开以当前规则初始化草稿,逐行解析去空白去空行(截断至 50 条)后应用回调并关闭;清空规则回调空数组;点击外部关闭不触发回调;无效规则实时提示"将被忽略的无效规则"(修正/清空后提示消失,不再静默丢弃);提示文案与 title 含 ? 单字符与 [abc] 字符类新语法 - 快照对照模式(0.9.0):变迁统计徽章(已修复/新增差异/新文件/已移除/仍有差异/保持一致)与基线时间替换常规统计行;对照条目平铺全路径渲染、行尾变迁徽章、gone 回退快照数据;零计数变迁不占位;默认隐藏 stable、点击徽章单选过滤再点取消;双击当前条目进单文件对比、已移除条目双击无效;退出对照恢复常规视图;全 stable 空态提示;重扫刷新后过滤的变迁类型计数归零自动复位(0.9.3,残留过滤不再误显空态);对照条目含基线内容时 title 提示"双击查看快照基线内容对照"(0.10.0) +- 快照归组下拉(0.10.5 布局重构):「快照」入口打开下拉含保存/加载菜单项,菜单项触发回调并关闭;点击外部关闭不触发回调;scanning/saving 时入口禁用(不打开下拉)、其余操作(重扫/重选/导出/返回)不受影响 +- 换目录视图状态重置(0.10.5):目录对变化重置搜索词/状态过滤/扩展名过滤/折叠集合/变迁过滤(同目录重扫保留——区分「重扫」与「换目录」) +- 搜索框(0.10.5):Esc 清空并失焦(与单文件搜索框同款)、有内容时显示 × 清空按钮(点击清空并聚焦回输入框)、searchInputRef 转发(Ctrl+F 聚焦用) - 扫描进度遮罩(0.10.0):progress props 非空时遮罩显示"已比对 N / M",null 时仅基础文案 - 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动) - 扫描中交互防抖(0.9.2):扫描进行中语义判等开关与忽略规则入口禁用(触发重扫的入口,点击不触发回调/不打开下拉);仅看差异开关与搜索仍可用(纯视图过滤不触发重扫);「重新选择」扫描中保持可用(0.9.3 固化:发起新会话作废在途扫描,与「返回文件对比」同类) @@ -208,7 +214,7 @@ src/renderer/src/ - 启动冒烟:标题与双空态面板、空态无导出入口 - 粘贴两侧:差异渲染 + 统计徽章(主进程剪贴板链路) - 编码链路:GBK 与 UTF-8 BOM 文件打开(编码标注 + 内容渲染)、大体积 GBK 文件经解码 worker 后台解码完整渲染(状态栏总行数断言) -- 比较选项:忽略大小写与忽略所有空白组合、字符级跨行重组判一致 +- 比较选项:忽略大小写与忽略所有空白组合、字符级跨行重组判一致、字符级模式字符粒度统计徽章出现(0.10.5,+0 字 / −0 字) - 滚动稳定性回归锚(0.7.0):真实文件(行数不等)→ 开字符级进 worker 重路径 → 滚到中部 → 切选项二次重算 → 断言 scrollTop 保持不归零、滚到底内容连续(0.6.3 回退缺陷的固化防线) - 导航与视图:F7 / Shift+F7、仅看差异折叠、交换左右后面板互换 - 单文件视图搜索(0.9.3):粘贴两侧后 Ctrl+F 聚焦搜索框(真实键盘事件)→ 输入关键字即时计数与行内命中高亮 → Enter 跳转下一命中行(active 行号定位断言)→ Esc 清空复位计数 @@ -220,7 +226,7 @@ src/renderer/src/ - 忽略规则(0.9.0,0.10.0 高级 glob):真实目录含 node_modules 子树——无规则时差异参与对比,输入规则应用重扫后整树剪枝(不同归零、空态一致提示、被忽略条目消失);高级 glob——build 下深层 temp 目录经 build/**/temp 跨段剪枝、*.log 忽略 + !keep.log 否定救回(差异计数精确收敛)、无效规则提示出现 - 扫描进度反馈(0.10.0,0.10.3 采样修稳):4000 条目目录扫描期间遮罩"已比对 N / 4000"轮询抓取(瞬态断言)——evaluate 直读 DOM(避免遮罩消失后 locator 自动重试阻塞采样)+ intervals 前密采样,快机上数百毫秒的瞬态窗口稳定可抓;完成后全量判定与遮罩消失 - 增量缓存跨会话持久化(0.9.0):第一实例扫描后 userData/scan-cache.json 落盘(等待异步写盘,断言含条目与合法结构)→ 同 userData 第二实例重扫同目录结果正确(主进程注入盘上缓存) -- 快照基线(0.9.0,0.9.1 增强,0.10.0 内容级对照):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章);截断快照拦截(0.10.4)——手工构造 truncated: true 快照加载被拦截提示"条目不完整无法对照",不进入对照模式 +- 快照基线(0.9.0,0.9.1 增强,0.10.0 内容级对照,0.10.5 快照归组下拉交互):保存快照真实落盘(JSON 含版本与条目;保存/加载经「快照」下拉两步触发)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章);截断快照拦截(0.10.4)——手工构造 truncated: true 快照加载被拦截提示"条目不完整无法对照",不进入对照模式 - 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s;300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归) - 关于弹框(0.10.2):点击 header ⓘ 打开弹框——版本号(app.getVersion 真实链路,0.x 形态正则)/作者/双邮箱/组织/开源地址/许可证断言;点击邮箱走主进程剪贴板并 toast;Esc 关闭 - 大快照加载(0.10.2):手工构造 11MB+ 快照 JSON(contents 塞大字符串)→ snapshot:read 专用通道加载成功("已加载快照"出现且无 10MB 拦截提示)——固化保存/加载上限不对称修复 diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 8c3b8f1..770600b 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -106,13 +106,16 @@ test.describe('比较选项端到端', () => { await app.close() }) - test('字符级对比:跨行重组判为一致', async () => { + test('字符级对比:跨行重组判为一致,字符粒度统计徽章出现(0.10.5)', async () => { const { app, window } = await launchApp() await pasteText(window, 'left', 'ab cd') await pasteText(window, 'right', 'ab\ncd') await expect(window.getByText('有差异')).toBeVisible() await window.getByLabel('字符级对比').check() await expect(window.getByText('两文件内容完全一致')).toBeVisible() + // 字符级模式追加字符粒度增删徽章(一致场景为 +0 字 / −0 字) + await expect(window.getByText('+0 字')).toBeVisible() + await expect(window.getByText('−0 字')).toBeVisible() await app.close() }) @@ -604,10 +607,12 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => { await expect(window.getByText('相同 1')).toBeVisible() await expect(window.getByText('不同 1')).toBeVisible() - // 保存快照(真实写盘):v2 结构含差异条目双侧内容(0.10.0 内容级对照) + // 保存快照(真实写盘):v2 结构含差异条目双侧内容(0.10.0 内容级对照); + // 快照归组下拉(0.10.5 布局重构):先开「快照」下拉再点菜单项 const snapPath = join(root, 'baseline.json') await stubSaveDialog(app, snapPath) - await window.getByRole('button', { name: '保存快照' }).click() + await window.getByRole('button', { name: '快照' }).click() + await window.getByText('保存快照…').click() await expect(window.getByText(/快照已保存/)).toBeVisible() const snapRaw = await readFile(snapPath, 'utf-8') expect(snapRaw).toContain('"version": 2') @@ -624,7 +629,8 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => { // 加载快照对照:diff.txt 已修复、new.txt 新文件 await stubOpenDialogSeq(app, [[snapPath]]) - await window.getByRole('button', { name: '加载快照' }).click() + await window.getByRole('button', { name: '快照' }).click() + await window.getByText('加载快照…').click() await expect(window.getByText(/已加载快照/)).toBeVisible() await expect(window.getByText('已修复 1')).toBeVisible() await expect(window.getByText('新文件 1')).toBeVisible() @@ -673,7 +679,8 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => { // 保存快照(v2):差异条目双侧内容写入 JSON const snapPath = join(root, 'baseline.json') await stubSaveDialog(app, snapPath) - await window.getByRole('button', { name: '保存快照' }).click() + await window.getByRole('button', { name: '快照' }).click() + await window.getByText('保存快照…').click() await expect(window.getByText(/快照已保存/)).toBeVisible() const snapRaw = await readFile(snapPath, 'utf-8') expect(snapRaw).toContain('"version": 2') @@ -687,7 +694,8 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => { // 加载快照对照 → 双击 diff.txt 进入内容对照(左=快照基线,右=当前左侧文件) await stubOpenDialogSeq(app, [[snapPath]]) - await window.getByRole('button', { name: '加载快照' }).click() + await window.getByRole('button', { name: '快照' }).click() + await window.getByText('加载快照…').click() await expect(window.getByText(/已加载快照/)).toBeVisible() await window.getByText('diff.txt').dblclick() await expect( @@ -734,7 +742,8 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => { await stubOpenDialogSeq(app, [[leftDir], [rightDir], [snapPath]]) await window.getByRole('button', { name: '对比文件夹' }).click() await expect(window.getByText('相同 1')).toBeVisible() - await window.getByRole('button', { name: '加载快照' }).click() + await window.getByRole('button', { name: '快照' }).click() + await window.getByText('加载快照…').click() // 拦截提示出现,不进入对照模式 await expect(window.getByText(/条目不完整无法对照/)).toBeVisible() await expect(window.getByText(/已加载快照/)).toHaveCount(0) @@ -876,7 +885,8 @@ test.describe('大快照加载(0.10.2 专用读取通道)', () => { await stubOpenDialogSeq(app, [[leftDir], [rightDir], [snapPath]]) await window.getByRole('button', { name: '对比文件夹' }).click() await expect(window.getByText('相同 1')).toBeVisible() - await window.getByRole('button', { name: '加载快照' }).click() + await window.getByRole('button', { name: '快照' }).click() + await window.getByText('加载快照…').click() // 专用通道(128MB 上限)加载成功:出现对照 toast,而非"文件超过 10MB"拦截 await expect(window.getByText(/已加载快照/)).toBeVisible() await expect(window.getByText(/文件超过 10MB/)).toHaveCount(0) diff --git a/package.json b/package.json index e4b50c5..c0f839a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.10.4", + "version": "0.10.5", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a1f03d7..94d335b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -135,7 +135,14 @@ function loadPrefs(): Prefs { } /** 计算中/无内容时的空统计(保持状态栏与徽章渲染稳定) */ -const EMPTY_SUMMARY: DiffSummary = { changedLines: 0, inserted: 0, deleted: 0, modified: 0 } +const EMPTY_SUMMARY: DiffSummary = { + changedLines: 0, + inserted: 0, + deleted: 0, + modified: 0, + charsAdded: 0, + charsDeleted: 0 +} /** toast 队列元素 */ interface ToastItem { @@ -238,6 +245,8 @@ export default function App(): ReactElement { const [searchQuery, setSearchQuery] = useState('') const [searchIndex, setSearchIndex] = useState(0) const searchInputRef = useRef(null) + // 文件夹模式的文件名搜索框 ref(Ctrl+F 跨模式聚焦分流用) + const folderSearchRef = 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) @@ -1027,14 +1036,16 @@ export default function App(): ReactElement { [searchHits, searchIndex, displayRows, showToast] ) - // Ctrl+F 聚焦搜索框(单文件模式;粘贴弹窗/右键菜单打开时不抢占焦点) + // 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 + if (pasteSide !== null || menu !== null) return e.preventDefault() - searchInputRef.current?.focus() - searchInputRef.current?.select() + const target = mode === 'folder' ? folderSearchRef.current : searchInputRef.current + target?.focus() + target?.select() } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) @@ -1345,6 +1356,7 @@ export default function App(): ReactElement { total={folderState.total} scanning={folderScanning} saving={snapshotSaving} + searchInputRef={folderSearchRef} progress={scanProgress} onlyDiff={folderOnlyDiff} onOnlyDiffChange={setFolderOnlyDiff} diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 10b9751..4a01ef9 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -1420,6 +1420,63 @@ describe('App - 文件夹对比', () => { }) }) +describe('App - 文件夹模式搜索与换目录状态重置(0.10.5)', () => { + const entries = [ + { rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 }, + { rel: 'b.txt', status: 'different' as const, leftSize: 3, rightSize: 4 }, + { rel: 'c.txt', status: 'left-only' as const, leftSize: 2, rightSize: null } + ] + + it('Ctrl+F 聚焦文件名搜索框并 Esc 清空(跨模式一致)', async () => { + const dirs = ['C:/left-dir', 'D:/right-dir'] + let call = 0 + window.api = mockApi({ + pickFolder: async () => dirs[call++] ?? null, + scanFolder: async () => ({ entries, truncated: false, total: 3 }) + }) + render() + fireEvent.click(screen.getByText('对比文件夹')) + await screen.findByText('b.txt') + // Ctrl+F 聚焦文件夹模式的文件名搜索框 + fireEvent.keyDown(window, { key: 'f', ctrlKey: true }) + const input = screen.getByPlaceholderText(/搜索文件名…/) + expect(document.activeElement).toBe(input) + // Esc 清空复位 + fireEvent.change(input, { target: { value: 'b' } }) + fireEvent.keyDown(input, { key: 'Escape' }) + expect(input).toHaveValue('') + }) + + it('重新选择新目录后旧搜索词不残留(换目录状态重置)', async () => { + const dirs = ['C:/l1', 'D:/r1', 'C:/l2', 'D:/r2'] + let pick = 0 + window.api = mockApi({ + pickFolder: async () => dirs[pick++] ?? null, + scanFolder: async () => { + // 第二轮(重新选择后的新目录对)返回含 x.md 的结果供断言(different 在仅看差异下可见) + return pick >= 3 + ? { entries: [...entries, { rel: 'x.md', status: 'different' as const, leftSize: 1, rightSize: 2 }], truncated: false, total: 4 } + : { entries, truncated: false, total: 3 } + } + }) + render() + fireEvent.click(screen.getByText('对比文件夹')) + await screen.findByText('b.txt') + // 搜索 b:列表收窄到 b.txt + fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: 'b' } }) + expect(screen.getByText('b.txt')).toBeInTheDocument() + // 重新选择新目录对(第三、四次 pick) + fireEvent.click(screen.getByText('重新选择')) + await screen.findByText('x.md') + // 旧搜索词已重置:全部条目可见(含关闭仅看差异后才能看到的 a.txt) + const input = screen.getByPlaceholderText(/搜索文件名…/) as HTMLInputElement + expect(input).toHaveValue('') + fireEvent.click(screen.getByLabelText('仅看差异')) + expect(screen.getByText('a.txt')).toBeInTheDocument() + expect(screen.getByText('x.md')).toBeInTheDocument() + }) +}) + describe('App - 文件夹扫描竞态', () => { it('慢的旧扫描后完成不覆盖新扫描结果(重新选择抢先完成)', async () => { const E1 = [{ rel: 'diff1.txt', status: 'different' as const, leftSize: 1, rightSize: 2 }] @@ -2082,7 +2139,8 @@ describe('App - 快照基线', () => { render() fireEvent.click(screen.getByText('对比文件夹')) await screen.findByText('b.txt') - fireEvent.click(screen.getByRole('button', { name: '保存快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('保存快照…')) expect(await screen.findByText(/快照已保存/)).toBeInTheDocument() expect(savedName).toMatch(/^DiffLens-snapshot-\d{8}-\d{6}\.json$/) const snap = parseSnapshot(saved) @@ -2104,7 +2162,8 @@ describe('App - 快照基线', () => { render() fireEvent.click(screen.getByText('对比文件夹')) await screen.findByText(/文件过多/) - fireEvent.click(screen.getByRole('button', { name: '保存快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('保存快照…')) expect(await screen.findByText(/快照不完整无法对照/)).toBeInTheDocument() expect(saveReport).not.toHaveBeenCalled() expect(screen.queryByText(/快照已保存/)).not.toBeInTheDocument() @@ -2112,7 +2171,8 @@ describe('App - 快照基线', () => { it('加载快照:对照模式渲染变迁统计与标注', async () => { await enterForSnapshot(makeSnapJson()) - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) expect(await screen.findByText(/已加载快照/)).toBeInTheDocument() // 变迁统计徽章:b.txt 快照 same → 当前 different(新增差异)、c.txt 新文件 expect(screen.getByText('新增差异 1')).toBeInTheDocument() @@ -2147,7 +2207,8 @@ describe('App - 快照基线', () => { render() fireEvent.click(screen.getByText('对比文件夹')) await screen.findByText('b.txt') - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) await screen.findByText(/已加载快照/) fireEvent.click(screen.getByRole('button', { name: '导出报告' })) fireEvent.click(screen.getByText('纯文本')) @@ -2161,7 +2222,8 @@ describe('App - 快照基线', () => { it('退出对照恢复常规视图', async () => { await enterForSnapshot(makeSnapJson()) - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) await screen.findByText(/已加载快照/) fireEvent.click(screen.getByRole('button', { name: '退出对照' })) // 恢复常规统计徽章(当前结果:相同 1 / 不同 1 / 仅右 1) @@ -2173,7 +2235,8 @@ describe('App - 快照基线', () => { await enterForSnapshot( makeSnapJson({ leftDir: 'C:/other-left', rightDir: 'D:/right-dir' }) ) - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) expect(await screen.findByText(/快照的对比目录与当前不一致/)).toBeInTheDocument() expect(screen.queryByText(/已加载快照/)).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: '退出对照' })).not.toBeInTheDocument() @@ -2181,7 +2244,8 @@ describe('App - 快照基线', () => { it('快照文件无效(版本不符):拦截提示', async () => { await enterForSnapshot(makeSnapJson({ version: 99 })) - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) expect(await screen.findByText(/快照文件无效或已损坏/)).toBeInTheDocument() }) @@ -2189,7 +2253,8 @@ describe('App - 快照基线', () => { // 自家保存链路在截断时已拦截,truncated: true 仅来自手工构造/外部工具; // 截断快照缺失的文件会被全部误报为"新文件",须与保存侧/当前侧拦截对称 await enterForSnapshot(makeSnapJson({ truncated: true })) - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) expect(await screen.findByText(/条目不完整无法对照/)).toBeInTheDocument() expect(screen.queryByText(/已加载快照/)).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: '退出对照' })).not.toBeInTheDocument() @@ -2200,14 +2265,15 @@ describe('App - 快照基线', () => { window.api = mockApi({ readSnapshot: async () => ({ error: 'read-failed' as const, name: 'snap.json' }) }) - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) expect(await screen.findByText(/快照读取失败,请重试/)).toBeInTheDocument() }) it('未进行文件夹对比时加载快照:拦截提示', async () => { render() // header 无加载快照入口(仅 FolderView 内)——空态下按钮不存在,行为由回调守卫保证 - expect(screen.queryByRole('button', { name: '加载快照' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '快照' })).not.toBeInTheDocument() }) it('对照模式随重扫刷新:重新扫描后变迁按新结果更新', async () => { @@ -2244,7 +2310,8 @@ describe('App - 快照基线', () => { render() fireEvent.click(screen.getByText('对比文件夹')) await screen.findByText('b.txt') - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) await screen.findByText(/已加载快照/) // 快照 b different ↔ 当前 b different → 仍有差异 1 expect(screen.getByText('仍有差异 1')).toBeInTheDocument() @@ -2266,7 +2333,8 @@ describe('App - 快照基线', () => { render() fireEvent.click(screen.getByText('对比文件夹')) await screen.findByText(/文件过多/) - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) expect(await screen.findByText(/当前扫描结果已截断/)).toBeInTheDocument() // 拦截发生在文件选择之前(对称防呆:截断的当前结果会把缺失文件误报为已移除) expect(readSnapshot).not.toHaveBeenCalled() @@ -2276,7 +2344,8 @@ describe('App - 快照基线', () => { it('快照语义判等口径与当前开关不一致:加载提示含口径差异', async () => { // 当前语义判等默认开启;快照保存时为关闭口径 → 提示对照结果含口径差异 await enterForSnapshot(makeSnapJson({ semantic: false })) - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) expect(await screen.findByText(/口径差异/)).toBeInTheDocument() // 口径不一致不拦截对照(基线信息行仍展示快照口径) expect(screen.getByRole('button', { name: '退出对照' })).toBeInTheDocument() @@ -2285,7 +2354,8 @@ describe('App - 快照基线', () => { it('快照语义判等口径与当前一致:无口径差异提示', async () => { // 快照 semantic: true 与当前默认开关一致,提示为常规文案 await enterForSnapshot(makeSnapJson()) - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) expect(await screen.findByText(/已加载快照/)).toBeInTheDocument() expect(screen.queryByText(/口径差异/)).not.toBeInTheDocument() }) @@ -2314,7 +2384,8 @@ describe('App - 快照基线', () => { render() fireEvent.click(screen.getByText('对比文件夹')) await screen.findByText('b.txt') - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) await screen.findByText(/已加载快照/) expect(screen.getByRole('button', { name: '退出对照' })).toBeInTheDocument() // 重扫触发截断:对照自动退出(回到常规视图与截断警示),不残留过期对照 @@ -2347,8 +2418,9 @@ describe('App - 快照内容级对照(v2)', () => { render() fireEvent.click(screen.getByText('对比文件夹')) // 等待扫描完成进入文件夹模式(保存快照按钮出现即列表就绪) - await screen.findByRole('button', { name: '保存快照' }) - fireEvent.click(screen.getByRole('button', { name: '保存快照' })) + await screen.findByRole('button', { name: '快照' }) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('保存快照…')) await screen.findByText(/快照已保存/) } @@ -2467,7 +2539,8 @@ describe('App - 快照内容级对照(v2)', () => { render() fireEvent.click(screen.getByText('对比文件夹')) await screen.findByText('b.txt') - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) await screen.findByText(/已加载快照/) fireEvent.dblClick(screen.getByText('b.txt')) // 左面板:快照基线内容(面板头标注"快照 · b.txt"),右面板:当前左侧文件(跟踪左文件优先) @@ -2519,7 +2592,8 @@ describe('App - 快照内容级对照(v2)', () => { render() fireEvent.click(screen.getByText('对比文件夹')) await screen.findByText('b.txt') - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) await screen.findByText(/已加载快照/) fireEvent.dblClick(screen.getByText('b.txt')) // 两侧面板均为当前文件(无快照基线面板) @@ -2571,8 +2645,9 @@ describe('App - 快照内容级对照(v2)', () => { }) render() fireEvent.click(screen.getByText('对比文件夹')) - await screen.findByRole('button', { name: '加载快照' }) - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + await screen.findByRole('button', { name: '快照' }) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) await screen.findByText(/已加载快照/) // 对照列表:b.txt 为已修复(默认隐藏 stable 的 a.txt) fireEvent.dblClick(screen.getByText('b.txt')) @@ -2619,8 +2694,9 @@ describe('App - 快照内容级对照(v2)', () => { }) render() fireEvent.click(screen.getByText('对比文件夹')) - await screen.findByRole('button', { name: '保存快照' }) - fireEvent.click(screen.getByRole('button', { name: '保存快照' })) + await screen.findByRole('button', { name: '快照' }) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('保存快照…')) // 第一批(200 个文件)完成、第二批挂起:遮罩显示 已读取 200 / 220 expect(await screen.findByText(/已读取 200 \/ 220/)).toBeInTheDocument() await act(async () => { @@ -2669,8 +2745,9 @@ describe('App - 快照内容级对照(v2)', () => { }) render() fireEvent.click(screen.getByText('对比文件夹')) - await screen.findByRole('button', { name: '保存快照' }) - fireEvent.click(screen.getByRole('button', { name: '保存快照' })) + await screen.findByRole('button', { name: '快照' }) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('保存快照…')) // 第一批(200 个文件)完成、第二批挂起:遮罩显示读取进度 expect(await screen.findByText(/已读取 200 \/ 220/)).toBeInTheDocument() // 退出文件夹对比:遮罩即时消失、回到单文件空态 diff --git a/src/renderer/src/components/FolderView.test.tsx b/src/renderer/src/components/FolderView.test.tsx index 4fb2ab2..856bc7e 100644 --- a/src/renderer/src/components/FolderView.test.tsx +++ b/src/renderer/src/components/FolderView.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 FolderView, { FOLDER_ROW_HEIGHT } from './FolderView' import type { FolderEntry } from '../../../preload/index' import { diffSnapshot, type FolderSnapshot } from '../diff/snapshotDiff' @@ -58,8 +59,11 @@ describe('FolderView - 树形渲染', () => { expect(screen.getAllByText('D:/right').length).toBeGreaterThan(0) expect(screen.getByRole('button', { name: '重新选择' })).toBeInTheDocument() expect(screen.getByRole('button', { name: '返回文件对比' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: '保存快照' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: '加载快照' })).toBeInTheDocument() + // 快照归组下拉(0.10.5 布局重构):入口常驻,菜单项打开后可见 + expect(screen.getByRole('button', { name: '快照' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '快照' })) + expect(screen.getByText('保存快照…')).toBeInTheDocument() + expect(screen.getByText('加载快照…')).toBeInTheDocument() }) it('目录聚合为树形:根层文件与目录混排(字典序),目录行显示聚合状态与计数', () => { @@ -185,7 +189,7 @@ describe('FolderView - 统计徽章与过滤', () => { describe('FolderView - 搜索', () => { it('输入关键字过滤文件并强制展开命中路径', () => { renderView(ENTRIES, false) - fireEvent.change(screen.getByPlaceholderText('搜索文件名…'), { target: { value: 'diff.ts' } }) + fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: 'diff.ts' } }) expect(screen.getByText('diff.ts')).toBeInTheDocument() expect(screen.getByText('sub')).toBeInTheDocument() expect(screen.getByText('deep')).toBeInTheDocument() @@ -194,7 +198,7 @@ describe('FolderView - 搜索', () => { it('搜索不区分大小写且匹配目录名中段', () => { renderView(ENTRIES, false) - fireEvent.change(screen.getByPlaceholderText('搜索文件名…'), { target: { value: 'DEEP' } }) + fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: 'DEEP' } }) expect(screen.getByText('diff.ts')).toBeInTheDocument() expect(screen.queryByText('a.txt')).not.toBeInTheDocument() }) @@ -204,19 +208,74 @@ describe('FolderView - 搜索', () => { fireEvent.click(screen.getByText('sub')) expect(screen.queryByText('diff.ts')).not.toBeInTheDocument() // 搜索强制展开 - fireEvent.change(screen.getByPlaceholderText('搜索文件名…'), { target: { value: 'diff' } }) + fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: 'diff' } }) expect(screen.getByText('diff.ts')).toBeInTheDocument() // 清空搜索恢复折叠 - fireEvent.change(screen.getByPlaceholderText('搜索文件名…'), { target: { value: '' } }) + fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: '' } }) expect(screen.queryByText('diff.ts')).not.toBeInTheDocument() expect(screen.getByText('sub')).toBeInTheDocument() }) it('无命中给出提示', () => { renderView(ENTRIES, false) - fireEvent.change(screen.getByPlaceholderText('搜索文件名…'), { target: { value: 'zzz' } }) + fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: 'zzz' } }) expect(screen.getByText('没有匹配“zzz”的文件')).toBeInTheDocument() }) + + it('Esc 清空搜索关键字并失焦(与单文件搜索框行为一致,0.10.5)', () => { + renderView(ENTRIES, false) + const input = screen.getByPlaceholderText(/搜索文件名…/) + fireEvent.change(input, { target: { value: 'diff' } }) + expect(screen.getByText('diff.ts')).toBeInTheDocument() + input.focus() + fireEvent.keyDown(input, { key: 'Escape' }) + expect(input).toHaveValue('') + expect(screen.getByText('a.txt')).toBeInTheDocument() + expect(document.activeElement).not.toBe(input) + }) + + it('有内容时显示清空按钮,点击清空并保持焦点(0.10.5)', () => { + renderView(ENTRIES, false) + fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: 'diff' } }) + expect(screen.getByTitle('清空搜索(Esc)')).toBeInTheDocument() + fireEvent.click(screen.getByTitle('清空搜索(Esc)')) + const input = screen.getByPlaceholderText(/搜索文件名…/) as HTMLInputElement + expect(input).toHaveValue('') + expect(screen.getByText('a.txt')).toBeInTheDocument() + }) + + it('searchInputRef 转发到搜索框(Ctrl+F 聚焦用,0.10.5)', () => { + const ref = createRef() + render() + expect(ref.current).toBe(screen.getByPlaceholderText(/搜索文件名…/)) + }) + + it('目录对变化重置搜索词/状态过滤/扩展名过滤/折叠集合;同目录重扫保留(0.10.5)', () => { + // 建立残留状态:搜索词 + 折叠 sub + 状态过滤 + const { rerender } = renderView(ENTRIES, false) + fireEvent.click(screen.getByText('sub')) + expect(screen.queryByText('diff.ts')).not.toBeInTheDocument() + fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: 'diff' } }) + // 搜索强制展开命中路径:diff.ts 可见 + expect(screen.getByText('diff.ts')).toBeInTheDocument() + // 同目录重扫(leftDir/rightDir 不变,entries 变化):状态保留(diff.ts 仍受搜索过滤) + rerender( + + ) + expect(screen.getByText('diff.ts')).toBeInTheDocument() + // 重新选择新目录对:搜索/折叠全部重置(无搜索过滤、目录默认展开) + rerender( + + ) + const input = screen.getByPlaceholderText(/搜索文件名…/) as HTMLInputElement + expect(input).toHaveValue('') + expect(screen.getByText('a.txt')).toBeInTheDocument() + expect(screen.getByText('only-right.txt')).toBeInTheDocument() + }) }) describe('FolderView - 扩展名过滤', () => { @@ -537,9 +596,8 @@ describe('FolderView - 其他', () => { expect(onRescan).toHaveBeenCalledTimes(1) rerender() expect(screen.getByRole('button', { name: '重新扫描' })).toBeDisabled() - // 快照按钮扫描中同样禁用 - expect(screen.getByRole('button', { name: '保存快照' })).toBeDisabled() - expect(screen.getByRole('button', { name: '加载快照' })).toBeDisabled() + // 快照下拉入口扫描中禁用(入口即拦截,菜单不再打开) + expect(screen.getByRole('button', { name: '快照' })).toBeDisabled() }) it('扫描中重新选择保持可用(发起新会话并作废在途扫描,0.9.3 固化)', () => { @@ -554,19 +612,32 @@ describe('FolderView - 其他', () => { expect(onReopen).toHaveBeenCalledTimes(2) }) - it('保存/加载快照按钮触发回调', () => { + it('快照下拉菜单项触发保存/加载回调并关闭菜单(0.10.5 归组下拉)', () => { const onSaveSnapshot = vi.fn() const onLoadSnapshot = vi.fn() render( ) - fireEvent.click(screen.getByRole('button', { name: '保存快照' })) + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('保存快照…')) expect(onSaveSnapshot).toHaveBeenCalledTimes(1) - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + expect(screen.queryByText('保存快照…')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '快照' })) + fireEvent.click(screen.getByText('加载快照…')) expect(onLoadSnapshot).toHaveBeenCalledTimes(1) }) - it('快照保存中禁用保存/加载快照按钮,其余操作不受影响(0.10.2 保存遮罩不拦截交互的按钮级防抖)', () => { + it('点击外部区域关闭快照下拉(不触发回调)', () => { + const onSaveSnapshot = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: '快照' })) + expect(screen.getByText('保存快照…')).toBeInTheDocument() + fireEvent.mouseDown(document.body) + expect(screen.queryByText('保存快照…')).not.toBeInTheDocument() + expect(onSaveSnapshot).not.toHaveBeenCalled() + }) + + it('快照保存中禁用快照入口与菜单项,其余操作不受影响(0.10.2 按钮级防抖的归组形态)', () => { const onSaveSnapshot = vi.fn() const onLoadSnapshot = vi.fn() render( @@ -574,12 +645,10 @@ describe('FolderView - 其他', () => { {...fullProps(ENTRIES, { saving: true, onSaveSnapshot, onLoadSnapshot })} /> ) - expect(screen.getByRole('button', { name: '保存快照' })).toBeDisabled() - expect(screen.getByRole('button', { name: '加载快照' })).toBeDisabled() - fireEvent.click(screen.getByRole('button', { name: '保存快照' })) - fireEvent.click(screen.getByRole('button', { name: '加载快照' })) - expect(onSaveSnapshot).not.toHaveBeenCalled() - expect(onLoadSnapshot).not.toHaveBeenCalled() + // 入口禁用(点击不打开下拉) + expect(screen.getByRole('button', { name: '快照' })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: '快照' })) + expect(screen.queryByText('保存快照…')).not.toBeInTheDocument() // 保存中仅是快照链路防抖:重新扫描/重新选择/导出/返回不受影响 expect(screen.getByRole('button', { name: '重新扫描' })).toBeEnabled() expect(screen.getByRole('button', { name: '重新选择' })).toBeEnabled() @@ -649,7 +718,7 @@ describe('FolderView - 其他', () => { ) expect(screen.getByLabelText('仅看差异')).not.toBeDisabled() - const search = screen.getByPlaceholderText('搜索文件名…') + const search = screen.getByPlaceholderText(/搜索文件名…/) expect(search).not.toBeDisabled() fireEvent.click(screen.getByLabelText('仅看差异')) expect(onOnlyDiffChange).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/components/FolderView.tsx b/src/renderer/src/components/FolderView.tsx index 6cf0164..d90c21c 100644 --- a/src/renderer/src/components/FolderView.tsx +++ b/src/renderer/src/components/FolderView.tsx @@ -5,7 +5,8 @@ import { useMemo, useRef, useState, - type ReactElement + type ReactElement, + type RefObject } from 'react' import type { FolderEntry, FolderEntryStatus } from '../../../preload/index' import { type ReportFormat } from '../diff/report' @@ -34,6 +35,8 @@ export interface FolderViewProps { scanning: boolean /** 快照保存中(批量读取差异条目内容期间):保存/加载快照入口禁用(保存遮罩不拦截交互,按钮级防抖) */ saving?: boolean + /** Ctrl+F 聚焦用:文件名搜索输入框 ref(App 持有并转发到 input;缺省时内部自持供清空按钮聚焦) */ + searchInputRef?: RefObject /** 比对进度(主进程推送;null = 尚无进度态,仅扫描中遮罩展示) */ progress?: { done: number; total: number } | null /** 仅看差异(隐藏两侧一致的文件);与状态过滤互斥(后者开启时本开关自动关闭) */ @@ -113,6 +116,7 @@ export default function FolderView({ total, scanning, saving = false, + searchInputRef, progress, onlyDiff, onOnlyDiffChange, @@ -138,6 +142,16 @@ export default function FolderView({ const closeExport = useCallback((): void => setExportOpen(false), []) useDismiss(exportWrapRef, closeExport) + // 快照下拉(保存/加载归组;点击外部 / Esc 关闭) + const [snapOpen, setSnapOpen] = useState(false) + const snapWrapRef = useRef(null) + const closeSnap = useCallback((): void => setSnapOpen(false), []) + useDismiss(snapWrapRef, closeSnap) + + // 内部搜索框 ref(清空按钮点击后聚焦用;外部传入 searchInputRef 时共用同一个) + const innerSearchRef = useRef(null) + const folderSearchRef = searchInputRef ?? innerSearchRef + // 搜索(相对路径子串,不区分大小写;对照模式同样生效) const [query, setQuery] = useState('') // 状态精确过滤(null = 不过滤;与 onlyDiff 互斥) @@ -171,6 +185,18 @@ export default function FolderView({ // 快照对照模式的变迁类型过滤(null = 不过滤;单选 toggle) const [changeFilter, setChangeFilter] = useState(null) + // 目录对变化(重新选择新目录对):重置全部视图层状态——旧搜索词 / 状态过滤 / + // 扩展名过滤 / 折叠集合 / 变迁过滤对新目录无意义,残留会静默生效(旧目录的 + // 折叠路径会让新目录同名目录意外折叠、旧关键字继续过滤新列表)。 + // 同目录重扫(leftDir/rightDir 不变)不触发,保留用户视图状态。 + useEffect(() => { + setQuery('') + setStatusFilter(null) + setExtFilter(null) + setCollapsedDirs(new Set()) + setChangeFilter(null) + }, [leftDir, rightDir]) + /** 应用忽略规则草稿:逐行解析、去空白、去空行,截断至上限后回调重扫 */ const applyIgnoreDraft = useCallback((): void => { const rules = ignoreDraft @@ -322,7 +348,7 @@ export default function FolderView({ return (
- {/* 两侧文件夹卡片 */} + {/* 两侧文件夹卡片 + 横排操作区(快照归组下拉;单行头部把高度留给条目列表) */}
@@ -341,34 +367,6 @@ export default function FolderView({ > 重新扫描 - - {/* 重新选择保持可用(与“返回文件对比”同类:发起新会话并作废在途扫描, 竞态守卫保证迟到的旧结果被丢弃;大目录扫描慢时允许用户放弃改选) */} +
+ + {snapOpen && ( +
+ + +
+ )} +
+ )} +
@@ -556,14 +627,37 @@ export default function FolderView({
)}
- setQuery(e.target.value)} - /> +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + if (query !== '') e.preventDefault() + setQuery('') + e.currentTarget.blur() + } + }} + /> + {query.trim() !== '' && ( + + )} +
- 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) - } - }} - /> +
+ 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() !== '' && ( + + )} +
{searchQuery.trim() === '' ? '' : `${searchCount > 0 ? searchIndex + 1 : 0} / ${searchCount}`} @@ -279,6 +293,22 @@ export default function Toolbar({ ~{summary.modified} + {options.charMode && ( + <> + + +{summary.charsAdded} 字 + + + −{summary.charsDeleted} 字 + + + )}
) diff --git a/src/renderer/src/diff/diffEngine.test.ts b/src/renderer/src/diff/diffEngine.test.ts index 8a82c77..82ac482 100644 --- a/src/renderer/src/diff/diffEngine.test.ts +++ b/src/renderer/src/diff/diffEngine.test.ts @@ -905,4 +905,60 @@ describe('computeDiff - 预算自适应行为固化', () => { result.rows.filter((r) => r.rowKind === 'modified' && r.left.segs === null) ).toHaveLength(1) }) +}) + +describe('computeDiff - 字符粒度增删统计(0.10.5)', () => { + it('行级 added / removed 行按整行文本码点计数', () => { + // 新增行 '新增行'(3 码点)+ 删除行 'gone'(4 码点) + const { summary } = computeDiff('gone\nkeep', 'keep\n新增行') + expect(summary.inserted).toBe(1) + expect(summary.charsAdded).toBe(3) + expect(summary.deleted).toBe(1) + expect(summary.charsDeleted).toBe(4) + }) + + it('modified 行按 segs 的增删段计数,common 段不计', () => { + // 'hello world' → 'hello moon':world(5) 删除、moon(4) 新增 + const { summary } = computeDiff('hello world', 'hello moon') + expect(summary.modified).toBe(1) + expect(summary.charsAdded).toBe(4) + expect(summary.charsDeleted).toBe(5) + }) + + it('全角与 emoji 按码点计数(代理对不拆分)', () => { + // '中文' → '中😀':文(1 码点)删除、😀(1 码点)新增;UTF-16 长度下 😀 为 2 + const { summary } = computeDiff('中文', '中😀') + expect(summary.charsAdded).toBe(1) + expect(summary.charsDeleted).toBe(1) + }) + + it('字符级模式:modified 行的字符高亮段计入统计(跨行重组)', () => { + // 组内回映射:'abcXYZdef' → 'abcdef' 删除 XYZ(3 码点) + const { summary } = computeDiff('abcXYZdef', 'abcdef', { charMode: true }) + expect(summary.modified).toBe(1) + expect(summary.charsDeleted).toBe(3) + expect(summary.charsAdded).toBe(0) + }) + + it('超长行防护(segs 为 null)的 modified 行不计入字符统计', () => { + const { summary } = computeDiff('a'.repeat(5001), 'b'.repeat(5001)) + expect(summary.modified).toBe(1) + expect(summary.charsAdded).toBe(0) + expect(summary.charsDeleted).toBe(0) + }) + + it('内容完全一致时字符统计为零', () => { + const { summary } = computeDiff('same\nsame2', 'same\nsame2', { charMode: true }) + expect(summary.charsAdded).toBe(0) + expect(summary.charsDeleted).toBe(0) + }) + + it('行级与字符级路径的统计口径一致(同输入同统计)', () => { + const left = 'a\nbb\nccc' + const right = 'a\nxx\nccc\ndddd' + const lineLevel = computeDiff(left, right) + const charMode = computeDiff(left, right, { charMode: true }) + expect(charMode.summary.charsAdded).toBe(lineLevel.summary.charsAdded) + expect(charMode.summary.charsDeleted).toBe(lineLevel.summary.charsDeleted) + }) }) \ No newline at end of file diff --git a/src/renderer/src/diff/diffEngine.ts b/src/renderer/src/diff/diffEngine.ts index a6895d4..45bf1c9 100644 --- a/src/renderer/src/diff/diffEngine.ts +++ b/src/renderer/src/diff/diffEngine.ts @@ -32,6 +32,14 @@ export interface DiffSummary { inserted: number deleted: number modified: number + /** + * 字符粒度的新增量(码点数):added 行计右侧整行文本、modified 行计 segs 的新增段。 + * 超长行防护(segs 为 null)的 modified 行无法细分,不计入。码点计数避免 + * emoji / CJK 扩展字符的代理对被拆成 2 个"字符"。 + */ + charsAdded: number + /** 字符粒度的删除量(码点数):removed 行计左侧整行文本、modified 行计 segs 的删除段 */ + charsDeleted: number } export interface DiffOptions { @@ -76,6 +84,44 @@ function emptyLine(): SideCell { return { lineNo: null, text: null, segs: null } } +/** + * 行级 + 字符粒度的差异统计(行级与字符级路径共用)。 + * 字符粒度按码点计数([...text].length):added 行计右侧整行、removed 行计左侧整行、 + * modified 行计 segs 的新增/删除段(行级词级与字符级回映射的 segs 结构一致,通用); + * 超长行防护(segs 为 null)的 modified 行无法细分,不计入字符统计。 + */ +function summarize(rows: DiffRow[]): DiffSummary { + let changedLines = 0 + let inserted = 0 + let deleted = 0 + let modified = 0 + let charsAdded = 0 + let charsDeleted = 0 + for (const r of rows) { + if (r.isChanged) changedLines++ + if (r.rowKind === 'added') { + inserted++ + if (r.right.text !== null) charsAdded += [...r.right.text].length + } else if (r.rowKind === 'removed') { + deleted++ + if (r.left.text !== null) charsDeleted += [...r.left.text].length + } else if (r.rowKind === 'modified') { + modified++ + if (r.right.segs !== null) { + for (const s of r.right.segs) { + if (s.kind === 'insert') charsAdded += [...s.text].length + } + } + if (r.left.segs !== null) { + for (const s of r.left.segs) { + if (s.kind === 'delete') charsDeleted += [...s.text].length + } + } + } + } + return { changedLines, inserted, deleted, modified, charsAdded, charsDeleted } +} + /** 带原始行号的行(忽略空行模式下空行被剔除,行号保留原值以维持定位) */ interface NumberedLine { text: string @@ -286,14 +332,7 @@ function computeLineDiff( } if (queuedRemoved.length > 0) flushRemoved() - const summary: DiffSummary = { - changedLines: rows.filter((r) => r.isChanged).length, - inserted: rows.filter((r) => r.rowKind === 'added').length, - deleted: rows.filter((r) => r.rowKind === 'removed').length, - modified: rows.filter((r) => r.rowKind === 'modified').length - } - - return { rows, summary } + return { rows, summary: summarize(rows) } } /** ============ 字符级对比 ============ */ @@ -563,16 +602,9 @@ function computeCharDiff( } } - const summary: DiffSummary = { - changedLines: rows.filter((r) => r.isChanged).length, - inserted: rows.filter((r) => r.rowKind === 'added').length, - deleted: rows.filter((r) => r.rowKind === 'removed').length, - modified: rows.filter((r) => r.rowKind === 'modified').length - } - return { rows, - summary, + summary: summarize(rows), // 存在行级降级组且也有字符级组:部分降级(供界面与报告提示) ...(lineLevelGroups.size > 0 ? { charModePartial: true } : {}) } diff --git a/src/renderer/src/diff/report.test.ts b/src/renderer/src/diff/report.test.ts index 58ca66f..dbf488b 100644 --- a/src/renderer/src/diff/report.test.ts +++ b/src/renderer/src/diff/report.test.ts @@ -82,21 +82,21 @@ describe('buildSegments - 差异分块', () => { describe('plainSummary - 人话总结', () => { it('无差异时给出一致结论', () => { - expect(plainSummary({ changedLines: 0, inserted: 0, deleted: 0, modified: 0 })).toBe( - '两文件内容完全一致' - ) + expect( + plainSummary({ changedLines: 0, inserted: 0, deleted: 0, modified: 0, charsAdded: 0, charsDeleted: 0 }) + ).toBe('两文件内容完全一致') }) it('为 0 的项被省略', () => { - expect(plainSummary({ changedLines: 2, inserted: 2, deleted: 0, modified: 0 })).toBe( - '共 2 处不同:新增 2 行' - ) + expect( + plainSummary({ changedLines: 2, inserted: 2, deleted: 0, modified: 0, charsAdded: 5, charsDeleted: 0 }) + ).toBe('共 2 处不同:新增 2 行') }) it('三类变更齐全时完整罗列', () => { - expect(plainSummary({ changedLines: 9, inserted: 4, deleted: 3, modified: 2 })).toBe( - '共 9 处不同:新增 4 行、删除 3 行、修改 2 行' - ) + expect( + plainSummary({ changedLines: 9, inserted: 4, deleted: 3, modified: 2, charsAdded: 10, charsDeleted: 8 }) + ).toBe('共 9 处不同:新增 4 行、删除 3 行、修改 2 行') }) }) @@ -246,6 +246,26 @@ describe('buildTxtReport', () => { expect(txt).toContain('结论:共 1 处不同:新增 1 行') }) + it('字符级模式的统计行追加字符粒度统计;行级模式不追加(0.10.5)', () => { + // 引擎用行级路径构造(词级 world↔moon:+4/−5 码点),报告层按 ctx.options.charMode 决定是否展示 + const { rows, summary } = computeDiff('hello world', 'hello moon') + expect(summary.charsAdded).toBe(4) + expect(summary.charsDeleted).toBe(5) + const txt = buildTxtReport(rows, { ...ctx, options: { charMode: true } }, summary) + expect(txt).toContain('变更统计:新增 0 | 删除 0 | 修改 1 | 字符 +4 / −5') + // HTML 统计卡同步追加 + const html = buildHtmlReport(rows, { ...ctx, options: { charMode: true } }, summary) + expect(html).toContain('字符增删') + expect(html).toContain('+4 / −5') + // Markdown 头部引用块同步 + const md = buildMarkdownReport(rows, { ...ctx, options: { charMode: true } }, summary) + expect(md).toContain('> 变更统计:新增 0 | 删除 0 | 修改 1 | 字符 +4 / −5') + // 行级模式(未传 options)不含字符统计 + const plain = buildTxtReport(rows, ctx, summary) + expect(plain).toContain('变更统计:新增 0 | 删除 0 | 修改 1\n') + expect(plain).not.toContain('字符 +') + }) + it('行号按最大位数右对齐填充', () => { const left = Array.from({ length: 12 }, (_, i) => `l${i}`).join('\n') const right = left.replace('l1', 'changed') diff --git a/src/renderer/src/diff/report.ts b/src/renderer/src/diff/report.ts index 91bf515..8abbccb 100644 --- a/src/renderer/src/diff/report.ts +++ b/src/renderer/src/diff/report.ts @@ -160,7 +160,11 @@ const headLines = ( `对比时间:${new Date().toLocaleString('zh-CN')}`, `比较选项:${plainOptions(ctx.options, { charModePartial: ctx.charModePartial })}`, `结论:${plainSummary(summary)}`, - `变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}` + `变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}` + + // 字符级模式追加字符粒度统计(码点数;超长防护行无法细分不计入) + (ctx.options?.charMode + ? ` | 字符 +${summary.charsAdded} / −${summary.charsDeleted}` + : '') ] /** ============ HTML 报告 ============ */ @@ -503,6 +507,11 @@ export function buildHtmlReport( ${statCard('mod', '修改行', `~${summary.modified}`)} ${statCard('same', '未更改行', unchanged)} ${statCard('rate', '行变更率', rate)} + ${ + ctx.options?.charMode + ? statCard('rate', '字符增删', `+${summary.charsAdded} / −${summary.charsDeleted}`) + : '' + }
+ 新增行 diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 0392ad1..86ddd2e 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -1056,9 +1056,9 @@ button.about-v:hover { .folder-head { flex: 0 0 auto; display: flex; - align-items: stretch; + align-items: center; gap: 10px; - padding: 10px 18px 6px; + padding: 8px 18px 6px; } .folder-card { @@ -1067,7 +1067,7 @@ button.about-v:hover { display: flex; align-items: center; gap: 8px; - padding: 8px 12px; + padding: 5px 12px; border-radius: 10px; background: var(--surface); border: 1px solid var(--border); @@ -1082,11 +1082,13 @@ button.about-v:hover { white-space: nowrap; } +/* 操作区横排(0.10.5 布局重构:此前 6 按钮竖排把头部撑高约 200px, + 小窗口下列表区被严重压缩;快照归组下拉后横排单行约 40px) */ .folder-actions { flex: none; display: flex; - flex-direction: column; - gap: 6px; + align-items: center; + gap: 8px; justify-content: center; } @@ -1167,7 +1169,7 @@ button.fstat.active { /* 输入框与文件夹搜索同款视觉语言 */ .diff-search { width: 170px; - padding: 5px 12px; + padding: 5px 24px 5px 12px; border-radius: 8px; font-size: 12px; font-family: var(--mono); @@ -1184,6 +1186,44 @@ button.fstat.active { color: var(--muted); } +/* ============ 搜索框清空按钮(两个搜索框共用) ============ */ +/* 包一层相对定位容器;有内容时显示 × 一键清空(与 Esc 清空同语义的鼠标流入口) */ +.search-box { + position: relative; + display: inline-flex; + align-items: center; +} +.search-box .diff-search, +.search-box .folder-search { + padding-right: 24px; +} +.search-clear { + position: absolute; + right: 4px; + width: 16px; + height: 16px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 12px; + line-height: 1; + color: var(--muted); + background: rgba(148, 163, 255, 0.12); + transition: all 0.13s ease; +} +.search-clear:hover { + color: var(--text); + background: var(--surface-hover); +} + +/* 字符粒度增删统计徽章(字符级模式追加,青色系与行级徽章区分) */ +.badge.chars { + color: var(--accent-2); + background: rgba(34, 211, 238, 0.10); + border: 1px solid rgba(34, 211, 238, 0.22); + font-weight: 600; +} + /* 命中计数(x / y,等宽数字;未搜索时为空不占宽) */ .search-count { font-size: 12px;