feat: 0.8.0 文件夹对比深度增强与字符级逐组预算制 - 按扩展名多选过滤(第四维过滤与其他条件取交集、N/总数计数切换)、目录行双击聚合对比(差异导航限定子树内批量审查、双击文件回全局、空差异提示与折叠态自愈)、增量重扫(size+mtime指纹一致零IO沿用、语义开关翻转按semSame纯内存组合零IO、unreadable不缓存与截断弃缓存、重新扫描按钮)、字符级变更组逐组预算制(超限组仅行级配对输出不再牵连其余组、charModePartial部分降级标记与报告说明、全部组降级仍整体降级)、拖拽导入E2E(dropFiles合成DataTransfer覆盖decodeBuffer IPC全链路4用例)、文档与版本号同步
This commit is contained in:
@@ -11,7 +11,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
|||||||
|
|
||||||
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
|
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
|
||||||
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
|
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
|
||||||
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑)批量审查,一键返回文件夹列表
|
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新)
|
||||||
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
|
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
|
||||||
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览)
|
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览)
|
||||||
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
|
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
|
||||||
@@ -22,7 +22,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
|||||||
- **交换左右侧**:一键互换两侧内容与视角
|
- **交换左右侧**:一键互换两侧内容与视角
|
||||||
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
|
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
|
||||||
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
|
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
|
||||||
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;**相同内容自动锚定跳过计算(行锚定 + 变更组分块),大文件不再整篇受限**;差异过大时自动降级行级对比并提示(编辑距离封顶,不会卡死);重算期间保留旧结果撑住滚动位置,**切换选项不再回弹到顶部**
|
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;**相同内容自动锚定跳过计算(行锚定 + 变更组分块),大文件不再整篇受限**;**变更组逐组预算制**——单组过大 / 编辑距离超限 / 预算耗尽的块仅按行级配对输出(部分降级,界面与报告标注),不再牵连其余块;全部块降级时自动降级行级对比并提示(编辑距离封顶,不会卡死);重算期间保留旧结果撑住滚动位置,**切换选项不再回弹到顶部**
|
||||||
- **选项联动透明**:开启字符级对比时,被包含的空白选项灰显勾选并标注“已包含”(点击有解释提示,关闭字符级即恢复独立设置);差异过大自动降级时开关旁标注“已降级”,状态一目了然
|
- **选项联动透明**:开启字符级对比时,被包含的空白选项灰显勾选并标注“已包含”(点击有解释提示,关闭字符级即恢复独立设置);差异过大自动降级时开关旁标注“已降级”,状态一目了然
|
||||||
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
|
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
|
||||||
- **偏好记忆**:比较选项与"仅看差异"开关自动保存,窗口尺寸/位置与上次打开文件目录一并记忆,下次启动自动恢复(损坏数据自动回退默认)
|
- **偏好记忆**:比较选项与"仅看差异"开关自动保存,窗口尺寸/位置与上次打开文件目录一并记忆,下次启动自动恢复(损坏数据自动回退默认)
|
||||||
@@ -32,7 +32,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
|||||||
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
||||||
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
|
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
|
||||||
- **导出一键报告**:单文件与文件夹对比均可导出 HTML / 纯文本 / Markdown 三种格式。单文件报告始终包含全量行,HTML 为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(点击展开零丢失),并支持打印与窄屏;文件夹报告含判定口径说明与全量条目清单
|
- **导出一键报告**:单文件与文件夹对比均可导出 HTML / 纯文本 / Markdown 三种格式。单文件报告始终包含全量行,HTML 为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(点击展开零丢失),并支持打印与窄屏;文件夹报告含判定口径说明与全量条目清单
|
||||||
- **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本
|
- **拖拽与按钮双入口**:支持文件拖拽导入(E2E 覆盖真实拖放链路),也可通过界面按钮选择文件或粘贴文本
|
||||||
- **滚动联动**:左右两个面板滚动位置自动同步
|
- **滚动联动**:左右两个面板滚动位置自动同步
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -110,7 +110,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
|
|||||||
- 作者:thzxx
|
- 作者:thzxx
|
||||||
- 组织:MetonaTeam
|
- 组织:MetonaTeam
|
||||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||||
- 版本:0.7.1
|
- 版本:0.8.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -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.7.1`**
|
当前基线版本:**`0.8.0`**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -92,6 +92,12 @@ z = 补丁版本号(Patch)
|
|||||||
- 字符级对比分块锚定(恢复 0.6.3 算法):行归一化锚定 + 变更组分块字符流两阶段——相同内容不进入 diffChars,大文件字符级不再整篇受限(组上限 5 万字符/编辑距离 5000/组总流 10 万,任一超限降级行级);引擎输出经真机验证精确无误(0.6.3 事故根因不在引擎)
|
- 字符级对比分块锚定(恢复 0.6.3 算法):行归一化锚定 + 变更组分块字符流两阶段——相同内容不进入 diffChars,大文件字符级不再整篇受限(组上限 5 万字符/编辑距离 5000/组总流 10 万,任一超限降级行级);引擎输出经真机验证精确无误(0.6.3 事故根因不在引擎)
|
||||||
- 滚动回弹修复(0.6.3 事故真因,0.5.3 起即存在):worker 重路径计算期间保留旧结果撑住虚拟滚动容器高度(快路径结果兜底首次重算),items 不再塌陷——scrollTop 不被浏览器钳制归零;文本变化(打开新文件/交换左右)仍清空防过期差异误导;E2E 新增滚动稳定性回归锚(真实文件+行数不等+开字符级+切选项重算断言位置保持)
|
- 滚动回弹修复(0.6.3 事故真因,0.5.3 起即存在):worker 重路径计算期间保留旧结果撑住虚拟滚动容器高度(快路径结果兜底首次重算),items 不再塌陷——scrollTop 不被浏览器钳制归零;文本变化(打开新文件/交换左右)仍清空防过期差异误导;E2E 新增滚动稳定性回归锚(真实文件+行数不等+开字符级+切选项重算断言位置保持)
|
||||||
0.7.1 ← 文件夹扫描竞态守卫与边角修复(已发布):文件夹扫描会话序号丢弃过期结果(语义切换触发重扫与重新选择新目录的并发扫描按序号判定,慢的旧扫描后完成不再覆盖新扫描的目录与条目、不误弹失败提示、不误清扫描态;退出文件夹对比作废在途扫描,迟到的结果不把界面拉回文件夹模式、遮罩不残留) · FolderView 补窗口尺寸变化监听(拉伸窗口变高后虚拟滚动窗口即时补齐,底部不再出现空白区,对齐 DiffView 行为) · 行号列改不透明等效合成背景(真机证实横向滚动时行文字透过 sticky 半透明行号列叠印,修复后完全遮盖,视觉色值为原叠加的预合成) · 最大化/全屏关闭改存 getNormalBounds 普通状态(本次会话调整过的窗口位置不因最大化关闭丢失,最小化仍不记录) · 行 id 生成统一为 rows.length(消除 flushRemoved 与 commitModified 双轨) · fmtSize 收敛为 folderReport 导出的单一实现(报告与列表共用) · 文档与版本号同步
|
0.7.1 ← 文件夹扫描竞态守卫与边角修复(已发布):文件夹扫描会话序号丢弃过期结果(语义切换触发重扫与重新选择新目录的并发扫描按序号判定,慢的旧扫描后完成不再覆盖新扫描的目录与条目、不误弹失败提示、不误清扫描态;退出文件夹对比作废在途扫描,迟到的结果不把界面拉回文件夹模式、遮罩不残留) · FolderView 补窗口尺寸变化监听(拉伸窗口变高后虚拟滚动窗口即时补齐,底部不再出现空白区,对齐 DiffView 行为) · 行号列改不透明等效合成背景(真机证实横向滚动时行文字透过 sticky 半透明行号列叠印,修复后完全遮盖,视觉色值为原叠加的预合成) · 最大化/全屏关闭改存 getNormalBounds 普通状态(本次会话调整过的窗口位置不因最大化关闭丢失,最小化仍不记录) · 行 id 生成统一为 rows.length(消除 flushRemoved 与 commitModified 双轨) · fmtSize 收敛为 folderReport 导出的单一实现(报告与列表共用) · 文档与版本号同步
|
||||||
|
0.8.0 ← 文件夹对比深度增强 · 字符级逐组预算制 · 拖拽 E2E(已发布):
|
||||||
|
- 按扩展名过滤:folderTree 纯逻辑层增 relExt/EntryFilter.exts(第四维过滤,与其他条件取交集;无扩展名文件归空串类目) · FolderView 扩展名多选下拉(动态收集全量条目 ext→count 字典序,chips 激活态、清除按钮、useDismiss 关闭) · 过滤激活时"共 N / 总数"计数切换,统计徽章口径保持全量不变
|
||||||
|
- 目录行双击聚合对比:双击目录行进入该子树的差异文件批量审查(导航序列限定子树内,语义同不计入;标签标注当前范围,Alt+↓/Alt+↑ 在范围内不漂移) · 双击任意文件条目回全局序列 · 目录内无差异文件 toast 提示不进入 · 双击前的两次单击 toggle 残留折叠态由进入时自动展开消除 · openFolderEntry 增 navScope 参数(导航与聚合入口保持范围,手动双击清除)
|
||||||
|
- 增量重扫:folderScan 枚举 stat 保留 size+mtimeMs 指纹,扫描结果携带增量缓存(ScanCacheEntry:双侧指纹 + 状态 + semSame 语义判定参与记录) · 重扫携带上次缓存时指纹一致的条目零 IO 沿用;语义判等开关翻转按 semSame 纯内存组合(different ↔ semantic-same 零 IO),仅"上次未参与语义而本次需要"的条目读内容补判 · unreadable 不进缓存(重扫重试)、截断忽略缓存且 cache 产出为空(条目不完整不可信) · IPC folder:scan 增 previous 参数(主进程防御性校验数组形态) · FolderView 增「重新扫描」按钮(不重选目录直接刷新) · 已知取舍:mtime 未变视为内容未变(rsync/make 同款,手动改回时间戳不防)
|
||||||
|
- 字符级逐组预算制(变更组粒度放宽):第二阶段从"任一超限整体降级"改为逐组判定——组流 ≤ 5 万且编辑距离 ≤ 5000 且在总预算(10 万字符)内的组走字符级,超限/预算耗尽的组仅按行级配对输出(emitGroupLineLevel:行归一化 LCS + 相邻块配 modified 词级高亮 + 多余侧 removed/added,与行级路径同构含超长行词级防护) · DiffResult 增 charModePartial 部分降级标志(与 charModeDowngraded 互斥:全部组降级仍整体降级) · Toolbar「部分降级」标记、toast 提示、报告头部选项说明(plainOptions 增 partial 描述) · 行为变更:0.7.x 整体降级的多组混合输入在 0.8.0 为部分降级(特性而非回归,测试固化新行为)
|
||||||
|
- 拖拽导入 E2E:helpers 增 dropFiles(真实 Chromium DataTransfer/DragEvent 合成拖放,覆盖 dropPane → file.arrayBuffer → decodeBuffer IPC → 编码探测全链路) · 4 用例:空面板拖入渲染、双侧拖入对比、非文本扩展名拒绝、多文件提示取首个
|
||||||
...
|
...
|
||||||
0.y.z ← 长期停留,永不进入 1.x
|
0.y.z ← 长期停留,永不进入 1.x
|
||||||
```
|
```
|
||||||
|
|||||||
+18
-13
@@ -44,8 +44,8 @@
|
|||||||
vitest.config.ts # Vitest 配置(jsdom + 测试范围 + 覆盖率 80% 硬门禁)
|
vitest.config.ts # Vitest 配置(jsdom + 测试范围 + 覆盖率 80% 硬门禁)
|
||||||
playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑)
|
playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑)
|
||||||
e2e/
|
e2e/
|
||||||
├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口)
|
├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口/合成拖放)
|
||||||
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/偏好重启)
|
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/拖拽导入/偏好重启)
|
||||||
src/main/
|
src/main/
|
||||||
├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,真实链路由 E2E 覆盖)
|
├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,真实链路由 E2E 覆盖)
|
||||||
├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用)
|
├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用)
|
||||||
@@ -74,13 +74,13 @@ src/renderer/src/
|
|||||||
├── components/
|
├── components/
|
||||||
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出)
|
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出)
|
||||||
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
|
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
|
||||||
│ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤/子树收集)
|
│ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤含扩展名/子树收集)
|
||||||
│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索)
|
│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt)
|
||||||
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/双击/导出菜单/虚拟滚动/resize 重算)
|
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/目录双击/重新扫描/双击/导出菜单/虚拟滚动/resize 重算)
|
||||||
│ ├── 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 # 入口挂载冒烟测试
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ src/renderer/src/
|
|||||||
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`)
|
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`)
|
||||||
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
|
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
|
||||||
- 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines`、`ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文
|
- 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines`、`ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文
|
||||||
- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);降级组(归一化流超 3 万字符自动降级行级并带 `charModeDowngraded` 标志 / 编辑距离超限快速降级(内部参数 charDiffMaxEdit 注入小阈值毫秒级构造)/ 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志)
|
- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);降级组(归一化流超 3 万字符自动降级行级并带 `charModeDowngraded` 标志 / 编辑距离超限快速降级(内部参数 charDiffMaxEdit 注入小阈值毫秒级构造)/ 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志 / 全部组超限整体降级不出现 partial);逐组预算制部分降级组(0.8.0):组总流超预算时预算内组字符级、预算外组行级配对(`charModePartial` 置位、segs 拼接无损、超长行触发词级防护 segs 为 null、行号单调)/ 单组流超上限仅该组行级其余组字符级精确定位 / 单组编辑距离超限仅该组行级(charDiffMaxEdit 注入)/ 行级降级组内行数不等按序配对多余侧 added
|
||||||
- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs
|
- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs
|
||||||
- 行切分:结尾换行不产生多余空行
|
- 行切分:结尾换行不产生多余空行
|
||||||
|
|
||||||
@@ -163,12 +163,15 @@ src/renderer/src/
|
|||||||
- 递归子目录按相对路径对齐(统一 / 分隔);二进制内容字节级判定;symlink 跳过;条目字典序排序;total 取两侧较大值
|
- 递归子目录按相对路径对齐(统一 / 分隔);二进制内容字节级判定;symlink 跳过;条目字典序排序;total 取两侧较大值
|
||||||
- 边界与选项:两个空目录 / maxFiles 截断(truncated + total + 条目数受限;截断后不再深入子目录,total 为已遍历下限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different
|
- 边界与选项:两个空目录 / maxFiles 截断(truncated + total + 条目数受限;截断后不再深入子目录,total 为已遍历下限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different
|
||||||
- 受控并发:跨多个并发批次(> 2×SCAN_CONCURRENCY)的混合状态全部判定正确、条目排序与并发执行顺序无关
|
- 受控并发:跨多个并发批次(> 2×SCAN_CONCURRENCY)的混合状态全部判定正确、条目排序与并发执行顺序无关
|
||||||
|
- 增量重扫(0.8.0,previous 缓存):指纹一致(size+mtime 未变)零 IO 沿用判定(decode 计数零增长)/ 语义开关翻转按 semSame 纯内存组合(different ↔ semantic-same 零 IO,开→关→开三轮)/ 上次未参与语义(semSame null)而本次开启读内容补判 / 指纹变化(内容修改)重新判定且文件增删正确 / 单侧缺失形状变化不误沿用 / 本次截断忽略 previous 且 cache 产出为空 / unreadable 不进缓存(故障恢复后重扫正确判定)
|
||||||
|
|
||||||
### 5.10 文件夹视图(`FolderView.tsx`)与条目树(`folderTree.ts`)
|
### 5.10 文件夹视图(`FolderView.tsx`)与条目树(`folderTree.ts`)
|
||||||
- 条目树纯逻辑:按路径分段聚合目录/文件(同层字典序);目录聚合子树状态(different > unreadable > only > 语义同 > 相同)与文件计数;折叠目录子树隐藏;过滤(onlyDiff / statusFilter / query 子串不区分大小写);空目录剪枝由“过滤后建树”天然保证
|
- 条目树纯逻辑:按路径分段聚合目录/文件(同层字典序);目录聚合子树状态(different > unreadable > only > 语义同 > 相同)与文件计数;折叠目录子树隐藏;过滤(onlyDiff / statusFilter / query 子串不区分大小写 / exts 扩展名集合);空目录剪枝由“过滤后建树”天然保证;relExt 取文件名段扩展名(小写、无扩展名空串、不误取目录名中的点)
|
||||||
- 渲染:目录路径卡片 / 重新选择、导出报告与返回按钮 / 统计徽章(相同/语义同/不同/仅左/仅右/无法读取/总数;语义关闭隐藏语义同、无失败隐藏无法读取)
|
- 渲染:目录路径卡片 / 重新扫描、重新选择、导出报告与返回按钮 / 统计徽章(相同/语义同/不同/仅左/仅右/无法读取/总数;语义关闭隐藏语义同、无失败隐藏无法读取)
|
||||||
- 树形交互:目录行点击折叠展开(默认全展开);搜索命中强制展开命中路径、清空恢复用户折叠;无命中提示
|
- 树形交互:目录行点击折叠展开(默认全展开);搜索命中强制展开命中路径、清空恢复用户折叠;无命中提示
|
||||||
|
- 扩展名过滤(0.8.0):下拉展示全量条目 ext→count(字典序、无扩展名单列);点选过滤与其他条件取交集、计数切 N / 总数;多选并集、再点取消、清除按钮复位;与状态过滤双激活空态提示;点击外部关闭且过滤状态保留
|
||||||
- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动)
|
- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动)
|
||||||
|
- 目录行双击(0.8.0):触发 onOpenDir 携带目录相对路径;双击前折叠态自动展开(toggle 残留消除);重新扫描按钮触发 onRescan 且扫描中禁用
|
||||||
- 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果)
|
- 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果)
|
||||||
- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满
|
- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满
|
||||||
- 窗口尺寸变化:resize 后按新视口高度补齐渲染窗口(stub clientHeight 模拟拉高,DOM 行数随之增加)
|
- 窗口尺寸变化:resize 后按新视口高度补齐渲染窗口(stub clientHeight 模拟拉高,DOM 行数随之增加)
|
||||||
@@ -183,6 +186,8 @@ src/renderer/src/
|
|||||||
- 报告导出:stub 保存对话框 → 单文件 HTML 报告真实落盘且含结论与统计
|
- 报告导出:stub 保存对话框 → 单文件 HTML 报告真实落盘且含结论与统计
|
||||||
- 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤 / 树形显示)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留)
|
- 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤 / 树形显示)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留)
|
||||||
- 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目)
|
- 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目)
|
||||||
|
- 增量重扫(0.8.0):真实目录修改文件后点「重新扫描」状态正确更新(相同→不同→改回相同,真实 mtime 变化走指纹判定)
|
||||||
|
- 拖拽导入(0.8.0):dropFiles 合成 DataTransfer 拖放——空面板拖入渲染(arrayBuffer → decodeBuffer IPC 全链路)、双侧拖入对比、非文本扩展名拒绝、多文件提示取首个
|
||||||
- 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData)
|
- 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData)
|
||||||
- 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理)
|
- 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理)
|
||||||
|
|
||||||
@@ -225,9 +230,9 @@ npm run build
|
|||||||
|
|
||||||
## 9. 演进方向(后续迭代)
|
## 9. 演进方向(后续迭代)
|
||||||
|
|
||||||
- 文件夹对比深度增强:目录行双击聚合对比 / 按扩展名过滤 / 文件夹对比结果的增量重扫(记忆上次结果只扫变更)
|
- 文件夹对比:增量缓存跨会话持久化(userData 落盘,重启后仍可增量) · 文件夹对比快照基线(保存/加载上次结果对比) · 忽略规则(排除 node_modules 等目录/文件过滤器)
|
||||||
- 字符级对比能力扩展:变更组粒度的进一步放宽(大改文件分块提示而非整体降级)
|
- 字符级对比能力扩展:变更组预算制的自适应(按输入规模动态放宽预算)
|
||||||
- E2E 覆盖扩展:拖拽导入链路 / 大文件性能基准
|
- E2E 覆盖扩展:大文件性能基准(万级行计算耗时上限断言)
|
||||||
- 主进程 IPC 层补充集成测试(folder:scan 并发场景)
|
- 主进程 IPC 层补充集成测试(folder:scan 并发场景)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+90
-1
@@ -8,7 +8,8 @@ import {
|
|||||||
makeTmpDir,
|
makeTmpDir,
|
||||||
removeTmpDir,
|
removeTmpDir,
|
||||||
writeTextFixture,
|
writeTextFixture,
|
||||||
pasteText
|
pasteText,
|
||||||
|
dropFiles
|
||||||
} from './helpers'
|
} from './helpers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -292,6 +293,42 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('增量重扫:修改文件后点重新扫描,状态正确更新', async () => {
|
||||||
|
const root = await makeTmpDir('difflens-e2e-rescan-')
|
||||||
|
try {
|
||||||
|
const leftDir = join(root, 'left')
|
||||||
|
const rightDir = join(root, 'right')
|
||||||
|
const { mkdir, writeFile: wf } = await import('fs/promises')
|
||||||
|
await mkdir(leftDir, { recursive: true })
|
||||||
|
await mkdir(rightDir, { recursive: true })
|
||||||
|
await wf(join(leftDir, 'same.txt'), 'identical')
|
||||||
|
await wf(join(rightDir, 'same.txt'), 'identical')
|
||||||
|
await wf(join(leftDir, 'diff.txt'), 'left v1')
|
||||||
|
await wf(join(rightDir, 'diff.txt'), 'right v1')
|
||||||
|
|
||||||
|
const { app, window } = await launchApp()
|
||||||
|
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
|
||||||
|
await window.getByRole('button', { name: '对比文件夹' }).click()
|
||||||
|
await expect(window.getByText('相同 1')).toBeVisible()
|
||||||
|
await expect(window.getByText('不同 1')).toBeVisible()
|
||||||
|
|
||||||
|
// 修改右侧 same.txt(真实 mtime 变化)→ 重新扫描(增量)→ 判定更新
|
||||||
|
await wf(join(rightDir, 'same.txt'), 'identical changed')
|
||||||
|
await window.getByRole('button', { name: '重新扫描' }).click()
|
||||||
|
await expect(window.getByText('相同 0')).toBeVisible()
|
||||||
|
await expect(window.getByText('不同 2')).toBeVisible()
|
||||||
|
|
||||||
|
// 改回一致 → 再扫 → 恢复
|
||||||
|
await wf(join(rightDir, 'same.txt'), 'identical')
|
||||||
|
await window.getByRole('button', { name: '重新扫描' }).click()
|
||||||
|
await expect(window.getByText('相同 1')).toBeVisible()
|
||||||
|
await expect(window.getByText('不同 1')).toBeVisible()
|
||||||
|
await app.close()
|
||||||
|
} finally {
|
||||||
|
await removeTmpDir(root)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('语义判等、树形折叠、搜索与文件夹报告导出', async () => {
|
test('语义判等、树形折叠、搜索与文件夹报告导出', async () => {
|
||||||
const root = await makeTmpDir('difflens-e2e-folder2-')
|
const root = await makeTmpDir('difflens-e2e-folder2-')
|
||||||
try {
|
try {
|
||||||
@@ -353,6 +390,58 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test.describe('拖拽导入(真实 Chromium DataTransfer 链路)', () => {
|
||||||
|
test('空面板拖入文件:内容渲染与文件名显示', async () => {
|
||||||
|
const { app, window } = await launchApp()
|
||||||
|
await dropFiles(window, '.pane-empty', [
|
||||||
|
{ name: 'dropped-left.txt', content: 'drag line 1\ndrag line 2' }
|
||||||
|
])
|
||||||
|
// 文件名出现在面板头,内容渲染(file.arrayBuffer → decodeBuffer IPC → 编码探测全链路)
|
||||||
|
await expect(window.locator('.pane-file').filter({ hasText: 'dropped-left.txt' })).toBeVisible()
|
||||||
|
await expect(window.getByText('drag line 1').first()).toBeVisible()
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('双侧拖入形成对比:差异渲染', async () => {
|
||||||
|
const { app, window } = await launchApp()
|
||||||
|
await dropFiles(window, '.pane-empty', [
|
||||||
|
{ name: 'a.txt', content: 'same\nleft-unique' }
|
||||||
|
])
|
||||||
|
// 左侧装载后进入对比视图:右侧为 DiffView 面板(.pane-right),不再是空态
|
||||||
|
await dropFiles(window, '.pane-right', [
|
||||||
|
{ name: 'b.txt', content: 'same\nright-unique' }
|
||||||
|
])
|
||||||
|
await expect(window.getByText('有差异')).toBeVisible()
|
||||||
|
await expect(window.getByText('left-unique').first()).toBeVisible()
|
||||||
|
await expect(window.getByText('right-unique').first()).toBeVisible()
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('拖入非文本扩展名被拒绝并提示', async () => {
|
||||||
|
const { app, window } = await launchApp()
|
||||||
|
await dropFiles(window, '.pane-empty', [
|
||||||
|
{ name: 'image.png', content: 'fake-binary', type: 'image/png' }
|
||||||
|
])
|
||||||
|
await expect(window.getByText(/仅支持文本文件:image\.png/)).toBeVisible()
|
||||||
|
// 保持空态(无导出入口)
|
||||||
|
await expect(window.getByRole('button', { name: '导出报告' })).toHaveCount(0)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('多文件拖入提示已取第一个并加载首个文件', async () => {
|
||||||
|
const { app, window } = await launchApp()
|
||||||
|
await dropFiles(window, '.pane-empty', [
|
||||||
|
{ name: 'first.txt', content: 'first content' },
|
||||||
|
{ name: 'second.txt', content: 'second content' }
|
||||||
|
])
|
||||||
|
await expect(window.getByText(/一次只能导入一个文件/)).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
window.locator('.pane-file').filter({ hasText: 'first.txt' })
|
||||||
|
).toBeVisible()
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test.describe('偏好持久化(跨实例重启)', () => {
|
test.describe('偏好持久化(跨实例重启)', () => {
|
||||||
test('重启后恢复比较选项与视图开关', async () => {
|
test('重启后恢复比较选项与视图开关', async () => {
|
||||||
// 第一实例 fresh 清掉历史残留,建立干净基线后再写入偏好
|
// 第一实例 fresh 清掉历史残留,建立干净基线后再写入偏好
|
||||||
|
|||||||
@@ -93,3 +93,30 @@ export async function pasteText(window: Page, side: 'left' | 'right', text: stri
|
|||||||
await window.getByPlaceholder(/粘贴或输入/).fill(text)
|
await window.getByPlaceholder(/粘贴或输入/).fill(text)
|
||||||
await window.getByRole('button', { name: '开始对比' }).click()
|
await window.getByRole('button', { name: '开始对比' }).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向指定选择器元素派发合成拖放(真实 Chromium 支持 DataTransfer/DragEvent 构造)。
|
||||||
|
* 覆盖渲染进程拖拽导入全链路:dropPane 走 file.arrayBuffer → decodeBuffer IPC → 编码探测。
|
||||||
|
* 先派发 dragover(preventDefault 激活 drop 目标)再派发 drop。
|
||||||
|
*/
|
||||||
|
export async function dropFiles(
|
||||||
|
window: Page,
|
||||||
|
selector: string,
|
||||||
|
files: { name: string; content: string; type?: string }[]
|
||||||
|
): Promise<void> {
|
||||||
|
await window.evaluate(
|
||||||
|
({ sel, specs }) => {
|
||||||
|
const el = document.querySelector(sel)
|
||||||
|
if (!el) throw new Error(`dropFiles: selector not found: ${sel}`)
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
for (const f of specs) {
|
||||||
|
dt.items.add(new File([f.content], f.name, { type: f.type ?? 'text/plain' }))
|
||||||
|
}
|
||||||
|
el.dispatchEvent(
|
||||||
|
new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt })
|
||||||
|
)
|
||||||
|
el.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||||
|
},
|
||||||
|
{ sel: selector, specs: files }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.7.1",
|
"version": "0.8.0",
|
||||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
+207
-1
@@ -2,7 +2,12 @@ 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, semanticEquals, SEMANTIC_MAX_BYTES, type FolderEntry } from './folderScan'
|
import {
|
||||||
|
scanFolders,
|
||||||
|
semanticEquals,
|
||||||
|
SEMANTIC_MAX_BYTES,
|
||||||
|
type FolderEntry
|
||||||
|
} from './folderScan'
|
||||||
|
|
||||||
/** 以临时目录为根构造两个待对比文件夹 */
|
/** 以临时目录为根构造两个待对比文件夹 */
|
||||||
let leftDir: string
|
let leftDir: string
|
||||||
@@ -375,3 +380,204 @@ describe('scanFolders - 受控并发内容比对', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('scanFolders - 增量重扫(previous 缓存)', () => {
|
||||||
|
/** 语义判等注入:同步解码 + 计数器(零 IO 断言的核心观察点) */
|
||||||
|
const semWithCounter = (): {
|
||||||
|
opts: {
|
||||||
|
semantic: {
|
||||||
|
maxBytes: number
|
||||||
|
isTextFile: (rel: string) => boolean
|
||||||
|
decode: (b: Buffer) => Promise<string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count: () => number
|
||||||
|
} => {
|
||||||
|
let decoded = 0
|
||||||
|
return {
|
||||||
|
opts: {
|
||||||
|
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 (b: Buffer): Promise<string> => {
|
||||||
|
decoded++
|
||||||
|
return b.toString('utf-8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
count: () => decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('指纹一致零 IO:same 与语义同条目沿用判定,decode 计数零增长', async () => {
|
||||||
|
const l = await mkdtemp(join(tmpdir(), 'difflens-inc1-l-'))
|
||||||
|
const r = await mkdtemp(join(tmpdir(), 'difflens-inc1-r-'))
|
||||||
|
try {
|
||||||
|
// same(字节一致)+ semantic-same(仅空白差异,需解码判定)
|
||||||
|
await writeFile(join(l, 'same.txt'), 'identical\n')
|
||||||
|
await writeFile(join(r, 'same.txt'), 'identical\n')
|
||||||
|
await writeFile(join(l, 'sem.txt'), 'a b\nc\n')
|
||||||
|
await writeFile(join(r, 'sem.txt'), 'ab\nc\n')
|
||||||
|
const sem = semWithCounter()
|
||||||
|
const first = await scanFolders(l, r, sem.opts)
|
||||||
|
expect(byRel(first.entries, 'same.txt')?.status).toBe('same')
|
||||||
|
expect(byRel(first.entries, 'sem.txt')?.status).toBe('semantic-same')
|
||||||
|
expect(sem.count()).toBe(2) // 首次:sem.txt 双侧各解码一次
|
||||||
|
// 二次扫描携带缓存:指纹全部一致 → 全部零 IO 沿用
|
||||||
|
const second = await scanFolders(l, r, { ...sem.opts, previous: first.cache })
|
||||||
|
expect(byRel(second.entries, 'same.txt')?.status).toBe('same')
|
||||||
|
expect(byRel(second.entries, 'sem.txt')?.status).toBe('semantic-same')
|
||||||
|
expect(sem.count()).toBe(2) // 零增长
|
||||||
|
// 缓存条目被原样再次产出(链式增量可用)
|
||||||
|
expect(second.cache.length).toBe(2)
|
||||||
|
} finally {
|
||||||
|
await rm(l, { recursive: true, force: true })
|
||||||
|
await rm(r, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('语义开关翻转零 IO:semSame 非 null 条目纯内存组合 different ↔ semantic-same', async () => {
|
||||||
|
const l = await mkdtemp(join(tmpdir(), 'difflens-inc2-l-'))
|
||||||
|
const r = await mkdtemp(join(tmpdir(), 'difflens-inc2-r-'))
|
||||||
|
try {
|
||||||
|
await writeFile(join(l, 'sem.txt'), 'a b\nc\n')
|
||||||
|
await writeFile(join(r, 'sem.txt'), 'ab\nc\n')
|
||||||
|
// 第一次:语义开 → semantic-same(semSame = true 入缓存)
|
||||||
|
const semOn = semWithCounter()
|
||||||
|
const on1 = await scanFolders(l, r, semOn.opts)
|
||||||
|
expect(byRel(on1.entries, 'sem.txt')?.status).toBe('semantic-same')
|
||||||
|
expect(semOn.count()).toBe(2)
|
||||||
|
// 第二次:语义关 + 缓存 → different(字节不同),零 IO
|
||||||
|
const off = await scanFolders(l, r, { previous: on1.cache })
|
||||||
|
expect(byRel(off.entries, 'sem.txt')?.status).toBe('different')
|
||||||
|
expect(semOn.count()).toBe(2)
|
||||||
|
// 第三次:语义再开 + 新缓存 → semantic-same,仍零 IO
|
||||||
|
const semOn2 = semWithCounter()
|
||||||
|
const on2 = await scanFolders(l, r, { ...semOn2.opts, previous: off.cache })
|
||||||
|
expect(byRel(on2.entries, 'sem.txt')?.status).toBe('semantic-same')
|
||||||
|
expect(semOn2.count()).toBe(0)
|
||||||
|
} finally {
|
||||||
|
await rm(l, { recursive: true, force: true })
|
||||||
|
await rm(r, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('上次未参与语义(semSame null)而本次开启:读内容补判', async () => {
|
||||||
|
const l = await mkdtemp(join(tmpdir(), 'difflens-inc3-l-'))
|
||||||
|
const r = await mkdtemp(join(tmpdir(), 'difflens-inc3-r-'))
|
||||||
|
try {
|
||||||
|
await writeFile(join(l, 'sem.txt'), 'a b\n')
|
||||||
|
await writeFile(join(r, 'sem.txt'), 'ab\n')
|
||||||
|
// 第一次:语义关 → different(semSame = null)
|
||||||
|
const off = await scanFolders(l, r)
|
||||||
|
expect(byRel(off.entries, 'sem.txt')?.status).toBe('different')
|
||||||
|
// 第二次:语义开 + 缓存 → sem.txt 需补判(decode 2 次)
|
||||||
|
const sem = semWithCounter()
|
||||||
|
const on = await scanFolders(l, r, { ...sem.opts, previous: off.cache })
|
||||||
|
expect(byRel(on.entries, 'sem.txt')?.status).toBe('semantic-same')
|
||||||
|
expect(sem.count()).toBe(2)
|
||||||
|
} finally {
|
||||||
|
await rm(l, { recursive: true, force: true })
|
||||||
|
await rm(r, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('指纹变化(内容修改)重新判定,文件增删正确处理', async () => {
|
||||||
|
const l = await mkdtemp(join(tmpdir(), 'difflens-inc4-l-'))
|
||||||
|
const r = await mkdtemp(join(tmpdir(), 'difflens-inc4-r-'))
|
||||||
|
try {
|
||||||
|
await writeFile(join(l, 'a.txt'), 'v1\n')
|
||||||
|
await writeFile(join(r, 'a.txt'), 'v1\n')
|
||||||
|
await writeFile(join(l, 'gone.txt'), 'x\n')
|
||||||
|
const first = await scanFolders(l, r)
|
||||||
|
expect(byRel(first.entries, 'a.txt')?.status).toBe('same')
|
||||||
|
expect(byRel(first.entries, 'gone.txt')?.status).toBe('left-only')
|
||||||
|
// 修改右侧 a.txt(mtime 变化)+ 删除 gone.txt + 新增 new.txt
|
||||||
|
await writeFile(join(r, 'a.txt'), 'v2 changed\n')
|
||||||
|
await rm(join(l, 'gone.txt'))
|
||||||
|
await writeFile(join(r, 'new.txt'), 'n\n')
|
||||||
|
const second = await scanFolders(l, r, { previous: first.cache })
|
||||||
|
expect(byRel(second.entries, 'a.txt')?.status).toBe('different')
|
||||||
|
expect(second.entries.some((e) => e.rel === 'gone.txt')).toBe(false)
|
||||||
|
expect(byRel(second.entries, 'new.txt')?.status).toBe('right-only')
|
||||||
|
} finally {
|
||||||
|
await rm(l, { recursive: true, force: true })
|
||||||
|
await rm(r, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mtime 相同但指纹形状变化(单侧缺失变化)不误沿用', async () => {
|
||||||
|
const l = await mkdtemp(join(tmpdir(), 'difflens-inc5-l-'))
|
||||||
|
const r = await mkdtemp(join(tmpdir(), 'difflens-inc5-r-'))
|
||||||
|
try {
|
||||||
|
await writeFile(join(l, 'a.txt'), 'v1\n')
|
||||||
|
await writeFile(join(r, 'a.txt'), 'v1\n')
|
||||||
|
const first = await scanFolders(l, r)
|
||||||
|
expect(byRel(first.entries, 'a.txt')?.status).toBe('same')
|
||||||
|
// 删除右侧文件:same → left-only(缓存形状不一致,必须重判)
|
||||||
|
await rm(join(r, 'a.txt'))
|
||||||
|
const second = await scanFolders(l, r, { previous: first.cache })
|
||||||
|
expect(byRel(second.entries, 'a.txt')?.status).toBe('left-only')
|
||||||
|
} finally {
|
||||||
|
await rm(l, { recursive: true, force: true })
|
||||||
|
await rm(r, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('本次截断忽略 previous 且 cache 产出为空(条目不完整不可信)', async () => {
|
||||||
|
const l = await mkdtemp(join(tmpdir(), 'difflens-inc6-l-'))
|
||||||
|
const r = await mkdtemp(join(tmpdir(), 'difflens-inc6-r-'))
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await writeFile(join(l, `f${i}.txt`), String(i))
|
||||||
|
await writeFile(join(r, `f${i}.txt`), String(i))
|
||||||
|
}
|
||||||
|
const first = await scanFolders(l, r)
|
||||||
|
expect(first.cache.length).toBe(5)
|
||||||
|
// 截断扫描:previous 被忽略(等同全量),cache 为空
|
||||||
|
const second = await scanFolders(l, r, { maxFiles: 2, previous: first.cache })
|
||||||
|
expect(second.truncated).toBe(true)
|
||||||
|
expect(second.cache).toEqual([])
|
||||||
|
} finally {
|
||||||
|
await rm(l, { recursive: true, force: true })
|
||||||
|
await rm(r, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unreadable 不进缓存:故障恢复后重扫得到正确判定', async () => {
|
||||||
|
const l = await mkdtemp(join(tmpdir(), 'difflens-inc7-l-'))
|
||||||
|
const r = await mkdtemp(join(tmpdir(), 'difflens-inc7-r-'))
|
||||||
|
try {
|
||||||
|
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')
|
||||||
|
// 第一次:locked.txt 解码抛异常(模拟 Windows 文件锁)→ unreadable
|
||||||
|
const sem = semWithCounter()
|
||||||
|
const patched = {
|
||||||
|
semantic: {
|
||||||
|
...sem.opts.semantic,
|
||||||
|
decode: async (b: Buffer): Promise<string> => {
|
||||||
|
if (b.toString('utf-8').includes('x')) throw new Error('EBUSY')
|
||||||
|
return b.toString('utf-8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const first = await scanFolders(l, r, patched)
|
||||||
|
expect(byRel(first.entries, 'ok.txt')?.status).toBe('different')
|
||||||
|
expect(byRel(first.entries, 'locked.txt')?.status).toBe('unreadable')
|
||||||
|
// unreadable 不进缓存(cache 仅 1 条 ok.txt)
|
||||||
|
expect(first.cache.map((c) => c.rel)).toEqual(['ok.txt'])
|
||||||
|
// 第二次:故障恢复(正常 decode)+ 缓存 → locked.txt 补判 semantic-same
|
||||||
|
const second = await scanFolders(l, r, { ...sem.opts, previous: first.cache })
|
||||||
|
expect(byRel(second.entries, 'ok.txt')?.status).toBe('different')
|
||||||
|
expect(byRel(second.entries, 'locked.txt')?.status).toBe('semantic-same')
|
||||||
|
} finally {
|
||||||
|
await rm(l, { recursive: true, force: true })
|
||||||
|
await rm(r, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+154
-15
@@ -28,6 +28,30 @@ export interface FolderEntry {
|
|||||||
approximate?: boolean
|
approximate?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单侧文件指纹(size + mtimeMs):增量重扫的"内容是否变化"判定依据。
|
||||||
|
* 与 rsync/make 同款取舍:mtime 未变即视为内容未变(手动改回时间戳的极端场景不防) */
|
||||||
|
export interface FileFingerprint {
|
||||||
|
size: number
|
||||||
|
mtime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增量重扫的缓存条目:上次扫描的单条判定结果(按双侧指纹沿用,避免重复 IO)。
|
||||||
|
* - status 为 same(含采样近似)/ only 类:指纹一致即可零 IO 沿用;
|
||||||
|
* - 字节不同类(different / semantic-same):semSame 非 null 时语义开关翻转可纯内存
|
||||||
|
* 组合出新状态(different ↔ semantic-same 零 IO),null 表示上次未参与语义判定;
|
||||||
|
* - unreadable(被锁/权限)不进缓存:下次重扫重试。
|
||||||
|
*/
|
||||||
|
export interface ScanCacheEntry {
|
||||||
|
rel: string
|
||||||
|
leftFp: FileFingerprint | null
|
||||||
|
rightFp: FileFingerprint | null
|
||||||
|
status: 'same' | 'semantic-same' | 'different' | 'left-only' | 'right-only'
|
||||||
|
approximate?: boolean
|
||||||
|
/** 上次是否参与过语义判等及其结果(null = 未参与:语义关闭 / 非文本 / 超大小上限) */
|
||||||
|
semSame: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScanResult {
|
export interface ScanResult {
|
||||||
/** 全部条目(按相对路径字典序排序) */
|
/** 全部条目(按相对路径字典序排序) */
|
||||||
entries: FolderEntry[]
|
entries: FolderEntry[]
|
||||||
@@ -35,6 +59,8 @@ export interface ScanResult {
|
|||||||
truncated: boolean
|
truncated: boolean
|
||||||
/** 枚举发现的文件总数(截断时为已遍历下限:凑满上限即停止深入,不再统计剩余子树) */
|
/** 枚举发现的文件总数(截断时为已遍历下限:凑满上限即停止深入,不再统计剩余子树) */
|
||||||
total: number
|
total: number
|
||||||
|
/** 本次结果生成的增量重扫缓存(unreadable 不含;截断时为空数组——条目不完整不可信) */
|
||||||
|
cache: ScanCacheEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScanOptions {
|
export interface ScanOptions {
|
||||||
@@ -44,6 +70,8 @@ export interface ScanOptions {
|
|||||||
maxContentBytes?: number
|
maxContentBytes?: number
|
||||||
/** 语义判等(可选):字节级判 different 的文本文件二次判等,剔除空白后一致 → semantic-same */
|
/** 语义判等(可选):字节级判 different 的文本文件二次判等,剔除空白后一致 → semantic-same */
|
||||||
semantic?: SemanticOptions
|
semantic?: SemanticOptions
|
||||||
|
/** 增量重扫缓存(可选):上次扫描返回的 cache,指纹一致的条目零 IO 沿用判定 */
|
||||||
|
previous?: ScanCacheEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,7 +121,7 @@ export const SCAN_CONCURRENCY = 16
|
|||||||
async function listFiles(
|
async function listFiles(
|
||||||
root: string,
|
root: string,
|
||||||
maxFiles: number
|
maxFiles: number
|
||||||
): Promise<{ files: Map<string, number>; truncated: boolean; total: number }> {
|
): Promise<{ files: Map<string, FileFingerprint>; truncated: boolean; total: number }> {
|
||||||
const rels: string[] = []
|
const rels: string[] = []
|
||||||
let total = 0
|
let total = 0
|
||||||
let truncated = false
|
let truncated = false
|
||||||
@@ -124,12 +152,14 @@ async function listFiles(
|
|||||||
if (truncated) break
|
if (truncated) break
|
||||||
}
|
}
|
||||||
// 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束);
|
// 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束);
|
||||||
// 单文件 stat 失败(被锁/权限)跳过该条目,不整体失败
|
// 保留 size + mtimeMs 作为增量重扫指纹;单文件 stat 失败(被锁/权限)跳过该条目,不整体失败
|
||||||
const stats = await Promise.allSettled(rels.map((rel) => stat(join(root, rel))))
|
const stats = await Promise.allSettled(rels.map((rel) => stat(join(root, rel))))
|
||||||
const files = new Map<string, number>()
|
const files = new Map<string, FileFingerprint>()
|
||||||
rels.forEach((rel, i) => {
|
rels.forEach((rel, i) => {
|
||||||
const s = stats[i]
|
const s = stats[i]
|
||||||
if (s.status === 'fulfilled') files.set(rel, s.value.size)
|
if (s.status === 'fulfilled') {
|
||||||
|
files.set(rel, { size: s.value.size, mtime: s.value.mtimeMs })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return { files, truncated, total }
|
return { files, truncated, total }
|
||||||
}
|
}
|
||||||
@@ -154,6 +184,11 @@ async function readHead(path: string, bytes: number): Promise<Buffer> {
|
|||||||
* 需读内容的条目按 SCAN_CONCURRENCY 受控并发执行(输出与顺序和串行实现完全一致);
|
* 需读内容的条目按 SCAN_CONCURRENCY 受控并发执行(输出与顺序和串行实现完全一致);
|
||||||
* 单文件/单目录 IO 失败(被锁/权限,如 Windows Defender 占用)降级处理——
|
* 单文件/单目录 IO 失败(被锁/权限,如 Windows Defender 占用)降级处理——
|
||||||
* 文件标记 unreadable、目录跳过,不整体失败(大目录下单点锁不应丢弃全部结果)。
|
* 文件标记 unreadable、目录跳过,不整体失败(大目录下单点锁不应丢弃全部结果)。
|
||||||
|
*
|
||||||
|
* 增量重扫(options.previous,本次枚举未截断时启用):双侧指纹(size+mtime)与上次
|
||||||
|
* 一致的条目零 IO 沿用判定——same/only 直接沿用;字节不同类在语义开关翻转时按缓存
|
||||||
|
* semSame 纯内存组合(different ↔ semantic-same),仅"上次未参与语义判定而本次需要"
|
||||||
|
* 的条目读内容补判。上次或本次截断、unreadable 条目不享用缓存(全量重判/重试)。
|
||||||
*/
|
*/
|
||||||
export async function scanFolders(
|
export async function scanFolders(
|
||||||
leftDir: string,
|
leftDir: string,
|
||||||
@@ -173,21 +208,91 @@ export async function scanFolders(
|
|||||||
rSize <= semantic.maxBytes &&
|
rSize <= semantic.maxBytes &&
|
||||||
semantic.isTextFile(rel)
|
semantic.isTextFile(rel)
|
||||||
|
|
||||||
|
/** 本次或上次截断:条目集不完整,缓存不可信(本次产出的 cache 也置空) */
|
||||||
|
const useCache = options.previous !== undefined && !left.truncated && !right.truncated
|
||||||
|
const prevMap = new Map((useCache ? options.previous! : []).map((c) => [c.rel, c]))
|
||||||
|
|
||||||
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)
|
||||||
|
const cache: Array<ScanCacheEntry | undefined> = new Array(rels.length)
|
||||||
const tasks: { idx: number; run: () => Promise<void> }[] = []
|
const tasks: { idx: number; run: () => Promise<void> }[] = []
|
||||||
|
|
||||||
|
/** 双侧指纹与缓存条目一致(含单侧缺失形状一致;mtime 未变视为内容未变) */
|
||||||
|
const fpHit = (
|
||||||
|
prev: ScanCacheEntry,
|
||||||
|
lFp: FileFingerprint | undefined,
|
||||||
|
rFp: FileFingerprint | undefined
|
||||||
|
): boolean => {
|
||||||
|
if ((prev.leftFp === null) !== (lFp === undefined)) return false
|
||||||
|
if ((prev.rightFp === null) !== (rFp === undefined)) return false
|
||||||
|
if (lFp !== undefined && (prev.leftFp!.size !== lFp.size || prev.leftFp!.mtime !== lFp.mtime))
|
||||||
|
return false
|
||||||
|
if (rFp !== undefined && (prev.rightFp!.size !== rFp.size || prev.rightFp!.mtime !== rFp.mtime))
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
rels.forEach((rel, idx) => {
|
rels.forEach((rel, idx) => {
|
||||||
const lSize = left.files.get(rel)
|
const lFp = left.files.get(rel)
|
||||||
const rSize = right.files.get(rel)
|
const rFp = right.files.get(rel)
|
||||||
if (lSize === undefined) {
|
const lSize = lFp !== undefined ? lFp.size : null
|
||||||
entries[idx] = { rel, status: 'right-only', leftSize: null, rightSize: rSize ?? null }
|
const rSize = rFp !== undefined ? rFp.size : null
|
||||||
} else if (rSize === undefined) {
|
const prev = prevMap.get(rel)
|
||||||
|
|
||||||
|
// ===== 增量缓存命中:零 IO 组合判定 =====
|
||||||
|
if (prev !== undefined && fpHit(prev, lFp, rFp)) {
|
||||||
|
if (prev.status === 'same') {
|
||||||
|
entries[idx] = {
|
||||||
|
rel,
|
||||||
|
status: 'same',
|
||||||
|
leftSize: lSize,
|
||||||
|
rightSize: rSize,
|
||||||
|
...(prev.approximate ? { approximate: true } : {})
|
||||||
|
}
|
||||||
|
cache[idx] = prev
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (prev.status === 'left-only' || prev.status === 'right-only') {
|
||||||
|
entries[idx] = { rel, status: prev.status, leftSize: lSize, rightSize: rSize }
|
||||||
|
cache[idx] = prev
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 字节不同类:语义关闭或不适用 → different;开启且上次参与过 → 按 semSame 组合;
|
||||||
|
// 开启但上次未参与(semSame null)→ 落到全量判定补判
|
||||||
|
const semActive =
|
||||||
|
semantic !== undefined && semanticEligible(rel, lSize ?? 0, rSize ?? 0)
|
||||||
|
if (!semActive) {
|
||||||
|
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
|
||||||
|
cache[idx] = { ...prev, status: 'different' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (prev.semSame !== null) {
|
||||||
|
const status = prev.semSame ? 'semantic-same' : 'different'
|
||||||
|
entries[idx] = { rel, status, leftSize: lSize, rightSize: rSize }
|
||||||
|
cache[idx] = { ...prev, status }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 全量判定(缓存未命中 / 语义补判)=====
|
||||||
|
if (lFp === undefined) {
|
||||||
|
entries[idx] = { rel, status: 'right-only', leftSize: null, rightSize: rSize }
|
||||||
|
cache[idx] = {
|
||||||
|
rel,
|
||||||
|
leftFp: null,
|
||||||
|
rightFp: rFp ?? null,
|
||||||
|
status: 'right-only',
|
||||||
|
semSame: null
|
||||||
|
}
|
||||||
|
} else if (rFp === 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 && !semanticEligible(rel, lSize, rSize)) {
|
cache[idx] = { rel, leftFp: lFp, rightFp: null, status: 'left-only', semSame: null }
|
||||||
|
} else if (lFp.size !== rFp.size && !semanticEligible(rel, lFp.size, rFp.size)) {
|
||||||
// 大小不同且不参与语义判等(未开启/超上限/非文本):无需读内容直接定案
|
// 大小不同且不参与语义判等(未开启/超上限/非文本):无需读内容直接定案
|
||||||
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
|
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
|
||||||
} else if (lSize === rSize && lSize > maxContentBytes) {
|
cache[idx] = { rel, leftFp: lFp, rightFp: rFp, status: 'different', semSame: null }
|
||||||
|
} else if (lFp.size === rFp.size && lFp.size > maxContentBytes) {
|
||||||
// 大小一致但超过全量比对上限:头部采样近似判定,结果带 approximate 标注;
|
// 大小一致但超过全量比对上限:头部采样近似判定,结果带 approximate 标注;
|
||||||
// 单文件读取失败(被锁/权限,如 Windows Defender 占用)标记 unreadable,不整体失败
|
// 单文件读取失败(被锁/权限,如 Windows Defender 占用)标记 unreadable,不整体失败
|
||||||
tasks.push({
|
tasks.push({
|
||||||
@@ -198,9 +303,18 @@ export async function scanFolders(
|
|||||||
readHead(join(leftDir, rel), SAMPLE_BYTES),
|
readHead(join(leftDir, rel), SAMPLE_BYTES),
|
||||||
readHead(join(rightDir, rel), SAMPLE_BYTES)
|
readHead(join(rightDir, rel), SAMPLE_BYTES)
|
||||||
])
|
])
|
||||||
entries[idx] = lHead.equals(rHead)
|
const same = lHead.equals(rHead)
|
||||||
|
entries[idx] = same
|
||||||
? { rel, status: 'same', leftSize: lSize, rightSize: rSize, approximate: true }
|
? { rel, status: 'same', leftSize: lSize, rightSize: rSize, approximate: true }
|
||||||
: { rel, status: 'different', leftSize: lSize, rightSize: rSize }
|
: { rel, status: 'different', leftSize: lSize, rightSize: rSize }
|
||||||
|
cache[idx] = {
|
||||||
|
rel,
|
||||||
|
leftFp: lFp,
|
||||||
|
rightFp: rFp,
|
||||||
|
status: same ? 'same' : 'different',
|
||||||
|
...(same ? { approximate: true } : {}),
|
||||||
|
semSame: null
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize }
|
entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize }
|
||||||
}
|
}
|
||||||
@@ -220,16 +334,34 @@ export async function scanFolders(
|
|||||||
])
|
])
|
||||||
if (lBuf.equals(rBuf)) {
|
if (lBuf.equals(rBuf)) {
|
||||||
entries[idx] = { rel, status: 'same', leftSize: lSize, rightSize: rSize }
|
entries[idx] = { rel, status: 'same', leftSize: lSize, rightSize: rSize }
|
||||||
|
cache[idx] = { rel, leftFp: lFp, rightFp: rFp, status: 'same', semSame: null }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (semanticEligible(rel, lSize, rSize)) {
|
if (semanticEligible(rel, lFp!.size, rFp!.size)) {
|
||||||
const [lt, rt] = await Promise.all([semantic!.decode(lBuf), semantic!.decode(rBuf)])
|
const [lt, rt] = await Promise.all([semantic!.decode(lBuf), semantic!.decode(rBuf)])
|
||||||
if (semanticEquals(lt, rt)) {
|
if (semanticEquals(lt, rt)) {
|
||||||
entries[idx] = { rel, status: 'semantic-same', leftSize: lSize, rightSize: rSize }
|
entries[idx] = { rel, status: 'semantic-same', leftSize: lSize, rightSize: rSize }
|
||||||
|
cache[idx] = {
|
||||||
|
rel,
|
||||||
|
leftFp: lFp,
|
||||||
|
rightFp: rFp,
|
||||||
|
status: 'semantic-same',
|
||||||
|
semSame: true
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
|
||||||
|
cache[idx] = {
|
||||||
|
rel,
|
||||||
|
leftFp: lFp,
|
||||||
|
rightFp: rFp,
|
||||||
|
status: 'different',
|
||||||
|
semSame: false
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
|
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
|
||||||
|
cache[idx] = { rel, leftFp: lFp, rightFp: rFp, status: 'different', semSame: null }
|
||||||
} catch {
|
} catch {
|
||||||
entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize }
|
entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize }
|
||||||
}
|
}
|
||||||
@@ -241,5 +373,12 @@ export async function scanFolders(
|
|||||||
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()))
|
||||||
}
|
}
|
||||||
return { entries, truncated: left.truncated || right.truncated, total: Math.max(left.total, right.total) }
|
const truncated = left.truncated || right.truncated
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
truncated,
|
||||||
|
total: Math.max(left.total, right.total),
|
||||||
|
// 截断时条目不完整:cache 置空防止下次误沿用(unreadable 槽位本就为 undefined)
|
||||||
|
cache: truncated ? [] : (cache.filter((c) => c !== undefined) as ScanCacheEntry[])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-18
@@ -5,7 +5,7 @@ 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, SEMANTIC_MAX_BYTES } from './folderScan'
|
import { scanFolders, SEMANTIC_MAX_BYTES, type ScanCacheEntry } from './folderScan'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文本类扩展名清单(小写,不含点):
|
* 文本类扩展名清单(小写,不含点):
|
||||||
@@ -264,24 +264,47 @@ ipcMain.handle('folder:pick', async () => {
|
|||||||
return dirPath
|
return dirPath
|
||||||
})
|
})
|
||||||
|
|
||||||
/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等;异步 fs 与解码 worker 不阻塞主进程) */
|
/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等 + 增量缓存;异步 fs 与解码 worker 不阻塞主进程) */
|
||||||
ipcMain.handle('folder:scan', async (_event, leftDir: string, rightDir: string, semantic: boolean) => {
|
ipcMain.handle(
|
||||||
try {
|
'folder:scan',
|
||||||
return await scanFolders(leftDir, rightDir, semantic
|
async (
|
||||||
? {
|
_event,
|
||||||
// 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线)
|
leftDir: string,
|
||||||
semantic: {
|
rightDir: string,
|
||||||
maxBytes: SEMANTIC_MAX_BYTES,
|
semantic: boolean,
|
||||||
isTextFile: isTextRel,
|
previous?: unknown
|
||||||
decode: async (buf) => (await decodeMaybeWorker(buf)).text
|
) => {
|
||||||
}
|
try {
|
||||||
}
|
// 增量缓存防御性校验:仅接受数组形态(渲染端从上次扫描结果携带,损坏即全量重扫)
|
||||||
: {})
|
const cache =
|
||||||
} catch {
|
Array.isArray(previous) && previous.every(
|
||||||
// 目录不存在/不可读等:转为错误标记,渲染端提示
|
(c) =>
|
||||||
return { error: 'scan-failed' as const }
|
typeof c === 'object' &&
|
||||||
|
c !== null &&
|
||||||
|
typeof (c as ScanCacheEntry).rel === 'string' &&
|
||||||
|
typeof (c as ScanCacheEntry).status === 'string'
|
||||||
|
)
|
||||||
|
? (previous as ScanCacheEntry[])
|
||||||
|
: undefined
|
||||||
|
return await scanFolders(leftDir, rightDir, {
|
||||||
|
...(cache ? { previous: cache } : {}),
|
||||||
|
...(semantic
|
||||||
|
? {
|
||||||
|
// 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线)
|
||||||
|
semantic: {
|
||||||
|
maxBytes: SEMANTIC_MAX_BYTES,
|
||||||
|
isTextFile: isTextRel,
|
||||||
|
decode: async (buf) => (await decodeMaybeWorker(buf)).text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 目录不存在/不可读等:转为错误标记,渲染端提示
|
||||||
|
return { error: 'scan-failed' as const }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
/** IPC: 按绝对路径读取文本文件(文件夹对比双击进入单文件对比;与 file:open 同一读取管线) */
|
/** IPC: 按绝对路径读取文本文件(文件夹对比双击进入单文件对比;与 file:open 同一读取管线) */
|
||||||
ipcMain.handle('file:read-by-path', async (_event, filePath: string) => {
|
ipcMain.handle('file:read-by-path', async (_event, filePath: string) => {
|
||||||
|
|||||||
+23
-2
@@ -45,6 +45,26 @@ export interface FolderScanResult {
|
|||||||
entries: FolderEntry[]
|
entries: FolderEntry[]
|
||||||
truncated: boolean
|
truncated: boolean
|
||||||
total: number
|
total: number
|
||||||
|
/** 增量重扫缓存(与主进程 folderScan 的 ScanCacheEntry 对齐;截断时为空数组)。
|
||||||
|
* 下次扫描同目录时携带可零 IO 沿用指纹一致的判定 */
|
||||||
|
cache?: FolderScanCacheEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单侧文件指纹(size + mtimeMs),增量重扫的变更判定依据 */
|
||||||
|
export interface FolderFileFingerprint {
|
||||||
|
size: number
|
||||||
|
mtime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 增量重扫缓存条目(与主进程 folderScan 对齐;unreadable 不进缓存) */
|
||||||
|
export interface FolderScanCacheEntry {
|
||||||
|
rel: string
|
||||||
|
leftFp: FolderFileFingerprint | null
|
||||||
|
rightFp: FolderFileFingerprint | null
|
||||||
|
status: 'same' | 'semantic-same' | 'different' | 'left-only' | 'right-only'
|
||||||
|
approximate?: boolean
|
||||||
|
/** 上次是否参与过语义判等及其结果(null = 未参与);语义开关翻转时非 null 可零 IO 组合 */
|
||||||
|
semSame: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文件夹扫描失败(目录不存在/不可读) */
|
/** 文件夹扫描失败(目录不存在/不可读) */
|
||||||
@@ -69,9 +89,10 @@ const api = {
|
|||||||
scanFolder: (
|
scanFolder: (
|
||||||
leftDir: string,
|
leftDir: string,
|
||||||
rightDir: string,
|
rightDir: string,
|
||||||
semantic: boolean
|
semantic: boolean,
|
||||||
|
previous?: FolderScanCacheEntry[] | null
|
||||||
): Promise<FolderScanResult | FolderScanError> =>
|
): Promise<FolderScanResult | FolderScanError> =>
|
||||||
ipcRenderer.invoke('folder:scan', leftDir, rightDir, semantic),
|
ipcRenderer.invoke('folder:scan', leftDir, rightDir, semantic, previous ?? null),
|
||||||
readByPath: (filePath: string): Promise<FileOpenResult> =>
|
readByPath: (filePath: string): Promise<FileOpenResult> =>
|
||||||
ipcRenderer.invoke('file:read-by-path', filePath)
|
ipcRenderer.invoke('file:read-by-path', filePath)
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-18
@@ -188,6 +188,9 @@ export default function App(): ReactElement {
|
|||||||
const [folderScanning, setFolderScanning] = useState(false)
|
const [folderScanning, setFolderScanning] = useState(false)
|
||||||
// 从文件夹进入单文件对比时的当前条目 rel(差异文件导航的游标;返回/退出文件夹模式时清空)
|
// 从文件夹进入单文件对比时的当前条目 rel(差异文件导航的游标;返回/退出文件夹模式时清空)
|
||||||
const [folderEntryCursor, setFolderEntryCursor] = useState<string | null>(null)
|
const [folderEntryCursor, setFolderEntryCursor] = useState<string | null>(null)
|
||||||
|
// 目录聚合对比的导航范围(双击目录行进入:差异文件导航限定在该子树内;
|
||||||
|
// 双击任意文件条目回到全局序列;返回/退出文件夹模式时清空)
|
||||||
|
const [folderNavScope, setFolderNavScope] = useState<string | null>(null)
|
||||||
// 偏好只读一次盘:全部初始 state 共享同一次 loadPrefs 结果(原实现挂载期读取两次)
|
// 偏好只读一次盘:全部初始 state 共享同一次 loadPrefs 结果(原实现挂载期读取两次)
|
||||||
const [initialPrefs] = useState(() => loadPrefs())
|
const [initialPrefs] = useState(() => loadPrefs())
|
||||||
// 文件夹模式的「仅看差异」:与单文件模式的 onlyDiff 独立(文件夹场景默认关心差异文件)
|
// 文件夹模式的「仅看差异」:与单文件模式的 onlyDiff 独立(文件夹场景默认关心差异文件)
|
||||||
@@ -360,13 +363,22 @@ export default function App(): ReactElement {
|
|||||||
*/
|
*/
|
||||||
const scanSeqRef = useRef(0)
|
const scanSeqRef = useRef(0)
|
||||||
|
|
||||||
/** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式;语义判等开关随扫描参数下发) */
|
/** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式;语义判等开关随扫描参数下发)。
|
||||||
|
* 目录与上次一致且未截断时携带增量缓存——指纹一致的条目零 IO 沿用判定,
|
||||||
|
* 语义开关切换触发的重扫从全量 IO 变为近乎零 IO */
|
||||||
const runFolderScan = useCallback(
|
const runFolderScan = useCallback(
|
||||||
async (leftDir: string, rightDir: string, semantic: boolean): Promise<void> => {
|
async (leftDir: string, rightDir: string, semantic: boolean): Promise<void> => {
|
||||||
const seq = ++scanSeqRef.current
|
const seq = ++scanSeqRef.current
|
||||||
setFolderScanning(true)
|
setFolderScanning(true)
|
||||||
try {
|
try {
|
||||||
const res = await window.api.scanFolder(leftDir, rightDir, semantic)
|
const prev =
|
||||||
|
folderState &&
|
||||||
|
folderState.leftDir === leftDir &&
|
||||||
|
folderState.rightDir === rightDir &&
|
||||||
|
!folderState.truncated
|
||||||
|
? (folderState.cache ?? null)
|
||||||
|
: null
|
||||||
|
const res = await window.api.scanFolder(leftDir, rightDir, semantic, prev)
|
||||||
// 过期扫描(期间已发起新扫描或已退出文件夹对比):结果整体丢弃
|
// 过期扫描(期间已发起新扫描或已退出文件夹对比):结果整体丢弃
|
||||||
if (seq !== scanSeqRef.current) return
|
if (seq !== scanSeqRef.current) return
|
||||||
if ('error' in res) {
|
if ('error' in res) {
|
||||||
@@ -383,7 +395,7 @@ export default function App(): ReactElement {
|
|||||||
if (seq === scanSeqRef.current) setFolderScanning(false)
|
if (seq === scanSeqRef.current) setFolderScanning(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[showToast]
|
[showToast, folderState]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 入口:连续选择左右两个文件夹后开始对比 */
|
/** 入口:连续选择左右两个文件夹后开始对比 */
|
||||||
@@ -450,10 +462,13 @@ export default function App(): ReactElement {
|
|||||||
[folderState, folderSemantic, showToast]
|
[folderState, folderSemantic, showToast]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 双击文件夹条目:按路径读取存在侧进入单文件对比(保留文件夹状态供返回) */
|
/** 双击文件夹条目:按路径读取存在侧进入单文件对比(保留文件夹状态供返回)。
|
||||||
|
* navScope 为本次进入后的导航范围:手动双击文件条目传 null(回全局序列),
|
||||||
|
* 目录聚合入口与差异文件导航传当前 scope(保持子树范围不被清除) */
|
||||||
const openFolderEntry = useCallback(
|
const openFolderEntry = useCallback(
|
||||||
async (entry: FolderEntry): Promise<void> => {
|
async (entry: FolderEntry, navScope: string | null = null): Promise<void> => {
|
||||||
if (!folderState) return
|
if (!folderState) return
|
||||||
|
setFolderNavScope(navScope)
|
||||||
// 清空指定侧面板:双击新条目的任一读取失败路径都必须清掉旧内容,
|
// 清空指定侧面板:双击新条目的任一读取失败路径都必须清掉旧内容,
|
||||||
// 否则成功侧与旧内容组成“名不符实”的对比(面板头还是旧文件名)
|
// 否则成功侧与旧内容组成“名不符实”的对比(面板头还是旧文件名)
|
||||||
const clearSide = (side: 'left' | 'right'): void => {
|
const clearSide = (side: 'left' | 'right'): void => {
|
||||||
@@ -499,35 +514,56 @@ export default function App(): ReactElement {
|
|||||||
[folderState, applyFileData, showToast]
|
[folderState, applyFileData, showToast]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 差异文件序列(不同/仅左/仅右;语义同不计入),批量审查时的导航范围 */
|
/** 差异文件序列(不同/仅左/仅右;语义同不计入),批量审查时的导航范围;
|
||||||
|
* 目录聚合对比激活时限定在该子树内(folderNavScope) */
|
||||||
const diffFileEntries = useMemo(() => {
|
const diffFileEntries = useMemo(() => {
|
||||||
if (!folderState) return []
|
if (!folderState) return []
|
||||||
return folderState.entries.filter(
|
const all = folderState.entries.filter(
|
||||||
(e) => e.status === 'different' || e.status === 'left-only' || e.status === 'right-only'
|
(e) => e.status === 'different' || e.status === 'left-only' || e.status === 'right-only'
|
||||||
)
|
)
|
||||||
}, [folderState])
|
if (folderNavScope === null) return all
|
||||||
|
const prefix = folderNavScope + '/'
|
||||||
|
return all.filter((e) => e.rel.startsWith(prefix))
|
||||||
|
}, [folderState, folderNavScope])
|
||||||
|
|
||||||
/** 差异文件导航:跳转到上/下一个差异文件(不循环,越界不动) */
|
/** 双击目录行进入聚合对比:差异导航限定在该子树,打开其中第一个差异文件 */
|
||||||
|
const openDirScope = useCallback(
|
||||||
|
(dirPath: string): void => {
|
||||||
|
if (!folderState) return
|
||||||
|
const inScope = diffFileEntries.filter((e) =>
|
||||||
|
e.rel.startsWith(dirPath + '/')
|
||||||
|
)
|
||||||
|
if (inScope.length === 0) {
|
||||||
|
showToast(`目录「${dirPath}」内没有差异文件`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void openFolderEntry(inScope[0], dirPath)
|
||||||
|
},
|
||||||
|
[folderState, diffFileEntries, openFolderEntry, showToast]
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 差异文件导航:跳转到上/下一个差异文件(不循环,越界不动;导航不改变当前聚合范围) */
|
||||||
const goDiffFile = useCallback(
|
const goDiffFile = useCallback(
|
||||||
(dir: 1 | -1): void => {
|
(dir: 1 | -1): void => {
|
||||||
if (diffFileEntries.length === 0) return
|
if (diffFileEntries.length === 0) return
|
||||||
const idx = diffFileEntries.findIndex((e) => e.rel === folderEntryCursor)
|
const idx = diffFileEntries.findIndex((e) => e.rel === folderEntryCursor)
|
||||||
const next = idx + dir
|
const next = idx + dir
|
||||||
if (idx < 0 || next < 0 || next >= diffFileEntries.length) return
|
if (idx < 0 || next < 0 || next >= diffFileEntries.length) return
|
||||||
void openFolderEntry(diffFileEntries[next])
|
void openFolderEntry(diffFileEntries[next], folderNavScope)
|
||||||
},
|
},
|
||||||
[diffFileEntries, folderEntryCursor, openFolderEntry]
|
[diffFileEntries, folderEntryCursor, folderNavScope, openFolderEntry]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 从单文件对比返回文件夹列表(清空两侧面板与导航游标,文件夹扫描结果保留) */
|
/** 从单文件对比返回文件夹列表(清空两侧面板与导航游标/聚合范围,文件夹扫描结果保留) */
|
||||||
const backToFolder = useCallback((): void => {
|
const backToFolder = useCallback((): void => {
|
||||||
setPaneL(null)
|
setPaneL(null)
|
||||||
setPaneR(null)
|
setPaneR(null)
|
||||||
setFolderEntryCursor(null)
|
setFolderEntryCursor(null)
|
||||||
|
setFolderNavScope(null)
|
||||||
setMode('folder')
|
setMode('folder')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/** 退出文件夹对比(清空文件夹状态、导航游标与两侧面板,回到单文件空态) */
|
/** 退出文件夹对比(清空文件夹状态、导航游标/聚合范围与两侧面板,回到单文件空态) */
|
||||||
const exitFolder = useCallback((): void => {
|
const exitFolder = useCallback((): void => {
|
||||||
// 作废在途扫描并复位扫描态:迟到的扫描结果不得把界面拉回文件夹模式,
|
// 作废在途扫描并复位扫描态:迟到的扫描结果不得把界面拉回文件夹模式,
|
||||||
// 也不得让扫描遮罩残留到下一次文件夹会话
|
// 也不得让扫描遮罩残留到下一次文件夹会话
|
||||||
@@ -537,16 +573,20 @@ export default function App(): ReactElement {
|
|||||||
setPaneL(null)
|
setPaneL(null)
|
||||||
setPaneR(null)
|
setPaneR(null)
|
||||||
setFolderEntryCursor(null)
|
setFolderEntryCursor(null)
|
||||||
|
setFolderNavScope(null)
|
||||||
setMode('file')
|
setMode('file')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径
|
// diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径
|
||||||
const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options)
|
const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options)
|
||||||
|
|
||||||
// 字符级对比因内容超限或差异过大自动降级为行级时提示(依赖 diff 对象,同一结果只提示一次)
|
// 字符级对比因内容超限或差异过大自动降级为行级时提示(依赖 diff 对象,同一结果只提示一次);
|
||||||
|
// 部分降级(部分超限组行级配对、其余组仍字符级)单独提示
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (diff?.charModeDowngraded) {
|
if (diff?.charModeDowngraded) {
|
||||||
showToast('内容或差异过大,字符级对比已自动降级为行级对比')
|
showToast('内容或差异过大,字符级对比已自动降级为行级对比')
|
||||||
|
} else if (diff?.charModePartial) {
|
||||||
|
showToast('部分差异块过大已按行级对比,其余差异仍为字符级高亮')
|
||||||
}
|
}
|
||||||
}, [diff, showToast])
|
}, [diff, showToast])
|
||||||
|
|
||||||
@@ -695,7 +735,8 @@ export default function App(): ReactElement {
|
|||||||
async (fmt: ReportFormat) => {
|
async (fmt: ReportFormat) => {
|
||||||
// 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御
|
// 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御
|
||||||
if (!diff) return
|
if (!diff) return
|
||||||
// 字符级对比降级时,报告头部按实际生效的行级语义描述(与引擎降级分支的选项构造完全一致)
|
// 字符级对比降级时,报告头部按实际生效语义描述:
|
||||||
|
// 整体降级按行级语义;部分降级保持字符级语义并标注部分块按行级
|
||||||
const reportOptions: DiffOptions = diff.charModeDowngraded
|
const reportOptions: DiffOptions = diff.charModeDowngraded
|
||||||
? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true, trimWhitespace: false }
|
? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true, trimWhitespace: false }
|
||||||
: options
|
: options
|
||||||
@@ -706,7 +747,10 @@ export default function App(): ReactElement {
|
|||||||
rightEncoding: paneR?.meta.encoding ?? '',
|
rightEncoding: paneR?.meta.encoding ?? '',
|
||||||
leftPath: paneL?.path || undefined,
|
leftPath: paneL?.path || undefined,
|
||||||
rightPath: paneR?.path || undefined,
|
rightPath: paneR?.path || undefined,
|
||||||
options: reportOptions
|
options: reportOptions,
|
||||||
|
...(diff.charModePartial && !diff.charModeDowngraded
|
||||||
|
? { charModePartial: true }
|
||||||
|
: {})
|
||||||
}
|
}
|
||||||
const content = buildReport(rows, ctx, summary, fmt)
|
const content = buildReport(rows, ctx, summary, fmt)
|
||||||
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
|
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
|
||||||
@@ -779,7 +823,14 @@ export default function App(): ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{folderState && folderEntryCursor !== null && diffFileEntries.length > 0 && (
|
{folderState && folderEntryCursor !== null && diffFileEntries.length > 0 && (
|
||||||
<div className="tool-group diff-file-nav" title="在文件夹的差异文件之间跳转(Alt+↓ / Alt+↑)">
|
<div
|
||||||
|
className="tool-group diff-file-nav"
|
||||||
|
title={
|
||||||
|
folderNavScope !== null
|
||||||
|
? `在目录「${folderNavScope}/」的差异文件之间跳转(Alt+↓ / Alt+↑);双击任意文件条目回到全局序列`
|
||||||
|
: '在文件夹的差异文件之间跳转(Alt+↓ / Alt+↑)'
|
||||||
|
}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
title="上一个差异文件(Alt+↑)"
|
title="上一个差异文件(Alt+↑)"
|
||||||
@@ -807,7 +858,9 @@ export default function App(): ReactElement {
|
|||||||
>
|
>
|
||||||
▼
|
▼
|
||||||
</button>
|
</button>
|
||||||
<span className="diff-file-label">差异文件</span>
|
<span className="diff-file-label">
|
||||||
|
差异文件{folderNavScope !== null ? ` · ${folderNavScope}/` : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button className="btn ghost" onClick={() => void openPane('left')}>
|
<button className="btn ghost" onClick={() => void openPane('left')}>
|
||||||
@@ -872,9 +925,14 @@ export default function App(): ReactElement {
|
|||||||
semantic={folderSemantic}
|
semantic={folderSemantic}
|
||||||
onSemanticChange={handleSemanticChange}
|
onSemanticChange={handleSemanticChange}
|
||||||
onReopen={() => void startFolderCompare()}
|
onReopen={() => void startFolderCompare()}
|
||||||
|
onRescan={() => {
|
||||||
|
if (folderState)
|
||||||
|
void runFolderScan(folderState.leftDir, folderState.rightDir, folderSemantic)
|
||||||
|
}}
|
||||||
onExit={exitFolder}
|
onExit={exitFolder}
|
||||||
onExport={(fmt) => void exportFolderReport(fmt)}
|
onExport={(fmt) => void exportFolderReport(fmt)}
|
||||||
onOpenEntry={(e) => void openFolderEntry(e)}
|
onOpenEntry={(e) => void openFolderEntry(e)}
|
||||||
|
onOpenDir={openDirScope}
|
||||||
/>
|
/>
|
||||||
) : anyPane ? (
|
) : anyPane ? (
|
||||||
<>
|
<>
|
||||||
@@ -903,6 +961,7 @@ export default function App(): ReactElement {
|
|||||||
showToast(`「${label}」已包含在字符级对比中,关闭字符级后可单独设置`)
|
showToast(`「${label}」已包含在字符级对比中,关闭字符级后可单独设置`)
|
||||||
}
|
}
|
||||||
charModeDowngraded={diff?.charModeDowngraded ?? false}
|
charModeDowngraded={diff?.charModeDowngraded ?? false}
|
||||||
|
charModePartial={diff?.charModePartial ?? false}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -774,6 +774,53 @@ describe('App - 字符级对比', () => {
|
|||||||
expect(await screen.findByText('已降级')).toBeInTheDocument()
|
expect(await screen.findByText('已降级')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('混合场景部分降级:小差异组字符级 + 超限组行级,标记与提示可见', async () => {
|
||||||
|
// mid 锚行分隔两组:cat↔cap 字符级;单行全差异超 5 万 → 行级(部分降级)
|
||||||
|
window.api = mockApi({
|
||||||
|
openFile: async (side) =>
|
||||||
|
side === 'left'
|
||||||
|
? { path: '/tmp/p1.txt', name: 'p1.txt', text: `head\ncat\nmid\n${'a'.repeat(25001)}`, encoding: 'UTF-8', binary: false }
|
||||||
|
: { path: '/tmp/p2.txt', name: 'p2.txt', text: `head\ncap\nmid\n${'b'.repeat(25001)}`, encoding: 'UTF-8', binary: false }
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('导出报告')
|
||||||
|
fireEvent.click(screen.getByText('打开右侧'))
|
||||||
|
await screen.findAllByText('p2.txt')
|
||||||
|
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||||
|
// 无整体降级;出现部分降级标记与提示
|
||||||
|
await screen.findByText('有差异')
|
||||||
|
expect(screen.queryByText(/已自动降级为行级对比/)).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('已降级')).not.toBeInTheDocument()
|
||||||
|
expect(await screen.findByText('部分降级')).toBeInTheDocument()
|
||||||
|
expect(await screen.findByText(/部分差异块过大已按行级对比/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('部分降级时导出报告携带部分降级说明', async () => {
|
||||||
|
let saved = ''
|
||||||
|
window.api = mockApi({
|
||||||
|
openFile: async (side) =>
|
||||||
|
side === 'left'
|
||||||
|
? { path: '/tmp/p1.txt', name: 'p1.txt', text: `head\ncat\nmid\n${'a'.repeat(25001)}`, encoding: 'UTF-8', binary: false }
|
||||||
|
: { path: '/tmp/p2.txt', name: 'p2.txt', text: `head\ncap\nmid\n${'b'.repeat(25001)}`, encoding: 'UTF-8', binary: false },
|
||||||
|
saveReport: async (content: string) => {
|
||||||
|
saved = content
|
||||||
|
return { ok: true, path: 'C:/docs/r.txt' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('导出报告')
|
||||||
|
fireEvent.click(screen.getByText('打开右侧'))
|
||||||
|
await screen.findAllByText('p2.txt')
|
||||||
|
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||||
|
await screen.findByText('部分降级')
|
||||||
|
fireEvent.click(screen.getByText('导出报告'))
|
||||||
|
fireEvent.click(screen.getByText('纯文本'))
|
||||||
|
await screen.findByText(/报告已保存/)
|
||||||
|
expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行;部分差异过大的块按行级对比)')
|
||||||
|
})
|
||||||
|
|
||||||
it('大文件仅局部差异:字符级分块完成,不降级且精确定位差异', async () => {
|
it('大文件仅局部差异:字符级分块完成,不降级且精确定位差异', async () => {
|
||||||
// 2000 行 × 50 字符 ≈ 10 万归一化字符(远超单组 5 万上限),仅一行不同:
|
// 2000 行 × 50 字符 ≈ 10 万归一化字符(远超单组 5 万上限),仅一行不同:
|
||||||
// 行锚定后唯一变更组只有该行,字符级正常完成(旧实现必降级)
|
// 行锚定后唯一变更组只有该行,字符级正常完成(旧实现必降级)
|
||||||
@@ -1498,4 +1545,181 @@ describe('App - 差异文件导航', () => {
|
|||||||
fireEvent.keyDown(window, { key: 'ArrowDown', altKey: true })
|
fireEvent.keyDown(window, { key: 'ArrowDown', altKey: true })
|
||||||
expect(screen.getByText('导出报告')).toBeInTheDocument()
|
expect(screen.getByText('导出报告')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('App - 增量重扫', () => {
|
||||||
|
const incEntries = [
|
||||||
|
{ rel: 'same.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
|
||||||
|
{ rel: 'diff.txt', status: 'different' as const, leftSize: 3, rightSize: 5 }
|
||||||
|
]
|
||||||
|
const incCache = [
|
||||||
|
{
|
||||||
|
rel: 'same.txt',
|
||||||
|
leftFp: { size: 3, mtime: 100 },
|
||||||
|
rightFp: { size: 3, mtime: 100 },
|
||||||
|
status: 'same' as const,
|
||||||
|
semSame: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'diff.txt',
|
||||||
|
leftFp: { size: 3, mtime: 101 },
|
||||||
|
rightFp: { size: 5, mtime: 102 },
|
||||||
|
status: 'different' as const,
|
||||||
|
semSame: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
it('同目录重扫(语义切换/重新扫描)携带上次缓存;新目录不带', async () => {
|
||||||
|
const calls: Array<{ l: string; r: string; s: boolean; p: unknown }> = []
|
||||||
|
let pick = 0
|
||||||
|
const dirs = ['C:/l1', 'D:/r1', 'C:/l2', 'D:/r2']
|
||||||
|
window.api = mockApi({
|
||||||
|
pickFolder: async () => dirs[pick++] ?? null,
|
||||||
|
scanFolder: async (l: string, r: string, s: boolean, p?: unknown) => {
|
||||||
|
calls.push({ l, r, s, p: p ?? null })
|
||||||
|
return { entries: incEntries, truncated: false, total: 2, cache: incCache }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
// 首次扫描:无缓存
|
||||||
|
fireEvent.click(screen.getByText('对比文件夹'))
|
||||||
|
await screen.findByText('相同 1')
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0].p).toBeNull()
|
||||||
|
// 语义切换重扫(同目录):携带上次返回的 cache
|
||||||
|
fireEvent.click(screen.getByLabelText('语义判等'))
|
||||||
|
await waitFor(() => expect(calls).toHaveLength(2))
|
||||||
|
expect(calls[1].p).toEqual(incCache)
|
||||||
|
// 重新扫描按钮(同目录):同样携带
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '重新扫描' }))
|
||||||
|
await waitFor(() => expect(calls).toHaveLength(3))
|
||||||
|
expect(calls[2].p).toEqual(incCache)
|
||||||
|
// 重新选择新目录:不携带缓存
|
||||||
|
fireEvent.click(screen.getByText('重新选择'))
|
||||||
|
await screen.findByText('相同 1')
|
||||||
|
await waitFor(() => expect(calls).toHaveLength(4))
|
||||||
|
expect(calls[3].p).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mock 结果未携带 cache 字段时重扫传 null(向后兼容)', async () => {
|
||||||
|
const calls: Array<unknown> = []
|
||||||
|
const dirs = ['C:/l1', 'D:/r1']
|
||||||
|
let pick = 0
|
||||||
|
window.api = mockApi({
|
||||||
|
pickFolder: async () => dirs[pick++] ?? null,
|
||||||
|
scanFolder: async (_l: unknown, _r: unknown, _s: unknown, p?: unknown) => {
|
||||||
|
calls.push(p ?? null)
|
||||||
|
// 模拟旧版扫描结果:无 cache 字段
|
||||||
|
return { entries: incEntries, truncated: false, total: 2 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('对比文件夹'))
|
||||||
|
await screen.findByText('相同 1')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '重新扫描' }))
|
||||||
|
await waitFor(() => expect(calls).toHaveLength(2))
|
||||||
|
expect(calls[1]).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('App - 目录聚合对比(双击目录行)', () => {
|
||||||
|
/** sub 内两个差异(b.txt/sub/x.ts)+ 根层一个差异(c.txt)+ sub 内相同与语义同 */
|
||||||
|
const scopeEntries = [
|
||||||
|
{ rel: 'a.txt', status: 'same' as const, leftSize: 1, rightSize: 1 },
|
||||||
|
{ rel: 'b.txt', status: 'different' as const, leftSize: 1, rightSize: 2 },
|
||||||
|
{ rel: 'sub/x.ts', status: 'different' as const, leftSize: 1, rightSize: 2 },
|
||||||
|
{ rel: 'sub/s.txt', status: 'semantic-same' as const, leftSize: 1, rightSize: 2 },
|
||||||
|
{ rel: 'sub/deep/y.md', status: 'different' as const, leftSize: 1, rightSize: 2 },
|
||||||
|
{ rel: 'c.txt', status: 'left-only' as const, leftSize: 1, rightSize: null },
|
||||||
|
{ rel: 'empty-dir-not-listed', status: 'same' as const, leftSize: 1, rightSize: 1 }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 进入文件夹模式并双击目录 sub(返回进入后的辅助) */
|
||||||
|
async function enterAndOpenDir(dirName: string) {
|
||||||
|
const dirs = ['C:/left-dir', 'D:/right-dir']
|
||||||
|
let call = 0
|
||||||
|
window.api = mockApi({
|
||||||
|
pickFolder: async () => dirs[call++] ?? null,
|
||||||
|
scanFolder: async () => ({ entries: scopeEntries, truncated: false, total: 7 }),
|
||||||
|
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('对比文件夹'))
|
||||||
|
await screen.findByText(dirName)
|
||||||
|
fireEvent.dblClick(screen.getByText(dirName))
|
||||||
|
// scope 激活后导航标签为「差异文件 · <dir>/」,用正则匹配两种形态
|
||||||
|
await screen.findByText(/差异文件/)
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftPaneFile = (): string =>
|
||||||
|
Array.from(document.querySelectorAll('.pane-file')).map((e) => e.textContent)[0] ?? ''
|
||||||
|
|
||||||
|
it('双击目录行:导航限定子树(语义同不计入),打开首个差异文件并标注范围', async () => {
|
||||||
|
await enterAndOpenDir('sub')
|
||||||
|
// sub 子树差异 = x.ts 与 deep/y.md(s 语义同不计入)→ 1/2,标签带范围
|
||||||
|
expect(screen.getByText('1 / 2')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/差异文件 · sub\//)).toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(leftPaneFile()).toBe('x.ts'))
|
||||||
|
// 根层差异 c.txt 不在序列内:下一个到 deep/y.md 后再下一个禁用
|
||||||
|
fireEvent.click(screen.getByTitle('下一个差异文件(Alt+↓)'))
|
||||||
|
await waitFor(() => expect(leftPaneFile()).toBe('y.md'))
|
||||||
|
expect(screen.getByText('2 / 2')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTitle('下一个差异文件(Alt+↓)')).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('聚合范围内 Alt+↓/Alt+↑ 导航保持范围不漂移', async () => {
|
||||||
|
await enterAndOpenDir('sub')
|
||||||
|
fireEvent.keyDown(window, { key: 'ArrowDown', altKey: true })
|
||||||
|
await waitFor(() => expect(leftPaneFile()).toBe('y.md'))
|
||||||
|
expect(screen.getByText('2 / 2')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/差异文件 · sub\//)).toBeInTheDocument()
|
||||||
|
fireEvent.keyDown(window, { key: 'ArrowUp', altKey: true })
|
||||||
|
await waitFor(() => expect(leftPaneFile()).toBe('x.ts'))
|
||||||
|
expect(screen.getByText('1 / 2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('双击文件条目回到全局序列(范围清除)', async () => {
|
||||||
|
await enterAndOpenDir('sub')
|
||||||
|
expect(screen.getByText(/差异文件 · sub\//)).toBeInTheDocument()
|
||||||
|
// 返回文件夹列表,双击根层文件 c.txt:回全局序列(b/c/x/y = 4 个差异)
|
||||||
|
fireEvent.click(screen.getByText('← 返回文件夹对比'))
|
||||||
|
await screen.findByText('相同 2')
|
||||||
|
fireEvent.dblClick(screen.getByText('c.txt'))
|
||||||
|
await screen.findByText('差异文件')
|
||||||
|
await waitFor(() => expect(leftPaneFile()).toBe('c.txt'))
|
||||||
|
// 全局序列按 mock 条目顺序(真实扫描为字典序):b/x.ts/y.md/c.txt → c.txt 为第 4 个
|
||||||
|
expect(screen.getByText('4 / 4')).toBeInTheDocument()
|
||||||
|
// 标签不再带子目录范围
|
||||||
|
expect(screen.queryByText(/差异文件 · sub\//)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('目录内没有差异文件时提示且不进入单文件对比', async () => {
|
||||||
|
const entries = [
|
||||||
|
{ rel: 'b.txt', status: 'different' as const, leftSize: 1, rightSize: 2 },
|
||||||
|
{ rel: 'nodiff/a.txt', status: 'same' as const, leftSize: 1, rightSize: 1 },
|
||||||
|
{ rel: 'nodiff/s.txt', status: 'semantic-same' as const, leftSize: 1, rightSize: 2 }
|
||||||
|
]
|
||||||
|
const dirs = ['C:/left-dir', 'D:/right-dir']
|
||||||
|
let call = 0
|
||||||
|
window.api = mockApi({
|
||||||
|
pickFolder: async () => dirs[call++] ?? null,
|
||||||
|
scanFolder: async () => ({ entries, truncated: false, total: 3 })
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('对比文件夹'))
|
||||||
|
await screen.findByText('b.txt')
|
||||||
|
// 默认仅看差异:nodiff 目录被剪枝不可见;关闭后可见
|
||||||
|
fireEvent.click(screen.getByLabelText('仅看差异'))
|
||||||
|
expect(screen.getByText('nodiff')).toBeInTheDocument()
|
||||||
|
fireEvent.dblClick(screen.getByText('nodiff'))
|
||||||
|
// 无差异文件:提示且停留在文件夹列表(不进入单文件对比)
|
||||||
|
expect(await screen.findByText(/目录「nodiff」内没有差异文件/)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('← 返回文件夹对比')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -30,9 +30,11 @@ function renderView(
|
|||||||
semantic={semantic}
|
semantic={semantic}
|
||||||
onSemanticChange={vi.fn()}
|
onSemanticChange={vi.fn()}
|
||||||
onReopen={vi.fn()}
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
onExit={vi.fn()}
|
onExit={vi.fn()}
|
||||||
onExport={vi.fn()}
|
onExport={vi.fn()}
|
||||||
onOpenEntry={vi.fn()}
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -77,6 +79,42 @@ describe('FolderView - 树形渲染', () => {
|
|||||||
expect(screen.getByText('diff.ts')).toBeInTheDocument()
|
expect(screen.getByText('diff.ts')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('双击目录行触发 onOpenDir(携带目录相对路径)', () => {
|
||||||
|
const onOpenDir = vi.fn()
|
||||||
|
render(
|
||||||
|
<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()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
|
onExit={vi.fn()}
|
||||||
|
onExport={vi.fn()}
|
||||||
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={onOpenDir}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.dblClick(screen.getByText('sub'))
|
||||||
|
expect(onOpenDir).toHaveBeenCalledWith('sub')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('双击目录行前该目录处于折叠态:进入时自动展开(消除双击 toggle 残留)', () => {
|
||||||
|
renderView(ENTRIES, false)
|
||||||
|
// 先折叠 sub
|
||||||
|
fireEvent.click(screen.getByText('sub'))
|
||||||
|
expect(screen.queryByText('diff.ts')).not.toBeInTheDocument()
|
||||||
|
// 双击 sub:onOpenDir 触发且目录恢复展开(返回列表后子行可见)
|
||||||
|
fireEvent.dblClick(screen.getByText('sub'))
|
||||||
|
expect(screen.getByText('diff.ts')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('双击文件条目触发 onOpenEntry(携带原始条目)', () => {
|
it('双击文件条目触发 onOpenEntry(携带原始条目)', () => {
|
||||||
const onOpenEntry = vi.fn()
|
const onOpenEntry = vi.fn()
|
||||||
render(
|
render(
|
||||||
@@ -92,9 +130,11 @@ describe('FolderView - 树形渲染', () => {
|
|||||||
semantic={true}
|
semantic={true}
|
||||||
onSemanticChange={vi.fn()}
|
onSemanticChange={vi.fn()}
|
||||||
onReopen={vi.fn()}
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
onExit={vi.fn()}
|
onExit={vi.fn()}
|
||||||
onExport={vi.fn()}
|
onExport={vi.fn()}
|
||||||
onOpenEntry={onOpenEntry}
|
onOpenEntry={onOpenEntry}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
fireEvent.dblClick(screen.getByText('b.txt'))
|
fireEvent.dblClick(screen.getByText('b.txt'))
|
||||||
@@ -137,9 +177,11 @@ describe('FolderView - 统计徽章与过滤', () => {
|
|||||||
semantic={true}
|
semantic={true}
|
||||||
onSemanticChange={vi.fn()}
|
onSemanticChange={vi.fn()}
|
||||||
onReopen={vi.fn()}
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
onExit={vi.fn()}
|
onExit={vi.fn()}
|
||||||
onExport={vi.fn()}
|
onExport={vi.fn()}
|
||||||
onOpenEntry={vi.fn()}
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
expect(screen.getByText('a.txt')).toBeInTheDocument()
|
expect(screen.getByText('a.txt')).toBeInTheDocument()
|
||||||
@@ -176,9 +218,11 @@ describe('FolderView - 统计徽章与过滤', () => {
|
|||||||
semantic={true}
|
semantic={true}
|
||||||
onSemanticChange={vi.fn()}
|
onSemanticChange={vi.fn()}
|
||||||
onReopen={vi.fn()}
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
onExit={vi.fn()}
|
onExit={vi.fn()}
|
||||||
onExport={vi.fn()}
|
onExport={vi.fn()}
|
||||||
onOpenEntry={vi.fn()}
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
fireEvent.click(screen.getByText('相同 1'))
|
fireEvent.click(screen.getByText('相同 1'))
|
||||||
@@ -237,6 +281,176 @@ describe('FolderView - 搜索', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('FolderView - 扩展名过滤', () => {
|
||||||
|
const EXT_ENTRIES: FolderEntry[] = [
|
||||||
|
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
|
||||||
|
{ rel: 'b.txt', status: 'different', leftSize: 1, rightSize: 2 },
|
||||||
|
{ rel: 'c.md', status: 'different', leftSize: 1, rightSize: 2 },
|
||||||
|
{ rel: 'noext', status: 'left-only', leftSize: 1, rightSize: null }
|
||||||
|
]
|
||||||
|
|
||||||
|
it('下拉展示全量条目的扩展名分布与计数(字典序,无扩展名单列)', () => {
|
||||||
|
render(
|
||||||
|
<FolderView
|
||||||
|
leftDir="C:/left"
|
||||||
|
rightDir="D:/right"
|
||||||
|
entries={EXT_ENTRIES}
|
||||||
|
truncated={false}
|
||||||
|
total={4}
|
||||||
|
scanning={false}
|
||||||
|
onlyDiff={false}
|
||||||
|
onOnlyDiffChange={vi.fn()}
|
||||||
|
semantic={true}
|
||||||
|
onSemanticChange={vi.fn()}
|
||||||
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
|
onExit={vi.fn()}
|
||||||
|
onExport={vi.fn()}
|
||||||
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
|
||||||
|
// 字典序:(无扩展名)/ .md / .txt,各带计数
|
||||||
|
expect(screen.getByText('(无扩展名)')).toBeInTheDocument()
|
||||||
|
const md = screen.getByText('.md')
|
||||||
|
expect(md.textContent).toContain('1')
|
||||||
|
const txt = screen.getByText('.txt')
|
||||||
|
expect(txt.textContent).toContain('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点选扩展名过滤条目:交集过滤生效,计数切换为 N / 总数', () => {
|
||||||
|
render(
|
||||||
|
<FolderView
|
||||||
|
leftDir="C:/left"
|
||||||
|
rightDir="D:/right"
|
||||||
|
entries={EXT_ENTRIES}
|
||||||
|
truncated={false}
|
||||||
|
total={4}
|
||||||
|
scanning={false}
|
||||||
|
onlyDiff={false}
|
||||||
|
onOnlyDiffChange={vi.fn()}
|
||||||
|
semantic={true}
|
||||||
|
onSemanticChange={vi.fn()}
|
||||||
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
|
onExit={vi.fn()}
|
||||||
|
onExport={vi.fn()}
|
||||||
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('共 4 项')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
|
||||||
|
fireEvent.click(screen.getByText('.txt'))
|
||||||
|
// 只剩 txt 条目(a.txt 同状态被关掉 onlyDiff 后可见)
|
||||||
|
expect(screen.getByText('a.txt')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('b.txt')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('c.md')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('noext')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('共 2 / 4 项')).toBeInTheDocument()
|
||||||
|
// 入口按钮显示已选数量
|
||||||
|
expect(screen.getByRole('button', { name: /扩展名 · 1/ })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多选扩展名取并集,再次点击取消,清除按钮复位', () => {
|
||||||
|
render(
|
||||||
|
<FolderView
|
||||||
|
leftDir="C:/left"
|
||||||
|
rightDir="D:/right"
|
||||||
|
entries={EXT_ENTRIES}
|
||||||
|
truncated={false}
|
||||||
|
total={4}
|
||||||
|
scanning={false}
|
||||||
|
onlyDiff={false}
|
||||||
|
onOnlyDiffChange={vi.fn()}
|
||||||
|
semantic={true}
|
||||||
|
onSemanticChange={vi.fn()}
|
||||||
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
|
onExit={vi.fn()}
|
||||||
|
onExport={vi.fn()}
|
||||||
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
|
||||||
|
fireEvent.click(screen.getByText('.txt'))
|
||||||
|
fireEvent.click(screen.getByText('.md'))
|
||||||
|
expect(screen.getByText('b.txt')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('c.md')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('noext')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('共 3 / 4 项')).toBeInTheDocument()
|
||||||
|
// 取消 .txt
|
||||||
|
fireEvent.click(screen.getByText('.txt'))
|
||||||
|
expect(screen.queryByText('b.txt')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('c.md')).toBeInTheDocument()
|
||||||
|
// 清除按钮整体复位
|
||||||
|
fireEvent.click(screen.getByText('清除扩展名过滤'))
|
||||||
|
expect(screen.getByText('共 4 项')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('noext')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('与状态过滤交集:双激活时无结果的空态提示', () => {
|
||||||
|
render(
|
||||||
|
<FolderView
|
||||||
|
leftDir="C:/left"
|
||||||
|
rightDir="D:/right"
|
||||||
|
entries={EXT_ENTRIES}
|
||||||
|
truncated={false}
|
||||||
|
total={4}
|
||||||
|
scanning={false}
|
||||||
|
onlyDiff={false}
|
||||||
|
onOnlyDiffChange={vi.fn()}
|
||||||
|
semantic={true}
|
||||||
|
onSemanticChange={vi.fn()}
|
||||||
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
|
onExit={vi.fn()}
|
||||||
|
onExport={vi.fn()}
|
||||||
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
// 状态过滤:只看相同(仅 a.txt)
|
||||||
|
fireEvent.click(screen.getByText('相同 1'))
|
||||||
|
// 扩展名过滤:.md(与 a.txt 无交集)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
|
||||||
|
fireEvent.click(screen.getByText('.md'))
|
||||||
|
expect(screen.getByText('当前过滤条件下没有文件')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击外部区域关闭扩展名下拉(过滤状态保留)', () => {
|
||||||
|
render(
|
||||||
|
<FolderView
|
||||||
|
leftDir="C:/left"
|
||||||
|
rightDir="D:/right"
|
||||||
|
entries={EXT_ENTRIES}
|
||||||
|
truncated={false}
|
||||||
|
total={4}
|
||||||
|
scanning={false}
|
||||||
|
onlyDiff={false}
|
||||||
|
onOnlyDiffChange={vi.fn()}
|
||||||
|
semantic={true}
|
||||||
|
onSemanticChange={vi.fn()}
|
||||||
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
|
onExit={vi.fn()}
|
||||||
|
onExport={vi.fn()}
|
||||||
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
|
||||||
|
fireEvent.click(screen.getByText('.txt'))
|
||||||
|
fireEvent.mouseDown(document.body)
|
||||||
|
expect(screen.queryByText('.md')).not.toBeInTheDocument()
|
||||||
|
// 过滤仍在生效
|
||||||
|
expect(screen.queryByText('c.md')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /扩展名 · 1/ })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('FolderView - 其他', () => {
|
describe('FolderView - 其他', () => {
|
||||||
it('切换语义判等开关触发 onSemanticChange', () => {
|
it('切换语义判等开关触发 onSemanticChange', () => {
|
||||||
const onSemanticChange = vi.fn()
|
const onSemanticChange = vi.fn()
|
||||||
@@ -253,15 +467,64 @@ describe('FolderView - 其他', () => {
|
|||||||
semantic={true}
|
semantic={true}
|
||||||
onSemanticChange={onSemanticChange}
|
onSemanticChange={onSemanticChange}
|
||||||
onReopen={vi.fn()}
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
onExit={vi.fn()}
|
onExit={vi.fn()}
|
||||||
onExport={vi.fn()}
|
onExport={vi.fn()}
|
||||||
onOpenEntry={vi.fn()}
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
fireEvent.click(screen.getByLabelText('语义判等'))
|
fireEvent.click(screen.getByLabelText('语义判等'))
|
||||||
expect(onSemanticChange).toHaveBeenCalledWith(false)
|
expect(onSemanticChange).toHaveBeenCalledWith(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('点击重新扫描触发 onRescan;扫描中禁用', () => {
|
||||||
|
const onRescan = vi.fn()
|
||||||
|
const { rerender } = 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()}
|
||||||
|
onRescan={onRescan}
|
||||||
|
onExit={vi.fn()}
|
||||||
|
onExport={vi.fn()}
|
||||||
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '重新扫描' }))
|
||||||
|
expect(onRescan).toHaveBeenCalledTimes(1)
|
||||||
|
rerender(
|
||||||
|
<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()}
|
||||||
|
onRescan={onRescan}
|
||||||
|
onExit={vi.fn()}
|
||||||
|
onExport={vi.fn()}
|
||||||
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('button', { name: '重新扫描' })).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
it('导出报告菜单选择格式触发 onExport 并关闭菜单', () => {
|
it('导出报告菜单选择格式触发 onExport 并关闭菜单', () => {
|
||||||
const onExport = vi.fn()
|
const onExport = vi.fn()
|
||||||
render(
|
render(
|
||||||
@@ -277,9 +540,11 @@ describe('FolderView - 其他', () => {
|
|||||||
semantic={true}
|
semantic={true}
|
||||||
onSemanticChange={vi.fn()}
|
onSemanticChange={vi.fn()}
|
||||||
onReopen={vi.fn()}
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
onExit={vi.fn()}
|
onExit={vi.fn()}
|
||||||
onExport={onExport}
|
onExport={onExport}
|
||||||
onOpenEntry={vi.fn()}
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
|
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
|
||||||
@@ -302,9 +567,11 @@ describe('FolderView - 其他', () => {
|
|||||||
semantic={true}
|
semantic={true}
|
||||||
onSemanticChange={vi.fn()}
|
onSemanticChange={vi.fn()}
|
||||||
onReopen={vi.fn()}
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
onExit={vi.fn()}
|
onExit={vi.fn()}
|
||||||
onExport={vi.fn()}
|
onExport={vi.fn()}
|
||||||
onOpenEntry={vi.fn()}
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
expect(screen.getByRole('button', { name: '导出报告' })).toBeDisabled()
|
expect(screen.getByRole('button', { name: '导出报告' })).toBeDisabled()
|
||||||
@@ -324,9 +591,11 @@ describe('FolderView - 其他', () => {
|
|||||||
semantic={true}
|
semantic={true}
|
||||||
onSemanticChange={vi.fn()}
|
onSemanticChange={vi.fn()}
|
||||||
onReopen={vi.fn()}
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
onExit={vi.fn()}
|
onExit={vi.fn()}
|
||||||
onExport={vi.fn()}
|
onExport={vi.fn()}
|
||||||
onOpenEntry={vi.fn()}
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
|
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
|
||||||
@@ -363,9 +632,11 @@ describe('FolderView - 其他', () => {
|
|||||||
semantic={true}
|
semantic={true}
|
||||||
onSemanticChange={vi.fn()}
|
onSemanticChange={vi.fn()}
|
||||||
onReopen={vi.fn()}
|
onReopen={vi.fn()}
|
||||||
|
onRescan={vi.fn()}
|
||||||
onExit={vi.fn()}
|
onExit={vi.fn()}
|
||||||
onExport={vi.fn()}
|
onExport={vi.fn()}
|
||||||
onOpenEntry={vi.fn()}
|
onOpenEntry={vi.fn()}
|
||||||
|
onOpenDir={vi.fn()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
expect(screen.getByText(/文件过多/)).toBeInTheDocument()
|
expect(screen.getByText(/文件过多/)).toBeInTheDocument()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
|
|||||||
import { type ReportFormat } from '../diff/report'
|
import { type ReportFormat } from '../diff/report'
|
||||||
import { countFolderStats, fmtSize } from '../diff/folderReport'
|
import { countFolderStats, fmtSize } from '../diff/folderReport'
|
||||||
import { useDismiss } from '../hooks/useDismiss'
|
import { useDismiss } from '../hooks/useDismiss'
|
||||||
import { buildFolderTree, filterEntries, flattenFolderTree } from './folderTree'
|
import { buildFolderTree, filterEntries, flattenFolderTree, relExt } 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
|
||||||
@@ -37,12 +37,16 @@ export interface FolderViewProps {
|
|||||||
onSemanticChange: (v: boolean) => void
|
onSemanticChange: (v: boolean) => void
|
||||||
/** 重新选择两侧文件夹 */
|
/** 重新选择两侧文件夹 */
|
||||||
onReopen: () => void
|
onReopen: () => void
|
||||||
|
/** 不重选目录直接重新扫描(增量:指纹一致的条目零 IO 沿用) */
|
||||||
|
onRescan: () => void
|
||||||
/** 返回文件对比模式(清空文件夹状态) */
|
/** 返回文件对比模式(清空文件夹状态) */
|
||||||
onExit: () => void
|
onExit: () => void
|
||||||
/** 导出文件夹对比报告 */
|
/** 导出文件夹对比报告 */
|
||||||
onExport: (fmt: ReportFormat) => void
|
onExport: (fmt: ReportFormat) => void
|
||||||
/** 双击条目进入单文件对比 */
|
/** 双击条目进入单文件对比 */
|
||||||
onOpenEntry: (entry: FolderEntry) => void
|
onOpenEntry: (entry: FolderEntry) => void
|
||||||
|
/** 双击目录行进入该目录的聚合对比(差异文件导航限定在子树内) */
|
||||||
|
onOpenDir: (dirPath: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导出格式清单(与单文件报告一致) */
|
/** 导出格式清单(与单文件报告一致) */
|
||||||
@@ -83,9 +87,11 @@ export default function FolderView({
|
|||||||
semantic,
|
semantic,
|
||||||
onSemanticChange,
|
onSemanticChange,
|
||||||
onReopen,
|
onReopen,
|
||||||
|
onRescan,
|
||||||
onExit,
|
onExit,
|
||||||
onExport,
|
onExport,
|
||||||
onOpenEntry
|
onOpenEntry,
|
||||||
|
onOpenDir
|
||||||
}: FolderViewProps): ReactElement {
|
}: FolderViewProps): ReactElement {
|
||||||
// 导出下拉(点击外部 / Esc 关闭)
|
// 导出下拉(点击外部 / Esc 关闭)
|
||||||
const [exportOpen, setExportOpen] = useState(false)
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
@@ -97,22 +103,45 @@ export default function FolderView({
|
|||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
// 状态精确过滤(null = 不过滤;与 onlyDiff 互斥)
|
// 状态精确过滤(null = 不过滤;与 onlyDiff 互斥)
|
||||||
const [statusFilter, setStatusFilter] = useState<Set<FolderEntryStatus> | null>(null)
|
const [statusFilter, setStatusFilter] = useState<Set<FolderEntryStatus> | null>(null)
|
||||||
|
// 扩展名精确过滤(null = 不过滤;与其他过滤条件取交集)
|
||||||
|
const [extFilter, setExtFilter] = useState<Set<string> | null>(null)
|
||||||
|
// 扩展名下拉(点击外部 / Esc 关闭)
|
||||||
|
const [extOpen, setExtOpen] = useState(false)
|
||||||
|
const extWrapRef = useRef<HTMLDivElement>(null)
|
||||||
|
const closeExt = useCallback((): void => setExtOpen(false), [])
|
||||||
|
useDismiss(extWrapRef, closeExt)
|
||||||
// 折叠的目录集合(默认全展开;空集合 = 全部展开)
|
// 折叠的目录集合(默认全展开;空集合 = 全部展开)
|
||||||
const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(new Set())
|
const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
// 统计(条目集不变时不重算;口径与报告导出共用同一实现,始终基于全量条目)
|
// 统计(条目集不变时不重算;口径与报告导出共用同一实现,始终基于全量条目)
|
||||||
const stats = useMemo(() => countFolderStats(entries), [entries])
|
const stats = useMemo(() => countFolderStats(entries), [entries])
|
||||||
|
|
||||||
|
/** 全量条目的扩展名分布(字典序),扩展名过滤下拉的数据源 */
|
||||||
|
const extStats = useMemo(() => {
|
||||||
|
const m = new Map<string, number>()
|
||||||
|
for (const e of entries) {
|
||||||
|
const x = relExt(e.rel)
|
||||||
|
m.set(x, (m.get(x) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return [...m.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||||
|
}, [entries])
|
||||||
|
|
||||||
/** 过滤 → 建树(剪枝:过滤后无文件的目录不出现) */
|
/** 过滤 → 建树(剪枝:过滤后无文件的目录不出现) */
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const filtered = filterEntries(entries, { onlyDiff, statusFilter, query })
|
const filtered = filterEntries(entries, { onlyDiff, statusFilter, query, exts: extFilter })
|
||||||
const tree = buildFolderTree(filtered)
|
const tree = buildFolderTree(filtered)
|
||||||
const q = query.trim()
|
const q = query.trim()
|
||||||
// 搜索激活时强制全部展开(命中路径完整可见);清空搜索恢复用户折叠状态
|
// 搜索激活时强制全部展开(命中路径完整可见);清空搜索恢复用户折叠状态
|
||||||
const isExpanded = (dirPath: string): boolean =>
|
const isExpanded = (dirPath: string): boolean =>
|
||||||
q !== '' ? true : !collapsedDirs.has(dirPath)
|
q !== '' ? true : !collapsedDirs.has(dirPath)
|
||||||
return flattenFolderTree(tree, isExpanded)
|
return flattenFolderTree(tree, isExpanded)
|
||||||
}, [entries, onlyDiff, statusFilter, query, collapsedDirs])
|
}, [entries, onlyDiff, statusFilter, query, extFilter, collapsedDirs])
|
||||||
|
|
||||||
|
/** 过滤后的文件数("共 N / 总数"计数用,不含目录行) */
|
||||||
|
const filteredCount = useMemo(
|
||||||
|
() => filterEntries(entries, { onlyDiff, statusFilter, query, exts: extFilter }).length,
|
||||||
|
[entries, onlyDiff, statusFilter, query, extFilter]
|
||||||
|
)
|
||||||
|
|
||||||
const toggleDir = useCallback((path: string): void => {
|
const toggleDir = useCallback((path: string): void => {
|
||||||
setCollapsedDirs((prev) => {
|
setCollapsedDirs((prev) => {
|
||||||
@@ -146,6 +175,16 @@ export default function FolderView({
|
|||||||
[onlyDiff, onOnlyDiffChange]
|
[onlyDiff, onOnlyDiffChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 扩展名下拉项点击:toggle 过滤集合(空集合回落 null = 不过滤) */
|
||||||
|
const handleExtToggle = useCallback((ext: string): void => {
|
||||||
|
setExtFilter((prev) => {
|
||||||
|
const next = new Set(prev ?? [])
|
||||||
|
if (next.has(ext)) next.delete(ext)
|
||||||
|
else next.add(ext)
|
||||||
|
return next.size === 0 ? null : next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 虚拟滚动:固定行高换算可见窗口(与 DiffView 同思路)
|
// 虚拟滚动:固定行高换算可见窗口(与 DiffView 同思路)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const [range, setRange] = useState({ start: 0, end: 0 })
|
const [range, setRange] = useState({ start: 0, end: 0 })
|
||||||
@@ -188,6 +227,14 @@ export default function FolderView({
|
|||||||
<span className="folder-path">{rightDir}</span>
|
<span className="folder-path">{rightDir}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="folder-actions">
|
<div className="folder-actions">
|
||||||
|
<button
|
||||||
|
className="btn ghost"
|
||||||
|
disabled={scanning}
|
||||||
|
title={scanning ? '正在扫描文件夹' : '重新扫描当前两个目录(增量:未变更的文件直接沿用判定)'}
|
||||||
|
onClick={onRescan}
|
||||||
|
>
|
||||||
|
重新扫描
|
||||||
|
</button>
|
||||||
<button className="btn ghost" onClick={onReopen}>
|
<button className="btn ghost" onClick={onReopen}>
|
||||||
重新选择
|
重新选择
|
||||||
</button>
|
</button>
|
||||||
@@ -247,7 +294,41 @@ export default function FolderView({
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<span className="fstat muted">共 {entries.length} 项</span>
|
<span className="fstat muted" title="条目总数(扩展名/状态过滤不影响统计徽章口径)">
|
||||||
|
共 {extFilter !== null || statusFilter !== null ? `${filteredCount} / ${entries.length}` : entries.length} 项
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="export-wrap" ref={extWrapRef}>
|
||||||
|
<button
|
||||||
|
className={'btn ghost' + (extFilter !== null ? ' filtered' : '')}
|
||||||
|
title="按扩展名过滤条目(可多选,与其他过滤条件取交集)"
|
||||||
|
onClick={() => setExtOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
扩展名{extFilter !== null ? ` · ${extFilter.size}` : ''}
|
||||||
|
</button>
|
||||||
|
{extOpen && (
|
||||||
|
<div className="export-menu ext-menu">
|
||||||
|
{extStats.map(([ext, n]) => {
|
||||||
|
const on = extFilter?.has(ext) ?? false
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ext}
|
||||||
|
className={'ctx-item' + (on ? ' active' : '')}
|
||||||
|
title={on ? `点击取消“${ext === '' ? '无扩展名' : '.' + ext}”过滤` : `点击只看“${ext === '' ? '无扩展名' : '.' + ext}”的文件`}
|
||||||
|
onClick={() => handleExtToggle(ext)}
|
||||||
|
>
|
||||||
|
{ext === '' ? '(无扩展名)' : `.${ext}`}
|
||||||
|
<span className="ext-count">{n}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{extFilter !== null && (
|
||||||
|
<button className="ctx-item ext-clear" onClick={() => setExtFilter(null)}>
|
||||||
|
清除扩展名过滤
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
className="folder-search"
|
className="folder-search"
|
||||||
@@ -296,8 +377,18 @@ export default function FolderView({
|
|||||||
<div
|
<div
|
||||||
className={`folder-row dir ${meta.cls}`}
|
className={`folder-row dir ${meta.cls}`}
|
||||||
key={`dir-${n.path}`}
|
key={`dir-${n.path}`}
|
||||||
title={`${n.path}/ · ${n.fileCount ?? 0} 个文件`}
|
title={`${n.path}/ · ${n.fileCount ?? 0} 个文件 · 双击对比此目录内的差异文件`}
|
||||||
onClick={() => toggleDir(n.path)}
|
onClick={() => toggleDir(n.path)}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
// 双击前两次单击 toggle 会回到原折叠态:进入聚合对比前确保该目录展开
|
||||||
|
setCollapsedDirs((prev) => {
|
||||||
|
if (!prev.has(n.path)) return prev
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(n.path)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
onOpenDir(n.path)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="folder-caret">{collapsed ? '▶' : '▼'}</span>
|
<span className="folder-caret">{collapsed ? '▶' : '▼'}</span>
|
||||||
<span className="folder-rel">{n.name}</span>
|
<span className="folder-rel">{n.name}</span>
|
||||||
@@ -331,7 +422,7 @@ export default function FolderView({
|
|||||||
? `没有匹配“${q}”的文件`
|
? `没有匹配“${q}”的文件`
|
||||||
: entries.length === 0
|
: entries.length === 0
|
||||||
? '两个文件夹内都没有文件'
|
? '两个文件夹内都没有文件'
|
||||||
: statusFilter !== null
|
: statusFilter !== null || extFilter !== null
|
||||||
? '当前过滤条件下没有文件'
|
? '当前过滤条件下没有文件'
|
||||||
: '没有差异文件(两侧内容全部一致)'}
|
: '没有差异文件(两侧内容全部一致)'}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ function setup(extra?: {
|
|||||||
computing?: boolean
|
computing?: boolean
|
||||||
charMode?: boolean
|
charMode?: boolean
|
||||||
charModeDowngraded?: boolean
|
charModeDowngraded?: boolean
|
||||||
|
charModePartial?: boolean
|
||||||
baseOptions?: Partial<import('../diff/diffEngine').DiffOptions>
|
baseOptions?: Partial<import('../diff/diffEngine').DiffOptions>
|
||||||
onOptionBlocked?: (label: string) => void
|
onOptionBlocked?: (label: string) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -39,6 +40,7 @@ function setup(extra?: {
|
|||||||
onSwap={onSwap}
|
onSwap={onSwap}
|
||||||
onOptionBlocked={extra?.onOptionBlocked}
|
onOptionBlocked={extra?.onOptionBlocked}
|
||||||
charModeDowngraded={extra?.charModeDowngraded ?? false}
|
charModeDowngraded={extra?.charModeDowngraded ?? false}
|
||||||
|
charModePartial={extra?.charModePartial ?? false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
return { onOptionsChange, onNav, onExport, onOnlyDiffChange, onSwap, ...utils }
|
return { onOptionsChange, onNav, onExport, onOnlyDiffChange, onSwap, ...utils }
|
||||||
@@ -133,6 +135,25 @@ describe('Toolbar', () => {
|
|||||||
expect(screen.getByText('已降级')).toBeInTheDocument()
|
expect(screen.getByText('已降级')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('字符级部分降级时显示“部分降级”标记(不出现整体降级标记)', () => {
|
||||||
|
setup({ charMode: true, charModePartial: true })
|
||||||
|
expect(screen.getByText('部分降级')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('已降级')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('整体降级优先呈现:同时标记时只显示“已降级”', () => {
|
||||||
|
setup({ charMode: true, charModePartial: true, charModeDowngraded: true })
|
||||||
|
expect(screen.getByText('已降级')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('部分降级')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('字符级关闭或未降级时不显示“部分降级”标记', () => {
|
||||||
|
setup({ charMode: true })
|
||||||
|
expect(screen.queryByText('部分降级')).not.toBeInTheDocument()
|
||||||
|
setup({ charModePartial: true })
|
||||||
|
expect(screen.queryByText('部分降级')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('未降级或字符级关闭时不显示“已降级”标记', () => {
|
it('未降级或字符级关闭时不显示“已降级”标记', () => {
|
||||||
setup({ charMode: true })
|
setup({ charMode: true })
|
||||||
expect(screen.queryByText('已降级')).not.toBeInTheDocument()
|
expect(screen.queryByText('已降级')).not.toBeInTheDocument()
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ interface ToolbarProps {
|
|||||||
onOptionBlocked?: (label: string) => void
|
onOptionBlocked?: (label: string) => void
|
||||||
/** 字符级对比因内容超限自动降级为行级(开关旁显示“已降级”标记) */
|
/** 字符级对比因内容超限自动降级为行级(开关旁显示“已降级”标记) */
|
||||||
charModeDowngraded?: boolean
|
charModeDowngraded?: boolean
|
||||||
|
/** 字符级对比部分降级(部分超限组按行级,其余组仍字符级;显示“部分降级”标记) */
|
||||||
|
charModePartial?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Toolbar({
|
export default function Toolbar({
|
||||||
@@ -43,7 +45,8 @@ export default function Toolbar({
|
|||||||
onOnlyDiffChange,
|
onOnlyDiffChange,
|
||||||
onSwap,
|
onSwap,
|
||||||
onOptionBlocked,
|
onOptionBlocked,
|
||||||
charModeDowngraded = false
|
charModeDowngraded = false,
|
||||||
|
charModePartial = false
|
||||||
}: ToolbarProps): ReactElement {
|
}: ToolbarProps): ReactElement {
|
||||||
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
|
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
|
||||||
const [exportOpen, setExportOpen] = useState(false)
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
@@ -145,6 +148,14 @@ export default function Toolbar({
|
|||||||
已降级
|
已降级
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{options.charMode && !charModeDowngraded && charModePartial && (
|
||||||
|
<span
|
||||||
|
className="opt-badge warn"
|
||||||
|
title="部分差异过大的块已按行级对比,其余差异块仍为字符级高亮"
|
||||||
|
>
|
||||||
|
部分降级
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import type { FolderEntry } from '../../../preload/index'
|
import type { FolderEntry } from '../../../preload/index'
|
||||||
import { buildFolderTree, filterEntries, flattenFolderTree, collectEntries } from './folderTree'
|
import {
|
||||||
|
buildFolderTree,
|
||||||
|
filterEntries,
|
||||||
|
flattenFolderTree,
|
||||||
|
collectEntries,
|
||||||
|
relExt
|
||||||
|
} from './folderTree'
|
||||||
|
|
||||||
const E: FolderEntry[] = [
|
const E: FolderEntry[] = [
|
||||||
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
|
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
|
||||||
@@ -108,6 +114,58 @@ describe('filterEntries', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('filterEntries - 扩展名过滤', () => {
|
||||||
|
const X: FolderEntry[] = [
|
||||||
|
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
|
||||||
|
{ rel: 'b.TXT', status: 'different', leftSize: 1, rightSize: 2 },
|
||||||
|
{ rel: 'sub/x.ts', status: 'different', leftSize: 1, rightSize: 2 },
|
||||||
|
{ rel: 'noext', status: 'left-only', leftSize: 1, rightSize: null },
|
||||||
|
{ rel: 'd/noext2', status: 'right-only', leftSize: null, rightSize: 1 }
|
||||||
|
]
|
||||||
|
|
||||||
|
it('relExt 取文件名段扩展名(小写;无扩展名为空串;不误取目录名中的点)', () => {
|
||||||
|
expect(relExt('a.txt')).toBe('txt')
|
||||||
|
expect(relExt('A.TXT')).toBe('txt')
|
||||||
|
expect(relExt('sub/x.ts')).toBe('ts')
|
||||||
|
expect(relExt('noext')).toBe('')
|
||||||
|
expect(relExt('v1.2.3/md')).toBe('')
|
||||||
|
expect(relExt('archive.tar.gz')).toBe('gz')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exts 非空时只保留集合内扩展名(大小写不敏感,无扩展名归空串类目)', () => {
|
||||||
|
const out = filterEntries(X, {
|
||||||
|
onlyDiff: false,
|
||||||
|
statusFilter: null,
|
||||||
|
query: '',
|
||||||
|
exts: new Set(['txt'])
|
||||||
|
})
|
||||||
|
expect(out.map((e) => e.rel)).toEqual(['a.txt', 'b.TXT'])
|
||||||
|
const noExt = filterEntries(X, {
|
||||||
|
onlyDiff: false,
|
||||||
|
statusFilter: null,
|
||||||
|
query: '',
|
||||||
|
exts: new Set([''])
|
||||||
|
})
|
||||||
|
expect(noExt.map((e) => e.rel)).toEqual(['noext', 'd/noext2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多选扩展名取并集,与其他条件取交集(onlyDiff + exts)', () => {
|
||||||
|
const out = filterEntries(X, {
|
||||||
|
onlyDiff: true,
|
||||||
|
statusFilter: null,
|
||||||
|
query: '',
|
||||||
|
exts: new Set(['txt', 'ts'])
|
||||||
|
})
|
||||||
|
// onlyDiff 过滤掉 same 的 a.txt,剩 b.TXT 与 sub/x.ts
|
||||||
|
expect(out.map((e) => e.rel)).toEqual(['b.TXT', 'sub/x.ts'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exts 为 null 或缺省时不过滤(向后兼容)', () => {
|
||||||
|
expect(filterEntries(X, { onlyDiff: false, statusFilter: null, query: '', exts: null })).toHaveLength(5)
|
||||||
|
expect(filterEntries(X, { onlyDiff: false, statusFilter: null, query: '' })).toHaveLength(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('collectEntries', () => {
|
describe('collectEntries', () => {
|
||||||
it('收集子树全部文件条目', () => {
|
it('收集子树全部文件条目', () => {
|
||||||
const root = buildFolderTree(E)
|
const root = buildFolderTree(E)
|
||||||
|
|||||||
@@ -47,15 +47,26 @@ export interface EntryFilter {
|
|||||||
statusFilter: Set<FolderEntryStatus> | null
|
statusFilter: Set<FolderEntryStatus> | null
|
||||||
/** 相对路径子串搜索(不区分大小写;空串不过滤) */
|
/** 相对路径子串搜索(不区分大小写;空串不过滤) */
|
||||||
query: string
|
query: string
|
||||||
|
/** 扩展名精确过滤(null 为不过滤;非空时只保留集合内扩展名,与其他条件取交集) */
|
||||||
|
exts?: Set<string> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按视图条件过滤条目(文件层过滤,树构建之前执行) */
|
/** 条目的扩展名(小写不含点;仅取文件名段,无扩展名为空串——无扩展名文件归独立类目) */
|
||||||
|
export function relExt(rel: string): string {
|
||||||
|
const name = rel.slice(rel.lastIndexOf('/') + 1)
|
||||||
|
const dot = name.lastIndexOf('.')
|
||||||
|
if (dot < 0) return ''
|
||||||
|
return name.slice(dot + 1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按视图条件过滤条目(文件层过滤,树构建之前执行;全部条件取交集) */
|
||||||
export function filterEntries(entries: FolderEntry[], f: EntryFilter): FolderEntry[] {
|
export function filterEntries(entries: FolderEntry[], f: EntryFilter): FolderEntry[] {
|
||||||
const q = f.query.trim().toLowerCase()
|
const q = f.query.trim().toLowerCase()
|
||||||
return entries.filter((e) => {
|
return entries.filter((e) => {
|
||||||
if (f.onlyDiff && (e.status === 'same' || e.status === 'semantic-same')) return false
|
if (f.onlyDiff && (e.status === 'same' || e.status === 'semantic-same')) return false
|
||||||
if (f.statusFilter !== null && !f.statusFilter.has(e.status)) return false
|
if (f.statusFilter !== null && !f.statusFilter.has(e.status)) return false
|
||||||
if (q !== '' && !e.rel.toLowerCase().includes(q)) return false
|
if (q !== '' && !e.rel.toLowerCase().includes(q)) return false
|
||||||
|
if (f.exts != null && !f.exts.has(relExt(e.rel))) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -564,9 +564,9 @@ describe('computeDiff - 字符级对比(分块锚定)', () => {
|
|||||||
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
|
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('变更组总流超全局上限仍降级(每组在限内但合计超限)', () => {
|
it('组总流超预算:预算内组字符级、预算外组行级降级(部分降级,不再整体降级)', async () => {
|
||||||
// 8 个变更组,每组流 6500 + 6501 = 13001(在单组上限内,编辑距离 1),
|
// 8 个变更组,每组流 6500 + 6501 = 13001(在单组上限内,编辑距离 1):
|
||||||
// 合计 104008 > CHAR_DIFF_GROUP_TOTAL_CHARS(100000)→ 整体降级
|
// 前 7 组消耗 91007,第 8 组 13001 > 剩余 8993 → 行级配对(partial)
|
||||||
const build = (withX: boolean): string => {
|
const build = (withX: boolean): string => {
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
@@ -576,7 +576,119 @@ describe('computeDiff - 字符级对比(分块锚定)', () => {
|
|||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
const result = computeDiff(build(false), build(true), { charMode: true })
|
const result = computeDiff(build(false), build(true), { charMode: true })
|
||||||
|
// 部分降级:不再整体降级
|
||||||
|
expect(result.charModeDowngraded).toBeUndefined()
|
||||||
|
expect(result.charModePartial).toBe(true)
|
||||||
|
// 8 个组各 1:1 行配对输出 modified:前 7 组字符级(segs 字符粒度),
|
||||||
|
// 第 8 组行级配对(超长行 > 5000 触发词级防护,segs 为 null,行仍 modified)
|
||||||
|
expect(result.summary.modified).toBe(8)
|
||||||
|
expect(result.summary.deleted).toBe(0)
|
||||||
|
expect(result.summary.inserted).toBe(0)
|
||||||
|
const mods = result.rows.filter((r) => r.rowKind === 'modified')
|
||||||
|
expect(mods).toHaveLength(8)
|
||||||
|
// 前 7 组字符级 segs 拼接无损;第 8 组词级防护 segs 为 null
|
||||||
|
const noSegs = mods.filter((m) => m.left.segs === null)
|
||||||
|
expect(noSegs).toHaveLength(1)
|
||||||
|
for (const m of mods) {
|
||||||
|
if (m.left.segs === null) continue
|
||||||
|
expect(joinSegs(m.left.segs)).toBe(m.left.text)
|
||||||
|
expect(joinSegs(m.right.segs)).toBe(m.right.text)
|
||||||
|
}
|
||||||
|
// 行号单调
|
||||||
|
const lNos = result.rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
|
||||||
|
const rNos = result.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('单组流超上限:仅该组行级降级,其余组字符级(partial)', () => {
|
||||||
|
// mid 锚行分隔两个变更组:组 1(cat↔cap)流小字符级;组 2 单行全差异 50002 > 上限 → 行级
|
||||||
|
const left = ['head', 'cat', 'mid', 'a'.repeat(25001)]
|
||||||
|
const right = ['head', 'cap', 'mid', 'b'.repeat(25001)]
|
||||||
|
const result = computeDiff(left.join('\n'), right.join('\n'), { charMode: true })
|
||||||
|
expect(result.charModeDowngraded).toBeUndefined()
|
||||||
|
expect(result.charModePartial).toBe(true)
|
||||||
|
// 组 1(cat↔cap)字符级精确定位:1 个 modified,字符粒度高亮
|
||||||
|
const cat = result.rows.find(
|
||||||
|
(r) => r.rowKind === 'modified' && r.left.text === 'cat'
|
||||||
|
)!
|
||||||
|
expect(cat.right.text).toBe('cap')
|
||||||
|
expect(cat.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('t'))).toBe(true)
|
||||||
|
expect(cat.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('p'))).toBe(true)
|
||||||
|
// 组 2 行级配对:a…a ↔ b…b 配 modified(25001 > 5000 触发词级防护,segs 为 null)
|
||||||
|
const big = result.rows.find(
|
||||||
|
(r) => r.rowKind === 'modified' && (r.left.text ?? '').startsWith('a')
|
||||||
|
)!
|
||||||
|
expect(big.right.text).toBe('b'.repeat(25001))
|
||||||
|
expect(big.left.segs).toBeNull()
|
||||||
|
// 锚行保持 unchanged(head 与 mid)
|
||||||
|
expect(result.rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('单组编辑距离超限:仅该组行级降级,其余组字符级(partial,预算未耗尽)', () => {
|
||||||
|
// charDiffMaxEdit 注入 10;mid 锚行分隔:组 1(cat↔cap)编辑距离 1 成功;
|
||||||
|
// 组 2(a*2000 ↔ b*2000)编辑距离 4000 超限 → 行级
|
||||||
|
const left = ['x', 'cat', 'mid', 'a'.repeat(2000)]
|
||||||
|
const right = ['x', 'cap', 'mid', 'b'.repeat(2000)]
|
||||||
|
const result = computeDiff(left.join('\n'), right.join('\n'), {
|
||||||
|
charMode: true,
|
||||||
|
charDiffMaxEdit: 10
|
||||||
|
})
|
||||||
|
expect(result.charModeDowngraded).toBeUndefined()
|
||||||
|
expect(result.charModePartial).toBe(true)
|
||||||
|
// 组 1 字符级完成(cat↔cap 1 个 modified)
|
||||||
|
const cat = result.rows.find((r) => r.rowKind === 'modified' && r.left.text === 'cat')!
|
||||||
|
expect(cat.right.text).toBe('cap')
|
||||||
|
expect(cat.left.segs!.some((s) => s.kind === 'delete')).toBe(true)
|
||||||
|
// 组 2 行级配对 modified(词级 segs)
|
||||||
|
const big = result.rows.find(
|
||||||
|
(r) => r.rowKind === 'modified' && (r.left.text ?? '').startsWith('a')
|
||||||
|
)!
|
||||||
|
expect(big.right.text).toBe('b'.repeat(2000))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('全部组超限:整体降级行级(charModeDowngraded,不出现 partial)', () => {
|
||||||
|
// 两个组都单行全差异超 5 万 → 零组完成字符级 → 整体降级
|
||||||
|
const half = 25001
|
||||||
|
const left = ['anchor', 'a'.repeat(half), 'mid', 'c'.repeat(half)]
|
||||||
|
const right = ['anchor', 'b'.repeat(half), 'mid', 'd'.repeat(half)]
|
||||||
|
const result = computeDiff(left.join('\n'), right.join('\n'), { charMode: true })
|
||||||
expect(result.charModeDowngraded).toBe(true)
|
expect(result.charModeDowngraded).toBe(true)
|
||||||
|
expect(result.charModePartial).toBeUndefined()
|
||||||
|
// 降级结果与等效行级选项直接计算一致
|
||||||
|
const direct = computeDiff(left.join('\n'), right.join('\n'), {
|
||||||
|
ignoreAllWhitespace: true,
|
||||||
|
ignoreBlankLines: true
|
||||||
|
})
|
||||||
|
expect(result.summary).toEqual(direct.summary)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('行级降级组内行数不等:相邻块按序配对,多余侧 added/removed', () => {
|
||||||
|
// mid 锚行分隔:组 1(small↔small)字符级保底;
|
||||||
|
// 组 2 左 [x*15000, only] 右 [y*15000, r1, r2]——组流 3 万在限内但编辑距离
|
||||||
|
// 超 5000(x…↔y… 全替换)→ 行级配对:x…↔y… 与 only↔r1 配 modified,r2 多余 added
|
||||||
|
const left = ['head', 'small-l', 'mid', 'x'.repeat(15000), 'only-left-line']
|
||||||
|
const right = ['head', 'small-r', 'mid', 'y'.repeat(15000), 'r-line-1', 'r-line-2']
|
||||||
|
const result = computeDiff(left.join('\n'), right.join('\n'), { charMode: true })
|
||||||
|
expect(result.charModeDowngraded).toBeUndefined()
|
||||||
|
expect(result.charModePartial).toBe(true)
|
||||||
|
const mods = result.rows.filter((r) => r.rowKind === 'modified')
|
||||||
|
// 组 1 small 配对 + 组 2 两对(x…↔y…、only↔r1)
|
||||||
|
expect(mods).toHaveLength(3)
|
||||||
|
expect(mods.some((m) => m.left.text === 'small-l' && m.right.text === 'small-r')).toBe(true)
|
||||||
|
expect(mods.some((m) => (m.left.text ?? '').startsWith('x'))).toBe(true)
|
||||||
|
expect(mods.some((m) => m.left.text === 'only-left-line' && m.right.text === 'r-line-1')).toBe(true)
|
||||||
|
// 多余右侧 r2 → added;无 removed
|
||||||
|
const added = result.rows.filter((r) => r.rowKind === 'added')
|
||||||
|
expect(added.map((r) => r.right.text)).toEqual(['r-line-2'])
|
||||||
|
expect(result.summary.deleted).toBe(0)
|
||||||
|
// 锚行 head/mid unchanged
|
||||||
|
expect(result.rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(2)
|
||||||
|
// 行号单调
|
||||||
|
const lNos = result.rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
|
||||||
|
const rNos = result.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('跨行重组落在变更组内:组内字符流判等,锚行 zip 输出', () => {
|
it('跨行重组落在变更组内:组内字符流判等,锚行 zip 输出', () => {
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ export interface DiffResult {
|
|||||||
summary: DiffSummary
|
summary: DiffSummary
|
||||||
/** 字符级对比因内容超限自动降级为行级对比时为 true(供界面与报告提示) */
|
/** 字符级对比因内容超限自动降级为行级对比时为 true(供界面与报告提示) */
|
||||||
charModeDowngraded?: boolean
|
charModeDowngraded?: boolean
|
||||||
|
/**
|
||||||
|
* 字符级对比部分降级:存在完成字符级计算的变更组,也有超限按行级配对的变更组
|
||||||
|
* (逐组预算制:单组超 CHAR_DIFF_MAX_CHARS / 编辑距离超限 / 总预算耗尽的组行级降级,
|
||||||
|
* 其余组仍字符级)。与 charModeDowngraded 互斥(全部组降级时只置后者)。
|
||||||
|
*/
|
||||||
|
charModePartial?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyLine(): SideCell {
|
function emptyLine(): SideCell {
|
||||||
@@ -309,8 +315,9 @@ export const CHAR_DIFF_MAX_CHARS = 50000
|
|||||||
export const CHAR_DIFF_MAX_EDIT = 5000
|
export const CHAR_DIFF_MAX_EDIT = 5000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全部变更组归一化流的总字符上限(两侧合计):兜底“组数多且总量大”的累计耗时
|
* 变更组字符级计算的总预算(全部成功组两侧归一化流合计):预算制约束累计耗时
|
||||||
* (每组最坏约 5 秒,2 组满载 ≈ 10 秒,在 worker 内计算不冻结界面)。
|
* (每组最坏约 5 秒,预算内组先到先得),耗尽后的组按行级配对输出(部分降级),
|
||||||
|
* 不再牵连其余组。全部组都降级时才整体降级行级。
|
||||||
*/
|
*/
|
||||||
export const CHAR_DIFF_GROUP_TOTAL_CHARS = CHAR_DIFF_MAX_CHARS * 2
|
export const CHAR_DIFF_GROUP_TOTAL_CHARS = CHAR_DIFF_MAX_CHARS * 2
|
||||||
|
|
||||||
@@ -406,10 +413,10 @@ function charSegs(text: string, stat: Map<number, number> | undefined): Seg[] {
|
|||||||
* 因此归一化流总量不再受单次 diffChars 上限约束(大文件字符级不再整篇受限)。
|
* 因此归一化流总量不再受单次 diffChars 上限约束(大文件字符级不再整篇受限)。
|
||||||
*
|
*
|
||||||
* 第二阶段:连续的 removed/added 行块合为一个“变更组”,组内左右行拼接为归一化
|
* 第二阶段:连续的 removed/added 行块合为一个“变更组”,组内左右行拼接为归一化
|
||||||
* 字符流送 diffChars(差异以字符粒度回映射到行)。约束:单个变更组流不超过
|
* 字符流送 diffChars(差异以字符粒度回映射到行)。逐组判定 + 预算制:组流不超过
|
||||||
* CHAR_DIFF_MAX_CHARS、编辑距离不超过 CHAR_DIFF_MAX_EDIT、全部变更组流总量不超过
|
* CHAR_DIFF_MAX_CHARS、编辑距离不超过 CHAR_DIFF_MAX_EDIT 且在总预算内的组走字符级;
|
||||||
* CHAR_DIFF_GROUP_TOTAL_CHARS——任一超限返回 null,由调用方降级行级(整篇重排或
|
* 超限/预算耗尽的组按行级配对输出(部分降级,置 charModePartial);全部组降级
|
||||||
* 大改场景,字符高亮本就失去可读性)。
|
* 才返回 null 由调用方整体降级(置 charModeDowngraded)。
|
||||||
*
|
*
|
||||||
* 无任何锚行时(两侧行归一化完全无交集)唯一变更组即全文,行为与整篇流对比同构。
|
* 无任何锚行时(两侧行归一化完全无交集)唯一变更组即全文,行为与整篇流对比同构。
|
||||||
*/
|
*/
|
||||||
@@ -469,7 +476,7 @@ function computeCharDiff(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 第二阶段:变更组字符流 diff(先全部预检,任一超限整体降级)=====
|
// ===== 第二阶段:变更组字符流 diff(逐组判定 + 预算制部分降级)=====
|
||||||
const streams = groups.map((g) => ({
|
const streams = groups.map((g) => ({
|
||||||
ls: groupStream(leftOrig, g.l, normCh),
|
ls: groupStream(leftOrig, g.l, normCh),
|
||||||
rs: groupStream(rightOrig, g.r, normCh),
|
rs: groupStream(rightOrig, g.r, normCh),
|
||||||
@@ -477,25 +484,37 @@ function computeCharDiff(
|
|||||||
gl: new Set(g.l.map((i) => i + 1)),
|
gl: new Set(g.l.map((i) => i + 1)),
|
||||||
gr: new Set(g.r.map((i) => i + 1))
|
gr: new Set(g.r.map((i) => i + 1))
|
||||||
}))
|
}))
|
||||||
let totalStream = 0
|
/**
|
||||||
|
* 逐组判定 + 预算制:组流 ≤ CHAR_DIFF_MAX_CHARS 且在剩余预算内、编辑距离 ≤ 上限的组
|
||||||
|
* 走 diffChars(字符级);单组超限 / 编辑距离超限 / 预算耗尽的组仅按行级配对输出
|
||||||
|
* (不再牵连其余组)。预算只被完成字符级计算的组消耗,最坏总耗时仍有界。
|
||||||
|
* 全部组都降级时返回 null 由调用方整体降级(等价旧行为)。
|
||||||
|
*/
|
||||||
|
const groupParts: Array<Change[] | null> = []
|
||||||
|
let budget = CHAR_DIFF_GROUP_TOTAL_CHARS
|
||||||
|
let anyChar = false
|
||||||
for (const { ls, rs } of streams) {
|
for (const { ls, rs } of streams) {
|
||||||
totalStream += ls.text.length + rs.text.length
|
const n = ls.text.length + rs.text.length
|
||||||
}
|
if (n <= CHAR_DIFF_MAX_CHARS && n <= budget) {
|
||||||
if (totalStream > CHAR_DIFF_GROUP_TOTAL_CHARS) return null
|
// maxEditLength 封顶 Myers 迭代轮数(编辑距离),防止 O(ND) 无界计算卡死;
|
||||||
const groupParts: Change[][] = []
|
// jsdiff 超限时运行时返回 undefined(类型签名未标注,真值判断兜底)
|
||||||
for (const { ls, rs } of streams) {
|
const parts = diffChars(ls.text, rs.text, {
|
||||||
if (ls.text.length + rs.text.length > CHAR_DIFF_MAX_CHARS) return null
|
maxEditLength: options.charDiffMaxEdit ?? CHAR_DIFF_MAX_EDIT
|
||||||
// maxEditLength 封顶 Myers 迭代轮数(编辑距离),防止 O(ND) 无界计算卡死;
|
})
|
||||||
// jsdiff 超限时运行时返回 undefined(类型签名未标注,真值判断兜底)
|
if (parts) {
|
||||||
const parts = diffChars(ls.text, rs.text, {
|
groupParts.push(parts)
|
||||||
maxEditLength: options.charDiffMaxEdit ?? CHAR_DIFF_MAX_EDIT
|
budget -= n
|
||||||
})
|
anyChar = true
|
||||||
if (!parts) return null
|
continue
|
||||||
groupParts.push(parts)
|
}
|
||||||
|
}
|
||||||
|
groupParts.push(null)
|
||||||
}
|
}
|
||||||
|
if (groups.length > 0 && !anyChar) return null
|
||||||
|
|
||||||
// ===== 按时间线输出:锚行 + 变更组回映射 =====
|
// ===== 按时间线输出:锚行 + 变更组回映射(字符级组 / 行级降级组分发)=====
|
||||||
const rows: DiffRow[] = []
|
const rows: DiffRow[] = []
|
||||||
|
const lineLevelGroups = new Set<number>()
|
||||||
for (const item of timeline) {
|
for (const item of timeline) {
|
||||||
if (item.kind === 'anchor') {
|
if (item.kind === 'anchor') {
|
||||||
rows.push({
|
rows.push({
|
||||||
@@ -507,7 +526,13 @@ function computeCharDiff(
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const { ls, rs, gl, gr } = streams[item.g]
|
const { ls, rs, gl, gr } = streams[item.g]
|
||||||
emitGroupRows(ls, rs, leftOrig, rightOrig, groupParts[item.g], rows, gl, gr)
|
const parts = groupParts[item.g]
|
||||||
|
if (parts) {
|
||||||
|
emitGroupRows(ls, rs, leftOrig, rightOrig, parts, rows, gl, gr)
|
||||||
|
} else {
|
||||||
|
lineLevelGroups.add(item.g)
|
||||||
|
emitGroupLineLevel(groups[item.g], leftOrig, rightOrig, normLine, rows)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,7 +543,128 @@ function computeCharDiff(
|
|||||||
modified: rows.filter((r) => r.rowKind === 'modified').length
|
modified: rows.filter((r) => r.rowKind === 'modified').length
|
||||||
}
|
}
|
||||||
|
|
||||||
return { rows, summary }
|
return {
|
||||||
|
rows,
|
||||||
|
summary,
|
||||||
|
// 存在行级降级组且也有字符级组:部分降级(供界面与报告提示)
|
||||||
|
...(lineLevelGroups.size > 0 ? { charModePartial: true } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行级降级组的输出:组内左右行以行归一化(字符级语义:剔除全部空白 + 可选小写)
|
||||||
|
* 做行级 LCS 配对——判等行配 unchanged;相邻删除/新增块配 modified(词级高亮,
|
||||||
|
* 与行级路径的 commitModified 同构,含超长行跳过词级防护);多余侧降级 removed/added。
|
||||||
|
* 供字符级对比的逐组预算制降级(超限组不再牵连其余组,也不整体降级)。
|
||||||
|
*/
|
||||||
|
function emitGroupLineLevel(
|
||||||
|
g: { l: number[]; r: number[] },
|
||||||
|
leftOrig: string[],
|
||||||
|
rightOrig: string[],
|
||||||
|
normLine: (s: string) => string,
|
||||||
|
rows: DiffRow[]
|
||||||
|
): void {
|
||||||
|
const leftLines = g.l.map((i) => ({ text: leftOrig[i], lineNo: i + 1 }))
|
||||||
|
const rightLines = g.r.map((i) => ({ text: rightOrig[i], lineNo: i + 1 }))
|
||||||
|
const parts = diffArrays(
|
||||||
|
leftLines.map((p) => normLine(p.text)),
|
||||||
|
rightLines.map((p) => normLine(p.text))
|
||||||
|
)
|
||||||
|
let li = 0
|
||||||
|
let ri = 0
|
||||||
|
let queuedRemoved: NumberedLine[] = []
|
||||||
|
|
||||||
|
const flushRemoved = (): void => {
|
||||||
|
for (const r of queuedRemoved) {
|
||||||
|
rows.push({
|
||||||
|
id: `r${rows.length}`,
|
||||||
|
rowKind: 'removed',
|
||||||
|
isChanged: true,
|
||||||
|
left: { lineNo: r.lineNo, text: r.text, segs: null },
|
||||||
|
right: emptyLine()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
queuedRemoved = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitModified = (addedRows: NumberedLine[]): void => {
|
||||||
|
const count = Math.max(queuedRemoved.length, addedRows.length)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const l = queuedRemoved[i]
|
||||||
|
const r = addedRows[i]
|
||||||
|
if (l && r) {
|
||||||
|
const skipWordDiff =
|
||||||
|
l.text.length > WORD_DIFF_MAX_LEN || r.text.length > WORD_DIFF_MAX_LEN
|
||||||
|
const { left: lSegs, right: rSegs } = skipWordDiff
|
||||||
|
? { left: null, right: null }
|
||||||
|
: wordSegments(l.text, r.text)
|
||||||
|
rows.push({
|
||||||
|
id: `r${rows.length}`,
|
||||||
|
rowKind: 'modified',
|
||||||
|
isChanged: true,
|
||||||
|
left: { lineNo: l.lineNo, text: l.text, segs: lSegs },
|
||||||
|
right: { lineNo: r.lineNo, text: r.text, segs: rSegs }
|
||||||
|
})
|
||||||
|
} else if (l) {
|
||||||
|
rows.push({
|
||||||
|
id: `r${rows.length}`,
|
||||||
|
rowKind: 'removed',
|
||||||
|
isChanged: true,
|
||||||
|
left: { lineNo: l.lineNo, text: l.text, segs: null },
|
||||||
|
right: emptyLine()
|
||||||
|
})
|
||||||
|
} else if (r) {
|
||||||
|
rows.push({
|
||||||
|
id: `r${rows.length}`,
|
||||||
|
rowKind: 'added',
|
||||||
|
isChanged: true,
|
||||||
|
left: emptyLine(),
|
||||||
|
right: { lineNo: r.lineNo, text: r.text, segs: null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queuedRemoved = []
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const value = part.value as string[]
|
||||||
|
if (part.removed) {
|
||||||
|
for (let i = 0; i < value.length; i++) queuedRemoved.push(leftLines[li + i])
|
||||||
|
li += value.length
|
||||||
|
} else if (part.added) {
|
||||||
|
const added: NumberedLine[] = []
|
||||||
|
for (let i = 0; i < value.length; i++) added.push(rightLines[ri + i])
|
||||||
|
ri += value.length
|
||||||
|
if (queuedRemoved.length > 0) commitModified(added)
|
||||||
|
else {
|
||||||
|
for (const r of added) {
|
||||||
|
rows.push({
|
||||||
|
id: `r${rows.length}`,
|
||||||
|
rowKind: 'added',
|
||||||
|
isChanged: true,
|
||||||
|
left: emptyLine(),
|
||||||
|
right: { lineNo: r.lineNo, text: r.text, segs: null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (queuedRemoved.length > 0) flushRemoved()
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const lp = leftLines[li + i]
|
||||||
|
const rp = rightLines[ri + i]
|
||||||
|
rows.push({
|
||||||
|
id: `r${rows.length}`,
|
||||||
|
rowKind: 'unchanged',
|
||||||
|
isChanged: false,
|
||||||
|
left: { lineNo: lp.lineNo, text: lp.text, segs: null },
|
||||||
|
right: { lineNo: rp.lineNo, text: rp.text, segs: null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
li += value.length
|
||||||
|
ri += value.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queuedRemoved.length > 0) flushRemoved()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -195,6 +195,17 @@ describe('plainOptions - 比较选项描述', () => {
|
|||||||
'字符级对比(忽略全部空白与换行,大内容自动分块计算)、忽略大小写'
|
'字符级对比(忽略全部空白与换行,大内容自动分块计算)、忽略大小写'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('字符级部分降级时描述标注部分块按行级对比', () => {
|
||||||
|
expect(plainOptions({ charMode: true }, { charModePartial: true })).toBe(
|
||||||
|
'字符级对比(忽略全部空白与换行;部分差异过大的块按行级对比)'
|
||||||
|
)
|
||||||
|
expect(plainOptions({ charMode: true, ignoreCase: true }, { charModePartial: true })).toBe(
|
||||||
|
'字符级对比(忽略全部空白与换行;部分差异过大的块按行级对比)、忽略大小写'
|
||||||
|
)
|
||||||
|
// 未开启字符级时 partial 参数无效果
|
||||||
|
expect(plainOptions({}, { charModePartial: true })).toBe('无(严格逐字符对比)')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('报告头部比较选项说明', () => {
|
describe('报告头部比较选项说明', () => {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface ReportContext {
|
|||||||
rightPath?: string
|
rightPath?: string
|
||||||
/** 本次对比生效的比较选项(用于报告头部说明,可选) */
|
/** 本次对比生效的比较选项(用于报告头部说明,可选) */
|
||||||
options?: DiffOptions
|
options?: DiffOptions
|
||||||
|
/** 字符级对比部分降级(部分超限块按行级配对,其余仍字符级;头部说明标注) */
|
||||||
|
charModePartial?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTML 转义(文件夹报告复用) */
|
/** HTML 转义(文件夹报告复用) */
|
||||||
@@ -120,11 +122,18 @@ export function plainSummary(summary: DiffSummary): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */
|
/** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */
|
||||||
export function plainOptions(options?: DiffOptions): string {
|
export function plainOptions(
|
||||||
|
options?: DiffOptions,
|
||||||
|
extra?: { charModePartial?: boolean }
|
||||||
|
): string {
|
||||||
// 字符级对比为最强空白语义(全部空白与换行都不参与判等),单独描述并遮蔽空白类子选项;
|
// 字符级对比为最强空白语义(全部空白与换行都不参与判等),单独描述并遮蔽空白类子选项;
|
||||||
// 大内容按“行锚定 + 变更组分块”计算,不再整篇受字符量上限约束
|
// 大内容按“行锚定 + 变更组分块”计算,不再整篇受字符量上限约束;
|
||||||
|
// 部分降级(逐组预算制)时标注部分差异过大的块按行级对比
|
||||||
if (options?.charMode) {
|
if (options?.charMode) {
|
||||||
const on = ['字符级对比(忽略全部空白与换行,大内容自动分块计算)']
|
const base = extra?.charModePartial
|
||||||
|
? '字符级对比(忽略全部空白与换行;部分差异过大的块按行级对比)'
|
||||||
|
: '字符级对比(忽略全部空白与换行,大内容自动分块计算)'
|
||||||
|
const on = [base]
|
||||||
if (options.ignoreCase) on.push('忽略大小写')
|
if (options.ignoreCase) on.push('忽略大小写')
|
||||||
return on.join('、')
|
return on.join('、')
|
||||||
}
|
}
|
||||||
@@ -149,7 +158,7 @@ const headLines = (
|
|||||||
ctx.rightPath ? ` [${ctx.rightPath}]` : ''
|
ctx.rightPath ? ` [${ctx.rightPath}]` : ''
|
||||||
}`,
|
}`,
|
||||||
`对比时间:${new Date().toLocaleString('zh-CN')}`,
|
`对比时间:${new Date().toLocaleString('zh-CN')}`,
|
||||||
`比较选项:${plainOptions(ctx.options)}`,
|
`比较选项:${plainOptions(ctx.options, { charModePartial: ctx.charModePartial })}`,
|
||||||
`结论:${plainSummary(summary)}`,
|
`结论:${plainSummary(summary)}`,
|
||||||
`变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}`
|
`变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}`
|
||||||
]
|
]
|
||||||
@@ -481,7 +490,7 @@ export function buildHtmlReport(
|
|||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div class="hero-title">🔍 DiffLens 差异报告</div>
|
<div class="hero-title">🔍 DiffLens 差异报告</div>
|
||||||
<div class="hero-time">生成于 ${new Date().toLocaleString('zh-CN')}</div>
|
<div class="hero-time">生成于 ${new Date().toLocaleString('zh-CN')}</div>
|
||||||
<div class="hero-opts">比较选项:${esc(plainOptions(ctx.options))}</div>
|
<div class="hero-opts">比较选项:${esc(plainOptions(ctx.options, { charModePartial: ctx.charModePartial }))}</div>
|
||||||
<div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainSummary(summary))}</div>
|
<div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainSummary(summary))}</div>
|
||||||
<div class="files">
|
<div class="files">
|
||||||
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)}
|
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)}
|
||||||
|
|||||||
@@ -640,6 +640,41 @@ button:disabled {
|
|||||||
bottom: calc(100% + 6px);
|
bottom: calc(100% + 6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ 扩展名过滤下拉(folder-toolbar 内,向下弹出) ============ */
|
||||||
|
.export-menu.ext-menu {
|
||||||
|
min-width: 190px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 激活(已选入过滤集合)的扩展名项:与状态徽章激活态同语言 */
|
||||||
|
.ctx-item.active {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 扩展名项内右侧的计数(等宽数字,弱化) */
|
||||||
|
.ctx-item .ext-count {
|
||||||
|
float: right;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-item.ext-clear {
|
||||||
|
color: var(--accent-2);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 过滤激活时的入口按钮强调(与常规 ghost 区分,提示过滤在生效) */
|
||||||
|
.btn.ghost.filtered {
|
||||||
|
color: var(--accent-2);
|
||||||
|
border-color: rgba(34, 211, 238, 0.4);
|
||||||
|
background: rgba(34, 211, 238, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ toast 队列(最多 3 条堆叠,各自独立计时) ============ */
|
/* ============ toast 队列(最多 3 条堆叠,各自独立计时) ============ */
|
||||||
.toast-wrap {
|
.toast-wrap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user