diff --git a/README.md b/README.md index e0300a7..2e61b27 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 -- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 不同 / 仅左 / 仅右四种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、统计徽章与仅看差异过滤;双击任一文件进入单文件对比,一键返回文件夹列表 +- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑)批量审查,一键返回文件夹列表 +- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成 +- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览) - **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅 - **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应 - **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径 @@ -20,8 +22,8 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **交换左右侧**:一键互换两侧内容与视角 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景 -- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;内容超 3 万字符或差异过大时自动降级行级对比并提示(编辑距离封顶,最坏约 2 秒出结果,不会卡死) -- **选项联动透明**:开启字符级对比时,被包含的空白选项灰显勾选并标注“已包含”(点击有解释提示,关闭字符级即恢复独立设置);内容或差异过大自动降级时开关旁标注“已降级”,状态一目了然 +- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;**相同内容自动锚定跳过计算(行锚定 + 变更组分块),大文件不再整篇受限**;差异过大时自动降级行级对比并提示(编辑距离封顶,不会卡死);重算期间保留旧结果撑住滚动位置,**切换选项不再回弹到顶部** +- **选项联动透明**:开启字符级对比时,被包含的空白选项灰显勾选并标注“已包含”(点击有解释提示,关闭字符级即恢复独立设置);差异过大自动降级时开关旁标注“已降级”,状态一目了然 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 - **偏好记忆**:比较选项与"仅看差异"开关自动保存,窗口尺寸/位置与上次打开文件目录一并记忆,下次启动自动恢复(损坏数据自动回退默认) - **防误触清空**:清空前二次确认(提示窗口内再次点击才执行),粘贴输入的手动文本不因误点丢失 @@ -29,7 +31,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 - **差异统计徽章**:实时统计新增 / 删除 / 修改行数 -- **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式(始终包含全量行)。HTML 报告为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(点击展开零丢失),并支持打印与窄屏 +- **导出一键报告**:单文件与文件夹对比均可导出 HTML / 纯文本 / Markdown 三种格式。单文件报告始终包含全量行,HTML 为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(点击展开零丢失),并支持打印与窄屏;文件夹报告含判定口径说明与全量条目清单 - **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本 - **滚动联动**:左右两个面板滚动位置自动同步 @@ -108,7 +110,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主 - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.6.2 +- 版本:0.7.0 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 2acddb9..dc9c787 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.6.2`** +当前基线版本:**`0.7.0`** --- @@ -81,7 +81,16 @@ z = 补丁版本号(Patch) - Playwright E2E 测试体系建立:对 build 产物启动真实 Electron 验收(复用项目自带 Electron,无需下载浏览器) · 首批 11 用例覆盖 jsdom 无法触达的主进程真实链路(GBK/BOM/大文件 worker 解码、报告写盘、剪贴板、偏好跨实例重启、文件夹真实扫描) · 主进程 dialog stub(showOpenDialog/showSaveDialog 可自动化) · 用例间清空 localStorage 隔离偏好污染 · npm run test:e2e 一键构建+验收,纳入发布 checklist - 文件夹对比(MVP):新增「对比文件夹」入口(依次选择两侧目录) · 递归扫描按相对路径对齐,文件级状态判定(相同/不同/仅左/仅右;大小不同即不同,大小一致做字节级全量比对,超 10MB 采样头部 8KB 近似判定并标注 ≈) · symlink 跳过防环,单侧文件数上限 10000(超限截断提示) · FolderView 虚拟滚动列表(统计徽章/仅看差异默认开/大小列示) · 双击条目进入单文件对比复用全部 diff 能力,一键返回文件夹列表(扫描结果保留) · file:open 与 file:read-by-path 共用同一读取管线(10MB 上限/编码探测/二进制预警一致) 0.6.1 ← 文件夹对比收尾打磨(已发布):文件夹扫描内容比对受控并发化(大小一致条目按 16 并发分批读取比对,批内 Promise.all 批间串行,大目录不再逐文件串行等 IO,输出与失败语义和串行实现完全一致) · 「返回文件对比」清空两侧面板(修复先有文件对比再进文件夹退出后残留旧对比,对齐文档的单文件空态) · 两次选择同一文件夹防呆提示(不进入扫描) · ScanResult.total 注释修正(真实枚举总数,原注释误写为上限值) · README 版本号补同步 0.6.0 遗漏(元信息维护表要求随版本同步) -0.6.2 ← 文件夹对比稳健性与性能修复(当前):双击文件夹条目任一侧读取失败时清空该侧面板(修复失败侧残留旧对比内容与旧文件名组成“名不符实”对比的缺陷,readByPath 异常/空返回/error 三条失败路径全覆盖) · 文件夹枚举改手写 BFS 遍历凑满即停(替换 readdir recursive 无法提前终止的实现,误选超大目录如 node_modules 不再枚举耗时数秒;截断时 total 为已遍历下限,界面提示改“文件过多(N+)”) +0.6.2 ← 文件夹对比稳健性与性能修复(已发布):双击文件夹条目任一侧读取失败时清空该侧面板(修复失败侧残留旧对比内容与旧文件名组成“名不符实”对比的缺陷,readByPath 异常/空返回/error 三条失败路径全覆盖) · 文件夹枚举改手写 BFS 遍历凑满即停(替换 readdir recursive 无法提前终止的实现,误选超大目录如 node_modules 不再枚举耗时数秒;截断时 total 为已遍历下限,界面提示改“文件过多(N+)”) +0.7.0 ← 文件夹对比增强 · 字符级分块与滚动回弹修复(当前): + - 语义判等:字节级判“不同”的文本文件(≤2MB)二次判等——剔除全部空白(换行符 CRLF/LF、缩进、空行、排版重排)后一致计为“语义同”(大小写敏感,大小写差异仍为实质差异),消除换行符误报;解码复用解码 worker(大缓冲后台解码不阻塞),与文件打开共用同一管线;开关默认开启并持久化,切换即重扫;语义同不计入差异(仅看差异过滤),双击仍可查看明细 + - 子目录树形展示:条目按路径聚合为目录树(folderTree 纯逻辑模块:建树/状态聚合/扁平化/过滤),目录行展示子树聚合状态(different > unreadable > only > 语义同 > 相同 优先级)与文件计数,点击折叠/展开(默认全展开);树形扁平化行数组保持虚拟滚动;过滤/搜索后无文件的目录自动剪枝 + - 搜索与状态过滤:文件名搜索框(相对路径子串、不区分大小写,命中路径强制展开);统计徽章可点击作状态精确过滤(可多选,与“仅看差异”互斥联动) + - 文件夹报告导出:HTML/纯文本/Markdown 三格式(folderReport 模块),HTML 延续单文件报告主题(目录卡/统计卡/条目表/打印适配),报告含全量条目与判定口径说明,与界面过滤无关 + - 差异文件导航:从文件夹进入单文件对比后 header 出现“差异文件 x/y”导航(Alt+↓/Alt+↑ 快捷键,不循环),语义同不计入序列;批量审查无需反复返回双击 + - 大目录单点失败降级:单文件读取失败(Windows 文件锁/Defender 占用)标记“无法读取”状态(琥珀警示徽章,默认显示),单目录不可读跳过——不再整体失败丢弃全部结果(万级目录真机冒烟验证:被锁的 electron asar 正确降级,其余 9999 项正常判定) + - 字符级对比分块锚定(恢复 0.6.3 算法):行归一化锚定 + 变更组分块字符流两阶段——相同内容不进入 diffChars,大文件字符级不再整篇受限(组上限 5 万字符/编辑距离 5000/组总流 10 万,任一超限降级行级);引擎输出经真机验证精确无误(0.6.3 事故根因不在引擎) + - 滚动回弹修复(0.6.3 事故真因,0.5.3 起即存在):worker 重路径计算期间保留旧结果撑住虚拟滚动容器高度(快路径结果兜底首次重算),items 不再塌陷——scrollTop 不被浏览器钳制归零;文本变化(打开新文件/交换左右)仍清空防过期差异误导;E2E 新增滚动稳定性回归锚(真实文件+行数不等+开字符级+切选项重算断言位置保持) ... 0.y.z ← 长期停留,永不进入 1.x ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 0b4595f..821915b 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -51,32 +51,36 @@ src/main/ ├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用) ├── decode.test.ts # 解码单元测试(BOM 三种/严格 UTF-8/GBK 回退/二进制启发式) ├── decodeWorker.ts # 解码 worker_threads 入口(覆盖率排除,逻辑由 decode 测试覆盖) -├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对) -├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似) +├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对/语义判等/单点失败降级) +├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似/语义判等/目录降级/unreadable) ├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖) └── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸) src/renderer/src/ ├── test/ │ └── setup.ts # 全局 setup:jest-dom 匹配器 + 自动 cleanup ├── diff/ -│ ├── diffEngine.test.ts # diff 引擎单元测试 +│ ├── diffEngine.test.ts # diff 引擎单元测试(行级/词级/字符级分块锚定与降级/换位不变量) │ ├── diffWorker.ts # diff 计算 Web Worker(内联打包,覆盖率排除,逻辑由 diffEngine 覆盖) │ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化) │ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退 -│ ├── report.test.ts # 报告生成测试 +│ ├── report.test.ts # 单文件报告生成测试 +│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式) +│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable) │ └── textUtils.test.ts # 文本扩展名与行数统计测试 ├── hooks/ -│ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理/字符级大字符量重路径) +│ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理/字符级大字符量重路径/选项变化保留旧结果防滚动塌陷) │ ├── useDiff.ts │ └── useDismiss.ts ├── components/ │ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出) │ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) -│ ├── FolderView.test.tsx # 文件夹视图(统计/过滤/双击/虚拟滚动/截断与空态提示) +│ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤/子树收集) +│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索) +│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/双击/导出菜单/虚拟滚动) │ ├── TextInputModal.test.tsx # 粘贴文本弹窗 │ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级标记) └── __tests__/ - ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击) + ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击/语义判等链路/差异文件导航) └── main.test.ts # 入口挂载冒烟测试 ``` @@ -154,23 +158,30 @@ src/renderer/src/ ### 5.9 文件夹扫描(`src/main/folderScan.ts`,真实临时目录) - 状态判定:内容一致 same / 同大小内容不同 different / 大小不同 different / 单侧缺失 only(另一侧大小 null) +- 语义判等(0.7.0):semanticEquals 判等口径(空白/换行/空行/跨行重排判等,实质差异与大小写不判等);仅空白差异文本文件判 semantic-same(同大小与不同大小均覆盖);实质/大小写差异仍 different;非文本扩展名与超大小上限不参与(保持 different);未开启时行为与 0.6.2 一致;字节一致文件不触发解码 +- 失败降级(0.7.0):单文件 IO 失败(模拟被锁)标记 unreadable 不影响其余条目;目录不可读跳过该目录(该侧按无文件处理,另一侧条目为 only),不再整体抛异常 - 递归子目录按相对路径对齐(统一 / 分隔);二进制内容字节级判定;symlink 跳过;条目字典序排序;total 取两侧较大值 -- 边界与选项:两个空目录 / 目录不存在抛异常(IPC 层转错误结果) / maxFiles 截断(truncated + total + 条目数受限;截断后不再深入子目录,total 为已遍历下限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different -- 受控并发:跨多个并发批次(> 2×SCAN_CONCURRENCY)的混合状态全部判定正确、条目排序与并发执行顺序无关(失败整体抛出由 Promise.all 语义保证,与串行实现一致,不单测) +- 边界与选项:两个空目录 / maxFiles 截断(truncated + total + 条目数受限;截断后不再深入子目录,total 为已遍历下限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different +- 受控并发:跨多个并发批次(> 2×SCAN_CONCURRENCY)的混合状态全部判定正确、条目排序与并发执行顺序无关 -### 5.10 文件夹视图(`FolderView.tsx`) -- 渲染:目录路径卡片 / 重新选择与返回按钮 / 统计徽章(相同/不同/仅左/仅右/总数) -- 过滤:仅看差异隐藏 same、关闭展示全部;双击回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;截断提示;空态提示(无文件 / 全部一致) -- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按全部条目撑满 +### 5.10 文件夹视图(`FolderView.tsx`)与条目树(`folderTree.ts`) +- 条目树纯逻辑:按路径分段聚合目录/文件(同层字典序);目录聚合子树状态(different > unreadable > only > 语义同 > 相同)与文件计数;折叠目录子树隐藏;过滤(onlyDiff / statusFilter / query 子串不区分大小写);空目录剪枝由“过滤后建树”天然保证 +- 渲染:目录路径卡片 / 重新选择、导出报告与返回按钮 / 统计徽章(相同/语义同/不同/仅左/仅右/无法读取/总数;语义关闭隐藏语义同、无失败隐藏无法读取) +- 树形交互:目录行点击折叠展开(默认全展开);搜索命中强制展开命中路径、清空恢复用户折叠;无命中提示 +- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动) +- 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果) +- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满 ### 5.11 E2E 端到端(`e2e/app.spec.ts`,对 build 产物启动真实 Electron) - 启动冒烟:标题与双空态面板、空态无导出入口 - 粘贴两侧:差异渲染 + 统计徽章(主进程剪贴板链路) - 编码链路:GBK 与 UTF-8 BOM 文件打开(编码标注 + 内容渲染)、大体积 GBK 文件经解码 worker 后台解码完整渲染(状态栏总行数断言) - 比较选项:忽略大小写与忽略所有空白组合、字符级跨行重组判一致 +- 滚动稳定性回归锚(0.7.0):真实文件(行数不等)→ 开字符级进 worker 重路径 → 滚到中部 → 切选项二次重算 → 断言 scrollTop 保持不归零、滚到底内容连续(0.6.3 回退缺陷的固化防线) - 导航与视图:F7 / Shift+F7、仅看差异折叠、交换左右后面板互换 -- 报告导出:stub 保存对话框 → 文件真实落盘且含结论与统计 -- 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留) +- 报告导出:stub 保存对话框 → 单文件 HTML 报告真实落盘且含结论与统计 +- 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤 / 树形显示)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留) +- 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目) - 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData) - 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理) @@ -213,7 +224,8 @@ npm run build ## 9. 演进方向(后续迭代) -- 文件夹对比增强(0.7.0 候选):子目录树形展示 / 按扩展名过滤 / 文件夹对比报告导出 / 语义判定(套用忽略空白等选项) +- 文件夹对比深度增强:目录行双击聚合对比 / 按扩展名过滤 / 文件夹对比结果的增量重扫(记忆上次结果只扫变更) +- 字符级对比能力扩展:变更组粒度的进一步放宽(大改文件分块提示而非整体降级) - E2E 覆盖扩展:拖拽导入链路 / 大文件性能基准 - 主进程 IPC 层补充集成测试(folder:scan 并发场景) diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 2b835ba..fc36495 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -114,6 +114,70 @@ test.describe('比较选项端到端', () => { await expect(window.getByText('两文件内容完全一致')).toBeVisible() await app.close() }) + + test('字符级大输入:重算期间滚动位置不回弹(0.6.3 回退缺陷的回归锚)', async () => { + // fixture 严格对齐用户实测场景:真实文件打开 + 行数不等(左 950 / 右 942:改 3 + 删 20 + 插 12), + // 字符量约 4.7 万 > 重输入阈值(5000)→ 开字符级即进 worker 重路径 + const dir = await makeTmpDir('difflens-e2e-scroll-') + try { + const pad = (i: number): string => String(i).padStart(4, '0') + const left = Array.from({ length: 950 }, (_, i) => `L${pad(i)}-abcdefghijklmnopqrstuvwxyz`) + const right = [...left] + right[300] = 'L0300-abcdefghijXlmnopqrstuvwxyz' + right.splice(530, 20) + right.splice(812, 0, ...Array.from({ length: 12 }, (_, k) => `N${pad(k)}-newcontent-${k}0987654321`)) + const lp = await writeTextFixture(dir, 'left.txt', left.join('\n')) + const rp = await writeTextFixture(dir, 'right.txt', right.join('\n')) + + const { app, window } = await launchApp() + await stubOpenDialogSeq(app, [[lp], [rp]]) + await window.getByRole('button', { name: '打开左侧' }).click() + await window.getByText('L0000-abcdefghijklmnopqrstuvwxyz').first().waitFor() + await window.getByRole('button', { name: '打开右侧' }).click() + await window.getByText('L0001-abcdefghijklmnopqrstuvwxyz').first().waitFor() + + // 开字符级(首次进入重路径:快路径结果兜底撑住容器高度,不塌陷归零) + await window.getByLabel('字符级对比').check() + await window.getByText('有差异').first().waitFor() + + // 滚到中部后二次触发重算(切选项):computing 期间位置必须保持 + const scrollSel = '.pane-left .diff-scroll' + await window.evaluate((s) => { + const el = document.querySelector(s) + el.scrollTop = 5000 + el.dispatchEvent(new Event('scroll')) + }, scrollSel) + expect(await window.evaluate((s) => document.querySelector(s).scrollTop, scrollSel)).toBe(5000) + + await window.getByLabel('忽略大小写').check() + await window.waitForTimeout(150) + // 关键断言:重算期间 scrollTop 不被钳制归零(缺陷版本会多级下坠到 0) + const during = await window.evaluate((s) => document.querySelector(s).scrollTop, scrollSel) + expect(during).toBeGreaterThanOrEqual(4000) + + // 重算完成后位置仍稳定 + await window.getByText('有差异').first().waitFor() + await window.waitForTimeout(300) + expect(await window.evaluate((s) => document.querySelector(s).scrollTop, scrollSel)).toBe(5000) + + // 滚到底部验证内容连续无重复渲染错乱 + await window.evaluate((s) => { + const el = document.querySelector(s) + el.scrollTop = el.scrollHeight + el.dispatchEvent(new Event('scroll')) + }, scrollSel) + await window.waitForTimeout(300) + const lastRow = await window.evaluate((s) => { + const sc = document.querySelector(s) + const rows = sc.querySelectorAll('.diff-row') + return rows.length > 0 ? rows[rows.length - 1].textContent : '' + }, scrollSel) + expect(lastRow).toContain('L0949') + await app.close() + } finally { + await removeTmpDir(dir) + } + }) }) test.describe('差异导航与视图', () => { @@ -205,9 +269,10 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => { await expect(window.getByText('相同 1')).toBeVisible() await expect(window.getByText('不同 2')).toBeVisible() await expect(window.getByText('仅左 1')).toBeVisible() - // 默认仅看差异:same.txt 被过滤,差异条目可见 + // 默认仅看差异:same.txt 被过滤,差异条目可见(树形视图显示文件尾段名) await expect(window.getByText('diff.txt')).toBeVisible() - await expect(window.getByText('sub/nested.txt')).toBeVisible() + await expect(window.getByText('sub')).toBeVisible() + await expect(window.getByText('nested.txt')).toBeVisible() await expect(window.getByText('same.txt')).toHaveCount(0) // 双击差异条目进入单文件对比 @@ -226,6 +291,66 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => { await removeTmpDir(root) } }) + + test('语义判等、树形折叠、搜索与文件夹报告导出', async () => { + const root = await makeTmpDir('difflens-e2e-folder2-') + try { + const leftDir = join(root, 'left') + const rightDir = join(root, 'right') + const { mkdir, writeFile: wf } = await import('fs/promises') + await mkdir(join(leftDir, 'sub'), { recursive: true }) + await mkdir(join(rightDir, 'sub'), { recursive: true }) + // 仅换行符差异(左 CRLF 右 LF)→ 语义同 + await wf(join(leftDir, 'crlf.txt'), 'line1\r\nline2\r\n') + await wf(join(rightDir, 'crlf.txt'), 'line1\nline2\n') + // 实质内容差异 → 不同 + await wf(join(leftDir, 'real.txt'), 'value = 1\n') + await wf(join(rightDir, 'real.txt'), 'value = 2\n') + // 完全一致(子目录) + await wf(join(leftDir, 'sub', 'same.txt'), 'identical') + await wf(join(rightDir, 'sub', 'same.txt'), 'identical') + + const { app, window } = await launchApp() + await stubOpenDialogSeq(app, [[leftDir], [rightDir]]) + await window.getByRole('button', { name: '对比文件夹' }).click() + + // 语义判等默认开:crlf.txt 计为语义同(统计徽章) + await expect(window.getByText('语义同 1')).toBeVisible() + await expect(window.getByText('不同 1')).toBeVisible() + // 默认仅看差异:语义同被过滤,只剩 real.txt + await expect(window.getByText('real.txt')).toBeVisible() + await expect(window.getByText('crlf.txt')).toHaveCount(0) + // 关闭仅看差异:树形展示(sub 目录行 + 语义同条目标注) + await window.getByLabel('仅看差异').uncheck() + await expect(window.getByText('crlf.txt')).toBeVisible() + await expect(window.getByText('sub')).toBeVisible() + await expect(window.getByText('same.txt')).toBeVisible() + // 目录折叠/展开 + await window.getByText('sub').click() + await expect(window.getByText('same.txt')).toHaveCount(0) + await window.getByText('sub').click() + await expect(window.getByText('same.txt')).toBeVisible() + // 搜索过滤 + await window.getByPlaceholder('搜索文件名…').fill('crlf') + await expect(window.getByText('crlf.txt')).toBeVisible() + await expect(window.getByText('real.txt')).toHaveCount(0) + await window.getByPlaceholder('搜索文件名…').fill('') + // 导出文件夹报告(真实写盘) + const savePath = join(root, 'folder-report.txt') + await stubSaveDialog(app, savePath) + await window.getByRole('button', { name: '导出报告' }).click() + await window.getByRole('button', { name: '纯文本' }).click() + await expect(window.getByText(/报告已保存/)).toBeVisible() + const txt = await readFile(savePath, 'utf-8') + expect(txt).toContain('DiffLens 文件夹对比报告') + expect(txt).toContain('判定口径:语义判等开启') + expect(txt).toContain('共 1 个文件存在差异') + expect(txt).toContain('语义同') + await app.close() + } finally { + await removeTmpDir(root) + } + }) }) test.describe('偏好持久化(跨实例重启)', () => { diff --git a/package.json b/package.json index 04e34dd..634f9ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.6.2", + "version": "0.7.0", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/folderScan.test.ts b/src/main/folderScan.test.ts index 4430d28..b7bac69 100644 --- a/src/main/folderScan.test.ts +++ b/src/main/folderScan.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' -import { scanFolders, type FolderEntry } from './folderScan' +import { scanFolders, semanticEquals, SEMANTIC_MAX_BYTES, type FolderEntry } from './folderScan' /** 以临时目录为根构造两个待对比文件夹 */ let leftDir: string @@ -114,8 +114,11 @@ describe('scanFolders - 边界与选项', () => { } }) - it('目录不存在时抛出异常(由 IPC 层转为错误结果)', async () => { - await expect(scanFolders(join(leftDir, 'not-exist'), rightDir)).rejects.toThrow() + it('目录不存在时不再抛异常:该侧按无文件处理(另一侧条目为 only)', async () => { + // 单目录 IO 失败降级(被锁/权限)而非整体失败:大目录下单点锁不丢弃全部结果 + const r = await scanFolders(join(leftDir, 'not-exist'), rightDir) + expect(r.entries.length).toBeGreaterThan(0) + expect(r.entries.every((e) => e.status === 'right-only')).toBe(true) }) it('maxFiles 截断:条目不完整且 truncated 为 true', async () => { @@ -195,6 +198,147 @@ describe('scanFolders - 边界与选项', () => { }) }) +describe('scanFolders - 语义判等', () => { + /** 语义判等注入:同步解码(UTF-8 直读)+ 文本扩展名白名单 */ + const SEMANTIC = { + maxBytes: SEMANTIC_MAX_BYTES, + isTextFile: (rel: string): boolean => { + const ext = rel.slice(rel.lastIndexOf('.') + 1).toLowerCase() + return ['txt', 'md', 'json', 'ts'].includes(ext) + }, + decode: async (buf: Buffer): Promise => buf.toString('utf-8') + } + + it('semanticEquals:空白/换行/空行差异判等,跨行重排判等,实质差异与大小写不判等', () => { + expect(semanticEquals('a b', 'ab')).toBe(true) + expect(semanticEquals('a\r\nb', 'a\nb')).toBe(true) + expect(semanticEquals('a\n\n\nb', 'ab')).toBe(true) + expect(semanticEquals('ab cd', 'ab\ncd')).toBe(true) + expect(semanticEquals('abc', 'abd')).toBe(false) + expect(semanticEquals('ABC', 'abc')).toBe(false) + expect(semanticEquals('', '')).toBe(true) + }) + + it('仅空白/换行差异的文本文件判为 semantic-same(同大小与不同大小均覆盖)', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-sem-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-sem-r-')) + try { + // 不同大小:行内空白差异(tab/空格/数量) + await writeFile(join(l, 'ws.txt'), 'a\tb\nc d\n') + await writeFile(join(r, 'ws.txt'), 'a b\nc d\n') + // 不同大小:CRLF vs LF + await writeFile(join(l, 'crlf.txt'), 'line1\r\nline2\r\n') + await writeFile(join(r, 'crlf.txt'), 'line1\nline2\n') + // 排版重排(跨行重组) + await writeFile(join(l, 'reflow.txt'), 'hello world\n') + await writeFile(join(r, 'reflow.txt'), 'hello\nworld\n') + const res = await scanFolders(l, r, { semantic: SEMANTIC }) + expect(byRel(res.entries, 'ws.txt')?.status).toBe('semantic-same') + expect(byRel(res.entries, 'crlf.txt')?.status).toBe('semantic-same') + expect(byRel(res.entries, 'reflow.txt')?.status).toBe('semantic-same') + } finally { + await rm(l, { recursive: true, force: true }) + await rm(r, { recursive: true, force: true }) + } + }) + + it('实质内容差异与大小写差异仍判 different', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-sem2-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-sem2-r-')) + try { + await writeFile(join(l, 'real.txt'), 'value = 1\n') + await writeFile(join(r, 'real.txt'), 'value = 2\n') + await writeFile(join(l, 'case.txt'), 'Config\n') + await writeFile(join(r, 'case.txt'), 'config\n') + const res = await scanFolders(l, r, { semantic: SEMANTIC }) + expect(byRel(res.entries, 'real.txt')?.status).toBe('different') + expect(byRel(res.entries, 'case.txt')?.status).toBe('different') + } finally { + await rm(l, { recursive: true, force: true }) + await rm(r, { recursive: true, force: true }) + } + }) + + it('非文本扩展名与超大小上限的条目不参与语义判等(保持字节级 different)', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-sem3-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-sem3-r-')) + try { + // 非文本扩展名(.bin 不在白名单):仅空白差异仍判 different + await writeFile(join(l, 'data.bin'), 'a b\n') + await writeFile(join(r, 'data.bin'), 'ab\n') + // 大小超上限(注入 maxBytes=4):仅空白差异仍判 different + await writeFile(join(l, 'big.txt'), 'aaaa bbbb\n') + await writeFile(join(r, 'big.txt'), 'aaaabbbb\n') + const res = await scanFolders(l, r, { + semantic: { ...SEMANTIC, maxBytes: 4 } + }) + expect(byRel(res.entries, 'data.bin')?.status).toBe('different') + expect(byRel(res.entries, 'big.txt')?.status).toBe('different') + } finally { + await rm(l, { recursive: true, force: true }) + await rm(r, { recursive: true, force: true }) + } + }) + + it('未开启语义判等时空白差异保持字节级 different(行为与 0.6.2 一致)', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-sem4-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-sem4-r-')) + try { + await writeFile(join(l, 'ws.txt'), 'a b\n') + await writeFile(join(r, 'ws.txt'), 'ab\n') + const res = await scanFolders(l, r) + expect(byRel(res.entries, 'ws.txt')?.status).toBe('different') + } finally { + await rm(l, { recursive: true, force: true }) + await rm(r, { recursive: true, force: true }) + } + }) + + it('字节一致的文件不受语义开关影响(仍判 same,不触发解码)', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-sem5-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-sem5-r-')) + let decoded = 0 + try { + await writeFile(join(l, 'same.txt'), 'same content\n') + await writeFile(join(r, 'same.txt'), 'same content\n') + const res = await scanFolders(l, r, { + semantic: { ...SEMANTIC, decode: async (b) => { decoded++; return b.toString('utf-8') } } + }) + expect(byRel(res.entries, 'same.txt')?.status).toBe('same') + expect(decoded).toBe(0) + } finally { + await rm(l, { recursive: true, force: true }) + await rm(r, { recursive: true, force: true }) + } + }) + + it('单文件 IO 失败(模拟被锁)标记 unreadable,不影响其余条目判定', async () => { + const l = await mkdtemp(join(tmpdir(), 'difflens-sem6-l-')) + const r = await mkdtemp(join(tmpdir(), 'difflens-sem6-r-')) + try { + // 正常差异文件 + 失败文件(decode 抛异常模拟读取后置失败,如 Windows 文件锁) + await writeFile(join(l, 'ok.txt'), 'a\n') + await writeFile(join(r, 'ok.txt'), 'b\n') + await writeFile(join(l, 'locked.txt'), 'x y\n') + await writeFile(join(r, 'locked.txt'), 'xy\n') + const res = await scanFolders(l, r, { + semantic: { + ...SEMANTIC, + decode: async (b) => { + if (b.toString('utf-8').includes('x')) throw new Error('EBUSY') + return b.toString('utf-8') + } + } + }) + expect(byRel(res.entries, 'ok.txt')?.status).toBe('different') + expect(byRel(res.entries, 'locked.txt')?.status).toBe('unreadable') + } finally { + await rm(l, { recursive: true, force: true }) + await rm(r, { recursive: true, force: true }) + } + }) +}) + describe('scanFolders - 受控并发内容比对', () => { it('跨多个并发批次的混合状态全部判定正确且排序不变', async () => { const l = await mkdtemp(join(tmpdir(), 'difflens-conc-l-')) diff --git a/src/main/folderScan.ts b/src/main/folderScan.ts index c0475ae..c18df0f 100644 --- a/src/main/folderScan.ts +++ b/src/main/folderScan.ts @@ -8,7 +8,13 @@ import { join } from 'path' */ /** 单个文件条目的对比状态 */ -export type FolderEntryStatus = 'same' | 'different' | 'left-only' | 'right-only' +export type FolderEntryStatus = + | 'same' + | 'semantic-same' + | 'different' + | 'left-only' + | 'right-only' + | 'unreadable' export interface FolderEntry { /** 相对路径(统一 / 分隔,含子目录前缀) */ @@ -36,6 +42,34 @@ export interface ScanOptions { maxFiles?: number /** 全量字节比对的文件大小上限(默认 10MB,超出仅采样头部 8KB 近似判定) */ maxContentBytes?: number + /** 语义判等(可选):字节级判 different 的文本文件二次判等,剔除空白后一致 → semantic-same */ + semantic?: SemanticOptions +} + +/** + * 语义判等的依赖注入(folderScan 保持纯逻辑可测,解码与扩展名判定由调用方注入)。 + * 生产环境 decode 走主进程解码 worker(大缓冲不阻塞事件循环),测试注入同步实现。 + */ +export interface SemanticOptions { + /** 参与语义判等的单侧文件大小上限 */ + maxBytes: number + /** 相对路径是否为文本文件(按扩展名判定,与打开文件对话框的清单对齐) */ + isTextFile: (rel: string) => boolean + /** 解码文件字节为文本(BOM/编码探测由注入实现处理) */ + decode: (buf: Buffer) => Promise +} + +/** 语义判等参与文件的大小上限(2MB):覆盖绝大多数文本源码/配置;超大文件维持字节级判定 */ +export const SEMANTIC_MAX_BYTES = 2 * 1024 * 1024 + +/** + * 语义判等:剔除全部空白(含换行,与字符级对比的判等语义一致)后拼接比较; + * 大小写敏感(大小写差异在代码中多为实质差异,不忽略)。 + * 换行符 CRLF/LF、行尾空白、缩进、空行数量、排版重排(含跨行重组)均不构成差异。 + */ +export function semanticEquals(left: string, right: string): boolean { + const n = (s: string): string => s.replace(/\s+/g, '') + return n(left) === n(right) } export const FOLDER_MAX_FILES = 10000 @@ -66,7 +100,13 @@ async function listFiles( const queue: Array<{ dir: string; prefix: string }> = [{ dir: root, prefix: '' }] while (queue.length > 0) { const { dir, prefix } = queue.shift()! - const dirents = await readdir(dir, { withFileTypes: true }) + // 单个目录不可读(权限/被锁):跳过该目录继续扫描其余部分,不整体失败 + let dirents + try { + dirents = await readdir(dir, { withFileTypes: true }) + } catch { + continue + } for (const d of dirents) { // Dirent 为 lstat 语义:symlink 既非 file 也非 directory,天然跳过(防环) if (d.isDirectory()) { @@ -83,10 +123,14 @@ async function listFiles( // 凑满即停:不再处理队列中剩余的子目录(当前目录统计完整,保证确定性) if (truncated) break } - // 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束) - const stats = await Promise.all(rels.map((rel) => stat(join(root, rel)))) + // 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束); + // 单文件 stat 失败(被锁/权限)跳过该条目,不整体失败 + const stats = await Promise.allSettled(rels.map((rel) => stat(join(root, rel)))) const files = new Map() - rels.forEach((rel, i) => files.set(rel, stats[i].size)) + rels.forEach((rel, i) => { + const s = stats[i] + if (s.status === 'fulfilled') files.set(rel, s.value.size) + }) return { files, truncated, total } } @@ -104,10 +148,12 @@ async function readHead(path: string, bytes: number): Promise { /** * 对比两个文件夹:按相对路径对齐条目并判定内容异同。 - * 判定规则:单侧缺失 → only;大小不同 → different; - * 大小相同且 ≤ maxContentBytes → 全量字节比对;超过上限 → 头部 8KB 采样近似判定。 - * 大小一致的条目按 SCAN_CONCURRENCY 受控并发读取比对(输出与顺序和串行实现完全一致, - * 任一文件读取失败仍整体抛出,由 IPC 调用方转为错误结果)。 + * 判定规则:单侧缺失 → only;大小一致且 ≤ maxContentBytes → 全量字节比对; + * 大小不同(≤ 语义上限)或字节不同 → 字节级 different(开启语义判等时二次判等, + * 剔除空白后一致改判 semantic-same);超过全量比对上限 → 头部 8KB 采样近似判定。 + * 需读内容的条目按 SCAN_CONCURRENCY 受控并发执行(输出与顺序和串行实现完全一致); + * 单文件/单目录 IO 失败(被锁/权限,如 Windows Defender 占用)降级处理—— + * 文件标记 unreadable、目录跳过,不整体失败(大目录下单点锁不应丢弃全部结果)。 */ export async function scanFolders( leftDir: string, @@ -116,9 +162,17 @@ export async function scanFolders( ): Promise { const maxFiles = options.maxFiles ?? FOLDER_MAX_FILES const maxContentBytes = options.maxContentBytes ?? FOLDER_MAX_CONTENT_BYTES + const semantic = options.semantic const left = await listFiles(leftDir, maxFiles) const right = await listFiles(rightDir, maxFiles) + /** 条目是否参与语义判等:双侧存在、均在大小上限内、文本扩展名 */ + const semanticEligible = (rel: string, lSize: number, rSize: number): boolean => + semantic !== undefined && + lSize <= semantic.maxBytes && + rSize <= semantic.maxBytes && + semantic.isTextFile(rel) + const rels = [...new Set([...left.files.keys(), ...right.files.keys()])].sort() // 预分配结果槽位:无 IO 的条目直接定案,需读内容的条目收集为任务并发执行 const entries: FolderEntry[] = new Array(rels.length) @@ -130,38 +184,60 @@ export async function scanFolders( entries[idx] = { rel, status: 'right-only', leftSize: null, rightSize: rSize ?? null } } else if (rSize === undefined) { entries[idx] = { rel, status: 'left-only', leftSize: lSize, rightSize: null } - } else if (lSize !== rSize) { + } else if (lSize !== rSize && !semanticEligible(rel, lSize, rSize)) { + // 大小不同且不参与语义判等(未开启/超上限/非文本):无需读内容直接定案 entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize } - } else if (lSize <= maxContentBytes) { + } else if (lSize === rSize && lSize > maxContentBytes) { + // 大小一致但超过全量比对上限:头部采样近似判定,结果带 approximate 标注; + // 单文件读取失败(被锁/权限,如 Windows Defender 占用)标记 unreadable,不整体失败 tasks.push({ idx, run: async () => { - const [lBuf, rBuf] = await Promise.all([ - readFile(join(leftDir, rel)), - readFile(join(rightDir, rel)) - ]) - entries[idx] = lBuf.equals(rBuf) - ? { rel, status: 'same', leftSize: lSize, rightSize: rSize } - : { rel, status: 'different', leftSize: lSize, rightSize: rSize } + try { + const [lHead, rHead] = await Promise.all([ + readHead(join(leftDir, rel), SAMPLE_BYTES), + readHead(join(rightDir, rel), SAMPLE_BYTES) + ]) + entries[idx] = lHead.equals(rHead) + ? { rel, status: 'same', leftSize: lSize, rightSize: rSize, approximate: true } + : { rel, status: 'different', leftSize: lSize, rightSize: rSize } + } catch { + entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize } + } } }) } else { - // 超过全量比对上限:头部采样近似判定,结果带 approximate 标注 + // 大小一致(≤ 上限)全量字节比对;大小不同且均在语义上限内读两侧全量。 + // 字节 different 且开启语义判等时:同一批读取上接语义判等(避免二次 IO); + // 单文件读取/解码失败(被锁/权限)标记 unreadable,不整体失败 tasks.push({ idx, run: async () => { - const [lHead, rHead] = await Promise.all([ - readHead(join(leftDir, rel), SAMPLE_BYTES), - readHead(join(rightDir, rel), SAMPLE_BYTES) - ]) - entries[idx] = lHead.equals(rHead) - ? { rel, status: 'same', leftSize: lSize, rightSize: rSize, approximate: true } - : { rel, status: 'different', leftSize: lSize, rightSize: rSize } + try { + const [lBuf, rBuf] = await Promise.all([ + readFile(join(leftDir, rel)), + readFile(join(rightDir, rel)) + ]) + if (lBuf.equals(rBuf)) { + entries[idx] = { rel, status: 'same', leftSize: lSize, rightSize: rSize } + return + } + if (semanticEligible(rel, lSize, rSize)) { + const [lt, rt] = await Promise.all([semantic!.decode(lBuf), semantic!.decode(rBuf)]) + if (semanticEquals(lt, rt)) { + entries[idx] = { rel, status: 'semantic-same', leftSize: lSize, rightSize: rSize } + return + } + } + entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize } + } catch { + entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize } + } } }) } }) - // 受控并发分批执行(批内任一失败整体抛出,与串行实现语义一致) + // 受控并发分批执行(批内并行、批间串行;单任务失败已在任务内部降级为 unreadable) for (let i = 0; i < tasks.length; i += SCAN_CONCURRENCY) { await Promise.all(tasks.slice(i, i + SCAN_CONCURRENCY).map((t) => t.run())) } diff --git a/src/main/index.ts b/src/main/index.ts index 23fd2ff..83b8f0d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,27 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils' import fs from 'fs' import { parseState, clampBounds, type AppState } from './windowState' import { decodeText, type DecodeResult } from './decode' -import { scanFolders } from './folderScan' +import { scanFolders, SEMANTIC_MAX_BYTES } from './folderScan' + +/** + * 文本类扩展名清单(小写,不含点): + * 与渲染进程 TEXT_EXTENSIONS(src/renderer/src/diff/textUtils.ts)完全对齐,两进程无法共享模块, + * 改动需双向同步。供打开文件对话框筛选与文件夹语义判等的文本文件判定共用。 + */ +const TEXT_FILE_EXTENSIONS = [ + 'txt', 'text', 'md', 'markdown', 'json', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts', 'tsx', 'jsx', + 'css', 'scss', 'less', 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', + 'config', 'properties', 'env', 'py', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cc', 'cs', + 'swift', 'kt', 'rb', 'php', 'pl', 'sh', 'zsh', 'bash', 'fish', 'bat', 'cmd', 'ps1', 'log', + 'csv', 'tsv', 'sql', 'vue', 'svelte', 'graphql', 'gql', 'gradle', 'lock' +] + +/** 相对路径是否为文本文件(按扩展名,语义判等文本判定共用) */ +function isTextRel(rel: string): boolean { + const dot = rel.lastIndexOf('.') + if (dot < 0) return true + return TEXT_FILE_EXTENSIONS.includes(rel.slice(dot + 1).toLowerCase()) +} /** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */ const stateFile = (): string => join(app.getPath('userData'), 'window-state.json') @@ -182,8 +202,7 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => { defaultPath: appState.lastDir, properties: ['openFile'], filters: [ - // 与渲染进程 TEXT_EXTENSIONS(src/renderer/src/diff/textUtils.ts)完全对齐;两进程无法共享模块,改动需双向同步 - { name: '文本文件', extensions: ['txt', 'text', 'md', 'markdown', 'json', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts', 'tsx', 'jsx', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'config', 'properties', 'env', 'py', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cc', 'cs', 'swift', 'kt', 'rb', 'php', 'pl', 'sh', 'zsh', 'bash', 'fish', 'bat', 'cmd', 'ps1', 'log', 'csv', 'tsv', 'sql', 'vue', 'svelte', 'graphql', 'gql', 'gradle', 'lock'] }, + { name: '文本文件', extensions: TEXT_FILE_EXTENSIONS }, // 兜底入口:允许打开任意扩展名文件 { name: '所有文件', extensions: ['*'] } ] @@ -244,10 +263,19 @@ ipcMain.handle('folder:pick', async () => { return dirPath }) -/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定,异步 fs 不阻塞主进程) */ -ipcMain.handle('folder:scan', async (_event, leftDir: string, rightDir: string) => { +/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等;异步 fs 与解码 worker 不阻塞主进程) */ +ipcMain.handle('folder:scan', async (_event, leftDir: string, rightDir: string, semantic: boolean) => { try { - return await scanFolders(leftDir, rightDir) + return await scanFolders(leftDir, rightDir, semantic + ? { + // 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线) + semantic: { + maxBytes: SEMANTIC_MAX_BYTES, + isTextFile: isTextRel, + decode: async (buf) => (await decodeMaybeWorker(buf)).text + } + } + : {}) } catch { // 目录不存在/不可读等:转为错误标记,渲染端提示 return { error: 'scan-failed' as const } diff --git a/src/preload/index.ts b/src/preload/index.ts index 5be8394..408923e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,8 +22,14 @@ export interface FileOpenError { /** 打开文件结果:成功数据 / 失败原因 / 用户取消(null) */ export type FileOpenResult = FileData | FileOpenError | null -/** 文件夹对比条目状态(与主进程 folderScan 对齐) */ -export type FolderEntryStatus = 'same' | 'different' | 'left-only' | 'right-only' +/** 文件夹对比条目状态(与主进程 folderScan 对齐;semantic-same = 字节不同但剔除空白后内容一致;unreadable = 读取失败被锁/权限) */ +export type FolderEntryStatus = + | 'same' + | 'semantic-same' + | 'different' + | 'left-only' + | 'right-only' + | 'unreadable' /** 文件夹对比单个条目 */ export interface FolderEntry { @@ -60,8 +66,12 @@ const api = { ipcRenderer.invoke('clipboard:write', text), pickFolder: (): Promise => ipcRenderer.invoke('folder:pick'), - scanFolder: (leftDir: string, rightDir: string): Promise => - ipcRenderer.invoke('folder:scan', leftDir, rightDir), + scanFolder: ( + leftDir: string, + rightDir: string, + semantic: boolean + ): Promise => + ipcRenderer.invoke('folder:scan', leftDir, rightDir, semantic), readByPath: (filePath: string): Promise => ipcRenderer.invoke('file:read-by-path', filePath) } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f3f41d8..9d0d6bf 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -13,6 +13,7 @@ import Toolbar from './components/Toolbar' import ContextMenu, { type ContextMenuItem } from './components/ContextMenu' import TextInputModal from './components/TextInputModal' import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report' +import { buildFolderReport } from './diff/folderReport' import { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils' import { useDismiss } from './hooks/useDismiss' import { useDiff } from './hooks/useDiff' @@ -54,12 +55,27 @@ const PREF_BOOL_KEYS = [ 'charMode' ] as const satisfies (keyof DiffOptions)[] +/** 持久化偏好的完整结构(比较选项 + 单文件/文件夹视图开关) */ +interface Prefs { + options: DiffOptions + onlyDiff: boolean + /** 文件夹模式「仅看差异」(默认开:文件夹场景默认关心差异文件) */ + folderOnlyDiff: boolean + /** 文件夹模式「语义判等」(默认开:换行符/空白差异不再计为不同) */ + folderSemantic: boolean +} + /** * 读取持久化偏好:只接受已知字段的严格 boolean 值; * 数据损坏(JSON 非法 / 字段类型异常 / 存储不可用)一律回退默认,保证启动不受影响。 */ -function loadPrefs(): { options: DiffOptions; onlyDiff: boolean } { - const fallback = { options: { ...DEFAULT_OPTIONS }, onlyDiff: false } +function loadPrefs(): Prefs { + const fallback: Prefs = { + options: { ...DEFAULT_OPTIONS }, + onlyDiff: false, + folderOnlyDiff: true, + folderSemantic: true + } let raw: string | null = null try { raw = window.localStorage.getItem(PREFS_KEY) @@ -71,13 +87,20 @@ function loadPrefs(): { options: DiffOptions; onlyDiff: boolean } { const obj = JSON.parse(raw) as { options?: Partial> onlyDiff?: unknown + folderOnlyDiff?: unknown + folderSemantic?: unknown } - const bool = (v: unknown): boolean => v === true + const bool = (v: unknown, def: boolean): boolean => (v === undefined ? def : v === true) const options: DiffOptions = { ...DEFAULT_OPTIONS } for (const k of PREF_BOOL_KEYS) { - options[k] = bool(obj.options?.[k]) + options[k] = bool(obj.options?.[k], false) + } + return { + options, + onlyDiff: bool(obj.onlyDiff, false), + folderOnlyDiff: bool(obj.folderOnlyDiff, true), + folderSemantic: bool(obj.folderSemantic, true) } - return { options, onlyDiff: bool(obj.onlyDiff) } } catch { return fallback } @@ -163,10 +186,14 @@ export default function App(): ReactElement { (FolderScanResult & { leftDir: string; rightDir: string }) | null >(null) const [folderScanning, setFolderScanning] = useState(false) - // 文件夹模式的「仅看差异」:与单文件模式的 onlyDiff 独立(文件夹场景默认关心差异文件) - const [folderOnlyDiff, setFolderOnlyDiff] = useState(true) - // 偏好只读一次盘:两个初始 state 共享同一次 loadPrefs 结果(原实现挂载期读取两次) + // 从文件夹进入单文件对比时的当前条目 rel(差异文件导航的游标;返回/退出文件夹模式时清空) + const [folderEntryCursor, setFolderEntryCursor] = useState(null) + // 偏好只读一次盘:全部初始 state 共享同一次 loadPrefs 结果(原实现挂载期读取两次) const [initialPrefs] = useState(() => loadPrefs()) + // 文件夹模式的「仅看差异」:与单文件模式的 onlyDiff 独立(文件夹场景默认关心差异文件) + const [folderOnlyDiff, setFolderOnlyDiff] = useState(initialPrefs.folderOnlyDiff) + // 文件夹模式的「语义判等」:字节不同但剔除空白后一致计为语义同(默认开,消除换行符误报) + const [folderSemantic, setFolderSemantic] = useState(initialPrefs.folderSemantic) // 比较选项与视图开关:初始值从持久化偏好恢复 const [options, setOptions] = useState(initialPrefs.options) // 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行) @@ -222,14 +249,17 @@ export default function App(): ReactElement { } }, []) - // 偏好写回:比较选项与视图开关变更即持久化(存储不可用时静默忽略,不影响本次会话) + // 偏好写回:比较选项与全部视图开关变更即持久化(存储不可用时静默忽略,不影响本次会话) useEffect(() => { try { - window.localStorage.setItem(PREFS_KEY, JSON.stringify({ options, onlyDiff })) + window.localStorage.setItem( + PREFS_KEY, + JSON.stringify({ options, onlyDiff, folderOnlyDiff, folderSemantic }) + ) } catch { // 忽略写入失败(隐私模式/配额满等) } - }, [options, onlyDiff]) + }, [options, onlyDiff, folderOnlyDiff, folderSemantic]) // 超多行预警:行数超过阈值时提示后台计算耗时(渲染已虚拟化、计算已 worker 化,均不阻塞界面) const warnHeavyLines = useCallback( @@ -323,12 +353,12 @@ export default function App(): ReactElement { [showToast, warnHeavyLines] ) - /** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式) */ + /** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式;语义判等开关随扫描参数下发) */ const runFolderScan = useCallback( - async (leftDir: string, rightDir: string): Promise => { + async (leftDir: string, rightDir: string, semantic: boolean): Promise => { setFolderScanning(true) try { - const res = await window.api.scanFolder(leftDir, rightDir) + const res = await window.api.scanFolder(leftDir, rightDir, semantic) if ('error' in res) { showToast('文件夹扫描失败,请检查目录是否存在且可访问') return @@ -362,8 +392,51 @@ export default function App(): ReactElement { showToast('两次选择的是同一个文件夹,请选择两个不同的文件夹') return } - await runFolderScan(leftDir, rightDir) - }, [showToast, runFolderScan]) + await runFolderScan(leftDir, rightDir, folderSemantic) + }, [showToast, runFolderScan, folderSemantic]) + + /** 语义判等开关切换:记忆开关并对当前结果重新扫描(状态判定随语义变化) */ + const handleSemanticChange = useCallback( + (v: boolean): void => { + setFolderSemantic(v) + if (folderState) void runFolderScan(folderState.leftDir, folderState.rightDir, v) + }, + [folderState, runFolderScan] + ) + + /** 导出文件夹对比报告(三格式;报告含全量条目,与界面过滤无关) */ + const exportFolderReport = useCallback( + async (fmt: ReportFormat): Promise => { + if (!folderState) return + const content = buildFolderReport( + folderState.entries, + { + leftDir: folderState.leftDir, + rightDir: folderState.rightDir, + semantic: folderSemantic, + truncated: folderState.truncated, + total: folderState.total + }, + fmt + ) + const defaultName = `DiffLens-folder-report-${stamp()}.${REPORT_EXT[fmt]}` + let res: { ok: boolean; path: string | null } + try { + res = await window.api.saveReport(content, defaultName) + } catch { + showToast('导出失败,请重试') + return + } + if (res.ok && res.path) { + const p = res.path + showToast(`报告已保存:${p}`, { + label: '打开文件夹', + onClick: () => void window.api.showInFolder(p) + }) + } + }, + [folderState, folderSemantic, showToast] + ) /** 双击文件夹条目:按路径读取存在侧进入单文件对比(保留文件夹状态供返回) */ const openFolderEntry = useCallback( @@ -406,23 +479,48 @@ export default function App(): ReactElement { entry.status === 'left-only' ? null : `${folderState.rightDir}/${entry.rel}` ) // 至少一侧装载成功才进入对比视图(双侧失败保持文件夹列表) - if (okL || okR) setMode('file') + if (okL || okR) { + setFolderEntryCursor(entry.rel) + setMode('file') + } }, [folderState, applyFileData, showToast] ) - /** 从单文件对比返回文件夹列表(清空两侧面板,文件夹扫描结果保留) */ + /** 差异文件序列(不同/仅左/仅右;语义同不计入),批量审查时的导航范围 */ + const diffFileEntries = useMemo(() => { + if (!folderState) return [] + return folderState.entries.filter( + (e) => e.status === 'different' || e.status === 'left-only' || e.status === 'right-only' + ) + }, [folderState]) + + /** 差异文件导航:跳转到上/下一个差异文件(不循环,越界不动) */ + const goDiffFile = useCallback( + (dir: 1 | -1): void => { + if (diffFileEntries.length === 0) return + const idx = diffFileEntries.findIndex((e) => e.rel === folderEntryCursor) + const next = idx + dir + if (idx < 0 || next < 0 || next >= diffFileEntries.length) return + void openFolderEntry(diffFileEntries[next]) + }, + [diffFileEntries, folderEntryCursor, openFolderEntry] + ) + + /** 从单文件对比返回文件夹列表(清空两侧面板与导航游标,文件夹扫描结果保留) */ const backToFolder = useCallback((): void => { setPaneL(null) setPaneR(null) + setFolderEntryCursor(null) setMode('folder') }, []) - /** 退出文件夹对比(清空文件夹状态与两侧面板,回到单文件空态) */ + /** 退出文件夹对比(清空文件夹状态、导航游标与两侧面板,回到单文件空态) */ const exitFolder = useCallback((): void => { setFolderState(null) setPaneL(null) setPaneR(null) + setFolderEntryCursor(null) setMode('file') }, []) @@ -537,6 +635,18 @@ export default function App(): ReactElement { return () => window.removeEventListener('keydown', onKey) }, [go, pasteSide, menu]) + // 差异文件导航快捷键:Alt+↓ 下一个 / Alt+↑ 上一个(仅从文件夹进入的单文件对比可用) + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (!e.altKey || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return + if (pasteSide !== null || menu !== null || folderEntryCursor === null) return + e.preventDefault() + goDiffFile(e.key === 'ArrowDown' ? 1 : -1) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [goDiffFile, pasteSide, menu, folderEntryCursor]) + // 行级右键菜单 const onRowContext = useCallback( (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => { @@ -652,6 +762,38 @@ export default function App(): ReactElement { ← 返回文件夹对比 )} + {folderState && folderEntryCursor !== null && diffFileEntries.length > 0 && ( +
+ + + {(() => { + const i = diffFileEntries.findIndex((e) => e.rel === folderEntryCursor) + return i >= 0 ? `${i + 1} / ${diffFileEntries.length}` : '—' + })()} + + + 差异文件 +
+ )} @@ -711,8 +853,11 @@ export default function App(): ReactElement { scanning={folderScanning} onlyDiff={folderOnlyDiff} onOnlyDiffChange={setFolderOnlyDiff} + semantic={folderSemantic} + onSemanticChange={handleSemanticChange} onReopen={() => void startFolderCompare()} onExit={exitFolder} + onExport={(fmt) => void exportFolderReport(fmt)} onOpenEntry={(e) => void openFolderEntry(e)} /> ) : anyPane ? ( diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 7834ded..ca7297a 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, fireEvent, act } from '@testing-library/react' +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import App from '../App' let clipboardSpy: ReturnType @@ -754,28 +754,52 @@ describe('App - 字符级对比', () => { expect(await screen.findByText(/「忽略所有空白」已包含在字符级对比中/)).toBeInTheDocument() }) - it('内容超限时自动降级行级并给出提示', async () => { - // 1900 行 × 106 字符 ≈ 201,400 归一化字符(超过 20 万阈值); - // 总行数 1901 未超过快路径阈值,同步计算即时降级 - const big = Array.from({ length: 1900 }, () => 'a'.repeat(106)).join('\n') + it('差异过大时自动降级行级并给出提示', async () => { + // 单行全差异构造:变更组流 = 25001 + 25001 > 5 万上限; + // 总行数 1 未超过快路径阈值,同步计算即时降级 window.api = mockApi({ - openFile: async () => ({ - path: '/tmp/big.txt', - name: 'big.txt', - text: big, - encoding: 'UTF-8', - binary: false - }) + openFile: async (side) => + side === 'left' + ? { path: '/tmp/big.txt', name: 'big.txt', text: 'a'.repeat(25001), encoding: 'UTF-8', binary: false } + : { path: '/tmp/big2.txt', name: 'big2.txt', text: 'b'.repeat(25001), encoding: 'UTF-8', binary: false } }) render() fireEvent.click(screen.getByText('打开左侧')) await screen.findByText('导出报告') + fireEvent.click(screen.getByText('打开右侧')) + await screen.findAllByText('big2.txt') fireEvent.click(screen.getByLabelText('字符级对比')) expect(await screen.findByText(/已自动降级为行级对比/)).toBeInTheDocument() // 降级状态在工具栏可见:字符级开关旁出现“已降级”标记 expect(await screen.findByText('已降级')).toBeInTheDocument() }) + it('大文件仅局部差异:字符级分块完成,不降级且精确定位差异', async () => { + // 2000 行 × 50 字符 ≈ 10 万归一化字符(远超单组 5 万上限),仅一行不同: + // 行锚定后唯一变更组只有该行,字符级正常完成(旧实现必降级) + const base = Array.from({ length: 2000 }, (_, i) => `line-${i}-${'x'.repeat(40)}`) + const modified = [...base] + modified[1000] = `line-1000-${'y'.repeat(40)}` + window.api = mockApi({ + openFile: async (side) => + side === 'left' + ? { path: '/tmp/a.txt', name: 'a.txt', text: base.join('\n'), encoding: 'UTF-8', binary: false } + : { path: '/tmp/b.txt', name: 'b.txt', text: modified.join('\n'), encoding: 'UTF-8', binary: false } + }) + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + fireEvent.click(screen.getByText('打开右侧')) + await screen.findAllByText('b.txt') + fireEvent.click(screen.getByLabelText('字符级对比')) + // 无降级提示与标记(等去抖到期、后台回退同步计算完成) + await screen.findByText('有差异') + expect(screen.queryByText(/已自动降级/)).not.toBeInTheDocument() + expect(screen.queryByText('已降级')).not.toBeInTheDocument() + // 差异统计精确:一处修改 + expect(screen.getByText('~1')).toBeInTheDocument() + }) + it('导出报告携带字符级对比说明', async () => { let saved = '' window.api = mockApi({ @@ -798,17 +822,17 @@ describe('App - 字符级对比', () => { fireEvent.click(screen.getByText('导出报告')) fireEvent.click(screen.getByText('纯文本')) await screen.findByText(/报告已保存/) - expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行)') + expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行,大内容自动分块计算)') }) it('降级后导出报告按实际生效的行级语义描述选项', async () => { let saved = '' - const big = Array.from({ length: 1900 }, () => 'a'.repeat(106)).join('\n') + // 单行全差异(变更组流超 5 万上限):字符级降级为行级语义 window.api = mockApi({ openFile: async (side) => side === 'left' - ? { path: '/tmp/big.txt', name: 'big.txt', text: big, encoding: 'UTF-8', binary: false } - : { path: '/tmp/b.txt', name: 'b.txt', text: 'b', encoding: 'UTF-8', binary: false }, + ? { path: '/tmp/big.txt', name: 'big.txt', text: 'a'.repeat(25001), encoding: 'UTF-8', binary: false } + : { path: '/tmp/b.txt', name: 'b.txt', text: 'b'.repeat(25001), encoding: 'UTF-8', binary: false }, saveReport: async (content: string) => { saved = content return { ok: true, path: 'C:/docs/r.txt' } @@ -1180,4 +1204,219 @@ describe('App - 文件夹对比', () => { expect(paneFiles[0]).toBe('b.txt') expect(paneFiles[1]).toBe('(未选择)') }) +}) + +describe('App - 文件夹语义判等', () => { + const semEntries = [ + { rel: 'same.txt', status: 'same' as const, leftSize: 3, rightSize: 3 }, + { rel: 'sem.txt', status: 'semantic-same' as const, leftSize: 3, rightSize: 4 }, + { rel: 'diff.txt', status: 'different' as const, leftSize: 3, rightSize: 5 } + ] + + /** 进入文件夹模式(带语义条目),返回 scanFolder 调用记录 */ + async function enterWithSem() { + const calls: Array<{ l: string; r: string; s: boolean }> = [] + const dirs = ['C:/left-dir', 'D:/right-dir'] + let call = 0 + window.api = mockApi({ + pickFolder: async () => dirs[call++] ?? null, + scanFolder: async (l: string, r: string, s: boolean) => { + calls.push({ l, r, s }) + return { entries: semEntries, truncated: false, total: 3 } + } + }) + render() + fireEvent.click(screen.getByText('对比文件夹')) + await screen.findByText('语义同 1') + return calls + } + + it('语义同条目:统计徽章渲染,仅看差异时被过滤', async () => { + await enterWithSem() + expect(screen.getByText('相同 1')).toBeInTheDocument() + expect(screen.getByText('不同 1')).toBeInTheDocument() + // 默认仅看差异:same 与 semantic-same 均被过滤 + expect(screen.queryByText('same.txt')).not.toBeInTheDocument() + expect(screen.queryByText('sem.txt')).not.toBeInTheDocument() + expect(screen.getByText('diff.txt')).toBeInTheDocument() + // 关闭仅看差异:语义同条目可见 + fireEvent.click(screen.getByLabelText('仅看差异')) + expect(screen.getByText('sem.txt')).toBeInTheDocument() + }) + + it('首次扫描携带语义开关(默认开)', async () => { + const calls = await enterWithSem() + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ l: 'C:/left-dir', r: 'D:/right-dir', s: true }) + }) + + it('切换语义开关触发重新扫描(关闭后不再渲染语义同徽章)', async () => { + const calls = await enterWithSem() + fireEvent.click(screen.getByLabelText('语义判等')) + // 重新扫描完成(第二次调用返回后界面稳定):语义同徽章不再渲染 + await screen.findByText('相同 1') + await waitFor(() => expect(calls).toHaveLength(2)) + expect(calls[1].s).toBe(false) + expect(screen.queryByText(/语义同/)).not.toBeInTheDocument() + }) + + it('语义开关随偏好持久化写回', async () => { + await enterWithSem() + fireEvent.click(screen.getByLabelText('语义判等')) + await waitFor(() => { + const saved = JSON.parse(window.localStorage.getItem('difflens.prefs.v1') ?? '{}') + expect(saved.folderSemantic).toBe(false) + }) + expect(JSON.parse(window.localStorage.getItem('difflens.prefs.v1') ?? '{}').folderOnlyDiff).toBe(true) + }) + + it('导出文件夹报告:内容落盘携带目录与结论,成功提示可打开文件夹', async () => { + let saved = '' + let savedName = '' + const showInFolder = vi.fn(async () => true) + const dirs = ['C:/left-dir', 'D:/right-dir'] + let call = 0 + window.api = mockApi({ + pickFolder: async () => dirs[call++] ?? null, + scanFolder: async () => ({ entries: semEntries, truncated: false, total: 3 }), + saveReport: async (content: string, name: string) => { + saved = content + savedName = name + return { ok: true, path: 'C:/docs/folder-report.html' } + }, + showInFolder + }) + render() + fireEvent.click(screen.getByText('对比文件夹')) + await screen.findByText('语义同 1') + fireEvent.click(screen.getByRole('button', { name: '导出报告' })) + fireEvent.click(screen.getByText('HTML 报告')) + expect(await screen.findByText(/报告已保存/)).toBeInTheDocument() + expect(savedName).toMatch(/^DiffLens-folder-report-\d{8}-\d{6}\.html$/) + expect(saved).toContain('') + expect(saved).toContain('C:/left-dir') + expect(saved).toContain('D:/right-dir') + expect(saved).toContain('判定口径:语义判等开启') + expect(saved).toContain('共 1 个文件存在差异') + // 报告含全量条目(不受界面“仅看差异”过滤影响) + expect(saved).toContain('same.txt') + expect(saved).toContain('sem.txt') + fireEvent.click(screen.getByText('打开文件夹')) + expect(showInFolder).toHaveBeenCalledWith('C:/docs/folder-report.html') + }) + + it('导出文件夹报告 IPC 异常给出失败提示', async () => { + const dirs = ['C:/left-dir', 'D:/right-dir'] + let call = 0 + window.api = mockApi({ + pickFolder: async () => dirs[call++] ?? null, + scanFolder: async () => ({ entries: semEntries, truncated: false, total: 3 }), + saveReport: async () => { + throw new Error('boom') + } + }) + render() + fireEvent.click(screen.getByText('对比文件夹')) + await screen.findByText('语义同 1') + fireEvent.click(screen.getByRole('button', { name: '导出报告' })) + fireEvent.click(screen.getByText('纯文本')) + expect(await screen.findByText(/导出失败/)).toBeInTheDocument() + }) +}) + +describe('App - 差异文件导航', () => { + /** 3 个差异条目(b/c/d)+ 1 个相同 + 1 个语义同(不参与导航) */ + const navEntries = [ + { rel: 'a.txt', status: 'same' as const, leftSize: 1, rightSize: 1 }, + { rel: 'b.txt', status: 'different' as const, leftSize: 1, rightSize: 2 }, + { rel: 'c.txt', status: 'left-only' as const, leftSize: 1, rightSize: null }, + { rel: 'd.txt', status: 'right-only' as const, leftSize: null, rightSize: 1 }, + { rel: 's.txt', status: 'semantic-same' as const, leftSize: 1, rightSize: 2 } + ] + + async function enterAndOpen(rel: string) { + const dirs = ['C:/left-dir', 'D:/right-dir'] + let call = 0 + window.api = mockApi({ + pickFolder: async () => dirs[call++] ?? null, + scanFolder: async () => ({ entries: navEntries, truncated: false, total: 5 }), + readByPath: async (p: string) => ({ + path: p, + name: p.split('/').pop() ?? 'f', + text: `content of ${p}`, + encoding: 'UTF-8', + binary: false + }) + }) + render() + fireEvent.click(screen.getByText('对比文件夹')) + // 关闭仅看差异让 same/语义同条目可见,双击目标条目 + await screen.findByText('b.txt') + fireEvent.click(screen.getByLabelText('仅看差异')) + fireEvent.dblClick(screen.getByText(rel)) + // 等待进入单文件对比(header 差异文件导航出现;内容被词级高亮拆分无法整串匹配) + await screen.findByText('差异文件') + } + + /** 当前左侧面板文件名(条目装载状态断言用) */ + const leftPaneFile = (): string => + Array.from(document.querySelectorAll('.pane-file')).map((e) => e.textContent)[0] ?? '' + + it('双击差异条目进入后显示导航:位置计数与边界禁用(语义同不计入序列)', async () => { + await enterAndOpen('b.txt') + // 差异文件序列 = b/c/d(s 语义同不计入),当前 b → 1/3 + expect(screen.getByText('1 / 3')).toBeInTheDocument() + expect(screen.getByTitle('上一个差异文件(Alt+↑)')).toBeDisabled() + expect(screen.getByTitle('下一个差异文件(Alt+↓)')).toBeEnabled() + }) + + it('点击下一个差异文件加载新条目并推进计数', async () => { + await enterAndOpen('b.txt') + fireEvent.click(screen.getByTitle('下一个差异文件(Alt+↓)')) + // c 是仅左条目:左侧装载 c.txt,右侧面板为未选择 + await waitFor(() => expect(leftPaneFile()).toBe('c.txt')) + expect(screen.getByText('2 / 3')).toBeInTheDocument() + const paneFiles = Array.from(document.querySelectorAll('.pane-file')).map((e) => e.textContent) + expect(paneFiles[1]).toBe('(未选择)') + }) + + it('最后一个差异文件时下一个禁用;Alt+↑/Alt+↓ 快捷键同样生效', async () => { + await enterAndOpen('d.txt') + expect(screen.getByText('3 / 3')).toBeInTheDocument() + expect(screen.getByTitle('下一个差异文件(Alt+↓)')).toBeDisabled() + // Alt+↑ 回到上一个 + fireEvent.keyDown(window, { key: 'ArrowUp', altKey: true }) + await waitFor(() => expect(leftPaneFile()).toBe('c.txt')) + expect(screen.getByText('2 / 3')).toBeInTheDocument() + // Alt+↓ 再前进(d 为仅右条目:右侧面板装载) + fireEvent.keyDown(window, { key: 'ArrowDown', altKey: true }) + await waitFor(() => + expect(Array.from(document.querySelectorAll('.pane-file')).map((e) => e.textContent)[1]).toBe('d.txt') + ) + expect(screen.getByText('3 / 3')).toBeInTheDocument() + }) + + it('返回文件夹列表后导航消失,再次双击重新出现', async () => { + await enterAndOpen('b.txt') + expect(screen.getByText('1 / 3')).toBeInTheDocument() + fireEvent.click(screen.getByText('← 返回文件夹对比')) + await screen.findByText('相同 1') + // 文件夹列表无差异文件导航 + expect(screen.queryByText('1 / 3')).not.toBeInTheDocument() + // 再次双击进入:导航重新出现且从该条目计数 + fireEvent.dblClick(screen.getByText('c.txt')) + await screen.findByText('差异文件') + await waitFor(() => expect(leftPaneFile()).toBe('c.txt')) + expect(screen.getByText('2 / 3')).toBeInTheDocument() + }) + + it('普通文件对比(未进入过文件夹模式)不显示差异文件导航', async () => { + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + expect(screen.queryByText('差异文件')).not.toBeInTheDocument() + // Alt+↓ 不触发任何导航(无报错即通过) + fireEvent.keyDown(window, { key: 'ArrowDown', altKey: true }) + expect(screen.getByText('导出报告')).toBeInTheDocument() + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/FolderView.test.tsx b/src/renderer/src/components/FolderView.test.tsx index 7fa404a..047a7ab 100644 --- a/src/renderer/src/components/FolderView.test.tsx +++ b/src/renderer/src/components/FolderView.test.tsx @@ -5,12 +5,18 @@ import type { FolderEntry } from '../../../preload/index' const ENTRIES: FolderEntry[] = [ { rel: 'a.txt', status: 'same', leftSize: 5, rightSize: 5 }, + { rel: 'sem.txt', status: 'semantic-same', leftSize: 5, rightSize: 6 }, { rel: 'b.txt', status: 'different', leftSize: 3, rightSize: 9 }, { rel: 'only-left.txt', status: 'left-only', leftSize: 1, rightSize: null }, - { rel: 'sub/only-right.txt', status: 'right-only', leftSize: null, rightSize: 7 } + { rel: 'sub/only-right.txt', status: 'right-only', leftSize: null, rightSize: 7 }, + { rel: 'sub/deep/diff.ts', status: 'different', leftSize: 2, rightSize: 4 } ] -function renderView(entries: FolderEntry[] = ENTRIES, onlyDiff = true): ReturnType { +function renderView( + entries: FolderEntry[] = ENTRIES, + onlyDiff = true, + semantic = true +): ReturnType { return render( ) } -describe('FolderView', () => { +describe('FolderView - 树形渲染', () => { it('渲染两侧目录路径与操作按钮', () => { renderView() expect(screen.getAllByText('C:/left').length).toBeGreaterThan(0) @@ -37,38 +46,38 @@ describe('FolderView', () => { expect(screen.getByRole('button', { name: '返回文件对比' })).toBeInTheDocument() }) - it('渲染统计徽章(相同/不同/仅左/仅右/总数)', () => { - renderView() - expect(screen.getByText('相同 1')).toBeInTheDocument() - expect(screen.getByText('不同 1')).toBeInTheDocument() - expect(screen.getByText('仅左 1')).toBeInTheDocument() - expect(screen.getByText('仅右 1')).toBeInTheDocument() - expect(screen.getByText('共 4 项')).toBeInTheDocument() - }) - - it('仅看差异开启时隐藏 same 条目,关闭后展示全部', () => { - const { rerender } = renderView() - expect(screen.queryByText('a.txt')).not.toBeInTheDocument() - expect(screen.getByText('b.txt')).toBeInTheDocument() - rerender( - - ) + it('目录聚合为树形:根层文件与目录混排(字典序),目录行显示聚合状态与计数', () => { + const { container } = renderView(ENTRIES, false) + // 根层(关闭过滤后 a.txt/sem.txt/b.txt/only-left.txt/sub 可见) expect(screen.getByText('a.txt')).toBeInTheDocument() + expect(screen.getByText('sub')).toBeInTheDocument() + const dirRow = container.querySelector('.folder-row.dir') + expect(dirRow).not.toBeNull() + // sub 子树含 different(diff.ts)→ 聚合徽章“不同” + expect(dirRow!.textContent).toContain('不同') + expect(dirRow!.textContent).toContain('2 项') }) - it('双击条目触发 onOpenEntry(携带原始条目)', () => { + it('子目录文件默认展开可见(深层文件渲染)', () => { + renderView(ENTRIES, false) + expect(screen.getByText('deep')).toBeInTheDocument() + expect(screen.getByText('diff.ts')).toBeInTheDocument() + expect(screen.getByText('only-right.txt')).toBeInTheDocument() + }) + + it('点击目录行折叠/展开子树', () => { + renderView(ENTRIES, false) + expect(screen.getByText('diff.ts')).toBeInTheDocument() + fireEvent.click(screen.getByText('sub')) + // sub 折叠:其下全部子行隐藏 + expect(screen.queryByText('only-right.txt')).not.toBeInTheDocument() + expect(screen.queryByText('deep')).not.toBeInTheDocument() + // 再次点击展开恢复 + fireEvent.click(screen.getByText('sub')) + expect(screen.getByText('diff.ts')).toBeInTheDocument() + }) + + it('双击文件条目触发 onOpenEntry(携带原始条目)', () => { const onOpenEntry = vi.fn() render( { rightDir="D:/right" entries={ENTRIES} truncated={false} - total={4} + total={6} scanning={false} onlyDiff={true} onOnlyDiffChange={vi.fn()} + semantic={true} + onSemanticChange={vi.fn()} onReopen={vi.fn()} onExit={vi.fn()} + onExport={vi.fn()} onOpenEntry={onOpenEntry} /> ) fireEvent.dblClick(screen.getByText('b.txt')) - expect(onOpenEntry).toHaveBeenCalledWith(ENTRIES[1]) + expect(onOpenEntry).toHaveBeenCalledWith(ENTRIES[2]) + }) +}) + +describe('FolderView - 统计徽章与过滤', () => { + it('渲染统计徽章(相同/语义同/不同/仅左/仅右/总数)', () => { + renderView(ENTRIES, false) + expect(screen.getByText('相同 1')).toBeInTheDocument() + expect(screen.getByText('语义同 1')).toBeInTheDocument() + expect(screen.getByText('不同 2')).toBeInTheDocument() + expect(screen.getByText('仅左 1')).toBeInTheDocument() + expect(screen.getByText('仅右 1')).toBeInTheDocument() + expect(screen.getByText('共 6 项')).toBeInTheDocument() + }) + + it('语义关闭时不渲染语义同徽章', () => { + renderView(ENTRIES, false, false) + expect(screen.queryByText(/语义同/)).not.toBeInTheDocument() + }) + + it('仅看差异开启时隐藏 same 与 semantic-same 条目(含其目录剪枝)', () => { + const { rerender } = renderView() + expect(screen.queryByText('a.txt')).not.toBeInTheDocument() + expect(screen.queryByText('sem.txt')).not.toBeInTheDocument() + expect(screen.getByText('b.txt')).toBeInTheDocument() + expect(screen.getByText('sub')).toBeInTheDocument() + rerender( + + ) + expect(screen.getByText('a.txt')).toBeInTheDocument() + expect(screen.getByText('sem.txt')).toBeInTheDocument() + }) + + it('点击徽章开启状态过滤:只看该状态且徽章激活', () => { + renderView(ENTRIES, false) + fireEvent.click(screen.getByText('不同 2')) + // 只剩 different 条目:b.txt 与 sub/deep/diff.ts + expect(screen.getByText('b.txt')).toBeInTheDocument() + expect(screen.queryByText('a.txt')).not.toBeInTheDocument() + expect(screen.queryByText('only-left.txt')).not.toBeInTheDocument() + expect(screen.getByText('diff.ts')).toBeInTheDocument() + // 激活态样式 + expect(screen.getByText('不同 2').className).toContain('active') + // 再次点击取消过滤 + fireEvent.click(screen.getByText('不同 2')) + expect(screen.getByText('a.txt')).toBeInTheDocument() + }) + + it('仅看差异开启时点击徽章:自动关闭仅看差异(回调传 false)', () => { + const onOnlyDiffChange = vi.fn() + render( + + ) + fireEvent.click(screen.getByText('相同 1')) + expect(onOnlyDiffChange).toHaveBeenCalledWith(false) + }) + + it('过滤后无文件的目录不渲染(剪枝)', () => { + renderView(ENTRIES, false) + // 只看仅右:唯一 right-only 在 sub 下 → sub 保留 + fireEvent.click(screen.getByText('仅右 1')) + expect(screen.getByText('sub')).toBeInTheDocument() + expect(screen.getByText('only-right.txt')).toBeInTheDocument() + expect(screen.queryByText('deep')).not.toBeInTheDocument() + // 只看相同:a.txt 在根层,sub 无 same 文件 → sub 剪掉 + fireEvent.click(screen.getByText('仅右 1')) + fireEvent.click(screen.getByText('相同 1')) + expect(screen.getByText('a.txt')).toBeInTheDocument() + expect(screen.queryByText('sub')).not.toBeInTheDocument() + }) +}) + +describe('FolderView - 搜索', () => { + it('输入关键字过滤文件并强制展开命中路径', () => { + renderView(ENTRIES, false) + fireEvent.change(screen.getByPlaceholderText('搜索文件名…'), { target: { value: 'diff.ts' } }) + expect(screen.getByText('diff.ts')).toBeInTheDocument() + expect(screen.getByText('sub')).toBeInTheDocument() + expect(screen.getByText('deep')).toBeInTheDocument() + expect(screen.queryByText('a.txt')).not.toBeInTheDocument() + }) + + it('搜索不区分大小写且匹配目录名中段', () => { + renderView(ENTRIES, false) + fireEvent.change(screen.getByPlaceholderText('搜索文件名…'), { target: { value: 'DEEP' } }) + expect(screen.getByText('diff.ts')).toBeInTheDocument() + expect(screen.queryByText('a.txt')).not.toBeInTheDocument() + }) + + it('折叠目录后搜索仍展开命中路径,清空搜索恢复折叠', () => { + renderView(ENTRIES, false) + fireEvent.click(screen.getByText('sub')) + expect(screen.queryByText('diff.ts')).not.toBeInTheDocument() + // 搜索强制展开 + fireEvent.change(screen.getByPlaceholderText('搜索文件名…'), { target: { value: 'diff' } }) + expect(screen.getByText('diff.ts')).toBeInTheDocument() + // 清空搜索恢复折叠 + 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' } }) + expect(screen.getByText('没有匹配“zzz”的文件')).toBeInTheDocument() + }) +}) + +describe('FolderView - 其他', () => { + it('切换语义判等开关触发 onSemanticChange', () => { + const onSemanticChange = vi.fn() + render( + + ) + fireEvent.click(screen.getByLabelText('语义判等')) + expect(onSemanticChange).toHaveBeenCalledWith(false) + }) + + it('导出报告菜单选择格式触发 onExport 并关闭菜单', () => { + const onExport = vi.fn() + render( + + ) + fireEvent.click(screen.getByRole('button', { name: '导出报告' })) + fireEvent.click(screen.getByText('HTML 报告')) + expect(onExport).toHaveBeenCalledWith('html') + expect(screen.queryByText('HTML 报告')).not.toBeInTheDocument() + }) + + it('扫描中禁用导出按钮', () => { + render( + + ) + expect(screen.getByRole('button', { name: '导出报告' })).toBeDisabled() + }) + + it('点击外部区域关闭导出菜单', () => { + render( + + ) + fireEvent.click(screen.getByRole('button', { name: '导出报告' })) + expect(screen.getByText('纯文本')).toBeInTheDocument() + fireEvent.mouseDown(document.body) + expect(screen.queryByText('纯文本')).not.toBeInTheDocument() }) it('文件大小格式化(B/KB 与缺失占位)', () => { - renderView([...ENTRIES, { rel: 'big.bin', status: 'same', leftSize: 2048, rightSize: 2048 }], false) + renderView( + [...ENTRIES, { rel: 'big.bin', status: 'same', leftSize: 2048, rightSize: 2048 }], + false + ) expect(screen.getAllByText('2.0 KB').length).toBe(2) expect(screen.getAllByText('—').length).toBeGreaterThan(0) }) @@ -111,8 +360,11 @@ describe('FolderView', () => { scanning={false} onlyDiff={true} onOnlyDiffChange={vi.fn()} + semantic={true} + onSemanticChange={vi.fn()} onReopen={vi.fn()} onExit={vi.fn()} + onExport={vi.fn()} onOpenEntry={vi.fn()} /> ) @@ -129,18 +381,22 @@ describe('FolderView', () => { expect(screen.getByText('两个文件夹内都没有文件')).toBeInTheDocument() }) - it('虚拟滚动:大列表仅渲染可见窗口且总高度撑满', () => { - const many: FolderEntry[] = Array.from({ length: 1000 }, (_, i) => ({ - rel: `f${i}.txt`, + it('虚拟滚动:大列表仅渲染可见窗口且总高度撑满(树形扁平化行数)', () => { + // 500 个子目录(各含 1 文件)+ 500 个根层文件 = 展开后 500 目录行 + 1000 文件行 = 1500 行 + const many: FolderEntry[] = Array.from({ length: 500 }, (_, i) => ({ + rel: `d${i}/f${i}.txt`, status: 'different' as const, leftSize: i, rightSize: i + 1 })) - const { container } = renderView(many) + for (let i = 0; i < 500; i++) { + many.push({ rel: `r${i}.txt`, status: 'same' as const, leftSize: 1, rightSize: 1 }) + } + const { container } = renderView(many, false) const rows = container.querySelectorAll('.folder-row') expect(rows.length).toBeGreaterThan(0) expect(rows.length).toBeLessThan(100) const sizer = container.querySelector('.folder-scroll > div') as HTMLElement - expect(sizer.style.height).toBe(`${1000 * FOLDER_ROW_HEIGHT}px`) + expect(sizer.style.height).toBe(`${1500 * FOLDER_ROW_HEIGHT}px`) }) }) diff --git a/src/renderer/src/components/FolderView.tsx b/src/renderer/src/components/FolderView.tsx index 178ca87..08d9716 100644 --- a/src/renderer/src/components/FolderView.tsx +++ b/src/renderer/src/components/FolderView.tsx @@ -1,4 +1,5 @@ import { + useCallback, useLayoutEffect, useMemo, useRef, @@ -6,6 +7,10 @@ import { type ReactElement } from 'react' import type { FolderEntry, FolderEntryStatus } from '../../../preload/index' +import { type ReportFormat } from '../diff/report' +import { countFolderStats } from '../diff/folderReport' +import { useDismiss } from '../hooks/useDismiss' +import { buildFolderTree, filterEntries, flattenFolderTree } from './folderTree' /** 文件夹列表虚拟行高(px),与 global.css 的 --folder-row-height 保持一致,改动需双向同步 */ export const FOLDER_ROW_HEIGHT = 26 @@ -13,6 +18,8 @@ export const FOLDER_ROW_HEIGHT = 26 const OVERSCAN = 8 /** 视口高度兜底值(jsdom 无布局时使用) */ const FALLBACK_VIEWPORT = 600 +/** 树形缩进步进(px / 深度) */ +const TREE_INDENT = 16 export interface FolderViewProps { leftDir: string @@ -21,24 +28,48 @@ export interface FolderViewProps { truncated: boolean total: number scanning: boolean - /** 仅看差异(过滤 same 条目);文件夹模式默认开启 */ + /** 仅看差异(隐藏两侧一致的文件);与状态过滤互斥(后者开启时本开关自动关闭) */ onlyDiff: boolean onOnlyDiffChange: (v: boolean) => void + /** 语义判等开关(字节不同但剔除空白后一致的文件计为“语义同”);切换后重新扫描 */ + semantic: boolean + onSemanticChange: (v: boolean) => void /** 重新选择两侧文件夹 */ onReopen: () => void /** 返回文件对比模式(清空文件夹状态) */ onExit: () => void + /** 导出文件夹对比报告 */ + onExport: (fmt: ReportFormat) => void /** 双击条目进入单文件对比 */ onOpenEntry: (entry: FolderEntry) => void } +/** 导出格式清单(与单文件报告一致) */ +const FORMATS: { fmt: ReportFormat; label: string }[] = [ + { fmt: 'html', label: 'HTML 报告' }, + { fmt: 'txt', label: '纯文本' }, + { fmt: 'md', label: 'Markdown' } +] + const STATUS_META: Record = { same: { icon: '●', label: '相同', cls: 'st-same' }, + 'semantic-same': { icon: '◐', label: '语义同', cls: 'st-sem' }, different: { icon: '●', label: '不同', cls: 'st-diff' }, 'left-only': { icon: '◀', label: '仅左侧', cls: 'st-left' }, - 'right-only': { icon: '▶', label: '仅右侧', cls: 'st-right' } + 'right-only': { icon: '▶', label: '仅右侧', cls: 'st-right' }, + unreadable: { icon: '⊘', label: '无法读取', cls: 'st-unread' } } +/** 状态过滤徽章与统计的展示配置(顺序即渲染顺序) */ +const BADGES: { status: FolderEntryStatus; countKey: keyof ReturnType; text: string }[] = [ + { status: 'same', countKey: 'same', text: '相同' }, + { status: 'semantic-same', countKey: 'semanticSame', text: '语义同' }, + { status: 'different', countKey: 'different', text: '不同' }, + { status: 'left-only', countKey: 'leftOnly', text: '仅左' }, + { status: 'right-only', countKey: 'rightOnly', text: '仅右' }, + { status: 'unreadable', countKey: 'unreadable', text: '无法读取' } +] + /** 人类可读的文件大小 */ function fmtSize(n: number | null): string { if (n === null) return '—' @@ -56,28 +87,70 @@ export default function FolderView({ scanning, onlyDiff, onOnlyDiffChange, + semantic, + onSemanticChange, onReopen, onExit, + onExport, onOpenEntry }: FolderViewProps): ReactElement { - // 统计(条目集不变时不重算) - const stats = useMemo(() => { - let same = 0 - let different = 0 - let leftOnly = 0 - let rightOnly = 0 - for (const e of entries) { - if (e.status === 'same') same++ - else if (e.status === 'different') different++ - else if (e.status === 'left-only') leftOnly++ - else rightOnly++ - } - return { same, different, leftOnly, rightOnly } - }, [entries]) + // 导出下拉(点击外部 / Esc 关闭) + const [exportOpen, setExportOpen] = useState(false) + const exportWrapRef = useRef(null) + const closeExport = useCallback((): void => setExportOpen(false), []) + useDismiss(exportWrapRef, closeExport) - const displayEntries = useMemo( - () => (onlyDiff ? entries.filter((e) => e.status !== 'same') : entries), - [entries, onlyDiff] + // 搜索(相对路径子串,不区分大小写) + const [query, setQuery] = useState('') + // 状态精确过滤(null = 不过滤;与 onlyDiff 互斥) + const [statusFilter, setStatusFilter] = useState | null>(null) + // 折叠的目录集合(默认全展开;空集合 = 全部展开) + const [collapsedDirs, setCollapsedDirs] = useState>(new Set()) + + // 统计(条目集不变时不重算;口径与报告导出共用同一实现,始终基于全量条目) + const stats = useMemo(() => countFolderStats(entries), [entries]) + + /** 过滤 → 建树(剪枝:过滤后无文件的目录不出现) */ + const rows = useMemo(() => { + const filtered = filterEntries(entries, { onlyDiff, statusFilter, query }) + const tree = buildFolderTree(filtered) + const q = query.trim() + // 搜索激活时强制全部展开(命中路径完整可见);清空搜索恢复用户折叠状态 + const isExpanded = (dirPath: string): boolean => + q !== '' ? true : !collapsedDirs.has(dirPath) + return flattenFolderTree(tree, isExpanded) + }, [entries, onlyDiff, statusFilter, query, collapsedDirs]) + + const toggleDir = useCallback((path: string): void => { + setCollapsedDirs((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + }, []) + + /** 仅看差异开关:开启时清空状态精确过滤(两机制互斥) */ + const handleOnlyDiff = useCallback( + (v: boolean): void => { + if (v) setStatusFilter(null) + onOnlyDiffChange(v) + }, + [onOnlyDiffChange] + ) + + /** 徽章点击:状态精确过滤 toggle;onlyDiff 开启时自动关闭 */ + const handleBadge = useCallback( + (st: FolderEntryStatus): void => { + setStatusFilter((prev) => { + const next = new Set(prev ?? []) + if (next.has(st)) next.delete(st) + else next.add(st) + return next.size === 0 ? null : next + }) + if (onlyDiff) onOnlyDiffChange(false) + }, + [onlyDiff, onOnlyDiffChange] ) // 虚拟滚动:固定行高换算可见窗口(与 DiffView 同思路) @@ -89,17 +162,16 @@ export default function FolderView({ if (!el) return const vh = el.clientHeight || FALLBACK_VIEWPORT const start = Math.max(0, Math.floor(el.scrollTop / FOLDER_ROW_HEIGHT) - OVERSCAN) - const end = Math.min( - displayEntries.length, - Math.ceil((el.scrollTop + vh) / FOLDER_ROW_HEIGHT) + OVERSCAN - ) + const end = Math.min(rows.length, Math.ceil((el.scrollTop + vh) / FOLDER_ROW_HEIGHT) + OVERSCAN) setRange((prev) => (prev.start === start && prev.end === end ? prev : { start, end })) } useLayoutEffect(() => { updateRange() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [displayEntries]) + }, [rows]) + + const q = query.trim() return (
@@ -117,26 +189,88 @@ export default function FolderView({ +
+ + {exportOpen && ( +
+ {FORMATS.map((f) => ( + + ))} +
+ )} +
- {/* 工具行:统计 + 过滤 */} + {/* 工具行:统计(可点击过滤)+ 搜索 + 开关 */}
- 相同 {stats.same} - 不同 {stats.different} - 仅左 {stats.leftOnly} - 仅右 {stats.rightOnly} + {BADGES.map((b) => { + // 语义关闭时语义同恒为 0,徽章隐藏(与旧版一致) + if (b.status === 'semantic-same' && !semantic) return null + // 无法读取为异常态:仅在出现时展示,平时不占位 + if (b.status === 'unreadable' && stats.unreadable === 0) return null + const active = statusFilter?.has(b.status) ?? false + return ( + + ) + })} 共 {entries.length} 项
-
- {/* 条目列表(虚拟滚动) */} + {/* 条目列表(目录树 + 虚拟滚动) */}
-
- {displayEntries.slice(range.start, range.end).map((e) => { +
+ {rows.slice(range.start, range.end).map((row) => { + const n = row.node + const indent = { paddingLeft: 18 + row.depth * TREE_INDENT } + if (n.isDir) { + const collapsed = q === '' && collapsedDirs.has(n.path) + const meta = STATUS_META[n.agg ?? 'same'] + return ( +
toggleDir(n.path)} + > + {collapsed ? '▶' : '▼'} + {n.name} + {meta.label} + {n.fileCount ?? 0} 项 +
+ ) + } + const e = n.entry! const meta = STATUS_META[e.status] return (
onOpenEntry(e)} > {meta.icon} - {e.rel} + {n.name} {e.approximate && } {fmtSize(e.leftSize)} {fmtSize(e.rightSize)} @@ -168,11 +323,15 @@ export default function FolderView({ ) })}
- {!scanning && displayEntries.length === 0 && ( + {!scanning && rows.length === 0 && (
- {entries.length === 0 - ? '两个文件夹内都没有文件' - : '没有差异文件(两侧内容全部一致)'} + {q !== '' + ? `没有匹配“${q}”的文件` + : entries.length === 0 + ? '两个文件夹内都没有文件' + : statusFilter !== null + ? '当前过滤条件下没有文件' + : '没有差异文件(两侧内容全部一致)'}
)}
diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index a08096c..78e5882 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -132,7 +132,7 @@ export default function Toolbar({