feat: 0.7.0 文件夹对比增强(语义判等/子目录树形/搜索与状态过滤/报告导出/差异文件导航/大目录单点失败降级)+ 字符级分块锚定恢复(0.6.3 算法)与滚动回弹根因修复(重算期间保留旧结果撑住容器高度)+ E2E 滚动稳定性回归锚
This commit is contained in:
@@ -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
@@ -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 stub(showOpenDialog/showSaveDialog 可自动化) · 用例间清空 localStorage 隔离偏好污染 · npm run test:e2e 一键构建+验收,纳入发布 checklist
|
- Playwright E2E 测试体系建立:对 build 产物启动真实 Electron 验收(复用项目自带 Electron,无需下载浏览器) · 首批 11 用例覆盖 jsdom 无法触达的主进程真实链路(GBK/BOM/大文件 worker 解码、报告写盘、剪贴板、偏好跨实例重启、文件夹真实扫描) · 主进程 dialog stub(showOpenDialog/showSaveDialog 可自动化) · 用例间清空 localStorage 隔离偏好污染 · npm run test:e2e 一键构建+验收,纳入发布 checklist
|
||||||
- 文件夹对比(MVP):新增「对比文件夹」入口(依次选择两侧目录) · 递归扫描按相对路径对齐,文件级状态判定(相同/不同/仅左/仅右;大小不同即不同,大小一致做字节级全量比对,超 10MB 采样头部 8KB 近似判定并标注 ≈) · symlink 跳过防环,单侧文件数上限 10000(超限截断提示) · FolderView 虚拟滚动列表(统计徽章/仅看差异默认开/大小列示) · 双击条目进入单文件对比复用全部 diff 能力,一键返回文件夹列表(扫描结果保留) · file:open 与 file:read-by-path 共用同一读取管线(10MB 上限/编码探测/二进制预警一致)
|
- 文件夹对比(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
@@ -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 # 全局 setup:jest-dom 匹配器 + 自动 cleanup
|
│ └── setup.ts # 全局 setup:jest-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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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_EXTENSIONS(src/renderer/src/diff/textUtils.ts)完全对齐,两进程无法共享模块,
|
||||||
|
* 改动需双向同步。供打开文件对话框筛选与文件夹语义判等的文本文件判定共用。
|
||||||
|
*/
|
||||||
|
const TEXT_FILE_EXTENSIONS = [
|
||||||
|
'txt', 'text', 'md', 'markdown', 'json', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts', 'tsx', 'jsx',
|
||||||
|
'css', 'scss', 'less', 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf',
|
||||||
|
'config', 'properties', 'env', 'py', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cc', 'cs',
|
||||||
|
'swift', 'kt', 'rb', 'php', 'pl', 'sh', 'zsh', 'bash', 'fish', 'bat', 'cmd', 'ps1', 'log',
|
||||||
|
'csv', 'tsv', 'sql', 'vue', 'svelte', 'graphql', 'gql', 'gradle', 'lock'
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 相对路径是否为文本文件(按扩展名,语义判等文本判定共用) */
|
||||||
|
function isTextRel(rel: string): boolean {
|
||||||
|
const dot = rel.lastIndexOf('.')
|
||||||
|
if (dot < 0) return true
|
||||||
|
return TEXT_FILE_EXTENSIONS.includes(rel.slice(dot + 1).toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
/** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */
|
/** 应用状态持久化文件(窗口 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_EXTENSIONS(src/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
@@ -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
@@ -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 ? (
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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 子树含 different(diff.ts)→ 聚合徽章“不同”
|
||||||
|
expect(dirRow!.textContent).toContain('不同')
|
||||||
|
expect(dirRow!.textContent).toContain('2 项')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('双击条目触发 onOpenEntry(携带原始条目)', () => {
|
it('子目录文件默认展开可见(深层文件渲染)', () => {
|
||||||
|
renderView(ENTRIES, false)
|
||||||
|
expect(screen.getByText('deep')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('diff.ts')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('only-right.txt')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击目录行折叠/展开子树', () => {
|
||||||
|
renderView(ENTRIES, false)
|
||||||
|
expect(screen.getByText('diff.ts')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByText('sub'))
|
||||||
|
// sub 折叠:其下全部子行隐藏
|
||||||
|
expect(screen.queryByText('only-right.txt')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('deep')).not.toBeInTheDocument()
|
||||||
|
// 再次点击展开恢复
|
||||||
|
fireEvent.click(screen.getByText('sub'))
|
||||||
|
expect(screen.getByText('diff.ts')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('双击文件条目触发 onOpenEntry(携带原始条目)', () => {
|
||||||
const onOpenEntry = vi.fn()
|
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`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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_CHARS(100000)→ 整体降级
|
||||||
|
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 对齐具有任意性
|
||||||
|
|||||||
@@ -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_EDIT,diffChars
|
* 第二阶段:连续的 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 }
|
|
||||||
}
|
}
|
||||||
@@ -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('两个文件夹内都没有文件')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
@@ -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(
|
||||||
'字符级对比(忽略全部空白与换行)、忽略大小写'
|
'字符级对比(忽略全部空白与换行,大内容自动分块计算)、忽略大小写'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
|
|
||||||
/** 格式化行号:传 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 {
|
||||||
|
|||||||
@@ -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_CHARS(5000)
|
||||||
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 字符 = 恰好 7500(CHAR_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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user