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:
2026-08-19 13:24:35 +08:00
parent c1e63c3328
commit c209ae291a
25 changed files with 1456 additions and 316 deletions
+6 -5
View File
@@ -11,8 +11,8 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**忽略规则**(gitignore 简化子集:`node_modules/` 目录整树剪枝、`*.log` 扩展名通配、`temp` 名字匹配,被忽略项不枚举不计数,规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫) - **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**扫描进度反馈**(比对阶段实时显示"已比对 N / M")、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**忽略规则**(gitignore 简化子集含高级 glob`node_modules/` 目录整树剪枝、`*.log` 通配、`src/temp` 多段路径、`build/**/cache` 跨段、`!keep.log` 否定规则(最后匹配优先);无效规则实时提示不再静默丢弃;规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫)
- **快照基线**:把当前文件夹对比结果保存为快照(JSON 文件),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别 - **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么");内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别v1 旧快照兼容加载(纯状态对照)
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成 - **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览) - **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览)
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅 - **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
@@ -95,10 +95,11 @@ npm run build:linux # Linux AppImage / deb
``` ```
src/ src/
├── main/ # 主进程:窗口、菜单、文件/文件夹读取与编码探测(解码 worker 化)、IPC ├── main/ # 主进程:窗口、菜单、文件/文件夹读取与编码探测(解码 worker 化)、批量读取与进度推送 IPC
├── preload/ # 预加载:contextBridge 安全桥,仅暴露最小 API ├── preload/ # 预加载:contextBridge 安全桥,仅暴露最小 API
├── shared/ # 两进程共用的纯逻辑源码(忽略规则匹配器:主进程扫描剪枝 + 渲染端无效规则提示)
└── renderer/ # 渲染进程:React 界面与全部 diff 逻辑 └── renderer/ # 渲染进程:React 界面与全部 diff 逻辑
├── diff/ # diff 引擎(行级 + 词级高亮、Worker 后台计算 ├── diff/ # diff 引擎(行级 + 词级高亮、字符级分块、报告与快照纯逻辑
├── hooks/ # 状态 hooks(diff 计算调度、下拉关闭) ├── hooks/ # 状态 hooks(diff 计算调度、下拉关闭)
├── components/ # 界面组件(DiffView / FolderView / Toolbar 等) ├── components/ # 界面组件(DiffView / FolderView / Toolbar 等)
└── styles/ # 全局主题样式 └── styles/ # 全局主题样式
@@ -115,7 +116,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
- 作者:thzxx - 作者:thzxx
- 组织:MetonaTeam - 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE) - 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.9.3 - 版本:0.10.0
--- ---
+7 -2
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。 > 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.9.3`** 当前基线版本:**`0.10.0`**
--- ---
@@ -117,9 +117,14 @@ z = 补丁版本号(Patch
- 微打磨与依赖:清理 CSS 孤儿变量(--pink/--panel-2 定义未消费) · 依赖 patch 级升级(npm update 语义化范围内,核心依赖 electron/vitest/vite 版本不变,仅间接依赖小幅更新) - 微打磨与依赖:清理 CSS 孤儿变量(--pink/--panel-2 定义未消费) · 依赖 patch 级升级(npm update 语义化范围内,核心依赖 electron/vitest/vite 版本不变,仅间接依赖小幅更新)
0.9.3 ← 单文件视图搜索 · 扫描缓存落盘串行化 · 交互收尾(已发布): 0.9.3 ← 单文件视图搜索 · 扫描缓存落盘串行化 · 交互收尾(已发布):
- 单文件视图文本搜索(补齐文件夹模式有搜索而单文件模式没有的功能缺口):工具栏新增搜索框,Ctrl+F 聚焦(粘贴弹窗/右键菜单打开时不抢占)、大小写不敏感即时命中计数(x / y)、Enter/Shift+Enter 在命中行间循环跳转(复用差异导航的 activeRowId 受控定位,虚拟滚动下天然可用)、行内命中子串高亮(纯文本行与词级 segs 行均拆分标注,黄色系与增删改三色不冲突)、Esc 清空复位 · 搜索基于全量行(与"仅看差异"独立,被折叠的命中行仍计数;跳转目标被折叠时 toast 提示且不推进) · 线性扫描 5 万行毫秒级无需 worker,会话级临时状态不持久化 - 单文件视图文本搜索(补齐文件夹模式有搜索而单文件模式没有的功能缺口):工具栏新增搜索框,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 语义化范围内) - 测试与依赖:新增 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 0.y.z ← 长期停留,永不进入 1.x
``` ```
+24 -19
View File
@@ -41,20 +41,21 @@
## 4. 目录结构 ## 4. 目录结构
``` ```
vitest.config.ts # Vitest 配置(jsdom + 测试范围 + 覆盖率 80% 硬门禁) vitest.config.ts # Vitest 配置(jsdom + 测试范围含 shared + 覆盖率 80% 硬门禁)
playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑) playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑)
e2e/ e2e/
├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口/合成拖放) ├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口/合成拖放)
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/拖拽导入/偏好重启) └── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照/拖拽导入/偏好重启)
src/shared/
├── ignoreRules.ts # 忽略规则匹配器纯逻辑(gitignore 简化子集含高级 glob;主进程扫描剪枝与渲染端无效提示共用,段模式预编译 + DP 段序列匹配 + 否定仲裁)
└── ignoreRules.test.ts # 忽略规则单元测试(名字/目录/通配回归 + 多段/锚定/跨段/段内通配/否定仲裁/无效清单/上限/IPC 校验)
src/main/ src/main/
├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,真实链路由 E2E 覆盖) ├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,真实链路由 E2E 覆盖;含 folder:read-batch 批量读取与 folder:scan 进度推送
├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用) ├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用)
├── decode.test.ts # 解码单元测试(BOM 三种/严格 UTF-8/GBK 回退/二进制启发式) ├── decode.test.ts # 解码单元测试(BOM 三种/严格 UTF-8/GBK 回退/二进制启发式)
├── decodeWorker.ts # 解码 worker_threads 入口(覆盖率排除,逻辑由 decode 测试覆盖) ├── decodeWorker.ts # 解码 worker_threads 入口(覆盖率排除,逻辑由 decode 测试覆盖)
├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对/语义判等/单点失败降级/忽略规则剪枝) ├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对/语义判等/单点失败降级/忽略规则剪枝含高级 glob/比对进度回调
├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似/语义判等/目录降级/unreadable/忽略规则) ├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似/语义判等/目录降级/unreadable/忽略规则含 glob 与否定/进度回调
├── ignoreRules.ts # 忽略规则纯逻辑(gitignore 简化子集编译为 matcher,无 electron 依赖)
├── ignoreRules.test.ts # 忽略规则单元测试(目录剪枝/通配/名字匹配/大小写/非法输入/条数上限/IPC 校验)
├── scanCacheStore.ts # 扫描缓存跨会话持久化纯数据(目录对键归一化/LRU/防御性解析) ├── scanCacheStore.ts # 扫描缓存跨会话持久化纯数据(目录对键归一化/LRU/防御性解析)
├── scanCacheStore.test.ts # 缓存存储单元测试(解析回退/条目过滤/键归一化/LRU 淘汰/序列化往返) ├── scanCacheStore.test.ts # 缓存存储单元测试(解析回退/条目过滤/键归一化/LRU 淘汰/序列化往返)
├── scanIpc.ts # folder:scan IPC 入参校验与缓存源选择纯逻辑(无 electron 依赖) ├── scanIpc.ts # folder:scan IPC 入参校验与缓存源选择纯逻辑(无 electron 依赖)
@@ -72,8 +73,8 @@ src/renderer/src/
│ ├── report.test.ts # 单文件报告生成测试 │ ├── report.test.ts # 单文件报告生成测试
│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注;对照模式条目表替换为变迁对照表) │ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注;对照模式条目表替换为变迁对照表)
│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable/快照变迁对照表) │ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable/快照变迁对照表)
│ ├── snapshotDiff.ts # 快照基线纯逻辑(结构/解析校验/目录对归一化匹配/六类变迁对照/共享变迁标签映射) │ ├── snapshotDiff.ts # 快照基线纯逻辑(v1/v2 结构/解析校验含 contents/目录对归一化匹配/六类变迁对照/共享变迁标签映射/内容保存上限常量
│ ├── snapshotDiff.test.ts # 快照单元测试(解析防御/变迁判定/双侧空集/unreadable 语义/目录对归一化匹配) │ ├── snapshotDiff.test.ts # 快照单元测试(解析防御/v1 v2 兼容/contents 形态校验/变迁判定/双侧空集/unreadable 语义/目录对归一化匹配)
│ └── textUtils.test.ts # 文本扩展名与行数统计测试 │ └── textUtils.test.ts # 文本扩展名与行数统计测试
├── hooks/ ├── hooks/
│ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理/字符级大字符量重路径/选项变化保留旧结果防滚动塌陷) │ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理/字符级大字符量重路径/选项变化保留旧结果防滚动塌陷)
@@ -86,11 +87,11 @@ src/renderer/src/
│ ├── ErrorBoundary.test.tsx # 渲染错误兜底测试(抛错子组件 fallback/正常子组件透传) │ ├── ErrorBoundary.test.tsx # 渲染错误兜底测试(抛错子组件 fallback/正常子组件透传)
│ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤含扩展名/子树收集) │ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤含扩展名/子树收集)
│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt) │ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt)
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉/快照对照模式含变迁过滤归零复位/目录双击/重新扫描与重新选择/双击/导出菜单/虚拟滚动/resize 重算) │ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉含无效规则提示与进度遮罩/快照对照模式含变迁过滤归零复位与内容对照 title/目录双击/重新扫描与重新选择/双击/导出菜单/虚拟滚动/resize 重算)
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗 │ ├── TextInputModal.test.tsx # 粘贴文本弹窗
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记/搜索框计数与 Enter·Shift+Enter·Esc 键盘交互/ref 转发) │ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记/搜索框计数与 Enter·Shift+Enter·Esc 键盘交互/ref 转发)
└── __tests__/ └── __tests__/
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索/Ctrl+F/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截) ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索/Ctrl+F/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截/快照内容级对照(v2 保存含内容·超限降级·双击基线vs当前·v1 兼容)/扫描进度显示与过期丢弃
└── main.test.ts # 入口挂载冒烟测试 └── main.test.ts # 入口挂载冒烟测试
``` ```
@@ -147,7 +148,7 @@ src/renderer/src/
- 单文件视图搜索(0.9.3):输入关键字即时命中计数与首个命中行 active 定位 · Enter/Shift+Enter 命中行间推进回退(行号随动)· 首尾循环 · 大小写不敏感 · 无命中 0/0 且 Enter 不崩溃不推进 · Esc 清空复位 · Ctrl+F 聚焦搜索框 · 命中行被“仅看差异”折叠时计数仍统计全量行、跳转 toast 提示且不推进 - 单文件视图搜索(0.9.3):输入关键字即时命中计数与首个命中行 active 定位 · Enter/Shift+Enter 命中行间推进回退(行号随动)· 首尾循环 · 大小写不敏感 · 无命中 0/0 且 Enter 不崩溃不推进 · Esc 清空复位 · Ctrl+F 聚焦搜索框 · 命中行被“仅看差异”折叠时计数仍统计全量行、跳转 toast 提示且不推进
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式 - 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式
- 快照基线(0.9.00.9.1 收尾,0.9.2 对称防呆):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 当前扫描结果截断时加载快照同样被拦截(0.9.2,不弹文件选择——截断缺失文件会被误报已移除) · 对照模式中重扫触发截断自动退出对照并提示(0.9.2) · 快照语义判等口径与当前开关不一致时加载提示含口径差异、一致时无提示(0.9.2) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新 - 快照基线(0.9.00.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` ### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`
- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致 - 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致
@@ -177,15 +178,17 @@ src/renderer/src/
- 边界与选项:两个空目录 / maxFiles 截断(truncated + total + 条目数受限;截断后不再深入子目录,total 为已遍历下限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different - 边界与选项:两个空目录 / maxFiles 截断(truncated + total + 条目数受限;截断后不再深入子目录,total 为已遍历下限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different
- 受控并发:跨多个并发批次(> 2×SCAN_CONCURRENCY)的混合状态全部判定正确、条目排序与并发执行顺序无关 - 受控并发:跨多个并发批次(> 2×SCAN_CONCURRENCY)的混合状态全部判定正确、条目排序与并发执行顺序无关
- 增量重扫(0.8.0,previous 缓存):指纹一致(size+mtime 未变)零 IO 沿用判定(decode 计数零增长)/ 语义开关翻转按 semSame 纯内存组合(different ↔ semantic-same 零 IO,开→关→开三轮)/ 上次未参与语义(semSame null)而本次开启读内容补判 / 指纹变化(内容修改)重新判定且文件增删正确 / 单侧缺失形状变化不误沿用 / 本次截断忽略 previous 且 cache 产出为空 / unreadable 不进缓存(故障恢复后重扫正确判定) - 增量重扫(0.8.0,previous 缓存):指纹一致(size+mtime 未变)零 IO 沿用判定(decode 计数零增长)/ 语义开关翻转按 semSame 纯内存组合(different ↔ semantic-same 零 IO,开→关→开三轮)/ 上次未参与语义(semSame null)而本次开启读内容补判 / 指纹变化(内容修改)重新判定且文件增删正确 / 单侧缺失形状变化不误沿用 / 本次截断忽略 previous 且 cache 产出为空 / unreadable 不进缓存(故障恢复后重扫正确判定)
- 忽略规则(0.9.0):目录规则整树剪枝(深层子树不枚举、不计数、不进结果与缓存)/ 扩展名通配与名字匹配跳过文件(大小写不敏感)/ 被忽略文件不占用 maxFiles(原本截断的场景忽略后完整扫描)/ 规则变化后重扫缓存未命中的重新纳入条目走全量判定 / 空规则数组与全非法规则不产生任何剪枝 - 忽略规则(0.9.00.10.0 高级 glob):目录规则整树剪枝(深层子树不枚举、不计数、不进结果与缓存)/ 扩展名通配与名字匹配跳过文件(大小写不敏感)/ 被忽略文件不占用 maxFiles(原本截断的场景忽略后完整扫描)/ 规则变化后重扫缓存未命中的重新纳入条目走全量判定 / 空规则数组与全非法规则不产生任何剪枝 / 多段路径从根锚定剪枝(其他分支同名目录不受影响)/ ** 跨段剪枝与否定救回(父目录未被剪枝时子路径可被反否定)/ 父目录被剪枝后否定规则救不回子项(gitignore 同款语义)
- 比对进度回调(0.10.0):需读内容的条目按批推进进度(total 恒为需读内容条目数、done 单调递增至 total)/ 全部条目零 IO(单侧缺失)时不触发回调
### 5.10 文件夹视图(`FolderView.tsx`)与条目树(`folderTree.ts` ### 5.10 文件夹视图(`FolderView.tsx`)与条目树(`folderTree.ts`
- 条目树纯逻辑:按路径分段聚合目录/文件(同层字典序);目录聚合子树状态(different > unreadable > only > 语义同 > 相同)与文件计数;折叠目录子树隐藏;过滤(onlyDiff / statusFilter / query 子串不区分大小写 / exts 扩展名集合);空目录剪枝由“过滤后建树”天然保证;relExt 取文件名段扩展名(小写、无扩展名空串、不误取目录名中的点) - 条目树纯逻辑:按路径分段聚合目录/文件(同层字典序);目录聚合子树状态(different > unreadable > only > 语义同 > 相同)与文件计数;折叠目录子树隐藏;过滤(onlyDiff / statusFilter / query 子串不区分大小写 / exts 扩展名集合);空目录剪枝由“过滤后建树”天然保证;relExt 取文件名段扩展名(小写、无扩展名空串、不误取目录名中的点)
- 渲染:目录路径卡片 / 重新扫描、重新选择、导出报告与返回按钮 / 统计徽章(相同/语义同/不同/仅左/仅右/无法读取/总数;语义关闭隐藏语义同、无失败隐藏无法读取) - 渲染:目录路径卡片 / 重新扫描、重新选择、导出报告与返回按钮 / 统计徽章(相同/语义同/不同/仅左/仅右/无法读取/总数;语义关闭隐藏语义同、无失败隐藏无法读取)
- 树形交互:目录行点击折叠展开(默认全展开);搜索命中强制展开命中路径、清空恢复用户折叠;无命中提示 - 树形交互:目录行点击折叠展开(默认全展开);搜索命中强制展开命中路径、清空恢复用户折叠;无命中提示
- 扩展名过滤(0.8.0):下拉展示全量条目 ext→count(字典序、无扩展名单列);点选过滤与其他条件取交集、计数切 N / 总数;多选并集、再点取消、清除按钮复位;与状态过滤双激活空态提示;点击外部关闭且过滤状态保留 - 扩展名过滤(0.8.0):下拉展示全量条目 ext→count(字典序、无扩展名单列);点选过滤与其他条件取交集、计数切 N / 总数;多选并集、再点取消、清除按钮复位;与状态过滤双激活空态提示;点击外部关闭且过滤状态保留
- 忽略规则(0.9.0):已有规则时入口标注数量并高亮;下拉打开以当前规则初始化草稿,逐行解析去空白去空行(截断至 50 条)后应用回调并关闭;清空规则回调空数组;点击外部关闭不触发回调 - 忽略规则(0.9.00.10.0 高级 glob 与无效提示):已有规则时入口标注数量并高亮;下拉打开以当前规则初始化草稿,逐行解析去空白去空行(截断至 50 条)后应用回调并关闭;清空规则回调空数组;点击外部关闭不触发回调;无效规则实时提示"将被忽略的无效规则"(修正/清空后提示消失,不再静默丢弃)
- 快照对照模式(0.9.0):变迁统计徽章(已修复/新增差异/新文件/已移除/仍有差异/保持一致)与基线时间替换常规统计行;对照条目平铺全路径渲染、行尾变迁徽章、gone 回退快照数据;零计数变迁不占位;默认隐藏 stable、点击徽章单选过滤再点取消;双击当前条目进单文件对比、已移除条目双击无效;退出对照恢复常规视图;全 stable 空态提示;重扫刷新后过滤的变迁类型计数归零自动复位(0.9.3,残留过滤不再误显空态) - 快照对照模式(0.9.0):变迁统计徽章(已修复/新增差异/新文件/已移除/仍有差异/保持一致)与基线时间替换常规统计行;对照条目平铺全路径渲染、行尾变迁徽章、gone 回退快照数据;零计数变迁不占位;默认隐藏 stable、点击徽章单选过滤再点取消;双击当前条目进单文件对比、已移除条目双击无效;退出对照恢复常规视图;全 stable 空态提示;重扫刷新后过滤的变迁类型计数归零自动复位(0.9.3,残留过滤不再误显空态);对照条目含基线内容时 title 提示"双击查看快照基线内容对照"(0.10.0
- 扫描进度遮罩(0.10.0):progress props 非空时遮罩显示"已比对 N / M"null 时仅基础文案
- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动) - 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动)
- 扫描中交互防抖(0.9.2):扫描进行中语义判等开关与忽略规则入口禁用(触发重扫的入口,点击不触发回调/不打开下拉);仅看差异开关与搜索仍可用(纯视图过滤不触发重扫);「重新选择」扫描中保持可用(0.9.3 固化:发起新会话作废在途扫描,与「返回文件对比」同类) - 扫描中交互防抖(0.9.2):扫描进行中语义判等开关与忽略规则入口禁用(触发重扫的入口,点击不触发回调/不打开下拉);仅看差异开关与搜索仍可用(纯视图过滤不触发重扫);「重新选择」扫描中保持可用(0.9.3 固化:发起新会话作废在途扫描,与「返回文件对比」同类)
- 目录行双击(0.8.0):触发 onOpenDir 携带目录相对路径;双击前折叠态自动展开(toggle 残留消除);重新扫描按钮触发 onRescan 且扫描中禁用 - 目录行双击(0.8.0):触发 onOpenDir 携带目录相对路径;双击前折叠态自动展开(toggle 残留消除);重新扫描按钮触发 onRescan 且扫描中禁用
@@ -206,9 +209,10 @@ src/renderer/src/
- 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目) - 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目)
- 增量重扫(0.8.0):真实目录修改文件后点「重新扫描」状态正确更新(相同→不同→改回相同,真实 mtime 变化走指纹判定) - 增量重扫(0.8.0):真实目录修改文件后点「重新扫描」状态正确更新(相同→不同→改回相同,真实 mtime 变化走指纹判定)
- 拖拽导入(0.8.0):dropFiles 合成 DataTransfer 拖放——空面板拖入渲染(arrayBuffer → decodeBuffer IPC 全链路)、双侧拖入对比、非文本扩展名拒绝、多文件提示取首个 - 拖拽导入(0.8.0):dropFiles 合成 DataTransfer 拖放——空面板拖入渲染(arrayBuffer → decodeBuffer IPC 全链路)、双侧拖入对比、非文本扩展名拒绝、多文件提示取首个
- 忽略规则(0.9.0):真实目录含 node_modules 子树——无规则时差异参与对比,输入规则应用重扫后整树剪枝(不同归零、空态一致提示、被忽略条目消失) - 忽略规则(0.9.00.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):第一实例扫描后 userData/scan-cache.json 落盘(等待异步写盘,断言含条目与合法结构)→ 同 userData 第二实例重扫同目录结果正确(主进程注入盘上缓存)
- 快照基线(0.9.0,0.9.1 增强):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复 - 快照基线(0.9.00.9.1 增强0.10.0 内容级对照):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章)
- 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归) - 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归)
- 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData) - 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData)
- 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理) - 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理)
@@ -259,8 +263,9 @@ npm run build
## 9. 演进方向(后续迭代) ## 9. 演进方向(后续迭代)
- 文件夹对比:快照 vs 当前的逐行文本对照(快照保存内容或按需读盘,对照视图内差异明细) · 忽略规则高级 glob(`**` 跨段、否定规则 `!` - 快照体验:regressed 场景基线内容保存策略(当前仅保存差异条目,基线一致的条目内容不存——需全量保存的体积代价权衡) · 快照内差异内容的报告导出
- 主进程 IPC 并发场景集成测试(入参校验与缓存注入逻辑已于 0.9.1 单测化:scanIpc - 忽略规则:gitignore 更完整子集评估(`?` 单字符、字符类、前导 `!` 组合语义等,按真实需求驱动
- 主进程 IPC 并发场景集成测试(入参校验与缓存注入逻辑已于 0.9.1 单测化:scanIpc;缓存落盘串行化于 0.9.3 修复)
- mac 构建实际验证(entitlements 配置就绪但从未打包跑通) - mac 构建实际验证(entitlements 配置就绪但从未打包跑通)
--- ---
+136 -2
View File
@@ -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 () => { test('增量缓存跨会话持久化:落盘文件存在且重启后重扫正确', async () => {
const root = await makeTmpDir('difflens-e2e-cachestore-') const root = await makeTmpDir('difflens-e2e-cachestore-')
try { try {
@@ -515,14 +595,16 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
await expect(window.getByText('相同 1')).toBeVisible() await expect(window.getByText('相同 1')).toBeVisible()
await expect(window.getByText('不同 1')).toBeVisible() await expect(window.getByText('不同 1')).toBeVisible()
// 保存快照(真实写盘) // 保存快照(真实写盘):v2 结构含差异条目双侧内容(0.10.0 内容级对照)
const snapPath = join(root, 'baseline.json') const snapPath = join(root, 'baseline.json')
await stubSaveDialog(app, snapPath) await stubSaveDialog(app, snapPath)
await window.getByRole('button', { name: '保存快照' }).click() await window.getByRole('button', { name: '保存快照' }).click()
await expect(window.getByText(/快照已保存/)).toBeVisible() await expect(window.getByText(/快照已保存/)).toBeVisible()
const snapRaw = await readFile(snapPath, 'utf-8') 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('diff.txt')
expect(snapRaw).toContain('left v1')
expect(snapRaw).toContain('right v1')
// 修复 diff.txt(两侧一致)+ 新增 new.txt → 重扫 // 修复 diff.txt(两侧一致)+ 新增 new.txt → 重扫
await wf(join(rightDir, 'diff.txt'), 'left v1') await wf(join(rightDir, 'diff.txt'), 'left v1')
@@ -559,6 +641,58 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
await removeTmpDir(root) 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 链路)', () => { test.describe('拖拽导入(真实 Chromium DataTransfer 链路)', () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.9.3", "version": "0.10.0",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+105 -1
View File
@@ -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 () => { it('空规则数组与全非法规则不产生任何剪枝(行为与无规则一致)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign5-l-')) const l = await mkdtemp(join(tmpdir(), 'difflens-ign5-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign5-r-')) const r = await mkdtemp(join(tmpdir(), 'difflens-ign5-r-'))
@@ -686,7 +745,8 @@ describe('scanFolders - 忽略规则', () => {
} }
const empty = await scanFolders(l, r, { ignore: [] }) const empty = await scanFolders(l, r, { ignore: [] })
expect(empty.entries).toHaveLength(2) 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) expect(invalid.entries).toHaveLength(2)
} finally { } finally {
await rm(l, { recursive: true, force: true }) 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 = 403 个批次
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
View File
@@ -1,6 +1,6 @@
import { readdir, stat, readFile, open } from 'fs/promises' import { readdir, stat, readFile, open } from 'fs/promises'
import { join } from 'path' import { join } from 'path'
import { compileIgnoreRules, type IgnoreMatcher } from './ignoreRules' import { compileIgnoreRules, type IgnoreMatcher } from '../shared/ignoreRules'
/** /**
* 文件夹对比的纯逻辑模块(仅依赖 node:fs,vitest 以真实临时目录直接测试)。 * 文件夹对比的纯逻辑模块(仅依赖 node:fs,vitest 以真实临时目录直接测试)。
@@ -74,11 +74,17 @@ export interface ScanOptions {
/** 增量重扫缓存(可选):上次扫描返回的 cache,指纹一致的条目零 IO 沿用判定 */ /** 增量重扫缓存(可选):上次扫描返回的 cache,指纹一致的条目零 IO 沿用判定 */
previous?: ScanCacheEntry[] previous?: ScanCacheEntry[]
/** /**
* 忽略规则(gitignore 简化子集,可选):命中的目录整树剪枝(不枚举不计数, * 忽略规则(gitignore 简化子集含高级 glob,可选):按完整相对路径判定——
* 被忽略项不占用 maxFiles 也不计入 total/truncated)、命中的文件跳过。 * 命中的目录整树剪枝(不枚举不计数,被忽略项不占用 maxFiles 也不计入 total/truncated)、
* 命中的文件跳过。支持多段路径 / `**` 跨段 / 段内 `*` 通配 / `!` 否定(最后匹配优先)。
* 规则变化的缓存失效由指纹机制天然覆盖:未命中条目走全量判定。 * 规则变化的缓存失效由指纹机制天然覆盖:未命中条目走全量判定。
*/ */
ignore?: string[] ignore?: string[]
/**
* 比对进度回调(可选):需读内容的条目按受控并发分批比对,每批完成后触发一次;
* done 为已完成条目数,total 为需读内容的条目总数(缓存命中的零 IO 条目不计入)。
*/
onProgress?: (done: number, total: number) => void
} }
/** /**
@@ -146,17 +152,19 @@ async function listFiles(
for (const d of dirents) { for (const d of dirents) {
// Dirent 为 lstat 语义:symlink 既非 file 也非 directory,天然跳过(防环) // Dirent 为 lstat 语义:symlink 既非 file 也非 directory,天然跳过(防环)
if (d.isDirectory()) { if (d.isDirectory()) {
// 忽略规则命中目录整树剪枝(不入队不计数,被忽略子树完全不枚举) // 忽略规则按完整相对路径判定(支持多段/**/否定):命中目录整树剪枝
if (ignore?.matchesDir(d.name)) continue const rel = prefix === '' ? d.name : `${prefix}/${d.name}`
queue.push({ dir: join(dir, d.name), prefix: 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()) { } else if (d.isFile()) {
// 忽略规则命中文件跳过(不计数,不占用 maxFiles) // 命中文件跳过(不计数,不占用 maxFiles)
if (ignore?.matchesFile(d.name)) continue const rel = prefix === '' ? d.name : `${prefix}/${d.name}`
if (ignore?.matchesPath(rel, false)) continue
total++ total++
if (rels.length >= maxFiles) { if (rels.length >= maxFiles) {
truncated = true truncated = true
} else { } 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) { for (let i = 0; i < tasks.length; i += SCAN_CONCURRENCY) {
await Promise.all(tasks.slice(i, i + SCAN_CONCURRENCY).map((t) => t.run())) await Promise.all(tasks.slice(i, i + SCAN_CONCURRENCY).map((t) => t.run()))
done = Math.min(i + SCAN_CONCURRENCY, tasks.length)
try {
options.onProgress?.(done, tasks.length)
} catch {
// 进度回调异常不影响扫描
}
} }
const truncated = left.truncated || right.truncated const truncated = left.truncated || right.truncated
return { return {
-133
View File
@@ -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)
})
})
-91
View File
@@ -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
View File
@@ -5,7 +5,7 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import fs from 'fs' import fs from 'fs'
import { parseState, clampBounds, type AppState } from './windowState' import { parseState, clampBounds, type AppState } from './windowState'
import { decodeText, type DecodeResult } from './decode' import { decodeText, type DecodeResult } from './decode'
import { scanFolders, SEMANTIC_MAX_BYTES, type ScanCacheEntry } from './folderScan' import { scanFolders, SEMANTIC_MAX_BYTES, SCAN_CONCURRENCY, type ScanCacheEntry } from './folderScan'
import { resolveScanArgs } from './scanIpc' import { resolveScanArgs } from './scanIpc'
import { import {
parseScanCacheStore, parseScanCacheStore,
@@ -309,24 +309,41 @@ ipcMain.handle('folder:pick', async () => {
return dirPath return dirPath
}) })
/** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等 + 忽略规则 + 增量缓存跨会话持久化;异步 fs 与解码 worker 不阻塞主进程) */ /**
* IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等 + 忽略规则 + 增量缓存跨会话持久化;异步 fs 与解码 worker 不阻塞主进程)。
* scanId(渲染端扫描会话序号)原样携带在进度事件中:渲染端只显示最新会话的进度,
* 旧扫描迟到的进度事件天然被丢弃(与渲染端结果竞态守卫同一套序号,不引入新竞态面)。
*/
ipcMain.handle( ipcMain.handle(
'folder:scan', 'folder:scan',
async ( async (
_event, event,
leftDir: string, leftDir: string,
rightDir: string, rightDir: string,
semantic: boolean, semantic: boolean,
previous?: unknown, previous?: unknown,
ignoreRules?: unknown ignoreRules?: unknown,
scanId?: unknown
) => { ) => {
try { try {
// 入参防御性校验与增量缓存源选择(内存缓存优先,未携带/非法回退 userData 落盘缓存,惰性读盘) // 入参防御性校验与增量缓存源选择(内存缓存优先,未携带/非法回退 userData 落盘缓存,惰性读盘)
const args = resolveScanArgs(previous, ignoreRules, () => const args = resolveScanArgs(previous, ignoreRules, () =>
getScanCache(loadScanCacheStore(), leftDir, rightDir) 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, { const result = await scanFolders(leftDir, rightDir, {
...args, ...args,
onProgress,
...(semantic ...(semantic
? { ? {
// 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线) // 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线)
@@ -353,6 +370,36 @@ ipcMain.handle('file:read-by-path', async (_event, filePath: string) => {
return readTextFile(filePath) 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(() => { app.whenReady().then(() => {
electronApp.setAppUserModelId('com.metonateam.difflens') electronApp.setAppUserModelId('com.metonateam.difflens')
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ScanCacheEntry } from './folderScan' import type { ScanCacheEntry } from './folderScan'
import { isIgnoreRulesArray } from './ignoreRules' import { isIgnoreRulesArray } from '../shared/ignoreRules'
/** /**
* folder:scan IPC 入参的防御性校验与增量缓存源选择(纯逻辑,无 electron/fs 依赖)。 * folder:scan IPC 入参的防御性校验与增量缓存源选择(纯逻辑,无 electron/fs 依赖)。
+40 -4
View File
@@ -72,6 +72,18 @@ export interface FolderScanError {
error: 'scan-failed' error: 'scan-failed'
} }
/** 扫描进度事件负载(folder:scan-progress 推送;scanId 与渲染端扫描会话序号对应) */
export interface FolderScanProgress {
scanId: number
done: number
total: number
}
/** 批量读取结果:全部路径的读取结果(按入参顺序;单文件失败为错误对象,不整体失败) */
export interface ReadBatchResult {
files: FileOpenResult[]
}
/** 主进程暴露给渲染进程的安全 API */ /** 主进程暴露给渲染进程的安全 API */
const api = { const api = {
openFile: (side?: 'left' | 'right'): Promise<FileOpenResult> => openFile: (side?: 'left' | 'right'): Promise<FileOpenResult> =>
@@ -91,12 +103,36 @@ const api = {
rightDir: string, rightDir: string,
semantic: boolean, semantic: boolean,
previous?: FolderScanCacheEntry[] | null, previous?: FolderScanCacheEntry[] | null,
/** 忽略规则(gitignore 简化子集;命中的目录整树剪枝、文件跳过) */ /** 忽略规则(gitignore 简化子集含高级 glob;命中的目录整树剪枝、文件跳过) */
ignoreRules?: string[] | null ignoreRules?: string[] | null,
/** 扫描会话序号(渲染端 scanSeq;进度事件携带回传,渲染端只显示最新会话的进度) */
scanId?: number
): Promise<FolderScanResult | FolderScanError> => ): 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> => 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 export type DiffLensApi = typeof api
+168 -21
View File
@@ -18,7 +18,12 @@ import {
parseSnapshot, parseSnapshot,
diffSnapshot, diffSnapshot,
snapshotDirsMatch, snapshotDirsMatch,
isDiffStatus,
SNAPSHOT_VERSION,
SNAPSHOT_FILE_MAX_BYTES,
SNAPSHOT_CONTENTS_MAX_CHARS,
type FolderSnapshot, type FolderSnapshot,
type SnapshotContents,
type SnapshotDiffResult type SnapshotDiffResult
} from './diff/snapshotDiff' } from './diff/snapshotDiff'
import { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils' import { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils'
@@ -41,6 +46,9 @@ function stamp(): string {
/** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */ /** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */
const MAX_FILE_SIZE = 10 * 1024 * 1024 const MAX_FILE_SIZE = 10 * 1024 * 1024
/** 快照保存批量读取的分批大小(主进程 folder:read-batch 上限 256,留余量) */
const SNAPSHOT_READ_BATCH = 200
/** 用户偏好(比较选项 + 视图开关)持久化键 */ /** 用户偏好(比较选项 + 视图开关)持久化键 */
const PREFS_KEY = 'difflens.prefs.v1' const PREFS_KEY = 'difflens.prefs.v1'
@@ -205,6 +213,10 @@ export default function App(): ReactElement {
(FolderScanResult & { leftDir: string; rightDir: string }) | null (FolderScanResult & { leftDir: string; rightDir: string }) | null
>(null) >(null)
const [folderScanning, setFolderScanning] = useState(false) const [folderScanning, setFolderScanning] = useState(false)
// 扫描比对进度(push 型 IPC 推送;仅显示与当前会话序号一致的事件,null = 无进度态)
const [scanProgress, setScanProgress] = useState<{ done: number; total: number } | null>(null)
// 快照保存中(批量读取差异条目内容期间的遮罩态)
const [snapshotSaving, setSnapshotSaving] = useState(false)
// 从文件夹进入单文件对比时的当前条目 rel(差异文件导航的游标;返回/退出文件夹模式时清空) // 从文件夹进入单文件对比时的当前条目 rel(差异文件导航的游标;返回/退出文件夹模式时清空)
const [folderEntryCursor, setFolderEntryCursor] = useState<string | null>(null) 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> => { async (leftDir: string, rightDir: string, semantic: boolean, ignoreRules: string[]): Promise<void> => {
const seq = ++scanSeqRef.current const seq = ++scanSeqRef.current
setFolderScanning(true) setFolderScanning(true)
setScanProgress(null)
try { try {
const prev = const prev =
folderState && folderState &&
@@ -407,7 +420,8 @@ export default function App(): ReactElement {
!folderState.truncated !folderState.truncated
? (folderState.cache ?? null) ? (folderState.cache ?? null)
: 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 (seq !== scanSeqRef.current) return
if ('error' in res) { if ('error' in res) {
@@ -421,12 +435,23 @@ export default function App(): ReactElement {
showToast('文件夹扫描失败,请重试') showToast('文件夹扫描失败,请重试')
} finally { } finally {
// 仅最新会话允许复位扫描态(新扫描进行中不被旧扫描的 finally 误清) // 仅最新会话允许复位扫描态(新扫描进行中不被旧扫描的 finally 误清)
if (seq === scanSeqRef.current) setFolderScanning(false) if (seq === scanSeqRef.current) {
setFolderScanning(false)
setScanProgress(null)
}
} }
}, },
[showToast, folderState] [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> => { const startFolderCompare = useCallback(async (): Promise<void> => {
let leftDir: string | null = null let leftDir: string | null = null
@@ -512,25 +537,89 @@ export default function App(): ReactElement {
) )
/** 保存当前对比结果为快照基线(JSON 落盘,复用报告保存管线)。 /** 保存当前对比结果为快照基线(JSON 落盘,复用报告保存管线)。
* 截断结果(文件过多)条目不完整:快照缺失的文件在日后对照中会被误判为"新文件",拦截保存 */ * 截断结果(文件过多)条目不完整:快照缺失的文件在日后对照中会被误判为"新文件",拦截保存
* v2 起可选保存差异条目的双侧内容(对照模式双击进入"快照基线 vs 当前"的内容对比):
* 仅差异条目(same/语义同不存——regressed 场景基线内容不保存为既定取舍)、
* 单文件超 2MB 的侧不读(该侧 null)、内容总量超 32MB 整体降级为纯状态快照(保存永不失败) */
const saveSnapshot = useCallback((): void => { const saveSnapshot = useCallback((): void => {
if (!folderState) return if (!folderState) return
if (folderState.truncated) { if (folderState.truncated) {
showToast('扫描结果已截断(文件过多),快照不完整无法对照,请先缩小对比范围') showToast('扫描结果已截断(文件过多),快照不完整无法对照,请先缩小对比范围')
return 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> => { 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 } let res: { ok: boolean; path: string | null }
try { try {
res = await window.api.saveReport(JSON.stringify(snap, null, 2), defaultName) 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) { if (res.ok && res.path) {
const p = res.path const p = res.path
showToast(`快照已保存:${p}`, { showToast(
label: '打开文件夹', contentsOverflow
onClick: () => void window.api.showInFolder(p) ? `快照已保存(差异内容总量超限,仅保存状态不含内容):${p}`
}) : `快照已保存:${p}`,
{
label: '打开文件夹',
onClick: () => void window.api.showInFolder(p)
}
)
} }
})() })()
}, [folderState, folderSemantic, showToast]) }, [folderState, folderSemantic, showToast])
@@ -619,6 +713,9 @@ export default function App(): ReactElement {
}, [folderState, snapshot, showToast]) }, [folderState, snapshot, showToast])
/** 双击文件夹条目:按路径读取存在侧进入单文件对比(保留文件夹状态供返回)。 /** 双击文件夹条目:按路径读取存在侧进入单文件对比(保留文件夹状态供返回)。
* 快照对照模式下条目含基线内容时优先进入"内容对照":左面板为快照基线内容、
* 右面板为当前文件内容(跟踪左文件优先,快照时刻该侧不存在则跟踪右),
* 完整复用单文件对比能力(比较选项/字符级/搜索/差异导航/导出)。
* navScope 为本次进入后的导航范围:手动双击文件条目传 null(回全局序列), * navScope 为本次进入后的导航范围:手动双击文件条目传 null(回全局序列),
* 目录聚合入口与差异文件导航传当前 scope(保持子树范围不被清除) */ * 目录聚合入口与差异文件导航传当前 scope(保持子树范围不被清除) */
const openFolderEntry = useCallback( const openFolderEntry = useCallback(
@@ -653,6 +750,44 @@ export default function App(): ReactElement {
if (!ok) clearSide(side) if (!ok) clearSide(side)
return ok 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( const okL = await loadSide(
'left', 'left',
entry.status === 'right-only' ? null : `${folderState.leftDir}/${entry.rel}` entry.status === 'right-only' ? null : `${folderState.leftDir}/${entry.rel}`
@@ -667,7 +802,7 @@ export default function App(): ReactElement {
setMode('file') setMode('file')
} }
}, },
[folderState, applyFileData, showToast] [folderState, snapshot, snapshotDiff, applyFileData, showToast]
) )
/** 差异文件序列(不同/仅左/仅右;语义同不计入),批量审查时的导航范围; /** 差异文件序列(不同/仅左/仅右;语义同不计入),批量审查时的导航范围;
@@ -722,9 +857,10 @@ export default function App(): ReactElement {
/** 退出文件夹对比(清空文件夹状态、导航游标/聚合范围、快照对照与两侧面板,回到单文件空态) */ /** 退出文件夹对比(清空文件夹状态、导航游标/聚合范围、快照对照与两侧面板,回到单文件空态) */
const exitFolder = useCallback((): void => { const exitFolder = useCallback((): void => {
// 作废在途扫描并复位扫描态:迟到的扫描结果不得把界面拉回文件夹模式, // 作废在途扫描并复位扫描态:迟到的扫描结果不得把界面拉回文件夹模式,
// 也不得让扫描遮罩残留到下一次文件夹会话 // 也不得让扫描遮罩与进度残留到下一次文件夹会话
scanSeqRef.current++ scanSeqRef.current++
setFolderScanning(false) setFolderScanning(false)
setScanProgress(null)
setFolderState(null) setFolderState(null)
setPaneL(null) setPaneL(null)
setPaneR(null) setPaneR(null)
@@ -1136,6 +1272,7 @@ export default function App(): ReactElement {
truncated={folderState.truncated} truncated={folderState.truncated}
total={folderState.total} total={folderState.total}
scanning={folderScanning} scanning={folderScanning}
progress={scanProgress}
onlyDiff={folderOnlyDiff} onlyDiff={folderOnlyDiff}
onOnlyDiffChange={setFolderOnlyDiff} onOnlyDiffChange={setFolderOnlyDiff}
semantic={folderSemantic} semantic={folderSemantic}
@@ -1246,7 +1383,17 @@ export default function App(): ReactElement {
{folderScanning && mode !== 'folder' && ( {folderScanning && mode !== 'folder' && (
<div className="computing-overlay"> <div className="computing-overlay">
<span className="computing-ring" /> <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> </div>
)} )}
+263 -2
View File
@@ -1,7 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
import App from '../App' 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> let clipboardSpy: ReturnType<typeof vi.fn>
@@ -22,7 +29,11 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
setClipboard: clipboardSpy, setClipboard: clipboardSpy,
pickFolder: async () => null, pickFolder: async () => null,
scanFolder: async () => ({ entries: [], truncated: false, total: 0 }), 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 return { ...base, ...overrides } as typeof window.api
} }
@@ -2218,4 +2229,254 @@ describe('App - 快照基线', () => {
expect(screen.queryByRole('button', { name: '退出对照' })).not.toBeInTheDocument() 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)
// 差异条目双侧内容入 contentsright-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()
})
}) })
+13
View File
@@ -8,6 +8,19 @@ describe('main 入口', () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = '<div id="root"></div>' document.body.innerHTML = '<div id="root"></div>'
vi.resetModules() 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 () => { it('挂载 DiffLens 应用(覆盖 main.tsx 入口)', async () => {
@@ -352,6 +352,21 @@ describe('FolderView - 忽略规则', () => {
Array.from({ length: 50 }, (_, i) => `d${i}/`) 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 - 快照对照模式', () => { describe('FolderView - 快照对照模式', () => {
@@ -489,6 +504,22 @@ describe('FolderView - 快照对照模式', () => {
) )
expect(screen.getByText('与快照基线完全一致,没有任何变迁')).toBeInTheDocument() expect(screen.getByText('与快照基线完全一致,没有任何变迁')).toBeInTheDocument()
}) })
it('对照条目含基线内容时 title 提示内容对照(v2 快照)', () => {
// v2 快照带 contentsa.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 - 其他', () => { describe('FolderView - 其他', () => {
@@ -567,6 +598,17 @@ describe('FolderView - 其他', () => {
expect(onSemanticChange).toHaveBeenCalledWith(false) 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('扫描中禁用忽略规则入口(应用规则会触发重扫)', () => { it('扫描中禁用忽略规则入口(应用规则会触发重扫)', () => {
const onIgnoreRulesChange = vi.fn() const onIgnoreRulesChange = vi.fn()
render( render(
+32 -4
View File
@@ -13,6 +13,7 @@ import { countFolderStats, fmtSize } from '../diff/folderReport'
import type { FolderSnapshot, SnapshotChange, SnapshotDiffResult } from '../diff/snapshotDiff' import type { FolderSnapshot, SnapshotChange, SnapshotDiffResult } from '../diff/snapshotDiff'
import { SNAPSHOT_CHANGE_LABEL } from '../diff/snapshotDiff' import { SNAPSHOT_CHANGE_LABEL } from '../diff/snapshotDiff'
import { useDismiss } from '../hooks/useDismiss' import { useDismiss } from '../hooks/useDismiss'
import { compileIgnoreRules } from '../../../shared/ignoreRules'
import { buildFolderTree, filterEntries, flattenFolderTree, relExt } from './folderTree' import { buildFolderTree, filterEntries, flattenFolderTree, relExt } from './folderTree'
/** 文件夹列表虚拟行高(px),与 global.css 的 --folder-row-height 保持一致,改动需双向同步 */ /** 文件夹列表虚拟行高(px),与 global.css 的 --folder-row-height 保持一致,改动需双向同步 */
@@ -33,6 +34,8 @@ export interface FolderViewProps {
truncated: boolean truncated: boolean
total: number total: number
scanning: boolean scanning: boolean
/** 比对进度(主进程推送;null = 尚无进度态,仅扫描中遮罩展示) */
progress?: { done: number; total: number } | null
/** 仅看差异(隐藏两侧一致的文件);与状态过滤互斥(后者开启时本开关自动关闭) */ /** 仅看差异(隐藏两侧一致的文件);与状态过滤互斥(后者开启时本开关自动关闭) */
onlyDiff: boolean onlyDiff: boolean
onOnlyDiffChange: (v: boolean) => void onOnlyDiffChange: (v: boolean) => void
@@ -109,6 +112,7 @@ export default function FolderView({
truncated, truncated,
total, total,
scanning, scanning,
progress,
onlyDiff, onlyDiff,
onOnlyDiffChange, onOnlyDiffChange,
semantic, semantic,
@@ -154,6 +158,15 @@ export default function FolderView({
const closeIgnore = useCallback((): void => setIgnoreOpen(false), []) const closeIgnore = useCallback((): void => setIgnoreOpen(false), [])
useDismiss(ignoreWrapRef, closeIgnore) 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 // 快照对照模式的变迁类型过滤(null = 不过滤;单选 toggle
const [changeFilter, setChangeFilter] = useState<SnapshotChange | null>(null) const [changeFilter, setChangeFilter] = useState<SnapshotChange | null>(null)
@@ -495,7 +508,7 @@ export default function FolderView({
title={ title={
scanning scanning
? '正在扫描文件夹' ? '正在扫描文件夹'
: '忽略规则(gitignore 简化子集):node_modules/ 目录整树剪枝、*.log 扩展名通配、temp 名字匹配;应用后重新扫描' : '忽略规则(gitignore 简化子集含高级 glob):node_modules/ 目录整树剪枝、*.log 通配、src/temp 多段路径、build/**/cache 跨段、!keep.log 否定;应用后重新扫描'
} }
onClick={() => { onClick={() => {
setIgnoreDraft(ignoreRules.join('\n')) setIgnoreDraft(ignoreRules.join('\n'))
@@ -511,9 +524,16 @@ export default function FolderView({
value={ignoreDraft} value={ignoreDraft}
rows={6} rows={6}
spellCheck={false} 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)} onChange={(e) => setIgnoreDraft(e.target.value)}
/> />
{ignoreInvalid.length > 0 && (
<div className="ignore-invalid" title="以下规则不被支持,应用时将被忽略">
{ignoreInvalid.join('、')}
</div>
)}
<button className="ctx-item" onClick={applyIgnoreDraft}> <button className="ctx-item" onClick={applyIgnoreDraft}>
</button> </button>
@@ -572,12 +592,17 @@ export default function FolderView({
const display = e.current ?? e.baseline! const display = e.current ?? e.baseline!
const meta = STATUS_META[display.status] const meta = STATUS_META[display.status]
const sc = SNAP_CHANGES.find((c) => c.key === e.change)! 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 ( return (
<div <div
className={'folder-row ' + meta.cls} className={'folder-row ' + meta.cls}
key={e.rel} key={e.rel}
style={{ paddingLeft: 18 }} style={{ paddingLeft: 18 }}
title={`${e.rel} · 快照对照:${sc.text}${e.current ? '' : '(当前已不存在,大小为快照值)'}`} title={`${e.rel} · 快照对照:${sc.text}${e.current ? '' : '(当前已不存在,大小为快照值)'}${hasContents ? ' · 双击查看快照基线内容对照' : ''}`}
onDoubleClick={() => { onDoubleClick={() => {
if (e.current) onOpenEntry(e.current) if (e.current) onOpenEntry(e.current)
}} }}
@@ -661,7 +686,10 @@ export default function FolderView({
{scanning && ( {scanning && (
<div className="computing-overlay"> <div className="computing-overlay">
<span className="computing-ring" /> <span className="computing-ring" />
<span></span> <span>
{progress ? ` 已比对 ${progress.done} / ${progress.total}` : ''}
</span>
</div> </div>
)} )}
</div> </div>
+69 -2
View File
@@ -5,10 +5,13 @@ import {
diffSnapshot, diffSnapshot,
snapshotDirsMatch, snapshotDirsMatch,
isDiffStatus, isDiffStatus,
SNAPSHOT_VERSION,
SNAPSHOT_FILE_MAX_BYTES,
SNAPSHOT_CONTENTS_MAX_CHARS,
type FolderSnapshot type FolderSnapshot
} from './snapshotDiff' } from './snapshotDiff'
/** 构造合法快照 */ /** 构造合法快照v1:无 contents 字段,验证向后兼容加载) */
function makeSnapshot(overrides?: Partial<FolderSnapshot>): FolderSnapshot { function makeSnapshot(overrides?: Partial<FolderSnapshot>): FolderSnapshot {
return { return {
version: 1, version: 1,
@@ -35,7 +38,7 @@ describe('parseSnapshot - 解析校验', () => {
it('非法 JSON / 版本不符 / 顶层字段异常回退 null', () => { it('非法 JSON / 版本不符 / 顶层字段异常回退 null', () => {
expect(parseSnapshot('{broken json!!')).toBeNull() 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(), leftDir: 42 }))).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), semantic: 'yes' }))).toBeNull() expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), semantic: 'yes' }))).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), savedAt: 'no-time' }))).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', () => { describe('isDiffStatus / snapshotDirsMatch', () => {
it('different / only / unreadable 计为有差异,same / semantic-same 不计', () => { it('different / only / unreadable 计为有差异,same / semantic-same 不计', () => {
expect(isDiffStatus('different')).toBe(true) expect(isDiffStatus('different')).toBe(true)
+55 -9
View File
@@ -3,15 +3,34 @@ import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
/** /**
* 文件夹对比快照基线的纯逻辑模块: * 文件夹对比快照基线的纯逻辑模块:
* 快照文件结构定义、解析校验、快照 ↔ 当前扫描结果的状态变迁对照。 * 快照文件结构定义、解析校验、快照 ↔ 当前扫描结果的状态变迁对照。
* 快照只保存判定结果(不保存文件内容),对照粒度为条目状态变迁。 * v2 起可选保存差异条目的双侧文件内容(contents),对照模式双击进入
* "快照基线内容 vs 当前内容"的单文件对比(快照只存内容不存判定外的任何派生数据)。
*/ */
/** 快照结构版本(不兼容变更时递增并让旧文件作废) */ /** 快照结构版本(不兼容变更时递增并让旧文件作废v1 无 contents 字段,加载时功能降级为纯状态对照 */
export const SNAPSHOT_VERSION = 1 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 { export interface FolderSnapshot {
version: 1 version: 1 | 2
leftDir: string leftDir: string
rightDir: string rightDir: string
/** 保存时的语义判等开关(对照口径说明用) */ /** 保存时的语义判等开关(对照口径说明用) */
@@ -21,6 +40,8 @@ export interface FolderSnapshot {
entries: FolderEntry[] entries: FolderEntry[]
/** 保存时间戳 */ /** 保存时间戳 */
savedAt: number savedAt: number
/** v2 可选:差异条目的双侧内容(key 为 rel;仅 isDiffStatus 条目;超限/总量降级时缺失) */
contents?: Record<string, SnapshotContents>
} }
/** 条目状态的合法值(解析校验共用) */ /** 条目状态的合法值(解析校验共用) */
@@ -46,28 +67,53 @@ function isEntry(v: unknown): v is FolderEntry {
return true 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(调用方提示加载失败,绝不部分加载)。 * 一律回退 null(调用方提示加载失败,绝不部分加载)。
* v1 快照无 contents 字段合法(加载后功能降级为纯状态对照)。
*/ */
export function parseSnapshot(raw: string): FolderSnapshot | null { export function parseSnapshot(raw: string): FolderSnapshot | null {
try { try {
const obj = JSON.parse(raw) as Record<string, unknown> 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.leftDir !== 'string' || typeof obj.rightDir !== 'string') return null
if (typeof obj.semantic !== 'boolean' || typeof obj.truncated !== 'boolean') 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.total !== 'number' || !Number.isFinite(obj.total)) return null
if (typeof obj.savedAt !== 'number' || !Number.isFinite(obj.savedAt)) return null if (typeof obj.savedAt !== 'number' || !Number.isFinite(obj.savedAt)) return null
if (!Array.isArray(obj.entries) || !obj.entries.every(isEntry)) 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 { return {
version: 1, version: obj.version,
leftDir: obj.leftDir, leftDir: obj.leftDir,
rightDir: obj.rightDir, rightDir: obj.rightDir,
semantic: obj.semantic, semantic: obj.semantic,
truncated: obj.truncated, truncated: obj.truncated,
total: obj.total, total: obj.total,
entries: obj.entries, entries: obj.entries,
savedAt: obj.savedAt savedAt: obj.savedAt,
...(contents !== undefined ? { contents } : {})
} }
} catch { } catch {
return null return null
+13
View File
@@ -674,6 +674,19 @@ button:disabled {
color: var(--muted); 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 { .fstat.snap-fixed {
color: var(--add-fg); color: var(--add-fg);
+236
View File
@@ -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('规则首尾空白被 trimtrim 后仍非法的按原文 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)
})
})
+160
View File
@@ -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
View File
@@ -3,7 +3,8 @@
"include": [ "include": [
"electron.vite.config.*", "electron.vite.config.*",
"src/main/**/*", "src/main/**/*",
"src/preload/**/*" "src/preload/**/*",
"src/shared/**/*"
], ],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
+2 -1
View File
@@ -4,7 +4,8 @@
"src/renderer/src/env.d.ts", "src/renderer/src/env.d.ts",
"src/renderer/src/**/*", "src/renderer/src/**/*",
"src/renderer/src/**/*.tsx", "src/renderer/src/**/*.tsx",
"src/preload/*.d.ts" "src/preload/*.d.ts",
"src/shared/**/*"
], ],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
+4 -3
View File
@@ -15,14 +15,15 @@ export default defineConfig({
setupFiles: ['./src/renderer/src/test/setup.ts'], setupFiles: ['./src/renderer/src/test/setup.ts'],
include: [ include: [
'src/renderer/src/**/*.{test,spec}.{ts,tsx}', 'src/renderer/src/**/*.{test,spec}.{ts,tsx}',
// 主进程纯逻辑模块(无 electron 依赖)的单元测试 // 主进程纯逻辑模块(无 electron 依赖)与两进程共享纯逻辑模块的单元测试
'src/main/**/*.{test,spec}.{ts,tsx}' 'src/main/**/*.{test,spec}.{ts,tsx}',
'src/shared/**/*.{test,spec}.{ts,tsx}'
], ],
exclude: ['node_modules', 'dist', 'out'], exclude: ['node_modules', 'dist', 'out'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'text-summary'], 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: [ exclude: [
'src/renderer/src/**/*.{test,spec}.{ts,tsx}', 'src/renderer/src/**/*.{test,spec}.{ts,tsx}',
'src/renderer/src/test/**', 'src/renderer/src/test/**',