feat: 0.10.0 快照内容级对照、忽略规则高级 glob 与扫描进度反馈 - 快照v2可选携带差异条目双侧内容(folder:read-batch批量读取IPC受控并发、单文件2MB与总量32MB双限超限整体降级为纯状态快照保存永不失败、对照模式双击进入快照基线vs当前文件的内容对比完整复用单文件能力、v1旧快照兼容加载、contents解析防御绝不部分加载、保存期间读取遮罩)、忽略规则匹配器重写支持多段路径/前导/锚定/整段**跨段/段内*通配/!否定规则(最后匹配优先与gitignore一致、父目录剪枝后子项无法反否定、无效规则UI实时提示不再静默丢弃、纯逻辑迁src/shared两进程共用源码)、扫描进度推送(首个push型IPC、scanId复用扫描会话序号旧扫描迟到进度天然丢弃、遮罩显示已比对N/M)、新增33单测与4条E2E(596单测+28E2E全过覆盖率98.16/93.28/93.69/98.16),文档四件套同步
This commit is contained in:
@@ -11,8 +11,8 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
||||
|
||||
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
|
||||
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
|
||||
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**忽略规则**(gitignore 简化子集:`node_modules/` 目录整树剪枝、`*.log` 扩展名通配、`temp` 名字匹配,被忽略项不枚举不计数,规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫)
|
||||
- **快照基线**:把当前文件夹对比结果保存为快照(JSON 文件),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别
|
||||
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**扫描进度反馈**(比对阶段实时显示"已比对 N / M")、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**忽略规则**(gitignore 简化子集含高级 glob:`node_modules/` 目录整树剪枝、`*.log` 通配、`src/temp` 多段路径、`build/**/cache` 跨段、`!keep.log` 否定规则(最后匹配优先);无效规则实时提示不再静默丢弃;规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫)
|
||||
- **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么");内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照)
|
||||
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
|
||||
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览)
|
||||
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
|
||||
@@ -95,10 +95,11 @@ npm run build:linux # Linux AppImage / deb
|
||||
|
||||
```
|
||||
src/
|
||||
├── main/ # 主进程:窗口、菜单、文件/文件夹读取与编码探测(解码 worker 化)、IPC
|
||||
├── main/ # 主进程:窗口、菜单、文件/文件夹读取与编码探测(解码 worker 化)、批量读取与进度推送 IPC
|
||||
├── preload/ # 预加载:contextBridge 安全桥,仅暴露最小 API
|
||||
├── shared/ # 两进程共用的纯逻辑源码(忽略规则匹配器:主进程扫描剪枝 + 渲染端无效规则提示)
|
||||
└── renderer/ # 渲染进程:React 界面与全部 diff 逻辑
|
||||
├── diff/ # diff 引擎(行级 + 词级高亮、Worker 后台计算)
|
||||
├── diff/ # diff 引擎(行级 + 词级高亮、字符级分块、报告与快照纯逻辑)
|
||||
├── hooks/ # 状态 hooks(diff 计算调度、下拉关闭)
|
||||
├── components/ # 界面组件(DiffView / FolderView / Toolbar 等)
|
||||
└── styles/ # 全局主题样式
|
||||
@@ -115,7 +116,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
|
||||
- 作者:thzxx
|
||||
- 组织:MetonaTeam
|
||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||
- 版本:0.9.3
|
||||
- 版本:0.10.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-2
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch)
|
||||
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
||||
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
||||
|
||||
当前基线版本:**`0.9.3`**
|
||||
当前基线版本:**`0.10.0`**
|
||||
|
||||
---
|
||||
|
||||
@@ -117,9 +117,14 @@ z = 补丁版本号(Patch)
|
||||
- 微打磨与依赖:清理 CSS 孤儿变量(--pink/--panel-2 定义未消费) · 依赖 patch 级升级(npm update 语义化范围内,核心依赖 electron/vitest/vite 版本不变,仅间接依赖小幅更新)
|
||||
0.9.3 ← 单文件视图搜索 · 扫描缓存落盘串行化 · 交互收尾(已发布):
|
||||
- 单文件视图文本搜索(补齐文件夹模式有搜索而单文件模式没有的功能缺口):工具栏新增搜索框,Ctrl+F 聚焦(粘贴弹窗/右键菜单打开时不抢占)、大小写不敏感即时命中计数(x / y)、Enter/Shift+Enter 在命中行间循环跳转(复用差异导航的 activeRowId 受控定位,虚拟滚动下天然可用)、行内命中子串高亮(纯文本行与词级 segs 行均拆分标注,黄色系与增删改三色不冲突)、Esc 清空复位 · 搜索基于全量行(与"仅看差异"独立,被折叠的命中行仍计数;跳转目标被折叠时 toast 提示且不推进) · 线性扫描 5 万行毫秒级无需 worker,会话级临时状态不持久化
|
||||
- 扫描缓存落盘串行化:persistScanCache 读-改-写经内存 promise 链按序执行(每次追加链尾,失败静默不断链)——两次扫描近乎同时完成时不再后写整体覆盖先写丢 LRU 目录对
|
||||
- 扫描缓存落盘串行化:persistScanCache 读-改-写经内存 promise 链按序执行(每次追加到链尾,失败静默不断链)——两次扫描近乎同时完成时不再后写整体覆盖先写丢 LRU 目录对
|
||||
- 交互收尾:快照对照的变迁类型过滤在重扫刷新后计数归零时自动复位(残留过滤会让列表误显空态) · 「重新选择」扫描中保持可用并固化测试(与「返回文件对比」同类:发起新会话作废在途扫描,竞态守卫保证迟到结果丢弃;大目录扫描慢时允许用户放弃改选——曾考虑按防抖一致性禁用,经既有用例证伪后放弃)
|
||||
- 测试与依赖:新增 21 用例(App 8 / Toolbar 7 / DiffView 4 / FolderView 2)与 E2E 搜索链路用例 · 依赖 patch 级升级(npm update 语义化范围内)
|
||||
0.10.0 ← 快照内容级对照 · 忽略规则高级 glob · 扫描进度反馈(已发布):
|
||||
- 快照内容级对照(快照功能链收官):快照结构升级 v2 可选携带差异条目的双侧文件内容(contents),对照模式双击含基线内容的变迁条目进入"快照基线 vs 当前文件"的单文件内容对比(左面板为基线内容并标注"快照 · 文件名",右面板为当前文件,完整复用比较选项/字符级/搜索/差异导航/导出全部能力) · 保存链路经新增 folder:read-batch 批量读取 IPC(受控并发复用单文件读取管线,单文件失败该条目降级不整体失败) · 体积双限:单文件 2MB(对齐语义判等上限,超限侧不存内容)、内容总量 32MB(超出整体降级为纯状态快照并提示,保存永不失败) · v1 旧快照兼容加载(无 contents 走纯状态对照,双击维持常规当前文件对比) · 既定取舍:仅保存差异条目内容,regressed 场景(基线一致→现在有差异)的基线内容不保存 · 解析校验 contents 任一条目形态异常整体拒绝(绝不部分加载的既有口径) · 保存期间"正在读取差异文件内容"遮罩
|
||||
- 忽略规则高级 glob(匹配器重写):新增多段路径(src/temp 从根锚定)、前导 / 锚定(/build 仅根级,无前导单段名维持任意层级)、整段 ** 跨段通配(build/**/cache,尾部 ** 需至少一段与 gitignore 一致)、段内 * 通配放开位置限制、! 否定规则(同一路径最后一条匹配的规则生效,与 gitignore 语义一致;父目录被剪枝后子项无法反否定——BFS 剪枝语义与 gitignore 同款并文档化) · 匹配器从按名字匹配重构为按完整相对路径判定(matchesPath,含否定仲裁;预编译字面量/段正则/跨段三类段模式,DP 段序列匹配) · 无效规则不再静默丢弃:规则下拉实时提示"将被忽略的无效规则"(段内 ** / 空段 / ! 后为空等) · 纯逻辑模块迁移至 src/shared(主进程 folderScan 与渲染端 FolderView 共用同一源文件,消除双向同步负担;tsconfig 双端与 vitest include 同步扩展)
|
||||
- 扫描进度反馈(首个 push 型 IPC):folder:scan 增 scanId 参数(复用渲染端扫描会话序号),比对阶段每并发批次完成经 event.sender.send 推送进度(isDestroyed + try/catch 防御窗口销毁) · preload 暴露 onScanProgress 订阅(返回取消函数) · 渲染端只显示与当前会话序号一致的进度事件(旧扫描迟到进度天然丢弃,与结果竞态守卫同一套序号,不引入新竞态面) · 扫描遮罩显示"已比对 N / M"(缓存命中的零 IO 条目不计入 total,增量重扫进度即时完成符合语义)
|
||||
- 测试与文档:新增 33 单测(ignoreRules 30 全量重写 / folderScan glob 集成与 onProgress / snapshotDiff v2 / App 内容对照与进度 / FolderView 无效提示与进度)与 4 条 E2E(快照内容级对照全链路 / 高级 glob 含否定与无效提示 / 扫描进度轮询抓取),共 596 单测 + 28 E2E
|
||||
...
|
||||
0.y.z ← 长期停留,永不进入 1.x
|
||||
```
|
||||
|
||||
+24
-19
@@ -41,20 +41,21 @@
|
||||
## 4. 目录结构
|
||||
|
||||
```
|
||||
vitest.config.ts # Vitest 配置(jsdom + 测试范围 + 覆盖率 80% 硬门禁)
|
||||
vitest.config.ts # Vitest 配置(jsdom + 测试范围含 shared + 覆盖率 80% 硬门禁)
|
||||
playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑)
|
||||
e2e/
|
||||
├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口/合成拖放)
|
||||
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/拖拽导入/偏好重启)
|
||||
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照/拖拽导入/偏好重启)
|
||||
src/shared/
|
||||
├── ignoreRules.ts # 忽略规则匹配器纯逻辑(gitignore 简化子集含高级 glob;主进程扫描剪枝与渲染端无效提示共用,段模式预编译 + DP 段序列匹配 + 否定仲裁)
|
||||
└── ignoreRules.test.ts # 忽略规则单元测试(名字/目录/通配回归 + 多段/锚定/跨段/段内通配/否定仲裁/无效清单/上限/IPC 校验)
|
||||
src/main/
|
||||
├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,真实链路由 E2E 覆盖)
|
||||
├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,真实链路由 E2E 覆盖;含 folder:read-batch 批量读取与 folder:scan 进度推送)
|
||||
├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用)
|
||||
├── decode.test.ts # 解码单元测试(BOM 三种/严格 UTF-8/GBK 回退/二进制启发式)
|
||||
├── decodeWorker.ts # 解码 worker_threads 入口(覆盖率排除,逻辑由 decode 测试覆盖)
|
||||
├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对/语义判等/单点失败降级/忽略规则剪枝)
|
||||
├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似/语义判等/目录降级/unreadable/忽略规则)
|
||||
├── ignoreRules.ts # 忽略规则纯逻辑(gitignore 简化子集编译为 matcher,无 electron 依赖)
|
||||
├── ignoreRules.test.ts # 忽略规则单元测试(目录剪枝/通配/名字匹配/大小写/非法输入/条数上限/IPC 校验)
|
||||
├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对/语义判等/单点失败降级/忽略规则剪枝含高级 glob/比对进度回调)
|
||||
├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似/语义判等/目录降级/unreadable/忽略规则含 glob 与否定/进度回调)
|
||||
├── scanCacheStore.ts # 扫描缓存跨会话持久化纯数据(目录对键归一化/LRU/防御性解析)
|
||||
├── scanCacheStore.test.ts # 缓存存储单元测试(解析回退/条目过滤/键归一化/LRU 淘汰/序列化往返)
|
||||
├── scanIpc.ts # folder:scan IPC 入参校验与缓存源选择纯逻辑(无 electron 依赖)
|
||||
@@ -72,8 +73,8 @@ src/renderer/src/
|
||||
│ ├── report.test.ts # 单文件报告生成测试
|
||||
│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注;对照模式条目表替换为变迁对照表)
|
||||
│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable/快照变迁对照表)
|
||||
│ ├── snapshotDiff.ts # 快照基线纯逻辑(结构/解析校验/目录对归一化匹配/六类变迁对照/共享变迁标签映射)
|
||||
│ ├── snapshotDiff.test.ts # 快照单元测试(解析防御/变迁判定/双侧空集/unreadable 语义/目录对归一化匹配)
|
||||
│ ├── snapshotDiff.ts # 快照基线纯逻辑(v1/v2 结构/解析校验含 contents/目录对归一化匹配/六类变迁对照/共享变迁标签映射/内容保存上限常量)
|
||||
│ ├── snapshotDiff.test.ts # 快照单元测试(解析防御/v1 v2 兼容/contents 形态校验/变迁判定/双侧空集/unreadable 语义/目录对归一化匹配)
|
||||
│ └── textUtils.test.ts # 文本扩展名与行数统计测试
|
||||
├── hooks/
|
||||
│ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理/字符级大字符量重路径/选项变化保留旧结果防滚动塌陷)
|
||||
@@ -86,11 +87,11 @@ src/renderer/src/
|
||||
│ ├── ErrorBoundary.test.tsx # 渲染错误兜底测试(抛错子组件 fallback/正常子组件透传)
|
||||
│ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤含扩展名/子树收集)
|
||||
│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt)
|
||||
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉/快照对照模式含变迁过滤归零复位/目录双击/重新扫描与重新选择/双击/导出菜单/虚拟滚动/resize 重算)
|
||||
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉含无效规则提示与进度遮罩/快照对照模式含变迁过滤归零复位与内容对照 title/目录双击/重新扫描与重新选择/双击/导出菜单/虚拟滚动/resize 重算)
|
||||
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
|
||||
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记/搜索框计数与 Enter·Shift+Enter·Esc 键盘交互/ref 转发)
|
||||
└── __tests__/
|
||||
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索/Ctrl+F/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截)
|
||||
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索/Ctrl+F/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截/快照内容级对照(v2 保存含内容·超限降级·双击基线vs当前·v1 兼容)/扫描进度显示与过期丢弃)
|
||||
└── main.test.ts # 入口挂载冒烟测试
|
||||
```
|
||||
|
||||
@@ -147,7 +148,7 @@ src/renderer/src/
|
||||
- 单文件视图搜索(0.9.3):输入关键字即时命中计数与首个命中行 active 定位 · Enter/Shift+Enter 命中行间推进回退(行号随动)· 首尾循环 · 大小写不敏感 · 无命中 0/0 且 Enter 不崩溃不推进 · Esc 清空复位 · Ctrl+F 聚焦搜索框 · 命中行被“仅看差异”折叠时计数仍统计全量行、跳转 toast 提示且不推进
|
||||
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
|
||||
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式
|
||||
- 快照基线(0.9.0,0.9.1 收尾,0.9.2 对称防呆):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 当前扫描结果截断时加载快照同样被拦截(0.9.2,不弹文件选择——截断缺失文件会被误报已移除) · 对照模式中重扫触发截断自动退出对照并提示(0.9.2) · 快照语义判等口径与当前开关不一致时加载提示含口径差异、一致时无提示(0.9.2) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新
|
||||
- 快照基线(0.9.0,0.9.1 收尾,0.9.2 对称防呆,0.10.0 内容级对照):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 当前扫描结果截断时加载快照同样被拦截(0.9.2,不弹文件选择——截断缺失文件会被误报已移除) · 对照模式中重扫触发截断自动退出对照并提示(0.9.2) · 快照语义判等口径与当前开关不一致时加载提示含口径差异、一致时无提示(0.9.2) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新 · 快照内容级对照(0.10.0):保存时差异条目双侧内容入 contents(批量读取按路径返回、same 条目不存、单文件超限侧不读该侧 null)/ 内容总量超限整体降级为纯状态快照并提示(vi.mock 压低上限构造)/ 对照模式双击含基线内容条目进入"快照基线 vs 当前"内容对比(左面板快照基线标注、右面板当前文件、有差异徽章)/ v1 快照双击走常规当前文件对比(兼容回归锚)
|
||||
|
||||
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`)
|
||||
- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致
|
||||
@@ -177,15 +178,17 @@ src/renderer/src/
|
||||
- 边界与选项:两个空目录 / maxFiles 截断(truncated + total + 条目数受限;截断后不再深入子目录,total 为已遍历下限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different
|
||||
- 受控并发:跨多个并发批次(> 2×SCAN_CONCURRENCY)的混合状态全部判定正确、条目排序与并发执行顺序无关
|
||||
- 增量重扫(0.8.0,previous 缓存):指纹一致(size+mtime 未变)零 IO 沿用判定(decode 计数零增长)/ 语义开关翻转按 semSame 纯内存组合(different ↔ semantic-same 零 IO,开→关→开三轮)/ 上次未参与语义(semSame null)而本次开启读内容补判 / 指纹变化(内容修改)重新判定且文件增删正确 / 单侧缺失形状变化不误沿用 / 本次截断忽略 previous 且 cache 产出为空 / unreadable 不进缓存(故障恢复后重扫正确判定)
|
||||
- 忽略规则(0.9.0):目录规则整树剪枝(深层子树不枚举、不计数、不进结果与缓存)/ 扩展名通配与名字匹配跳过文件(大小写不敏感)/ 被忽略文件不占用 maxFiles(原本截断的场景忽略后完整扫描)/ 规则变化后重扫缓存未命中的重新纳入条目走全量判定 / 空规则数组与全非法规则不产生任何剪枝
|
||||
- 忽略规则(0.9.0,0.10.0 高级 glob):目录规则整树剪枝(深层子树不枚举、不计数、不进结果与缓存)/ 扩展名通配与名字匹配跳过文件(大小写不敏感)/ 被忽略文件不占用 maxFiles(原本截断的场景忽略后完整扫描)/ 规则变化后重扫缓存未命中的重新纳入条目走全量判定 / 空规则数组与全非法规则不产生任何剪枝 / 多段路径从根锚定剪枝(其他分支同名目录不受影响)/ ** 跨段剪枝与否定救回(父目录未被剪枝时子路径可被反否定)/ 父目录被剪枝后否定规则救不回子项(gitignore 同款语义)
|
||||
- 比对进度回调(0.10.0):需读内容的条目按批推进进度(total 恒为需读内容条目数、done 单调递增至 total)/ 全部条目零 IO(单侧缺失)时不触发回调
|
||||
|
||||
### 5.10 文件夹视图(`FolderView.tsx`)与条目树(`folderTree.ts`)
|
||||
- 条目树纯逻辑:按路径分段聚合目录/文件(同层字典序);目录聚合子树状态(different > unreadable > only > 语义同 > 相同)与文件计数;折叠目录子树隐藏;过滤(onlyDiff / statusFilter / query 子串不区分大小写 / exts 扩展名集合);空目录剪枝由“过滤后建树”天然保证;relExt 取文件名段扩展名(小写、无扩展名空串、不误取目录名中的点)
|
||||
- 渲染:目录路径卡片 / 重新扫描、重新选择、导出报告与返回按钮 / 统计徽章(相同/语义同/不同/仅左/仅右/无法读取/总数;语义关闭隐藏语义同、无失败隐藏无法读取)
|
||||
- 树形交互:目录行点击折叠展开(默认全展开);搜索命中强制展开命中路径、清空恢复用户折叠;无命中提示
|
||||
- 扩展名过滤(0.8.0):下拉展示全量条目 ext→count(字典序、无扩展名单列);点选过滤与其他条件取交集、计数切 N / 总数;多选并集、再点取消、清除按钮复位;与状态过滤双激活空态提示;点击外部关闭且过滤状态保留
|
||||
- 忽略规则(0.9.0):已有规则时入口标注数量并高亮;下拉打开以当前规则初始化草稿,逐行解析去空白去空行(截断至 50 条)后应用回调并关闭;清空规则回调空数组;点击外部关闭不触发回调
|
||||
- 快照对照模式(0.9.0):变迁统计徽章(已修复/新增差异/新文件/已移除/仍有差异/保持一致)与基线时间替换常规统计行;对照条目平铺全路径渲染、行尾变迁徽章、gone 回退快照数据;零计数变迁不占位;默认隐藏 stable、点击徽章单选过滤再点取消;双击当前条目进单文件对比、已移除条目双击无效;退出对照恢复常规视图;全 stable 空态提示;重扫刷新后过滤的变迁类型计数归零自动复位(0.9.3,残留过滤不再误显空态)
|
||||
- 忽略规则(0.9.0,0.10.0 高级 glob 与无效提示):已有规则时入口标注数量并高亮;下拉打开以当前规则初始化草稿,逐行解析去空白去空行(截断至 50 条)后应用回调并关闭;清空规则回调空数组;点击外部关闭不触发回调;无效规则实时提示"将被忽略的无效规则"(修正/清空后提示消失,不再静默丢弃)
|
||||
- 快照对照模式(0.9.0):变迁统计徽章(已修复/新增差异/新文件/已移除/仍有差异/保持一致)与基线时间替换常规统计行;对照条目平铺全路径渲染、行尾变迁徽章、gone 回退快照数据;零计数变迁不占位;默认隐藏 stable、点击徽章单选过滤再点取消;双击当前条目进单文件对比、已移除条目双击无效;退出对照恢复常规视图;全 stable 空态提示;重扫刷新后过滤的变迁类型计数归零自动复位(0.9.3,残留过滤不再误显空态);对照条目含基线内容时 title 提示"双击查看快照基线内容对照"(0.10.0)
|
||||
- 扫描进度遮罩(0.10.0):progress props 非空时遮罩显示"已比对 N / M",null 时仅基础文案
|
||||
- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动)
|
||||
- 扫描中交互防抖(0.9.2):扫描进行中语义判等开关与忽略规则入口禁用(触发重扫的入口,点击不触发回调/不打开下拉);仅看差异开关与搜索仍可用(纯视图过滤不触发重扫);「重新选择」扫描中保持可用(0.9.3 固化:发起新会话作废在途扫描,与「返回文件对比」同类)
|
||||
- 目录行双击(0.8.0):触发 onOpenDir 携带目录相对路径;双击前折叠态自动展开(toggle 残留消除);重新扫描按钮触发 onRescan 且扫描中禁用
|
||||
@@ -206,9 +209,10 @@ src/renderer/src/
|
||||
- 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目)
|
||||
- 增量重扫(0.8.0):真实目录修改文件后点「重新扫描」状态正确更新(相同→不同→改回相同,真实 mtime 变化走指纹判定)
|
||||
- 拖拽导入(0.8.0):dropFiles 合成 DataTransfer 拖放——空面板拖入渲染(arrayBuffer → decodeBuffer IPC 全链路)、双侧拖入对比、非文本扩展名拒绝、多文件提示取首个
|
||||
- 忽略规则(0.9.0):真实目录含 node_modules 子树——无规则时差异参与对比,输入规则应用重扫后整树剪枝(不同归零、空态一致提示、被忽略条目消失)
|
||||
- 忽略规则(0.9.0,0.10.0 高级 glob):真实目录含 node_modules 子树——无规则时差异参与对比,输入规则应用重扫后整树剪枝(不同归零、空态一致提示、被忽略条目消失);高级 glob——build 下深层 temp 目录经 build/**/temp 跨段剪枝、*.log 忽略 + !keep.log 否定救回(差异计数精确收敛)、无效规则提示出现
|
||||
- 扫描进度反馈(0.10.0):2000 条目目录扫描期间遮罩"已比对 N / 2000"轮询抓取(瞬态断言),完成后全量判定与遮罩消失
|
||||
- 增量缓存跨会话持久化(0.9.0):第一实例扫描后 userData/scan-cache.json 落盘(等待异步写盘,断言含条目与合法结构)→ 同 userData 第二实例重扫同目录结果正确(主进程注入盘上缓存)
|
||||
- 快照基线(0.9.0,0.9.1 增强):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复
|
||||
- 快照基线(0.9.0,0.9.1 增强,0.10.0 内容级对照):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章)
|
||||
- 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s;300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归)
|
||||
- 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData)
|
||||
- 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理)
|
||||
@@ -259,8 +263,9 @@ npm run build
|
||||
|
||||
## 9. 演进方向(后续迭代)
|
||||
|
||||
- 文件夹对比:快照 vs 当前的逐行文本对照(快照保存内容或按需读盘,对照视图内差异明细) · 忽略规则高级 glob(`**` 跨段、否定规则 `!`)
|
||||
- 主进程 IPC 并发场景集成测试(入参校验与缓存注入逻辑已于 0.9.1 单测化:scanIpc)
|
||||
- 快照体验:regressed 场景基线内容保存策略(当前仅保存差异条目,基线一致的条目内容不存——需全量保存的体积代价权衡) · 快照内差异内容的报告导出
|
||||
- 忽略规则:gitignore 更完整子集评估(`?` 单字符、字符类、前导 `!` 组合语义等,按真实需求驱动)
|
||||
- 主进程 IPC 并发场景集成测试(入参校验与缓存注入逻辑已于 0.9.1 单测化:scanIpc;缓存落盘串行化于 0.9.3 修复)
|
||||
- mac 构建实际验证(entitlements 配置就绪但从未打包跑通)
|
||||
|
||||
---
|
||||
|
||||
+136
-2
@@ -447,6 +447,86 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('忽略规则高级 glob:** 跨段剪枝、否定规则救回与无效规则提示', async () => {
|
||||
const root = await makeTmpDir('difflens-e2e-glob-')
|
||||
try {
|
||||
const leftDir = join(root, 'left')
|
||||
const rightDir = join(root, 'right')
|
||||
const { mkdir, writeFile: wf } = await import('fs/promises')
|
||||
// build 下深层 temp 目录(** 跨段目标)+ 保留文件 + log 类差异文件
|
||||
for (const d of [leftDir, rightDir]) {
|
||||
await mkdir(join(d, 'build', 'a', 'temp'), { recursive: true })
|
||||
await mkdir(join(d, 'build', 'b', 'deep', 'temp'), { recursive: true })
|
||||
await wf(join(d, 'build', 'a', 'temp', 't1.txt'), 'L')
|
||||
await wf(join(d, 'build', 'b', 'deep', 'temp', 't2.txt'), 'L')
|
||||
await wf(join(d, 'build', 'keep.txt'), 'L')
|
||||
await wf(join(d, 'app.log'), 'L')
|
||||
await wf(join(d, 'keep.log'), 'L')
|
||||
}
|
||||
await wf(join(rightDir, 'build', 'a', 'temp', 't1.txt'), 'R')
|
||||
await wf(join(rightDir, 'build', 'b', 'deep', 'temp', 't2.txt'), 'R')
|
||||
await wf(join(rightDir, 'build', 'keep.txt'), 'R')
|
||||
await wf(join(rightDir, 'app.log'), 'R')
|
||||
await wf(join(rightDir, 'keep.log'), 'R')
|
||||
|
||||
const { app, window } = await launchApp()
|
||||
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
|
||||
await window.getByRole('button', { name: '对比文件夹' }).click()
|
||||
await expect(window.getByText('不同 5')).toBeVisible()
|
||||
|
||||
await window.getByRole('button', { name: '忽略规则' }).click()
|
||||
// 先输入含无效规则的草稿:提示出现(不再静默丢弃)
|
||||
await window.getByPlaceholder(/每行一条/).fill('a**b')
|
||||
await expect(window.getByText(/将被忽略的无效规则:a\*\*b/)).toBeVisible()
|
||||
// 替换为正式规则:build 下任意深度 temp 剪枝 + *.log 忽略 + keep.log 否定救回
|
||||
await window
|
||||
.getByPlaceholder(/每行一条/)
|
||||
.fill('build/**/temp\n*.log\n!keep.log')
|
||||
await window.getByText('应用并重扫').click()
|
||||
// t1/t2 被剪(** 跨段)、app.log 被忽略、keep.log 被否定救回 → 仅剩 keep.txt 与 keep.log 差异
|
||||
await expect(window.getByText('不同 2')).toBeVisible()
|
||||
await expect(window.getByText('t1.txt')).toHaveCount(0)
|
||||
await expect(window.getByText('keep.log')).toBeVisible()
|
||||
await app.close()
|
||||
} finally {
|
||||
await removeTmpDir(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('扫描进度反馈:大目录比对期间遮罩显示已比对进度', async () => {
|
||||
const root = await makeTmpDir('difflens-e2e-progress-')
|
||||
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 })
|
||||
// 2000 个大小一致条目需读内容比对(125 个并发批次),进度事件可见窗口足够
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const name = `f${String(i).padStart(4, '0')}.txt`
|
||||
await wf(join(leftDir, name), `L${i}`)
|
||||
await wf(join(rightDir, name), `R${i}`)
|
||||
}
|
||||
|
||||
const { app, window } = await launchApp()
|
||||
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
|
||||
await window.getByRole('button', { name: '对比文件夹' }).click()
|
||||
// 瞬态进度文案:轮询抓取扫描期间的遮罩文本(完成后遮罩消失)
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
/已比对 \d+ \/ 2000/.test((await window.locator('.computing-overlay').textContent()) ?? '')
|
||||
)
|
||||
.toBe(true)
|
||||
// 扫描完成:全量判定与遮罩消失
|
||||
await expect(window.getByText('不同 2000')).toBeVisible()
|
||||
await expect(window.locator('.computing-overlay')).toHaveCount(0)
|
||||
await app.close()
|
||||
} finally {
|
||||
await removeTmpDir(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('增量缓存跨会话持久化:落盘文件存在且重启后重扫正确', async () => {
|
||||
const root = await makeTmpDir('difflens-e2e-cachestore-')
|
||||
try {
|
||||
@@ -515,14 +595,16 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
|
||||
await expect(window.getByText('相同 1')).toBeVisible()
|
||||
await expect(window.getByText('不同 1')).toBeVisible()
|
||||
|
||||
// 保存快照(真实写盘)
|
||||
// 保存快照(真实写盘):v2 结构含差异条目双侧内容(0.10.0 内容级对照)
|
||||
const snapPath = join(root, 'baseline.json')
|
||||
await stubSaveDialog(app, snapPath)
|
||||
await window.getByRole('button', { name: '保存快照' }).click()
|
||||
await expect(window.getByText(/快照已保存/)).toBeVisible()
|
||||
const snapRaw = await readFile(snapPath, 'utf-8')
|
||||
expect(snapRaw).toContain('"version": 1')
|
||||
expect(snapRaw).toContain('"version": 2')
|
||||
expect(snapRaw).toContain('diff.txt')
|
||||
expect(snapRaw).toContain('left v1')
|
||||
expect(snapRaw).toContain('right v1')
|
||||
|
||||
// 修复 diff.txt(两侧一致)+ 新增 new.txt → 重扫
|
||||
await wf(join(rightDir, 'diff.txt'), 'left v1')
|
||||
@@ -559,6 +641,58 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
|
||||
await removeTmpDir(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('快照内容级对照:保存含内容快照 → 修改文件 → 对照双击查看基线 vs 当前', async () => {
|
||||
const root = await makeTmpDir('difflens-e2e-snapcontent-')
|
||||
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'), 'snapshot baseline value A')
|
||||
await wf(join(rightDir, 'diff.txt'), 'snapshot baseline value B')
|
||||
|
||||
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()
|
||||
|
||||
// 保存快照(v2):差异条目双侧内容写入 JSON
|
||||
const snapPath = join(root, 'baseline.json')
|
||||
await stubSaveDialog(app, snapPath)
|
||||
await window.getByRole('button', { name: '保存快照' }).click()
|
||||
await expect(window.getByText(/快照已保存/)).toBeVisible()
|
||||
const snapRaw = await readFile(snapPath, 'utf-8')
|
||||
expect(snapRaw).toContain('"version": 2')
|
||||
expect(snapRaw).toContain('snapshot baseline value A')
|
||||
expect(snapRaw).toContain('snapshot baseline value B')
|
||||
|
||||
// 修改左侧 diff.txt → 重扫(相对快照基线出现内容变化)
|
||||
await wf(join(leftDir, 'diff.txt'), 'current changed value A')
|
||||
await window.getByRole('button', { name: '重新扫描' }).click()
|
||||
await expect(window.getByText('不同 1')).toBeVisible()
|
||||
|
||||
// 加载快照对照 → 双击 diff.txt 进入内容对照(左=快照基线,右=当前左侧文件)
|
||||
await stubOpenDialogSeq(app, [[snapPath]])
|
||||
await window.getByRole('button', { name: '加载快照' }).click()
|
||||
await expect(window.getByText(/已加载快照/)).toBeVisible()
|
||||
await window.getByText('diff.txt').dblclick()
|
||||
await expect(
|
||||
window.locator('.pane-file').filter({ hasText: '快照 · diff.txt' })
|
||||
).toBeVisible()
|
||||
// 左面板渲染快照基线旧词、右面板渲染当前新词(词级高亮拆分为独立段)
|
||||
await expect(window.getByText('snapshot', { exact: true })).toBeVisible()
|
||||
await expect(window.getByText('current', { exact: true })).toBeVisible()
|
||||
await expect(window.getByText('有差异')).toBeVisible()
|
||||
await app.close()
|
||||
} finally {
|
||||
await removeTmpDir(root)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('拖拽导入(真实 Chromium DataTransfer 链路)', () => {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.9.3",
|
||||
"version": "0.10.0",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
+105
-1
@@ -675,6 +675,65 @@ describe('scanFolders - 忽略规则', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('多段路径从根锚定剪枝:仅命中该路径(其他分支同名目录不受影响)', async () => {
|
||||
const l = await mkdtemp(join(tmpdir(), 'difflens-ign6-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-ign6-r-'))
|
||||
try {
|
||||
for (const d of [l, r]) {
|
||||
await mkdir(join(d, 'src', 'temp'), { recursive: true })
|
||||
await mkdir(join(d, 'lib', 'temp'), { recursive: true })
|
||||
await writeFile(join(d, 'src', 'temp', 'a.txt'), 'a')
|
||||
await writeFile(join(d, 'lib', 'temp', 'b.txt'), 'b')
|
||||
await writeFile(join(d, 'src', 'keep.txt'), 'k')
|
||||
}
|
||||
const res = await scanFolders(l, r, { ignore: ['src/temp'] })
|
||||
// src/temp 剪枝;lib/temp 同名目录在其他分支不受影响;被忽略文件不计入 total
|
||||
expect(res.entries.map((e) => e.rel)).toEqual(['lib/temp/b.txt', 'src/keep.txt'])
|
||||
expect(res.total).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-ign7-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-ign7-r-'))
|
||||
try {
|
||||
for (const d of [l, r]) {
|
||||
await mkdir(join(d, 'build', 'a', 'temp'), { recursive: true })
|
||||
await mkdir(join(d, 'build', 'special', 'temp'), { recursive: true })
|
||||
await writeFile(join(d, 'build', 'a', 'temp', 'd1.txt'), 'x')
|
||||
await writeFile(join(d, 'build', 'special', 'temp', 'd2.txt'), 'y')
|
||||
await writeFile(join(d, 'keep.txt'), 'k')
|
||||
}
|
||||
// build/a/temp 剪枝;build/special/temp 被否定救回(build 与 special 未被剪枝,BFS 正常下钻)
|
||||
const res = await scanFolders(l, r, { ignore: ['build/**/temp/', '!build/special/temp/'] })
|
||||
expect(res.entries.map((e) => e.rel)).toEqual(['build/special/temp/d2.txt', 'keep.txt'])
|
||||
} finally {
|
||||
await rm(l, { recursive: true, force: true })
|
||||
await rm(r, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('父目录被剪枝后否定规则救不回子项(gitignore 同款语义)', async () => {
|
||||
const l = await mkdtemp(join(tmpdir(), 'difflens-ign8-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-ign8-r-'))
|
||||
try {
|
||||
for (const d of [l, r]) {
|
||||
await mkdir(join(d, 'node_modules', 'pkg'), { recursive: true })
|
||||
await writeFile(join(d, 'node_modules', 'pkg', 'keep.js'), 'k')
|
||||
await writeFile(join(d, 'app.js'), 'a')
|
||||
}
|
||||
// node_modules/ 目录命中即整树剪枝,!node_modules/pkg/keep.js 无法救回
|
||||
const res = await scanFolders(l, r, { ignore: ['node_modules/', '!node_modules/pkg/keep.js'] })
|
||||
expect(res.entries.map((e) => e.rel)).toEqual(['app.js'])
|
||||
} finally {
|
||||
await rm(l, { recursive: true, force: true })
|
||||
await rm(r, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('空规则数组与全非法规则不产生任何剪枝(行为与无规则一致)', async () => {
|
||||
const l = await mkdtemp(join(tmpdir(), 'difflens-ign5-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-ign5-r-'))
|
||||
@@ -686,7 +745,8 @@ describe('scanFolders - 忽略规则', () => {
|
||||
}
|
||||
const empty = await scanFolders(l, r, { ignore: [] })
|
||||
expect(empty.entries).toHaveLength(2)
|
||||
const invalid = await scanFolders(l, r, { ignore: ['# note', 'a/b', '**'] })
|
||||
// 全部为非法规则(空段 / ! 后为空 / 段内 **):不产生任何剪枝
|
||||
const invalid = await scanFolders(l, r, { ignore: ['# note', 'a//b', '!', 'a**b'] })
|
||||
expect(invalid.entries).toHaveLength(2)
|
||||
} finally {
|
||||
await rm(l, { recursive: true, force: true })
|
||||
@@ -694,3 +754,47 @@ describe('scanFolders - 忽略规则', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanFolders - 比对进度回调(onProgress)', () => {
|
||||
it('需读内容的条目按批推进进度:done 单调递增至 total', async () => {
|
||||
const l = await mkdtemp(join(tmpdir(), 'difflens-prog-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-prog-r-'))
|
||||
try {
|
||||
// 40 个大小一致条目(需读内容比对)+ 2 个单侧(无 IO)→ tasks = 40,3 个批次
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await writeFile(join(l, `f${String(i).padStart(2, '0')}.txt`), `c${i}`)
|
||||
await writeFile(join(r, `f${String(i).padStart(2, '0')}.txt`), `c${i}`)
|
||||
}
|
||||
await writeFile(join(l, 'only-l.txt'), 'L')
|
||||
const events: Array<{ done: number; total: number }> = []
|
||||
const res = await scanFolders(l, r, {
|
||||
onProgress: (done, total) => events.push({ done, total })
|
||||
})
|
||||
expect(res.entries).toHaveLength(41)
|
||||
expect(events.length).toBeGreaterThan(0)
|
||||
// total 恒为需读内容的条目总数;done 单调递增且最终到达 total
|
||||
expect(events.every((e) => e.total === 40)).toBe(true)
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
expect(events[i].done).toBeGreaterThan(events[i - 1].done)
|
||||
}
|
||||
expect(events[events.length - 1].done).toBe(40)
|
||||
} finally {
|
||||
await rm(l, { recursive: true, force: true })
|
||||
await rm(r, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('全部条目零 IO(缓存命中/单侧缺失)时不触发进度回调', async () => {
|
||||
const l = await mkdtemp(join(tmpdir(), 'difflens-prog2-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-prog2-r-'))
|
||||
try {
|
||||
await writeFile(join(l, 'only-l.txt'), 'L')
|
||||
const events: Array<{ done: number; total: number }> = []
|
||||
await scanFolders(l, r, { onProgress: (done, total) => events.push({ done, total }) })
|
||||
expect(events).toHaveLength(0)
|
||||
} finally {
|
||||
await rm(l, { recursive: true, force: true })
|
||||
await rm(r, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+26
-10
@@ -1,6 +1,6 @@
|
||||
import { readdir, stat, readFile, open } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { compileIgnoreRules, type IgnoreMatcher } from './ignoreRules'
|
||||
import { compileIgnoreRules, type IgnoreMatcher } from '../shared/ignoreRules'
|
||||
|
||||
/**
|
||||
* 文件夹对比的纯逻辑模块(仅依赖 node:fs,vitest 以真实临时目录直接测试)。
|
||||
@@ -74,11 +74,17 @@ export interface ScanOptions {
|
||||
/** 增量重扫缓存(可选):上次扫描返回的 cache,指纹一致的条目零 IO 沿用判定 */
|
||||
previous?: ScanCacheEntry[]
|
||||
/**
|
||||
* 忽略规则(gitignore 简化子集,可选):命中的目录整树剪枝(不枚举不计数,
|
||||
* 被忽略项不占用 maxFiles 也不计入 total/truncated)、命中的文件跳过。
|
||||
* 忽略规则(gitignore 简化子集含高级 glob,可选):按完整相对路径判定——
|
||||
* 命中的目录整树剪枝(不枚举不计数,被忽略项不占用 maxFiles 也不计入 total/truncated)、
|
||||
* 命中的文件跳过。支持多段路径 / `**` 跨段 / 段内 `*` 通配 / `!` 否定(最后匹配优先)。
|
||||
* 规则变化的缓存失效由指纹机制天然覆盖:未命中条目走全量判定。
|
||||
*/
|
||||
ignore?: string[]
|
||||
/**
|
||||
* 比对进度回调(可选):需读内容的条目按受控并发分批比对,每批完成后触发一次;
|
||||
* done 为已完成条目数,total 为需读内容的条目总数(缓存命中的零 IO 条目不计入)。
|
||||
*/
|
||||
onProgress?: (done: number, total: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,17 +152,19 @@ async function listFiles(
|
||||
for (const d of dirents) {
|
||||
// Dirent 为 lstat 语义:symlink 既非 file 也非 directory,天然跳过(防环)
|
||||
if (d.isDirectory()) {
|
||||
// 忽略规则命中目录:整树剪枝(不入队不计数,被忽略子树完全不枚举)
|
||||
if (ignore?.matchesDir(d.name)) continue
|
||||
queue.push({ dir: join(dir, d.name), prefix: prefix === '' ? d.name : `${prefix}/${d.name}` })
|
||||
// 忽略规则按完整相对路径判定(支持多段/**/否定):命中目录整树剪枝
|
||||
const rel = prefix === '' ? d.name : `${prefix}/${d.name}`
|
||||
if (ignore?.matchesPath(rel, true)) continue
|
||||
queue.push({ dir: join(dir, d.name), prefix: rel })
|
||||
} else if (d.isFile()) {
|
||||
// 忽略规则命中文件:跳过(不计数,不占用 maxFiles)
|
||||
if (ignore?.matchesFile(d.name)) continue
|
||||
// 命中的文件跳过(不计数,不占用 maxFiles)
|
||||
const rel = prefix === '' ? d.name : `${prefix}/${d.name}`
|
||||
if (ignore?.matchesPath(rel, false)) continue
|
||||
total++
|
||||
if (rels.length >= maxFiles) {
|
||||
truncated = true
|
||||
} else {
|
||||
rels.push(prefix === '' ? d.name : `${prefix}/${d.name}`)
|
||||
rels.push(rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -386,9 +394,17 @@ export async function scanFolders(
|
||||
})
|
||||
}
|
||||
})
|
||||
// 受控并发分批执行(批内并行、批间串行;单任务失败已在任务内部降级为 unreadable)
|
||||
// 受控并发分批执行(批内并行、批间串行;单任务失败已在任务内部降级为 unreadable);
|
||||
// 每批完成触发一次进度回调(调用方推送 UI 进度,异常由调用方防御)
|
||||
let done = 0
|
||||
for (let i = 0; i < tasks.length; i += SCAN_CONCURRENCY) {
|
||||
await Promise.all(tasks.slice(i, i + SCAN_CONCURRENCY).map((t) => t.run()))
|
||||
done = Math.min(i + SCAN_CONCURRENCY, tasks.length)
|
||||
try {
|
||||
options.onProgress?.(done, tasks.length)
|
||||
} catch {
|
||||
// 进度回调异常不影响扫描
|
||||
}
|
||||
}
|
||||
const truncated = left.truncated || right.truncated
|
||||
return {
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
compileIgnoreRules,
|
||||
isIgnoreRulesArray,
|
||||
IGNORE_RULES_MAX,
|
||||
IGNORE_RULE_MAX_LEN
|
||||
} from './ignoreRules'
|
||||
|
||||
describe('compileIgnoreRules - 目录规则(尾部 /)', () => {
|
||||
it('目录名任意层级命中,文件不命中', () => {
|
||||
const m = compileIgnoreRules(['node_modules/'])
|
||||
expect(m.matchesDir('node_modules')).toBe(true)
|
||||
expect(m.matchesFile('node_modules')).toBe(false)
|
||||
expect(m.matchesFile('a.txt')).toBe(false)
|
||||
})
|
||||
|
||||
it('大小写不敏感(双侧)', () => {
|
||||
const m = compileIgnoreRules(['NODE_MODULES/'])
|
||||
expect(m.matchesDir('node_modules')).toBe(true)
|
||||
expect(m.matchesDir('Node_Modules')).toBe(true)
|
||||
})
|
||||
|
||||
it('扩展名通配与目录专用组合(*.log/)仅匹配以 .log 结尾的目录名', () => {
|
||||
const m = compileIgnoreRules(['*.log/'])
|
||||
expect(m.matchesDir('debug.log')).toBe(true)
|
||||
expect(m.matchesDir('logs')).toBe(false)
|
||||
expect(m.matchesFile('debug.log')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compileIgnoreRules - 扩展名通配(*.ext)', () => {
|
||||
it('匹配任意层级的文件名后缀', () => {
|
||||
const m = compileIgnoreRules(['*.log'])
|
||||
expect(m.matchesFile('app.log')).toBe(true)
|
||||
expect(m.matchesFile('a.LOG')).toBe(true)
|
||||
expect(m.matchesFile('app.log.txt')).toBe(false)
|
||||
expect(m.matchesFile('a.txt')).toBe(false)
|
||||
})
|
||||
|
||||
it('* 至少匹配一个字符(裸 .log 不命中)', () => {
|
||||
const m = compileIgnoreRules(['*.log'])
|
||||
expect(m.matchesFile('.log')).toBe(false)
|
||||
})
|
||||
|
||||
it('非目录专用的通配规则同样可命中目录名', () => {
|
||||
const m = compileIgnoreRules(['*.log'])
|
||||
expect(m.matchesDir('dump.log')).toBe(true)
|
||||
expect(m.matchesDir('src')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compileIgnoreRules - 名字精确匹配', () => {
|
||||
it('文件与目录均命中', () => {
|
||||
const m = compileIgnoreRules(['temp'])
|
||||
expect(m.matchesDir('temp')).toBe(true)
|
||||
expect(m.matchesFile('temp')).toBe(true)
|
||||
expect(m.matchesFile('temp.txt')).toBe(false)
|
||||
expect(m.matchesDir('temporary')).toBe(false)
|
||||
})
|
||||
|
||||
it('多条规则任一命中即可', () => {
|
||||
const m = compileIgnoreRules(['temp', 'dist/', '*.tmp'])
|
||||
expect(m.matchesDir('dist')).toBe(true)
|
||||
expect(m.matchesFile('a.tmp')).toBe(true)
|
||||
expect(m.matchesFile('temp')).toBe(true)
|
||||
expect(m.matchesFile('keep.txt')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compileIgnoreRules - 非法与边界输入', () => {
|
||||
it('空白行与 # 注释跳过', () => {
|
||||
const m = compileIgnoreRules(['', ' ', '# 构建产物', 'dist/'])
|
||||
expect(m.matchesDir('dist')).toBe(true)
|
||||
expect(m.matchesDir('#')).toBe(false)
|
||||
})
|
||||
|
||||
it('多段路径规则(含 /)整体忽略', () => {
|
||||
const m = compileIgnoreRules(['a/b', 'build/out/'])
|
||||
expect(m.matchesDir('a')).toBe(false)
|
||||
expect(m.matchesDir('b')).toBe(false)
|
||||
expect(m.matchesDir('out')).toBe(false)
|
||||
})
|
||||
|
||||
it('非 *.ext 前缀的通配(*x / x* / a*b)整体忽略', () => {
|
||||
const m = compileIgnoreRules(['*cache', 'temp*', 'a*b'])
|
||||
expect(m.matchesFile('*cache')).toBe(false)
|
||||
expect(m.matchesFile('temp*')).toBe(false)
|
||||
expect(m.matchesFile('a*b')).toBe(false)
|
||||
})
|
||||
|
||||
it('规则首尾空白被 trim', () => {
|
||||
const m = compileIgnoreRules([' node_modules/ '])
|
||||
expect(m.matchesDir('node_modules')).toBe(true)
|
||||
})
|
||||
|
||||
it('空数组与全非法输入产出永不匹配的匹配器', () => {
|
||||
const empty = compileIgnoreRules([])
|
||||
expect(empty.matchesDir('node_modules')).toBe(false)
|
||||
expect(empty.matchesFile('a.log')).toBe(false)
|
||||
const invalid = compileIgnoreRules(['#', 'a/b', '**'])
|
||||
expect(invalid.matchesDir('anything')).toBe(false)
|
||||
})
|
||||
|
||||
it('超长单条规则(> 500 字符)被忽略', () => {
|
||||
const m = compileIgnoreRules(['x'.repeat(IGNORE_RULE_MAX_LEN + 1)])
|
||||
expect(m.matchesDir('x'.repeat(IGNORE_RULE_MAX_LEN + 1))).toBe(false)
|
||||
})
|
||||
|
||||
it('条数超上限截断(第 51 条起不生效)', () => {
|
||||
const rules = Array.from({ length: IGNORE_RULES_MAX + 5 }, (_, i) => `d${i}/`)
|
||||
const m = compileIgnoreRules(rules)
|
||||
// 前 50 条生效
|
||||
expect(m.matchesDir('d0')).toBe(true)
|
||||
expect(m.matchesDir(`d${IGNORE_RULES_MAX - 1}`)).toBe(true)
|
||||
// 第 51 条起被截断
|
||||
expect(m.matchesDir(`d${IGNORE_RULES_MAX}`)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isIgnoreRulesArray - IPC 防御性校验', () => {
|
||||
it('合法字符串数组通过', () => {
|
||||
expect(isIgnoreRulesArray([])).toBe(true)
|
||||
expect(isIgnoreRulesArray(['node_modules/', '*.log'])).toBe(true)
|
||||
})
|
||||
|
||||
it('非数组 / 含非字符串 / 超条数 / 超长条目拒绝', () => {
|
||||
expect(isIgnoreRulesArray(null)).toBe(false)
|
||||
expect(isIgnoreRulesArray('node_modules/')).toBe(false)
|
||||
expect(isIgnoreRulesArray(['a', 42])).toBe(false)
|
||||
expect(isIgnoreRulesArray(Array.from({ length: IGNORE_RULES_MAX + 1 }, () => 'a'))).toBe(false)
|
||||
expect(isIgnoreRulesArray(['x'.repeat(IGNORE_RULE_MAX_LEN + 1)])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* 文件夹对比忽略规则的纯逻辑模块(无 electron/fs 依赖,vitest 直接测试)。
|
||||
* gitignore 简化子集,仅支持单段名字规则:
|
||||
* - `node_modules/`:目录名匹配(任意层级命中整树剪枝,不匹配文件)
|
||||
* - `*.log`:扩展名通配(文件与目录名均可命中)
|
||||
* - `temp`:名字精确匹配(文件与目录均命中)
|
||||
* 大小写不敏感(Windows 文件系统语义);`#` 开头注释与空白行跳过;
|
||||
* 含多段路径(`a/b`)或非法通配(`*x`)的规则整体忽略(宁缺毋滥,防误匹配全部)。
|
||||
*/
|
||||
|
||||
/** 规则条数上限(UI 与主进程 IPC 双重防御) */
|
||||
export const IGNORE_RULES_MAX = 50
|
||||
/** 单条规则长度上限 */
|
||||
export const IGNORE_RULE_MAX_LEN = 500
|
||||
|
||||
export interface IgnoreMatcher {
|
||||
/** 目录名是否命中(命中的目录整树剪枝,不枚举不计数) */
|
||||
matchesDir: (name: string) => boolean
|
||||
/** 文件名是否命中(命中的文件跳过,不参与枚举与判定) */
|
||||
matchesFile: (name: string) => boolean
|
||||
}
|
||||
|
||||
interface CompiledRule {
|
||||
/** 规则名(已小写;*.ext 形式按扩展名通配处理) */
|
||||
raw: string
|
||||
/** 目录专用(尾部 /) */
|
||||
dirOnly: boolean
|
||||
/** 扩展名通配(*.log → 'log');null = 名字精确匹配 */
|
||||
ext: string | null
|
||||
}
|
||||
|
||||
/** 编译单条规则文本(调用方已做 trim);无法解析返回 null */
|
||||
function compileOne(rule: string): CompiledRule | null {
|
||||
if (rule.length === 0 || rule.length > IGNORE_RULE_MAX_LEN) return null
|
||||
if (rule.startsWith('#')) return null
|
||||
let body = rule
|
||||
let dirOnly = false
|
||||
if (body.endsWith('/')) {
|
||||
dirOnly = true
|
||||
body = body.slice(0, -1)
|
||||
}
|
||||
if (body.length === 0 || body.includes('/')) return null
|
||||
const lower = body.toLowerCase()
|
||||
// *.ext 形式:提取扩展名做后缀通配
|
||||
if (lower.startsWith('*.')) {
|
||||
const ext = lower.slice(2)
|
||||
if (ext.length === 0 || ext.includes('*')) return null
|
||||
return { raw: lower, dirOnly, ext }
|
||||
}
|
||||
// 名字内不允许残留通配符(简化子集仅支持 *.ext 前缀形式)
|
||||
if (lower.includes('*')) return null
|
||||
return { raw: lower, dirOnly, ext: null }
|
||||
}
|
||||
|
||||
/** 规则数组是否为可接受的 IPC 形态(folder:scan 防御性校验共用) */
|
||||
export function isIgnoreRulesArray(v: unknown): v is string[] {
|
||||
return (
|
||||
Array.isArray(v) &&
|
||||
v.length <= IGNORE_RULES_MAX &&
|
||||
v.every((r) => typeof r === 'string' && r.length <= IGNORE_RULE_MAX_LEN)
|
||||
)
|
||||
}
|
||||
|
||||
/** 编译规则串数组为匹配器(空数组 / 全部非法 → 永不匹配的空匹配器) */
|
||||
export function compileIgnoreRules(rules: readonly string[]): IgnoreMatcher {
|
||||
const compiled: CompiledRule[] = []
|
||||
for (const r of rules) {
|
||||
if (compiled.length >= IGNORE_RULES_MAX) break
|
||||
if (typeof r !== 'string') continue
|
||||
const c = compileOne(r.trim())
|
||||
if (c) compiled.push(c)
|
||||
}
|
||||
const nameHit = (name: string, forDir: boolean): boolean => {
|
||||
const lower = name.toLowerCase()
|
||||
for (const c of compiled) {
|
||||
// 目录专用规则(尾部 /)不匹配文件
|
||||
if (c.dirOnly && !forDir) continue
|
||||
if (c.ext !== null) {
|
||||
// *.ext:名字以 .ext 结尾且 * 至少匹配一个字符
|
||||
if (lower.endsWith('.' + c.ext) && lower.length > c.ext.length + 1) return true
|
||||
continue
|
||||
}
|
||||
if (lower === c.raw) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return {
|
||||
matchesDir: (name) => nameHit(name, true),
|
||||
matchesFile: (name) => nameHit(name, false)
|
||||
}
|
||||
}
|
||||
+51
-4
@@ -5,7 +5,7 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import fs from 'fs'
|
||||
import { parseState, clampBounds, type AppState } from './windowState'
|
||||
import { decodeText, type DecodeResult } from './decode'
|
||||
import { scanFolders, SEMANTIC_MAX_BYTES, type ScanCacheEntry } from './folderScan'
|
||||
import { scanFolders, SEMANTIC_MAX_BYTES, SCAN_CONCURRENCY, type ScanCacheEntry } from './folderScan'
|
||||
import { resolveScanArgs } from './scanIpc'
|
||||
import {
|
||||
parseScanCacheStore,
|
||||
@@ -309,24 +309,41 @@ ipcMain.handle('folder:pick', async () => {
|
||||
return dirPath
|
||||
})
|
||||
|
||||
/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等 + 忽略规则 + 增量缓存跨会话持久化;异步 fs 与解码 worker 不阻塞主进程) */
|
||||
/**
|
||||
* IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等 + 忽略规则 + 增量缓存跨会话持久化;异步 fs 与解码 worker 不阻塞主进程)。
|
||||
* scanId(渲染端扫描会话序号)原样携带在进度事件中:渲染端只显示最新会话的进度,
|
||||
* 旧扫描迟到的进度事件天然被丢弃(与渲染端结果竞态守卫同一套序号,不引入新竞态面)。
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'folder:scan',
|
||||
async (
|
||||
_event,
|
||||
event,
|
||||
leftDir: string,
|
||||
rightDir: string,
|
||||
semantic: boolean,
|
||||
previous?: unknown,
|
||||
ignoreRules?: unknown
|
||||
ignoreRules?: unknown,
|
||||
scanId?: unknown
|
||||
) => {
|
||||
try {
|
||||
// 入参防御性校验与增量缓存源选择(内存缓存优先,未携带/非法回退 userData 落盘缓存,惰性读盘)
|
||||
const args = resolveScanArgs(previous, ignoreRules, () =>
|
||||
getScanCache(loadScanCacheStore(), leftDir, rightDir)
|
||||
)
|
||||
const id = typeof scanId === 'number' && Number.isFinite(scanId) ? scanId : -1
|
||||
// 比对进度推送(首个 push 型 IPC):窗口销毁后 send 抛错,进度丢失无影响
|
||||
const onProgress = (done: number, total: number): void => {
|
||||
try {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('folder:scan-progress', { scanId: id, done, total })
|
||||
}
|
||||
} catch {
|
||||
// 窗口已销毁等:忽略
|
||||
}
|
||||
}
|
||||
const result = await scanFolders(leftDir, rightDir, {
|
||||
...args,
|
||||
onProgress,
|
||||
...(semantic
|
||||
? {
|
||||
// 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线)
|
||||
@@ -353,6 +370,36 @@ ipcMain.handle('file:read-by-path', async (_event, filePath: string) => {
|
||||
return readTextFile(filePath)
|
||||
})
|
||||
|
||||
/** 批量读取的路径数上限(渲染端快照保存按差异条目收集,超出说明异常输入,整体拒绝) */
|
||||
const READ_BATCH_MAX_PATHS = 256
|
||||
|
||||
/**
|
||||
* IPC: 批量读取多个文本文件(快照保存收集差异条目内容用)。
|
||||
* 与单文件读取同一管线(10MB 上限/编码探测/解码 worker),受控并发分批;
|
||||
* 单文件失败该条目返回错误对象不整体失败(与 folderScan 的单点降级哲学一致),
|
||||
* 渲染端把失败条目按"该侧无内容"处理。
|
||||
*/
|
||||
ipcMain.handle('folder:read-batch', async (_event, paths: unknown) => {
|
||||
if (
|
||||
!Array.isArray(paths) ||
|
||||
paths.length === 0 ||
|
||||
paths.length > READ_BATCH_MAX_PATHS ||
|
||||
!paths.every((p) => typeof p === 'string' && p.length > 0)
|
||||
) {
|
||||
return { error: 'invalid' as const }
|
||||
}
|
||||
const results = new Array<Awaited<ReturnType<typeof readTextFile>>>(paths.length)
|
||||
for (let i = 0; i < paths.length; i += SCAN_CONCURRENCY) {
|
||||
const batch = paths.slice(i, i + SCAN_CONCURRENCY)
|
||||
await Promise.all(
|
||||
batch.map(async (p, k) => {
|
||||
results[i + k] = await readTextFile(p)
|
||||
})
|
||||
)
|
||||
}
|
||||
return { files: results }
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId('com.metonateam.difflens')
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type { ScanCacheEntry } from './folderScan'
|
||||
import { isIgnoreRulesArray } from './ignoreRules'
|
||||
import { isIgnoreRulesArray } from '../shared/ignoreRules'
|
||||
|
||||
/**
|
||||
* folder:scan IPC 入参的防御性校验与增量缓存源选择(纯逻辑,无 electron/fs 依赖)。
|
||||
|
||||
+40
-4
@@ -72,6 +72,18 @@ export interface FolderScanError {
|
||||
error: 'scan-failed'
|
||||
}
|
||||
|
||||
/** 扫描进度事件负载(folder:scan-progress 推送;scanId 与渲染端扫描会话序号对应) */
|
||||
export interface FolderScanProgress {
|
||||
scanId: number
|
||||
done: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 批量读取结果:全部路径的读取结果(按入参顺序;单文件失败为错误对象,不整体失败) */
|
||||
export interface ReadBatchResult {
|
||||
files: FileOpenResult[]
|
||||
}
|
||||
|
||||
/** 主进程暴露给渲染进程的安全 API */
|
||||
const api = {
|
||||
openFile: (side?: 'left' | 'right'): Promise<FileOpenResult> =>
|
||||
@@ -91,12 +103,36 @@ const api = {
|
||||
rightDir: string,
|
||||
semantic: boolean,
|
||||
previous?: FolderScanCacheEntry[] | null,
|
||||
/** 忽略规则(gitignore 简化子集;命中的目录整树剪枝、文件跳过) */
|
||||
ignoreRules?: string[] | null
|
||||
/** 忽略规则(gitignore 简化子集含高级 glob;命中的目录整树剪枝、文件跳过) */
|
||||
ignoreRules?: string[] | null,
|
||||
/** 扫描会话序号(渲染端 scanSeq;进度事件携带回传,渲染端只显示最新会话的进度) */
|
||||
scanId?: number
|
||||
): Promise<FolderScanResult | FolderScanError> =>
|
||||
ipcRenderer.invoke('folder:scan', leftDir, rightDir, semantic, previous ?? null, ignoreRules ?? null),
|
||||
ipcRenderer.invoke(
|
||||
'folder:scan',
|
||||
leftDir,
|
||||
rightDir,
|
||||
semantic,
|
||||
previous ?? null,
|
||||
ignoreRules ?? null,
|
||||
scanId ?? -1
|
||||
),
|
||||
/**
|
||||
* 订阅扫描进度推送(首个 push 型 IPC):返回取消订阅函数。
|
||||
* 事件负载带 scanId,渲染端按当前会话序号过滤,旧扫描迟到的进度被丢弃。
|
||||
*/
|
||||
onScanProgress: (cb: (data: FolderScanProgress) => void): (() => void) => {
|
||||
const listener = (_e: unknown, data: FolderScanProgress): void => cb(data)
|
||||
ipcRenderer.on('folder:scan-progress', listener)
|
||||
return (): void => {
|
||||
ipcRenderer.removeListener('folder:scan-progress', listener)
|
||||
}
|
||||
},
|
||||
readByPath: (filePath: string): Promise<FileOpenResult> =>
|
||||
ipcRenderer.invoke('file:read-by-path', filePath)
|
||||
ipcRenderer.invoke('file:read-by-path', filePath),
|
||||
/** 批量读取文本文件(快照保存收集差异条目内容用;单文件失败为错误对象不整体失败) */
|
||||
readFilesByPaths: (paths: string[]): Promise<ReadBatchResult | { error: 'invalid' }> =>
|
||||
ipcRenderer.invoke('folder:read-batch', paths)
|
||||
}
|
||||
|
||||
export type DiffLensApi = typeof api
|
||||
|
||||
+168
-21
@@ -18,7 +18,12 @@ import {
|
||||
parseSnapshot,
|
||||
diffSnapshot,
|
||||
snapshotDirsMatch,
|
||||
isDiffStatus,
|
||||
SNAPSHOT_VERSION,
|
||||
SNAPSHOT_FILE_MAX_BYTES,
|
||||
SNAPSHOT_CONTENTS_MAX_CHARS,
|
||||
type FolderSnapshot,
|
||||
type SnapshotContents,
|
||||
type SnapshotDiffResult
|
||||
} from './diff/snapshotDiff'
|
||||
import { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils'
|
||||
@@ -41,6 +46,9 @@ function stamp(): string {
|
||||
/** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** 快照保存批量读取的分批大小(主进程 folder:read-batch 上限 256,留余量) */
|
||||
const SNAPSHOT_READ_BATCH = 200
|
||||
|
||||
/** 用户偏好(比较选项 + 视图开关)持久化键 */
|
||||
const PREFS_KEY = 'difflens.prefs.v1'
|
||||
|
||||
@@ -205,6 +213,10 @@ export default function App(): ReactElement {
|
||||
(FolderScanResult & { leftDir: string; rightDir: string }) | null
|
||||
>(null)
|
||||
const [folderScanning, setFolderScanning] = useState(false)
|
||||
// 扫描比对进度(push 型 IPC 推送;仅显示与当前会话序号一致的事件,null = 无进度态)
|
||||
const [scanProgress, setScanProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
// 快照保存中(批量读取差异条目内容期间的遮罩态)
|
||||
const [snapshotSaving, setSnapshotSaving] = useState(false)
|
||||
// 从文件夹进入单文件对比时的当前条目 rel(差异文件导航的游标;返回/退出文件夹模式时清空)
|
||||
const [folderEntryCursor, setFolderEntryCursor] = useState<string | null>(null)
|
||||
// 目录聚合对比的导航范围(双击目录行进入:差异文件导航限定在该子树内;
|
||||
@@ -399,6 +411,7 @@ export default function App(): ReactElement {
|
||||
async (leftDir: string, rightDir: string, semantic: boolean, ignoreRules: string[]): Promise<void> => {
|
||||
const seq = ++scanSeqRef.current
|
||||
setFolderScanning(true)
|
||||
setScanProgress(null)
|
||||
try {
|
||||
const prev =
|
||||
folderState &&
|
||||
@@ -407,7 +420,8 @@ export default function App(): ReactElement {
|
||||
!folderState.truncated
|
||||
? (folderState.cache ?? null)
|
||||
: null
|
||||
const res = await window.api.scanFolder(leftDir, rightDir, semantic, prev, ignoreRules)
|
||||
// scanId = 会话序号:进度事件携带回传,渲染端按序号过滤旧扫描的迟到进度
|
||||
const res = await window.api.scanFolder(leftDir, rightDir, semantic, prev, ignoreRules, seq)
|
||||
// 过期扫描(期间已发起新扫描或已退出文件夹对比):结果整体丢弃
|
||||
if (seq !== scanSeqRef.current) return
|
||||
if ('error' in res) {
|
||||
@@ -421,12 +435,23 @@ export default function App(): ReactElement {
|
||||
showToast('文件夹扫描失败,请重试')
|
||||
} finally {
|
||||
// 仅最新会话允许复位扫描态(新扫描进行中不被旧扫描的 finally 误清)
|
||||
if (seq === scanSeqRef.current) setFolderScanning(false)
|
||||
if (seq === scanSeqRef.current) {
|
||||
setFolderScanning(false)
|
||||
setScanProgress(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
[showToast, folderState]
|
||||
)
|
||||
|
||||
// 订阅主进程扫描进度推送:仅显示与当前会话序号一致的事件(旧扫描迟到进度天然丢弃)
|
||||
useEffect(() => {
|
||||
const off = window.api.onScanProgress((data) => {
|
||||
if (data.scanId === scanSeqRef.current) setScanProgress({ done: data.done, total: data.total })
|
||||
})
|
||||
return off
|
||||
}, [])
|
||||
|
||||
/** 入口:连续选择左右两个文件夹后开始对比 */
|
||||
const startFolderCompare = useCallback(async (): Promise<void> => {
|
||||
let leftDir: string | null = null
|
||||
@@ -512,25 +537,89 @@ export default function App(): ReactElement {
|
||||
)
|
||||
|
||||
/** 保存当前对比结果为快照基线(JSON 落盘,复用报告保存管线)。
|
||||
* 截断结果(文件过多)条目不完整:快照缺失的文件在日后对照中会被误判为"新文件",拦截保存 */
|
||||
* 截断结果(文件过多)条目不完整:快照缺失的文件在日后对照中会被误判为"新文件",拦截保存。
|
||||
* v2 起可选保存差异条目的双侧内容(对照模式双击进入"快照基线 vs 当前"的内容对比):
|
||||
* 仅差异条目(same/语义同不存——regressed 场景基线内容不保存为既定取舍)、
|
||||
* 单文件超 2MB 的侧不读(该侧 null)、内容总量超 32MB 整体降级为纯状态快照(保存永不失败) */
|
||||
const saveSnapshot = useCallback((): void => {
|
||||
if (!folderState) return
|
||||
if (folderState.truncated) {
|
||||
showToast('扫描结果已截断(文件过多),快照不完整无法对照,请先缩小对比范围')
|
||||
return
|
||||
}
|
||||
const snap: FolderSnapshot = {
|
||||
version: 1,
|
||||
leftDir: folderState.leftDir,
|
||||
rightDir: folderState.rightDir,
|
||||
semantic: folderSemantic,
|
||||
truncated: folderState.truncated,
|
||||
total: folderState.total,
|
||||
entries: folderState.entries,
|
||||
savedAt: Date.now()
|
||||
}
|
||||
const defaultName = `DiffLens-snapshot-${stamp()}.json`
|
||||
void (async (): Promise<void> => {
|
||||
// ===== 收集差异条目双侧路径(超限/单侧缺失的侧不读) =====
|
||||
const targets: Array<{ rel: string; leftPath: string | null; rightPath: string | null }> = []
|
||||
for (const e of folderState.entries) {
|
||||
if (!isDiffStatus(e.status) || e.status === 'unreadable') continue
|
||||
const lOver = e.leftSize !== null && e.leftSize > SNAPSHOT_FILE_MAX_BYTES
|
||||
const rOver = e.rightSize !== null && e.rightSize > SNAPSHOT_FILE_MAX_BYTES
|
||||
targets.push({
|
||||
rel: e.rel,
|
||||
leftPath: e.status === 'right-only' || lOver ? null : `${folderState.leftDir}/${e.rel}`,
|
||||
rightPath: e.status === 'left-only' || rOver ? null : `${folderState.rightDir}/${e.rel}`
|
||||
})
|
||||
}
|
||||
let contents: Record<string, SnapshotContents> | undefined
|
||||
let contentsOverflow = false
|
||||
if (targets.length > 0) {
|
||||
setSnapshotSaving(true)
|
||||
try {
|
||||
// 去重路径分批批量读:单文件读取失败该条目按"该侧无内容"处理(不整体失败)
|
||||
const allPaths = [
|
||||
...new Set(
|
||||
targets.flatMap((t) => [t.leftPath, t.rightPath].filter((p): p is string => p !== null))
|
||||
)
|
||||
]
|
||||
const readMap = new Map<string, string>()
|
||||
for (let i = 0; i < allPaths.length; i += SNAPSHOT_READ_BATCH) {
|
||||
const batch = allPaths.slice(i, i + SNAPSHOT_READ_BATCH)
|
||||
let res: Awaited<ReturnType<typeof window.api.readFilesByPaths>>
|
||||
try {
|
||||
res = await window.api.readFilesByPaths(batch)
|
||||
} catch {
|
||||
showToast('快照保存失败:差异文件读取失败,请重试')
|
||||
return
|
||||
}
|
||||
if ('error' in res) {
|
||||
showToast('快照保存失败:差异文件读取失败,请重试')
|
||||
return
|
||||
}
|
||||
res.files.forEach((f, k) => {
|
||||
if (f && !('error' in f)) readMap.set(batch[k], f.text)
|
||||
})
|
||||
}
|
||||
// ===== 组装内容并执行总量上限(超出整体降级为纯状态快照) =====
|
||||
const map: Record<string, SnapshotContents> = {}
|
||||
let totalChars = 0
|
||||
for (const t of targets) {
|
||||
const lc = t.leftPath !== null ? (readMap.get(t.leftPath) ?? null) : null
|
||||
const rc = t.rightPath !== null ? (readMap.get(t.rightPath) ?? null) : null
|
||||
if (lc === null && rc === null) continue
|
||||
totalChars += (lc?.length ?? 0) + (rc?.length ?? 0)
|
||||
if (totalChars > SNAPSHOT_CONTENTS_MAX_CHARS) {
|
||||
contentsOverflow = true
|
||||
break
|
||||
}
|
||||
map[t.rel] = { left: lc, right: rc }
|
||||
}
|
||||
if (!contentsOverflow && Object.keys(map).length > 0) contents = map
|
||||
} finally {
|
||||
setSnapshotSaving(false)
|
||||
}
|
||||
}
|
||||
const snap: FolderSnapshot = {
|
||||
version: SNAPSHOT_VERSION,
|
||||
leftDir: folderState.leftDir,
|
||||
rightDir: folderState.rightDir,
|
||||
semantic: folderSemantic,
|
||||
truncated: folderState.truncated,
|
||||
total: folderState.total,
|
||||
entries: folderState.entries,
|
||||
savedAt: Date.now(),
|
||||
...(contents !== undefined ? { contents } : {})
|
||||
}
|
||||
const defaultName = `DiffLens-snapshot-${stamp()}.json`
|
||||
let res: { ok: boolean; path: string | null }
|
||||
try {
|
||||
res = await window.api.saveReport(JSON.stringify(snap, null, 2), defaultName)
|
||||
@@ -540,10 +629,15 @@ export default function App(): ReactElement {
|
||||
}
|
||||
if (res.ok && res.path) {
|
||||
const p = res.path
|
||||
showToast(`快照已保存:${p}`, {
|
||||
label: '打开文件夹',
|
||||
onClick: () => void window.api.showInFolder(p)
|
||||
})
|
||||
showToast(
|
||||
contentsOverflow
|
||||
? `快照已保存(差异内容总量超限,仅保存状态不含内容):${p}`
|
||||
: `快照已保存:${p}`,
|
||||
{
|
||||
label: '打开文件夹',
|
||||
onClick: () => void window.api.showInFolder(p)
|
||||
}
|
||||
)
|
||||
}
|
||||
})()
|
||||
}, [folderState, folderSemantic, showToast])
|
||||
@@ -619,6 +713,9 @@ export default function App(): ReactElement {
|
||||
}, [folderState, snapshot, showToast])
|
||||
|
||||
/** 双击文件夹条目:按路径读取存在侧进入单文件对比(保留文件夹状态供返回)。
|
||||
* 快照对照模式下条目含基线内容时优先进入"内容对照":左面板为快照基线内容、
|
||||
* 右面板为当前文件内容(跟踪左文件优先,快照时刻该侧不存在则跟踪右),
|
||||
* 完整复用单文件对比能力(比较选项/字符级/搜索/差异导航/导出)。
|
||||
* navScope 为本次进入后的导航范围:手动双击文件条目传 null(回全局序列),
|
||||
* 目录聚合入口与差异文件导航传当前 scope(保持子树范围不被清除) */
|
||||
const openFolderEntry = useCallback(
|
||||
@@ -653,6 +750,44 @@ export default function App(): ReactElement {
|
||||
if (!ok) clearSide(side)
|
||||
return ok
|
||||
}
|
||||
// ===== 快照内容对照:对照模式且快照 v2 含该条目基线内容(双侧均为 null 时走常规双击) =====
|
||||
const snapContents =
|
||||
snapshot !== null && snapshotDiff !== null ? snapshot.contents?.[entry.rel] : undefined
|
||||
if (snapContents !== undefined && (snapContents.left !== null || snapContents.right !== null)) {
|
||||
// 跟踪侧选择:优先"基线有内容且当前也存在"的一侧;兜底跟基线存在侧(当前侧空面板展示基线 vs 已删除)
|
||||
const baseSide: 'left' | 'right' =
|
||||
snapContents.left !== null && entry.status !== 'right-only'
|
||||
? 'left'
|
||||
: snapContents.right !== null && entry.status !== 'left-only'
|
||||
? 'right'
|
||||
: snapContents.left !== null
|
||||
? 'left'
|
||||
: 'right'
|
||||
const baseText = baseSide === 'left' ? snapContents.left : snapContents.right
|
||||
if (baseText !== null) {
|
||||
setPaneL({
|
||||
meta: {
|
||||
name: `快照 · ${entry.rel.split('/').pop() ?? entry.rel}`,
|
||||
encoding: '快照基线'
|
||||
},
|
||||
path: '',
|
||||
text: baseText
|
||||
})
|
||||
} else {
|
||||
clearSide('left')
|
||||
}
|
||||
const curExists =
|
||||
baseSide === 'left' ? entry.status !== 'right-only' : entry.status !== 'left-only'
|
||||
const curPath = curExists
|
||||
? `${(baseSide === 'left' ? folderState.leftDir : folderState.rightDir)}/${entry.rel}`
|
||||
: null
|
||||
const okCur = await loadSide('right', curPath)
|
||||
if (baseText !== null || okCur) {
|
||||
setFolderEntryCursor(entry.rel)
|
||||
setMode('file')
|
||||
}
|
||||
return
|
||||
}
|
||||
const okL = await loadSide(
|
||||
'left',
|
||||
entry.status === 'right-only' ? null : `${folderState.leftDir}/${entry.rel}`
|
||||
@@ -667,7 +802,7 @@ export default function App(): ReactElement {
|
||||
setMode('file')
|
||||
}
|
||||
},
|
||||
[folderState, applyFileData, showToast]
|
||||
[folderState, snapshot, snapshotDiff, applyFileData, showToast]
|
||||
)
|
||||
|
||||
/** 差异文件序列(不同/仅左/仅右;语义同不计入),批量审查时的导航范围;
|
||||
@@ -722,9 +857,10 @@ export default function App(): ReactElement {
|
||||
/** 退出文件夹对比(清空文件夹状态、导航游标/聚合范围、快照对照与两侧面板,回到单文件空态) */
|
||||
const exitFolder = useCallback((): void => {
|
||||
// 作废在途扫描并复位扫描态:迟到的扫描结果不得把界面拉回文件夹模式,
|
||||
// 也不得让扫描遮罩残留到下一次文件夹会话
|
||||
// 也不得让扫描遮罩与进度残留到下一次文件夹会话
|
||||
scanSeqRef.current++
|
||||
setFolderScanning(false)
|
||||
setScanProgress(null)
|
||||
setFolderState(null)
|
||||
setPaneL(null)
|
||||
setPaneR(null)
|
||||
@@ -1136,6 +1272,7 @@ export default function App(): ReactElement {
|
||||
truncated={folderState.truncated}
|
||||
total={folderState.total}
|
||||
scanning={folderScanning}
|
||||
progress={scanProgress}
|
||||
onlyDiff={folderOnlyDiff}
|
||||
onOnlyDiffChange={setFolderOnlyDiff}
|
||||
semantic={folderSemantic}
|
||||
@@ -1246,7 +1383,17 @@ export default function App(): ReactElement {
|
||||
{folderScanning && mode !== 'folder' && (
|
||||
<div className="computing-overlay">
|
||||
<span className="computing-ring" />
|
||||
<span>正在扫描文件夹…</span>
|
||||
<span>
|
||||
正在扫描文件夹…
|
||||
{scanProgress ? ` 已比对 ${scanProgress.done} / ${scanProgress.total}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshotSaving && (
|
||||
<div className="computing-overlay">
|
||||
<span className="computing-ring" />
|
||||
<span>正在读取差异文件内容,准备快照…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
|
||||
import App from '../App'
|
||||
import { parseSnapshot } from '../diff/snapshotDiff'
|
||||
import { parseSnapshot, type SnapshotContents } from '../diff/snapshotDiff'
|
||||
|
||||
// 快照内容总量上限压低至 100 字符:专测"超限整体降级为纯状态快照"路径(vi.mock 工厂在文件级生效,
|
||||
// 其余快照用例的批量读 mock 均返回空 → 不产生 contents,不受该常量影响;真实常量由 snapshotDiff 测试覆盖)
|
||||
vi.mock('../diff/snapshotDiff', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../diff/snapshotDiff')>()
|
||||
return { ...actual, SNAPSHOT_CONTENTS_MAX_CHARS: 100 }
|
||||
})
|
||||
|
||||
let clipboardSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -22,7 +29,11 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
|
||||
setClipboard: clipboardSpy,
|
||||
pickFolder: async () => null,
|
||||
scanFolder: async () => ({ entries: [], truncated: false, total: 0 }),
|
||||
readByPath: async () => null
|
||||
// 扫描进度订阅:no-op 订阅(进度事件链路由专项用例经捕获回调触发)
|
||||
onScanProgress: (): (() => void) => () => {},
|
||||
readByPath: async () => null,
|
||||
// 批量读取默认空结果(快照保存的 contents 数据流由专项用例覆写)
|
||||
readFilesByPaths: async () => ({ files: [] })
|
||||
}
|
||||
return { ...base, ...overrides } as typeof window.api
|
||||
}
|
||||
@@ -2218,4 +2229,254 @@ describe('App - 快照基线', () => {
|
||||
expect(screen.queryByRole('button', { name: '退出对照' })).not.toBeInTheDocument()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('App - 快照内容级对照(v2)', () => {
|
||||
/** 进入文件夹模式并保存快照的公共流程(返回落盘内容捕获器) */
|
||||
async function enterAndSave(
|
||||
entries: Array<{ rel: string; status: 'same' | 'different' | 'left-only' | 'right-only'; leftSize: number | null; rightSize: number | null }>,
|
||||
readFiles: (paths: string[]) => Array<{ path: string; name: string; text: string; encoding: string; binary: boolean } | { error: 'read-failed'; name: string }>,
|
||||
saveContent: { get: () => string }
|
||||
): Promise<void> {
|
||||
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: entries.length }),
|
||||
readFilesByPaths: async (paths: string[]) => ({ files: readFiles(paths) }),
|
||||
saveReport: async (content: string) => {
|
||||
saveContent.get = () => content
|
||||
return { ok: true, path: 'C:/docs/snap.json' }
|
||||
}
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('对比文件夹'))
|
||||
// 等待扫描完成进入文件夹模式(保存快照按钮出现即列表就绪)
|
||||
await screen.findByRole('button', { name: '保存快照' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存快照' }))
|
||||
await screen.findByText(/快照已保存/)
|
||||
}
|
||||
|
||||
it('保存快照:差异条目双侧内容入 contents(批量读取按路径返回,same 条目不存内容)', async () => {
|
||||
const saved = { get: () => '' }
|
||||
const entries = [
|
||||
{ rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
|
||||
{ rel: 'b.txt', status: 'different' as const, leftSize: 6, rightSize: 7 },
|
||||
{ rel: 'c.txt', status: 'right-only' as const, leftSize: null, rightSize: 2 }
|
||||
]
|
||||
await enterAndSave(
|
||||
entries,
|
||||
(paths) =>
|
||||
paths.map((p) => ({
|
||||
path: p,
|
||||
name: p.split('/').pop() ?? '',
|
||||
text: `content of ${p}`,
|
||||
encoding: 'UTF-8',
|
||||
binary: false
|
||||
})),
|
||||
saved
|
||||
)
|
||||
const snap = parseSnapshot(saved.get())
|
||||
expect(snap?.version).toBe(2)
|
||||
// 差异条目双侧内容入 contents(right-only 条目左侧为 null)
|
||||
const b = snap?.contents?.['b.txt'] as SnapshotContents | undefined
|
||||
expect(b?.left).toBe('content of C:/left-dir/b.txt')
|
||||
expect(b?.right).toBe('content of D:/right-dir/b.txt')
|
||||
const c = snap?.contents?.['c.txt'] as SnapshotContents | undefined
|
||||
expect(c?.left).toBeNull()
|
||||
expect(c?.right).toBe('content of D:/right-dir/c.txt')
|
||||
// same 条目不存内容(既定取舍:regressed 场景基线内容不保存)
|
||||
expect(snap?.contents?.['a.txt']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('保存快照:单文件超限侧不读取内容(该侧 null),快照仍保存状态', async () => {
|
||||
const saved = { get: () => '' }
|
||||
let requested: string[] = []
|
||||
await enterAndSave(
|
||||
[{ rel: 'big.txt', status: 'different' as const, leftSize: 3 * 1024 * 1024, rightSize: 7 }],
|
||||
(paths) => {
|
||||
requested = [...requested, ...paths]
|
||||
return paths.map((p) => ({
|
||||
path: p,
|
||||
name: p.split('/').pop() ?? '',
|
||||
text: 'right content',
|
||||
encoding: 'UTF-8',
|
||||
binary: false
|
||||
}))
|
||||
},
|
||||
saved
|
||||
)
|
||||
const snap = parseSnapshot(saved.get())
|
||||
// 左侧 3MB 超过单文件上限:不发起读取,contents.left 为 null
|
||||
expect(requested.some((p) => p.includes('left-dir'))).toBe(false)
|
||||
expect(snap?.contents?.['big.txt']).toEqual({ left: null, right: 'right content' })
|
||||
})
|
||||
|
||||
it('保存快照:差异内容总量超限整体降级为纯状态快照并提示', async () => {
|
||||
// SNAPSHOT_CONTENTS_MAX_CHARS 被文件级 mock 压至 100:两个差异条目内容合计 120 字符超限
|
||||
const saved = { get: () => '' }
|
||||
await enterAndSave(
|
||||
[
|
||||
{ rel: 'b1.txt', status: 'different' as const, leftSize: 60, rightSize: 60 },
|
||||
{ rel: 'b2.txt', status: 'different' as const, leftSize: 60, rightSize: 60 }
|
||||
],
|
||||
(paths) =>
|
||||
paths.map((p) => ({
|
||||
path: p,
|
||||
name: p.split('/').pop() ?? '',
|
||||
text: 'x'.repeat(30),
|
||||
encoding: 'UTF-8',
|
||||
binary: false
|
||||
})),
|
||||
saved
|
||||
)
|
||||
await screen.findByText(/仅保存状态不含内容/)
|
||||
const snap = parseSnapshot(saved.get())
|
||||
expect(snap).not.toBeNull()
|
||||
expect(snap?.contents).toBeUndefined()
|
||||
})
|
||||
|
||||
it('对照模式双击含基线内容的条目:左为快照基线、右为当前内容(内容级对照)', async () => {
|
||||
const curEntries = [
|
||||
{ rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
|
||||
{ rel: 'b.txt', status: 'different' as const, leftSize: 3, rightSize: 5 }
|
||||
]
|
||||
const snapJson = JSON.stringify({
|
||||
version: 2,
|
||||
leftDir: 'C:/left-dir',
|
||||
rightDir: 'D:/right-dir',
|
||||
semantic: true,
|
||||
truncated: false,
|
||||
total: 2,
|
||||
savedAt: 1700000000000,
|
||||
entries: curEntries,
|
||||
contents: { 'b.txt': { left: 'alpha baseline', right: 'alpha baseline-r' } }
|
||||
})
|
||||
const dirs = ['C:/left-dir', 'D:/right-dir']
|
||||
let call = 0
|
||||
window.api = mockApi({
|
||||
pickFolder: async () => dirs[call++] ?? null,
|
||||
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 2 }),
|
||||
openFile: async () => ({
|
||||
path: '/tmp/snap.json',
|
||||
name: 'snap.json',
|
||||
text: snapJson,
|
||||
encoding: 'UTF-8',
|
||||
binary: false
|
||||
}),
|
||||
readByPath: async (p: string) =>
|
||||
p === 'C:/left-dir/b.txt'
|
||||
? { path: p, name: 'b.txt', text: 'alpha current', encoding: 'UTF-8', binary: false }
|
||||
: null
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('对比文件夹'))
|
||||
await screen.findByText('b.txt')
|
||||
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
|
||||
await screen.findByText(/已加载快照/)
|
||||
fireEvent.dblClick(screen.getByText('b.txt'))
|
||||
// 左面板:快照基线内容(面板头标注"快照 · b.txt"),右面板:当前左侧文件(跟踪左文件优先)
|
||||
// 注:状态栏也含同名文本,用 selector 限定面板头
|
||||
expect(
|
||||
await screen.findByText('快照 · b.txt', { selector: '.pane-file' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('baseline', { exact: true })).toBeInTheDocument()
|
||||
expect(screen.getByText('current', { exact: true })).toBeInTheDocument()
|
||||
expect(screen.getByText('有差异')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('对照模式双击无基线内容的条目(v1 快照):走常规当前文件双击', async () => {
|
||||
const curEntries = [
|
||||
{ rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
|
||||
{ rel: 'b.txt', status: 'different' as const, leftSize: 3, rightSize: 5 }
|
||||
]
|
||||
// v1 快照(无 contents 字段):加载后双击走现状(当前文件对比,无"快照 ·"面板)
|
||||
const snapJson = JSON.stringify({
|
||||
version: 1,
|
||||
leftDir: 'C:/left-dir',
|
||||
rightDir: 'D:/right-dir',
|
||||
semantic: true,
|
||||
truncated: false,
|
||||
total: 2,
|
||||
savedAt: 1700000000000,
|
||||
entries: curEntries
|
||||
})
|
||||
const dirs = ['C:/left-dir', 'D:/right-dir']
|
||||
let call = 0
|
||||
window.api = mockApi({
|
||||
pickFolder: async () => dirs[call++] ?? null,
|
||||
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 2 }),
|
||||
openFile: async () => ({
|
||||
path: '/tmp/snap.json',
|
||||
name: 'snap.json',
|
||||
text: snapJson,
|
||||
encoding: 'UTF-8',
|
||||
binary: false
|
||||
}),
|
||||
readByPath: async (p: string) => ({
|
||||
path: p,
|
||||
name: 'b.txt',
|
||||
text: 'plain current content',
|
||||
encoding: 'UTF-8',
|
||||
binary: false
|
||||
})
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('对比文件夹'))
|
||||
await screen.findByText('b.txt')
|
||||
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
|
||||
await screen.findByText(/已加载快照/)
|
||||
fireEvent.dblClick(screen.getByText('b.txt'))
|
||||
// 两侧面板均为当前文件(无快照基线面板)
|
||||
await screen.findByText('← 返回文件夹对比')
|
||||
const paneFiles = Array.from(document.querySelectorAll('.pane-file')).map((e) => e.textContent)
|
||||
expect(paneFiles).toEqual(['b.txt', 'b.txt'])
|
||||
expect(screen.queryByText(/快照 ·/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App - 扫描进度反馈', () => {
|
||||
it('扫描中进度事件显示在遮罩;旧会话的迟到进度被丢弃;完成后遮罩消失', async () => {
|
||||
let progressCb: ((d: { scanId: number; done: number; total: number }) => void) | null = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let resolveScan: (v: any) => void = () => {}
|
||||
const dirs = ['C:/l', 'D:/r']
|
||||
let pick = 0
|
||||
window.api = mockApi({
|
||||
pickFolder: async () => dirs[pick++] ?? null,
|
||||
// 扫描挂起:遮罩期间触发进度事件验证显示与过滤
|
||||
scanFolder: async () =>
|
||||
new Promise((res) => {
|
||||
resolveScan = res
|
||||
}) as unknown as ReturnType<typeof window.api.scanFolder>,
|
||||
onScanProgress: (cb: (d: { scanId: number; done: number; total: number }) => void) => {
|
||||
progressCb = cb
|
||||
return () => {
|
||||
progressCb = null
|
||||
}
|
||||
}
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('对比文件夹'))
|
||||
// 扫描挂起中:App 层遮罩可见(mode 仍为 file)
|
||||
await screen.findByText(/正在扫描文件夹/)
|
||||
// 当前会话(seq=1)的进度事件:遮罩显示已比对进度
|
||||
act(() => {
|
||||
progressCb?.({ scanId: 1, done: 3, total: 9 })
|
||||
})
|
||||
expect(screen.getByText(/已比对 3 \/ 9/)).toBeInTheDocument()
|
||||
// 旧会话(scanId=0)的迟到进度被丢弃(不覆盖当前显示)
|
||||
act(() => {
|
||||
progressCb?.({ scanId: 0, done: 8, total: 9 })
|
||||
})
|
||||
expect(screen.getByText(/已比对 3 \/ 9/)).toBeInTheDocument()
|
||||
// 扫描完成:遮罩与进度消失
|
||||
await act(async () => {
|
||||
resolveScan({ entries: [], truncated: false, total: 0 })
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(/正在扫描文件夹/)).not.toBeInTheDocument()
|
||||
)
|
||||
expect(screen.queryByText(/已比对/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,19 @@ describe('main 入口', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="root"></div>'
|
||||
vi.resetModules()
|
||||
// App 挂载期订阅扫描进度推送(onScanProgress):入口冒烟需要可用的 api 桩
|
||||
window.api = {
|
||||
openFile: async () => null,
|
||||
decodeBuffer: async () => ({ text: '', encoding: 'UTF-8', binary: false }),
|
||||
saveReport: async () => ({ ok: false, path: null }),
|
||||
showInFolder: async () => true,
|
||||
setClipboard: async () => true,
|
||||
pickFolder: async () => null,
|
||||
scanFolder: async () => ({ entries: [], truncated: false, total: 0 }),
|
||||
onScanProgress: (): (() => void) => () => {},
|
||||
readByPath: async () => null,
|
||||
readFilesByPaths: async () => ({ files: [] })
|
||||
} as typeof window.api
|
||||
})
|
||||
|
||||
it('挂载 DiffLens 应用(覆盖 main.tsx 入口)', async () => {
|
||||
|
||||
@@ -352,6 +352,21 @@ describe('FolderView - 忽略规则', () => {
|
||||
Array.from({ length: 50 }, (_, i) => `d${i}/`)
|
||||
)
|
||||
})
|
||||
|
||||
it('无效规则实时提示(不再静默丢弃),修正/清空后提示消失', () => {
|
||||
render(<FolderView {...fullProps(ENTRIES, { onIgnoreRulesChange: vi.fn() })} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '忽略规则' }))
|
||||
const ta = screen.getByPlaceholderText(/每行一条/)
|
||||
// 输入合法与非法混合规则
|
||||
fireEvent.change(ta, { target: { value: 'node_modules/\na**b\n!' } })
|
||||
expect(screen.getByText(/将被忽略的无效规则:a\*\*b、!/)).toBeInTheDocument()
|
||||
// 修正非法规则后提示消失
|
||||
fireEvent.change(ta, { target: { value: 'node_modules/\nbuild/**/cache' } })
|
||||
expect(screen.queryByText(/将被忽略/)).not.toBeInTheDocument()
|
||||
// 清空草稿同样消失
|
||||
fireEvent.change(ta, { target: { value: '' } })
|
||||
expect(screen.queryByText(/将被忽略/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FolderView - 快照对照模式', () => {
|
||||
@@ -489,6 +504,22 @@ describe('FolderView - 快照对照模式', () => {
|
||||
)
|
||||
expect(screen.getByText('与快照基线完全一致,没有任何变迁')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('对照条目含基线内容时 title 提示内容对照(v2 快照)', () => {
|
||||
// v2 快照带 contents:a.txt 有基线内容 → title 提示;new.txt 无内容 → 不提示
|
||||
const snapV2: FolderSnapshot = {
|
||||
...snap,
|
||||
version: 2,
|
||||
contents: { 'a.txt': { left: 'baseline', right: null } }
|
||||
}
|
||||
render(
|
||||
<FolderView {...fullProps(CUR_ENTRIES, { snapshot: snapV2, snapshotDiff: snapDiff })} />
|
||||
)
|
||||
const rowWith = screen.getByText('a.txt').closest('.folder-row') as HTMLElement
|
||||
expect(rowWith.title).toContain('双击查看快照基线内容对照')
|
||||
const rowWithout = screen.getByText('new.txt').closest('.folder-row') as HTMLElement
|
||||
expect(rowWithout.title).not.toContain('双击查看快照基线内容对照')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FolderView - 其他', () => {
|
||||
@@ -567,6 +598,17 @@ describe('FolderView - 其他', () => {
|
||||
expect(onSemanticChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('扫描进度显示在遮罩(progress props),无进度时仅基础文案', () => {
|
||||
const { rerender } = render(
|
||||
<FolderView {...fullProps(ENTRIES, { scanning: true, progress: { done: 3, total: 9 } })} />
|
||||
)
|
||||
expect(screen.getByText(/正在扫描文件夹… 已比对 3 \/ 9/)).toBeInTheDocument()
|
||||
// 进度清空(比对完成或尚未开始):回到基础文案
|
||||
rerender(<FolderView {...fullProps(ENTRIES, { scanning: true, progress: null })} />)
|
||||
expect(screen.getByText('正在扫描文件夹…')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/已比对/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('扫描中禁用忽略规则入口(应用规则会触发重扫)', () => {
|
||||
const onIgnoreRulesChange = vi.fn()
|
||||
render(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { countFolderStats, fmtSize } from '../diff/folderReport'
|
||||
import type { FolderSnapshot, SnapshotChange, SnapshotDiffResult } from '../diff/snapshotDiff'
|
||||
import { SNAPSHOT_CHANGE_LABEL } from '../diff/snapshotDiff'
|
||||
import { useDismiss } from '../hooks/useDismiss'
|
||||
import { compileIgnoreRules } from '../../../shared/ignoreRules'
|
||||
import { buildFolderTree, filterEntries, flattenFolderTree, relExt } from './folderTree'
|
||||
|
||||
/** 文件夹列表虚拟行高(px),与 global.css 的 --folder-row-height 保持一致,改动需双向同步 */
|
||||
@@ -33,6 +34,8 @@ export interface FolderViewProps {
|
||||
truncated: boolean
|
||||
total: number
|
||||
scanning: boolean
|
||||
/** 比对进度(主进程推送;null = 尚无进度态,仅扫描中遮罩展示) */
|
||||
progress?: { done: number; total: number } | null
|
||||
/** 仅看差异(隐藏两侧一致的文件);与状态过滤互斥(后者开启时本开关自动关闭) */
|
||||
onlyDiff: boolean
|
||||
onOnlyDiffChange: (v: boolean) => void
|
||||
@@ -109,6 +112,7 @@ export default function FolderView({
|
||||
truncated,
|
||||
total,
|
||||
scanning,
|
||||
progress,
|
||||
onlyDiff,
|
||||
onOnlyDiffChange,
|
||||
semantic,
|
||||
@@ -154,6 +158,15 @@ export default function FolderView({
|
||||
const closeIgnore = useCallback((): void => setIgnoreOpen(false), [])
|
||||
useDismiss(ignoreWrapRef, closeIgnore)
|
||||
|
||||
// 草稿的无效规则清单(实时提示,不再静默丢弃):逐行 trim 后编译,收集非法规则原文
|
||||
const ignoreInvalid = useMemo(() => {
|
||||
const lines = ignoreDraft
|
||||
.split('\n')
|
||||
.map((r) => r.trim())
|
||||
.filter((r) => r !== '')
|
||||
return compileIgnoreRules(lines).invalid
|
||||
}, [ignoreDraft])
|
||||
|
||||
// 快照对照模式的变迁类型过滤(null = 不过滤;单选 toggle)
|
||||
const [changeFilter, setChangeFilter] = useState<SnapshotChange | null>(null)
|
||||
|
||||
@@ -495,7 +508,7 @@ export default function FolderView({
|
||||
title={
|
||||
scanning
|
||||
? '正在扫描文件夹'
|
||||
: '忽略规则(gitignore 简化子集):node_modules/ 目录整树剪枝、*.log 扩展名通配、temp 名字匹配;应用后重新扫描'
|
||||
: '忽略规则(gitignore 简化子集含高级 glob):node_modules/ 目录整树剪枝、*.log 通配、src/temp 多段路径、build/**/cache 跨段、!keep.log 否定;应用后重新扫描'
|
||||
}
|
||||
onClick={() => {
|
||||
setIgnoreDraft(ignoreRules.join('\n'))
|
||||
@@ -511,9 +524,16 @@ export default function FolderView({
|
||||
value={ignoreDraft}
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
placeholder={'每行一条(gitignore 简化子集):\nnode_modules/ 目录整树剪枝\n*.log 扩展名通配\ntemp 名字匹配'}
|
||||
placeholder={
|
||||
'每行一条(gitignore 简化子集):\nnode_modules/ 目录整树剪枝\n*.log 扩展名通配\ntemp 名字匹配\nsrc/temp 多段路径(从根)\nbuild/**/cache ** 跨段\n!keep.log 否定(最后匹配优先)'
|
||||
}
|
||||
onChange={(e) => setIgnoreDraft(e.target.value)}
|
||||
/>
|
||||
{ignoreInvalid.length > 0 && (
|
||||
<div className="ignore-invalid" title="以下规则不被支持,应用时将被忽略">
|
||||
将被忽略的无效规则:{ignoreInvalid.join('、')}
|
||||
</div>
|
||||
)}
|
||||
<button className="ctx-item" onClick={applyIgnoreDraft}>
|
||||
应用并重扫
|
||||
</button>
|
||||
@@ -572,12 +592,17 @@ export default function FolderView({
|
||||
const display = e.current ?? e.baseline!
|
||||
const meta = STATUS_META[display.status]
|
||||
const sc = SNAP_CHANGES.find((c) => c.key === e.change)!
|
||||
// 快照含基线内容时双击进入"快照基线 vs 当前"的内容对照(App 判定 contents)
|
||||
const hasContents =
|
||||
e.current !== null &&
|
||||
snapshot?.contents?.[e.rel] !== undefined &&
|
||||
(snapshot.contents[e.rel].left !== null || snapshot.contents[e.rel].right !== null)
|
||||
return (
|
||||
<div
|
||||
className={'folder-row ' + meta.cls}
|
||||
key={e.rel}
|
||||
style={{ paddingLeft: 18 }}
|
||||
title={`${e.rel} · 快照对照:${sc.text}${e.current ? '' : '(当前已不存在,大小为快照值)'}`}
|
||||
title={`${e.rel} · 快照对照:${sc.text}${e.current ? '' : '(当前已不存在,大小为快照值)'}${hasContents ? ' · 双击查看快照基线内容对照' : ''}`}
|
||||
onDoubleClick={() => {
|
||||
if (e.current) onOpenEntry(e.current)
|
||||
}}
|
||||
@@ -661,7 +686,10 @@ export default function FolderView({
|
||||
{scanning && (
|
||||
<div className="computing-overlay">
|
||||
<span className="computing-ring" />
|
||||
<span>正在扫描文件夹…</span>
|
||||
<span>
|
||||
正在扫描文件夹…
|
||||
{progress ? ` 已比对 ${progress.done} / ${progress.total}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
diffSnapshot,
|
||||
snapshotDirsMatch,
|
||||
isDiffStatus,
|
||||
SNAPSHOT_VERSION,
|
||||
SNAPSHOT_FILE_MAX_BYTES,
|
||||
SNAPSHOT_CONTENTS_MAX_CHARS,
|
||||
type FolderSnapshot
|
||||
} from './snapshotDiff'
|
||||
|
||||
/** 构造合法快照 */
|
||||
/** 构造合法快照(v1:无 contents 字段,验证向后兼容加载) */
|
||||
function makeSnapshot(overrides?: Partial<FolderSnapshot>): FolderSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
@@ -35,7 +38,7 @@ describe('parseSnapshot - 解析校验', () => {
|
||||
|
||||
it('非法 JSON / 版本不符 / 顶层字段异常回退 null', () => {
|
||||
expect(parseSnapshot('{broken json!!')).toBeNull()
|
||||
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), version: 2 }))).toBeNull()
|
||||
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), version: 99 }))).toBeNull()
|
||||
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), leftDir: 42 }))).toBeNull()
|
||||
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), semantic: 'yes' }))).toBeNull()
|
||||
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), savedAt: 'no-time' }))).toBeNull()
|
||||
@@ -77,6 +80,70 @@ describe('parseSnapshot - 解析校验', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSnapshot - v2 内容字段(contents)', () => {
|
||||
const v2Snap = (): FolderSnapshot => ({
|
||||
...makeSnapshot({ version: 2 }),
|
||||
contents: {
|
||||
'b.txt': { left: 'baseline left', right: 'baseline right' },
|
||||
'c.txt': { left: null, right: 'only right content' }
|
||||
}
|
||||
})
|
||||
|
||||
it('v2 含 contents 完整解析(含 null 侧)', () => {
|
||||
const parsed = parseSnapshot(JSON.stringify(v2Snap()))
|
||||
expect(parsed).toEqual(v2Snap())
|
||||
})
|
||||
|
||||
it('v2 无 contents 字段合法(纯状态快照:超限降级 / 全部读取失败的保存形态)', () => {
|
||||
const snap = makeSnapshot({ version: 2 })
|
||||
const parsed = parseSnapshot(JSON.stringify(snap))
|
||||
expect(parsed).toEqual(snap)
|
||||
expect(parsed?.contents).toBeUndefined()
|
||||
})
|
||||
|
||||
it('v1(无 contents)与 v2 均可加载,其余版本拒绝', () => {
|
||||
expect(parseSnapshot(JSON.stringify(makeSnapshot()))).not.toBeNull()
|
||||
expect(parseSnapshot(JSON.stringify(makeSnapshot({ version: 2 }))))?.not.toBeNull()
|
||||
expect(parseSnapshot(JSON.stringify(makeSnapshot({ version: 3 })))).toBeNull()
|
||||
expect(parseSnapshot(JSON.stringify(makeSnapshot({ version: 0 })))).toBeNull()
|
||||
})
|
||||
|
||||
it('contents 形态异常整体回退 null(绝不部分加载)', () => {
|
||||
// contents 非对象
|
||||
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot({ version: 2 }), contents: 'x' }))).toBeNull()
|
||||
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot({ version: 2 }), contents: [] }))).toBeNull()
|
||||
// 单条记录 left/right 非字符串也非 null
|
||||
expect(
|
||||
parseSnapshot(
|
||||
JSON.stringify({
|
||||
...makeSnapshot({ version: 2 }),
|
||||
contents: { 'b.txt': { left: 42, right: 'ok' } }
|
||||
})
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
parseSnapshot(
|
||||
JSON.stringify({
|
||||
...makeSnapshot({ version: 2 }),
|
||||
contents: { 'b.txt': { left: 'ok', right: true } }
|
||||
})
|
||||
)
|
||||
).toBeNull()
|
||||
// 单条记录非对象
|
||||
expect(
|
||||
parseSnapshot(
|
||||
JSON.stringify({ ...makeSnapshot({ version: 2 }), contents: { 'b.txt': 'plain' } })
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('内容保存上限常量(单文件 2MB 对齐语义判等上限;总量 32MB)', () => {
|
||||
expect(SNAPSHOT_VERSION).toBe(2)
|
||||
expect(SNAPSHOT_FILE_MAX_BYTES).toBe(2 * 1024 * 1024)
|
||||
expect(SNAPSHOT_CONTENTS_MAX_CHARS).toBe(32 * 1024 * 1024)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDiffStatus / snapshotDirsMatch', () => {
|
||||
it('different / only / unreadable 计为有差异,same / semantic-same 不计', () => {
|
||||
expect(isDiffStatus('different')).toBe(true)
|
||||
|
||||
@@ -3,15 +3,34 @@ import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
|
||||
/**
|
||||
* 文件夹对比快照基线的纯逻辑模块:
|
||||
* 快照文件结构定义、解析校验、快照 ↔ 当前扫描结果的状态变迁对照。
|
||||
* 快照只保存判定结果(不保存文件内容),对照粒度为条目状态变迁。
|
||||
* v2 起可选保存差异条目的双侧文件内容(contents),供对照模式双击进入
|
||||
* "快照基线内容 vs 当前内容"的单文件对比(快照只存内容不存判定外的任何派生数据)。
|
||||
*/
|
||||
|
||||
/** 快照结构版本(不兼容变更时递增并让旧文件作废) */
|
||||
export const SNAPSHOT_VERSION = 1
|
||||
/** 快照结构版本(不兼容变更时递增并让旧文件作废;v1 无 contents 字段,加载时功能降级为纯状态对照) */
|
||||
export const SNAPSHOT_VERSION = 2
|
||||
|
||||
/** 快照文件结构(JSON 落盘,与扫描结果同构 + 目录对与时间戳) */
|
||||
/**
|
||||
* 快照内容保存的单文件字节上限(对齐主进程语义判等的 SEMANTIC_MAX_BYTES):
|
||||
* 超限侧不存内容(快照仍保存状态),双击该条目走常规当前文件对比。
|
||||
*/
|
||||
export const SNAPSHOT_FILE_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* 快照内容总量上限(字符数):差异条目内容合计超出时整体降级为纯状态快照
|
||||
* (保存永不失败,行为可预期优于按字典序随机截断),toast 告知用户。
|
||||
*/
|
||||
export const SNAPSHOT_CONTENTS_MAX_CHARS = 32 * 1024 * 1024
|
||||
|
||||
/** 单个差异条目的双侧内容(对应侧快照时刻不存在或超限为 null) */
|
||||
export interface SnapshotContents {
|
||||
left: string | null
|
||||
right: string | null
|
||||
}
|
||||
|
||||
/** 快照文件结构(JSON 落盘,与扫描结果同构 + 目录对与时间戳 + 可选差异内容) */
|
||||
export interface FolderSnapshot {
|
||||
version: 1
|
||||
version: 1 | 2
|
||||
leftDir: string
|
||||
rightDir: string
|
||||
/** 保存时的语义判等开关(对照口径说明用) */
|
||||
@@ -21,6 +40,8 @@ export interface FolderSnapshot {
|
||||
entries: FolderEntry[]
|
||||
/** 保存时间戳 */
|
||||
savedAt: number
|
||||
/** v2 可选:差异条目的双侧内容(key 为 rel;仅 isDiffStatus 条目;超限/总量降级时缺失) */
|
||||
contents?: Record<string, SnapshotContents>
|
||||
}
|
||||
|
||||
/** 条目状态的合法值(解析校验共用) */
|
||||
@@ -46,28 +67,53 @@ function isEntry(v: unknown): v is FolderEntry {
|
||||
return true
|
||||
}
|
||||
|
||||
/** 单条内容记录形态校验(left/right 为字符串或 null) */
|
||||
function isContents(v: unknown): v is SnapshotContents {
|
||||
if (typeof v !== 'object' || v === null) return false
|
||||
const c = v as Record<string, unknown>
|
||||
return (
|
||||
(c.left === null || typeof c.left === 'string') &&
|
||||
(c.right === null || typeof c.right === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析快照 JSON 文本:JSON 非法 / 版本不符 / 任一字段或条目形态异常
|
||||
* 解析快照 JSON 文本:JSON 非法 / 版本不符(仅接受 1 与 2)/ 任一字段或条目形态异常
|
||||
* 一律回退 null(调用方提示加载失败,绝不部分加载)。
|
||||
* v1 快照无 contents 字段合法(加载后功能降级为纯状态对照)。
|
||||
*/
|
||||
export function parseSnapshot(raw: string): FolderSnapshot | null {
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Record<string, unknown>
|
||||
if (obj.version !== SNAPSHOT_VERSION) return null
|
||||
if (obj.version !== 1 && obj.version !== 2) return null
|
||||
if (typeof obj.leftDir !== 'string' || typeof obj.rightDir !== 'string') return null
|
||||
if (typeof obj.semantic !== 'boolean' || typeof obj.truncated !== 'boolean') return null
|
||||
if (typeof obj.total !== 'number' || !Number.isFinite(obj.total)) return null
|
||||
if (typeof obj.savedAt !== 'number' || !Number.isFinite(obj.savedAt)) return null
|
||||
if (!Array.isArray(obj.entries) || !obj.entries.every(isEntry)) return null
|
||||
// contents 可选;存在时任一条目形态异常整体回退 null(与 entries 的绝不部分加载口径一致)
|
||||
let contents: Record<string, SnapshotContents> | undefined
|
||||
if (obj.contents !== undefined) {
|
||||
if (typeof obj.contents !== 'object' || obj.contents === null || Array.isArray(obj.contents)) {
|
||||
return null
|
||||
}
|
||||
const map: Record<string, SnapshotContents> = {}
|
||||
for (const [rel, val] of Object.entries(obj.contents as Record<string, unknown>)) {
|
||||
if (!isContents(val)) return null
|
||||
map[rel] = val
|
||||
}
|
||||
contents = map
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
version: obj.version,
|
||||
leftDir: obj.leftDir,
|
||||
rightDir: obj.rightDir,
|
||||
semantic: obj.semantic,
|
||||
truncated: obj.truncated,
|
||||
total: obj.total,
|
||||
entries: obj.entries,
|
||||
savedAt: obj.savedAt
|
||||
savedAt: obj.savedAt,
|
||||
...(contents !== undefined ? { contents } : {})
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -674,6 +674,19 @@ button:disabled {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 忽略规则下拉内的无效规则提示(琥珀警示,与截断警示同语言) */
|
||||
.ignore-invalid {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 6px;
|
||||
color: var(--mod-fg);
|
||||
background: var(--mod-bg);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ============ 快照对照(变迁统计徽章与条目标注) ============ */
|
||||
.fstat.snap-fixed {
|
||||
color: var(--add-fg);
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
compileIgnoreRules,
|
||||
isIgnoreRulesArray,
|
||||
IGNORE_RULES_MAX,
|
||||
IGNORE_RULE_MAX_LEN
|
||||
} from './ignoreRules'
|
||||
|
||||
/** 便捷断言:路径是否被忽略(isDir 缺省 false) */
|
||||
function ignored(rules: string[], rel: string, isDir = false): boolean {
|
||||
return compileIgnoreRules(rules).matchesPath(rel, isDir)
|
||||
}
|
||||
|
||||
describe('compileIgnoreRules - 名字与目录规则(既有语义回归)', () => {
|
||||
it('单段名字匹配:任意层级命中文件与目录', () => {
|
||||
expect(ignored(['temp'], 'temp')).toBe(true)
|
||||
expect(ignored(['temp'], 'a/temp')).toBe(true)
|
||||
expect(ignored(['temp'], 'a/b/temp', true)).toBe(true)
|
||||
expect(ignored(['temp'], 'temp.txt')).toBe(false)
|
||||
expect(ignored(['temp'], 'temporary')).toBe(false)
|
||||
})
|
||||
|
||||
it('尾 / 目录专用:文件不命中', () => {
|
||||
expect(ignored(['node_modules/'], 'node_modules', true)).toBe(true)
|
||||
expect(ignored(['node_modules/'], 'node_modules')).toBe(false)
|
||||
expect(ignored(['node_modules/'], 'pkg/node_modules', true)).toBe(true)
|
||||
expect(ignored(['node_modules/'], 'a.txt')).toBe(false)
|
||||
})
|
||||
|
||||
it('大小写不敏感(双侧)', () => {
|
||||
expect(ignored(['NODE_MODULES/'], 'node_modules', true)).toBe(true)
|
||||
expect(ignored(['node_modules/'], 'Node_Modules', true)).toBe(true)
|
||||
expect(ignored(['*.log'], 'a.LOG')).toBe(true)
|
||||
})
|
||||
|
||||
it('扩展名通配 *.ext:任意层级后缀命中(段内 * 可匹配零字符,对齐 gitignore)', () => {
|
||||
expect(ignored(['*.log'], 'app.log')).toBe(true)
|
||||
expect(ignored(['*.log'], 'sub/app.log')).toBe(true)
|
||||
expect(ignored(['*.log'], 'app.log.txt')).toBe(false)
|
||||
expect(ignored(['*.log'], '.log')).toBe(true)
|
||||
// 非目录专用的通配规则同样可命中目录名
|
||||
expect(ignored(['*.log'], 'dump.log', true)).toBe(true)
|
||||
})
|
||||
|
||||
it('目录专用通配(*.log/)仅匹配以 .log 结尾的目录名', () => {
|
||||
expect(ignored(['*.log/'], 'debug.log', true)).toBe(true)
|
||||
expect(ignored(['*.log/'], 'logs', true)).toBe(false)
|
||||
expect(ignored(['*.log/'], 'debug.log')).toBe(false)
|
||||
})
|
||||
|
||||
it('多条规则任一命中即可', () => {
|
||||
const m = compileIgnoreRules(['temp', 'dist/', '*.tmp'])
|
||||
expect(m.matchesPath('dist', true)).toBe(true)
|
||||
expect(m.matchesPath('a.tmp')).toBe(true)
|
||||
expect(m.matchesPath('temp')).toBe(true)
|
||||
expect(m.matchesPath('keep.txt')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compileIgnoreRules - 多段路径与前导 / 锚定', () => {
|
||||
it('多段路径从根锚定匹配(模式精确匹配路径;子树忽略由目录剪枝实现,与 gitignore 一致)', () => {
|
||||
expect(ignored(['src/temp'], 'src/temp')).toBe(true)
|
||||
expect(ignored(['src/temp'], 'src/temp', true)).toBe(true) // 目录命中 → BFS 整树剪枝
|
||||
expect(ignored(['src/temp'], 'src/temp/x.ts')).toBe(false) // 模式不匹配子路径(剪枝后不会被查询)
|
||||
expect(ignored(['src/temp'], 'src/deep/temp')).toBe(false) // 中间层不命中
|
||||
expect(ignored(['src/temp'], 'lib/temp')).toBe(false) // 其他分支不命中
|
||||
})
|
||||
|
||||
it('多段路径尾 / 目录专用', () => {
|
||||
expect(ignored(['src/temp/'], 'src/temp', true)).toBe(true)
|
||||
expect(ignored(['src/temp/'], 'src/temp')).toBe(false)
|
||||
})
|
||||
|
||||
it('前导 / 锚定:仅根级匹配(无前导 / 的单段名为任意层级)', () => {
|
||||
expect(ignored(['/build'], 'build', true)).toBe(true)
|
||||
expect(ignored(['/build'], 'build')).toBe(true) // 无尾 / 也匹配文件
|
||||
expect(ignored(['/build'], 'build/out/x.js')).toBe(false) // 子树由目录剪枝实现
|
||||
expect(ignored(['/build'], 'packages/app/build', true)).toBe(false)
|
||||
expect(ignored(['build'], 'packages/app/build', true)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compileIgnoreRules - ** 跨段通配', () => {
|
||||
it('build、**、cache 三段模式:build 下任意深度(含零段)的 cache', () => {
|
||||
expect(ignored(['build/**/cache'], 'build/cache', true)).toBe(true)
|
||||
expect(ignored(['build/**/cache'], 'build/a/cache', true)).toBe(true)
|
||||
expect(ignored(['build/**/cache'], 'build/a/b/cache', true)).toBe(true)
|
||||
expect(ignored(['build/**/cache'], 'build/a/b/cache/x.js')).toBe(false) // 子树由 cache 目录剪枝实现
|
||||
expect(ignored(['build/**/cache'], 'dist/a/cache', true)).toBe(false)
|
||||
expect(ignored(['build/**/cache'], 'build/a/temp', true)).toBe(false)
|
||||
})
|
||||
|
||||
it('尾部 ** 需至少一段:a/** 不匹配 a 自身(对齐 gitignore)', () => {
|
||||
expect(ignored(['a/**'], 'a')).toBe(false)
|
||||
expect(ignored(['a/**'], 'a', true)).toBe(false)
|
||||
expect(ignored(['a/**'], 'a/b')).toBe(true)
|
||||
expect(ignored(['a/**'], 'a/b/c')).toBe(true)
|
||||
expect(ignored(['a/**'], 'x/a/b')).toBe(false) // 多段锚定根
|
||||
})
|
||||
|
||||
it('前缀 **/:显式写法与单段名等价(任意层级)', () => {
|
||||
expect(ignored(['**/temp'], 'temp', true)).toBe(true)
|
||||
expect(ignored(['**/temp'], 'a/b/temp', true)).toBe(true)
|
||||
expect(ignored(['**/temp'], 'a/temptation')).toBe(false)
|
||||
})
|
||||
|
||||
it('** 与段内通配组合', () => {
|
||||
expect(ignored(['**/*.min.js'], 'app.min.js')).toBe(true)
|
||||
expect(ignored(['**/*.min.js'], 'dist/app.min.js')).toBe(true)
|
||||
expect(ignored(['**/*.min.js'], 'app.js')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compileIgnoreRules - 段内 * 通配', () => {
|
||||
it('* 位置不限,可匹配零字符(glob 惯例)', () => {
|
||||
expect(ignored(['a*b'], 'ab')).toBe(true)
|
||||
expect(ignored(['a*b'], 'axb')).toBe(true)
|
||||
expect(ignored(['a*b'], 'a-very-long-b')).toBe(true)
|
||||
expect(ignored(['a*b'], 'a/b')).toBe(false) // 不跨段
|
||||
expect(ignored(['*cache'], 'my-cache')).toBe(true)
|
||||
expect(ignored(['temp*'], 'temporary')).toBe(true)
|
||||
})
|
||||
|
||||
it('段内多个 * 组合', () => {
|
||||
expect(ignored(['a*c*e'], 'abcde')).toBe(true)
|
||||
expect(ignored(['a*c*e'], 'ace')).toBe(true) // 段内 * 可匹配零字符
|
||||
expect(ignored(['a*c*e'], 'abcd')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compileIgnoreRules - 否定规则(! 最后匹配优先)', () => {
|
||||
it('否定救回:同路径最后一条匹配规则生效', () => {
|
||||
const m = compileIgnoreRules(['*.log', '!keep.log'])
|
||||
expect(m.matchesPath('app.log')).toBe(true)
|
||||
expect(m.matchesPath('keep.log')).toBe(false)
|
||||
expect(m.matchesPath('sub/keep.log')).toBe(false) // *.log 与 !keep.log 均按路径命中
|
||||
})
|
||||
|
||||
it('否定顺序反转:! 在前则被后规则重新忽略', () => {
|
||||
expect(ignored(['!keep.log', '*.log'], 'keep.log')).toBe(true)
|
||||
})
|
||||
|
||||
it('否定目录路径本身(父目录被剪枝时子项实际救不回,gitignore 同款语义)', () => {
|
||||
const m = compileIgnoreRules(['temp/', '!temp/keep/'])
|
||||
expect(m.matchesPath('temp', true)).toBe(true) // temp 目录被忽略 → BFS 剪枝,keep 实际救不回
|
||||
expect(m.matchesPath('temp/keep', true)).toBe(false) // 匹配器层面不忽略(剪枝后不会被查询)
|
||||
})
|
||||
|
||||
it('** + 否定:深层路径可被精确否定救回(父目录未被剪枝时)', () => {
|
||||
const m = compileIgnoreRules(['build/**/temp/', '!build/special/temp/'])
|
||||
expect(m.matchesPath('build/a/temp', true)).toBe(true)
|
||||
expect(m.matchesPath('build/special/temp', true)).toBe(false)
|
||||
expect(m.matchesPath('build/special/temp/x.js')).toBe(false)
|
||||
})
|
||||
|
||||
it('父目录被剪枝后子项无法反否定(BFS 剪枝语义,与 gitignore 一致)', () => {
|
||||
// node_modules/ 忽略 node_modules 目录本身 → BFS 剪枝,keep 永远不被枚举
|
||||
// 匹配器层面体现为:node_modules/keep.js 不因 !node_modules/keep.js 而改变
|
||||
// node_modules 目录的判定(剪枝依据)只受匹配 node_modules 路径的规则影响
|
||||
const m = compileIgnoreRules(['node_modules/', '!node_modules/keep.js'])
|
||||
expect(m.matchesPath('node_modules', true)).toBe(true) // 目录仍被剪枝
|
||||
expect(m.matchesPath('node_modules/keep.js')).toBe(false) // 假如被枚举到则不忽略
|
||||
})
|
||||
|
||||
it('否定 + 前导 / 锚定组合', () => {
|
||||
const m = compileIgnoreRules(['/dist', '!/dist'])
|
||||
expect(m.matchesPath('dist', true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compileIgnoreRules - invalid 无效规则清单', () => {
|
||||
it('注释与空白行不计入 invalid', () => {
|
||||
const m = compileIgnoreRules(['', ' ', '# 构建产物', 'dist/'])
|
||||
expect(m.invalid).toEqual([])
|
||||
expect(m.matchesPath('dist', true)).toBe(true)
|
||||
})
|
||||
|
||||
it('段内 ** 为非法(仅允许整段 **)', () => {
|
||||
const m = compileIgnoreRules(['a**b', 'build/**'])
|
||||
expect(m.invalid).toEqual(['a**b'])
|
||||
expect(m.matchesPath('axxb')).toBe(false)
|
||||
expect(m.matchesPath('build/x')).toBe(true)
|
||||
})
|
||||
|
||||
it('空段(a//b、仅 /、! 后为空)非法', () => {
|
||||
const m = compileIgnoreRules(['a//b', '/', '!', '!/', 'ok/'])
|
||||
expect(m.invalid).toEqual(['a//b', '/', '!', '!/'])
|
||||
expect(m.matchesPath('ok', true)).toBe(true)
|
||||
})
|
||||
|
||||
it('规则首尾空白被 trim(trim 后仍非法的按原文 trim 记录)', () => {
|
||||
const m = compileIgnoreRules([' node_modules/ ', ' a**b '])
|
||||
expect(m.matchesPath('node_modules', true)).toBe(true)
|
||||
expect(m.invalid).toEqual(['a**b'])
|
||||
})
|
||||
|
||||
it('空数组与全非法输入产出永不匹配的匹配器', () => {
|
||||
const empty = compileIgnoreRules([])
|
||||
expect(empty.matchesPath('node_modules', true)).toBe(false)
|
||||
expect(empty.matchesPath('a.log')).toBe(false)
|
||||
expect(empty.invalid).toEqual([])
|
||||
const allInvalid = compileIgnoreRules(['/', '!', 'a**b'])
|
||||
expect(allInvalid.matchesPath('anything')).toBe(false)
|
||||
expect(allInvalid.invalid).toEqual(['/', '!', 'a**b'])
|
||||
})
|
||||
|
||||
it('超长单条规则(> 500 字符)被忽略并记入 invalid', () => {
|
||||
const long = 'x'.repeat(IGNORE_RULE_MAX_LEN + 1)
|
||||
const m = compileIgnoreRules([long])
|
||||
expect(m.matchesPath(long)).toBe(false)
|
||||
expect(m.invalid).toEqual([long])
|
||||
})
|
||||
|
||||
it('条数超上限截断(第 51 条有效规则起不生效)', () => {
|
||||
const rules = Array.from({ length: IGNORE_RULES_MAX + 5 }, (_, i) => `d${i}/`)
|
||||
const m = compileIgnoreRules(rules)
|
||||
expect(m.matchesPath('d0', true)).toBe(true)
|
||||
expect(m.matchesPath(`d${IGNORE_RULES_MAX - 1}`, true)).toBe(true)
|
||||
expect(m.matchesPath(`d${IGNORE_RULES_MAX}`, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isIgnoreRulesArray - IPC 防御性校验', () => {
|
||||
it('合法字符串数组通过', () => {
|
||||
expect(isIgnoreRulesArray([])).toBe(true)
|
||||
expect(isIgnoreRulesArray(['node_modules/', '*.log', 'build/**/cache', '!keep.log'])).toBe(true)
|
||||
})
|
||||
|
||||
it('非数组 / 含非字符串 / 超条数 / 超长条目拒绝', () => {
|
||||
expect(isIgnoreRulesArray(null)).toBe(false)
|
||||
expect(isIgnoreRulesArray('node_modules/')).toBe(false)
|
||||
expect(isIgnoreRulesArray(['a', 42])).toBe(false)
|
||||
expect(isIgnoreRulesArray(Array.from({ length: IGNORE_RULES_MAX + 1 }, () => 'a'))).toBe(false)
|
||||
expect(isIgnoreRulesArray(['x'.repeat(IGNORE_RULE_MAX_LEN + 1)])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 文件夹对比忽略规则的纯逻辑模块(无 electron/fs 依赖,vitest 直接测试)。
|
||||
* 位于 src/shared:主进程 folderScan 与渲染端 FolderView(无效规则提示)打包各自引用同一源文件,
|
||||
* 两进程无法共享运行时模块但可共享源码(与 preload 类型反向引用同理)。
|
||||
*
|
||||
* gitignore 简化子集(0.10.0 高级 glob):
|
||||
* - `name`:名字匹配(任意层级命中文件与目录)
|
||||
* - `name/`:目录名匹配(任意层级,仅目录)
|
||||
* - `*.ext` / `a*b`:段内 `*` 通配(匹配任意字符,位置不限;段即 / 切分的路径片段)
|
||||
* - `src/temp`:多段路径(从根锚定匹配)
|
||||
* - `/build`:前导 `/` 锚定(仅根级匹配;无前导 `/` 的单段名为任意层级)
|
||||
* - `**`:整段跨段通配(匹配零或多段,如 build、**、cache 三段模式命中 build 下任意深度的
|
||||
* cache;尾部 `**` 需至少一段——`a/**` 不匹配 a 自身,与 gitignore 一致)
|
||||
* - `!pattern`:否定规则(同一路径以最后一条匹配的规则为准,与 gitignore 一致)
|
||||
*
|
||||
* 语义约定:
|
||||
* - 大小写不敏感(Windows 文件系统语义)
|
||||
* - `#` 开头注释与空白行静默跳过(不计入无效规则)
|
||||
* - 父目录被剪枝后其子项无法被否定规则救回(BFS 剪枝语义,与 gitignore 一致)
|
||||
* - 非法规则(段内 `**`、空段、`!` 后为空、仅分隔符等)跳过并记入 invalid 清单供 UI 提示
|
||||
*/
|
||||
|
||||
/** 规则条数上限(UI 与主进程 IPC 双重防御) */
|
||||
export const IGNORE_RULES_MAX = 50
|
||||
/** 单条规则长度上限 */
|
||||
export const IGNORE_RULE_MAX_LEN = 500
|
||||
|
||||
/** 编译后的匹配器:对完整相对路径判定(含否定规则仲裁) */
|
||||
export interface IgnoreMatcher {
|
||||
/** 相对路径(统一 / 分隔,含文件/目录自身)是否被忽略;isDir 缺省按文件判定 */
|
||||
matchesPath: (rel: string, isDir?: boolean) => boolean
|
||||
/** 被跳过的无效规则原文(供 UI 提示;注释与空白行不计入) */
|
||||
invalid: readonly string[]
|
||||
}
|
||||
|
||||
/** 编译后的单段形态:字面量(=== 快路径)/ 段内通配(正则)/ 跨段通配 */
|
||||
interface SegPattern {
|
||||
kind: 'literal' | 'wild' | 'globstar'
|
||||
/** literal 的字面值(已小写) */
|
||||
lit?: string
|
||||
/** wild 的整段正则(已锚定 ^$) */
|
||||
re?: RegExp
|
||||
}
|
||||
|
||||
interface CompiledRule {
|
||||
/** 段序列(已编译;已小写) */
|
||||
segs: SegPattern[]
|
||||
/** 目录专用(尾部 /) */
|
||||
dirOnly: boolean
|
||||
/** 否定规则(! 前缀) */
|
||||
negate: boolean
|
||||
/** 从根锚定(多段或前导 /);否则任意层级起匹配 */
|
||||
anchored: boolean
|
||||
}
|
||||
|
||||
/** 编译单条规则文本(调用方已做 trim);返回 null 表示无效或为注释/空白行 */
|
||||
function compileOne(rule: string): CompiledRule | null {
|
||||
if (rule.length === 0 || rule.length > IGNORE_RULE_MAX_LEN) return null
|
||||
if (rule.startsWith('#')) return null
|
||||
let body = rule
|
||||
let negate = false
|
||||
if (body.startsWith('!')) {
|
||||
negate = true
|
||||
body = body.slice(1)
|
||||
}
|
||||
let dirOnly = false
|
||||
if (body.endsWith('/')) {
|
||||
dirOnly = true
|
||||
body = body.slice(0, -1)
|
||||
}
|
||||
let anchored = false
|
||||
if (body.startsWith('/')) {
|
||||
anchored = true
|
||||
body = body.slice(1)
|
||||
}
|
||||
if (body.length === 0) return null
|
||||
const rawSegs = body.split('/')
|
||||
if (rawSegs.some((s) => s.length === 0)) return null
|
||||
if (rawSegs.some((s) => s.includes('**') && s !== '**')) return null
|
||||
if (rawSegs.length > 1) anchored = true
|
||||
const lower = rawSegs.map((s) => s.toLowerCase())
|
||||
const segs: SegPattern[] = lower.map((s) => {
|
||||
if (s === '**') return { kind: 'globstar' }
|
||||
if (!s.includes('*')) return { kind: 'literal', lit: s }
|
||||
// 段内 * 通配:* → .*(段内无分隔符),其余字符转义后整段锚定匹配
|
||||
const re = new RegExp('^' + s.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$')
|
||||
return { kind: 'wild', re }
|
||||
})
|
||||
return { segs, dirOnly, negate, anchored }
|
||||
}
|
||||
|
||||
/** 模式段序列是否匹配路径段序列(自后向前 DP)。
|
||||
* `**` 跨零或多段;尾段 `**` 需至少一段(`a/**` 不含 a 自身,对齐 gitignore) */
|
||||
function matchSegs(pat: SegPattern[], path: string[]): boolean {
|
||||
const P = pat.length
|
||||
const L = path.length
|
||||
// dp[i][j] = pat[i..] 匹配 path[j..]
|
||||
const dp: boolean[][] = Array.from({ length: P + 1 }, () => new Array<boolean>(L + 1).fill(false))
|
||||
dp[P][L] = true
|
||||
for (let i = P - 1; i >= 0; i--) {
|
||||
for (let j = L; j >= 0; j--) {
|
||||
const seg = pat[i]
|
||||
if (seg.kind === 'globstar') {
|
||||
dp[i][j] =
|
||||
i === P - 1
|
||||
? j < L // 尾部 **:吞掉剩余全部段(至少一段)
|
||||
: dp[i + 1][j] || (j < L && dp[i][j + 1]) // 非尾部:跨零段或多段
|
||||
} else if (seg.kind === 'literal') {
|
||||
dp[i][j] = j < L && path[j] === seg.lit && dp[i + 1][j + 1]
|
||||
} else {
|
||||
dp[i][j] = j < L && seg.re!.test(path[j]) && dp[i + 1][j + 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return dp[0][0]
|
||||
}
|
||||
|
||||
/** 单条规则是否匹配路径(dirOnly 仅匹配目录;非锚定规则任意起点起匹配) */
|
||||
function ruleMatches(rule: CompiledRule, pathSegs: string[], isDir: boolean): boolean {
|
||||
if (rule.dirOnly && !isDir) return false
|
||||
if (rule.anchored) return matchSegs(rule.segs, pathSegs)
|
||||
for (let k = 0; k < pathSegs.length; k++) {
|
||||
if (matchSegs(rule.segs, pathSegs.slice(k))) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 规则数组是否为可接受的 IPC 形态(folder:scan 防御性校验共用) */
|
||||
export function isIgnoreRulesArray(v: unknown): v is string[] {
|
||||
return (
|
||||
Array.isArray(v) &&
|
||||
v.length <= IGNORE_RULES_MAX &&
|
||||
v.every((r) => typeof r === 'string' && r.length <= IGNORE_RULE_MAX_LEN)
|
||||
)
|
||||
}
|
||||
|
||||
/** 编译规则串数组为匹配器(空数组 / 全部非法 → 永不匹配的空匹配器) */
|
||||
export function compileIgnoreRules(rules: readonly string[]): IgnoreMatcher {
|
||||
const compiled: CompiledRule[] = []
|
||||
const invalid: string[] = []
|
||||
for (const r of rules) {
|
||||
if (compiled.length >= IGNORE_RULES_MAX) break
|
||||
if (typeof r !== 'string') continue
|
||||
const trimmed = r.trim()
|
||||
const c = compileOne(trimmed)
|
||||
if (c) compiled.push(c)
|
||||
else if (trimmed !== '' && !trimmed.startsWith('#')) invalid.push(trimmed)
|
||||
}
|
||||
const matchesPath = (rel: string, isDir?: boolean): boolean => {
|
||||
if (rel === '') return false
|
||||
const dir = isDir === true
|
||||
const segs = rel.toLowerCase().split('/')
|
||||
// 否定仲裁:从后往前找第一条匹配的规则,其否定性决定结果(gitignore 语义)
|
||||
for (let i = compiled.length - 1; i >= 0; i--) {
|
||||
if (ruleMatches(compiled[i], segs, dir)) return !compiled[i].negate
|
||||
}
|
||||
return false
|
||||
}
|
||||
return { matchesPath, invalid }
|
||||
}
|
||||
+2
-1
@@ -3,7 +3,8 @@
|
||||
"include": [
|
||||
"electron.vite.config.*",
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*"
|
||||
"src/preload/**/*",
|
||||
"src/shared/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.tsx",
|
||||
"src/preload/*.d.ts"
|
||||
"src/preload/*.d.ts",
|
||||
"src/shared/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
+4
-3
@@ -15,14 +15,15 @@ export default defineConfig({
|
||||
setupFiles: ['./src/renderer/src/test/setup.ts'],
|
||||
include: [
|
||||
'src/renderer/src/**/*.{test,spec}.{ts,tsx}',
|
||||
// 主进程纯逻辑模块(无 electron 依赖)的单元测试
|
||||
'src/main/**/*.{test,spec}.{ts,tsx}'
|
||||
// 主进程纯逻辑模块(无 electron 依赖)与两进程共享纯逻辑模块的单元测试
|
||||
'src/main/**/*.{test,spec}.{ts,tsx}',
|
||||
'src/shared/**/*.{test,spec}.{ts,tsx}'
|
||||
],
|
||||
exclude: ['node_modules', 'dist', 'out'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'text-summary'],
|
||||
include: ['src/renderer/src/**/*.{ts,tsx}', 'src/main/**/*.ts'],
|
||||
include: ['src/renderer/src/**/*.{ts,tsx}', 'src/main/**/*.ts', 'src/shared/**/*.ts'],
|
||||
exclude: [
|
||||
'src/renderer/src/**/*.{test,spec}.{ts,tsx}',
|
||||
'src/renderer/src/test/**',
|
||||
|
||||
Reference in New Issue
Block a user