fix: v0.10.3 GB大小显示修复、主进程同步IO异步化、双进程重复逻辑收敛 - fmtSize补GB档(文件夹对比GB级文件此前显示1024.0MB)、scan-cache读盘与偏好写盘改fs.promises(IPC全异步原则补漏)、TEXT_EXTENSIONS/normDir/IGNORE_RULES_MAX收敛src/shared、安装包排除e2e与playwright配置、扫描进度E2E采样方式修稳

This commit is contained in:
2026-08-19 20:30:33 +08:00
parent 719681a366
commit 3ca18364cf
20 changed files with 267 additions and 174 deletions
+3 -3
View File
@@ -14,7 +14,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**扫描进度反馈**(比对阶段实时显示"已比对 N / M")、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**忽略规则**(gitignore 简化子集含高级 glob`node_modules/` 目录整树剪枝、`*.log` 通配、`lo?g` 单字符、`*.[ch]` 字符类(范围与取反)、`src/temp` 多段路径、`build/**/cache` 跨段、`!keep.log` 否定规则(最后匹配优先);无效规则实时提示不再静默丢弃;规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫) - **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**扫描进度反馈**(比对阶段实时显示"已比对 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 超限降级为纯状态快照,保存永不失败);**大快照专用读取通道**(加载上限 128MB,通用文件的 10MB 上限不拦截自家保存的大快照,且不污染文件对话框的目录记忆);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照) - **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**,保存期间批量读取实时显示"已读取 N / M"进度,保存中防重复触发),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么");内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);**大快照专用读取通道**(加载上限 128MB,通用文件的 10MB 上限不拦截自家保存的大快照,且不污染文件对话框的目录记忆);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照)
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成 - **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览) - **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览,文件大小 B/KB/MB/GB 人类可读
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅 - **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应 - **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应
- **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径 - **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径
@@ -98,7 +98,7 @@ npm run build:linux # Linux AppImage / deb
src/ src/
├── main/ # 主进程:窗口、菜单、文件/文件夹读取与编码探测(解码 worker 化)、批量读取与进度推送 IPC ├── main/ # 主进程:窗口、菜单、文件/文件夹读取与编码探测(解码 worker 化)、批量读取与进度推送 IPC
├── preload/ # 预加载:contextBridge 安全桥,仅暴露最小 API ├── preload/ # 预加载:contextBridge 安全桥,仅暴露最小 API
├── shared/ # 两进程共用的纯逻辑源码(忽略规则匹配器:主进程扫描剪枝 + 渲染端无效规则提示 ├── shared/ # 两进程共用的纯逻辑源码(忽略规则匹配器、文本扩展名清单、路径归一化
└── renderer/ # 渲染进程:React 界面与全部 diff 逻辑 └── renderer/ # 渲染进程:React 界面与全部 diff 逻辑
├── diff/ # diff 引擎(行级 + 词级高亮、字符级分块、报告与快照纯逻辑) ├── diff/ # diff 引擎(行级 + 词级高亮、字符级分块、报告与快照纯逻辑)
├── hooks/ # 状态 hooks(diff 计算调度、下拉关闭) ├── hooks/ # 状态 hooks(diff 计算调度、下拉关闭)
@@ -117,7 +117,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
- 作者:thzxx - 作者:thzxx
- 组织:MetonaTeam - 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE) - 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.10.2 - 版本:0.10.3
--- ---
+8 -1
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。 > 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.10.2`** 当前基线版本:**`0.10.3`**
--- ---
@@ -137,6 +137,13 @@ z = 补丁版本号(Patch
- 关于弹框:header 右侧常驻 ⓘ 图标按钮(两种模式可见)→ 弹框展示应用 Logo/介绍/版本号(app:get-version IPC 取 app.getVersion()package.json 为唯一来源,dev 与打包一致)/作者 thzxx/邮箱(stinanimz@gmail.com、1440196015@qq.com/组织 MetonaTeam/开源地址/许可证 MIT;Esc/遮罩/关闭按钮关闭,邮箱与开源地址点击复制(复用主进程剪贴板管线) - 关于弹框:header 右侧常驻 ⓘ 图标按钮(两种模式可见)→ 弹框展示应用 Logo/介绍/版本号(app:get-version IPC 取 app.getVersion()package.json 为唯一来源,dev 与打包一致)/作者 thzxx/邮箱(stinanimz@gmail.com、1440196015@qq.com/组织 MetonaTeam/开源地址/许可证 MIT;Esc/遮罩/关闭按钮关闭,邮箱与开源地址点击复制(复用主进程剪贴板管线)
- 微清理:删除孤儿 CSS 类 .diff-empty-pane0.9.2 清理孤儿变量的漏网);folderTree 聚合优先级注释补 unreadable - 微清理:删除孤儿 CSS 类 .diff-empty-pane0.9.2 清理孤儿变量的漏网);folderTree 聚合优先级注释补 unreadable
- 测试与文档:新增 14 单测(AboutModal 9 / App 4:关于打开关闭·复制·文件夹模式可见·异写防呆 / FolderView 1saving 禁用)与 2 条 E2E(关于弹框真实链路含版本号与复制 / 11MB 大快照加载成功固化上限修复),共 626 单测 + 29 E2E - 测试与文档:新增 14 单测(AboutModal 9 / App 4:关于打开关闭·复制·文件夹模式可见·异写防呆 / FolderView 1saving 禁用)与 2 条 E2E(关于弹框真实链路含版本号与复制 / 11MB 大快照加载成功固化上限修复),共 626 单测 + 29 E2E
0.10.3 ← GB 大小显示修复 · 主进程同步 IO 异步化 · 双进程重复逻辑收敛(已发布):
- GB 级文件大小显示修复:fmtSize(文件夹列表与三格式报告共用实现)补 GB 档——文件夹对比无单文件大小上限(10MB 只是全量比对上限,更大文件采样判定但大小字段照常显示),GB 级文件(视频/镜像)此前显示为 1024.0 MB 甚至 512000.0 MB,现正确显示 1.0 GB 档
- 主进程同步 IO 异步化("主进程 IPC 全异步"原则补漏):loadScanCacheStore 的 readFileSync 改 fs.promises(缓存文件可达 MB 级——LRU 8 对 × 万级条目,此前在 folder:scan handler 内与落盘读-改-写中同步读盘阻塞事件循环) · scanIpc.resolveScanArgs 异步化(loadStored 接受同步/异步实现,惰性语义不变——渲染端携带有效缓存时仍零读盘) · saveState 拆分 syncclose 事件,保证退出前落盘)/ asyncfile:open 与 folder:pick 内)双版本
- 双进程重复纯逻辑收敛到 src/shared(沿 0.10.0 ignoreRules 迁移先例,消除"两进程无法共享模块"的过时注释约束):TEXT_EXTENSIONS 60 项清单(主进程对话框筛选与语义判等文本判定 ↔ 渲染端拖拽入口校验,双向同步负担清零) · normDir 路径归一化(主进程 scanCacheStore 目录对键 ↔ 渲染端 snapshotDiff 目录对匹配与 App 同目录防呆) · IGNORE_RULES_MAX 上限常量(shared 定义 + App/FolderView 改直接导入,三处同步收敛为一处)
- 安装包排除开发期文件补漏:electron-builder files 增 !e2e/* 与 !playwright.config.*0.4.1 排除清单制定时 e2e 尚不存在,0.6.0 引入 E2E 后未补——E2E 源码与配置此前被打进 asar)
- 扫描进度 E2E 用例采样修稳(环境敏感型瞬态断言,非功能缺陷):快机上 2000 条目比对窗口仅 ~120mslocator.textContent() 在遮罩消失后自动重试阻塞 10s 吞掉后续采样——改为 evaluate 直读 DOM + 条目增至 4000 拉长瞬态窗口 + intervals 前密采样;诊断实证进度事件链路正常(遮罩文本完整经历 272/976/1696 推进)
- 测试与文档:新增 8 单测(fmtSize 各档与 GB 修复锚 3 / scanIpc 异步 stub 兼容 1 / pathNorm 直接单测 4)与共 634 单测 + 29 E2E(进度用例采样方式修稳,语义不变)
... ...
0.y.z ← 长期停留,永不进入 1.x 0.y.z ← 长期停留,永不进入 1.x
``` ```
+14 -11
View File
@@ -48,18 +48,21 @@ e2e/
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照与大快照专用通道/关于弹框/拖拽导入/偏好重启) └── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照与大快照专用通道/关于弹框/拖拽导入/偏好重启)
src/shared/ src/shared/
├── ignoreRules.ts # 忽略规则匹配器纯逻辑(gitignore 简化子集含高级 glob 与段内字符通配——? 单字符/[abc] 字符类;主进程扫描剪枝与渲染端无效提示共用,段模式预编译 + DP 段序列匹配 + 否定仲裁) ├── ignoreRules.ts # 忽略规则匹配器纯逻辑(gitignore 简化子集含高级 glob 与段内字符通配——? 单字符/[abc] 字符类;主进程扫描剪枝与渲染端无效提示共用,段模式预编译 + DP 段序列匹配 + 否定仲裁)
── ignoreRules.test.ts # 忽略规则单元测试(名字/目录/通配回归 + 多段/锚定/跨段/段内通配/否定仲裁/无效清单/上限/IPC 校验 + ? 与字符类专项) ── ignoreRules.test.ts # 忽略规则单元测试(名字/目录/通配回归 + 多段/锚定/跨段/段内通配/否定仲裁/无效清单/上限/IPC 校验 + ? 与字符类专项)
├── textExtensions.ts # 文本扩展名清单(0.10.3 收敛:主进程对话框筛选与语义判等文本判定、渲染端拖拽入口校验共用同一源文件,双向同步负担清零)
├── pathNorm.ts # 路径归一化纯逻辑(0.10.3 收敛:主进程 scanCacheStore 目录对键、渲染端 snapshotDiff 目录对匹配与 App 同目录防呆共用)
└── pathNorm.test.ts # 路径归一化单元测试(分隔符归一/盘符小写/尾斜杠/非路径透传)
src/main/ src/main/
├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,真实链路由 E2E 覆盖;含 folder:read-batch 批量读取与 folder:scan 进度推送) ├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,真实链路由 E2E 覆盖;含 folder:read-batch 批量读取与 folder:scan 进度推送;扫描缓存读盘与偏好写盘均异步 fs 不阻塞事件循环
├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用) ├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用)
├── decode.test.ts # 解码单元测试(BOM 三种/严格 UTF-8/GBK 回退/二进制启发式) ├── decode.test.ts # 解码单元测试(BOM 三种/严格 UTF-8/GBK 回退/二进制启发式)
├── decodeWorker.ts # 解码 worker_threads 入口(覆盖率排除,逻辑由 decode 测试覆盖) ├── decodeWorker.ts # 解码 worker_threads 入口(覆盖率排除,逻辑由 decode 测试覆盖)
├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对/语义判等/单点失败降级/忽略规则剪枝含高级 glob 与字符通配/比对进度回调) ├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似/受控并发比对/语义判等/单点失败降级/忽略规则剪枝含高级 glob 与字符通配/比对进度回调)
├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似/语义判等/目录降级/unreadable/忽略规则含 glob·否定·?与字符类/进度回调) ├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似/语义判等/目录降级/unreadable/忽略规则含 glob·否定·?与字符类/进度回调)
├── scanCacheStore.ts # 扫描缓存跨会话持久化纯数据(目录对键归一化/LRU/防御性解析) ├── scanCacheStore.ts # 扫描缓存跨会话持久化纯数据(目录对键归一化复用 shared/pathNorm/LRU/防御性解析)
├── scanCacheStore.test.ts # 缓存存储单元测试(解析回退/条目过滤/键归一化/LRU 淘汰/序列化往返) ├── scanCacheStore.test.ts # 缓存存储单元测试(解析回退/条目过滤/键归一化/LRU 淘汰/序列化往返)
├── scanIpc.ts # folder:scan IPC 入参校验与缓存源选择纯逻辑(无 electron 依赖) ├── scanIpc.ts # folder:scan IPC 入参校验与缓存源选择纯逻辑(无 electron 依赖;异步签名——loadStored 同步/异步实现均兼容,惰性读盘语义不变
├── scanIpc.test.ts # IPC 校验单元测试(previous 形态校验/缓存源选择与惰性读盘/忽略规则校验) ├── scanIpc.test.ts # IPC 校验单元测试(previous 形态校验/缓存源选择与惰性读盘含异步 stub 兼容/忽略规则校验)
├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖) ├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖)
└── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸) └── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸)
src/renderer/src/ src/renderer/src/
@@ -71,9 +74,9 @@ src/renderer/src/
│ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化) │ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化)
│ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退 │ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退
│ ├── report.test.ts # 单文件报告生成测试 │ ├── report.test.ts # 单文件报告生成测试
│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注;对照模式条目表替换为变迁对照表) │ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注;对照模式条目表替换为变迁对照表fmtSize 大小格式化含 GB 档,列表与报告共用
│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable/快照变迁对照表) │ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable/快照变迁对照表/fmtSize 各档与 GB 修复锚
│ ├── snapshotDiff.ts # 快照基线纯逻辑(v1/v2 结构/解析校验含 contents/目录对归一化匹配/六类变迁对照/共享变迁标签映射/内容保存上限常量) │ ├── snapshotDiff.ts # 快照基线纯逻辑(v1/v2 结构/解析校验含 contents/目录对归一化匹配normDir 复用 shared/pathNorm/六类变迁对照/共享变迁标签映射/内容保存上限常量)
│ ├── snapshotDiff.test.ts # 快照单元测试(解析防御/v1 v2 兼容/contents 形态校验/变迁判定/双侧空集/unreadable 语义/目录对归一化匹配) │ ├── snapshotDiff.test.ts # 快照单元测试(解析防御/v1 v2 兼容/contents 形态校验/变迁判定/双侧空集/unreadable 语义/目录对归一化匹配)
│ └── textUtils.test.ts # 文本扩展名与行数统计测试 │ └── textUtils.test.ts # 文本扩展名与行数统计测试
├── hooks/ ├── hooks/
@@ -215,7 +218,7 @@ src/renderer/src/
- 增量重扫(0.8.0):真实目录修改文件后点「重新扫描」状态正确更新(相同→不同→改回相同,真实 mtime 变化走指纹判定) - 增量重扫(0.8.0):真实目录修改文件后点「重新扫描」状态正确更新(相同→不同→改回相同,真实 mtime 变化走指纹判定)
- 拖拽导入(0.8.0):dropFiles 合成 DataTransfer 拖放——空面板拖入渲染(arrayBuffer → decodeBuffer IPC 全链路)、双侧拖入对比、非文本扩展名拒绝、多文件提示取首个 - 拖拽导入(0.8.0):dropFiles 合成 DataTransfer 拖放——空面板拖入渲染(arrayBuffer → decodeBuffer IPC 全链路)、双侧拖入对比、非文本扩展名拒绝、多文件提示取首个
- 忽略规则(0.9.00.10.0 高级 glob):真实目录含 node_modules 子树——无规则时差异参与对比,输入规则应用重扫后整树剪枝(不同归零、空态一致提示、被忽略条目消失);高级 glob——build 下深层 temp 目录经 build/**/temp 跨段剪枝、*.log 忽略 + !keep.log 否定救回(差异计数精确收敛)、无效规则提示出现 - 忽略规则(0.9.00.10.0 高级 glob):真实目录含 node_modules 子树——无规则时差异参与对比,输入规则应用重扫后整树剪枝(不同归零、空态一致提示、被忽略条目消失);高级 glob——build 下深层 temp 目录经 build/**/temp 跨段剪枝、*.log 忽略 + !keep.log 否定救回(差异计数精确收敛)、无效规则提示出现
- 扫描进度反馈(0.10.0):2000 条目目录扫描期间遮罩"已比对 N / 2000"轮询抓取(瞬态断言)完成后全量判定与遮罩消失 - 扫描进度反馈(0.10.00.10.3 采样修稳):4000 条目目录扫描期间遮罩"已比对 N / 4000"轮询抓取(瞬态断言)——evaluate 直读 DOM(避免遮罩消失后 locator 自动重试阻塞采样)+ intervals 前密采样,快机上数百毫秒的瞬态窗口稳定可抓;完成后全量判定与遮罩消失
- 增量缓存跨会话持久化(0.9.0):第一实例扫描后 userData/scan-cache.json 落盘(等待异步写盘,断言含条目与合法结构)→ 同 userData 第二实例重扫同目录结果正确(主进程注入盘上缓存) - 增量缓存跨会话持久化(0.9.0):第一实例扫描后 userData/scan-cache.json 落盘(等待异步写盘,断言含条目与合法结构)→ 同 userData 第二实例重扫同目录结果正确(主进程注入盘上缓存)
- 快照基线(0.9.00.9.1 增强,0.10.0 内容级对照):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章) - 快照基线(0.9.00.9.1 增强,0.10.0 内容级对照):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章)
- 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归) - 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归)
@@ -224,9 +227,9 @@ src/renderer/src/
- 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData) - 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData)
- 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理) - 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理)
### 5.12 folder:scan IPC 校验(`src/main/scanIpc.ts`0.9.1 ### 5.12 folder:scan IPC 校验(`src/main/scanIpc.ts`0.9.10.10.3 异步化
- isScanCacheEntries 形态校验:合法缓存数组(rel/status 为字符串)通过;非数组 / 含非对象 / rel 或 status 非字符串拒绝 - isScanCacheEntries 形态校验:合法缓存数组(rel/status 为字符串)通过;非数组 / 含非对象 / rel 或 status 非字符串拒绝
- resolveScanArgs 缓存源选择:渲染端携带合法内存缓存原样传递且惰性不读盘 / 内存缓存为空数组(合法形态)同样优先(阻止注入可能过期的盘上缓存)/ 未携带与形态非法回退盘上缓存(惰性读盘恰好一次)/ 盘上缓存为 null 或空数组不注入(全量扫描) - resolveScanArgs 缓存源选择(异步签名,loadStored 同步/异步实现均兼容):渲染端携带合法内存缓存原样传递且惰性不读盘 / 内存缓存为空数组(合法形态)同样优先(阻止注入可能过期的盘上缓存)/ 未携带与形态非法回退盘上缓存(惰性读盘恰好一次)/ 盘上缓存为 null 或空数组不注入(全量扫描)/ 异步 loadStored stub(生产 fs.promises 读盘形态)同样正确回退
- 忽略规则校验:复用 isIgnoreRulesArray(合法字符串数组与空数组传递;非数组 / 含非字符串 / 超条数整体忽略) - 忽略规则校验:复用 isIgnoreRulesArray(合法字符串数组与空数组传递;非数组 / 含非字符串 / 超条数整体忽略)
- 组合场景:内存缓存非法 + 忽略规则合法 + 盘上缓存有效时两者分别正确解析 - 组合场景:内存缓存非法 + 忽略规则合法 + 盘上缓存有效时两者分别正确解析
- 主进程 index.ts 的 handler 壳(IPC 注册与真实读写盘)由 E2E 文件夹用例覆盖 - 主进程 index.ts 的 handler 壳(IPC 注册与真实读写盘)由 E2E 文件夹用例覆盖
+14 -5
View File
@@ -501,8 +501,9 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
const { mkdir, writeFile: wf } = await import('fs/promises') const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(leftDir, { recursive: true }) await mkdir(leftDir, { recursive: true })
await mkdir(rightDir, { recursive: true }) await mkdir(rightDir, { recursive: true })
// 2000 个大小一致条目需读内容比对(125 个并发批次),进度事件可见窗口足够 // 4000 个大小一致条目需读内容比对(250 个并发批次):快机上比对窗口约数百毫秒,
for (let i = 0; i < 2000; i++) { // 进度文本存在时间足够被轮询采样抓到(2000 条在快机上仅 ~120ms,采样易错过)
for (let i = 0; i < 4000; i++) {
const name = `f${String(i).padStart(4, '0')}.txt` const name = `f${String(i).padStart(4, '0')}.txt`
await wf(join(leftDir, name), `L${i}`) await wf(join(leftDir, name), `L${i}`)
await wf(join(rightDir, name), `R${i}`) await wf(join(rightDir, name), `R${i}`)
@@ -511,15 +512,23 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
const { app, window } = await launchApp() const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[leftDir], [rightDir]]) await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
await window.getByRole('button', { name: '对比文件夹' }).click() await window.getByRole('button', { name: '对比文件夹' }).click()
// 瞬态进度文案:轮询抓取扫描期间的遮罩文本(完成后遮罩消失) // 瞬态进度文案:轮询抓取扫描期间的遮罩文本(完成后遮罩消失)
// evaluate 直读 DOM:遮罩消失后 querySelector 返回空串立即结束本轮采样,
// 避免 locator.textContent() 的自动重试在元素消失后阻塞 10s 吞掉后续采样窗口;
// intervals 前密后疏,抓住数百毫秒级的瞬态窗口
await expect await expect
.poll( .poll(
async () => async () =>
/已比对 \d+ \/ 2000/.test((await window.locator('.computing-overlay').textContent()) ?? '') /已比对 \d+ \/ 4000/.test(
(await window.evaluate(
() => document.querySelector('.computing-overlay')?.textContent ?? ''
)) ?? ''
),
{ intervals: [50, 100] }
) )
.toBe(true) .toBe(true)
// 扫描完成:全量判定与遮罩消失 // 扫描完成:全量判定与遮罩消失
await expect(window.getByText('不同 2000')).toBeVisible() await expect(window.getByText('不同 4000')).toBeVisible()
await expect(window.locator('.computing-overlay')).toHaveCount(0) await expect(window.locator('.computing-overlay')).toHaveCount(0)
await app.close() await app.close()
} finally { } finally {
+4 -1
View File
@@ -14,12 +14,15 @@ files:
- '!src/*' - '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}' - '!electron.vite.config.{js,ts,mjs,cjs}'
- '!vitest.config.{js,ts,mjs,cjs}' - '!vitest.config.{js,ts,mjs,cjs}'
- '!playwright.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
# 开发期专用文件不进入安装包(assets 仅为打包期图标源,运行时图标在 resources/) # 开发期专用文件不进入安装包(assets 仅为打包期图标源,运行时图标在 resources/)
# e2e 与两份测试配置为 0.6.0 / 0.2.1 引入,0.4.1 排除清单的补漏
- '!docs/*' - '!docs/*'
- '!assets/*' - '!assets/*'
- '!e2e/*'
asarUnpack: asarUnpack:
- resources/** - resources/**
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.9.3", "version": "0.10.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "DiffLens", "name": "DiffLens",
"version": "0.9.3", "version": "0.10.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.10.2", "version": "0.10.3",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+36 -27
View File
@@ -14,25 +14,17 @@ import {
emptyScanCacheStore, emptyScanCacheStore,
type ScanCacheStoreData type ScanCacheStoreData
} from './scanCacheStore' } from './scanCacheStore'
import { TEXT_EXTENSIONS } from '../shared/textExtensions'
/** /**
* 文本类扩展名清单(小写,不含点): * 相对路径是否为文本文件(按扩展名,语义判等文本判定共用):
* 与渲染进程 TEXT_EXTENSIONSsrc/renderer/src/diff/textUtils.ts)完全对齐,两进程无法共享模块, * 扩展名清单来自 src/shared/textExtensions(与渲染端拖拽入口共用同一源文件);
* 改动需双向同步。供打开文件对话框筛选与文件夹语义判等的文本文件判定共用 * 无扩展名的文件按文本放行(与渲染端 isTextFile 口径一致)
*/ */
const TEXT_FILE_EXTENSIONS = [
'txt', 'text', 'md', 'markdown', 'json', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts', 'tsx', 'jsx',
'css', 'scss', 'less', 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf',
'config', 'properties', 'env', 'py', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cc', 'cs',
'swift', 'kt', 'rb', 'php', 'pl', 'sh', 'zsh', 'bash', 'fish', 'bat', 'cmd', 'ps1', 'log',
'csv', 'tsv', 'sql', 'vue', 'svelte', 'graphql', 'gql', 'gradle', 'lock'
]
/** 相对路径是否为文本文件(按扩展名,语义判等文本判定共用) */
function isTextRel(rel: string): boolean { function isTextRel(rel: string): boolean {
const dot = rel.lastIndexOf('.') const dot = rel.lastIndexOf('.')
if (dot < 0) return true if (dot < 0) return true
return TEXT_FILE_EXTENSIONS.includes(rel.slice(dot + 1).toLowerCase()) return TEXT_EXTENSIONS.includes(rel.slice(dot + 1).toLowerCase())
} }
/** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */ /** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */
@@ -41,10 +33,12 @@ const stateFile = (): string => join(app.getPath('userData'), 'window-state.json
/** 文件夹扫描增量缓存的跨会话持久化文件,位于 userData 目录 */ /** 文件夹扫描增量缓存的跨会话持久化文件,位于 userData 目录 */
const scanCacheFile = (): string => join(app.getPath('userData'), 'scan-cache.json') const scanCacheFile = (): string => join(app.getPath('userData'), 'scan-cache.json')
/** 读取持久化扫描缓存存储:文件不存在/损坏回退空存储,不影响扫描 */ /** 读取持久化扫描缓存存储:文件不存在/损坏回退空存储,不影响扫描
function loadScanCacheStore(): ScanCacheStoreData { * 异步读盘(渲染端未携带内存缓存时在 folder:scan handler 内惰性触发)——
* 缓存文件可达 MB 级(LRU 8 对 × 万级条目),同步读会阻塞主进程事件循环 */
async function loadScanCacheStore(): Promise<ScanCacheStoreData> {
try { try {
return parseScanCacheStore(fs.readFileSync(scanCacheFile(), 'utf-8')) return parseScanCacheStore(await fs.promises.readFile(scanCacheFile(), 'utf-8'))
} catch { } catch {
return emptyScanCacheStore() return emptyScanCacheStore()
} }
@@ -68,7 +62,7 @@ async function writeScanCache(
cache: ScanCacheEntry[] cache: ScanCacheEntry[]
): Promise<void> { ): Promise<void> {
try { try {
const next = putScanCache(loadScanCacheStore(), leftDir, rightDir, cache, Date.now()) const next = putScanCache(await loadScanCacheStore(), leftDir, rightDir, cache, Date.now())
await fs.promises.writeFile(scanCacheFile(), JSON.stringify(next), 'utf-8') await fs.promises.writeFile(scanCacheFile(), JSON.stringify(next), 'utf-8')
} catch { } catch {
// 忽略写入失败 // 忽略写入失败
@@ -87,15 +81,30 @@ function loadState(): AppState {
} }
} }
/** 写回持久化状态:失败静默忽略(目录只读/磁盘满等,偏好记忆失效但不影响本次会话 */ /** 序列化当前应用状态(同步/异步写盘共用 */
function saveState(): void { function serializeState(): string {
return JSON.stringify(appState)
}
/** 写回持久化状态(close 事件专用:同步写保证 app 退出前落盘完成) */
function saveStateSync(): void {
try { try {
fs.writeFileSync(stateFile(), JSON.stringify(appState), 'utf-8') fs.writeFileSync(stateFile(), serializeState(), 'utf-8')
} catch { } catch {
// 忽略写入失败 // 忽略写入失败
} }
} }
/** 写回持久化状态(IPC handler 内专用:异步写盘不阻塞事件循环,失败静默忽略——
* 目录只读/磁盘满等偏好记忆失效但不影响本次会话;appState 内存态始终最新) */
function saveStateAsync(): void {
try {
void fs.promises.writeFile(stateFile(), serializeState(), 'utf-8').catch(() => {})
} catch {
// 序列化失败等:忽略
}
}
function createWindow(): void { function createWindow(): void {
// 恢复上次窗口尺寸/位置:越界 bounds 钳制回当前屏幕工作区,无记录时用默认尺寸 // 恢复上次窗口尺寸/位置:越界 bounds 钳制回当前屏幕工作区,无记录时用默认尺寸
const wa = screen.getPrimaryDisplay().workArea const wa = screen.getPrimaryDisplay().workArea
@@ -127,7 +136,7 @@ function createWindow(): void {
if (mainWindow.isMinimized()) return if (mainWindow.isMinimized()) return
const b = mainWindow.getNormalBounds() const b = mainWindow.getNormalBounds()
appState.bounds = { x: b.x, y: b.y, width: b.width, height: b.height } appState.bounds = { x: b.x, y: b.y, width: b.width, height: b.height }
saveState() saveStateSync()
}) })
mainWindow.on('ready-to-show', () => { mainWindow.on('ready-to-show', () => {
@@ -248,7 +257,7 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
defaultPath: appState.lastDir, defaultPath: appState.lastDir,
properties: ['openFile'], properties: ['openFile'],
filters: [ filters: [
{ name: '文本文件', extensions: TEXT_FILE_EXTENSIONS }, { name: '文本文件', extensions: TEXT_EXTENSIONS },
// 兜底入口:允许打开任意扩展名文件 // 兜底入口:允许打开任意扩展名文件
{ name: '所有文件', extensions: ['*'] } { name: '所有文件', extensions: ['*'] }
] ]
@@ -257,7 +266,7 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
const filePath = result.filePaths[0] const filePath = result.filePaths[0]
// 记录所在目录供下次对话框起始定位(与窗口 bounds 同存一个状态文件) // 记录所在目录供下次对话框起始定位(与窗口 bounds 同存一个状态文件)
appState.lastDir = dirname(filePath) appState.lastDir = dirname(filePath)
saveState() saveStateAsync()
return readTextFile(filePath) return readTextFile(filePath)
}) })
@@ -305,7 +314,7 @@ ipcMain.handle('folder:pick', async () => {
if (result.canceled || result.filePaths.length === 0) return null if (result.canceled || result.filePaths.length === 0) return null
const dirPath = result.filePaths[0] const dirPath = result.filePaths[0]
appState.lastDir = dirPath appState.lastDir = dirPath
saveState() saveStateAsync()
return dirPath return dirPath
}) })
@@ -326,9 +335,9 @@ ipcMain.handle(
scanId?: unknown scanId?: unknown
) => { ) => {
try { try {
// 入参防御性校验与增量缓存源选择(内存缓存优先,未携带/非法回退 userData 落盘缓存,惰性读盘) // 入参防御性校验与增量缓存源选择(内存缓存优先,未携带/非法回退 userData 落盘缓存,惰性异步读盘)
const args = resolveScanArgs(previous, ignoreRules, () => const args = await resolveScanArgs(previous, ignoreRules, async () =>
getScanCache(loadScanCacheStore(), leftDir, rightDir) getScanCache(await loadScanCacheStore(), leftDir, rightDir)
) )
const id = typeof scanId === 'number' && Number.isFinite(scanId) ? scanId : -1 const id = typeof scanId === 'number' && Number.isFinite(scanId) ? scanId : -1
// 比对进度推送(首个 push 型 IPC):窗口销毁后 send 抛错,进度丢失无影响 // 比对进度推送(首个 push 型 IPC):窗口销毁后 send 抛错,进度丢失无影响
+2 -9
View File
@@ -1,4 +1,5 @@
import type { ScanCacheEntry } from './folderScan' import type { ScanCacheEntry } from './folderScan'
import { normDir } from '../shared/pathNorm'
/** /**
* 文件夹扫描增量缓存的跨会话持久化纯数据模块(无 electron/fs 依赖,vitest 直接测试)。 * 文件夹扫描增量缓存的跨会话持久化纯数据模块(无 electron/fs 依赖,vitest 直接测试)。
@@ -34,15 +35,7 @@ export const emptyScanCacheStore = (): ScanCacheStoreData => ({
pairs: {} pairs: {}
}) })
/** 单一路径归一化:反斜杠→斜杠、去尾分隔符、盘符小写(其余保留原大小写 */ /** 目录对键:两个归一化路径(shared/pathNorm)以 | 拼接(| 不出现在路径合法字符中 */
function normDir(p: string): string {
let s = p.replace(/\\/g, '/')
while (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1)
if (s.length >= 2 && s[1] === ':') s = s[0].toLowerCase() + s.slice(1)
return s
}
/** 目录对键:两个归一化路径以 | 拼接(| 不出现在路径合法字符中) */
export function pairKey(leftDir: string, rightDir: string): string { export function pairKey(leftDir: string, rightDir: string): string {
return `${normDir(leftDir)}|${normDir(rightDir)}` return `${normDir(leftDir)}|${normDir(rightDir)}`
} }
+32 -22
View File
@@ -36,62 +36,72 @@ describe('isScanCacheEntries - IPC 形态校验', () => {
}) })
describe('resolveScanArgs - 缓存源选择与忽略规则校验', () => { describe('resolveScanArgs - 缓存源选择与忽略规则校验', () => {
it('渲染端携带合法内存缓存:原样传递且不读盘(惰性)', () => { it('渲染端携带合法内存缓存:原样传递且不读盘(惰性)', async () => {
const stored = storedLoader([entry('disk.txt')]) const stored = storedLoader([entry('disk.txt')])
const mem = [entry('mem.txt')] const mem = [entry('mem.txt')]
const r = resolveScanArgs(mem, undefined, stored) const r = await resolveScanArgs(mem, undefined, stored)
expect(r.previous).toBe(mem) expect(r.previous).toBe(mem)
expect(stored.calls).toBe(0) expect(stored.calls).toBe(0)
}) })
it('内存缓存为空数组(合法形态):使用空数组,同样不注入盘上缓存', () => { it('内存缓存为空数组(合法形态):使用空数组,同样不注入盘上缓存', async () => {
// 空数组是同目录上次扫描的最新形态(如空目录对),应优先于可能过期的盘上缓存 // 空数组是同目录上次扫描的最新形态(如空目录对),应优先于可能过期的盘上缓存
const stored = storedLoader([entry('disk.txt')]) const stored = storedLoader([entry('disk.txt')])
const r = resolveScanArgs([], undefined, stored) const r = await resolveScanArgs([], undefined, stored)
expect(r.previous).toEqual([]) expect(r.previous).toEqual([])
expect(stored.calls).toBe(0) expect(stored.calls).toBe(0)
}) })
it('未携带 / 形态非法的内存缓存:回退盘上缓存(惰性读盘一次)', () => { it('未携带 / 形态非法的内存缓存:回退盘上缓存(惰性读盘一次)', async () => {
const disk = [entry('disk.txt')] const disk = [entry('disk.txt')]
for (const bad of [undefined, null, 'x', [{ rel: 1, status: 'same' }]] as unknown[]) { for (const bad of [undefined, null, 'x', [{ rel: 1, status: 'same' }]] as unknown[]) {
const stored = storedLoader(disk) const stored = storedLoader(disk)
const r = resolveScanArgs(bad, undefined, stored) const r = await resolveScanArgs(bad, undefined, stored)
expect(r.previous).toBe(disk) expect(r.previous).toBe(disk)
expect(stored.calls).toBe(1) expect(stored.calls).toBe(1)
} }
}) })
it('盘上缓存为 null 或空数组:不注入 previous(全量扫描)', () => { it('盘上缓存为 null 或空数组:不注入 previous(全量扫描)', async () => {
expect(resolveScanArgs(undefined, undefined, storedLoader(null)).previous).toBeUndefined() expect((await resolveScanArgs(undefined, undefined, storedLoader(null))).previous).toBeUndefined()
expect(resolveScanArgs(undefined, undefined, storedLoader([])).previous).toBeUndefined() expect((await resolveScanArgs(undefined, undefined, storedLoader([]))).previous).toBeUndefined()
}) })
it('忽略规则合法字符串数组传递(空数组合法)', () => { it('loadStored 为异步实现(生产 fs.promises 读盘)同样正确回退', async () => {
expect(resolveScanArgs(undefined, ['node_modules/', '*.log'], storedLoader(null)).ignore).toEqual([ // 0.10.3 异步化:生产 loadStored 返回 Promise,同步 stub 与异步 stub 行为一致
const disk = [entry('disk.txt')]
const asyncLoader = async (): Promise<ScanCacheEntry[] | null> => disk
const r = await resolveScanArgs(undefined, undefined, asyncLoader)
expect(r.previous).toBe(disk)
})
it('忽略规则合法字符串数组传递(空数组合法)', async () => {
expect((await resolveScanArgs(undefined, ['node_modules/', '*.log'], storedLoader(null))).ignore).toEqual([
'node_modules/', 'node_modules/',
'*.log' '*.log'
]) ])
expect(resolveScanArgs(undefined, [], storedLoader(null)).ignore).toEqual([]) expect((await resolveScanArgs(undefined, [], storedLoader(null))).ignore).toEqual([])
}) })
it('忽略规则非法形态(非数组/含非字符串/超条数)整体忽略', () => { it('忽略规则非法形态(非数组/含非字符串/超条数)整体忽略', async () => {
expect(resolveScanArgs(undefined, null, storedLoader(null)).ignore).toBeUndefined() expect((await resolveScanArgs(undefined, null, storedLoader(null))).ignore).toBeUndefined()
expect(resolveScanArgs(undefined, 'node_modules/', storedLoader(null)).ignore).toBeUndefined() expect((await resolveScanArgs(undefined, 'node_modules/', storedLoader(null))).ignore).toBeUndefined()
expect(resolveScanArgs(undefined, ['a', 42], storedLoader(null)).ignore).toBeUndefined() expect((await resolveScanArgs(undefined, ['a', 42], storedLoader(null))).ignore).toBeUndefined()
expect( expect(
resolveScanArgs( (
undefined, await resolveScanArgs(
Array.from({ length: 51 }, () => 'a'), undefined,
storedLoader(null) Array.from({ length: 51 }, () => 'a'),
storedLoader(null)
)
).ignore ).ignore
).toBeUndefined() ).toBeUndefined()
}) })
it('组合:内存缓存非法 + 忽略规则合法 + 盘上缓存有效', () => { it('组合:内存缓存非法 + 忽略规则合法 + 盘上缓存有效', async () => {
const disk = [entry('disk.txt')] const disk = [entry('disk.txt')]
const stored = storedLoader(disk) const stored = storedLoader(disk)
const r = resolveScanArgs([{ rel: 'bad' }], ['dist/'], stored) const r = await resolveScanArgs([{ rel: 'bad' }], ['dist/'], stored)
expect(r.previous).toBe(disk) expect(r.previous).toBe(disk)
expect(r.ignore).toEqual(['dist/']) expect(r.ignore).toEqual(['dist/'])
expect(stored.calls).toBe(1) expect(stored.calls).toBe(1)
+9 -7
View File
@@ -35,20 +35,22 @@ export interface ResolvedScanArgs {
* 组合 folder:scan 的入参校验与缓存源选择: * 组合 folder:scan 的入参校验与缓存源选择:
* - previous 形态异常(非数组 / 条目缺字符串 rel/status)一律不沿用; * - previous 形态异常(非数组 / 条目缺字符串 rel/status)一律不沿用;
* - previous 未携带或非法时经 loadStored 惰性读取盘上持久化缓存(非空才注入), * - previous 未携带或非法时经 loadStored 惰性读取盘上持久化缓存(非空才注入),
* 惰性求值保证渲染端携带有效内存缓存时不产生读盘 IO; * 惰性求值await 仅在需要回退时发生)保证渲染端携带有效内存缓存时不产生读盘 IO;
* - loadStored 可同步或异步(生产实现为 fs.promises 异步读盘,测试注入同步实现),
* 异步化保证 IPC handler 内不再有同步 IO 阻塞主进程事件循环;
* - ignoreRules 复用 isIgnoreRulesArray(数组形态 + 条数/单条长度上限)。 * - ignoreRules 复用 isIgnoreRulesArray(数组形态 + 条数/单条长度上限)。
*/ */
export function resolveScanArgs( export async function resolveScanArgs(
previous: unknown, previous: unknown,
ignoreRules: unknown, ignoreRules: unknown,
loadStored: () => ScanCacheEntry[] | null loadStored: () => ScanCacheEntry[] | null | Promise<ScanCacheEntry[] | null>
): ResolvedScanArgs { ): Promise<ResolvedScanArgs> {
const mem = isScanCacheEntries(previous) ? previous : undefined const mem = isScanCacheEntries(previous) ? previous : undefined
const fallback = (): ScanCacheEntry[] | undefined => { const fallback = async (): Promise<ScanCacheEntry[] | undefined> => {
const stored = loadStored() const stored = await loadStored()
return stored !== null && stored.length > 0 ? stored : undefined return stored !== null && stored.length > 0 ? stored : undefined
} }
const previousOut = mem !== undefined ? mem : fallback() const previousOut = mem !== undefined ? mem : await fallback()
return { return {
...(previousOut !== undefined ? { previous: previousOut } : {}), ...(previousOut !== undefined ? { previous: previousOut } : {}),
...(isIgnoreRulesArray(ignoreRules) ? { ignore: ignoreRules } : {}) ...(isIgnoreRulesArray(ignoreRules) ? { ignore: ignoreRules } : {})
+2 -4
View File
@@ -18,7 +18,6 @@ import {
parseSnapshot, parseSnapshot,
diffSnapshot, diffSnapshot,
snapshotDirsMatch, snapshotDirsMatch,
normDir,
isDiffStatus, isDiffStatus,
SNAPSHOT_VERSION, SNAPSHOT_VERSION,
SNAPSHOT_FILE_MAX_BYTES, SNAPSHOT_FILE_MAX_BYTES,
@@ -27,6 +26,8 @@ import {
type SnapshotContents, type SnapshotContents,
type SnapshotDiffResult type SnapshotDiffResult
} from './diff/snapshotDiff' } from './diff/snapshotDiff'
import { normDir } from '../../shared/pathNorm'
import { IGNORE_RULES_MAX } from '../../shared/ignoreRules'
import { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils' import { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils'
import { useDismiss } from './hooks/useDismiss' import { useDismiss } from './hooks/useDismiss'
import { useDiff } from './hooks/useDiff' import { useDiff } from './hooks/useDiff'
@@ -85,9 +86,6 @@ interface Prefs {
folderIgnoreRules: string[] folderIgnoreRules: string[]
} }
/** 忽略规则条数上限(与主进程 ignoreRules.ts / FolderView 保持一致,改动需三方同步) */
const IGNORE_RULES_MAX = 50
/** /**
* 读取持久化偏好:只接受已知字段的严格 boolean 值与合法忽略规则数组; * 读取持久化偏好:只接受已知字段的严格 boolean 值与合法忽略规则数组;
* 数据损坏(JSON 非法 / 字段类型异常 / 存储不可用)一律回退默认,保证启动不受影响。 * 数据损坏(JSON 非法 / 字段类型异常 / 存储不可用)一律回退默认,保证启动不受影响。
+1 -3
View File
@@ -13,7 +13,7 @@ import { countFolderStats, fmtSize } from '../diff/folderReport'
import type { FolderSnapshot, SnapshotChange, SnapshotDiffResult } from '../diff/snapshotDiff' import type { FolderSnapshot, SnapshotChange, SnapshotDiffResult } from '../diff/snapshotDiff'
import { SNAPSHOT_CHANGE_LABEL } from '../diff/snapshotDiff' import { SNAPSHOT_CHANGE_LABEL } from '../diff/snapshotDiff'
import { useDismiss } from '../hooks/useDismiss' import { useDismiss } from '../hooks/useDismiss'
import { compileIgnoreRules } from '../../../shared/ignoreRules' import { compileIgnoreRules, IGNORE_RULES_MAX } from '../../../shared/ignoreRules'
import { buildFolderTree, filterEntries, flattenFolderTree, relExt } from './folderTree' import { buildFolderTree, filterEntries, flattenFolderTree, relExt } from './folderTree'
/** 文件夹列表虚拟行高(px),与 global.css 的 --folder-row-height 保持一致,改动需双向同步 */ /** 文件夹列表虚拟行高(px),与 global.css 的 --folder-row-height 保持一致,改动需双向同步 */
@@ -24,8 +24,6 @@ const OVERSCAN = 8
const FALLBACK_VIEWPORT = 600 const FALLBACK_VIEWPORT = 600
/** 树形缩进步进(px / 深度) */ /** 树形缩进步进(px / 深度) */
const TREE_INDENT = 16 const TREE_INDENT = 16
/** 忽略规则条数上限(与主进程 ignoreRules.ts 的 IGNORE_RULES_MAX 保持一致,改动需双向同步) */
const IGNORE_RULES_MAX = 50
export interface FolderViewProps { export interface FolderViewProps {
leftDir: string leftDir: string
@@ -3,6 +3,7 @@ import type { FolderEntry } from '../../../preload/index'
import { import {
buildFolderReport, buildFolderReport,
countFolderStats, countFolderStats,
fmtSize,
plainFolderSummary plainFolderSummary
} from './folderReport' } from './folderReport'
import { diffSnapshot } from './snapshotDiff' import { diffSnapshot } from './snapshotDiff'
@@ -46,6 +47,27 @@ describe('countFolderStats', () => {
}) })
}) })
describe('fmtSize - 人类可读文件大小', () => {
it('null 占位为 —', () => {
expect(fmtSize(null)).toBe('—')
})
it('B / KB / MB / GB 各档正确换算(含边界值)', () => {
expect(fmtSize(0)).toBe('0 B')
expect(fmtSize(1023)).toBe('1023 B')
expect(fmtSize(1024)).toBe('1.0 KB')
expect(fmtSize(1024 * 1024)).toBe('1.0 MB')
expect(fmtSize(1024 * 1024 * 1024)).toBe('1.0 GB')
expect(fmtSize(2.5 * 1024 * 1024 * 1024)).toBe('2.5 GB')
})
it('GB 级文件不再显示为 1024.0 MB0.10.3 修复锚)', () => {
// 文件夹对比无单文件大小上限,GB 级文件(视频/镜像)大小字段照常显示
expect(fmtSize(500 * 1024 * 1024 * 1024)).toBe('500.0 GB')
expect(fmtSize(1024 * 1024 * 1024)).not.toBe('1024.0 MB')
})
})
describe('plainFolderSummary', () => { describe('plainFolderSummary', () => {
it('空文件夹给出无文件结论', () => { it('空文件夹给出无文件结论', () => {
expect(plainFolderSummary(countFolderStats([]), 0)).toBe('两个文件夹内都没有文件') expect(plainFolderSummary(countFolderStats([]), 0)).toBe('两个文件夹内都没有文件')
+7 -2
View File
@@ -85,12 +85,17 @@ const STATUS_LABEL: Record<FolderEntryStatus, string> = {
unreadable: '无法读取' unreadable: '无法读取'
} }
/** 人类可读的文件大小(报告与 FolderView 列表共用同一实现) */ /**
* 人类可读的文件大小(报告与 FolderView 列表共用同一实现)。
* GB 档必备:文件夹对比无单文件大小上限(10MB 只是全量比对上限,更大文件
* 采样判定但大小字段照常显示),GB 级文件(视频/镜像)此前会显示为 1024.0 MB。
*/
export function fmtSize(n: number | null): string { export function fmtSize(n: number | null): string {
if (n === null) return '—' if (n === null) return '—'
if (n < 1024) return `${n} B` if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB` if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
} }
/** ============ HTML 报告 ============ */ /** ============ HTML 报告 ============ */
+1 -13
View File
@@ -1,4 +1,5 @@
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index' import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
import { normDir } from '../../../shared/pathNorm'
/** /**
* 文件夹对比快照基线的纯逻辑模块: * 文件夹对比快照基线的纯逻辑模块:
@@ -176,19 +177,6 @@ export const SNAPSHOT_CHANGE_LABEL: Record<SnapshotChange, string> = {
stable: '保持一致' stable: '保持一致'
} }
/**
* 单一路径归一化:反斜杠→斜杠、去尾分隔符、Windows 盘符小写(其余保留原大小写)。
* 与主进程 scanCacheStore.ts 的 normDir 保持完全一致(两进程无法共享模块,改动需双向同步):
* Windows 大小写不敏感文件系统下 C:\Foo 与 c:/Foo 为同一目录,逐字符比较会误判目录对不一致。
* 0.10.2 起导出供 App 的同目录防呆复用(startFolderCompare 的两侧目录判重)。
*/
export function normDir(p: string): string {
let s = p.replace(/\\/g, '/')
while (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1)
if (s.length >= 2 && s[1] === ':') s = s[0].toLowerCase() + s.slice(1)
return s
}
/** 快照与当前扫描的目录对是否一致(路径归一化后比较;不一致时对照无意义,调用方拦截) */ /** 快照与当前扫描的目录对是否一致(路径归一化后比较;不一致时对照无意义,调用方拦截) */
export function snapshotDirsMatch( export function snapshotDirsMatch(
baseline: FolderSnapshot, baseline: FolderSnapshot,
+2 -63
View File
@@ -1,68 +1,7 @@
import { splitLines } from './diffEngine' import { splitLines } from './diffEngine'
import { TEXT_EXTENSIONS } from '../../../shared/textExtensions'
/** 允许打开/拖拽的文本类扩展名(小写,不含点) */ export { TEXT_EXTENSIONS }
export const TEXT_EXTENSIONS: string[] = [
'txt',
'text',
'md',
'markdown',
'json',
'js',
'mjs',
'cjs',
'ts',
'mts',
'cts',
'tsx',
'jsx',
'css',
'scss',
'less',
'html',
'htm',
'xml',
'yml',
'yaml',
'toml',
'ini',
'cfg',
'conf',
'config',
'properties',
'env',
'py',
'java',
'go',
'rs',
'c',
'h',
'cpp',
'hpp',
'cc',
'cs',
'swift',
'kt',
'rb',
'php',
'pl',
'sh',
'zsh',
'bash',
'fish',
'bat',
'cmd',
'ps1',
'log',
'csv',
'tsv',
'sql',
'vue',
'svelte',
'graphql',
'gql',
'gradle',
'lock'
]
/** /**
* 判断文件名是否为允许的文本文件。 * 判断文件名是否为允许的文本文件。
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { normDir } from './pathNorm'
describe('normDir - 路径归一化(两进程共用)', () => {
it('反斜杠转斜杠、去尾分隔符', () => {
expect(normDir('C:\\left\\sub\\')).toBe('c:/left/sub')
expect(normDir('D:/right/')).toBe('d:/right')
})
it('仅去掉多余尾分隔符(保留单个根斜杠)', () => {
expect(normDir('/home/user//')).toBe('/home/user')
expect(normDir('/')).toBe('/')
})
it('Windows 盘符小写(其余路径段保留原大小写)', () => {
expect(normDir('D:\\Foo\\Bar')).toBe('d:/Foo/Bar')
expect(normDir('c:/Foo')).toBe('c:/Foo')
// 大小写敏感文件系统的路径段大小写不强制小写(避免误撞键)
expect(normDir('/home/A')).toBe('/home/A')
})
it('非路径形态输入原样透传(盘符位置以外的小写化不生效)', () => {
expect(normDir('ABC')).toBe('ABC')
expect(normDir('')).toBe('')
})
})
+12
View File
@@ -0,0 +1,12 @@
/**
* 路径归一化纯逻辑(无进程 API 依赖,两进程共用同一源文件)。
* 主进程 scanCacheStore 的目录对键与渲染端 snapshotDiff 的目录对匹配、
* App 的同目录防呆共用同一实现——Windows 大小写不敏感文件系统下
* C:\Foo 与 c:/Foo 为同一目录,逐字符比较会误判不一致。
*/
export function normDir(p: string): string {
let s = p.replace(/\\/g, '/')
while (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1)
if (s.length >= 2 && s[1] === ':') s = s[0].toLowerCase() + s.slice(1)
return s
}
+69
View File
@@ -0,0 +1,69 @@
/**
* 允许打开/拖拽/语义判等的文本类扩展名清单(小写,不含点)。
* 位于 src/shared:主进程(打开文件对话框筛选与文件夹语义判等的文本判定)
* 与渲染端(拖拽入口的扩展名校验)打包各自引用同一源文件——两进程无法共享
* 运行时模块但可共享源码(与 ignoreRules 同款模式),单一来源消除双向同步。
* 无扩展名的文件按文本放行(两进程的判定函数各自实现,口径一致)。
*/
export const TEXT_EXTENSIONS: string[] = [
'txt',
'text',
'md',
'markdown',
'json',
'js',
'mjs',
'cjs',
'ts',
'mts',
'cts',
'tsx',
'jsx',
'css',
'scss',
'less',
'html',
'htm',
'xml',
'yml',
'yaml',
'toml',
'ini',
'cfg',
'conf',
'config',
'properties',
'env',
'py',
'java',
'go',
'rs',
'c',
'h',
'cpp',
'hpp',
'cc',
'cs',
'swift',
'kt',
'rb',
'php',
'pl',
'sh',
'zsh',
'bash',
'fish',
'bat',
'cmd',
'ps1',
'log',
'csv',
'tsv',
'sql',
'vue',
'svelte',
'graphql',
'gql',
'gradle',
'lock'
]