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