feat: 0.10.1 忽略规则字符通配与收尾打磨 - 段内?单字符与[abc]/[a-z]/[!abc]字符类通配(未闭合与空类退化字面量、非法类体回退字面量永不崩溃、既有*/**/否定语义回归锚固化)、快照保存批量读取遮罩显示已读取N/M进度(文件数口径与扫描进度反馈对称)、差异文件导航游标不在序列时隐藏导航组(修双击same条目与对照模式fixed条目的—计数与死按钮)、修复0.10.0遗留snapshotDiff.test版本号类型错误(展开构造替代非法字面量传参)、修正E2E用例数记载(28→27),新增16用例(612单测+E2E 27全过覆盖率98.2/93.46/93.77/98.2),文档四件套同步

This commit is contained in:
2026-08-19 14:19:51 +08:00
parent c209ae291a
commit 05a603ee68
11 changed files with 364 additions and 42 deletions
+3 -3
View File
@@ -11,8 +11,8 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**扫描进度反馈**(比对阶段实时显示"已比对 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 旧快照兼容加载(纯状态对照)
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**扫描进度反馈**(比对阶段实时显示"已比对 N / M")、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**忽略规则**(gitignore 简化子集含高级 glob`node_modules/` 目录整树剪枝、`*.log` 通配、`lo?g` 单字符、`*.[ch]` 字符类(范围与取反)、`src/temp` 多段路径、`build/**/cache` 跨段、`!keep.log` 否定规则(最后匹配优先);无效规则实时提示不再静默丢弃;规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫)
- **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**,保存期间批量读取实时显示"已读取 N / M"进度),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么");内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照)
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览)
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
@@ -116,7 +116,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
- 作者:thzxx
- 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.10.0
- 版本:0.10.1
---
+7 -2
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.10.0`**
当前基线版本:**`0.10.1`**
---
@@ -124,7 +124,12 @@ z = 补丁版本号(Patch
- 快照内容级对照(快照功能链收官):快照结构升级 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
- 测试与文档:新增 33 单测(ignoreRules 30 全量重写 / folderScan glob 集成与 onProgress / snapshotDiff v2 / App 内容对照与进度 / FolderView 无效提示与进度)与 4 条 E2E(快照内容级对照全链路 / 高级 glob 含否定与无效提示 / 扫描进度轮询抓取),共 596 单测 + 27 E2E(此前文档误记 28,实际清点为 27)
0.10.1 ← 忽略规则字符通配 · 快照保存读取进度 · 差异导航游标修复(已发布):
- 忽略规则字符通配(段内通配子集扩展):段内模式新增 `?` 单字符通配(lo?g 命中 loog 不命中 log,不跨段)与 `[abc]`/`[a-z]`/`[!abc]` 字符类(*.[ch] 命中 .c/.h、log[0-9].txt 命中数字后缀、[!abc] 取反);未闭合 `[` 与空类体([]/[!])退化为字面量方括号(gitignore 同款语义),无法构成合法正则的类体(反转范围 [z-a])整段回退字面量匹配永不崩溃;大小写不敏感语义不变(模式与路径双侧小写化后匹配);既有 `*`/`**`/否定/多段锚定语义与段内 `**` 非法判定完全不变(回归锚固化);UI 忽略规则下拉的提示文案与 title 同步新语法
- 快照保存读取进度:保存快照批量读取差异条目内容期间遮罩显示"已读取 N / M"(文件数口径:去重后双侧路径总数,按 200/批推进,与扫描进度反馈对称,大差异目录不再无反馈干等);保存结束/失败进度随遮罩复位
- 差异文件导航游标修复:导航游标不在差异序列时导航组直接隐藏——此前显示"—"计数且"下一个"按钮可点无响应(goDiffFile 静默 return);触发路径为双击 same/语义同条目进入对比(0.7.0 遗留)与快照对照模式双击 fixed/regressed/new 等当前无差异条目进入内容对照(0.10.0 放大暴露面);双击差异条目即回序列、导航恢复
- 测试与文档:新增 16 用例(ignoreRules 字符通配 12 / folderScan 字符通配集成 1 / App 导航隐藏 2 与读取进度 1)并修复 0.10.0 遗留的 snapshotDiff.test 版本号类型错误(makeSnapshot 传 3/0 违反 1|2 字面量联合,改展开构造),共 612 单测 + 27 E2E
...
0.y.z ← 长期停留,永不进入 1.x
```
+9 -9
View File
@@ -47,15 +47,15 @@ e2e/
├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口/合成拖放)
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照/拖拽导入/偏好重启)
src/shared/
├── ignoreRules.ts # 忽略规则匹配器纯逻辑(gitignore 简化子集含高级 glob;主进程扫描剪枝与渲染端无效提示共用,段模式预编译 + DP 段序列匹配 + 否定仲裁)
└── ignoreRules.test.ts # 忽略规则单元测试(名字/目录/通配回归 + 多段/锚定/跨段/段内通配/否定仲裁/无效清单/上限/IPC 校验)
├── ignoreRules.ts # 忽略规则匹配器纯逻辑(gitignore 简化子集含高级 glob 与段内字符通配——? 单字符/[abc] 字符类;主进程扫描剪枝与渲染端无效提示共用,段模式预编译 + DP 段序列匹配 + 否定仲裁)
└── ignoreRules.test.ts # 忽略规则单元测试(名字/目录/通配回归 + 多段/锚定/跨段/段内通配/否定仲裁/无效清单/上限/IPC 校验 + ? 与字符类专项
src/main/
├── 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 # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对/语义判等/单点失败降级/忽略规则剪枝含高级 glob/比对进度回调)
├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似/语义判等/目录降级/unreadable/忽略规则含 glob 与否定/进度回调)
├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对/语义判等/单点失败降级/忽略规则剪枝含高级 glob 与字符通配/比对进度回调)
├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似/语义判等/目录降级/unreadable/忽略规则含 glob·否定·?与字符类/进度回调)
├── scanCacheStore.ts # 扫描缓存跨会话持久化纯数据(目录对键归一化/LRU/防御性解析)
├── scanCacheStore.test.ts # 缓存存储单元测试(解析回退/条目过滤/键归一化/LRU 淘汰/序列化往返)
├── scanIpc.ts # folder:scan IPC 入参校验与缓存源选择纯逻辑(无 electron 依赖)
@@ -91,7 +91,7 @@ src/renderer/src/
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记/搜索框计数与 Enter·Shift+Enter·Esc 键盘交互/ref 转发)
└── __tests__/
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索/Ctrl+F/文件夹模式进出与双击/语义判等链路/差异文件导航/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截/快照内容级对照(v2 保存含内容·超限降级·双击基线vs当前·v1 兼容)/扫描进度显示与过期丢弃)
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索/Ctrl+F/文件夹模式进出与双击/语义判等链路/差异文件导航(含游标不在序列时导航隐藏)/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截/快照内容级对照(v2 保存含内容与读取进度·超限降级·双击基线vs当前·v1 兼容)/扫描进度显示与过期丢弃)
└── main.test.ts # 入口挂载冒烟测试
```
@@ -148,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.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 快照双击走常规当前文件对比(兼容回归锚)
- 快照基线(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 快照双击走常规当前文件对比(兼容回归锚) · 保存读取分批进度遮罩"已读取 N / M"0.10.1,第二批挂起构造断言,结束复位) · 双击 same 条目 / 对照模式双击 fixed 条目进入对比后差异文件导航隐藏(0.10.1,游标不在差异序列;双击差异条目导航恢复)
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`
- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致
@@ -178,7 +178,7 @@ 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.00.10.0 高级 glob):目录规则整树剪枝(深层子树不枚举、不计数、不进结果与缓存)/ 扩展名通配与名字匹配跳过文件(大小写不敏感)/ 被忽略文件不占用 maxFiles(原本截断的场景忽略后完整扫描)/ 规则变化后重扫缓存未命中的重新纳入条目走全量判定 / 空规则数组与全非法规则不产生任何剪枝 / 多段路径从根锚定剪枝(其他分支同名目录不受影响)/ ** 跨段剪枝与否定救回(父目录未被剪枝时子路径可被反否定)/ 父目录被剪枝后否定规则救不回子项(gitignore 同款语义)
- 忽略规则(0.9.00.10.0 高级 glob0.10.1 字符通配):目录规则整树剪枝(深层子树不枚举、不计数、不进结果与缓存)/ 扩展名通配与名字匹配跳过文件(大小写不敏感)/ 被忽略文件不占用 maxFiles(原本截断的场景忽略后完整扫描)/ 规则变化后重扫缓存未命中的重新纳入条目走全量判定 / 空规则数组与全非法规则不产生任何剪枝 / 多段路径从根锚定剪枝(其他分支同名目录不受影响)/ ** 跨段剪枝与否定救回(父目录未被剪枝时子路径可被反否定)/ 父目录被剪枝后否定规则救不回子项(gitignore 同款语义)/ 段内 ? 单字符与 [abc] 字符类跳过文件(*.lo? 与 *.[ch] 真实目录集成,0.10.1
- 比对进度回调(0.10.0):需读内容的条目按批推进进度(total 恒为需读内容条目数、done 单调递增至 total)/ 全部条目零 IO(单侧缺失)时不触发回调
### 5.10 文件夹视图(`FolderView.tsx`)与条目树(`folderTree.ts`
@@ -186,7 +186,7 @@ src/renderer/src/
- 渲染:目录路径卡片 / 重新扫描、重新选择、导出报告与返回按钮 / 统计徽章(相同/语义同/不同/仅左/仅右/无法读取/总数;语义关闭隐藏语义同、无失败隐藏无法读取)
- 树形交互:目录行点击折叠展开(默认全展开);搜索命中强制展开命中路径、清空恢复用户折叠;无命中提示
- 扩展名过滤(0.8.0):下拉展示全量条目 ext→count(字典序、无扩展名单列);点选过滤与其他条件取交集、计数切 N / 总数;多选并集、再点取消、清除按钮复位;与状态过滤双激活空态提示;点击外部关闭且过滤状态保留
- 忽略规则(0.9.00.10.0 高级 glob 与无效提示):已有规则时入口标注数量并高亮;下拉打开以当前规则初始化草稿,逐行解析去空白去空行(截断至 50 条)后应用回调并关闭;清空规则回调空数组;点击外部关闭不触发回调;无效规则实时提示"将被忽略的无效规则"(修正/清空后提示消失,不再静默丢弃)
- 忽略规则(0.9.00.10.0 高级 glob 与无效提示0.10.1 字符通配文案):已有规则时入口标注数量并高亮;下拉打开以当前规则初始化草稿,逐行解析去空白去空行(截断至 50 条)后应用回调并关闭;清空规则回调空数组;点击外部关闭不触发回调;无效规则实时提示"将被忽略的无效规则"(修正/清空后提示消失,不再静默丢弃);提示文案与 title 含 ? 单字符与 [abc] 字符类新语法
- 快照对照模式(0.9.0):变迁统计徽章(已修复/新增差异/新文件/已移除/仍有差异/保持一致)与基线时间替换常规统计行;对照条目平铺全路径渲染、行尾变迁徽章、gone 回退快照数据;零计数变迁不占位;默认隐藏 stable、点击徽章单选过滤再点取消;双击当前条目进单文件对比、已移除条目双击无效;退出对照恢复常规视图;全 stable 空态提示;重扫刷新后过滤的变迁类型计数归零自动复位(0.9.3,残留过滤不再误显空态);对照条目含基线内容时 title 提示"双击查看快照基线内容对照"0.10.0
- 扫描进度遮罩(0.10.0):progress props 非空时遮罩显示"已比对 N / M"null 时仅基础文案
- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动)
@@ -264,7 +264,7 @@ npm run build
## 9. 演进方向(后续迭代)
- 快照体验:regressed 场景基线内容保存策略(当前仅保存差异条目,基线一致的条目内容不存——需全量保存的体积代价权衡) · 快照内差异内容的报告导出
- 忽略规则:gitignore 更完整子集评估(`?` 单字符、字符类、前导 `!` 组合语义等,按真实需求驱动)
- 忽略规则:gitignore 更完整子集评估(`\` 转义、前导 `!` 组合语义等,按真实需求驱动`?` 单字符与 `[abc]` 字符类已于 0.10.1 落地
- 主进程 IPC 并发场景集成测试(入参校验与缓存注入逻辑已于 0.9.1 单测化:scanIpc;缓存落盘串行化于 0.9.3 修复)
- mac 构建实际验证(entitlements 配置就绪但从未打包跑通)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.10.0",
"version": "0.10.1",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
+21
View File
@@ -734,6 +734,27 @@ describe('scanFolders - 忽略规则', () => {
}
})
it('段内 ? 与字符类规则跳过文件(0.10.1 字符通配集成)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign9-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign9-r-'))
try {
for (const d of [l, r]) {
await writeFile(join(d, 'keep.txt'), 'a')
await writeFile(join(d, 'debug.log'), 'x')
await writeFile(join(d, 'debug.leg'), 'x')
await writeFile(join(d, 'main.c'), 'x')
await writeFile(join(d, 'main.o'), 'x')
}
// *.l?g 命中 .log 与 .leg? 匹配任意单字符);*.[ch] 命中 .c 不命中 .o
const res = await scanFolders(l, r, { ignore: ['*.l?g', '*.[ch]'] })
expect(res.entries.map((e) => e.rel).sort()).toEqual(['keep.txt', 'main.o'])
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-ign5-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign5-r-'))
+30 -17
View File
@@ -217,6 +217,10 @@ export default function App(): ReactElement {
const [scanProgress, setScanProgress] = useState<{ done: number; total: number } | null>(null)
// 快照保存中(批量读取差异条目内容期间的遮罩态)
const [snapshotSaving, setSnapshotSaving] = useState(false)
// 快照保存批量读取进度(已读取文件数 / 总数;null = 尚无进度态)
const [snapshotProgress, setSnapshotProgress] = useState<{ done: number; total: number } | null>(
null
)
// 从文件夹进入单文件对比时的当前条目 rel(差异文件导航的游标;返回/退出文件夹模式时清空)
const [folderEntryCursor, setFolderEntryCursor] = useState<string | null>(null)
// 目录聚合对比的导航范围(双击目录行进入:差异文件导航限定在该子树内;
@@ -540,7 +544,8 @@ export default function App(): ReactElement {
* 截断结果(文件过多)条目不完整:快照缺失的文件在日后对照中会被误判为"新文件",拦截保存。
* v2 起可选保存差异条目的双侧内容(对照模式双击进入"快照基线 vs 当前"的内容对比):
* 仅差异条目(same/语义同不存——regressed 场景基线内容不保存为既定取舍)、
* 单文件超 2MB 的侧不读(该侧 null)、内容总量超 32MB 整体降级为纯状态快照(保存永不失败) */
* 单文件超 2MB 的侧不读(该侧 null)、内容总量超 32MB 整体降级为纯状态快照(保存永不失败)
* 批量读取期间遮罩显示"已读取 N / M"进度(与扫描进度反馈对称,大差异目录不再无反馈干等) */
const saveSnapshot = useCallback((): void => {
if (!folderState) return
if (folderState.truncated) {
@@ -564,8 +569,10 @@ export default function App(): ReactElement {
let contentsOverflow = false
if (targets.length > 0) {
setSnapshotSaving(true)
setSnapshotProgress(null)
try {
// 去重路径分批批量读:单文件读取失败该条目按"该侧无内容"处理(不整体失败)
// 去重路径分批批量读:单文件读取失败该条目按"该侧无内容"处理(不整体失败)
// 每批完成更新读取进度(文件数口径,去重后的双侧路径总数)
const allPaths = [
...new Set(
targets.flatMap((t) => [t.leftPath, t.rightPath].filter((p): p is string => p !== null))
@@ -588,6 +595,10 @@ export default function App(): ReactElement {
res.files.forEach((f, k) => {
if (f && !('error' in f)) readMap.set(batch[k], f.text)
})
setSnapshotProgress({
done: Math.min(i + SNAPSHOT_READ_BATCH, allPaths.length),
total: allPaths.length
})
}
// ===== 组装内容并执行总量上限(超出整体降级为纯状态快照) =====
const map: Record<string, SnapshotContents> = {}
@@ -606,6 +617,7 @@ export default function App(): ReactElement {
if (!contentsOverflow && Object.keys(map).length > 0) contents = map
} finally {
setSnapshotSaving(false)
setSnapshotProgress(null)
}
}
const snap: FolderSnapshot = {
@@ -845,6 +857,14 @@ export default function App(): ReactElement {
[diffFileEntries, folderEntryCursor, folderNavScope, openFolderEntry]
)
// 导航游标在差异序列中的位置(-1 = 不在序列:双击 same/语义同条目,或快照对照模式
// 双击 fixed/regressed/new 等当前无差异的条目——导航组随之隐藏,避免显示"—"计数
// 与可点但无响应的按钮;双击差异条目即回到序列、导航恢复)
const cursorIdx =
folderEntryCursor === null
? -1
: diffFileEntries.findIndex((e) => e.rel === folderEntryCursor)
/** 从单文件对比返回文件夹列表(清空两侧面板与导航游标/聚合范围,文件夹扫描结果保留) */
const backToFolder = useCallback((): void => {
setPaneL(null)
@@ -1174,7 +1194,7 @@ export default function App(): ReactElement {
</button>
)}
{folderState && folderEntryCursor !== null && diffFileEntries.length > 0 && (
{folderState && cursorIdx >= 0 && (
<div
className="tool-group diff-file-nav"
title={
@@ -1186,26 +1206,16 @@ export default function App(): ReactElement {
<button
className="nav-btn"
title="上一个差异文件(Alt+↑)"
disabled={
diffFileEntries.findIndex((e) => e.rel === folderEntryCursor) <= 0
}
disabled={cursorIdx <= 0}
onClick={() => goDiffFile(-1)}
>
</button>
<span className="nav-count">
{(() => {
const i = diffFileEntries.findIndex((e) => e.rel === folderEntryCursor)
return i >= 0 ? `${i + 1} / ${diffFileEntries.length}` : '—'
})()}
</span>
<span className="nav-count">{`${cursorIdx + 1} / ${diffFileEntries.length}`}</span>
<button
className="nav-btn"
title="下一个差异文件(Alt+↓)"
disabled={
diffFileEntries.findIndex((e) => e.rel === folderEntryCursor) >=
diffFileEntries.length - 1
}
disabled={cursorIdx >= diffFileEntries.length - 1}
onClick={() => goDiffFile(1)}
>
@@ -1393,7 +1403,10 @@ export default function App(): ReactElement {
{snapshotSaving && (
<div className="computing-overlay">
<span className="computing-ring" />
<span></span>
<span>
{snapshotProgress ? ` 已读取 ${snapshotProgress.done} / ${snapshotProgress.total}` : ''}
</span>
</div>
)}
+137
View File
@@ -1661,6 +1661,40 @@ describe('App - 差异文件导航', () => {
fireEvent.keyDown(window, { key: 'ArrowDown', altKey: true })
expect(screen.getByText('导出报告')).toBeInTheDocument()
})
it('双击 same 条目进入对比不显示导航(游标不在差异序列,0.10.1 修复)', async () => {
const dirs = ['C:/left-dir', 'D:/right-dir']
let call = 0
window.api = mockApi({
pickFolder: async () => dirs[call++] ?? null,
scanFolder: async () => ({ entries: navEntries, truncated: false, total: 5 }),
readByPath: async (p: string) => ({
path: p,
name: p.split('/').pop() ?? 'f',
text: `content of ${p}`,
encoding: 'UTF-8',
binary: false
})
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByText('b.txt')
// 关闭仅看差异让 same 条目可见,双击 a.txt 进入对比
fireEvent.click(screen.getByLabelText('仅看差异'))
fireEvent.dblClick(screen.getByText('a.txt'))
await screen.findByText('← 返回文件夹对比')
// 游标不在差异序列:导航组隐藏(缺陷版本显示"—"计数且"下一个"可点无响应)
expect(screen.queryByText(/差异文件/)).not.toBeInTheDocument()
// Alt+↓ 无副作用(不报错、不跳转)
fireEvent.keyDown(window, { key: 'ArrowDown', altKey: true })
expect(screen.getByText('← 返回文件夹对比')).toBeInTheDocument()
// 双击差异条目回到序列:导航恢复
fireEvent.click(screen.getByText('← 返回文件夹对比'))
await screen.findByText('b.txt')
fireEvent.dblClick(screen.getByText('b.txt'))
expect(await screen.findByText(/差异文件/)).toBeInTheDocument()
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
})
describe('App - 增量重扫', () => {
@@ -2433,6 +2467,109 @@ describe('App - 快照内容级对照(v2', () => {
expect(paneFiles).toEqual(['b.txt', 'b.txt'])
expect(screen.queryByText(/快照 ·/)).not.toBeInTheDocument()
})
it('对照模式双击 fixed 条目进入内容对照:游标不在差异序列,导航组隐藏(0.10.1 修复)', async () => {
// 当前结果全部一致(无差异文件);快照基线 b.txt different(含基线内容)→ fixed
const curEntries = [
{ rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
{ rel: 'b.txt', status: 'same' as const, leftSize: 3, rightSize: 3 }
]
const snapJson = JSON.stringify({
version: 2,
leftDir: 'C:/left-dir',
rightDir: 'D:/right-dir',
semantic: true,
truncated: false,
total: 2,
savedAt: 1700000000000,
entries: [
{ rel: 'a.txt', status: 'same', leftSize: 3, rightSize: 3 },
{ rel: 'b.txt', status: 'different', leftSize: 3, rightSize: 5 }
],
contents: { 'b.txt': { left: 'baseline old value', right: null } }
})
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: 'current fixed value',
encoding: 'UTF-8',
binary: false
})
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByRole('button', { name: '加载快照' })
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
await screen.findByText(/已加载快照/)
// 对照列表:b.txt 为已修复(默认隐藏 stable 的 a.txt
fireEvent.dblClick(screen.getByText('b.txt'))
// 进入内容对照:左面板快照基线、右面板当前文件(缺陷版本此处导航显示"—")
expect(
await screen.findByText('快照 · b.txt', { selector: '.pane-file' })
).toBeInTheDocument()
expect(screen.getByText('有差异')).toBeInTheDocument()
expect(screen.queryByText(/差异文件/)).not.toBeInTheDocument()
})
it('快照保存读取阶段遮罩显示分批进度(0.10.1)', async () => {
// 110 个差异条目 × 双侧 = 220 文件 > 200/批 → 两批:第一批完成后进度可见、第二批挂起
const entries = Array.from({ length: 110 }, (_, i) => ({
rel: `d${String(i).padStart(3, '0')}.txt`,
status: 'different' as const,
leftSize: 1,
rightSize: 2
}))
const okFile = (p: string) => ({
path: p,
name: p.split('/').pop() ?? '',
text: '',
encoding: 'UTF-8',
binary: false
})
let readCall = 0
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let resolveSecond: (v: any) => 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[]) => {
readCall++
if (readCall === 1) return { files: paths.map(okFile) }
// 第二批挂起:进度文案保持可见可断言
return new Promise((res) => {
resolveSecond = res
}) as unknown as ReturnType<typeof window.api.readFilesByPaths>
},
saveReport: async () => ({ ok: true, path: 'C:/docs/snap.json' })
})
render(<App />)
fireEvent.click(screen.getByText('对比文件夹'))
await screen.findByRole('button', { name: '保存快照' })
fireEvent.click(screen.getByRole('button', { name: '保存快照' }))
// 第一批(200 个文件)完成、第二批挂起:遮罩显示 已读取 200 / 220
expect(await screen.findByText(/已读取 200 \/ 220/)).toBeInTheDocument()
await act(async () => {
resolveSecond({ files: [] })
})
// 第二批完成:快照保存成功,遮罩与进度消失
await screen.findByText(/快照已保存/)
expect(screen.queryByText(/正在读取差异文件内容/)).not.toBeInTheDocument()
expect(screen.queryByText(/已读取/)).not.toBeInTheDocument()
})
})
describe('App - 扫描进度反馈', () => {
+3 -3
View File
@@ -508,7 +508,7 @@ export default function FolderView({
title={
scanning
? '正在扫描文件夹'
: '忽略规则(gitignore 简化子集含高级 glob):node_modules/ 目录整树剪枝、*.log 通配、src/temp 多段路径、build/**/cache 跨段、!keep.log 否定;应用后重新扫描'
: '忽略规则(gitignore 简化子集含高级 glob):node_modules/ 目录整树剪枝、*.log 通配、lo?g 单字符、*.[ch] 字符类、src/temp 多段路径、build/**/cache 跨段、!keep.log 否定;应用后重新扫描'
}
onClick={() => {
setIgnoreDraft(ignoreRules.join('\n'))
@@ -522,10 +522,10 @@ export default function FolderView({
<textarea
className="ignore-input"
value={ignoreDraft}
rows={6}
rows={8}
spellCheck={false}
placeholder={
'每行一条(gitignore 简化子集):\nnode_modules/ 目录整树剪枝\n*.log 扩展名通配\ntemp 名字匹配\nsrc/temp 多段路径(从根)\nbuild/**/cache ** 跨段\n!keep.log 否定(最后匹配优先)'
'每行一条(gitignore 简化子集):\nnode_modules/ 目录整树剪枝\n*.log 扩展名通配\nlo?g ? 单字符\n*.[ch] [abc] 字符类\nsrc/temp 多段路径(从根)\nbuild/**/cache ** 跨段\n!keep.log 否定(最后匹配优先)'
}
onChange={(e) => setIgnoreDraft(e.target.value)}
/>
+3 -2
View File
@@ -104,8 +104,9 @@ describe('parseSnapshot - v2 内容字段(contents', () => {
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()
// 非法版本号经展开构造(version 字面量类型为 1|2,直接传入 3/0 会编译报错)
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), version: 3 }))).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), version: 0 }))).toBeNull()
})
it('contents 形态异常整体回退 null(绝不部分加载)', () => {
+81
View File
@@ -128,6 +128,87 @@ describe('compileIgnoreRules - 段内 * 通配', () => {
})
})
describe('compileIgnoreRules - 段内 ? 单字符与字符类(0.10.1', () => {
it('? 匹配恰好一个任意字符(不足/超出均不命中)', () => {
expect(ignored(['lo?g'], 'loog')).toBe(true)
expect(ignored(['lo?g'], 'loXg')).toBe(true)
expect(ignored(['lo?g'], 'log')).toBe(false)
expect(ignored(['lo?g'], 'looog')).toBe(false)
expect(ignored(['?.txt'], 'a.txt')).toBe(true)
expect(ignored(['?.txt'], 'ab.txt')).toBe(false)
})
it('? 不跨段(单段模式只与整段匹配)', () => {
// 单段规则按路径段整体匹配:'a?c' 不命中 'a/c'(两段)
expect(ignored(['a?c'], 'a/c')).toBe(false)
expect(ignored(['src/??/temp'], 'src/ab/temp', true)).toBe(true)
expect(ignored(['src/??/temp'], 'src/a/temp', true)).toBe(false)
})
it('? 与 * 组合', () => {
expect(ignored(['*.?s'], 'a.ts')).toBe(true)
expect(ignored(['*.?s'], 'a.tsx')).toBe(false)
expect(ignored(['a?*c'], 'abxc')).toBe(true)
})
it('[abc] 字符类命中类内单字符', () => {
expect(ignored(['*.[ch]'], 'main.c')).toBe(true)
expect(ignored(['*.[ch]'], 'main.h')).toBe(true)
expect(ignored(['*.[ch]'], 'main.o')).toBe(false)
expect(ignored(['*.[ch]'], 'main.cc')).toBe(false)
})
it('[a-z] 范围类', () => {
expect(ignored(['file[0-9].txt'], 'file5.txt')).toBe(true)
expect(ignored(['file[0-9].txt'], 'fileA.txt')).toBe(false)
expect(ignored(['[a-c]x'], 'bx')).toBe(true)
expect(ignored(['[a-c]x'], 'dx')).toBe(false)
})
it('[!abc] 取反类', () => {
expect(ignored(['[!a].txt'], 'b.txt')).toBe(true)
expect(ignored(['[!a].txt'], 'a.txt')).toBe(false)
expect(ignored(['[!0-9]x'], 'ax')).toBe(true)
expect(ignored(['[!0-9]x'], '5x')).toBe(false)
})
it('字符类大小写不敏感([A-Z] 等效 [a-z],双侧小写化后匹配)', () => {
expect(ignored(['[A-Z].txt'], 'g.txt')).toBe(true)
expect(ignored(['[A-Z].txt'], 'G.TXT')).toBe(true)
expect(ignored(['[A-Z].txt'], '5.txt')).toBe(false)
})
it('未闭合 [ 与孤立 [ 按字面量方括号处理(gitignore 同款)', () => {
expect(ignored(['a[b'], 'a[b')).toBe(true)
expect(ignored(['a[b'], 'aab')).toBe(false)
expect(ignored(['x['], 'x[')).toBe(true)
})
it('空类体 [] 与 [!] 退化为字面量方括号', () => {
expect(ignored(['a[]b'], 'a[]b')).toBe(true)
expect(ignored(['a[]b'], 'ab')).toBe(false)
expect(ignored(['a[!]b'], 'a[!]b')).toBe(true)
})
it('无法构成合法正则的类体(反转范围 [z-a])整段回退字面量匹配,不崩溃', () => {
expect(ignored(['[z-a].txt'], '[z-a].txt')).toBe(true)
expect(ignored(['[z-a].txt'], 'a.txt')).toBe(false)
})
it('字符通配与前导 / 锚定组合(仅根级匹配)', () => {
expect(ignored(['/build[0-9]'], 'build7')).toBe(true)
expect(ignored(['/build[0-9]'], 'build7/dist')).toBe(false)
expect(ignored(['/build[0-9]'], 'pkg/build7')).toBe(false)
})
it('既有 * 语义与段内 ** 非法判定不受影响(回归锚)', () => {
expect(ignored(['a*b'], 'axb')).toBe(true)
expect(ignored(['a*b'], 'ab')).toBe(true)
expect(ignored(['*.log'], 'app.log')).toBe(true)
expect(ignored(['a**b'], 'axxb')).toBe(false)
})
})
describe('compileIgnoreRules - 否定规则(! 最后匹配优先)', () => {
it('否定救回:同路径最后一条匹配规则生效', () => {
const m = compileIgnoreRules(['*.log', '!keep.log'])
+69 -5
View File
@@ -3,10 +3,12 @@
* src/shared folderScan FolderView
* preload
*
* gitignore 0.10.0 glob
* gitignore 0.10.0 glob0.10.1
* - `name`
* - `name/`
* - `*.ext` / `a*b` `*` /
* - `a?c` / `lo?g` `?`
* - `*.[ch]` / `log[0-9].txt` / `[!abc]` `!`
* - `src/temp`
* - `/build` `/` `/`
* - `**` build**cache build
@@ -53,6 +55,64 @@ interface CompiledRule {
anchored: boolean
}
/** 单个非通配字符的正则转义(段内通配构造共用;`*` `?` `[` 由调用方先行分流) */
const escCh = (c: string): string => (/[$()*+.?[\\\]^{|}]/.test(c) ? '\\' + c : c)
/**
* seg[start] `[abc]` / `[a-z]` / `[!abc]`
* `[]` / `[!]` null
* `[` gitignore
* `-` `^` JS
*/
function parseCharClass(seg: string, start: number): { re: string; next: number } | null {
let j = start + 1
let neg = false
if (seg[j] === '!' || seg[j] === '^') {
neg = true
j++
}
let body = ''
while (j < seg.length && seg[j] !== ']') {
body += seg[j] === '\\' ? '\\\\' : seg[j]
j++
}
if (j >= seg.length || body === '') return null
return { re: `[${neg ? '^' : ''}${body}]`, next: j + 1 }
}
/**
*
* `*` = 0 `?` = `[...]` =
* / [z-a]退
*/
function wildSegRegex(seg: string): string {
let out = ''
let i = 0
while (i < seg.length) {
const c = seg[i]
if (c === '*') {
out += '.*'
i++
} else if (c === '?') {
out += '.'
i++
} else if (c === '[') {
const cls = parseCharClass(seg, i)
if (cls === null) {
out += '\\['
i++
} else {
out += cls.re
i = cls.next
}
} else {
out += escCh(c)
i++
}
}
return out
}
/** 编译单条规则文本(调用方已做 trim);返回 null 表示无效或为注释/空白行 */
function compileOne(rule: string): CompiledRule | null {
if (rule.length === 0 || rule.length > IGNORE_RULE_MAX_LEN) return null
@@ -81,10 +141,14 @@ function compileOne(rule: string): CompiledRule | null {
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 }
// 含段内通配字符(* ? [)才走正则编译,纯字面量走 === 快路径
if (!/[*?[]/.test(s)) return { kind: 'literal', lit: s }
try {
return { kind: 'wild', re: new RegExp('^' + wildSegRegex(s) + '$') }
} catch {
// 类体无法构成合法正则(如反转范围 [z-a]):整段退回字面量匹配,永不崩溃
return { kind: 'literal', lit: s }
}
})
return { segs, dirOnly, negate, anchored }
}