From 3ca18364cfef4cdf3e74891ebc258d686e58e442 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Wed, 19 Aug 2026 20:30:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.10.3=20GB=E5=A4=A7=E5=B0=8F=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E4=BF=AE=E5=A4=8D=E3=80=81=E4=B8=BB=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E5=90=8C=E6=AD=A5IO=E5=BC=82=E6=AD=A5=E5=8C=96=E3=80=81?= =?UTF-8?q?=E5=8F=8C=E8=BF=9B=E7=A8=8B=E9=87=8D=E5=A4=8D=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E6=94=B6=E6=95=9B=20-=20fmtSize=E8=A1=A5GB=E6=A1=A3=EF=BC=88?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B9=E5=AF=B9=E6=AF=94GB=E7=BA=A7?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=AD=A4=E5=89=8D=E6=98=BE=E7=A4=BA1024.0MB?= =?UTF-8?q?=EF=BC=89=E3=80=81scan-cache=E8=AF=BB=E7=9B=98=E4=B8=8E?= =?UTF-8?q?=E5=81=8F=E5=A5=BD=E5=86=99=E7=9B=98=E6=94=B9fs.promises?= =?UTF-8?q?=EF=BC=88IPC=E5=85=A8=E5=BC=82=E6=AD=A5=E5=8E=9F=E5=88=99?= =?UTF-8?q?=E8=A1=A5=E6=BC=8F=EF=BC=89=E3=80=81TEXT=5FEXTENSIONS/normDir/I?= =?UTF-8?q?GNORE=5FRULES=5FMAX=E6=94=B6=E6=95=9Bsrc/shared=E3=80=81?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E5=8C=85=E6=8E=92=E9=99=A4e2e=E4=B8=8Eplaywr?= =?UTF-8?q?ight=E9=85=8D=E7=BD=AE=E3=80=81=E6=89=AB=E6=8F=8F=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6E2E=E9=87=87=E6=A0=B7=E6=96=B9=E5=BC=8F=E4=BF=AE?= =?UTF-8?q?=E7=A8=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- docs/应用开发与版本迭代规范.md | 9 ++- docs/测试策略与方案.md | 25 ++++---- e2e/app.spec.ts | 19 ++++-- electron-builder.yml | 5 +- package-lock.json | 4 +- package.json | 2 +- src/main/index.ts | 63 +++++++++++--------- src/main/scanCacheStore.ts | 11 +--- src/main/scanIpc.test.ts | 54 ++++++++++------- src/main/scanIpc.ts | 16 ++--- src/renderer/src/App.tsx | 6 +- src/renderer/src/components/FolderView.tsx | 4 +- src/renderer/src/diff/folderReport.test.ts | 22 +++++++ src/renderer/src/diff/folderReport.ts | 9 ++- src/renderer/src/diff/snapshotDiff.ts | 14 +---- src/renderer/src/diff/textUtils.ts | 65 +------------------- src/shared/pathNorm.test.ts | 26 ++++++++ src/shared/pathNorm.ts | 12 ++++ src/shared/textExtensions.ts | 69 ++++++++++++++++++++++ 20 files changed, 267 insertions(+), 174 deletions(-) create mode 100644 src/shared/pathNorm.test.ts create mode 100644 src/shared/pathNorm.ts create mode 100644 src/shared/textExtensions.ts diff --git a/README.md b/README.md index f328c52..26b7af9 100644 --- a/README.md +++ b/README.md @@ -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 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 7fc3211..51c735b 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -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-pane(0.9.2 清理孤儿变量的漏网);folderTree 聚合优先级注释补 unreadable - 测试与文档:新增 14 单测(AboutModal 9 / App 4:关于打开关闭·复制·文件夹模式可见·异写防呆 / FolderView 1:saving 禁用)与 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 拆分 sync(close 事件,保证退出前落盘)/ async(file: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 条目比对窗口仅 ~120ms,locator.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 ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 821063d..45b294e 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -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.0,0.10.0 高级 glob):真实目录含 node_modules 子树——无规则时差异参与对比,输入规则应用重扫后整树剪枝(不同归零、空态一致提示、被忽略条目消失);高级 glob——build 下深层 temp 目录经 build/**/temp 跨段剪枝、*.log 忽略 + !keep.log 否定救回(差异计数精确收敛)、无效规则提示出现 -- 扫描进度反馈(0.10.0):2000 条目目录扫描期间遮罩"已比对 N / 2000"轮询抓取(瞬态断言),完成后全量判定与遮罩消失 +- 扫描进度反馈(0.10.0,0.10.3 采样修稳):4000 条目目录扫描期间遮罩"已比对 N / 4000"轮询抓取(瞬态断言)——evaluate 直读 DOM(避免遮罩消失后 locator 自动重试阻塞采样)+ intervals 前密采样,快机上数百毫秒的瞬态窗口稳定可抓;完成后全量判定与遮罩消失 - 增量缓存跨会话持久化(0.9.0):第一实例扫描后 userData/scan-cache.json 落盘(等待异步写盘,断言含条目与合法结构)→ 同 userData 第二实例重扫同目录结果正确(主进程注入盘上缓存) - 快照基线(0.9.0,0.9.1 增强,0.10.0 内容级对照):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章) - 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s;300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归) @@ -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.1,0.10.3 异步化) - isScanCacheEntries 形态校验:合法缓存数组(rel/status 为字符串)通过;非数组 / 含非对象 / rel 或 status 非字符串拒绝 -- resolveScanArgs 缓存源选择:渲染端携带合法内存缓存原样传递且惰性不读盘 / 内存缓存为空数组(合法形态)同样优先(阻止注入可能过期的盘上缓存)/ 未携带与形态非法回退盘上缓存(惰性读盘恰好一次)/ 盘上缓存为 null 或空数组不注入(全量扫描) +- resolveScanArgs 缓存源选择(异步签名,loadStored 同步/异步实现均兼容):渲染端携带合法内存缓存原样传递且惰性不读盘 / 内存缓存为空数组(合法形态)同样优先(阻止注入可能过期的盘上缓存)/ 未携带与形态非法回退盘上缓存(惰性读盘恰好一次)/ 盘上缓存为 null 或空数组不注入(全量扫描)/ 异步 loadStored stub(生产 fs.promises 读盘形态)同样正确回退 - 忽略规则校验:复用 isIgnoreRulesArray(合法字符串数组与空数组传递;非数组 / 含非字符串 / 超条数整体忽略) - 组合场景:内存缓存非法 + 忽略规则合法 + 盘上缓存有效时两者分别正确解析 - 主进程 index.ts 的 handler 壳(IPC 注册与真实读写盘)由 E2E 文件夹用例覆盖 diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index a1b6e04..36d8028 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -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 { diff --git a/electron-builder.yml b/electron-builder.yml index 96614eb..e347178 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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/** diff --git a/package-lock.json b/package-lock.json index 751b974..91a56c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c18f5c4..0c6d7eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.10.2", + "version": "0.10.3", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index c0162d8..9959d3e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,25 +14,17 @@ import { emptyScanCacheStore, type ScanCacheStoreData } from './scanCacheStore' +import { TEXT_EXTENSIONS } from '../shared/textExtensions' /** - * 文本类扩展名清单(小写,不含点): - * 与渲染进程 TEXT_EXTENSIONS(src/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 { 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 { 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 抛错,进度丢失无影响 diff --git a/src/main/scanCacheStore.ts b/src/main/scanCacheStore.ts index 49bc272..8d8d8c8 100644 --- a/src/main/scanCacheStore.ts +++ b/src/main/scanCacheStore.ts @@ -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)}` } diff --git a/src/main/scanIpc.test.ts b/src/main/scanIpc.test.ts index 654e319..d87cc3b 100644 --- a/src/main/scanIpc.test.ts +++ b/src/main/scanIpc.test.ts @@ -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 => 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) diff --git a/src/main/scanIpc.ts b/src/main/scanIpc.ts index 5096997..215c529 100644 --- a/src/main/scanIpc.ts +++ b/src/main/scanIpc.ts @@ -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 +): Promise { const mem = isScanCacheEntries(previous) ? previous : undefined - const fallback = (): ScanCacheEntry[] | undefined => { - const stored = loadStored() + const fallback = async (): Promise => { + 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 } : {}) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 488ce65..0eeaf26 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 非法 / 字段类型异常 / 存储不可用)一律回退默认,保证启动不受影响。 diff --git a/src/renderer/src/components/FolderView.tsx b/src/renderer/src/components/FolderView.tsx index 7744aeb..6cf0164 100644 --- a/src/renderer/src/components/FolderView.tsx +++ b/src/renderer/src/components/FolderView.tsx @@ -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 diff --git a/src/renderer/src/diff/folderReport.test.ts b/src/renderer/src/diff/folderReport.test.ts index 3c3dc9a..90617a9 100644 --- a/src/renderer/src/diff/folderReport.test.ts +++ b/src/renderer/src/diff/folderReport.test.ts @@ -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 MB(0.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('两个文件夹内都没有文件') diff --git a/src/renderer/src/diff/folderReport.ts b/src/renderer/src/diff/folderReport.ts index 625df02..a66d739 100644 --- a/src/renderer/src/diff/folderReport.ts +++ b/src/renderer/src/diff/folderReport.ts @@ -85,12 +85,17 @@ const STATUS_LABEL: Record = { 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 报告 ============ */ diff --git a/src/renderer/src/diff/snapshotDiff.ts b/src/renderer/src/diff/snapshotDiff.ts index 998742a..00417b9 100644 --- a/src/renderer/src/diff/snapshotDiff.ts +++ b/src/renderer/src/diff/snapshotDiff.ts @@ -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 = { 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, diff --git a/src/renderer/src/diff/textUtils.ts b/src/renderer/src/diff/textUtils.ts index fd83d88..e47d324 100644 --- a/src/renderer/src/diff/textUtils.ts +++ b/src/renderer/src/diff/textUtils.ts @@ -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 } /** * 判断文件名是否为允许的文本文件。 diff --git a/src/shared/pathNorm.test.ts b/src/shared/pathNorm.test.ts new file mode 100644 index 0000000..4f0fd58 --- /dev/null +++ b/src/shared/pathNorm.test.ts @@ -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('') + }) +}) diff --git a/src/shared/pathNorm.ts b/src/shared/pathNorm.ts new file mode 100644 index 0000000..96f089c --- /dev/null +++ b/src/shared/pathNorm.ts @@ -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 +} diff --git a/src/shared/textExtensions.ts b/src/shared/textExtensions.ts new file mode 100644 index 0000000..a6207d9 --- /dev/null +++ b/src/shared/textExtensions.ts @@ -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' +]