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,重启后同目录对自动增量重扫)
- **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**,保存期间批量读取实时显示"已读取 N / M"进度,保存中防重复触发),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么");内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);**大快照专用读取通道**(加载上限 128MB,通用文件的 10MB 上限不拦截自家保存的大快照,且不污染文件对话框的目录记忆);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照)
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览)
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览,文件大小 B/KB/MB/GB 人类可读
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应
- **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径
@@ -98,7 +98,7 @@ npm run build:linux # Linux AppImage / deb
src/
├── main/ # 主进程:窗口、菜单、文件/文件夹读取与编码探测(解码 worker 化)、批量读取与进度推送 IPC
├── preload/ # 预加载:contextBridge 安全桥,仅暴露最小 API
├── shared/ # 两进程共用的纯逻辑源码(忽略规则匹配器:主进程扫描剪枝 + 渲染端无效规则提示
├── shared/ # 两进程共用的纯逻辑源码(忽略规则匹配器、文本扩展名清单、路径归一化
└── renderer/ # 渲染进程:React 界面与全部 diff 逻辑
├── diff/ # diff 引擎(行级 + 词级高亮、字符级分块、报告与快照纯逻辑)
├── hooks/ # 状态 hooks(diff 计算调度、下拉关闭)
@@ -117,7 +117,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
- 作者:thzxx
- 组织:MetonaTeam
- 许可证: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`。**
> 版本迭代**只允许修改 `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/遮罩/关闭按钮关闭,邮箱与开源地址点击复制(复用主进程剪贴板管线)
- 微清理:删除孤儿 CSS 类 .diff-empty-pane0.9.2 清理孤儿变量的漏网);folderTree 聚合优先级注释补 unreadable
- 测试与文档:新增 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
```
+14 -11
View File
@@ -48,18 +48,21 @@ e2e/
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照与大快照专用通道/关于弹框/拖拽导入/偏好重启)
src/shared/
├── 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/
├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,真实链路由 E2E 覆盖;含 folder:read-batch 批量读取与 folder:scan 进度推送)
├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,真实链路由 E2E 覆盖;含 folder:read-batch 批量读取与 folder:scan 进度推送;扫描缓存读盘与偏好写盘均异步 fs 不阻塞事件循环
├── 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·否定·?与字符类/进度回调)
├── scanCacheStore.ts # 扫描缓存跨会话持久化纯数据(目录对键归一化/LRU/防御性解析)
├── scanCacheStore.ts # 扫描缓存跨会话持久化纯数据(目录对键归一化复用 shared/pathNorm/LRU/防御性解析)
├── scanCacheStore.test.ts # 缓存存储单元测试(解析回退/条目过滤/键归一化/LRU 淘汰/序列化往返)
├── scanIpc.ts # folder:scan IPC 入参校验与缓存源选择纯逻辑(无 electron 依赖)
├── scanIpc.test.ts # IPC 校验单元测试(previous 形态校验/缓存源选择与惰性读盘/忽略规则校验)
├── scanIpc.ts # folder:scan IPC 入参校验与缓存源选择纯逻辑(无 electron 依赖;异步签名——loadStored 同步/异步实现均兼容,惰性读盘语义不变
├── scanIpc.test.ts # IPC 校验单元测试(previous 形态校验/缓存源选择与惰性读盘含异步 stub 兼容/忽略规则校验)
├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖)
└── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸)
src/renderer/src/
@@ -71,9 +74,9 @@ src/renderer/src/
│ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化)
│ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退
│ ├── report.test.ts # 单文件报告生成测试
│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注;对照模式条目表替换为变迁对照表)
│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable/快照变迁对照表)
│ ├── snapshotDiff.ts # 快照基线纯逻辑(v1/v2 结构/解析校验含 contents/目录对归一化匹配/六类变迁对照/共享变迁标签映射/内容保存上限常量)
│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注;对照模式条目表替换为变迁对照表fmtSize 大小格式化含 GB 档,列表与报告共用
│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable/快照变迁对照表/fmtSize 各档与 GB 修复锚
│ ├── snapshotDiff.ts # 快照基线纯逻辑(v1/v2 结构/解析校验含 contents/目录对归一化匹配normDir 复用 shared/pathNorm/六类变迁对照/共享变迁标签映射/内容保存上限常量)
│ ├── snapshotDiff.test.ts # 快照单元测试(解析防御/v1 v2 兼容/contents 形态校验/变迁判定/双侧空集/unreadable 语义/目录对归一化匹配)
│ └── textUtils.test.ts # 文本扩展名与行数统计测试
├── hooks/
@@ -215,7 +218,7 @@ src/renderer/src/
- 增量重扫(0.8.0):真实目录修改文件后点「重新扫描」状态正确更新(相同→不同→改回相同,真实 mtime 变化走指纹判定)
- 拖拽导入(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.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.00.9.1 增强,0.10.0 内容级对照):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章)
- 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归)
@@ -224,9 +227,9 @@ src/renderer/src/
- 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData)
- 隔离:每用例启动清空 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 非字符串拒绝
- resolveScanArgs 缓存源选择:渲染端携带合法内存缓存原样传递且惰性不读盘 / 内存缓存为空数组(合法形态)同样优先(阻止注入可能过期的盘上缓存)/ 未携带与形态非法回退盘上缓存(惰性读盘恰好一次)/ 盘上缓存为 null 或空数组不注入(全量扫描)
- resolveScanArgs 缓存源选择(异步签名,loadStored 同步/异步实现均兼容):渲染端携带合法内存缓存原样传递且惰性不读盘 / 内存缓存为空数组(合法形态)同样优先(阻止注入可能过期的盘上缓存)/ 未携带与形态非法回退盘上缓存(惰性读盘恰好一次)/ 盘上缓存为 null 或空数组不注入(全量扫描)/ 异步 loadStored stub(生产 fs.promises 读盘形态)同样正确回退
- 忽略规则校验:复用 isIgnoreRulesArray(合法字符串数组与空数组传递;非数组 / 含非字符串 / 超条数整体忽略)
- 组合场景:内存缓存非法 + 忽略规则合法 + 盘上缓存有效时两者分别正确解析
- 主进程 index.ts 的 handler 壳(IPC 注册与真实读写盘)由 E2E 文件夹用例覆盖
+14 -5
View File
@@ -501,8 +501,9 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
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++) {
// 4000 个大小一致条目需读内容比对(250 个并发批次):快机上比对窗口约数百毫秒,
// 进度文本存在时间足够被轮询采样抓到(2000 条在快机上仅 ~120ms,采样易错过)
for (let i = 0; i < 4000; i++) {
const name = `f${String(i).padStart(4, '0')}.txt`
await wf(join(leftDir, name), `L${i}`)
await wf(join(rightDir, name), `R${i}`)
@@ -511,15 +512,23 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => {
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
await window.getByRole('button', { name: '对比文件夹' }).click()
// 瞬态进度文案:轮询抓取扫描期间的遮罩文本(完成后遮罩消失)
// 瞬态进度文案:轮询抓取扫描期间的遮罩文本(完成后遮罩消失)
// evaluate 直读 DOM:遮罩消失后 querySelector 返回空串立即结束本轮采样,
// 避免 locator.textContent() 的自动重试在元素消失后阻塞 10s 吞掉后续采样窗口;
// intervals 前密后疏,抓住数百毫秒级的瞬态窗口
await expect
.poll(
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)
// 扫描完成:全量判定与遮罩消失
await expect(window.getByText('不同 2000')).toBeVisible()
await expect(window.getByText('不同 4000')).toBeVisible()
await expect(window.locator('.computing-overlay')).toHaveCount(0)
await app.close()
} finally {
+4 -1
View File
@@ -14,12 +14,15 @@ files:
- '!src/*'
- '!electron.vite.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}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
# 开发期专用文件不进入安装包(assets 仅为打包期图标源,运行时图标在 resources/)
# 开发期专用文件不进入安装包(assets 仅为打包期图标源,运行时图标在 resources/)
# e2e 与两份测试配置为 0.6.0 / 0.2.1 引入,0.4.1 排除清单的补漏
- '!docs/*'
- '!assets/*'
- '!e2e/*'
asarUnpack:
- resources/**
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "DiffLens",
"version": "0.9.3",
"version": "0.10.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "DiffLens",
"version": "0.9.3",
"version": "0.10.2",
"license": "MIT",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.10.2",
"version": "0.10.3",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
+36 -27
View File
@@ -14,25 +14,17 @@ import {
emptyScanCacheStore,
type ScanCacheStoreData
} 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 {
const dot = rel.lastIndexOf('.')
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 目录 */
@@ -41,10 +33,12 @@ const stateFile = (): string => join(app.getPath('userData'), 'window-state.json
/** 文件夹扫描增量缓存的跨会话持久化文件,位于 userData 目录 */
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 {
return parseScanCacheStore(fs.readFileSync(scanCacheFile(), 'utf-8'))
return parseScanCacheStore(await fs.promises.readFile(scanCacheFile(), 'utf-8'))
} catch {
return emptyScanCacheStore()
}
@@ -68,7 +62,7 @@ async function writeScanCache(
cache: ScanCacheEntry[]
): Promise<void> {
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')
} catch {
// 忽略写入失败
@@ -87,15 +81,30 @@ function loadState(): AppState {
}
}
/** 写回持久化状态:失败静默忽略(目录只读/磁盘满等,偏好记忆失效但不影响本次会话 */
function saveState(): void {
/** 序列化当前应用状态(同步/异步写盘共用 */
function serializeState(): string {
return JSON.stringify(appState)
}
/** 写回持久化状态(close 事件专用:同步写保证 app 退出前落盘完成) */
function saveStateSync(): void {
try {
fs.writeFileSync(stateFile(), JSON.stringify(appState), 'utf-8')
fs.writeFileSync(stateFile(), serializeState(), 'utf-8')
} catch {
// 忽略写入失败
}
}
/** 写回持久化状态(IPC handler 内专用:异步写盘不阻塞事件循环,失败静默忽略——
* 目录只读/磁盘满等偏好记忆失效但不影响本次会话;appState 内存态始终最新) */
function saveStateAsync(): void {
try {
void fs.promises.writeFile(stateFile(), serializeState(), 'utf-8').catch(() => {})
} catch {
// 序列化失败等:忽略
}
}
function createWindow(): void {
// 恢复上次窗口尺寸/位置:越界 bounds 钳制回当前屏幕工作区,无记录时用默认尺寸
const wa = screen.getPrimaryDisplay().workArea
@@ -127,7 +136,7 @@ function createWindow(): void {
if (mainWindow.isMinimized()) return
const b = mainWindow.getNormalBounds()
appState.bounds = { x: b.x, y: b.y, width: b.width, height: b.height }
saveState()
saveStateSync()
})
mainWindow.on('ready-to-show', () => {
@@ -248,7 +257,7 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
defaultPath: appState.lastDir,
properties: ['openFile'],
filters: [
{ name: '文本文件', extensions: TEXT_FILE_EXTENSIONS },
{ name: '文本文件', extensions: TEXT_EXTENSIONS },
// 兜底入口:允许打开任意扩展名文件
{ name: '所有文件', extensions: ['*'] }
]
@@ -257,7 +266,7 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
const filePath = result.filePaths[0]
// 记录所在目录供下次对话框起始定位(与窗口 bounds 同存一个状态文件)
appState.lastDir = dirname(filePath)
saveState()
saveStateAsync()
return readTextFile(filePath)
})
@@ -305,7 +314,7 @@ ipcMain.handle('folder:pick', async () => {
if (result.canceled || result.filePaths.length === 0) return null
const dirPath = result.filePaths[0]
appState.lastDir = dirPath
saveState()
saveStateAsync()
return dirPath
})
@@ -326,9 +335,9 @@ ipcMain.handle(
scanId?: unknown
) => {
try {
// 入参防御性校验与增量缓存源选择(内存缓存优先,未携带/非法回退 userData 落盘缓存,惰性读盘)
const args = resolveScanArgs(previous, ignoreRules, () =>
getScanCache(loadScanCacheStore(), leftDir, rightDir)
// 入参防御性校验与增量缓存源选择(内存缓存优先,未携带/非法回退 userData 落盘缓存,惰性异步读盘)
const args = await resolveScanArgs(previous, ignoreRules, async () =>
getScanCache(await loadScanCacheStore(), leftDir, rightDir)
)
const id = typeof scanId === 'number' && Number.isFinite(scanId) ? scanId : -1
// 比对进度推送(首个 push 型 IPC):窗口销毁后 send 抛错,进度丢失无影响
+2 -9
View File
@@ -1,4 +1,5 @@
import type { ScanCacheEntry } from './folderScan'
import { normDir } from '../shared/pathNorm'
/**
* 文件夹扫描增量缓存的跨会话持久化纯数据模块(无 electron/fs 依赖,vitest 直接测试)。
@@ -34,15 +35,7 @@ export const emptyScanCacheStore = (): ScanCacheStoreData => ({
pairs: {}
})
/** 单一路径归一化:反斜杠→斜杠、去尾分隔符、盘符小写(其余保留原大小写 */
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
}
/** 目录对键:两个归一化路径以 | 拼接(| 不出现在路径合法字符中) */
/** 目录对键:两个归一化路径(shared/pathNorm)以 | 拼接(| 不出现在路径合法字符中 */
export function pairKey(leftDir: string, rightDir: string): string {
return `${normDir(leftDir)}|${normDir(rightDir)}`
}
+32 -22
View File
@@ -36,62 +36,72 @@ describe('isScanCacheEntries - IPC 形态校验', () => {
})
describe('resolveScanArgs - 缓存源选择与忽略规则校验', () => {
it('渲染端携带合法内存缓存:原样传递且不读盘(惰性)', () => {
it('渲染端携带合法内存缓存:原样传递且不读盘(惰性)', async () => {
const stored = storedLoader([entry('disk.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(stored.calls).toBe(0)
})
it('内存缓存为空数组(合法形态):使用空数组,同样不注入盘上缓存', () => {
it('内存缓存为空数组(合法形态):使用空数组,同样不注入盘上缓存', async () => {
// 空数组是同目录上次扫描的最新形态(如空目录对),应优先于可能过期的盘上缓存
const stored = storedLoader([entry('disk.txt')])
const r = resolveScanArgs([], undefined, stored)
const r = await resolveScanArgs([], undefined, stored)
expect(r.previous).toEqual([])
expect(stored.calls).toBe(0)
})
it('未携带 / 形态非法的内存缓存:回退盘上缓存(惰性读盘一次)', () => {
it('未携带 / 形态非法的内存缓存:回退盘上缓存(惰性读盘一次)', async () => {
const disk = [entry('disk.txt')]
for (const bad of [undefined, null, 'x', [{ rel: 1, status: 'same' }]] as unknown[]) {
const stored = storedLoader(disk)
const r = resolveScanArgs(bad, undefined, stored)
const r = await resolveScanArgs(bad, undefined, stored)
expect(r.previous).toBe(disk)
expect(stored.calls).toBe(1)
}
})
it('盘上缓存为 null 或空数组:不注入 previous(全量扫描)', () => {
expect(resolveScanArgs(undefined, undefined, storedLoader(null)).previous).toBeUndefined()
expect(resolveScanArgs(undefined, undefined, storedLoader([])).previous).toBeUndefined()
it('盘上缓存为 null 或空数组:不注入 previous(全量扫描)', async () => {
expect((await resolveScanArgs(undefined, undefined, storedLoader(null))).previous).toBeUndefined()
expect((await resolveScanArgs(undefined, undefined, storedLoader([]))).previous).toBeUndefined()
})
it('忽略规则合法字符串数组传递(空数组合法)', () => {
expect(resolveScanArgs(undefined, ['node_modules/', '*.log'], storedLoader(null)).ignore).toEqual([
it('loadStored 为异步实现(生产 fs.promises 读盘)同样正确回退', async () => {
// 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/',
'*.log'
])
expect(resolveScanArgs(undefined, [], storedLoader(null)).ignore).toEqual([])
expect((await resolveScanArgs(undefined, [], storedLoader(null))).ignore).toEqual([])
})
it('忽略规则非法形态(非数组/含非字符串/超条数)整体忽略', () => {
expect(resolveScanArgs(undefined, null, storedLoader(null)).ignore).toBeUndefined()
expect(resolveScanArgs(undefined, 'node_modules/', storedLoader(null)).ignore).toBeUndefined()
expect(resolveScanArgs(undefined, ['a', 42], storedLoader(null)).ignore).toBeUndefined()
it('忽略规则非法形态(非数组/含非字符串/超条数)整体忽略', async () => {
expect((await resolveScanArgs(undefined, null, storedLoader(null))).ignore).toBeUndefined()
expect((await resolveScanArgs(undefined, 'node_modules/', storedLoader(null))).ignore).toBeUndefined()
expect((await resolveScanArgs(undefined, ['a', 42], storedLoader(null))).ignore).toBeUndefined()
expect(
resolveScanArgs(
undefined,
Array.from({ length: 51 }, () => 'a'),
storedLoader(null)
(
await resolveScanArgs(
undefined,
Array.from({ length: 51 }, () => 'a'),
storedLoader(null)
)
).ignore
).toBeUndefined()
})
it('组合:内存缓存非法 + 忽略规则合法 + 盘上缓存有效', () => {
it('组合:内存缓存非法 + 忽略规则合法 + 盘上缓存有效', async () => {
const disk = [entry('disk.txt')]
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.ignore).toEqual(['dist/'])
expect(stored.calls).toBe(1)
+9 -7
View File
@@ -35,20 +35,22 @@ export interface ResolvedScanArgs {
* 组合 folder:scan 的入参校验与缓存源选择:
* - previous 形态异常(非数组 / 条目缺字符串 rel/status)一律不沿用;
* - previous 未携带或非法时经 loadStored 惰性读取盘上持久化缓存(非空才注入),
* 惰性求值保证渲染端携带有效内存缓存时不产生读盘 IO;
* 惰性求值await 仅在需要回退时发生)保证渲染端携带有效内存缓存时不产生读盘 IO;
* - loadStored 可同步或异步(生产实现为 fs.promises 异步读盘,测试注入同步实现),
* 异步化保证 IPC handler 内不再有同步 IO 阻塞主进程事件循环;
* - ignoreRules 复用 isIgnoreRulesArray(数组形态 + 条数/单条长度上限)。
*/
export function resolveScanArgs(
export async function resolveScanArgs(
previous: unknown,
ignoreRules: unknown,
loadStored: () => ScanCacheEntry[] | null
): ResolvedScanArgs {
loadStored: () => ScanCacheEntry[] | null | Promise<ScanCacheEntry[] | null>
): Promise<ResolvedScanArgs> {
const mem = isScanCacheEntries(previous) ? previous : undefined
const fallback = (): ScanCacheEntry[] | undefined => {
const stored = loadStored()
const fallback = async (): Promise<ScanCacheEntry[] | undefined> => {
const stored = await loadStored()
return stored !== null && stored.length > 0 ? stored : undefined
}
const previousOut = mem !== undefined ? mem : fallback()
const previousOut = mem !== undefined ? mem : await fallback()
return {
...(previousOut !== undefined ? { previous: previousOut } : {}),
...(isIgnoreRulesArray(ignoreRules) ? { ignore: ignoreRules } : {})
+2 -4
View File
@@ -18,7 +18,6 @@ import {
parseSnapshot,
diffSnapshot,
snapshotDirsMatch,
normDir,
isDiffStatus,
SNAPSHOT_VERSION,
SNAPSHOT_FILE_MAX_BYTES,
@@ -27,6 +26,8 @@ import {
type SnapshotContents,
type SnapshotDiffResult
} from './diff/snapshotDiff'
import { normDir } from '../../shared/pathNorm'
import { IGNORE_RULES_MAX } from '../../shared/ignoreRules'
import { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils'
import { useDismiss } from './hooks/useDismiss'
import { useDiff } from './hooks/useDiff'
@@ -85,9 +86,6 @@ interface Prefs {
folderIgnoreRules: string[]
}
/** 忽略规则条数上限(与主进程 ignoreRules.ts / FolderView 保持一致,改动需三方同步) */
const IGNORE_RULES_MAX = 50
/**
* 读取持久化偏好:只接受已知字段的严格 boolean 值与合法忽略规则数组;
* 数据损坏(JSON 非法 / 字段类型异常 / 存储不可用)一律回退默认,保证启动不受影响。
+1 -3
View File
@@ -13,7 +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 { compileIgnoreRules, IGNORE_RULES_MAX } from '../../../shared/ignoreRules'
import { buildFolderTree, filterEntries, flattenFolderTree, relExt } from './folderTree'
/** 文件夹列表虚拟行高(px),与 global.css 的 --folder-row-height 保持一致,改动需双向同步 */
@@ -24,8 +24,6 @@ const OVERSCAN = 8
const FALLBACK_VIEWPORT = 600
/** 树形缩进步进(px / 深度) */
const TREE_INDENT = 16
/** 忽略规则条数上限(与主进程 ignoreRules.ts 的 IGNORE_RULES_MAX 保持一致,改动需双向同步) */
const IGNORE_RULES_MAX = 50
export interface FolderViewProps {
leftDir: string
@@ -3,6 +3,7 @@ import type { FolderEntry } from '../../../preload/index'
import {
buildFolderReport,
countFolderStats,
fmtSize,
plainFolderSummary
} from './folderReport'
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', () => {
it('空文件夹给出无文件结论', () => {
expect(plainFolderSummary(countFolderStats([]), 0)).toBe('两个文件夹内都没有文件')
+7 -2
View File
@@ -85,12 +85,17 @@ const STATUS_LABEL: Record<FolderEntryStatus, string> = {
unreadable: '无法读取'
}
/** 人类可读的文件大小(报告与 FolderView 列表共用同一实现) */
/**
* FolderView
* GB 10MB
* GB / 1024.0 MB
*/
export function fmtSize(n: number | null): string {
if (n === null) return '—'
if (n < 1024) return `${n} B`
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 报告 ============ */
+1 -13
View File
@@ -1,4 +1,5 @@
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: '保持一致'
}
/**
* 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(
baseline: FolderSnapshot,
+2 -63
View File
@@ -1,68 +1,7 @@
import { splitLines } from './diffEngine'
import { TEXT_EXTENSIONS } from '../../../shared/textExtensions'
/** 允许打开/拖拽的文本类扩展名(小写,不含点) */
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'
]
export { TEXT_EXTENSIONS }
/**
*
+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'
]