feat: 0.7.0 文件夹对比增强(语义判等/子目录树形/搜索与状态过滤/报告导出/差异文件导航/大目录单点失败降级)+ 字符级分块锚定恢复(0.6.3 算法)与滚动回弹根因修复(重算期间保留旧结果撑住容器高度)+ E2E 滚动稳定性回归锚

This commit is contained in:
2026-08-18 18:40:03 +08:00
parent 74d52ec0b0
commit 1c52f5d3cb
25 changed files with 2703 additions and 281 deletions
+7 -5
View File
@@ -11,7 +11,9 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 不同 / 仅左 / 仅右种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、统计徽章与仅看差异过滤;双击任一文件进入单文件对比,一键返回文件夹列表 - **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑)批量审查,一键返回文件夹列表
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览)
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅 - **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应 - **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应
- **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径 - **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径
@@ -20,8 +22,8 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **交换左右侧**:一键互换两侧内容与视角 - **交换左右侧**:一键互换两侧内容与视角
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;内容超 3 万字符或差异过大时自动降级行级对比并提示(编辑距离封顶,最坏约 2 秒出结果,不会卡死) - **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;**相同内容自动锚定跳过计算(行锚定 + 变更组分块),大文件不再整篇受限**;差异过大时自动降级行级对比并提示(编辑距离封顶,不会卡死);重算期间保留旧结果撑住滚动位置,**切换选项不再回弹到顶部**
- **选项联动透明**:开启字符级对比时,被包含的空白选项灰显勾选并标注“已包含”(点击有解释提示,关闭字符级即恢复独立设置);内容或差异过大自动降级时开关旁标注“已降级”,状态一目了然 - **选项联动透明**:开启字符级对比时,被包含的空白选项灰显勾选并标注“已包含”(点击有解释提示,关闭字符级即恢复独立设置);差异过大自动降级时开关旁标注“已降级”,状态一目了然
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
- **偏好记忆**:比较选项与"仅看差异"开关自动保存,窗口尺寸/位置与上次打开文件目录一并记忆,下次启动自动恢复(损坏数据自动回退默认) - **偏好记忆**:比较选项与"仅看差异"开关自动保存,窗口尺寸/位置与上次打开文件目录一并记忆,下次启动自动恢复(损坏数据自动回退默认)
- **防误触清空**:清空前二次确认(提示窗口内再次点击才执行),粘贴输入的手动文本不因误点丢失 - **防误触清空**:清空前二次确认(提示窗口内再次点击才执行),粘贴输入的手动文本不因误点丢失
@@ -29,7 +31,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿 - **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数 - **差异统计徽章**:实时统计新增 / 删除 / 修改行数
- **导出一键报告**将对比结果导出 HTML / 纯文本 / Markdown 三种格式始终包含全量行)。HTML 报告为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(点击展开零丢失),并支持打印与窄屏 - **导出一键报告**单文件与文件夹对比均可导出 HTML / 纯文本 / Markdown 三种格式。单文件报告始终包含全量行HTML 为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(点击展开零丢失),并支持打印与窄屏;文件夹报告含判定口径说明与全量条目清单
- **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本 - **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本
- **滚动联动**:左右两个面板滚动位置自动同步 - **滚动联动**:左右两个面板滚动位置自动同步
@@ -108,7 +110,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
- 作者:thzxx - 作者:thzxx
- 组织:MetonaTeam - 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE) - 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.6.2 - 版本:0.7.0
--- ---
+11 -2
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。 > 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.6.2`** 当前基线版本:**`0.7.0`**
--- ---
@@ -81,7 +81,16 @@ z = 补丁版本号(Patch
- Playwright E2E 测试体系建立:对 build 产物启动真实 Electron 验收(复用项目自带 Electron,无需下载浏览器) · 首批 11 用例覆盖 jsdom 无法触达的主进程真实链路(GBK/BOM/大文件 worker 解码、报告写盘、剪贴板、偏好跨实例重启、文件夹真实扫描) · 主进程 dialog stubshowOpenDialog/showSaveDialog 可自动化) · 用例间清空 localStorage 隔离偏好污染 · npm run test:e2e 一键构建+验收,纳入发布 checklist - Playwright E2E 测试体系建立:对 build 产物启动真实 Electron 验收(复用项目自带 Electron,无需下载浏览器) · 首批 11 用例覆盖 jsdom 无法触达的主进程真实链路(GBK/BOM/大文件 worker 解码、报告写盘、剪贴板、偏好跨实例重启、文件夹真实扫描) · 主进程 dialog stubshowOpenDialog/showSaveDialog 可自动化) · 用例间清空 localStorage 隔离偏好污染 · npm run test:e2e 一键构建+验收,纳入发布 checklist
- 文件夹对比(MVP):新增「对比文件夹」入口(依次选择两侧目录) · 递归扫描按相对路径对齐,文件级状态判定(相同/不同/仅左/仅右;大小不同即不同,大小一致做字节级全量比对,超 10MB 采样头部 8KB 近似判定并标注 ≈) · symlink 跳过防环,单侧文件数上限 10000(超限截断提示) · FolderView 虚拟滚动列表(统计徽章/仅看差异默认开/大小列示) · 双击条目进入单文件对比复用全部 diff 能力,一键返回文件夹列表(扫描结果保留) · file:open 与 file:read-by-path 共用同一读取管线(10MB 上限/编码探测/二进制预警一致) - 文件夹对比(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.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 0.y.z ← 长期停留,永不进入 1.x
``` ```
+28 -16
View File
@@ -51,32 +51,36 @@ src/main/
├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用) ├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用)
├── decode.test.ts # 解码单元测试(BOM 三种/严格 UTF-8/GBK 回退/二进制启发式) ├── decode.test.ts # 解码单元测试(BOM 三种/严格 UTF-8/GBK 回退/二进制启发式)
├── decodeWorker.ts # 解码 worker_threads 入口(覆盖率排除,逻辑由 decode 测试覆盖) ├── decodeWorker.ts # 解码 worker_threads 入口(覆盖率排除,逻辑由 decode 测试覆盖)
├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对) ├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对/语义判等/单点失败降级
├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似) ├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似/语义判等/目录降级/unreadable
├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖) ├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖)
└── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸) └── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸)
src/renderer/src/ src/renderer/src/
├── test/ ├── test/
│ └── setup.ts # 全局 setupjest-dom 匹配器 + 自动 cleanup │ └── setup.ts # 全局 setupjest-dom 匹配器 + 自动 cleanup
├── diff/ ├── diff/
│ ├── diffEngine.test.ts # diff 引擎单元测试 │ ├── diffEngine.test.ts # diff 引擎单元测试(行级/词级/字符级分块锚定与降级/换位不变量)
│ ├── diffWorker.ts # diff 计算 Web Worker(内联打包,覆盖率排除,逻辑由 diffEngine 覆盖) │ ├── diffWorker.ts # diff 计算 Web Worker(内联打包,覆盖率排除,逻辑由 diffEngine 覆盖)
│ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化) │ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化)
│ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退 │ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退
│ ├── report.test.ts # 报告生成测试 │ ├── report.test.ts # 单文件报告生成测试
│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式)
│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable
│ └── textUtils.test.ts # 文本扩展名与行数统计测试 │ └── textUtils.test.ts # 文本扩展名与行数统计测试
├── hooks/ ├── hooks/
│ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理/字符级大字符量重路径) │ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理/字符级大字符量重路径/选项变化保留旧结果防滚动塌陷
│ ├── useDiff.ts │ ├── useDiff.ts
│ └── useDismiss.ts │ └── useDismiss.ts
├── components/ ├── components/
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出) │ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出)
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) │ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
│ ├── FolderView.test.tsx # 文件夹视图(统计/过滤/双击/虚拟滚动/截断与空态提示 │ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤/子树收集
│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索)
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/双击/导出菜单/虚拟滚动)
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗 │ ├── TextInputModal.test.tsx # 粘贴文本弹窗
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级标记) │ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级标记)
└── __tests__/ └── __tests__/
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击) ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击/语义判等链路/差异文件导航
└── main.test.ts # 入口挂载冒烟测试 └── main.test.ts # 入口挂载冒烟测试
``` ```
@@ -154,23 +158,30 @@ src/renderer/src/
### 5.9 文件夹扫描(`src/main/folderScan.ts`,真实临时目录) ### 5.9 文件夹扫描(`src/main/folderScan.ts`,真实临时目录)
- 状态判定:内容一致 same / 同大小内容不同 different / 大小不同 different / 单侧缺失 only(另一侧大小 null) - 状态判定:内容一致 same / 同大小内容不同 different / 大小不同 different / 单侧缺失 only(另一侧大小 null)
- 语义判等(0.7.0):semanticEquals 判等口径(空白/换行/空行/跨行重排判等,实质差异与大小写不判等);仅空白差异文本文件判 semantic-same(同大小与不同大小均覆盖);实质/大小写差异仍 different;非文本扩展名与超大小上限不参与(保持 different);未开启时行为与 0.6.2 一致;字节一致文件不触发解码
- 失败降级(0.7.0):单文件 IO 失败(模拟被锁)标记 unreadable 不影响其余条目;目录不可读跳过该目录(该侧按无文件处理,另一侧条目为 only),不再整体抛异常
- 递归子目录按相对路径对齐(统一 / 分隔);二进制内容字节级判定;symlink 跳过;条目字典序排序;total 取两侧较大值 - 递归子目录按相对路径对齐(统一 / 分隔);二进制内容字节级判定;symlink 跳过;条目字典序排序;total 取两侧较大值
- 边界与选项:两个空目录 / 目录不存在抛异常(IPC 层转错误结果) / maxFiles 截断(truncated + total + 条目数受限;截断后不再深入子目录,total 为已遍历下限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different - 边界与选项:两个空目录 / maxFiles 截断(truncated + total + 条目数受限;截断后不再深入子目录,total 为已遍历下限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different
- 受控并发:跨多个并发批次(> 2×SCAN_CONCURRENCY)的混合状态全部判定正确、条目排序与并发执行顺序无关(失败整体抛出由 Promise.all 语义保证,与串行实现一致,不单测) - 受控并发:跨多个并发批次(> 2×SCAN_CONCURRENCY)的混合状态全部判定正确、条目排序与并发执行顺序无关
### 5.10 文件夹视图(`FolderView.tsx` ### 5.10 文件夹视图(`FolderView.tsx`与条目树(`folderTree.ts`
- 渲染:目录路径卡片 / 重新选择与返回按钮 / 统计徽章(相同/不同/仅左/仅右/总数) - 条目树纯逻辑:按路径分段聚合目录/文件(同层字典序);目录聚合子树状态(different > unreadable > only > 语义同 > 相同)与文件计数;折叠目录子树隐藏;过滤(onlyDiff / statusFilter / query 子串不区分大小写);空目录剪枝由“过滤后建树”天然保证
- 过滤:仅看差异隐藏 same、关闭展示全部;双击回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;截断提示;空态提示(无文件 / 全部一致 - 渲染:目录路径卡片 / 重新选择、导出报告与返回按钮 / 统计徽章(相同/语义同/不同/仅左/仅右/无法读取/总数;语义关闭隐藏语义同、无失败隐藏无法读取
- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按全部条目撑满 - 树形交互:目录行点击折叠展开(默认全展开);搜索命中强制展开命中路径、清空恢复用户折叠;无命中提示
- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动)
- 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果)
- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满
### 5.11 E2E 端到端(`e2e/app.spec.ts`,对 build 产物启动真实 Electron ### 5.11 E2E 端到端(`e2e/app.spec.ts`,对 build 产物启动真实 Electron
- 启动冒烟:标题与双空态面板、空态无导出入口 - 启动冒烟:标题与双空态面板、空态无导出入口
- 粘贴两侧:差异渲染 + 统计徽章(主进程剪贴板链路) - 粘贴两侧:差异渲染 + 统计徽章(主进程剪贴板链路)
- 编码链路:GBK 与 UTF-8 BOM 文件打开(编码标注 + 内容渲染)、大体积 GBK 文件经解码 worker 后台解码完整渲染(状态栏总行数断言) - 编码链路:GBK 与 UTF-8 BOM 文件打开(编码标注 + 内容渲染)、大体积 GBK 文件经解码 worker 后台解码完整渲染(状态栏总行数断言)
- 比较选项:忽略大小写与忽略所有空白组合、字符级跨行重组判一致 - 比较选项:忽略大小写与忽略所有空白组合、字符级跨行重组判一致
- 滚动稳定性回归锚(0.7.0):真实文件(行数不等)→ 开字符级进 worker 重路径 → 滚到中部 → 切选项二次重算 → 断言 scrollTop 保持不归零、滚到底内容连续(0.6.3 回退缺陷的固化防线)
- 导航与视图:F7 / Shift+F7、仅看差异折叠、交换左右后面板互换 - 导航与视图:F7 / Shift+F7、仅看差异折叠、交换左右后面板互换
- 报告导出:stub 保存对话框 → 文件真实落盘且含结论与统计 - 报告导出:stub 保存对话框 → 文件 HTML 报告真实落盘且含结论与统计
- 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留) - 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤 / 树形显示)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留)
- 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目)
- 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData) - 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData)
- 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理) - 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理)
@@ -213,7 +224,8 @@ npm run build
## 9. 演进方向(后续迭代) ## 9. 演进方向(后续迭代)
- 文件夹对比增强(0.7.0 候选):子目录树形展示 / 按扩展名过滤 / 文件夹对比报告导出 / 语义判定(套用忽略空白等选项 - 文件夹对比深度增强:目录行双击聚合对比 / 按扩展名过滤 / 文件夹对比结果的增量重扫(记忆上次结果只扫变更
- 字符级对比能力扩展:变更组粒度的进一步放宽(大改文件分块提示而非整体降级)
- E2E 覆盖扩展:拖拽导入链路 / 大文件性能基准 - E2E 覆盖扩展:拖拽导入链路 / 大文件性能基准
- 主进程 IPC 层补充集成测试(folder:scan 并发场景) - 主进程 IPC 层补充集成测试(folder:scan 并发场景)
+127 -2
View File
@@ -114,6 +114,70 @@ test.describe('比较选项端到端', () => {
await expect(window.getByText('两文件内容完全一致')).toBeVisible() await expect(window.getByText('两文件内容完全一致')).toBeVisible()
await app.close() 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('差异导航与视图', () => { test.describe('差异导航与视图', () => {
@@ -205,9 +269,10 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
await expect(window.getByText('相同 1')).toBeVisible() await expect(window.getByText('相同 1')).toBeVisible()
await expect(window.getByText('不同 2')).toBeVisible() await expect(window.getByText('不同 2')).toBeVisible()
await expect(window.getByText('仅左 1')).toBeVisible() await expect(window.getByText('仅左 1')).toBeVisible()
// 默认仅看差异:same.txt 被过滤,差异条目可见 // 默认仅看差异:same.txt 被过滤,差异条目可见(树形视图显示文件尾段名)
await expect(window.getByText('diff.txt')).toBeVisible() 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) await expect(window.getByText('same.txt')).toHaveCount(0)
// 双击差异条目进入单文件对比 // 双击差异条目进入单文件对比
@@ -226,6 +291,66 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
await removeTmpDir(root) 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('偏好持久化(跨实例重启)', () => { test.describe('偏好持久化(跨实例重启)', () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.6.2", "version": "0.7.0",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+147 -3
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises' import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import { join } from 'path' import { join } from 'path'
import { scanFolders, type FolderEntry } from './folderScan' import { scanFolders, semanticEquals, SEMANTIC_MAX_BYTES, type FolderEntry } from './folderScan'
/** 以临时目录为根构造两个待对比文件夹 */ /** 以临时目录为根构造两个待对比文件夹 */
let leftDir: string let leftDir: string
@@ -114,8 +114,11 @@ describe('scanFolders - 边界与选项', () => {
} }
}) })
it('目录不存在时抛出异常(由 IPC 层转为错误结果', async () => { it('目录不存在时不再抛异常:该侧按无文件处理(另一侧条目为 only', async () => {
await expect(scanFolders(join(leftDir, 'not-exist'), rightDir)).rejects.toThrow() // 单目录 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 () => { 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<string> => 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 - 受控并发内容比对', () => { describe('scanFolders - 受控并发内容比对', () => {
it('跨多个并发批次的混合状态全部判定正确且排序不变', async () => { it('跨多个并发批次的混合状态全部判定正确且排序不变', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-conc-l-')) const l = await mkdtemp(join(tmpdir(), 'difflens-conc-l-'))
+103 -27
View File
@@ -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 { export interface FolderEntry {
/** 相对路径(统一 / 分隔,含子目录前缀) */ /** 相对路径(统一 / 分隔,含子目录前缀) */
@@ -36,6 +42,34 @@ export interface ScanOptions {
maxFiles?: number maxFiles?: number
/** 全量字节比对的文件大小上限(默认 10MB,超出仅采样头部 8KB 近似判定) */ /** 全量字节比对的文件大小上限(默认 10MB,超出仅采样头部 8KB 近似判定) */
maxContentBytes?: number maxContentBytes?: number
/** 语义判等(可选):字节级判 different 的文本文件二次判等,剔除空白后一致 → semantic-same */
semantic?: SemanticOptions
}
/**
* 语义判等的依赖注入(folderScan 保持纯逻辑可测,解码与扩展名判定由调用方注入)。
* 生产环境 decode 走主进程解码 worker(大缓冲不阻塞事件循环),测试注入同步实现。
*/
export interface SemanticOptions {
/** 参与语义判等的单侧文件大小上限 */
maxBytes: number
/** 相对路径是否为文本文件(按扩展名判定,与打开文件对话框的清单对齐) */
isTextFile: (rel: string) => boolean
/** 解码文件字节为文本(BOM/编码探测由注入实现处理) */
decode: (buf: Buffer) => Promise<string>
}
/** 语义判等参与文件的大小上限(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 export const FOLDER_MAX_FILES = 10000
@@ -66,7 +100,13 @@ async function listFiles(
const queue: Array<{ dir: string; prefix: string }> = [{ dir: root, prefix: '' }] const queue: Array<{ dir: string; prefix: string }> = [{ dir: root, prefix: '' }]
while (queue.length > 0) { while (queue.length > 0) {
const { dir, prefix } = queue.shift()! 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) { for (const d of dirents) {
// Dirent 为 lstat 语义:symlink 既非 file 也非 directory,天然跳过(防环) // Dirent 为 lstat 语义:symlink 既非 file 也非 directory,天然跳过(防环)
if (d.isDirectory()) { if (d.isDirectory()) {
@@ -83,10 +123,14 @@ async function listFiles(
// 凑满即停:不再处理队列中剩余的子目录(当前目录统计完整,保证确定性) // 凑满即停:不再处理队列中剩余的子目录(当前目录统计完整,保证确定性)
if (truncated) break if (truncated) break
} }
// 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束) // 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束)
const stats = await Promise.all(rels.map((rel) => stat(join(root, rel)))) // 单文件 stat 失败(被锁/权限)跳过该条目,不整体失败
const stats = await Promise.allSettled(rels.map((rel) => stat(join(root, rel))))
const files = new Map<string, number>() const files = new Map<string, number>()
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 } return { files, truncated, total }
} }
@@ -104,10 +148,12 @@ async function readHead(path: string, bytes: number): Promise<Buffer> {
/** /**
* 对比两个文件夹:按相对路径对齐条目并判定内容异同。 * 对比两个文件夹:按相对路径对齐条目并判定内容异同。
* 判定规则:单侧缺失 → only;大小不同 → different * 判定规则:单侧缺失 → only;大小一致且 ≤ maxContentBytes → 全量字节比对
* 大小相同且 ≤ maxContentBytes → 全量字节比对;超过上限 → 头部 8KB 采样近似判定。 * 大小不同(≤ 语义上限)或字节不同 → 字节级 different(开启语义判等时二次判等,
* 大小一致的条目按 SCAN_CONCURRENCY 受控并发读取比对(输出与顺序和串行实现完全一致, * 剔除空白后一致改判 semantic-same);超过全量比对上限 → 头部 8KB 采样近似判定。
* 任一文件读取失败仍整体抛出,由 IPC 调用方转为错误结果)。 * 需读内容的条目按 SCAN_CONCURRENCY 受控并发执行(输出与顺序和串行实现完全一致);
* 单文件/单目录 IO 失败(被锁/权限,如 Windows Defender 占用)降级处理——
* 文件标记 unreadable、目录跳过,不整体失败(大目录下单点锁不应丢弃全部结果)。
*/ */
export async function scanFolders( export async function scanFolders(
leftDir: string, leftDir: string,
@@ -116,9 +162,17 @@ export async function scanFolders(
): Promise<ScanResult> { ): Promise<ScanResult> {
const maxFiles = options.maxFiles ?? FOLDER_MAX_FILES const maxFiles = options.maxFiles ?? FOLDER_MAX_FILES
const maxContentBytes = options.maxContentBytes ?? FOLDER_MAX_CONTENT_BYTES const maxContentBytes = options.maxContentBytes ?? FOLDER_MAX_CONTENT_BYTES
const semantic = options.semantic
const left = await listFiles(leftDir, maxFiles) const left = await listFiles(leftDir, maxFiles)
const right = await listFiles(rightDir, 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() const rels = [...new Set([...left.files.keys(), ...right.files.keys()])].sort()
// 预分配结果槽位:无 IO 的条目直接定案,需读内容的条目收集为任务并发执行 // 预分配结果槽位:无 IO 的条目直接定案,需读内容的条目收集为任务并发执行
const entries: FolderEntry[] = new Array(rels.length) 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 } entries[idx] = { rel, status: 'right-only', leftSize: null, rightSize: rSize ?? null }
} else if (rSize === undefined) { } else if (rSize === undefined) {
entries[idx] = { rel, status: 'left-only', leftSize: lSize, rightSize: null } 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 } 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({ tasks.push({
idx, idx,
run: async () => { run: async () => {
const [lBuf, rBuf] = await Promise.all([ try {
readFile(join(leftDir, rel)), const [lHead, rHead] = await Promise.all([
readFile(join(rightDir, rel)) readHead(join(leftDir, rel), SAMPLE_BYTES),
]) readHead(join(rightDir, rel), SAMPLE_BYTES)
entries[idx] = lBuf.equals(rBuf) ])
? { rel, status: 'same', leftSize: lSize, rightSize: rSize } entries[idx] = lHead.equals(rHead)
: { rel, status: 'different', leftSize: lSize, rightSize: rSize } ? { 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 { } else {
// 超过全量比对上限:头部采样近似判定,结果带 approximate 标注 // 大小一致(≤ 上限)全量字节比对;大小不同且均在语义上限内读两侧全量。
// 字节 different 且开启语义判等时:同一批读取上接语义判等(避免二次 IO);
// 单文件读取/解码失败(被锁/权限)标记 unreadable,不整体失败
tasks.push({ tasks.push({
idx, idx,
run: async () => { run: async () => {
const [lHead, rHead] = await Promise.all([ try {
readHead(join(leftDir, rel), SAMPLE_BYTES), const [lBuf, rBuf] = await Promise.all([
readHead(join(rightDir, rel), SAMPLE_BYTES) readFile(join(leftDir, rel)),
]) readFile(join(rightDir, rel))
entries[idx] = lHead.equals(rHead) ])
? { rel, status: 'same', leftSize: lSize, rightSize: rSize, approximate: true } if (lBuf.equals(rBuf)) {
: { rel, status: 'different', leftSize: lSize, rightSize: rSize } 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) { for (let i = 0; i < tasks.length; i += SCAN_CONCURRENCY) {
await Promise.all(tasks.slice(i, i + SCAN_CONCURRENCY).map((t) => t.run())) await Promise.all(tasks.slice(i, i + SCAN_CONCURRENCY).map((t) => t.run()))
} }
+34 -6
View File
@@ -5,7 +5,27 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import fs from 'fs' import fs from 'fs'
import { parseState, clampBounds, type AppState } from './windowState' import { parseState, clampBounds, type AppState } from './windowState'
import { decodeText, type DecodeResult } from './decode' import { decodeText, type DecodeResult } from './decode'
import { scanFolders } from './folderScan' import { scanFolders, SEMANTIC_MAX_BYTES } from './folderScan'
/**
* 文本类扩展名清单(小写,不含点):
* 与渲染进程 TEXT_EXTENSIONSsrc/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 目录 */ /** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */
const stateFile = (): string => join(app.getPath('userData'), 'window-state.json') 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, defaultPath: appState.lastDir,
properties: ['openFile'], properties: ['openFile'],
filters: [ filters: [
// 与渲染进程 TEXT_EXTENSIONSsrc/renderer/src/diff/textUtils.ts)完全对齐;两进程无法共享模块,改动需双向同步 { name: '文本文件', extensions: TEXT_FILE_EXTENSIONS },
{ 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: ['*'] } { name: '所有文件', extensions: ['*'] }
] ]
@@ -244,10 +263,19 @@ ipcMain.handle('folder:pick', async () => {
return dirPath return dirPath
}) })
/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定,异步 fs 不阻塞主进程) */ /** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等;异步 fs 与解码 worker 不阻塞主进程) */
ipcMain.handle('folder:scan', async (_event, leftDir: string, rightDir: string) => { ipcMain.handle('folder:scan', async (_event, leftDir: string, rightDir: string, semantic: boolean) => {
try { 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 { } catch {
// 目录不存在/不可读等:转为错误标记,渲染端提示 // 目录不存在/不可读等:转为错误标记,渲染端提示
return { error: 'scan-failed' as const } return { error: 'scan-failed' as const }
+14 -4
View File
@@ -22,8 +22,14 @@ export interface FileOpenError {
/** 打开文件结果:成功数据 / 失败原因 / 用户取消(null) */ /** 打开文件结果:成功数据 / 失败原因 / 用户取消(null) */
export type FileOpenResult = FileData | FileOpenError | null export type FileOpenResult = FileData | FileOpenError | null
/** 文件夹对比条目状态(与主进程 folderScan 对齐) */ /** 文件夹对比条目状态(与主进程 folderScan 对齐semantic-same = 字节不同但剔除空白后内容一致;unreadable = 读取失败被锁/权限 */
export type FolderEntryStatus = 'same' | 'different' | 'left-only' | 'right-only' export type FolderEntryStatus =
| 'same'
| 'semantic-same'
| 'different'
| 'left-only'
| 'right-only'
| 'unreadable'
/** 文件夹对比单个条目 */ /** 文件夹对比单个条目 */
export interface FolderEntry { export interface FolderEntry {
@@ -60,8 +66,12 @@ const api = {
ipcRenderer.invoke('clipboard:write', text), ipcRenderer.invoke('clipboard:write', text),
pickFolder: (): Promise<string | null> => pickFolder: (): Promise<string | null> =>
ipcRenderer.invoke('folder:pick'), ipcRenderer.invoke('folder:pick'),
scanFolder: (leftDir: string, rightDir: string): Promise<FolderScanResult | FolderScanError> => scanFolder: (
ipcRenderer.invoke('folder:scan', leftDir, rightDir), leftDir: string,
rightDir: string,
semantic: boolean
): Promise<FolderScanResult | FolderScanError> =>
ipcRenderer.invoke('folder:scan', leftDir, rightDir, semantic),
readByPath: (filePath: string): Promise<FileOpenResult> => readByPath: (filePath: string): Promise<FileOpenResult> =>
ipcRenderer.invoke('file:read-by-path', filePath) ipcRenderer.invoke('file:read-by-path', filePath)
} }
+164 -19
View File
@@ -13,6 +13,7 @@ import Toolbar from './components/Toolbar'
import ContextMenu, { type ContextMenuItem } from './components/ContextMenu' import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
import TextInputModal from './components/TextInputModal' import TextInputModal from './components/TextInputModal'
import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report' 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 { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils'
import { useDismiss } from './hooks/useDismiss' import { useDismiss } from './hooks/useDismiss'
import { useDiff } from './hooks/useDiff' import { useDiff } from './hooks/useDiff'
@@ -54,12 +55,27 @@ const PREF_BOOL_KEYS = [
'charMode' 'charMode'
] as const satisfies (keyof DiffOptions)[] ] as const satisfies (keyof DiffOptions)[]
/** 持久化偏好的完整结构(比较选项 + 单文件/文件夹视图开关) */
interface Prefs {
options: DiffOptions
onlyDiff: boolean
/** 文件夹模式「仅看差异」(默认开:文件夹场景默认关心差异文件) */
folderOnlyDiff: boolean
/** 文件夹模式「语义判等」(默认开:换行符/空白差异不再计为不同) */
folderSemantic: boolean
}
/** /**
* 读取持久化偏好:只接受已知字段的严格 boolean 值; * 读取持久化偏好:只接受已知字段的严格 boolean 值;
* 数据损坏(JSON 非法 / 字段类型异常 / 存储不可用)一律回退默认,保证启动不受影响。 * 数据损坏(JSON 非法 / 字段类型异常 / 存储不可用)一律回退默认,保证启动不受影响。
*/ */
function loadPrefs(): { options: DiffOptions; onlyDiff: boolean } { function loadPrefs(): Prefs {
const fallback = { options: { ...DEFAULT_OPTIONS }, onlyDiff: false } const fallback: Prefs = {
options: { ...DEFAULT_OPTIONS },
onlyDiff: false,
folderOnlyDiff: true,
folderSemantic: true
}
let raw: string | null = null let raw: string | null = null
try { try {
raw = window.localStorage.getItem(PREFS_KEY) raw = window.localStorage.getItem(PREFS_KEY)
@@ -71,13 +87,20 @@ function loadPrefs(): { options: DiffOptions; onlyDiff: boolean } {
const obj = JSON.parse(raw) as { const obj = JSON.parse(raw) as {
options?: Partial<Record<keyof DiffOptions, unknown>> options?: Partial<Record<keyof DiffOptions, unknown>>
onlyDiff?: unknown 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 } const options: DiffOptions = { ...DEFAULT_OPTIONS }
for (const k of PREF_BOOL_KEYS) { 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 { } catch {
return fallback return fallback
} }
@@ -163,10 +186,14 @@ export default function App(): ReactElement {
(FolderScanResult & { leftDir: string; rightDir: string }) | null (FolderScanResult & { leftDir: string; rightDir: string }) | null
>(null) >(null)
const [folderScanning, setFolderScanning] = useState(false) const [folderScanning, setFolderScanning] = useState(false)
// 文件夹模式的「仅看差异」:与单文件模式的 onlyDiff 独立(文件夹场景默认关心差异文件 // 文件夹进入单文件对比时的当前条目 rel(差异文件导航的游标;返回/退出文件夹模式时清空
const [folderOnlyDiff, setFolderOnlyDiff] = useState(true) const [folderEntryCursor, setFolderEntryCursor] = useState<string | null>(null)
// 偏好只读一次盘:两个初始 state 共享同一次 loadPrefs 结果(原实现挂载期读取两次) // 偏好只读一次盘:全部初始 state 共享同一次 loadPrefs 结果(原实现挂载期读取两次)
const [initialPrefs] = useState(() => loadPrefs()) const [initialPrefs] = useState(() => loadPrefs())
// 文件夹模式的「仅看差异」:与单文件模式的 onlyDiff 独立(文件夹场景默认关心差异文件)
const [folderOnlyDiff, setFolderOnlyDiff] = useState<boolean>(initialPrefs.folderOnlyDiff)
// 文件夹模式的「语义判等」:字节不同但剔除空白后一致计为语义同(默认开,消除换行符误报)
const [folderSemantic, setFolderSemantic] = useState<boolean>(initialPrefs.folderSemantic)
// 比较选项与视图开关:初始值从持久化偏好恢复 // 比较选项与视图开关:初始值从持久化偏好恢复
const [options, setOptions] = useState<DiffOptions>(initialPrefs.options) const [options, setOptions] = useState<DiffOptions>(initialPrefs.options)
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行) // 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
@@ -222,14 +249,17 @@ export default function App(): ReactElement {
} }
}, []) }, [])
// 偏好写回:比较选项与视图开关变更即持久化(存储不可用时静默忽略,不影响本次会话) // 偏好写回:比较选项与全部视图开关变更即持久化(存储不可用时静默忽略,不影响本次会话)
useEffect(() => { useEffect(() => {
try { try {
window.localStorage.setItem(PREFS_KEY, JSON.stringify({ options, onlyDiff })) window.localStorage.setItem(
PREFS_KEY,
JSON.stringify({ options, onlyDiff, folderOnlyDiff, folderSemantic })
)
} catch { } catch {
// 忽略写入失败(隐私模式/配额满等) // 忽略写入失败(隐私模式/配额满等)
} }
}, [options, onlyDiff]) }, [options, onlyDiff, folderOnlyDiff, folderSemantic])
// 超多行预警:行数超过阈值时提示后台计算耗时(渲染已虚拟化、计算已 worker 化,均不阻塞界面) // 超多行预警:行数超过阈值时提示后台计算耗时(渲染已虚拟化、计算已 worker 化,均不阻塞界面)
const warnHeavyLines = useCallback( const warnHeavyLines = useCallback(
@@ -323,12 +353,12 @@ export default function App(): ReactElement {
[showToast, warnHeavyLines] [showToast, warnHeavyLines]
) )
/** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式) */ /** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式;语义判等开关随扫描参数下发 */
const runFolderScan = useCallback( const runFolderScan = useCallback(
async (leftDir: string, rightDir: string): Promise<void> => { async (leftDir: string, rightDir: string, semantic: boolean): Promise<void> => {
setFolderScanning(true) setFolderScanning(true)
try { try {
const res = await window.api.scanFolder(leftDir, rightDir) const res = await window.api.scanFolder(leftDir, rightDir, semantic)
if ('error' in res) { if ('error' in res) {
showToast('文件夹扫描失败,请检查目录是否存在且可访问') showToast('文件夹扫描失败,请检查目录是否存在且可访问')
return return
@@ -362,8 +392,51 @@ export default function App(): ReactElement {
showToast('两次选择的是同一个文件夹,请选择两个不同的文件夹') showToast('两次选择的是同一个文件夹,请选择两个不同的文件夹')
return return
} }
await runFolderScan(leftDir, rightDir) await runFolderScan(leftDir, rightDir, folderSemantic)
}, [showToast, runFolderScan]) }, [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<void> => {
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( const openFolderEntry = useCallback(
@@ -406,23 +479,48 @@ export default function App(): ReactElement {
entry.status === 'left-only' ? null : `${folderState.rightDir}/${entry.rel}` 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] [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 => { const backToFolder = useCallback((): void => {
setPaneL(null) setPaneL(null)
setPaneR(null) setPaneR(null)
setFolderEntryCursor(null)
setMode('folder') setMode('folder')
}, []) }, [])
/** 退出文件夹对比(清空文件夹状态与两侧面板,回到单文件空态) */ /** 退出文件夹对比(清空文件夹状态、导航游标与两侧面板,回到单文件空态) */
const exitFolder = useCallback((): void => { const exitFolder = useCallback((): void => {
setFolderState(null) setFolderState(null)
setPaneL(null) setPaneL(null)
setPaneR(null) setPaneR(null)
setFolderEntryCursor(null)
setMode('file') setMode('file')
}, []) }, [])
@@ -537,6 +635,18 @@ export default function App(): ReactElement {
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [go, pasteSide, menu]) }, [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( const onRowContext = useCallback(
(e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => { (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => {
@@ -652,6 +762,38 @@ export default function App(): ReactElement {
</button> </button>
)} )}
{folderState && folderEntryCursor !== null && diffFileEntries.length > 0 && (
<div className="tool-group diff-file-nav" title="在文件夹的差异文件之间跳转(Alt+↓ / Alt+↑)">
<button
className="nav-btn"
title="上一个差异文件(Alt+↑)"
disabled={
diffFileEntries.findIndex((e) => e.rel === folderEntryCursor) <= 0
}
onClick={() => goDiffFile(-1)}
>
</button>
<span className="nav-count">
{(() => {
const i = diffFileEntries.findIndex((e) => e.rel === folderEntryCursor)
return i >= 0 ? `${i + 1} / ${diffFileEntries.length}` : '—'
})()}
</span>
<button
className="nav-btn"
title="下一个差异文件(Alt+↓)"
disabled={
diffFileEntries.findIndex((e) => e.rel === folderEntryCursor) >=
diffFileEntries.length - 1
}
onClick={() => goDiffFile(1)}
>
</button>
<span className="diff-file-label"></span>
</div>
)}
<button className="btn ghost" onClick={() => void openPane('left')}> <button className="btn ghost" onClick={() => void openPane('left')}>
</button> </button>
@@ -711,8 +853,11 @@ export default function App(): ReactElement {
scanning={folderScanning} scanning={folderScanning}
onlyDiff={folderOnlyDiff} onlyDiff={folderOnlyDiff}
onOnlyDiffChange={setFolderOnlyDiff} onOnlyDiffChange={setFolderOnlyDiff}
semantic={folderSemantic}
onSemanticChange={handleSemanticChange}
onReopen={() => void startFolderCompare()} onReopen={() => void startFolderCompare()}
onExit={exitFolder} onExit={exitFolder}
onExport={(fmt) => void exportFolderReport(fmt)}
onOpenEntry={(e) => void openFolderEntry(e)} onOpenEntry={(e) => void openFolderEntry(e)}
/> />
) : anyPane ? ( ) : anyPane ? (
+255 -16
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 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' import App from '../App'
let clipboardSpy: ReturnType<typeof vi.fn> let clipboardSpy: ReturnType<typeof vi.fn>
@@ -754,28 +754,52 @@ describe('App - 字符级对比', () => {
expect(await screen.findByText(/「忽略所有空白」已包含在字符级对比中/)).toBeInTheDocument() expect(await screen.findByText(/「忽略所有空白」已包含在字符级对比中/)).toBeInTheDocument()
}) })
it('内容超限时自动降级行级并给出提示', async () => { it('差异过大时自动降级行级并给出提示', async () => {
// 1900 行 × 106 字符 ≈ 201,400 归一化字符(超过 20 万阈值) // 单行全差异构造:变更组流 = 25001 + 25001 > 5 万上限
// 总行数 1901 未超过快路径阈值,同步计算即时降级 // 总行数 1 未超过快路径阈值,同步计算即时降级
const big = Array.from({ length: 1900 }, () => 'a'.repeat(106)).join('\n')
window.api = mockApi({ window.api = mockApi({
openFile: async () => ({ openFile: async (side) =>
path: '/tmp/big.txt', side === 'left'
name: 'big.txt', ? { path: '/tmp/big.txt', name: 'big.txt', text: 'a'.repeat(25001), encoding: 'UTF-8', binary: false }
text: big, : { path: '/tmp/big2.txt', name: 'big2.txt', text: 'b'.repeat(25001), encoding: 'UTF-8', binary: false }
encoding: 'UTF-8',
binary: false
})
}) })
render(<App />) render(<App />)
fireEvent.click(screen.getByText('打开左侧')) fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告') await screen.findByText('导出报告')
fireEvent.click(screen.getByText('打开右侧'))
await screen.findAllByText('big2.txt')
fireEvent.click(screen.getByLabelText('字符级对比')) fireEvent.click(screen.getByLabelText('字符级对比'))
expect(await screen.findByText(/已自动降级为行级对比/)).toBeInTheDocument() expect(await screen.findByText(/已自动降级为行级对比/)).toBeInTheDocument()
// 降级状态在工具栏可见:字符级开关旁出现“已降级”标记 // 降级状态在工具栏可见:字符级开关旁出现“已降级”标记
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(<App />)
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 () => { it('导出报告携带字符级对比说明', async () => {
let saved = '' let saved = ''
window.api = mockApi({ window.api = mockApi({
@@ -798,17 +822,17 @@ describe('App - 字符级对比', () => {
fireEvent.click(screen.getByText('导出报告')) fireEvent.click(screen.getByText('导出报告'))
fireEvent.click(screen.getByText('纯文本')) fireEvent.click(screen.getByText('纯文本'))
await screen.findByText(/报告已保存/) await screen.findByText(/报告已保存/)
expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行)') expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行,大内容自动分块计算')
}) })
it('降级后导出报告按实际生效的行级语义描述选项', async () => { it('降级后导出报告按实际生效的行级语义描述选项', async () => {
let saved = '' let saved = ''
const big = Array.from({ length: 1900 }, () => 'a'.repeat(106)).join('\n') // 单行全差异(变更组流超 5 万上限):字符级降级为行级语义
window.api = mockApi({ window.api = mockApi({
openFile: async (side) => openFile: async (side) =>
side === 'left' side === 'left'
? { path: '/tmp/big.txt', name: 'big.txt', text: big, 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', 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) => { saveReport: async (content: string) => {
saved = content saved = content
return { ok: true, path: 'C:/docs/r.txt' } return { ok: true, path: 'C:/docs/r.txt' }
@@ -1180,4 +1204,219 @@ describe('App - 文件夹对比', () => {
expect(paneFiles[0]).toBe('b.txt') expect(paneFiles[0]).toBe('b.txt')
expect(paneFiles[1]).toBe('(未选择)') 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(<App />)
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(<App />)
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('<!doctype html>')
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(<App />)
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(<App />)
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(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
expect(screen.queryByText('差异文件')).not.toBeInTheDocument()
// Alt+↓ 不触发任何导航(无报错即通过)
fireEvent.keyDown(window, { key: 'ArrowDown', altKey: true })
expect(screen.getByText('导出报告')).toBeInTheDocument()
})
}) })
+296 -40
View File
@@ -5,12 +5,18 @@ import type { FolderEntry } from '../../../preload/index'
const ENTRIES: FolderEntry[] = [ const ENTRIES: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 5, rightSize: 5 }, { 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: 'b.txt', status: 'different', leftSize: 3, rightSize: 9 },
{ rel: 'only-left.txt', status: 'left-only', leftSize: 1, rightSize: null }, { 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<typeof render> { function renderView(
entries: FolderEntry[] = ENTRIES,
onlyDiff = true,
semantic = true
): ReturnType<typeof render> {
return render( return render(
<FolderView <FolderView
leftDir="C:/left" leftDir="C:/left"
@@ -21,14 +27,17 @@ function renderView(entries: FolderEntry[] = ENTRIES, onlyDiff = true): ReturnTy
scanning={false} scanning={false}
onlyDiff={onlyDiff} onlyDiff={onlyDiff}
onOnlyDiffChange={vi.fn()} onOnlyDiffChange={vi.fn()}
semantic={semantic}
onSemanticChange={vi.fn()}
onReopen={vi.fn()} onReopen={vi.fn()}
onExit={vi.fn()} onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()} onOpenEntry={vi.fn()}
/> />
) )
} }
describe('FolderView', () => { describe('FolderView - 树形渲染', () => {
it('渲染两侧目录路径与操作按钮', () => { it('渲染两侧目录路径与操作按钮', () => {
renderView() renderView()
expect(screen.getAllByText('C:/left').length).toBeGreaterThan(0) expect(screen.getAllByText('C:/left').length).toBeGreaterThan(0)
@@ -37,38 +46,38 @@ describe('FolderView', () => {
expect(screen.getByRole('button', { name: '返回文件对比' })).toBeInTheDocument() expect(screen.getByRole('button', { name: '返回文件对比' })).toBeInTheDocument()
}) })
it('渲染统计徽章(相同/不同/仅左/仅右/总数)', () => { it('目录聚合为树形:根层文件与目录混排(字典序),目录行显示聚合状态与计数', () => {
renderView() const { container } = renderView(ENTRIES, false)
expect(screen.getByText('相同 1')).toBeInTheDocument() // 根层(关闭过滤后 a.txt/sem.txt/b.txt/only-left.txt/sub 可见)
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(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={4}
scanning={false}
onlyDiff={false}
onOnlyDiffChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onOpenEntry={vi.fn()}
/>
)
expect(screen.getByText('a.txt')).toBeInTheDocument() expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.getByText('sub')).toBeInTheDocument()
const dirRow = container.querySelector('.folder-row.dir')
expect(dirRow).not.toBeNull()
// sub 子树含 differentdiff.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() const onOpenEntry = vi.fn()
render( render(
<FolderView <FolderView
@@ -76,21 +85,261 @@ describe('FolderView', () => {
rightDir="D:/right" rightDir="D:/right"
entries={ENTRIES} entries={ENTRIES}
truncated={false} truncated={false}
total={4} total={6}
scanning={false} scanning={false}
onlyDiff={true} onlyDiff={true}
onOnlyDiffChange={vi.fn()} onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()} onReopen={vi.fn()}
onExit={vi.fn()} onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={onOpenEntry} onOpenEntry={onOpenEntry}
/> />
) )
fireEvent.dblClick(screen.getByText('b.txt')) 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(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={6}
scanning={false}
onlyDiff={false}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
/>
)
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(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={6}
scanning={false}
onlyDiff={true}
onOnlyDiffChange={onOnlyDiffChange}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
/>
)
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(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={6}
scanning={false}
onlyDiff={true}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={onSemanticChange}
onReopen={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
/>
)
fireEvent.click(screen.getByLabelText('语义判等'))
expect(onSemanticChange).toHaveBeenCalledWith(false)
})
it('导出报告菜单选择格式触发 onExport 并关闭菜单', () => {
const onExport = vi.fn()
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={6}
scanning={false}
onlyDiff={true}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onExport={onExport}
onOpenEntry={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
fireEvent.click(screen.getByText('HTML 报告'))
expect(onExport).toHaveBeenCalledWith('html')
expect(screen.queryByText('HTML 报告')).not.toBeInTheDocument()
})
it('扫描中禁用导出按钮', () => {
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={6}
scanning={true}
onlyDiff={true}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: '导出报告' })).toBeDisabled()
})
it('点击外部区域关闭导出菜单', () => {
render(
<FolderView
leftDir="C:/left"
rightDir="D:/right"
entries={ENTRIES}
truncated={false}
total={6}
scanning={false}
onlyDiff={true}
onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()}
onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
expect(screen.getByText('纯文本')).toBeInTheDocument()
fireEvent.mouseDown(document.body)
expect(screen.queryByText('纯文本')).not.toBeInTheDocument()
}) })
it('文件大小格式化(B/KB 与缺失占位)', () => { 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('2.0 KB').length).toBe(2)
expect(screen.getAllByText('—').length).toBeGreaterThan(0) expect(screen.getAllByText('—').length).toBeGreaterThan(0)
}) })
@@ -111,8 +360,11 @@ describe('FolderView', () => {
scanning={false} scanning={false}
onlyDiff={true} onlyDiff={true}
onOnlyDiffChange={vi.fn()} onOnlyDiffChange={vi.fn()}
semantic={true}
onSemanticChange={vi.fn()}
onReopen={vi.fn()} onReopen={vi.fn()}
onExit={vi.fn()} onExit={vi.fn()}
onExport={vi.fn()}
onOpenEntry={vi.fn()} onOpenEntry={vi.fn()}
/> />
) )
@@ -129,18 +381,22 @@ describe('FolderView', () => {
expect(screen.getByText('两个文件夹内都没有文件')).toBeInTheDocument() expect(screen.getByText('两个文件夹内都没有文件')).toBeInTheDocument()
}) })
it('虚拟滚动:大列表仅渲染可见窗口且总高度撑满', () => { it('虚拟滚动:大列表仅渲染可见窗口且总高度撑满(树形扁平化行数)', () => {
const many: FolderEntry[] = Array.from({ length: 1000 }, (_, i) => ({ // 500 个子目录(各含 1 文件)+ 500 个根层文件 = 展开后 500 目录行 + 1000 文件行 = 1500 行
rel: `f${i}.txt`, const many: FolderEntry[] = Array.from({ length: 500 }, (_, i) => ({
rel: `d${i}/f${i}.txt`,
status: 'different' as const, status: 'different' as const,
leftSize: i, leftSize: i,
rightSize: i + 1 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') const rows = container.querySelectorAll('.folder-row')
expect(rows.length).toBeGreaterThan(0) expect(rows.length).toBeGreaterThan(0)
expect(rows.length).toBeLessThan(100) expect(rows.length).toBeLessThan(100)
const sizer = container.querySelector('.folder-scroll > div') as HTMLElement 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`)
}) })
}) })
+199 -40
View File
@@ -1,4 +1,5 @@
import { import {
useCallback,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
@@ -6,6 +7,10 @@ import {
type ReactElement type ReactElement
} from 'react' } from 'react'
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index' 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 保持一致,改动需双向同步 */ /** 文件夹列表虚拟行高(px),与 global.css 的 --folder-row-height 保持一致,改动需双向同步 */
export const FOLDER_ROW_HEIGHT = 26 export const FOLDER_ROW_HEIGHT = 26
@@ -13,6 +18,8 @@ export const FOLDER_ROW_HEIGHT = 26
const OVERSCAN = 8 const OVERSCAN = 8
/** 视口高度兜底值(jsdom 无布局时使用) */ /** 视口高度兜底值(jsdom 无布局时使用) */
const FALLBACK_VIEWPORT = 600 const FALLBACK_VIEWPORT = 600
/** 树形缩进步进(px / 深度) */
const TREE_INDENT = 16
export interface FolderViewProps { export interface FolderViewProps {
leftDir: string leftDir: string
@@ -21,24 +28,48 @@ export interface FolderViewProps {
truncated: boolean truncated: boolean
total: number total: number
scanning: boolean scanning: boolean
/** 仅看差异(过滤 same 条目);文件夹模式默认开启 */ /** 仅看差异(隐藏两侧一致的文件);与状态过滤互斥(后者开启时本开关自动关闭) */
onlyDiff: boolean onlyDiff: boolean
onOnlyDiffChange: (v: boolean) => void onOnlyDiffChange: (v: boolean) => void
/** 语义判等开关(字节不同但剔除空白后一致的文件计为“语义同”);切换后重新扫描 */
semantic: boolean
onSemanticChange: (v: boolean) => void
/** 重新选择两侧文件夹 */ /** 重新选择两侧文件夹 */
onReopen: () => void onReopen: () => void
/** 返回文件对比模式(清空文件夹状态) */ /** 返回文件对比模式(清空文件夹状态) */
onExit: () => void onExit: () => void
/** 导出文件夹对比报告 */
onExport: (fmt: ReportFormat) => void
/** 双击条目进入单文件对比 */ /** 双击条目进入单文件对比 */
onOpenEntry: (entry: FolderEntry) => 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<FolderEntryStatus, { icon: string; label: string; cls: string }> = { const STATUS_META: Record<FolderEntryStatus, { icon: string; label: string; cls: string }> = {
same: { icon: '●', label: '相同', cls: 'st-same' }, same: { icon: '●', label: '相同', cls: 'st-same' },
'semantic-same': { icon: '◐', label: '语义同', cls: 'st-sem' },
different: { icon: '●', label: '不同', cls: 'st-diff' }, different: { icon: '●', label: '不同', cls: 'st-diff' },
'left-only': { icon: '◀', label: '仅左侧', cls: 'st-left' }, '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<typeof countFolderStats>; 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 { function fmtSize(n: number | null): string {
if (n === null) return '—' if (n === null) return '—'
@@ -56,28 +87,70 @@ export default function FolderView({
scanning, scanning,
onlyDiff, onlyDiff,
onOnlyDiffChange, onOnlyDiffChange,
semantic,
onSemanticChange,
onReopen, onReopen,
onExit, onExit,
onExport,
onOpenEntry onOpenEntry
}: FolderViewProps): ReactElement { }: FolderViewProps): ReactElement {
// 统计(条目集不变时不重算 // 导出下拉(点击外部 / Esc 关闭
const stats = useMemo(() => { const [exportOpen, setExportOpen] = useState(false)
let same = 0 const exportWrapRef = useRef<HTMLDivElement>(null)
let different = 0 const closeExport = useCallback((): void => setExportOpen(false), [])
let leftOnly = 0 useDismiss(exportWrapRef, closeExport)
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])
const displayEntries = useMemo( // 搜索(相对路径子串,不区分大小写)
() => (onlyDiff ? entries.filter((e) => e.status !== 'same') : entries), const [query, setQuery] = useState('')
[entries, onlyDiff] // 状态精确过滤(null = 不过滤;与 onlyDiff 互斥)
const [statusFilter, setStatusFilter] = useState<Set<FolderEntryStatus> | null>(null)
// 折叠的目录集合(默认全展开;空集合 = 全部展开)
const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(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 同思路) // 虚拟滚动:固定行高换算可见窗口(与 DiffView 同思路)
@@ -89,17 +162,16 @@ export default function FolderView({
if (!el) return if (!el) return
const vh = el.clientHeight || FALLBACK_VIEWPORT const vh = el.clientHeight || FALLBACK_VIEWPORT
const start = Math.max(0, Math.floor(el.scrollTop / FOLDER_ROW_HEIGHT) - OVERSCAN) const start = Math.max(0, Math.floor(el.scrollTop / FOLDER_ROW_HEIGHT) - OVERSCAN)
const end = Math.min( const end = Math.min(rows.length, Math.ceil((el.scrollTop + vh) / FOLDER_ROW_HEIGHT) + OVERSCAN)
displayEntries.length,
Math.ceil((el.scrollTop + vh) / FOLDER_ROW_HEIGHT) + OVERSCAN
)
setRange((prev) => (prev.start === start && prev.end === end ? prev : { start, end })) setRange((prev) => (prev.start === start && prev.end === end ? prev : { start, end }))
} }
useLayoutEffect(() => { useLayoutEffect(() => {
updateRange() updateRange()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayEntries]) }, [rows])
const q = query.trim()
return ( return (
<div className="folder-view"> <div className="folder-view">
@@ -117,26 +189,88 @@ export default function FolderView({
<button className="btn ghost" onClick={onReopen}> <button className="btn ghost" onClick={onReopen}>
</button> </button>
<div className="export-wrap" ref={exportWrapRef}>
<button
className="btn ghost"
disabled={scanning}
title={scanning ? '正在扫描文件夹' : '导出文件夹对比报告(HTML / 纯文本 / Markdown'}
onClick={() => setExportOpen((o) => !o)}
>
</button>
{exportOpen && (
<div className="export-menu">
{FORMATS.map((f) => (
<button
key={f.fmt}
className="ctx-item"
onClick={() => {
setExportOpen(false)
onExport(f.fmt)
}}
>
{f.label}
</button>
))}
</div>
)}
</div>
<button className="btn ghost" onClick={onExit}> <button className="btn ghost" onClick={onExit}>
</button> </button>
</div> </div>
</div> </div>
{/* 工具行:统计 + 过滤 */} {/* 工具行:统计(可点击过滤)+ 搜索 + 开关 */}
<div className="folder-toolbar"> <div className="folder-toolbar">
<div className="folder-stats"> <div className="folder-stats">
<span className="fstat st-same"> {stats.same}</span> {BADGES.map((b) => {
<span className="fstat st-diff"> {stats.different}</span> // 语义关闭时语义同恒为 0,徽章隐藏(与旧版一致)
<span className="fstat st-left"> {stats.leftOnly}</span> if (b.status === 'semantic-same' && !semantic) return null
<span className="fstat st-right"> {stats.rightOnly}</span> // 无法读取为异常态:仅在出现时展示,平时不占位
if (b.status === 'unreadable' && stats.unreadable === 0) return null
const active = statusFilter?.has(b.status) ?? false
return (
<button
key={b.status}
className={`fstat ${STATUS_META[b.status].cls}${active ? ' active' : ''}`}
title={
active
? `点击取消“${STATUS_META[b.status].label}”过滤`
: `点击只看“${STATUS_META[b.status].label}”的文件`
}
onClick={() => handleBadge(b.status)}
>
{b.text} {stats[b.countKey]}
</button>
)
})}
<span className="fstat muted"> {entries.length} </span> <span className="fstat muted"> {entries.length} </span>
</div> </div>
<label className="switch" title="隐藏两侧内容一致的文件,专注差异"> <input
className="folder-search"
type="text"
value={query}
placeholder="搜索文件名…"
spellCheck={false}
onChange={(e) => setQuery(e.target.value)}
/>
<label
className="switch"
title="字节不同但剔除空白(换行符/缩进/空行)后内容一致的文件计为“语义同”,不计入差异;切换后重新扫描"
>
<input
type="checkbox"
checked={semantic}
onChange={(e) => onSemanticChange(e.target.checked)}
/>
</label>
<label className="switch" title="隐藏两侧内容一致的文件(含语义同),专注差异">
<input <input
type="checkbox" type="checkbox"
checked={onlyDiff} checked={onlyDiff}
onChange={(e) => onOnlyDiffChange(e.target.checked)} onChange={(e) => handleOnlyDiff(e.target.checked)}
/> />
</label> </label>
@@ -147,20 +281,41 @@ export default function FolderView({
)} )}
</div> </div>
{/* 条目列表(虚拟滚动) */} {/* 条目列表(目录树 + 虚拟滚动) */}
<div className="folder-scroll" ref={scrollRef} onScroll={updateRange}> <div className="folder-scroll" ref={scrollRef} onScroll={updateRange}>
<div style={{ height: displayEntries.length * FOLDER_ROW_HEIGHT }}> <div style={{ height: rows.length * FOLDER_ROW_HEIGHT }}>
{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 (
<div
className={`folder-row dir ${meta.cls}`}
key={`dir-${n.path}`}
title={`${n.path}/ · ${n.fileCount ?? 0} 个文件`}
onClick={() => toggleDir(n.path)}
>
<span className="folder-caret">{collapsed ? '▶' : '▼'}</span>
<span className="folder-rel">{n.name}</span>
<span className={`folder-agg ${meta.cls}`}>{meta.label}</span>
<span className="folder-dircount">{n.fileCount ?? 0} </span>
</div>
)
}
const e = n.entry!
const meta = STATUS_META[e.status] const meta = STATUS_META[e.status]
return ( return (
<div <div
className={'folder-row ' + meta.cls} className={'folder-row ' + meta.cls}
key={e.rel} key={e.rel}
title={`双击对比:${e.rel}${e.approximate ? '(超大文件,近似判定)' : ''}`} style={indent}
title={`双击对比:${e.rel}${e.status === 'semantic-same' ? '(语义同:仅空白/换行差异)' : ''}${e.approximate ? '(超大文件,近似判定)' : ''}`}
onDoubleClick={() => onOpenEntry(e)} onDoubleClick={() => onOpenEntry(e)}
> >
<span className="folder-st-icon">{meta.icon}</span> <span className="folder-st-icon">{meta.icon}</span>
<span className="folder-rel">{e.rel}</span> <span className="folder-rel">{n.name}</span>
{e.approximate && <span className="folder-approx"></span>} {e.approximate && <span className="folder-approx"></span>}
<span className="folder-size">{fmtSize(e.leftSize)}</span> <span className="folder-size">{fmtSize(e.leftSize)}</span>
<span className="folder-size">{fmtSize(e.rightSize)}</span> <span className="folder-size">{fmtSize(e.rightSize)}</span>
@@ -168,11 +323,15 @@ export default function FolderView({
) )
})} })}
</div> </div>
{!scanning && displayEntries.length === 0 && ( {!scanning && rows.length === 0 && (
<div className="folder-empty-note"> <div className="folder-empty-note">
{entries.length === 0 {q !== ''
? '两个文件夹内都没有文件' ? `没有匹配“${q}”的文件`
: '没有差异文件(两侧内容全部一致)'} : entries.length === 0
? '两个文件夹内都没有文件'
: statusFilter !== null
? '当前过滤条件下没有文件'
: '没有差异文件(两侧内容全部一致)'}
</div> </div>
)} )}
</div> </div>
+1 -1
View File
@@ -132,7 +132,7 @@ export default function Toolbar({
</label> </label>
<label <label
className="switch" className="switch"
title="空白与换行结构全部不参与判等,差异以字符粒度定位(跨行重组也能判等;超大内容自动降级行级对比" title="空白与换行结构全部不参与判等,差异以字符粒度定位(跨行重组也能判等);相同内容自动锚定跳过计算,大文件不再整篇受限;差异过大时降级行级对比"
> >
<input <input
type="checkbox" type="checkbox"
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest'
import type { FolderEntry } from '../../../preload/index'
import { buildFolderTree, filterEntries, flattenFolderTree, collectEntries } from './folderTree'
const E: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'sub/only-right.txt', status: 'right-only', leftSize: null, rightSize: 7 },
{ rel: 'sub/deep/x.ts', status: 'different', leftSize: 2, rightSize: 3 },
{ rel: 'z.md', status: 'semantic-same', leftSize: 1, rightSize: 2 }
]
describe('buildFolderTree', () => {
it('按路径分段聚合目录与文件,同层名称字典序', () => {
const root = buildFolderTree(E)
// 根层:a.txt / sub / z.md(字典序)
expect(root.children.map((c) => c.name)).toEqual(['a.txt', 'sub', 'z.md'])
expect(root.children.map((c) => c.isDir)).toEqual([false, true, false])
// sub 层:deep 目录 + only-right.txt
const sub = root.children[1]
expect(sub.children.map((c) => c.name)).toEqual(['deep', 'only-right.txt'])
// deep 层:x.ts
expect(sub.children[0].children[0].entry?.rel).toBe('sub/deep/x.ts')
})
it('目录聚合子树状态(different 优先)与文件计数', () => {
const root = buildFolderTree(E)
const deep = root.children[1].children[0]
expect(deep.agg).toBe('different')
expect(deep.fileCount).toBe(1)
const sub = root.children[1]
expect(sub.agg).toBe('different')
expect(sub.fileCount).toBe(2)
expect(root.agg).toBe('different')
expect(root.fileCount).toBe(4)
})
it('仅同种子树聚合为 same;only 与语义同优先级次之', () => {
const root = buildFolderTree([
{ rel: 'd/s.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'd2/l.txt', status: 'left-only', leftSize: 1, rightSize: null },
{ rel: 'd2/sem.txt', status: 'semantic-same', leftSize: 1, rightSize: 2 }
])
expect(root.children[0].agg).toBe('same')
// left-only(3) > semantic-same(2)
expect(root.children[1].agg).toBe('left-only')
})
it('空条目集返回空根', () => {
const root = buildFolderTree([])
expect(root.children).toHaveLength(0)
expect(root.fileCount).toBe(0)
})
})
describe('flattenFolderTree', () => {
it('全展开:深度递增、目录行在子行之前', () => {
const rows = flattenFolderTree(buildFolderTree(E), () => true)
expect(rows.map((r) => r.node.name)).toEqual([
'a.txt', 'sub', 'deep', 'x.ts', 'only-right.txt', 'z.md'
])
expect(rows.map((r) => r.depth)).toEqual([0, 0, 1, 2, 1, 0])
})
it('折叠目录:子树整体隐藏', () => {
const rows = flattenFolderTree(buildFolderTree(E), (p) => p !== 'sub/deep')
expect(rows.map((r) => r.node.name)).toEqual(['a.txt', 'sub', 'deep', 'only-right.txt', 'z.md'])
})
it('折叠顶层目录:整个子树隐藏', () => {
const rows = flattenFolderTree(buildFolderTree(E), (p) => p !== 'sub')
expect(rows.map((r) => r.node.name)).toEqual(['a.txt', 'sub', 'z.md'])
})
})
describe('filterEntries', () => {
it('onlyDiff 隐藏 same 与 semantic-same', () => {
const out = filterEntries(E, { onlyDiff: true, statusFilter: null, query: '' })
expect(out.map((e) => e.rel)).toEqual(['sub/only-right.txt', 'sub/deep/x.ts'])
})
it('statusFilter 只保留集合内状态', () => {
const out = filterEntries(E, {
onlyDiff: false,
statusFilter: new Set(['right-only', 'semantic-same']),
query: ''
})
expect(out.map((e) => e.rel)).toEqual(['sub/only-right.txt', 'z.md'])
})
it('query 子串搜索不区分大小写', () => {
const out = filterEntries(E, { onlyDiff: false, statusFilter: null, query: 'X.TS' })
expect(out.map((e) => e.rel)).toEqual(['sub/deep/x.ts'])
})
it('query 匹配路径中段(目录名)', () => {
const out = filterEntries(E, { onlyDiff: false, statusFilter: null, query: 'deep' })
expect(out.map((e) => e.rel)).toEqual(['sub/deep/x.ts'])
})
it('组合:onlyDiff + query 交集', () => {
const out = filterEntries(E, { onlyDiff: true, statusFilter: null, query: 'z' })
expect(out).toHaveLength(0)
})
it('空白 query 不过滤', () => {
const out = filterEntries(E, { onlyDiff: false, statusFilter: null, query: ' ' })
expect(out).toHaveLength(E.length)
})
})
describe('collectEntries', () => {
it('收集子树全部文件条目', () => {
const root = buildFolderTree(E)
const sub = root.children[1]
expect(collectEntries(sub).map((e) => e.rel)).toEqual(['sub/deep/x.ts', 'sub/only-right.txt'])
})
it('文件节点返回自身', () => {
const root = buildFolderTree(E)
expect(collectEntries(root.children[0]).map((e) => e.rel)).toEqual(['a.txt'])
})
})
+150
View File
@@ -0,0 +1,150 @@
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
/**
* 文件夹条目树(A5 子目录树形展示的纯逻辑层,渲染由 FolderView 负责)。
* 平铺 rel 聚合为目录/文件树,目录聚合子树状态,扁平化为虚拟滚动行序列。
*/
/** 树节点(目录或文件) */
export interface TreeNode {
/** 显示名(路径最后一段;根节点为空串) */
name: string
/** 相对完整路径(文件为其 rel;目录为其前缀路径,无尾斜杠) */
path: string
isDir: boolean
/** 文件节点挂原始条目 */
entry?: FolderEntry
/** 子节点(目录节点独有;同层按名称字典序) */
children: TreeNode[]
/** 目录节点:子树叶子的状态聚合(优先级 different > only > semantic-same > same */
agg?: FolderEntryStatus
/** 目录节点:子树文件总数 */
fileCount?: number
}
/** 扁平化后的虚拟滚动行 */
export interface TreeRow {
node: TreeNode
/** 缩进深度(根的直接子节点为 0) */
depth: number
}
/** 状态聚合优先级:数值大者优先(目录徽章展示最值得关注的子树状态;unreadable 需用户关注) */
const AGG_RANK: Record<FolderEntryStatus, number> = {
different: 5,
unreadable: 4,
'left-only': 3,
'right-only': 3,
'semantic-same': 2,
same: 1
}
/** 条目过滤条件 */
export interface EntryFilter {
/** 隐藏两侧一致的文件(same 与 semantic-same */
onlyDiff: boolean
/** 状态精确过滤(null 为不过滤;非空时只保留集合内状态) */
statusFilter: Set<FolderEntryStatus> | null
/** 相对路径子串搜索(不区分大小写;空串不过滤) */
query: string
}
/** 按视图条件过滤条目(文件层过滤,树构建之前执行) */
export function filterEntries(entries: FolderEntry[], f: EntryFilter): FolderEntry[] {
const q = f.query.trim().toLowerCase()
return entries.filter((e) => {
if (f.onlyDiff && (e.status === 'same' || e.status === 'semantic-same')) return false
if (f.statusFilter !== null && !f.statusFilter.has(e.status)) return false
if (q !== '' && !e.rel.toLowerCase().includes(q)) return false
return true
})
}
/** 递归排序子节点(名称字典序)并聚合目录状态与文件计数 */
function finalize(node: TreeNode): void {
if (!node.isDir) return
node.children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
let agg: FolderEntryStatus = 'same'
let rank = 0
let count = 0
for (const c of node.children) {
finalize(c)
if (c.isDir) {
count += c.fileCount ?? 0
const r = AGG_RANK[c.agg ?? 'same']
if (r > rank) {
rank = r
agg = c.agg ?? 'same'
}
} else {
count++
const r = AGG_RANK[c.entry!.status]
if (r > rank) {
rank = r
agg = c.entry!.status
}
}
}
node.agg = agg
node.fileCount = count
}
/**
* 把过滤后的条目聚合为树:按 rel 的 '/' 分段逐级插入,
* 同名目录合并;目录节点聚合子树状态与文件计数。
*/
export function buildFolderTree(entries: FolderEntry[]): TreeNode {
const root: TreeNode = { name: '', path: '', isDir: true, children: [] }
for (const e of entries) {
const segs = e.rel.split('/')
let cur = root
for (let i = 0; i < segs.length; i++) {
const isLeaf = i === segs.length - 1
const name = segs[i]
const path = segs.slice(0, i + 1).join('/')
let child = cur.children.find((c) => c.name === name && c.isDir === !isLeaf)
if (!child) {
child = isLeaf
? { name, path, isDir: false, children: [], entry: e }
: { name, path, isDir: true, children: [] }
cur.children.push(child)
}
cur = child
}
}
finalize(root)
return root
}
/**
* 树扁平化为虚拟滚动行:展开的目录递归下钻子节点,折叠的目录仅输出自身。
* isExpanded 接收目录 path 返回是否展开。
*/
export function flattenFolderTree(
root: TreeNode,
isExpanded: (dirPath: string) => boolean
): TreeRow[] {
const rows: TreeRow[] = []
const walk = (node: TreeNode, depth: number): void => {
for (const c of node.children) {
rows.push({ node: c, depth })
if (c.isDir && isExpanded(c.path)) walk(c, depth + 1)
}
}
walk(root, 0)
return rows
}
/** 收集一棵(子)树内的全部文件条目(目录行双击进入等场景) */
export function collectEntries(node: TreeNode): FolderEntry[] {
if (!node.isDir) return node.entry ? [node.entry] : []
const out: FolderEntry[] = []
const walk = (n: TreeNode): void => {
for (const c of n.children) {
if (c.isDir) walk(c)
else if (c.entry) out.push(c.entry)
}
}
walk(node)
return out
}
+116 -18
View File
@@ -436,17 +436,23 @@ describe('computeDiff - 字符级对比(差异定位与回映射)', () => {
}) })
describe('computeDiff - 字符级对比(超限降级)', () => { describe('computeDiff - 字符级对比(超限降级)', () => {
it('归一化流超限自动降级行级并携带降级标志', () => { it('变更组流超限自动降级行级并携带降级标志', () => {
const left = 'a'.repeat(100001) // 单行全差异:组流 = (half+1) × 2 = CHAR_DIFF_MAX_CHARS + 2,超单组上限
const right = 'a'.repeat(100001) const half = CHAR_DIFF_MAX_CHARS / 2
const left = 'a'.repeat(half + 1)
const right = 'b'.repeat(half + 1)
const result = computeDiff(left, right, { charMode: true }) const result = computeDiff(left, right, { charMode: true })
expect(result.charModeDowngraded).toBe(true) expect(result.charModeDowngraded).toBe(true)
// 降级行级采用“忽略所有空白 + 忽略空行”语义:全 a 单行仍判一致 // 降级行级采用“忽略所有空白 + 忽略空行”语义:单行全不同判 modified
expect(result.summary.changedLines).toBe(0) const direct = computeDiff(left, right, {
ignoreAllWhitespace: true,
ignoreBlankLines: true
})
expect(result.summary).toEqual(direct.summary)
}) })
it('编辑距离超限快速降级(总量未超但差异过大)', () => { it('编辑距离超限快速降级(总量未超但差异过大)', () => {
// 内部参数注入小阈值(10):生产默认 3000,触发截断需跑满上限轮数迭代,测试以小值毫秒级构造 // 内部参数注入小阈值(10):生产默认 5000,触发截断需跑满上限轮数迭代,测试以小值毫秒级构造
const left = 'a'.repeat(2000) const left = 'a'.repeat(2000)
const right = 'b'.repeat(2000) const right = 'b'.repeat(2000)
const result = computeDiff(left, right, { charMode: true, charDiffMaxEdit: 10 }) const result = computeDiff(left, right, { charMode: true, charDiffMaxEdit: 10 })
@@ -467,25 +473,32 @@ describe('computeDiff - 字符级对比(超限降级)', () => {
expect(summary.changedLines).toBeGreaterThan(0) expect(summary.changedLines).toBeGreaterThan(0)
}) })
it('降级结果与等效行级选项直接计算一致', () => { it('大输入仅局部差异:行锚定后字符级完成,不降级(分块能力回归)', () => {
// 旧实现(整篇流 diffChars)此输入必超限降级;分块后相同大行被锚定,
// 唯一变更组只有第 2 行(b → c),字符级精确定位
const left = 'a'.repeat(100001) + '\nb' const left = 'a'.repeat(100001) + '\nb'
const right = 'a'.repeat(100001) + '\nc' const right = 'a'.repeat(100001) + '\nc'
const result = computeDiff(left, right, { charMode: true }) const result = computeDiff(left, right, { charMode: true })
expect(result.charModeDowngraded).toBe(true) expect(result.charModeDowngraded).toBeUndefined()
const direct = computeDiff(left, right, { expect(result.summary.changedLines).toBe(1)
ignoreAllWhitespace: true, expect(result.summary.modified).toBe(1)
ignoreBlankLines: true const m = result.rows.find((r) => r.rowKind === 'modified')!
}) expect(m.left.lineNo).toBe(2)
expect(result.summary).toEqual(direct.summary) expect(m.right.lineNo).toBe(2)
expect(m.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('b'))).toBe(true)
expect(m.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('c'))).toBe(true)
// 首行 10 万字符的相同行锚定输出
expect(result.rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(1)
}) })
it('恰好阈值不降级', () => { it('变更组流恰好阈值不降级', () => {
// 单行组流 = half + half = 恰好 CHAR_DIFF_MAX_CHARS> 才降),编辑距离 1
const half = CHAR_DIFF_MAX_CHARS / 2 const half = CHAR_DIFF_MAX_CHARS / 2
const { summary, charModeDowngraded } = computeDiff('a'.repeat(half), 'a'.repeat(half), { const left = 'a'.repeat(half - 1) + 'b'
charMode: true const right = 'a'.repeat(half)
}) const { summary, charModeDowngraded } = computeDiff(left, right, { charMode: true })
expect(charModeDowngraded).toBeUndefined() expect(charModeDowngraded).toBeUndefined()
expect(summary.changedLines).toBe(0) expect(summary.changedLines).toBe(1)
}) })
it('未开启 charMode 时不携带降级标志', () => { it('未开启 charMode 时不携带降级标志', () => {
@@ -494,6 +507,91 @@ describe('computeDiff - 字符级对比(超限降级)', () => {
}) })
}) })
describe('computeDiff - 字符级对比(分块锚定)', () => {
it('大文件全同内容:行锚定判等,不降级且无差异(归一化流总量远超单组上限)', () => {
// 20 万归一化字符(远超 CHAR_DIFF_MAX_CHARS 的 5 万),内容全同
const text = Array.from({ length: 2000 }, () => 'a'.repeat(100)).join('\n')
const { summary, charModeDowngraded, rows } = computeDiff(text, text, { charMode: true })
expect(charModeDowngraded).toBeUndefined()
expect(summary.changedLines).toBe(0)
expect(rows).toHaveLength(2000)
expect(rows.every((r) => r.rowKind === 'unchanged')).toBe(true)
})
it('大文件局部差异:差异行 modified 精确定位,其余行锚定 unchanged', () => {
const base = Array.from({ length: 1000 }, (_, i) => `line-${i}-content`)
const modified = [...base]
modified[500] = 'line-500-CONTENT'
const { rows, summary, charModeDowngraded } = computeDiff(base.join('\n'), modified.join('\n'), {
charMode: true
})
expect(charModeDowngraded).toBeUndefined()
expect(summary.changedLines).toBe(1)
expect(summary.modified).toBe(1)
const m = rows.find((r) => r.rowKind === 'modified')!
expect(m.left.lineNo).toBe(501)
expect(m.right.lineNo).toBe(501)
expect(m.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('content'))).toBe(true)
expect(m.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('CONTENT'))).toBe(true)
expect(rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(999)
const lNos = rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
const rNos = rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null)
expect([...lNos].sort((a, b) => a - b)).toEqual(lNos)
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
})
it('多个变更组穿插锚行:各组独立回映射,行号全局单调', () => {
const base = Array.from({ length: 30 }, (_, i) => `row${i}`)
const modified = [...base]
modified[5] = 'row5X'
modified[15] = 'row15X'
modified[25] = 'row25X'
const { rows, summary, charModeDowngraded } = computeDiff(
base.join('\n'),
modified.join('\n'),
{ charMode: true }
)
expect(charModeDowngraded).toBeUndefined()
expect(summary.modified).toBe(3)
expect(summary.changedLines).toBe(3)
const mods = rows.filter((r) => r.rowKind === 'modified')
expect(mods.map((r) => r.left.lineNo)).toEqual([6, 16, 26])
expect(mods.map((r) => r.right.lineNo)).toEqual([6, 16, 26])
expect(rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(27)
const lNos = rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
const rNos = rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null)
expect([...lNos].sort((a, b) => a - b)).toEqual(lNos)
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
})
it('变更组总流超全局上限仍降级(每组在限内但合计超限)', () => {
// 8 个变更组,每组流 6500 + 6501 = 13001(在单组上限内,编辑距离 1),
// 合计 104008 > CHAR_DIFF_GROUP_TOTAL_CHARS100000)→ 整体降级
const build = (withX: boolean): string => {
const lines: string[] = []
for (let i = 0; i < 8; i++) {
lines.push(`same-${i}`)
lines.push('a'.repeat(6500) + (withX ? 'X' : ''))
}
return lines.join('\n')
}
const result = computeDiff(build(false), build(true), { charMode: true })
expect(result.charModeDowngraded).toBe(true)
})
it('跨行重组落在变更组内:组内字符流判等,锚行 zip 输出', () => {
// 行归一化无交集('ab cd' vs 'ab\ncd')→ 唯一变更组即全文,
// 组内流全等 → 锚行输出,行为与旧整篇路径一致
const { rows, summary } = computeDiff('ab cd', 'ab\ncd', { charMode: true })
expect(summary.changedLines).toBe(0)
expect(rows).toHaveLength(2)
expect(rows[0].left.lineNo).toBe(1)
expect(rows[0].right.lineNo).toBe(1)
expect(rows[1].left.lineNo).toBeNull()
expect(rows[1].right.lineNo).toBe(2)
})
})
describe('computeDiff - 字符级对比(字符换位专项)', () => { describe('computeDiff - 字符级对比(字符换位专项)', () => {
/** /**
* 已知取舍的固化测试:换位场景(如 ab ↔ ba)下 jsdiff 的 Myers 对齐具有任意性 * 已知取舍的固化测试:换位场景(如 ab ↔ ba)下 jsdiff 的 Myers 对齐具有任意性
+184 -62
View File
@@ -1,4 +1,4 @@
import { diffArrays, diffChars, diffWordsWithSpace } from 'diff' import { diffArrays, diffChars, diffWordsWithSpace, type Change } from 'diff'
/** 词级分段类型 */ /** 词级分段类型 */
export type SegKind = 'common' | 'insert' | 'delete' export type SegKind = 'common' | 'insert' | 'delete'
@@ -204,7 +204,7 @@ function computeLineDiff(
? { left: null, right: null } ? { left: null, right: null }
: wordSegments(l.text, r.text) : wordSegments(l.text, r.text)
rows.push({ rows.push({
id: `r${rowSeq++}`, id: `r${rows.length}`,
rowKind: 'modified', rowKind: 'modified',
isChanged: true, isChanged: true,
left: { lineNo: l.lineNo, text: l.text, segs: lSegs }, left: { lineNo: l.lineNo, text: l.text, segs: lSegs },
@@ -212,7 +212,7 @@ function computeLineDiff(
}) })
} else if (l) { } else if (l) {
rows.push({ rows.push({
id: `r${rowSeq++}`, id: `r${rows.length}`,
rowKind: 'removed', rowKind: 'removed',
isChanged: true, isChanged: true,
left: { lineNo: l.lineNo, text: l.text, segs: null }, left: { lineNo: l.lineNo, text: l.text, segs: null },
@@ -220,7 +220,7 @@ function computeLineDiff(
}) })
} else if (r) { } else if (r) {
rows.push({ rows.push({
id: `r${rowSeq++}`, id: `r${rows.length}`,
rowKind: 'added', rowKind: 'added',
isChanged: true, isChanged: true,
left: emptyLine(), left: emptyLine(),
@@ -251,7 +251,7 @@ function computeLineDiff(
} else { } else {
for (const r of addedRows) { for (const r of addedRows) {
rows.push({ rows.push({
id: `r${rowSeq++}`, id: `r${rows.length}`,
rowKind: 'added', rowKind: 'added',
isChanged: true, isChanged: true,
left: emptyLine(), left: emptyLine(),
@@ -267,7 +267,7 @@ function computeLineDiff(
const lp = leftUsed[leftPtr + i] const lp = leftUsed[leftPtr + i]
const rp = rightUsed[rightPtr + i] const rp = rightUsed[rightPtr + i]
rows.push({ rows.push({
id: `r${rowSeq++}`, id: `r${rows.length}`,
rowKind: 'unchanged', rowKind: 'unchanged',
isChanged: false, isChanged: false,
left: { lineNo: lp.lineNo, text: lp.text, segs: null }, left: { lineNo: lp.lineNo, text: lp.text, segs: null },
@@ -293,27 +293,36 @@ function computeLineDiff(
/** ============ 字符级对比 ============ */ /** ============ 字符级对比 ============ */
/** /**
* 字符级对比的字符总量上限(两侧归一化流合计): * 字符级对比的字符总量上限(单个变更组两侧归一化流合计):
* diffChars 为 O(ND),字符量与编辑距离任一超限都可能导致耗时失控 * diffChars 为 O(ND),字符量与编辑距离任一超限都可能导致耗时失控
* 超限自动降级为行级对比。3 万字符按实测将最坏耗时控制在约 2 秒内。 * 0.6.3 起对比按“行锚定 + 变更组分块”进行:相同内容不进入 diffChars,
* 本上限只约束单个变更组——大文件(总量远超上限)只要变更集中在组内限额之下,
* 字符级对比即可正常完成,不再整篇受限。
*/ */
export const CHAR_DIFF_MAX_CHARS = 30000 export const CHAR_DIFF_MAX_CHARS = 50000
/** /**
* 字符级对比的编辑距离上限:diffChars 以 maxEditLength 迭代轮数封顶, * 字符级对比的编辑距离上限:diffChars 以 maxEditLength 迭代轮数封顶,
* 编辑距离超限立即放弃(返回 undefined)并降级为行级对比—— * 编辑距离超限立即放弃(返回 undefined)并降级为行级对比——
* 差异过大时字符级高亮已失去可读性,行级语义足够。 * 差异过大时字符级高亮已失去可读性,行级语义足够。
* 实测 3000 轮 × 3 万字符 ≈ 1.8 秒(worker 内计算,界面不冻结)。 * 实测口径:5000 轮 × 5 万字符最坏约 5 秒(worker 内计算,界面不冻结)。
*/ */
export const CHAR_DIFF_MAX_EDIT = 3000 export const CHAR_DIFF_MAX_EDIT = 5000
/**
* 全部变更组归一化流的总字符上限(两侧合计):兜底“组数多且总量大”的累计耗时
* (每组最坏约 5 秒,2 组满载 ≈ 10 秒,在 worker 内计算不冻结界面)。
*/
export const CHAR_DIFF_GROUP_TOTAL_CHARS = CHAR_DIFF_MAX_CHARS * 2
/** /**
* 字符级对比的重输入阈值(两侧原始字符总量):超过即走 worker 后台计算。 * 字符级对比的重输入阈值(两侧原始字符总量):超过即走 worker 后台计算。
* 按 CHAR_DIFF_MAX_CHARS / 4 取值(留 4 倍余量给空白占比)——降级上限之内仍可能出现 * 按 CHAR_DIFF_MAX_EDIT 对齐取值——同步快路径的最坏耗时 ≈ 流长 × 编辑距离,
* 编辑距离极大、O(ND) 耗时失控的输入,且此类输入往往行数很少(压缩 JSON / base64 单行), * 5000 × 5000 ≈ 0.5 秒量级(与 0.5.5 收紧后的口径相当);
* 且此类重输入往往行数很少(压缩 JSON / base64 单行),
* 行数维度的重输入判定防不住,需以字符量兜底。 * 行数维度的重输入判定防不住,需以字符量兜底。
*/ */
export const CHAR_HEAVY_INPUT_CHARS = CHAR_DIFF_MAX_CHARS / 4 export const CHAR_HEAVY_INPUT_CHARS = 5000
/** 空白字符判定:与 ignoreAllWhitespace 的 \s 语义一致(含换行、制表符、全角空格) */ /** 空白字符判定:与 ignoreAllWhitespace 的 \s 语义一致(含换行、制表符、全角空格) */
function isWsChar(ch: string): boolean { function isWsChar(ch: string): boolean {
@@ -330,21 +339,30 @@ interface CharStream {
cols: number[] cols: number[]
} }
function buildCharStream(lines: string[], norm: (ch: string) => string): CharStream { /**
* 变更组的归一化流:组内各行拼接为一个字符流,
* 行号与列号取原始文件位置(供字符差异回映射到行)。
* lineIdx 为组内行的 0 基下标序列(左右两侧各自收集)。
*/
function groupStream(
orig: string[],
lineIdx: number[],
norm: (ch: string) => string
): CharStream {
const chars: string[] = [] const chars: string[] = []
const lineNos: number[] = [] const lineNos: number[] = []
const cols: number[] = [] const cols: number[] = []
lines.forEach((text, i) => { for (const li of lineIdx) {
let col = 0 let col = 0
for (const ch of text) { for (const ch of orig[li]) {
if (!isWsChar(ch)) { if (!isWsChar(ch)) {
chars.push(norm(ch)) chars.push(norm(ch))
lineNos.push(i + 1) lineNos.push(li + 1)
cols.push(col) cols.push(col)
} }
col++ col++
} }
}) }
return { text: chars.join(''), lineNos, cols } return { text: chars.join(''), lineNos, cols }
} }
@@ -382,17 +400,19 @@ function charSegs(text: string, stat: Map<number, number> | undefined): Seg[] {
} }
/** /**
* 字符级对比:两侧全文归一化为字符流 → diffChars 对齐 → 字符差异回映射为 DiffRow * 字符级对比(两阶段:行锚定 + 变更组分块)
* *
* 回映射规则(输出仍为 DiffRow,视图/报告/导航零改动): * 第一阶段:每行剔除全部空白(可选小写)后做行级判等——判等行的归一化字符流必然
* - 含变更(删除/新增)字符的行为“变更行”(跨界行——既有 common 又有变更字符——也划归变更行 * 完全一致,是安全锚点,直接输出锚行;相同内容不进入第二阶段的 diffChars
* 其 common 部分在 modified 行的 segs 中以 common 段呈现,信息不丢失); * 因此归一化流总量不再受单次 diffChars 上限约束(大文件字符级不再整篇受限)。
* - 连续变更行组配对:等长配 modified(segs 按整行重建),多余侧降级 removed / added(与行级 commitModified 同构);
* - 变更组之间的行(含纯空白行)为“锚行”,左右按序 zip 配对输出 unchanged,短侧空槽仍标 unchanged
* (字符级语义下纯空白行不构成差异)。
* *
* 内容超限(CHAR_DIFF_MAX_CHARS)或编辑距离超限(CHAR_DIFF_MAX_EDITdiffChars * 第二阶段:连续的 removed/added 行块合为一个“变更组”,组内左右行拼接为归一化
* 携 maxEditLength 超限返回 undefined)均返回 null,由调用方降级行级。 * 字符流送 diffChars(差异以字符粒度回映射到行)。约束:单个变更组流不超过
* CHAR_DIFF_MAX_CHARS、编辑距离不超过 CHAR_DIFF_MAX_EDIT、全部变更组流总量不超过
* CHAR_DIFF_GROUP_TOTAL_CHARS——任一超限返回 null,由调用方降级行级(整篇重排或
* 大改场景,字符高亮本就失去可读性)。
*
* 无任何锚行时(两侧行归一化完全无交集)唯一变更组即全文,行为与整篇流对比同构。
*/ */
function computeCharDiff( function computeCharDiff(
leftText: string, leftText: string,
@@ -402,23 +422,126 @@ function computeCharDiff(
const leftOrig = splitLines(leftText) const leftOrig = splitLines(leftText)
const rightOrig = splitLines(rightText) const rightOrig = splitLines(rightText)
// 大小写归一化在构造流时完成:toLowerCase 展开为多字符的字符(如 İ→i̇)退回原样, // 大小写归一化在构造流时完成:toLowerCase 展开为多字符的字符(如 İ→i̇)退回原样,
// 避免归一化后字符数变化破坏流索引 // 避免归一化后字符数变化破坏流索引(第一阶段行判等用整串 toLowerCase,两侧同样
// 变换不影响判等一致性;流索引稳定性仍由逐字符守卫保证)
const normCh = (ch: string): string => { const normCh = (ch: string): string => {
if (!options.ignoreCase) return ch if (!options.ignoreCase) return ch
const lo = ch.toLowerCase() const lo = ch.toLowerCase()
return lo.length === 1 ? lo : ch return lo.length === 1 ? lo : ch
} }
const left = buildCharStream(leftOrig, normCh)
const right = buildCharStream(rightOrig, normCh)
if (left.text.length + right.text.length > CHAR_DIFF_MAX_CHARS) return null
// maxEditLength 封顶 Myers 迭代轮数(编辑距离),防止 O(ND) 无界计算卡死; // ===== 第一阶段:行归一化锚定 =====
// jsdiff 超限时运行时返回 undefined(类型签名未标注,真值判断兜底) const normLine = (s: string): string => {
const parts = diffChars(left.text, right.text, { const t = s.replace(/\s+/g, '')
maxEditLength: options.charDiffMaxEdit ?? CHAR_DIFF_MAX_EDIT return options.ignoreCase ? t.toLowerCase() : t
}) }
if (!parts) return null const lineParts = diffArrays(leftOrig.map(normLine), rightOrig.map(normLine))
// 输出时间线:锚行(行归一化判等,左右原文各自展示)与变更组占位按序交替
const timeline: Array<
| { kind: 'anchor'; ln: number; rn: number }
| { kind: 'group'; g: number }
> = []
const groups: { l: number[]; r: number[] }[] = []
let lPtr = 0
let rPtr = 0
for (const part of lineParts) {
const len = part.value.length
if (!part.added && !part.removed) {
for (let i = 0; i < len; i++) {
timeline.push({ kind: 'anchor', ln: lPtr + i, rn: rPtr + i })
}
lPtr += len
rPtr += len
} else {
// 连续 removed/added 行块归为一个变更组(timeline 尾部已是本组则不重复占位)
if (groups.length === 0 || timeline[timeline.length - 1].kind !== 'group') {
groups.push({ l: [], r: [] })
timeline.push({ kind: 'group', g: groups.length - 1 })
}
const g = groups[groups.length - 1]
if (part.removed) {
for (let i = 0; i < len; i++) g.l.push(lPtr + i)
lPtr += len
} else {
for (let i = 0; i < len; i++) g.r.push(rPtr + i)
rPtr += len
}
}
}
// ===== 第二阶段:变更组字符流 diff(先全部预检,任一超限整体降级)=====
const streams = groups.map((g) => ({
ls: groupStream(leftOrig, g.l, normCh),
rs: groupStream(rightOrig, g.r, normCh),
// 组内行号集合(1 基):限定 emitGroupRows 的锚行/尾部输出范围
gl: new Set(g.l.map((i) => i + 1)),
gr: new Set(g.r.map((i) => i + 1))
}))
let totalStream = 0
for (const { ls, rs } of streams) {
totalStream += ls.text.length + rs.text.length
}
if (totalStream > CHAR_DIFF_GROUP_TOTAL_CHARS) return null
const groupParts: Change[][] = []
for (const { ls, rs } of streams) {
if (ls.text.length + rs.text.length > CHAR_DIFF_MAX_CHARS) return null
// maxEditLength 封顶 Myers 迭代轮数(编辑距离),防止 O(ND) 无界计算卡死;
// jsdiff 超限时运行时返回 undefined(类型签名未标注,真值判断兜底)
const parts = diffChars(ls.text, rs.text, {
maxEditLength: options.charDiffMaxEdit ?? CHAR_DIFF_MAX_EDIT
})
if (!parts) return null
groupParts.push(parts)
}
// ===== 按时间线输出:锚行 + 变更组回映射 =====
const rows: DiffRow[] = []
for (const item of timeline) {
if (item.kind === 'anchor') {
rows.push({
id: `r${rows.length}`,
rowKind: 'unchanged',
isChanged: false,
left: { lineNo: item.ln + 1, text: leftOrig[item.ln], segs: null },
right: { lineNo: item.rn + 1, text: rightOrig[item.rn], segs: null }
})
} else {
const { ls, rs, gl, gr } = streams[item.g]
emitGroupRows(ls, rs, leftOrig, rightOrig, groupParts[item.g], rows, gl, gr)
}
}
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 }
}
/**
* 单个变更组的字符差异回映射:组内归一化流的 diffChars 结果 → 追加 DiffRow 到 rows。
*
* 回映射规则(输出仍为 DiffRow,视图/报告/导航零改动):
* - 含变更(删除/新增)字符的行为“变更行”(跨界行——既有 common 又有变更字符——也划归变更行,
* 其 common 部分在 modified 行的 segs 中以 common 段呈现,信息不丢失);
* - 连续变更行组配对:等长配 modified(segs 按整行重建),多余侧降级 removed / added(与行级 commitModified 同构);
* - 组内对齐块之间的行(含纯空白行)为“锚行”,左右按序 zip 配对输出 unchanged,短侧空槽仍标 unchanged
* (字符级语义下纯空白行不构成差异)。
*/
function emitGroupRows(
left: CharStream,
right: CharStream,
leftOrig: string[],
rightOrig: string[],
parts: Change[],
rows: DiffRow[],
groupL: Set<number>,
groupR: Set<number>
): void {
// 流区间块序列:common 为 eq 块,连续 removed/added 合并为 ne 块(与行级 parts 循环同构) // 流区间块序列:common 为 eq 块,连续 removed/added 合并为 ne 块(与行级 parts 循环同构)
interface Block { interface Block {
eq: boolean eq: boolean
@@ -550,8 +673,6 @@ function computeCharDiff(
} }
} }
let rowSeq = 0
const rows: DiffRow[] = []
let pendingL: number[] = [] let pendingL: number[] = []
let pendingR: number[] = [] let pendingR: number[] = []
// 已输出进变更组的行:同行内多处变更(ne-eq-ne 落在同一行,如换位、同行两处替换) // 已输出进变更组的行:同行内多处变更(ne-eq-ne 落在同一行,如换位、同行两处替换)
@@ -567,7 +688,7 @@ function computeCharDiff(
const rn = pendingR[i] const rn = pendingR[i]
if (ln !== undefined && rn !== undefined) { if (ln !== undefined && rn !== undefined) {
rows.push({ rows.push({
id: `r${rowSeq++}`, id: `r${rows.length}`,
rowKind: 'modified', rowKind: 'modified',
isChanged: true, isChanged: true,
left: { left: {
@@ -585,7 +706,7 @@ function computeCharDiff(
doneR.add(rn) doneR.add(rn)
} else if (ln !== undefined) { } else if (ln !== undefined) {
rows.push({ rows.push({
id: `r${rowSeq++}`, id: `r${rows.length}`,
rowKind: 'removed', rowKind: 'removed',
isChanged: true, isChanged: true,
left: { lineNo: ln, text: leftOrig[ln - 1], segs: null }, left: { lineNo: ln, text: leftOrig[ln - 1], segs: null },
@@ -594,7 +715,7 @@ function computeCharDiff(
doneL.add(ln) doneL.add(ln)
} else if (rn !== undefined) { } else if (rn !== undefined) {
rows.push({ rows.push({
id: `r${rowSeq++}`, id: `r${rows.length}`,
rowKind: 'added', rowKind: 'added',
isChanged: true, isChanged: true,
left: emptyLine(), left: emptyLine(),
@@ -614,7 +735,7 @@ function computeCharDiff(
const ln = la[i] ?? null const ln = la[i] ?? null
const rn = ra[i] ?? null const rn = ra[i] ?? null
rows.push({ rows.push({
id: `r${rowSeq++}`, id: `r${rows.length}`,
rowKind: 'unchanged', rowKind: 'unchanged',
isChanged: false, isChanged: false,
left: ln === null ? emptyLine() : { lineNo: ln, text: leftOrig[ln - 1], segs: null }, left: ln === null ? emptyLine() : { lineNo: ln, text: leftOrig[ln - 1], segs: null },
@@ -623,21 +744,22 @@ function computeCharDiff(
} }
} }
// 行游标推进:锚段右边界 = 下一 ne 块首字符行 - 1(流尽则为文件末行) // 行游标推进:锚段右边界 = 下一 ne 块首字符行 - 1(流尽则为组内末行)
// 锚行与尾部范围限定在组内行集合(组外行由时间线的锚行/其他组负责,防止重复输出)
let lCursor = 1 let lCursor = 1
let rCursor = 1 let rCursor = 1
for (const b of blocks) { for (const b of blocks) {
if (b.eq) { if (b.eq) {
flushChange() flushChange()
const lEnd = b.l1 < left.lineNos.length ? left.lineNos[b.l1] - 1 : leftOrig.length const lEnd = b.l1 < left.lineNos.length ? left.lineNos[b.l1] - 1 : maxLine(groupL)
const rEnd = b.r1 < right.lineNos.length ? right.lineNos[b.r1] - 1 : rightOrig.length const rEnd = b.r1 < right.lineNos.length ? right.lineNos[b.r1] - 1 : maxLine(groupR)
const la: number[] = [] const la: number[] = []
for (let ln = lCursor; ln <= lEnd; ln++) { for (let ln = lCursor; ln <= lEnd; ln++) {
if (!pairedL.has(ln)) la.push(ln) if (groupL.has(ln) && !pairedL.has(ln)) la.push(ln)
} }
const ra: number[] = [] const ra: number[] = []
for (let rn = rCursor; rn <= rEnd; rn++) { for (let rn = rCursor; rn <= rEnd; rn++) {
if (!pairedR.has(rn)) ra.push(rn) if (groupR.has(rn) && !pairedR.has(rn)) ra.push(rn)
} }
emitAnchors(la, ra) emitAnchors(la, ra)
lCursor = lEnd + 1 lCursor = lEnd + 1
@@ -663,23 +785,23 @@ function computeCharDiff(
} }
flushChange() flushChange()
// 尾部剩余行(最后一块之后的空白行/未覆盖行)按锚行输出 // 尾部剩余行(组内未被覆盖的空白行等)按锚行输出(同样限定组内行集合)
const tailLa: number[] = [] const tailLa: number[] = []
for (let ln = lCursor; ln <= leftOrig.length; ln++) { for (let ln = lCursor; ln <= maxLine(groupL); ln++) {
if (!pairedL.has(ln)) tailLa.push(ln) if (groupL.has(ln) && !pairedL.has(ln)) tailLa.push(ln)
} }
const tailRa: number[] = [] const tailRa: number[] = []
for (let rn = rCursor; rn <= rightOrig.length; rn++) { for (let rn = rCursor; rn <= maxLine(groupR); rn++) {
if (!pairedR.has(rn)) tailRa.push(rn) if (groupR.has(rn) && !pairedR.has(rn)) tailRa.push(rn)
} }
emitAnchors(tailLa, tailRa) emitAnchors(tailLa, tailRa)
}
const summary: DiffSummary = { /** 行号集合的最大行(空集合为 0,即无锚行可输出) */
changedLines: rows.filter((r) => r.isChanged).length, function maxLine(lines: Set<number>): number {
inserted: rows.filter((r) => r.rowKind === 'added').length, let m = 0
deleted: rows.filter((r) => r.rowKind === 'removed').length, for (const ln of lines) {
modified: rows.filter((r) => r.rowKind === 'modified').length if (ln > m) m = ln
} }
return m
return { rows, summary }
} }
+182
View File
@@ -0,0 +1,182 @@
import { describe, it, expect } from 'vitest'
import type { FolderEntry } from '../../../preload/index'
import {
buildFolderReport,
countFolderStats,
plainFolderSummary
} from './folderReport'
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-l.txt', status: 'left-only', leftSize: 1, rightSize: null },
{ rel: 'sub/only-r.ts', status: 'right-only', leftSize: null, rightSize: 7 }
]
const CTX = { leftDir: 'C:/left', rightDir: 'D:/right', semantic: true, truncated: false, total: 5 }
describe('countFolderStats', () => {
it('六类状态计数正确', () => {
expect(
countFolderStats([
...ENTRIES,
{ rel: 'locked.txt', status: 'unreadable' as const, leftSize: 1, rightSize: 1 }
])
).toEqual({
same: 1,
semanticSame: 1,
different: 1,
leftOnly: 1,
rightOnly: 1,
unreadable: 1
})
})
it('空条目集全部为零', () => {
expect(countFolderStats([])).toEqual({
same: 0,
semanticSame: 0,
different: 0,
leftOnly: 0,
rightOnly: 0,
unreadable: 0
})
})
})
describe('plainFolderSummary', () => {
it('空文件夹给出无文件结论', () => {
expect(plainFolderSummary(countFolderStats([]), 0)).toBe('两个文件夹内都没有文件')
})
it('完全一致(无语义同)', () => {
const stats = countFolderStats([{ rel: 'a', status: 'same', leftSize: 1, rightSize: 1 }])
expect(plainFolderSummary(stats, 1)).toBe('两文件夹内容完全一致')
})
it('完全一致但有语义同时附加说明', () => {
const stats = countFolderStats([
{ rel: 'a', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'b', status: 'semantic-same', leftSize: 1, rightSize: 2 }
])
expect(plainFolderSummary(stats, 2)).toBe('两文件夹内容完全一致(其中 1 个文件仅空白/换行差异)')
})
it('存在差异时完整罗列并附语义同说明', () => {
const s = countFolderStats(ENTRIES)
expect(plainFolderSummary(s, ENTRIES.length)).toBe(
'共 3 个文件存在差异:内容不同 1 个、仅左侧 1 个、仅右侧 1 个(另有 1 个仅空白/换行差异,不计入不同)'
)
})
it('无语义同时不附加说明', () => {
const stats = countFolderStats([{ rel: 'b', status: 'different', leftSize: 1, rightSize: 2 }])
expect(plainFolderSummary(stats, 1)).toBe('共 1 个文件存在差异:内容不同 1 个')
})
})
describe('buildFolderReport - HTML', () => {
it('输出合法文档并包含目录、统计与条目明细', () => {
const html = buildFolderReport(ENTRIES, CTX, 'html')
expect(html).toContain('<!doctype html>')
expect(html).toContain('DiffLens 文件夹对比报告')
expect(html).toContain('C:/left')
expect(html).toContain('D:/right')
expect(html).toContain('判定口径:语义判等开启')
expect(html).toContain('共 3 个文件存在差异')
expect(html).toContain('sub/only-r.ts')
expect(html).toContain('st-sem')
expect(html).toContain('语义同')
})
it('语义关闭时判定口径为字节级严格比对', () => {
const html = buildFolderReport(ENTRIES, { ...CTX, semantic: false }, 'html')
expect(html).toContain('判定口径:字节级严格比对')
})
it('截断时输出警示行', () => {
const html = buildFolderReport(ENTRIES, { ...CTX, truncated: true, total: 9999 }, 'html')
expect(html).toContain('文件数量超出上限')
expect(html).toContain('9999')
})
it('空条目集给出空提示', () => {
const html = buildFolderReport([], { ...CTX, total: 0 }, 'html')
expect(html).toContain('两个文件夹内都没有文件')
})
it('完全一致时 verdict 为绿色(ok', () => {
const html = buildFolderReport(
[{ rel: 'a', status: 'same', leftSize: 1, rightSize: 1 }],
CTX,
'html'
)
expect(html).toContain('verdict ok')
expect(html).toContain('两文件夹内容完全一致')
})
it('approximate 条目渲染 ≈ 标注', () => {
const html = buildFolderReport(
[{ rel: 'big.bin', status: 'same', leftSize: 1, rightSize: 1, approximate: true }],
CTX,
'html'
)
expect(html).toContain('≈')
})
})
describe('buildFolderReport - 纯文本', () => {
it('头部包含目录/口径/结论/统计,条目行含状态与大小', () => {
const txt = buildFolderReport(ENTRIES, CTX, 'txt')
expect(txt).toContain('====== DiffLens 文件夹对比报告 ======')
expect(txt).toContain('左侧目录:C:/left')
expect(txt).toContain('判定口径:语义判等开启')
expect(txt).toContain('结论:共 3 个文件存在差异')
expect(txt).toContain('相同 1 | 语义同 1 | 不同 1 | 仅左 1 | 仅右 1 | 共 5 项')
expect(txt).toContain('sub/only-r.ts')
expect(txt).toContain('语义同')
expect(txt).toContain('仅左侧')
})
it('超长路径截断对齐(宽度封顶 60)', () => {
const longRel = `${'d'.repeat(80)}.txt`
const txt = buildFolderReport(
[{ rel: longRel, status: 'same', leftSize: 1, rightSize: 1 }],
CTX,
'txt'
)
expect(txt).toContain('…')
expect(txt).not.toContain('d'.repeat(80))
})
it('截断时输出警示行', () => {
const txt = buildFolderReport(ENTRIES, { ...CTX, truncated: true, total: 10000 }, 'txt')
expect(txt).toContain('⚠ 文件过多(10000+')
})
})
describe('buildFolderReport - Markdown', () => {
it('输出表格化报告(状态/路径/大小)与结论引用块', () => {
const md = buildFolderReport(ENTRIES, CTX, 'md')
expect(md.startsWith('# DiffLens 文件夹对比报告')).toBe(true)
expect(md).toContain('> 结论:共 3 个文件存在差异')
expect(md).toContain('| 状态 | 相对路径 | 左侧大小 | 右侧大小 |')
expect(md).toContain('`sub/only-r.ts`')
expect(md).toContain('| 语义同 |')
})
it('路径中的管道符被转义保持表格结构', () => {
const md = buildFolderReport(
[{ rel: 'a|b.txt', status: 'same', leftSize: 1, rightSize: 1 }],
CTX,
'md'
)
expect(md).toContain('a\\|b.txt')
})
it('空条目集表格给出占位行', () => {
const md = buildFolderReport([], { ...CTX, total: 0 }, 'md')
expect(md).toContain('两个文件夹内都没有文件')
})
})
+293
View File
@@ -0,0 +1,293 @@
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
import { esc, REPORT_CSS, REPORT_EXT, type ReportFormat } from './report'
/**
* 文件夹对比报告生成器(HTML / 纯文本 / Markdown 三格式)。
* 与单文件报告共用主题样式(REPORT_CSS)与转义工具;语义判等关闭时统计中语义同恒为 0。
*/
export type { ReportFormat }
/** 文件夹报告上下文 */
export interface FolderReportContext {
leftDir: string
rightDir: string
/** 语义判等是否开启(报告头部说明判定口径) */
semantic: boolean
/** 条目因数量上限被截断(报告头部警示) */
truncated: boolean
/** 枚举总数(截断时为已遍历下限) */
total: number
}
/** 六类状态统计 */
export interface FolderStats {
same: number
semanticSame: number
different: number
leftOnly: number
rightOnly: number
unreadable: number
}
export function countFolderStats(entries: FolderEntry[]): FolderStats {
const s: FolderStats = {
same: 0,
semanticSame: 0,
different: 0,
leftOnly: 0,
rightOnly: 0,
unreadable: 0
}
for (const e of entries) {
if (e.status === 'same') s.same++
else if (e.status === 'semantic-same') s.semanticSame++
else if (e.status === 'different') s.different++
else if (e.status === 'left-only') s.leftOnly++
else if (e.status === 'right-only') s.rightOnly++
else s.unreadable++
}
return s
}
/** 一句话结论:给不熟悉状态符号的人看 */
export function plainFolderSummary(stats: FolderStats, entriesLen: number): string {
if (entriesLen === 0) return '两个文件夹内都没有文件'
const diffTotal = stats.different + stats.leftOnly + stats.rightOnly
if (diffTotal === 0) {
return stats.semanticSame > 0
? `两文件夹内容完全一致(其中 ${stats.semanticSame} 个文件仅空白/换行差异)`
: '两文件夹内容完全一致'
}
const parts: string[] = []
if (stats.different > 0) parts.push(`内容不同 ${stats.different}`)
if (stats.leftOnly > 0) parts.push(`仅左侧 ${stats.leftOnly}`)
if (stats.rightOnly > 0) parts.push(`仅右侧 ${stats.rightOnly}`)
const semNote = stats.semanticSame > 0 ? `(另有 ${stats.semanticSame} 个仅空白/换行差异,不计入不同)` : ''
const unreadNote = stats.unreadable > 0 ? `;另有 ${stats.unreadable} 个文件无法读取(被占用/权限)` : ''
return `${diffTotal} 个文件存在差异:${parts.join('、')}${semNote}${unreadNote}`
}
/** 状态的人话标签 */
const STATUS_LABEL: Record<FolderEntryStatus, string> = {
same: '相同',
'semantic-same': '语义同',
different: '不同',
'left-only': '仅左侧',
'right-only': '仅右侧',
unreadable: '无法读取'
}
/** 人类可读的文件大小 */
function fmtSize(n: number | null): string {
if (n === null) return '—'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
/** ============ HTML 报告 ============ */
/** 文件夹报告追加样式:条目表状态列配色(叠加在 REPORT_CSS 之上) */
const FOLDER_EXTRA_CSS = `
.f-table { border-collapse:collapse; width:100%; table-layout:fixed;
font-family:Consolas,'JetBrains Mono',monospace; font-size:12.5px;
border:1px solid rgba(148,163,255,.14); border-radius:12px; overflow:hidden; }
.f-table col.c-st { width:76px; }
.f-table col.c-ls { width:88px; }
.f-table col.c-rs { width:88px; }
.f-table thead td { font-family:'Segoe UI','Microsoft YaHei',sans-serif; font-size:11px; color:#74809a;
background:rgba(124,108,255,.08); border-bottom:1px solid rgba(148,163,255,.18);
padding:6px 10px; text-align:left; letter-spacing:1px; }
.f-table td { border-bottom:1px solid rgba(148,163,255,.08); padding:4px 10px;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.f-table td.st { text-align:center; font-family:'Segoe UI','Microsoft YaHei',sans-serif;
font-weight:700; font-size:12px; border-radius:4px; }
.f-table td.sz { text-align:right; color:#74809a; font-variant-numeric:tabular-nums; }
.f-table td.rel { word-break:break-all; white-space:normal; }
.f-table tr.st-same td.st { color:#7ee2a8; background:rgba(34,197,94,.10); }
.f-table tr.st-sem td.st { color:#22d3ee; background:rgba(34,211,238,.10); }
.f-table tr.st-diff td.st { color:#f7a9a9; background:rgba(248,113,113,.10); }
.f-table tr.st-left td.st { color:#c9bfff; background:rgba(124,108,255,.12); }
.f-table tr.st-right td.st { color:#c9bfff; background:rgba(124,108,255,.12); }
.f-table tr.st-unread td.st { color:#f6bd87; background:rgba(245,158,11,.10); }
.f-table tr:hover { background:rgba(34,211,238,.05); }
.trunc-note { color:#f6bd87; font-size:12.5px; padding:8px 14px; border-radius:10px;
background:rgba(245,158,11,.10); border:1px solid rgba(245,158,11,.35); margin-bottom:14px; }
@media print {
.f-table { border-color:#ccc; }
.f-table td { border-color:#eee; }
.f-table thead td { background:#f3f3f6; color:#666; }
.trunc-note { color:#8a5a00; background:#fff7e6; border-color:#e8c07a; }
}
`
function buildFolderHtml(
entries: FolderEntry[],
ctx: FolderReportContext,
stats: FolderStats
): string {
const verdictOk = stats.different + stats.leftOnly + stats.rightOnly === 0
const semLine = ctx.semantic
? '判定口径:语义判等开启(空白/换行差异计为语义同,不计入不同)'
: '判定口径:字节级严格比对(任何字节差异均计为不同)'
const rowsHtml = entries
.map((e) => {
const cls =
e.status === 'same'
? 'st-same'
: e.status === 'semantic-same'
? 'st-sem'
: e.status === 'different'
? 'st-diff'
: e.status === 'left-only'
? 'st-left'
: e.status === 'right-only'
? 'st-right'
: 'st-unread'
const approx = e.approximate ? ' <i title="超大文件,头部采样近似判定">≈</i>' : ''
return `<tr class="${cls}">
<td class="st">${STATUS_LABEL[e.status]}</td>
<td class="rel">${esc(e.rel)}${approx}</td>
<td class="sz">${fmtSize(e.leftSize)}</td>
<td class="sz">${fmtSize(e.rightSize)}</td>
</tr>`
})
.join('\n')
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DiffLens 文件夹对比报告</title>
<style>${REPORT_CSS}${FOLDER_EXTRA_CSS}</style>
</head>
<body>
<div class="wrap">
<header class="hero">
<div class="hero-title">📁 DiffLens 文件夹对比报告</div>
<div class="hero-time">生成于 ${new Date().toLocaleString('zh-CN')}</div>
<div class="hero-opts">${esc(semLine)}</div>
<div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainFolderSummary(stats, entries.length))}</div>
<div class="files">
<div class="file-card left">
<div class="file-side">左侧目录</div>
<div class="file-name">${esc(ctx.leftDir)}</div>
</div>
<div class="files-arrow">➜</div>
<div class="file-card right">
<div class="file-side">右侧目录</div>
<div class="file-name">${esc(ctx.rightDir)}</div>
</div>
</div>
<div class="stats">
<div class="stat same"><div class="stat-num">${stats.same}</div><div class="stat-label">相同</div></div>
<div class="stat rate"><div class="stat-num">${stats.semanticSame}</div><div class="stat-label">语义同</div></div>
<div class="stat del"><div class="stat-num">${stats.different}</div><div class="stat-label">不同</div></div>
<div class="stat"><div class="stat-num">${stats.leftOnly}</div><div class="stat-label">仅左侧</div></div>
<div class="stat"><div class="stat-num">${stats.rightOnly}</div><div class="stat-label">仅右侧</div></div>
${stats.unreadable > 0 ? `<div class="stat mod"><div class="stat-num">${stats.unreadable}</div><div class="stat-label">无法读取</div></div>` : ''}
<div class="stat ins"><div class="stat-num">${entries.length}</div><div class="stat-label">条目总数</div></div>
</div>
</header>
${ctx.truncated ? `<div class="trunc-note">⚠ 文件数量超出上限(已发现 ${ctx.total}+ 个),以下仅包含部分条目,请人工确认完整性</div>` : ''}
<table class="f-table">
<colgroup><col class="c-st"><col><col class="c-ls"><col class="c-rs"></colgroup>
<thead><tr><td>状态</td><td>相对路径</td><td style="text-align:right">左侧大小</td><td style="text-align:right">右侧大小</td></tr></thead>
<tbody>
${entries.length === 0 ? '<tr><td colspan="4" style="text-align:center;color:#74809a;padding:24px">两个文件夹内都没有文件</td></tr>' : rowsHtml}
</tbody>
</table>
<div class="foot">由 DiffLens 生成 · 语义同 = 字节不同但剔除空白/换行后内容一致(双击条目可查看单文件差异明细)</div>
</div>
</body>
</html>`
}
/** ============ 纯文本报告 ============ */
function buildFolderTxt(
entries: FolderEntry[],
ctx: FolderReportContext,
stats: FolderStats
): string {
const lines = [
'====== DiffLens 文件夹对比报告 ======',
`左侧目录:${ctx.leftDir}`,
`右侧目录:${ctx.rightDir}`,
`对比时间:${new Date().toLocaleString('zh-CN')}`,
`判定口径:${ctx.semantic ? '语义判等开启(空白/换行差异计为语义同)' : '字节级严格比对'}`,
`结论:${plainFolderSummary(stats, entries.length)}`,
`统计:相同 ${stats.same} 语义同 ${stats.semanticSame} 不同 ${stats.different} 仅左 ${stats.leftOnly} 仅右 ${stats.rightOnly}${stats.unreadable > 0 ? ` 无法读取 ${stats.unreadable}` : ''} ${entries.length}`
]
if (ctx.truncated) {
lines.push(`⚠ 文件过多(${ctx.total}+),以下仅包含部分条目,请人工确认完整性`)
}
lines.push('----------------------------------')
if (entries.length === 0) {
lines.push('两个文件夹内都没有文件')
} else {
// 路径列宽对齐(封顶 60,超长路径截断保持可读)
const width = Math.min(60, Math.max(...entries.map((e) => e.rel.length)))
for (const e of entries) {
const rel = e.rel.length > width ? e.rel.slice(0, width - 1) + '…' : e.rel.padEnd(width)
lines.push(
`${STATUS_LABEL[e.status].padEnd(3)} ${rel} ${fmtSize(e.leftSize).padStart(9)} ${fmtSize(e.rightSize).padStart(9)}${e.approximate ? ' ≈' : ''}`
)
}
}
lines.push('----------------------------------')
lines.push('生成于 DiffLens')
return lines.join('\n')
}
/** ============ Markdown 报告 ============ */
function buildFolderMd(
entries: FolderEntry[],
ctx: FolderReportContext,
stats: FolderStats
): string {
const out: string[] = [
'# DiffLens 文件夹对比报告',
'',
`> 左侧目录:${ctx.leftDir}`,
`> 右侧目录:${ctx.rightDir}`,
`> 对比时间:${new Date().toLocaleString('zh-CN')}`,
`> 判定口径:${ctx.semantic ? '语义判等开启(空白/换行差异计为语义同)' : '字节级严格比对'}`,
`> 结论:${plainFolderSummary(stats, entries.length)}`,
`> 统计:相同 ${stats.same} 语义同 ${stats.semanticSame} 不同 ${stats.different} 仅左 ${stats.leftOnly} 仅右 ${stats.rightOnly}${stats.unreadable > 0 ? ` 无法读取 ${stats.unreadable}` : ''} ${entries.length}`,
''
]
if (ctx.truncated) {
out.push(`> ⚠ 文件过多(${ctx.total}+),以下仅包含部分条目,请人工确认完整性`, '')
}
out.push('| 状态 | 相对路径 | 左侧大小 | 右侧大小 |', '| --- | --- | --- | --- |')
if (entries.length === 0) {
out.push('| (空) | 两个文件夹内都没有文件 | — | — |')
} else {
for (const e of entries) {
const rel = e.rel.replace(/\|/g, '\\|')
out.push(`| ${STATUS_LABEL[e.status]} | \`${rel}\`${e.approximate ? ' ≈' : ''} | ${fmtSize(e.leftSize)} | ${fmtSize(e.rightSize)} |`)
}
}
out.push('', '> 生成于 DiffLens')
return out.join('\n')
}
/** 按格式生成文件夹对比报告字符串 */
export function buildFolderReport(
entries: FolderEntry[],
ctx: FolderReportContext,
format: ReportFormat
): string {
const stats = countFolderStats(entries)
if (format === 'html') return buildFolderHtml(entries, ctx, stats)
if (format === 'txt') return buildFolderTxt(entries, ctx, stats)
return buildFolderMd(entries, ctx, stats)
}
/** 文件夹报告扩展名(与单文件报告一致) */
export { REPORT_EXT }
+2 -2
View File
@@ -187,12 +187,12 @@ describe('plainOptions - 比较选项描述', () => {
it('字符级对比单独描述并遮蔽全部空白类选项', () => { it('字符级对比单独描述并遮蔽全部空白类选项', () => {
expect( expect(
plainOptions({ charMode: true, trimWhitespace: true, ignoreAllWhitespace: true, ignoreBlankLines: true }) plainOptions({ charMode: true, trimWhitespace: true, ignoreAllWhitespace: true, ignoreBlankLines: true })
).toBe('字符级对比(忽略全部空白与换行)') ).toBe('字符级对比(忽略全部空白与换行,大内容自动分块计算')
}) })
it('字符级对比与忽略大小写组合列出', () => { it('字符级对比与忽略大小写组合列出', () => {
expect(plainOptions({ charMode: true, ignoreCase: true })).toBe( expect(plainOptions({ charMode: true, ignoreCase: true })).toBe(
'字符级对比(忽略全部空白与换行)、忽略大小写' '字符级对比(忽略全部空白与换行,大内容自动分块计算)、忽略大小写'
) )
}) })
}) })
+7 -5
View File
@@ -19,7 +19,8 @@ export interface ReportContext {
options?: DiffOptions options?: DiffOptions
} }
const esc = (s: string): string => /** HTML 转义(文件夹报告复用) */
export const esc = (s: string): string =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
/** 格式化行号:传 width 时按位数右对齐(左补空格,用于纯文本报告对齐);null 返回空串 */ /** 格式化行号:传 width 时按位数右对齐(左补空格,用于纯文本报告对齐);null 返回空串 */
@@ -120,9 +121,10 @@ export function plainSummary(summary: DiffSummary): string {
/** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */ /** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */
export function plainOptions(options?: DiffOptions): string { export function plainOptions(options?: DiffOptions): string {
// 字符级对比为最强空白语义(全部空白与换行都不参与判等),单独描述并遮蔽空白类子选项 // 字符级对比为最强空白语义(全部空白与换行都不参与判等),单独描述并遮蔽空白类子选项
// 大内容按“行锚定 + 变更组分块”计算,不再整篇受字符量上限约束
if (options?.charMode) { if (options?.charMode) {
const on = ['字符级对比(忽略全部空白与换行)'] const on = ['字符级对比(忽略全部空白与换行,大内容自动分块计算']
if (options.ignoreCase) on.push('忽略大小写') if (options.ignoreCase) on.push('忽略大小写')
return on.join('、') return on.join('、')
} }
@@ -154,8 +156,8 @@ const headLines = (
/** ============ HTML 报告 ============ */ /** ============ HTML 报告 ============ */
/** 报告内嵌样式:延续应用暗色科幻主题,附带打印亮色与窄屏适配(零 JS,任意浏览器可开) */ /** 报告内嵌样式:延续应用暗色科幻主题,附带打印亮色与窄屏适配(零 JS,任意浏览器可开);文件夹报告复用并追加条目表样式 */
const REPORT_CSS = ` export const REPORT_CSS = `
* { margin:0; padding:0; box-sizing:border-box; } * { margin:0; padding:0; box-sizing:border-box; }
html { scroll-behavior:smooth; } html { scroll-behavior:smooth; }
body { body {
+99 -7
View File
@@ -1,7 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react' import { renderHook, act } from '@testing-library/react'
import { useDiff } from './useDiff' import { useDiff } from './useDiff'
import { computeDiff, type DiffResult, type DiffOptions } from '../diff/diffEngine' import {
computeDiff,
CHAR_HEAVY_INPUT_CHARS,
type DiffResult,
type DiffOptions
} from '../diff/diffEngine'
import type { DiffWorkerFactory } from '../diff/createDiffWorker' import type { DiffWorkerFactory } from '../diff/createDiffWorker'
/** 模拟 Worker:记录派发请求,可手动回传结果或触发崩溃;terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */ /** 模拟 Worker:记录派发请求,可手动回传结果或触发崩溃;terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */
@@ -76,7 +81,7 @@ describe('useDiff - 快路径', () => {
describe('useDiff - 字符级大字符量重路径', () => { describe('useDiff - 字符级大字符量重路径', () => {
it('少行大字符量输入(charMode 开启)进入重路径后台计算', () => { it('少行大字符量输入(charMode 开启)进入重路径后台计算', () => {
const factory = makeFactory() const factory = makeFactory()
// 2 行 × 约 3 万字符 ≈ 6 万字符:行数远低于快路径阈值,但字符量超过 CHAR_HEAVY_INPUT_CHARS // 2 行 × 约 3 万字符 ≈ 6 万字符:行数远低于快路径阈值,但字符量超过 CHAR_HEAVY_INPUT_CHARS5000
const big = 'a'.repeat(30000) + '\n' + 'b'.repeat(30000) const big = 'a'.repeat(30000) + '\n' + 'b'.repeat(30000)
const { result } = renderHook(() => useDiff(big, big, CHAR_ON, factory)) const { result } = renderHook(() => useDiff(big, big, CHAR_ON, factory))
expect(result.current.computing).toBe(true) expect(result.current.computing).toBe(true)
@@ -116,15 +121,45 @@ describe('useDiff - 字符级大字符量重路径', () => {
it('恰好阈值不触发重路径,超过阈值触发', () => { it('恰好阈值不触发重路径,超过阈值触发', () => {
const factory = makeFactory() const factory = makeFactory()
// 两侧各 3750 字符 = 恰好 7500CHAR_HEAVY_INPUT_CHARS = CHAR_DIFF_MAX_CHARS/4:不触发 // 两侧各 CHAR_HEAVY_INPUT_CHARS / 2 = 恰好 5000:不触发
const at = 'a'.repeat(3750) const at = 'a'.repeat(CHAR_HEAVY_INPUT_CHARS / 2)
const hook1 = renderHook(() => useDiff(at, at, CHAR_ON, factory)) const hook1 = renderHook(() => useDiff(at, at, CHAR_ON, factory))
expect(hook1.result.current.computing).toBe(false) expect(hook1.result.current.computing).toBe(false)
// 3751 + 3750 = 7501:触发 // 一侧超出 1 字符(5001 + 5000:触发
const over = 'a'.repeat(3751) const over = 'a'.repeat(CHAR_HEAVY_INPUT_CHARS / 2 + 1)
const hook2 = renderHook(() => useDiff(over, at, CHAR_ON, factory)) const hook2 = renderHook(() => useDiff(over, at, CHAR_ON, factory))
expect(hook2.result.current.computing).toBe(true) expect(hook2.result.current.computing).toBe(true)
}) })
it('快路径后开启字符级进入重路径:首算期间用快路径结果兜底(容器高度不塌陷)', () => {
const factory = makeFactory()
// 先以快路径(charMode 关)完成一次计算:结果被记录
const left = 'x'.repeat(4000) + '\nmore'
const right = 'x'.repeat(4000) + '\ntext'
const hook = renderHook(
({ options }: { options: DiffOptions }) => useDiff(left, right, options, factory),
{ initialProps: { options: { charMode: false } } }
)
expect(hook.result.current.computing).toBe(false)
const fast = hook.result.current.diff
expect(fast).not.toBeNull()
// 开字符级(字符量 > 5000 触发重路径):首算期间 asyncDiff 为 null
// 但文本未变 → 快路径结果兜底展示(滚动容器高度稳定,不回弹到顶)
hook.rerender({ options: { charMode: true } })
expect(hook.result.current.computing).toBe(true)
expect(hook.result.current.diff).toEqual(fast)
// worker 完成后替换为字符级结果
act(() => {
vi.advanceTimersByTime(250)
})
const w = factory.workers[0]
const res = computeDiff(left, right, { charMode: true })
act(() => {
w.respond(w.posted[0].jobId, res)
})
expect(hook.result.current.diff).toEqual(res)
expect(hook.result.current.computing).toBe(false)
})
}) })
describe('useDiff - worker 后台计算', () => { describe('useDiff - worker 后台计算', () => {
@@ -268,7 +303,7 @@ describe('useDiff - worker 后台计算', () => {
expect(result.current.computing).toBe(false) expect(result.current.computing).toBe(false)
}) })
it('迟到错误(输入已变化)只终止不回填,新任务结果正常生效', () => { it('迟到错误(输入已变化)只终止不回填,新任务结果正常生效', async () => {
const factory = makeFactory() const factory = makeFactory()
const hook = renderHook((p: string) => useDiff(p, 'x', NO_OPTIONS, factory), { const hook = renderHook((p: string) => useDiff(p, 'x', NO_OPTIONS, factory), {
initialProps: heavyText(2100) initialProps: heavyText(2100)
@@ -297,4 +332,61 @@ describe('useDiff - worker 后台计算', () => {
expect(hook.result.current.diff).toEqual(res) expect(hook.result.current.diff).toEqual(res)
expect(hook.result.current.computing).toBe(false) expect(hook.result.current.computing).toBe(false)
}) })
it('仅选项变化时保留旧结果撑住容器高度(防滚动塌陷回弹),新结果到达后替换', () => {
const factory = makeFactory()
const left = heavyText(2100)
const right = heavyText(2100)
const hook = renderHook(
({ options }: { options: DiffOptions }) => useDiff(left, right, options, factory),
{ initialProps: { options: { ignoreCase: false } } }
)
// 首次计算完成(首算无旧结果,computing 期间 diff 为 null
act(() => {
vi.advanceTimersByTime(250)
})
const w1 = factory.workers[0]
const res1 = computeDiff('a', 'b', {})
act(() => {
w1.respond(w1.posted[0].jobId, res1)
})
expect(hook.result.current.diff).toEqual(res1)
expect(hook.result.current.computing).toBe(false)
// 仅切换选项(文本不变):旧结果保留(虚拟滚动容器高度不塌陷)+ computing 开始
hook.rerender({ options: { ignoreCase: true } })
expect(hook.result.current.computing).toBe(true)
expect(hook.result.current.diff).toEqual(res1)
// 新结果到达后替换
act(() => {
vi.advanceTimersByTime(250)
})
const w2 = factory.workers[1]
const res2 = computeDiff('p', 'q', { ignoreCase: true })
act(() => {
w2.respond(w2.posted[0].jobId, res2)
})
expect(hook.result.current.diff).toEqual(res2)
expect(hook.result.current.computing).toBe(false)
})
it('文本变化时清空旧结果(防止展示与新面板不对应的过期差异)', () => {
const factory = makeFactory()
const hook = renderHook(
(p: string) => useDiff(p, 'x', NO_OPTIONS, factory),
{ initialProps: heavyText(2100) }
)
act(() => {
vi.advanceTimersByTime(250)
})
const w1 = factory.workers[0]
const res1 = computeDiff('a', 'b', {})
act(() => {
w1.respond(w1.posted[0].jobId, res1)
})
expect(hook.result.current.diff).toEqual(res1)
// 文本变化:旧结果立即清空
hook.rerender(heavyText(2300))
expect(hook.result.current.diff).toBeNull()
expect(hook.result.current.computing).toBe(true)
})
}) })
+34 -5
View File
@@ -45,20 +45,39 @@ export function useDiff(
) )
const factory = workerFactory ?? createDiffWorker const factory = workerFactory ?? createDiffWorker
// 快路径:同步计算,渲染当帧即得结果 // 最近一次计算成功的结果与其输入文本:重路径计算期间用它撑住虚拟滚动容器高度
// (快路径结果与 worker 结果都写入;文本一致时作为重路径的兜底展示)
const lastResultRef = useRef<{ left: string; right: string; result: DiffResult } | null>(null)
// 快路径:同步计算,渲染当帧即得结果(并记录到 lastResultRef
const fastDiff = useMemo(() => { const fastDiff = useMemo(() => {
if (!heavy) return computeDiff(left, right, options) if (!heavy) {
const r = computeDiff(left, right, options)
lastResultRef.current = { left, right, result: r }
return r
}
return null return null
}, [left, right, options, heavy]) }, [left, right, options, heavy])
const [asyncDiff, setAsyncDiff] = useState<DiffResult | null>(null) const [asyncDiff, setAsyncDiff] = useState<DiffResult | null>(null)
const [computing, setComputing] = useState(false) const [computing, setComputing] = useState(false)
const jobIdRef = useRef(0) const jobIdRef = useRef(0)
// 上一次重路径派发时的输入文本:区分「文本变化」与「仅选项变化」(见 effect 内说明)
const lastInputsRef = useRef<{ left: string; right: string } | null>(null)
useEffect(() => { useEffect(() => {
if (!heavy) return if (!heavy) return
// 重输入先清空旧结果:避免交换左右等场景下展示与新面板不对应的过期差异 // 重路径计算期间保留旧结果撑住虚拟滚动容器高度:items 清空会使容器高度塌陷为 0,
setAsyncDiff(null) // 浏览器把 scrollTop 钳制归 0(计算完成后也不恢复)——切换选项/开启字符级触发重算时
// 视图回弹到顶部(0.6.2 及之前版本的实际缺陷,用户实测“开字符级后向下滚动回弹”)。
// 旧结果来源:worker 完成的 asyncDiff 与快路径结果(首算时 asyncDiff 尚为 null)。
// 文本变化(打开新文件/交换左右)不兜底:避免展示与新面板不对应的过期差异,
// 此时滚动位置随内容重建归位属预期行为。
const textChanged =
lastInputsRef.current !== null &&
(lastInputsRef.current.left !== left || lastInputsRef.current.right !== right)
if (textChanged) setAsyncDiff(null)
lastInputsRef.current = { left, right }
setComputing(true) setComputing(true)
// effect 世代标志:cleanup 后置位,迟到回调(含去抖期内输入已变、jobId 尚未推进的窗口)一律丢弃 // effect 世代标志:cleanup 后置位,迟到回调(含去抖期内输入已变、jobId 尚未推进的窗口)一律丢弃
let cancelled = false let cancelled = false
@@ -77,6 +96,7 @@ export function useDiff(
w.onmessage = (e: MessageEvent<DiffWorkerResponse>): void => { w.onmessage = (e: MessageEvent<DiffWorkerResponse>): void => {
// 丢弃过期任务:输入已变化时旧计算结果不再生效 // 丢弃过期任务:输入已变化时旧计算结果不再生效
if (e.data.jobId !== jobId || cancelled) return if (e.data.jobId !== jobId || cancelled) return
lastResultRef.current = { left, right, result: e.data.result }
setAsyncDiff(e.data.result) setAsyncDiff(e.data.result)
setComputing(false) setComputing(false)
} }
@@ -97,5 +117,14 @@ export function useDiff(
} }
}, [left, right, options, heavy, factory]) }, [left, right, options, heavy, factory])
return heavy ? { diff: asyncDiff, computing } : { diff: fastDiff, computing: false } // 重路径输出:优先 worker 结果;计算中且文本未变时用最近一次结果兜底(撑住容器高度,
// 防 scrollTop 塌陷回弹;文本已变化时不兜底,避免过期差异误导)
if (heavy) {
if (asyncDiff !== null) return { diff: asyncDiff, computing }
const last = lastResultRef.current
const fallback =
last !== null && last.left === left && last.right === right ? last.result : null
return { diff: fallback, computing }
}
return { diff: fastDiff, computing: false }
} }
+127
View File
@@ -520,6 +520,16 @@ button:disabled {
text-align: center; text-align: center;
} }
/* header 内的差异文件导航(从文件夹进入单文件对比时出现) */
.diff-file-nav {
padding: 0 2px;
}
.diff-file-label {
font-size: 11px;
color: var(--muted);
letter-spacing: 1px;
}
/* 统计徽章 */ /* 统计徽章 */
.stats { .stats {
display: flex; display: flex;
@@ -874,6 +884,42 @@ button:disabled {
font-weight: 500; font-weight: 500;
} }
/* 徽章可点击(状态精确过滤):激活态高亮 */
button.fstat {
cursor: pointer;
font-family: inherit;
transition: all 0.15s ease;
}
button.fstat:hover {
border-color: var(--border-strong);
}
button.fstat.active {
color: #fff;
background: var(--surface-hover);
border-color: var(--accent);
box-shadow: 0 0 10px rgba(124, 108, 255, 0.25);
}
/* 文件名搜索框 */
.folder-search {
width: 180px;
padding: 5px 12px;
border-radius: 8px;
font-size: 12px;
font-family: var(--mono);
color: var(--text);
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
outline: none;
transition: border-color 0.15s ease;
}
.folder-search:focus {
border-color: var(--accent);
}
.folder-search::placeholder {
color: var(--muted);
}
.folder-scroll { .folder-scroll {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
@@ -909,6 +955,36 @@ button:disabled {
color: var(--add-fg); color: var(--add-fg);
} }
/* 语义同:字节不同但剔除空白后一致(青色弱化,介于相同与不同之间) */
.folder-row.st-sem .folder-st-icon {
color: var(--accent-2);
}
.folder-row.st-sem {
background: rgba(34, 211, 238, 0.07);
}
.fstat.st-sem {
color: var(--accent-2);
}
/* 无法读取:文件被占用/权限不足(琥珀警示) */
.folder-row.st-unread .folder-st-icon {
color: var(--mod-fg);
}
.folder-row.st-unread {
background: rgba(245, 158, 11, 0.07);
}
.fstat.st-unread {
color: var(--mod-fg);
}
.folder-agg.st-unread {
color: var(--mod-fg);
}
.folder-row.st-diff .folder-st-icon { .folder-row.st-diff .folder-st-icon {
color: var(--del-fg); color: var(--del-fg);
} }
@@ -940,6 +1016,57 @@ button:disabled {
cursor: help; cursor: help;
} }
/* 目录行(树形):折叠箭头 + 名称 + 子树聚合状态 + 计数 */
.folder-row.dir {
cursor: pointer;
background: rgba(148, 163, 255, 0.04);
font-weight: 600;
}
.folder-caret {
flex: none;
width: 16px;
font-size: 10px;
color: var(--muted);
text-align: center;
}
.folder-row.dir .folder-rel {
font-weight: 700;
}
.folder-agg {
flex: none;
font-size: 10.5px;
padding: 0 7px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
font-weight: 500;
}
.folder-agg.st-same {
color: var(--add-fg);
}
.folder-agg.st-sem {
color: var(--accent-2);
}
.folder-agg.st-diff {
color: var(--del-fg);
}
.folder-agg.st-left,
.folder-agg.st-right {
color: var(--accent);
}
.folder-dircount {
flex: none;
font-size: 11px;
color: var(--muted);
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.folder-size { .folder-size {
flex: none; flex: none;
width: 76px; width: 76px;