Compare commits

...
22 Commits
Author SHA1 Message Date
thzxx 4fd8506ffb feat: v0.11.0 导出报告全面增强 - 新增Patch标准差异第四格式(标准unified diff含hunk头/上下文3行/modified展开-旧+新/尾部无换行标记,产物可直接git apply;canBuildPatch逐行判定判等选项污染,不可导出时菜单禁用+title解释+App双保险拦截)、TXT/Markdown报告分块省略(复用buildSegments段间省略提示行,MD每段独立成表;完全一致保持全量留档语义)、报告元信息增强(版本号进尾部署名/文件夹报告忽略规则说明行超5条截断/快照基线内容对照导出附对照说明含保存时间/HTML文件卡行数修正为原始文本口径)、导出目录记忆(windowState新增lastReportDir独立于lastDir),新增33单测与2条E2E(666单测+32E2E全过覆盖率97.85/93.06/93.7/97.85),文档四件套同步 2026-08-20 13:54:08 +08:00
thzxx 79ba3055cc feat: v0.10.5 文件夹顶部布局重构、字符粒度统计、换目录状态重置与跨模式搜索一致 - 头部操作区6按钮竖排(约200px)改横排单行+快照归组下拉(约50px,列表可视高度+6行)、DiffSummary增charsAdded/charsDeleted码点统计(行级与字符级路径收敛summarize单一实现,字符级模式工具栏+徽章与三格式报告统计行同步,超长防护行不计)、重新选择新目录对后搜索/过滤/折叠/变迁过滤重置(同目录重扫保留,修复残留静默生效缺陷)、Ctrl+F跨模式聚焦分流+文件夹搜索框Esc清空+两个搜索框一键清空按钮,新增19单测(658全过覆盖率97.92/93.5/93.48/97.92)+E2E 30全过含滚动稳定性回归锚,文档四件套同步 2026-08-20 13:12:37 +08:00
thzxx c45e388fec fix: v0.10.4 快照对照对称防呆收官、搜索高亮重定位、快照保存中断与TB档 - 快照自身truncated拦截加载(与保存侧/当前侧对称的三侧防对照误报收官,截断快照缺失文件不再误报新文件)、切选项/换文件重算后搜索计数与active高亮自动钳制重定位(rows重建行id重新编号不再脱节,命中归零清空高亮)、保存世代号作废在途快照保存(退出文件夹对比不再在单文件空态弹保存对话框,遮罩即时复位,0.10.2按钮防抖与遮罩不拦截交互设计不变)、fmtSize补TB档(TB级文件不再显示1024.0GB,0.10.3档位延续),新增5单测与1条E2E(639单测+E2E 30全过覆盖率98.16/93.5/94.04/98.16),文档四件套同步 2026-08-19 21:11:49 +08:00
thzxx 3ca18364cf 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采样方式修稳 2026-08-19 20:30:33 +08:00
thzxx 719681a366 feat: 0.10.2 大快照加载修复、快照保存防抖与关于弹框 - 新增snapshot:read专用读取IPC(128MB上限,修复保存32MB内容上限与加载10MB通用上限不对称导致大快照保存成功却永远无法加载的自相矛盾,顺带消除加载污染lastDir记忆)、快照保存中保存/加载按钮saving禁用与App守卫双保险(computing-overlay不拦截交互可并发触发两轮保存对话框)、同目录防呆路径归一化判定(复用normDir,Windows异写路径不再绕过)、关于弹框(header常驻ⓘ按钮,app:get-version取版本号,作者/双邮箱/组织/开源地址/许可证,点击复制走主进程剪贴板)、删除孤儿CSS类.diff-empty-pane与folderTree注释补unreadable,新增14单测与2条E2E(626单测+E2E 29全过覆盖率98.15/93.49/93.65/98.15),文档四件套同步 2026-08-19 15:20:52 +08:00
thzxx 05a603ee68 feat: 0.10.1 忽略规则字符通配与收尾打磨 - 段内?单字符与[abc]/[a-z]/[!abc]字符类通配(未闭合与空类退化字面量、非法类体回退字面量永不崩溃、既有*/**/否定语义回归锚固化)、快照保存批量读取遮罩显示已读取N/M进度(文件数口径与扫描进度反馈对称)、差异文件导航游标不在序列时隐藏导航组(修双击same条目与对照模式fixed条目的—计数与死按钮)、修复0.10.0遗留snapshotDiff.test版本号类型错误(展开构造替代非法字面量传参)、修正E2E用例数记载(28→27),新增16用例(612单测+E2E 27全过覆盖率98.2/93.46/93.77/98.2),文档四件套同步 2026-08-19 14:19:51 +08:00
thzxx c209ae291a feat: 0.10.0 快照内容级对照、忽略规则高级 glob 与扫描进度反馈 - 快照v2可选携带差异条目双侧内容(folder:read-batch批量读取IPC受控并发、单文件2MB与总量32MB双限超限整体降级为纯状态快照保存永不失败、对照模式双击进入快照基线vs当前文件的内容对比完整复用单文件能力、v1旧快照兼容加载、contents解析防御绝不部分加载、保存期间读取遮罩)、忽略规则匹配器重写支持多段路径/前导/锚定/整段**跨段/段内*通配/!否定规则(最后匹配优先与gitignore一致、父目录剪枝后子项无法反否定、无效规则UI实时提示不再静默丢弃、纯逻辑迁src/shared两进程共用源码)、扫描进度推送(首个push型IPC、scanId复用扫描会话序号旧扫描迟到进度天然丢弃、遮罩显示已比对N/M)、新增33单测与4条E2E(596单测+28E2E全过覆盖率98.16/93.28/93.69/98.16),文档四件套同步 2026-08-19 13:24:35 +08:00
thzxx c1e63c3328 feat: 0.9.3 单文件视图文本搜索与稳健性收尾 - 工具栏搜索框(Ctrl+F聚焦/大小写不敏感即时命中计数/Enter与Shift+Enter命中行循环跳转复用activeRowId受控定位/行内命中子串高亮含词级segs行/Esc清空;基于全量行与仅看差异独立、折叠命中行跳转toast提示不推进)、扫描缓存落盘promise链串行化(并发读改写不再覆盖丢LRU目录对)、快照对照变迁过滤计数归零自动复位、重新选择扫描中保持可用固化测试(与返回文件对比同类发起新会话作废在途扫描)、依赖patch级升级,新增21单测用例与E2E搜索链路用例(564单测+E2E 24全过,覆盖率98.27/93.47/94.01/98.27),文档四件套同步 2026-08-19 11:07:15 +08:00
thzxx 42d2095de2 fix: 0.9.2 快照对照对称缺陷修复与交互打磨 - 加载快照拦截当前结果截断(0.9.1只拦保存侧,当前条目不完整会把截断缺失文件误报已移除)、对照模式中重扫截断自动退出对照、快照语义判等口径与当前开关不一致时提示含口径差异(口径变化不再被当成内容变化)、扫描中禁用语义开关与忽略规则入口(触发重扫入口防抖,纯视图过滤不禁用)、右键菜单↑/↓键盘导航跳过禁用项首尾循环Enter触发悬停同步高亮、清理CSS孤儿变量与依赖patch级升级,新增12测试用例(543单测+E2E 23全过),文档四件套同步 2026-08-19 10:06:27 +08:00
thzxx 38512d7882 fix: 0.9.1 快照收尾修复与稳健性增强 - 快照目录对归一化匹配(Windows同目录异写不误拦、与scanCacheStore同款逻辑双向同步)、截断结果保存快照拦截提示(防对照新文件误报)、folder:scan IPC校验抽scanIpc纯逻辑模块单测全覆盖(缓存源选择惰性读盘)、对照模式导出报告附变迁对照表(三格式当前/基线状态与大小、标签收敛SNAPSHOT_CHANGE_LABEL共享映射、E2E对照导出写盘断言)、顶层ErrorBoundary渲染兜底(错误面板一键重载防白屏)、文档四件套同步 2026-08-19 09:20:28 +08:00
thzxx 35fa81df9d feat: 0.9.0 忽略规则、增量缓存跨会话持久化、快照基线、字符级预算自适应与性能基准E2E - 忽略规则(gitignore简化子集:目录整树剪枝不枚举不计数、扩展名通配与名字匹配、随偏好持久化)、扫描增量缓存落盘userData跨会话自动注入(LRU 8对、防御性解析、渲染端零改动)、快照基线(保存/加载JSON对照六类状态变迁统计与条目标注、对照随重扫刷新、报告附快照口径)、字符级变更组预算随输入规模自适应(下限与固定值一致小输入零变化、大输入按总组流半数放宽上限100万封顶)、大文件性能基准E2E(5万行<15s与300KB GBK<10s耗时断言)+新功能E2E、文档与版本号同步 2026-08-18 22:58:00 +08:00
thzxx fa13160848 feat: 0.8.0 文件夹对比深度增强与字符级逐组预算制 - 按扩展名多选过滤(第四维过滤与其他条件取交集、N/总数计数切换)、目录行双击聚合对比(差异导航限定子树内批量审查、双击文件回全局、空差异提示与折叠态自愈)、增量重扫(size+mtime指纹一致零IO沿用、语义开关翻转按semSame纯内存组合零IO、unreadable不缓存与截断弃缓存、重新扫描按钮)、字符级变更组逐组预算制(超限组仅行级配对输出不再牵连其余组、charModePartial部分降级标记与报告说明、全部组降级仍整体降级)、拖拽导入E2E(dropFiles合成DataTransfer覆盖decodeBuffer IPC全链路4用例)、文档与版本号同步 2026-08-18 22:02:37 +08:00
thzxx 494b2ad6ea fix: 0.7.1 文件夹扫描竞态守卫与边角修复 - 扫描会话序号丢弃过期结果(并发重扫不互相覆盖、退出后迟到结果不拉回文件夹模式)、FolderView补resize监听(拉伸窗口即时补齐渲染窗口)、行号列不透明背景(真机证实横向滚动文字透过sticky半透明行号列叠印)、最大化关闭改存getNormalBounds(会话内调整的窗口位置不丢失)、行id生成统一与fmtSize收敛共用、文档与版本号同步 2026-08-18 20:41:45 +08:00
thzxx 1c52f5d3cb feat: 0.7.0 文件夹对比增强(语义判等/子目录树形/搜索与状态过滤/报告导出/差异文件导航/大目录单点失败降级)+ 字符级分块锚定恢复(0.6.3 算法)与滚动回弹根因修复(重算期间保留旧结果撑住容器高度)+ E2E 滚动稳定性回归锚 2026-08-18 18:40:03 +08:00
thzxx 74d52ec0b0 fix: 双击文件夹条目读取失败清空该侧面板修复名不符实对比残留、枚举改手写BFS凑满即停修复超大目录枚举耗时(0.6.2) 2026-08-18 15:29:21 +08:00
thzxx 946f6b2f6a fix: 文件夹对比收尾打磨 - 扫描内容比对受控并发化(16并发分批)、「返回文件对比」清空两侧面板修复旧对比残留、同目录防呆提示、total注释修正与README版本号补同步(0.6.1) 2026-08-18 15:07:02 +08:00
thzxx be619c2377 feat: 解码 worker 化、Playwright E2E 测试体系与文件夹对比(0.6.0) 2026-08-18 14:40:44 +08:00
thzxx 2b43b5e676 fix: 字符级对比无界计算卡死修复——maxEditLength 封顶编辑距离、降级阈值收紧至 3 万字符(0.5.5) 2026-08-18 13:46:45 +08:00
thzxx 1c34f386af fix: 比较选项联动可视化——禁用开关灰显样式、已包含徽标与点击解释、字符级降级标记(0.5.4) 2026-08-18 13:14:58 +08:00
thzxx 32a4bdf24d build: 版本提升至 0.5.3 并同步文档(演进记录/README/测试策略) 2026-08-18 12:42:00 +08:00
thzxx 6f7df57fba feat: 窗口尺寸位置与上次目录记忆、状态栏行数显示 2026-08-18 12:39:26 +08:00
thzxx 3e3f9b173e fix: 字符级大输入后台化、toast 队列化与清空二次确认 2026-08-18 12:33:27 +08:00
63 changed files with 14277 additions and 551 deletions
+4
View File
@@ -5,6 +5,10 @@ node_modules/
out/
dist/
# E2E
test-results/
playwright-report/
# 系统与编辑器
.DS_Store
Thumbs.db
+27 -9
View File
@@ -11,23 +11,35 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**扫描进度反馈**(比对阶段实时显示"已比对 N / M")、**单行紧凑头部**(操作横排 + 快照归组下拉,列表区最大化)、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**(Ctrl+F 聚焦 / Esc 清空)与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**换目录自动重置视图状态**(搜索/过滤/折叠不残留到新目录)、**忽略规则**(gitignore 简化子集含高级 glob`node_modules/` 目录整树剪枝、`*.log` 通配、`lo?g` 单字符、`*.[ch]` 字符类(范围与取反)、`src/temp` 多段路径、`build/**/cache` 跨段、`!keep.log` 否定规则(最后匹配优先);无效规则实时提示不再静默丢弃;规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹缓存,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫)
- **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**,保存期间批量读取实时显示"已读取 N / M"进度,保存中防重复触发),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么",导出的报告头部注明基线来源与保存时间);内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);**大快照专用读取通道**(加载上限 128MB,通用文件的 10MB 上限不拦截自家保存的大快照,且不污染文件对话框的目录记忆);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照、自身截断的快照同样禁止加载(三侧防对照误报),保存期间退出文件夹对比自动终止在途保存(不弹名不符实的保存对话框),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照)
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 忽略规则说明 + 统计概览,文件大小 B/KB/MB/GB/TB 人类可读)
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃),界面始终保持流畅响应
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应
- **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径
- **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键
- **视图内文本搜索**:单文件对比视图内搜索任意内容——Ctrl+F 聚焦、大小写不敏感即时命中计数、Enter / Shift+Enter 在命中行间循环跳转(虚拟滚动下精确定位)、行内命中子串高亮、Esc 清空、有内容时 × 一键清空;搜索基于全量行,与“仅看差异”视图过滤互不影响;切换比较选项/更换文件触发重算后,命中计数与高亮行保持一致(自动钳制重定位);文件夹模式 Ctrl+F 同样聚焦文件名搜索框(跨模式一致)
- **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异
- **交换左右侧**:一键互换两侧内容与视角
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮,超 20 万字符自动降级行级对比并提示
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮;**相同内容自动锚定跳过计算(行锚定 + 变更组分块),大文件不再整篇受限**;**变更组逐组预算制(预算随输入规模自适应)**——单组过大 / 编辑距离超限 / 预算耗尽的块仅按行级配对输出(部分降级,界面与报告标注),不再牵连其余块;预算下限与历史固定值一致(小输入行为不变),大输入按总组流比例放宽(上限封顶,最坏总耗时仍有界);全部块降级时自动降级行级对比并提示(编辑距离封顶,不会卡死);**字符粒度增删统计(+N 字 / −M 字徽章与报告统计行,码点计数 emoji 不撕裂)**;重算期间保留旧结果撑住滚动位置,**切换选项不再回弹到顶部**
- **选项联动透明**:开启字符级对比时,被包含的空白选项灰显勾选并标注“已包含”(点击有解释提示,关闭字符级即恢复独立设置);差异过大自动降级时开关旁标注“已降级”,状态一目了然
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
- **偏好记忆**:比较选项与"仅看差异"开关自动保存,下次启动自动恢复(损坏数据自动回退默认)
- **偏好记忆**:比较选项与"仅看差异"开关自动保存,窗口尺寸/位置与上次打开文件目录一并记忆,下次启动自动恢复(损坏数据自动回退默认)
- **防误触清空**:清空前二次确认(提示窗口内再次点击才执行),粘贴输入的手动文本不因误点丢失
- **原生暗色适配**:系统标题栏等原生控件跟随应用暗色科幻主题
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
- **渲染异常兜底**:任何组件渲染错误由顶层 ErrorBoundary 捕获,显示可复制的错误信息与一键重载按钮,不白屏死机
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
- **导出一键报告**将对比结果导出 HTML / 纯文本 / Markdown 三种格式(始终包含全量行)。HTML 报告为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(点击展开零丢失),并支持打印与窄屏
- **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本
- **导出一键报告**单文件可导出 HTML / 纯文本 / Markdown / **Patch 标准差异** 四种格式,文件夹对比可导出前三者。单文件报告为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(HTML 点击展开零丢失TXT/Markdown 段间省略提示行);**Patch 为标准 unified diff**git diff 风格,含 hunk 头与尾部无换行标记),产物可直接 `git apply` / `patch` 使用——判等类选项(忽略大小写/空白/字符级)污染上下文行时该项自动禁用并说明原因;报告尾部带生成器版本号;文件夹报告含判定口径、忽略规则说明与全量条目清单;快照基线内容对照导出的报告注明基线来源与保存时间;导出对话框记忆上次导出目录
- **拖拽与按钮双入口**:支持文件拖拽导入E2E 覆盖真实拖放链路),也可通过界面按钮选择文件或粘贴文本
- **右键菜单键盘导航**:行级右键菜单支持 ↑/↓ 移动高亮(跳过禁用项、首尾循环)、Enter 触发、Esc 关闭,鼠标悬停与键盘高亮同步
- **滚动联动**:左右两个面板滚动位置自动同步
- **性能基准 E2E**5 万行差异计算与 300KB GBK 解码带耗时上限断言(真实 Electron 链路),性能回归在发布前即可拦截
- **关于弹框**:header 右侧 ⓘ 图标一键查看应用介绍、版本号、作者(thzxx)、联系邮箱、组织(MetonaTeam)、开源地址与许可证;邮箱/地址点击复制到剪贴板
---
@@ -61,6 +73,9 @@ npm run typecheck
# 运行测试(Vitest 单元 + 组件测试)
npm test
# 运行 E2E 验收(先构建再对产物启动真实 Electron;发布前必跑)
npm run test:e2e
# 生产构建
npm run build
```
@@ -81,13 +96,16 @@ npm run build:linux # Linux AppImage / deb
```
src/
├── main/ # 主进程:窗口、菜单、文件读取与编码探测IPC
├── main/ # 主进程:窗口、菜单、文件/文件夹读取与编码探测(解码 worker 化)、批量读取与进度推送 IPC
├── preload/ # 预加载:contextBridge 安全桥,仅暴露最小 API
├── shared/ # 两进程共用的纯逻辑源码(忽略规则匹配器、文本扩展名清单、路径归一化)
└── renderer/ # 渲染进程:React 界面与全部 diff 逻辑
├── diff/ # diff 引擎(行级 + 词级高亮、Worker 后台计算
├── diff/ # diff 引擎(行级 + 词级高亮、字符级分块、报告与快照纯逻辑
├── hooks/ # 状态 hooks(diff 计算调度、下拉关闭)
├── components/ # 界面组件(DiffView / Toolbar 等)
├── components/ # 界面组件(DiffView / FolderView / Toolbar 等)
└── styles/ # 全局主题样式
e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主链路)
```
**安全设计**:渲染进程通过 `contextBridge` 隔离,不直接接触 Node 能力;文件读取等系统操作统一收敛在主进程,仅经 IPC 白名单暴露。
@@ -99,7 +117,7 @@ src/
- 作者:thzxx
- 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.5.2
- 版本:0.11.0
---
+96 -6
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.5.2`**
当前基线版本:**`0.11.0`**
---
@@ -72,8 +72,97 @@ z = 补丁版本号(Patch
0.4.3 ← 忽略所有空白比较选项与报告选项说明(已发布):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标)
0.5.0 ← 字符级对比模式(已发布):新增“字符级对比”开关(空白与换行结构全部不参与判等,两侧归一化为字符流 diff,跨行重组也能判等) · 字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow,视图/报告/导航零改动) · 归一化流超 20 万字符自动降级行级对比并提示(报告头部按实际语义描述) · 字符级开启时三个空白选项置灰(语义已包含)
0.5.1 ← 字符级同行多块变更重复输出修复(已发布):字符换位专项测试暴露并修复两处回映射缺陷——同行内多处变更(ne-eq-ne 同行,如换位、同行两处替换)不再重复输出多个 modified · 回拉跨界判定与对齐行判定锚定同一 eq 块,多行换位不再把全 common 行误判为 added · 新增字符换位专项测试组(对齐任意性下的不变量固化)
0.5.2 ← 稳健性收尾与偏好记忆(当前):diff worker 运行崩溃 onerror 兜底(终止实例回退主线程同步计算,effect 取消/jobId 已推进的迟到错误不回填) · 主进程 will-navigate 导航白名单加固(仅放行回到应用首页) · nativeTheme 暗色主题(Windows 标题栏等系统控件跟随应用风格) · 比较选项与仅看差异开关持久化(localStorage 记忆用户偏好,损坏数据防御性回退默认) · 字符级降级报告选项构造与引擎降级分支对齐(补 trimWhitespace 置否)
0.6.0新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比
0.5.2 ← 稳健性收尾与偏好记忆(已发布):diff worker 运行崩溃 onerror 兜底(终止实例回退主线程同步计算,effect 取消/jobId 已推进的迟到错误不回填) · 主进程 will-navigate 导航白名单加固(仅放行回到应用首页) · nativeTheme 暗色主题(Windows 标题栏等系统控件跟随应用风格) · 比较选项与仅看差异开关持久化(localStorage 记忆用户偏好,损坏数据防御性回退默认) · 字符级降级报告选项构造与引擎降级分支对齐(补 trimWhitespace 置否)
0.5.3稳健性补漏与偏好记忆收尾(已发布):字符级对比少行大字符量输入改走 worker 后台计算(重输入判定补入字符量维度,行数防不住 diffChars O(ND) 耗时失控) · toast 队列化(最多 3 条堆叠、各自独立计时,二进制预警不再被行数预警顶掉) · 清空按钮二次确认(toast 提示 + 3 秒确认窗口,超时自动复位) · 窗口尺寸/位置记忆(关闭保存 bounds,启动恢复并钳制回工作区,损坏数据回退默认) · 文件对话框记忆上次打开目录 · 状态栏显示两侧行数 · 偏好读取挂载期单次化 · 主进程测试基建建立(windowState 纯逻辑模块 + vitest include 扩展至 src/main
0.5.4 ← 比较选项联动可视化与状态透明(已发布):修复禁用开关零视觉反馈的样式缺陷(原 CSS 仅有 button:disabledcheckbox 禁用后外观不变,用户不知哪些可选) · 字符级开启时三个空白选项灰显勾选(循系统惯例表达“语义已包含且生效”,仅为视觉呈现不改写用户原状态,关闭字符级即复原) · 常显“已包含”徽标(原仅 hover title 可知禁用原因) · 点击被包含选项弹 toast 解释而非无反应 · 字符级因内容超限自动降级时开关旁常显“已降级”标记(状态透明)
0.5.5 ← 字符级对比无界计算卡死修复(已发布):diffChars 携 maxEditLength=3000 封顶 Myers 迭代轮数(编辑距离超限立即放弃并降级行级,根治大字符量+大差异输入 O(ND) 无界计算导致遮罩永久卡死——实测 3 万字符全不同无上限需数十分钟) · 字符量降级阈值 20 万收紧至 3 万(双限配合将最坏耗时锁在约 2 秒,worker 内计算界面不冻结) · 重输入 worker 派发阈值随动收紧(原始字符 7500,同步快路径最坏冻结压至 0.5 秒内) · 降级提示文案改“内容或差异过大”覆盖两种降级原因 · DiffOptions 增内部测试参数 charDiffMaxEdit(测试以小阈值毫秒级构造降级场景)
0.6.0 ← 解码 worker 化 · Playwright E2E · 文件夹对比(已发布):
- 主进程解码 worker 化:≥256KB 缓冲移入 worker_threads 后台解码(GBK 纯 JS 解码大文件可达数百毫秒,不再阻塞主进程事件循环) · 解码与编码探测抽为纯逻辑模块 decode.ts(Uint8Array 入参,主进程同步路径与 worker 克隆路径共用) · worker 单例管理(崩溃拒绝在途任务并销毁重建,创建/运行失败回退主进程同步解码,行为不降级) · 构建改双入口(index + decodeWorkericonv-lite 打包进产物) · out/main 整体 asarUnpackElectron 的 asar 补丁不覆盖 worker 线程,unpacked 路径下无法解析 asar 内 node_modules
- Playwright E2E 测试体系建立:对 build 产物启动真实 Electron 验收(复用项目自带 Electron,无需下载浏览器) · 首批 11 用例覆盖 jsdom 无法触达的主进程真实链路(GBK/BOM/大文件 worker 解码、报告写盘、剪贴板、偏好跨实例重启、文件夹真实扫描) · 主进程 dialog stubshowOpenDialog/showSaveDialog 可自动化) · 用例间清空 localStorage 隔离偏好污染 · npm run test:e2e 一键构建+验收,纳入发布 checklist
- 文件夹对比(MVP):新增「对比文件夹」入口(依次选择两侧目录) · 递归扫描按相对路径对齐,文件级状态判定(相同/不同/仅左/仅右;大小不同即不同,大小一致做字节级全量比对,超 10MB 采样头部 8KB 近似判定并标注 ≈) · symlink 跳过防环,单侧文件数上限 10000(超限截断提示) · FolderView 虚拟滚动列表(统计徽章/仅看差异默认开/大小列示) · 双击条目进入单文件对比复用全部 diff 能力,一键返回文件夹列表(扫描结果保留) · file:open 与 file:read-by-path 共用同一读取管线(10MB 上限/编码探测/二进制预警一致)
0.6.1 ← 文件夹对比收尾打磨(已发布):文件夹扫描内容比对受控并发化(大小一致条目按 16 并发分批读取比对,批内 Promise.all 批间串行,大目录不再逐文件串行等 IO,输出与失败语义和串行实现完全一致) · 「返回文件对比」清空两侧面板(修复先有文件对比再进文件夹退出后残留旧对比,对齐文档的单文件空态) · 两次选择同一文件夹防呆提示(不进入扫描) · ScanResult.total 注释修正(真实枚举总数,原注释误写为上限值) · README 版本号补同步 0.6.0 遗漏(元信息维护表要求随版本同步)
0.6.2 ← 文件夹对比稳健性与性能修复(已发布):双击文件夹条目任一侧读取失败时清空该侧面板(修复失败侧残留旧对比内容与旧文件名组成“名不符实”对比的缺陷,readByPath 异常/空返回/error 三条失败路径全覆盖) · 文件夹枚举改手写 BFS 遍历凑满即停(替换 readdir recursive 无法提前终止的实现,误选超大目录如 node_modules 不再枚举耗时数秒;截断时 total 为已遍历下限,界面提示改“文件过多(N+)”)
0.7.0 ← 文件夹对比增强 · 字符级分块与滚动回弹修复(已发布):
- 语义判等:字节级判“不同”的文本文件(≤2MB)二次判等——剔除全部空白(换行符 CRLF/LF、缩进、空行、排版重排)后一致计为“语义同”(大小写敏感,大小写差异仍为实质差异),消除换行符误报;解码复用解码 worker(大缓冲后台解码不阻塞),与文件打开共用同一管线;开关默认开启并持久化,切换即重扫;语义同不计入差异(仅看差异过滤),双击仍可查看明细
- 子目录树形展示:条目按路径聚合为目录树(folderTree 纯逻辑模块:建树/状态聚合/扁平化/过滤),目录行展示子树聚合状态(different > unreadable > only > 语义同 > 相同 优先级)与文件计数,点击折叠/展开(默认全展开);树形扁平化行数组保持虚拟滚动;过滤/搜索后无文件的目录自动剪枝
- 搜索与状态过滤:文件名搜索框(相对路径子串、不区分大小写,命中路径强制展开);统计徽章可点击作状态精确过滤(可多选,与“仅看差异”互斥联动)
- 文件夹报告导出:HTML/纯文本/Markdown 三格式(folderReport 模块),HTML 延续单文件报告主题(目录卡/统计卡/条目表/打印适配),报告含全量条目与判定口径说明,与界面过滤无关
- 差异文件导航:从文件夹进入单文件对比后 header 出现“差异文件 x/y”导航(Alt+↓/Alt+↑ 快捷键,不循环),语义同不计入序列;批量审查无需反复返回双击
- 大目录单点失败降级:单文件读取失败(Windows 文件锁/Defender 占用)标记“无法读取”状态(琥珀警示徽章,默认显示),单目录不可读跳过——不再整体失败丢弃全部结果(万级目录真机冒烟验证:被锁的 electron asar 正确降级,其余 9999 项正常判定)
- 字符级对比分块锚定(恢复 0.6.3 算法):行归一化锚定 + 变更组分块字符流两阶段——相同内容不进入 diffChars,大文件字符级不再整篇受限(组上限 5 万字符/编辑距离 5000/组总流 10 万,任一超限降级行级);引擎输出经真机验证精确无误(0.6.3 事故根因不在引擎)
- 滚动回弹修复(0.6.3 事故真因,0.5.3 起即存在):worker 重路径计算期间保留旧结果撑住虚拟滚动容器高度(快路径结果兜底首次重算),items 不再塌陷——scrollTop 不被浏览器钳制归零;文本变化(打开新文件/交换左右)仍清空防过期差异误导;E2E 新增滚动稳定性回归锚(真实文件+行数不等+开字符级+切选项重算断言位置保持)
0.7.1 ← 文件夹扫描竞态守卫与边角修复(已发布):文件夹扫描会话序号丢弃过期结果(语义切换触发重扫与重新选择新目录的并发扫描按序号判定,慢的旧扫描后完成不再覆盖新扫描的目录与条目、不误弹失败提示、不误清扫描态;退出文件夹对比作废在途扫描,迟到的结果不把界面拉回文件夹模式、遮罩不残留) · FolderView 补窗口尺寸变化监听(拉伸窗口变高后虚拟滚动窗口即时补齐,底部不再出现空白区,对齐 DiffView 行为) · 行号列改不透明等效合成背景(真机证实横向滚动时行文字透过 sticky 半透明行号列叠印,修复后完全遮盖,视觉色值为原叠加的预合成) · 最大化/全屏关闭改存 getNormalBounds 普通状态(本次会话调整过的窗口位置不因最大化关闭丢失,最小化仍不记录) · 行 id 生成统一为 rows.length(消除 flushRemoved 与 commitModified 双轨) · fmtSize 收敛为 folderReport 导出的单一实现(报告与列表共用) · 文档与版本号同步
0.8.0 ← 文件夹对比深度增强 · 字符级逐组预算制 · 拖拽 E2E(已发布):
- 按扩展名过滤:folderTree 纯逻辑层增 relExt/EntryFilter.exts(第四维过滤,与其他条件取交集;无扩展名文件归空串类目) · FolderView 扩展名多选下拉(动态收集全量条目 ext→count 字典序,chips 激活态、清除按钮、useDismiss 关闭) · 过滤激活时"共 N / 总数"计数切换,统计徽章口径保持全量不变
- 目录行双击聚合对比:双击目录行进入该子树的差异文件批量审查(导航序列限定子树内,语义同不计入;标签标注当前范围,Alt+↓/Alt+↑ 在范围内不漂移) · 双击任意文件条目回全局序列 · 目录内无差异文件 toast 提示不进入 · 双击前的两次单击 toggle 残留折叠态由进入时自动展开消除 · openFolderEntry 增 navScope 参数(导航与聚合入口保持范围,手动双击清除)
- 增量重扫:folderScan 枚举 stat 保留 size+mtimeMs 指纹,扫描结果携带增量缓存(ScanCacheEntry:双侧指纹 + 状态 + semSame 语义判定参与记录) · 重扫携带上次缓存时指纹一致的条目零 IO 沿用;语义判等开关翻转按 semSame 纯内存组合(different ↔ semantic-same 零 IO),仅"上次未参与语义而本次需要"的条目读内容补判 · unreadable 不进缓存(重扫重试)、截断忽略缓存且 cache 产出为空(条目不完整不可信) · IPC folder:scan 增 previous 参数(主进程防御性校验数组形态) · FolderView 增「重新扫描」按钮(不重选目录直接刷新) · 已知取舍:mtime 未变视为内容未变(rsync/make 同款,手动改回时间戳不防)
- 字符级逐组预算制(变更组粒度放宽):第二阶段从"任一超限整体降级"改为逐组判定——组流 ≤ 5 万且编辑距离 ≤ 5000 且在总预算(10 万字符)内的组走字符级,超限/预算耗尽的组仅按行级配对输出(emitGroupLineLevel:行归一化 LCS + 相邻块配 modified 词级高亮 + 多余侧 removed/added,与行级路径同构含超长行词级防护) · DiffResult 增 charModePartial 部分降级标志(与 charModeDowngraded 互斥:全部组降级仍整体降级) · Toolbar「部分降级」标记、toast 提示、报告头部选项说明(plainOptions 增 partial 描述) · 行为变更:0.7.x 整体降级的多组混合输入在 0.8.0 为部分降级(特性而非回归,测试固化新行为)
- 拖拽导入 E2Ehelpers 增 dropFiles(真实 Chromium DataTransfer/DragEvent 合成拖放,覆盖 dropPane → file.arrayBuffer → decodeBuffer IPC → 编码探测全链路) · 4 用例:空面板拖入渲染、双侧拖入对比、非文本扩展名拒绝、多文件提示取首个
0.9.0 ← 忽略规则 · 增量缓存跨会话持久化 · 快照基线 · 字符级预算自适应 · 性能基准 E2E(已发布):
- 忽略规则:ignoreRules 纯逻辑模块(gitignore 简化子集——`dir/` 目录名匹配整树剪枝(不枚举不计数,不占用 maxFiles)、`*.ext` 扩展名通配、`name` 名字精确匹配;大小写不敏感;`#` 注释与空白行跳过;多段路径与非法通配整体忽略;上限 50 条/单条 500 字符双重防御) · folderScan listFiles BFS 内剪枝(双侧共用同一 matcher) · 被忽略项不进结果/缓存/total/truncated,规则变化后缓存按指纹自然失效(未命中走全量判定) · FolderView「忽略规则」下拉(每行一条文本域草稿、应用并重扫、清空规则,入口标注规则数) · 规则随偏好 localStorage 持久化(启动恢复并随扫描下发,损坏字段防御性过滤) · IPC folder:scan 增 ignoreRules 参数(isIgnoreRulesArray 校验)
- 增量缓存跨会话持久化:scanCacheStore 纯数据模块(userData/scan-cache.json 落盘;目录对键归一化——反斜杠转斜杠/去尾分隔符/Windows 盘符小写;LRU 上限 8 对按 savedAt 淘汰,当前写入对永不淘汰;解析全部防御性校验——JSON 非法/版本不符整体回退空存储,单条 entry 异常仅丢弃该条) · 主进程 folder:scan 渲染端未携带内存缓存时自动注入盘上缓存(跨会话首次扫描增量),扫描成功且未截断异步落盘(读-改-写,失败静默) · 渲染端链路零改动(内存缓存优先逻辑保留)
- 快照基线:snapshotDiff 纯逻辑模块(快照结构 version/leftDir/rightDir/semantic/truncated/total/entries/savedAtparseSnapshot 全字段防御校验绝不部分加载;diffSnapshot 按 rel 对齐产出六类变迁——fixed/regressed/new/gone/changed/stable,以 isDiffStatus 为主轴;snapshotDirsMatch 目录对一致性校验) · 「保存快照」复用报告保存管线落盘 JSON(默认名带时间戳) · 「加载快照」走打开文件对话框读取(目录对不一致/文件无效/读取失败/无当前结果四类拦截提示) · 对照模式接管 FolderView 列表渲染:变迁统计徽章(点击单选过滤)替换常规统计行、平铺全路径行 + 变迁徽章(gone 回退快照数据展示)、默认隐藏 stable、搜索共用、退出对照恢复 · 对照随重扫自动刷新(目录对仍一致时),退出文件夹对比清空 · 对照模式导出报告附「快照对照」口径行(三格式)
- 字符级预算自适应:charGroupBudget(totalGroupChars) = clamp(总组流/2, 下限 10 万, 上限 100 万)——下限与 0.8.0 固定值一致(小输入行为与已固化测试零变化),大输入但变更组普遍较小的场景按比例放宽(上限封顶最坏总耗时仍有界,单组 5 万/编辑距离 5000 不变) · plainOptions 字案与 Toolbar title 同步("预算随规模自适应") · 行为固化测试:400 组跨行重组总流 48 万 → 预算 24 万 → 200 组字符级(固定预算仅 83 组)
- 性能基准 E2E:5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s(万级行实测秒级,慢机 4 倍余量防 flaky,断言失败即性能回归) · 300KB GBK 15000 行——打开到状态栏行数渲染 < 10s · 新功能 E2E:忽略规则目录剪枝重扫、增量缓存跨重启(scan-cache.json 落盘断言 + 第二实例重扫正确)、快照保存→修复差异→加载对照变迁
0.9.1 ← 快照收尾修复 · IPC 校验单测化 · 对照报告变迁明细 · 渲染兜底(已发布):
- 快照目录对归一化匹配(修复):snapshotDirsMatch 此前逐字符比较,Windows 下 C:\Foo 与 c:/Foo 同目录异写会被误判"目录对不一致"而拦截对照——归一化(反斜杠→斜杠/去尾分隔符/盘符小写)后比较,与主进程 scanCacheStore.pairKey 同款逻辑(两进程无法共享模块,双向同步标注);大小写敏感文件系统的路径段大小写差异仍正确判不一致
- 截断结果保存快照拦截(修复):扫描结果截断时条目不完整(仅前 1 万个),此前可照常保存快照——日后对照时快照缺失的文件被全部误判为"新文件";现保存前检查 truncatedtoast 拦截提示"快照不完整无法对照"(对齐同目录防呆、目录不一致拦截的既有防呆风格)
- folder:scan IPC 校验单测化:此前 handler 内联的 previous 形态校验 / ignoreRules 校验 / 盘上缓存注入组合逻辑位于覆盖率排除的主进程入口(零单测),抽为 scanIpc 纯逻辑模块(isScanCacheEntries 形态校验 + resolveScanArgs 缓存源选择——内存缓存优先含空数组、非法回退盘上缓存、惰性读盘保证携带有效缓存时不产生读盘 IO),index.ts 变薄壳,单测全覆盖(测试策略第 9 节演进方向落地)
- 对照模式导出报告附变迁明细:此前对照模式导出只附一行变迁统计说明,条目表仍是普通扫描结果(看不出逐条变迁)——现提供 snapshotEntries 时三格式条目表整体替换为变迁对照表(变迁/相对路径/当前状态/基线状态/双侧大小,gone 回退基线大小;HTML 按变迁配色含打印适配),全量含 stable(报告信息零丢失口径);变迁标签收敛为 snapshotDiff 导出的 SNAPSHOT_CHANGE_LABEL 共享映射(FolderView 徽章与报告共用单一实现);E2E 快照用例补对照导出真实写盘断言
- 顶层 ErrorBoundary 防白屏:任何组件渲染期抛错由边界捕获,显示错误信息(可复制)+ 一键重载按钮,替代整窗白屏死机;事件回调内异常不属于 ErrorBoundary 捕获范围(由各回调自身 try/catch 防御,注释标注边界)
0.9.2 ← 快照对照对称缺陷修复 · 扫描中交互防抖 · 右键菜单键盘导航(已发布):
- 快照对照对称缺陷修复(快照基线稳健性收尾):加载快照拦截当前扫描结果截断(0.9.1 只拦了保存侧——当前条目不完整时对照会把截断缺失的文件误报为"已移除",与保存侧"快照缺失误报新文件"对称的同类误报;拦截发生在文件选择之前不弹对话框) · 对照模式中重扫触发截断自动退出对照并提示(不残留过期对照) · 快照语义判等口径与当前开关不一致时加载提示"对照结果含口径差异"(口径变化不再被静默当成内容变化——原语义同文件换口径重判后显示"新增差异"类误导;提示不拦截对照,基线行仍标注快照口径)
- 扫描中交互防抖:扫描进行中禁用语义判等开关与忽略规则入口(二者触发重扫——竞态守卫本已保证结果正确,防抖消除无谓的叠加扫描;仅看差异/搜索/扩展名过滤为纯视图过滤不禁用,与"重新扫描/保存快照/加载快照/导出报告"的既有 disabled 风格对齐)
- 右键菜单键盘导航:↑/↓ 在菜单项间移动高亮(跳过禁用项、首尾循环),Enter 触发高亮项,Esc 关闭不变;鼠标悬停与键盘高亮同步;全部禁用时方向键无操作不崩溃
- 微打磨与依赖:清理 CSS 孤儿变量(--pink/--panel-2 定义未消费) · 依赖 patch 级升级(npm update 语义化范围内,核心依赖 electron/vitest/vite 版本不变,仅间接依赖小幅更新)
0.9.3 ← 单文件视图搜索 · 扫描缓存落盘串行化 · 交互收尾(已发布):
- 单文件视图文本搜索(补齐文件夹模式有搜索而单文件模式没有的功能缺口):工具栏新增搜索框,Ctrl+F 聚焦(粘贴弹窗/右键菜单打开时不抢占)、大小写不敏感即时命中计数(x / y)、Enter/Shift+Enter 在命中行间循环跳转(复用差异导航的 activeRowId 受控定位,虚拟滚动下天然可用)、行内命中子串高亮(纯文本行与词级 segs 行均拆分标注,黄色系与增删改三色不冲突)、Esc 清空复位 · 搜索基于全量行(与"仅看差异"独立,被折叠的命中行仍计数;跳转目标被折叠时 toast 提示且不推进) · 线性扫描 5 万行毫秒级无需 worker,会话级临时状态不持久化
- 扫描缓存落盘串行化:persistScanCache 读-改-写经内存 promise 链按序执行(每次追加到链尾,失败静默不断链)——两次扫描近乎同时完成时不再后写整体覆盖先写丢 LRU 目录对
- 交互收尾:快照对照的变迁类型过滤在重扫刷新后计数归零时自动复位(残留过滤会让列表误显空态) · 「重新选择」扫描中保持可用并固化测试(与「返回文件对比」同类:发起新会话作废在途扫描,竞态守卫保证迟到结果丢弃;大目录扫描慢时允许用户放弃改选——曾考虑按防抖一致性禁用,经既有用例证伪后放弃)
- 测试与依赖:新增 21 用例(App 8 / Toolbar 7 / DiffView 4 / FolderView 2)与 E2E 搜索链路用例 · 依赖 patch 级升级(npm update 语义化范围内)
0.10.0 ← 快照内容级对照 · 忽略规则高级 glob · 扫描进度反馈(已发布):
- 快照内容级对照(快照功能链收官):快照结构升级 v2 可选携带差异条目的双侧文件内容(contents),对照模式双击含基线内容的变迁条目进入"快照基线 vs 当前文件"的单文件内容对比(左面板为基线内容并标注"快照 · 文件名",右面板为当前文件,完整复用比较选项/字符级/搜索/差异导航/导出全部能力) · 保存链路经新增 folder:read-batch 批量读取 IPC(受控并发复用单文件读取管线,单文件失败该条目降级不整体失败) · 体积双限:单文件 2MB(对齐语义判等上限,超限侧不存内容)、内容总量 32MB(超出整体降级为纯状态快照并提示,保存永不失败) · v1 旧快照兼容加载(无 contents 走纯状态对照,双击维持常规当前文件对比) · 既定取舍:仅保存差异条目内容,regressed 场景(基线一致→现在有差异)的基线内容不保存 · 解析校验 contents 任一条目形态异常整体拒绝(绝不部分加载的既有口径) · 保存期间"正在读取差异文件内容"遮罩
- 忽略规则高级 glob(匹配器重写):新增多段路径(src/temp 从根锚定)、前导 / 锚定(/build 仅根级,无前导单段名维持任意层级)、整段 ** 跨段通配(build/**/cache,尾部 ** 需至少一段与 gitignore 一致)、段内 * 通配放开位置限制、! 否定规则(同一路径最后一条匹配的规则生效,与 gitignore 语义一致;父目录被剪枝后子项无法反否定——BFS 剪枝语义与 gitignore 同款并文档化) · 匹配器从按名字匹配重构为按完整相对路径判定(matchesPath,含否定仲裁;预编译字面量/段正则/跨段三类段模式,DP 段序列匹配) · 无效规则不再静默丢弃:规则下拉实时提示"将被忽略的无效规则"(段内 ** / 空段 / ! 后为空等) · 纯逻辑模块迁移至 src/shared(主进程 folderScan 与渲染端 FolderView 共用同一源文件,消除双向同步负担;tsconfig 双端与 vitest include 同步扩展)
- 扫描进度反馈(首个 push 型 IPC):folder:scan 增 scanId 参数(复用渲染端扫描会话序号),比对阶段每并发批次完成经 event.sender.send 推送进度(isDestroyed + try/catch 防御窗口销毁) · preload 暴露 onScanProgress 订阅(返回取消函数) · 渲染端只显示与当前会话序号一致的进度事件(旧扫描迟到进度天然丢弃,与结果竞态守卫同一套序号,不引入新竞态面) · 扫描遮罩显示"已比对 N / M"(缓存命中的零 IO 条目不计入 total,增量重扫进度即时完成符合语义)
- 测试与文档:新增 33 单测(ignoreRules 30 全量重写 / folderScan glob 集成与 onProgress / snapshotDiff v2 / App 内容对照与进度 / FolderView 无效提示与进度)与 4 条 E2E(快照内容级对照全链路 / 高级 glob 含否定与无效提示 / 扫描进度轮询抓取),共 596 单测 + 27 E2E(此前文档误记 28,实际清点为 27)
0.10.1 ← 忽略规则字符通配 · 快照保存读取进度 · 差异导航游标修复(已发布):
- 忽略规则字符通配(段内通配子集扩展):段内模式新增 `?` 单字符通配(lo?g 命中 loog 不命中 log,不跨段)与 `[abc]`/`[a-z]`/`[!abc]` 字符类(*.[ch] 命中 .c/.h、log[0-9].txt 命中数字后缀、[!abc] 取反);未闭合 `[` 与空类体([]/[!])退化为字面量方括号(gitignore 同款语义),无法构成合法正则的类体(反转范围 [z-a])整段回退字面量匹配永不崩溃;大小写不敏感语义不变(模式与路径双侧小写化后匹配);既有 `*`/`**`/否定/多段锚定语义与段内 `**` 非法判定完全不变(回归锚固化);UI 忽略规则下拉的提示文案与 title 同步新语法
- 快照保存读取进度:保存快照批量读取差异条目内容期间遮罩显示"已读取 N / M"(文件数口径:去重后双侧路径总数,按 200/批推进,与扫描进度反馈对称,大差异目录不再无反馈干等);保存结束/失败进度随遮罩复位
- 差异文件导航游标修复:导航游标不在差异序列时导航组直接隐藏——此前显示"—"计数且"下一个"按钮可点无响应(goDiffFile 静默 return);触发路径为双击 same/语义同条目进入对比(0.7.0 遗留)与快照对照模式双击 fixed/regressed/new 等当前无差异条目进入内容对照(0.10.0 放大暴露面);双击差异条目即回序列、导航恢复
- 测试与文档:新增 16 用例(ignoreRules 字符通配 12 / folderScan 字符通配集成 1 / App 导航隐藏 2 与读取进度 1)并修复 0.10.0 遗留的 snapshotDiff.test 版本号类型错误(makeSnapshot 传 3/0 违反 1|2 字面量联合,改展开构造),共 612 单测 + 27 E2E
0.10.2 ← 大快照加载修复 · 快照保存防抖 · 关于弹框(已发布):
- 大快照加载修复(保存/加载上限不对称):保存侧快照内容总量上限 32M 字符(中文 JSON 化可达 ~96MB),但加载走 file:open 通用通道被 10MB 上限拦截——大差异目录的快照"保存成功却永远无法加载";新增 snapshot:read 专用 IPCJSON 过滤对话框 + 128MB 读取上限 + UTF-8 直读不做编码探测),顺带消除快照加载污染文件对话框 lastDir 记忆的副作用
- 快照保存中防抖:保存遮罩为纯视觉反馈(computing-overlay 的 pointer-events:none 不拦截交互),此前保存期间可再点"保存/加载快照"并发触发两轮保存对话框——现 FolderView 保存/加载按钮随 saving 禁用(与扫描中禁用同语言),App.loadSnapshot 增加保存中守卫双保险
- 同目录防呆归一化:两次选择同目录的判定从严格字符串比较改为路径归一化后比较(复用 snapshotDiff 导出的 normDir,与 scanCacheStore 同款逻辑)——Windows 异写路径(大小写/分隔符差异)不再绕过防呆
- 关于弹框: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.10.4 ← 快照对照对称防呆收官 · 搜索高亮重定位 · 保存中断 · TB 档延续(已发布):
- 快照对照防呆第三面(对称缺陷收官):0.9.1 拦截保存侧(当前结果截断不可保存)、0.9.2 拦截当前侧(当前结果截断不可加载对照),本次补齐快照自身侧——快照 truncated: true(自家保存链路不会产出,仅来自手工构造/外部工具)时加载拦截提示"条目不完整无法对照",快照缺失的文件不再被误报为"新文件"(拦截发生在目录对一致性校验之前,与既有防呆风格一致)
- 搜索命中高亮重定位(缺陷修复):切比较选项/交换左右/更换文件触发 diff 重算后,rows 全部重建(行 id 按 r{index} 重新编号),旧 activeRowId 不再命中任何行——搜索计数(由序号钳制保持)与行高亮脱节直到下次 Enter;现导航重置 effect 在重算时同步把 active 行定位到钳制后的当前命中行(有命中定位、无命中清空),计数与高亮恒一致;searchHits/searchIndex 有意不入依赖——关键字变化路径由既有效果负责首个命中定位,避免旧序号覆盖
- 快照保存中断(保存遮罩不拦截交互哲学的收尾):保存批量读取期间退出文件夹对比,此前保存流程会继续跑完并在单文件空态界面上突然弹出保存对话框(保存的目录对上下文已消失,名不符实);现以保存世代号(snapshotSaveSeqRef,与扫描会话序号同款模式)作废在途保存——每批读取前与 saveReport 调用前检查,世代号推进即静默终止(finally 复位遮罩幂等兜底);退出/返回时遮罩即时复位,保存按钮防抖(0.10.2)与遮罩不拦截交互(pointer-events:none)的既有设计均不变
- TB 档大小显示(0.10.3 档位延续):fmtSize 补 TB 档——TB 级文件(虚拟机镜像/备份归档)此前显示为 1024.0 GB,与 GB 修复同源的档位逐级补全
- 测试与文档:新增 5 单测(快照截断拦截 1 / 搜索重定位与归零回归锚 2 / 保存中断 1 / fmtSize TB 档修复锚 1,另 TB 断言并入各档换算用例)与 1 条 E2E(手工构造截断快照加载拦截),共 639 单测 + 30 E2E · 依赖 patch 级升级(npm update 语义化范围内,本轮无新版本,零变更)
0.10.5 ← 文件夹顶部布局重构 · 字符粒度统计 · 换目录状态重置 · 跨模式搜索一致(已发布):
- 文件夹顶部布局重构(头部约 210px → 约 50px 单行):操作按钮区此前 6 按钮竖排一列把头部撑高约 200px,最小窗口高度下条目列表区仅剩约 300px——现改为横排单行(重新扫描 / 重新选择 / 快照 / 导出报告 / 返回文件对比),快照两操作(保存/加载)归组进「快照」下拉(沿用导出菜单的弹层语言与点击外部/Esc 关闭,菜单项随 scanning/saving 禁用),路径卡片压缩高度;条目列表可视高度增加约 6 行
- 字符粒度增删统计(字符级对比增强):DiffSummary 新增 charsAdded/charsDeleted(码点计数,emoji/CJK 扩展不撕裂代理对)——added 行计右侧整行、removed 行计左侧整行、modified 行计词级/字符级 segs 的新增/删除段(行级与字符级路径口径一致,超长行防护 segs 为 null 不计);行级与字符级两路径的 summary 构造收敛为 summarize 单一实现;字符级模式开启时工具栏统计区追加「+N 字 / −M 字」徽章(青色系与行级徽章区分),三格式报告头部统计行与 HTML 统计卡同步追加(仅字符级模式);引擎输出结构变更经滚动稳定性 E2E 回归锚验证无回退
- 换目录视图状态重置(确定性缺陷修复):重新选择新目录对后,旧搜索词/状态过滤/扩展名过滤/折叠集合/变迁过滤此前全部残留并静默生效(旧目录的折叠路径让新目录同名目录意外折叠、旧关键字继续过滤新列表)——现目录对变化即重置全部视图层状态;同目录重扫(leftDir/rightDir 不变)不触发,保留用户视图
- 跨模式搜索一致性:Ctrl+F 此前仅单文件模式生效——现文件夹模式聚焦文件名搜索框(App 分流 ref);文件夹搜索框补齐 Esc 清空与失焦(与单文件搜索框同款行为);两个搜索框均有内容时显示 × 一键清空按钮(清空后聚焦回输入框,鼠标流与键盘流双入口)
- 测试与文档:新增 19 单测(diffEngine 字符统计 7 / Toolbar 徽章与清空按钮 4 / FolderView 快照下拉与重置、Esc、ref、清空 5 / report 字符统计行 1 / App 集成 2,另有既有快照用例的下拉两步化改写)与 E2E 字符徽章断言(快照用例同步改写下拉交互),共 658 单测 + 30 E2E
0.11.0 ← 导出报告全面增强:Patch 标准差异 · TXT/MD 分块省略 · 报告元信息 · 导出目录记忆(已发布):
- Patch 标准差异导出(第四格式,报告从"人读"扩展到"机器可用"):单文件导出菜单新增「Patch 标准差异」——标准 unified diffgit diff 风格,`--- a/文件` / `@@ -l,c +l,c @@` hunk 头 / 上下文 3 行 / modified 行展开 -旧+新 / 尾部无换行 `\ No newline at end of file` 标记),产物可直接 git apply / patch 消费;默认文件名 DiffLens-patch-{时间戳}.patchhunk 结构复用 buildSegments 分块(段间省略天然由 hunk 行号跳转表达);无变更输出空 diff(仅文件头,与 git 行为一致)
- Patch 判等污染守卫:判等类比较选项(忽略大小写/所有空白/字符级)会把两侧实际不同的文本判为 unchanged(上下文行),此类 patch 无法正确应用——导出前逐行扫描 unchanged 行两侧文本一致性精确判定(canBuildPatch,碰巧未触发判等差异的场景仍可导出,比"有选项就禁用"宽松);不可导出时菜单项禁用并 title 解释原因,App 导出路径双保险拦截
- TXT/Markdown 报告分块省略(与 HTML 信息架构对齐):此前两格式全量输出全部行(5 万行文件报告数 MB 且相同行淹没差异)——现复用 buildSegments(上下文 3 行),段间以「⋯ 省略 N 行相同内容 ⋯」提示行替代(Markdown 每段独立成表重复表头,省略行作为段落间隔);完全一致场景保持全量展示(留档语义);HTML 明细的折叠语义不变
- 报告元信息增强:①生成器版本号进全部格式尾部署名("生成于 DiffLens vX.Y.Z",经 app:get-version 取 package.json 唯一来源;长期存档报告可追溯产出版本)②文件夹报告头部新增忽略规则说明行(有规则时,"忽略规则:node_modules/、*.log(命中项不参与对比)",超 5 条截断计数——脱离应用后可辨识条目缺失原因)③快照基线内容对照导出的报告头部新增对照说明("左侧为快照基线(保存于 …),右侧为当前文件"——脱离应用后可辨识数据来源,测试策略第 9 节"快照内差异内容的报告导出"演进方向落地)④HTML 文件卡行数修正为原始文本口径(此前按 rows 统计,忽略空行开启时空行被剔除致偏小)
- 导出目录记忆:file:save-report 保存对话框起始目录记忆上次成功导出位置(windowState 新增 lastReportDir 字段,与 lastDir 语义不同独立记忆;取消保存不记忆)——频繁导出无需每次从文档目录重新导航
- 测试与文档:新增 33 单测(report patch 构建器 8 / canBuildPatch 判定 5 / TXT·MD 分块 4 / 元信息 3 / folderReport 忽略规则与版本号 4 / Toolbar patch 菜单项 3 / windowState lastReportDir 1 / App 快照对照导出集成 1,另修复分块影响的既有 TXT 断言)与 2 条 E2E(patch 真实落盘断言 hunk 头与增删行 / 忽略大小写下 Patch 项禁用),共 666 单测 + 32 E2E
...
0.y.z ← 长期停留,永不进入 1.x
```
@@ -149,9 +238,10 @@ npm run build:win # Windows NSIS 安装包(在 Windows 上)
发布前 checklist
1. 更新 `package.json``version` 到新的 `0.y.z`
2. 更新本文件第 3 节演进记录
3. 通过质量门禁(typecheck + build
4. 打标签并推送到远程 `git.metona.cn/MetonaTeam/DiffLens`
5. 如需发布安装包,生成对应平台产物与 Release 说明
3. 通过质量门禁(typecheck + test + build
4. 通过 E2E 验收(`npm run test:e2e`,对 build 产物跑真实 Electron 主链路,0.6.0 起纳入发布必跑)
5. 打标签并推送到远程 `git.metona.cn/MetonaTeam/DiffLens`
6. 如需发布安装包,生成对应平台产物与 Release 说明
---
+144 -20
View File
@@ -18,11 +18,11 @@
| 层级 | 覆盖对象 | 工具 |
| --- | --- | --- |
| 单元测试(UT) | 纯逻辑:diff 引擎、报告生成器 | Vitest |
| 组件测试 | React 组件:应用空态、工具栏交互 | Vitest + Testing Library |
| 集成(演进中) | Electron IPC / 多进程协作 | 预留 Playwright 或 Electron 测试 |
| 单元测试(UT) | 纯逻辑:diff 引擎、报告生成器、解码探测、文件夹扫描 | Vitest |
| 组件测试 | React 组件:应用空态、工具栏交互、文件夹视图 | Vitest + Testing Library |
| E2E0.6.0 起) | 构建产物真实 Electron:主进程解码/写盘/文件夹扫描/偏好重启 | Playwright |
> 现阶段以**单元 + 组件**为主,覆盖核心、最易回归的部分
> 单元 + 组件覆盖核心逻辑与交互;E2E 覆盖 jsdom 无法触达的主进程真实链路(mock `window.api` 测不到的部分),发布前必跑
---
@@ -34,35 +34,70 @@
| 环境 | jsdom |
| React 测试 | @testing-library/react |
| 断言扩展 | @testing-library/jest-dom |
| 未来 E2E | Playwright预留,暂不引入 |
| E2E | Playwright`_electron` 模式,复用项目自带 Electron,无需下载浏览器 |
---
## 4. 目录结构
```
vitest.config.ts # Vitest 配置(jsdom + 测试范围 + 覆盖率 80% 硬门禁)
vitest.config.ts # Vitest 配置(jsdom + 测试范围含 shared + 覆盖率 80% 硬门禁)
playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑)
e2e/
├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口/合成拖放)
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项含字符粒度统计徽章/导航/导出写盘含 Patch 落盘与判等禁用/文件夹对比含单行头部与快照归组下拉/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照与大快照专用通道与截断快照拦截/关于弹框/拖拽导入/偏好重启)
src/shared/
├── ignoreRules.ts # 忽略规则匹配器纯逻辑(gitignore 简化子集含高级 glob 与段内字符通配——? 单字符/[abc] 字符类;主进程扫描剪枝与渲染端无效提示共用,段模式预编译 + DP 段序列匹配 + 否定仲裁)
├── 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 进度推送;扫描缓存读盘与偏好写盘均异步 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 # 扫描缓存跨会话持久化纯数据(目录对键归一化复用 shared/pathNorm/LRU/防御性解析)
├── scanCacheStore.test.ts # 缓存存储单元测试(解析回退/条目过滤/键归一化/LRU 淘汰/序列化往返)
├── scanIpc.ts # folder:scan IPC 入参校验与缓存源选择纯逻辑(无 electron 依赖;异步签名——loadStored 同步/异步实现均兼容,惰性读盘语义不变)
├── scanIpc.test.ts # IPC 校验单元测试(previous 形态校验/缓存源选择与惰性读盘含异步 stub 兼容/忽略规则校验)
├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖)
└── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸)
src/renderer/src/
├── test/
│ └── setup.ts # 全局 setupjest-dom 匹配器 + 自动 cleanup
├── diff/
│ ├── diffEngine.test.ts # diff 引擎单元测试
│ ├── diffEngine.test.ts # diff 引擎单元测试(行级/词级/字符级分块锚定与降级/换位不变量)
│ ├── diffWorker.ts # diff 计算 Web Worker(内联打包,覆盖率排除,逻辑由 diffEngine 覆盖)
│ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化)
│ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退
│ ├── report.test.ts # 报告生成测试
│ ├── report.test.ts # 单文件报告生成测试(含 0.11.0Patch 构建器/canBuildPatch 判定/TXT·MD 分块省略/元信息版本号与对照说明)
│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注;对照模式条目表替换为变迁对照表;fmtSize 大小格式化含 GB·TB 档,列表与报告共用;0.11.0:忽略规则说明行 + 版本号署名)
│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable/快照变迁对照表/fmtSize 各档与 GB·TB 修复锚/忽略规则与版本号元信息)
│ ├── snapshotDiff.ts # 快照基线纯逻辑(v1/v2 结构/解析校验含 contents/目录对归一化匹配(normDir 复用 shared/pathNorm)/六类变迁对照/共享变迁标签映射/内容保存上限常量)
│ ├── snapshotDiff.test.ts # 快照单元测试(解析防御/v1 v2 兼容/contents 形态校验/变迁判定/双侧空集/unreadable 语义/目录对归一化匹配)
│ └── textUtils.test.ts # 文本扩展名与行数统计测试
├── hooks/
│ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理)
│ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理/字符级大字符量重路径/选项变化保留旧结果防滚动塌陷
│ ├── useDiff.ts
│ └── useDismiss.ts
├── components/
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出)
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出;↑/↓ 键盘导航跳过禁用项首尾循环、Enter 触发高亮项、悬停同步高亮
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放/搜索命中行内高亮
│ ├── ErrorBoundary.tsx # 顶层渲染错误边界(错误面板 + 一键重载,防白屏死机)
│ ├── ErrorBoundary.test.tsx # 渲染错误兜底测试(抛错子组件 fallback/正常子组件透传)
│ ├── AboutModal.tsx # 「关于」弹框(应用介绍/版本/作者/邮箱/组织/开源地址/许可证;邮箱与地址点击复制)
│ ├── AboutModal.test.tsx # 关于弹框测试(元信息渲染/版本占位/复制回调/三种关闭方式/卸载监听清理)
│ ├── LogoIcon.tsx # 应用 Logo 图标(App 头部与关于弹框共用单一实现)
│ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤含扩展名/子树收集)
│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt)
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉含无效规则提示与进度遮罩/快照对照模式含变迁过滤归零复位与内容对照 title/目录双击/重新扫描与重新选择/快照保存中 saving 禁用/双击/导出菜单/虚拟滚动/resize 重算)
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动置灰
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/Patch 菜单项可用与判等禁用+title 解释(0.11.0)/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记/搜索框计数与 Enter·Shift+Enter·Esc 键盘交互/ref 转发
└── __tests__/
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/大输入回退
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索(含重算后计数与高亮一致·命中归零清空)/Ctrl+F/文件夹模式进出与双击(含同目录异写归一化防呆)/语义判等链路/差异文件导航(含游标不在序列时导航隐藏)/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截(加载走 snapshot:read 专用通道·快照自身截断拦截·保存中退出静默终止)/快照内容级对照(v2 保存含内容与读取进度·超限降级·双击基线vs当前·v1 兼容·导出报告含对照说明与版本号(0.11.0))/扫描进度显示与过期丢弃/关于弹框(打开·元信息·复制·双模式可见·Esc 关闭)
└── main.test.ts # 入口挂载冒烟测试
```
@@ -78,8 +113,9 @@ src/renderer/src/
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
- 忽略选项:`ignoreCase``trimWhitespace``ignoreBlankLines``ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文
- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);降级组(归一化流超 20 万字符自动降级行级并带 `charModeDowngraded` 标志 / 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志
- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);降级组(归一化流超 3 万字符自动降级行级并带 `charModeDowngraded` 标志 / 编辑距离超限快速降级(内部参数 charDiffMaxEdit 注入小阈值毫秒级构造)/ 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志 / 全部组超限整体降级不出现 partial);逐组预算制部分降级组(0.8.0):组总流超预算时预算内组字符级、预算外组行级配对(`charModePartial` 置位、segs 拼接无损、超长行触发词级防护 segs 为 null、行号单调)/ 单组流超上限仅该组行级其余组字符级精确定位 / 单组编辑距离超限仅该组行级(charDiffMaxEdit 注入)/ 行级降级组内行数不等按序配对多余侧 added;预算自适应组(0.9.0):charGroupBudget 函数边界(小输入取下限 / 大输入按总组流一半向上取整 / 上限封顶 100 万 / 非有限输入回退下限)/ 大输入跨行重组 400 组总流 48 万 → 预算 24 万 → 200 组字符级(固定预算仅 83 组,行为放宽固化)/ 总流未超下限两倍时与历史固定预算行为完全一致(回归锚
- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs
- 字符粒度增删统计(0.10.5):charsAdded/charsDeleted 码点计数——行级 added/removed 整行计数 / modified 按 segs 增删段计数(common 不计)/ 全角与 emoji 按码点(代理对不拆分)/ 字符级模式回映射 segs 同口径 / 超长防护行(segs null)不计 / 完全一致为零 / 行级与字符级路径统计口径一致
- 行切分:结尾换行不产生多余空行
### 5.2 报告生成(`report.ts`
@@ -89,7 +125,12 @@ src/renderer/src/
- 差异分块(`buildSegments`):空行集 / 无变更单段全展示 / 上下文 3 行与边界收敛 / 相邻区间合并 / 远距分两段省略数正确 / 自定义 context
- 人话总结(`plainSummary`):无差异一致结论 / 为 0 项省略 / 三类齐全完整罗列
- 比较选项描述(`plainOptions`)与头部说明行:无选项严格对比文案 / 生效项罗列 / 忽略所有空白遮蔽行首尾空白;TXT / Markdown / HTML 三格式头部含“比较选项”行
- 格式分发与扩展名映射(`REPORT_EXT`
- 字符粒度统计行(0.10.5):字符级模式(ctx.options.charMode)时统计行追加「| 字符 +A / −D」、HTML 追加「字符增删」统计卡;行级模式不追加
- TXT/Markdown 分块省略(0.11.0):段间相同行以「⋯ 省略 N 行相同内容 ⋯」提示行替代(Markdown 每段独立成表重复表头);段内上下文行保留、省略段内行不输出;完全一致场景保持全量(留档语义);单段场景无省略(回归锚)
- Patch 标准差异(0.11.0`buildPatchReport`):文件头 `--- a/` `+++ b/`(优先完整路径);hunk 头 `@@ -l,c +l,c @@` 双侧行号与计数正确;modified 行展开 -旧/+新、removed/added 单行、unchanged 上下文行;多段多 hunk 行号各自锚定;尾部无换行 `\ No newline at end of file` 标记(双侧/单侧/标记紧跟对应行且计数正确);无变更仅输出文件头(空 diff)
- Patch 可导出判定(0.11.0`canBuildPatch`):严格对比恒可导出 / 忽略大小写判等污染不可导出 / 选项开启但未触发判等差异仍可导出 / 字符级跨行重组不可导出 / 空变更行集可导出
- 元信息增强(0.11.0):版本号进三格式尾部署名(未提供时保持历史格式)/ 对照说明(compareNote)进三格式头部 / HTML 文件卡行数优先原始文本口径(ctx.leftLines
- 格式分发与扩展名映射(`REPORT_EXT`,含 patch
### 5.3 文本工具(`textUtils.ts`
- 文本扩展名白名单放行 / 拒绝 / 大小写不敏感
@@ -98,6 +139,7 @@ src/renderer/src/
### 5.4 对比视图(`DiffView.tsx`
- 渲染:行内容、行号、词级高亮段、行尾空白标注
- 搜索命中高亮(0.9.3):searchQuery 非空时命中子串包裹高亮 span(大小写不敏感、多处命中全部拆分、左右两侧各自渲染);词级 segs 行在 seg 文本内拆分;无命中或未传 query 不渲染
- 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满
- 内容最小宽度:探针测得字符宽后按最大列宽换算 minWidth(半角/全角列宽,超长行封顶);探针未测得时不设置
- 折叠提示行:默认文案与自定义文案(仅看差异视图)
@@ -109,27 +151,108 @@ src/renderer/src/
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
- 交换左右侧后面板文件互换
- toast 队列:连续多条提示同时可见不互顶 / 各自独立计时消失 / 超过上限顶掉最旧 / 动作按钮点击后该条立即关闭 / 二进制预警与行数预警共存
- 清空二次确认:有内容时第一次点击仅提示不清空 / 确认窗口内再点执行清空 / 超时自动复位(再点仍先提示)/ 空态点击不弹确认
- 状态栏行数:单侧/双侧加载显示对应行数(切分与引擎一致)/ 清空后随空态消失
- 偏好持久化(比较选项 + 仅看差异开关):启动时恢复上次保存的状态(选项勾选与视图折叠生效)/ 损坏 JSON 回退默认且不崩溃 / 非布尔字段防御性忽略 / 切换选项后写回存储(用例间 beforeEach 清理 localStorage 防串扰)
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项禁用;内容超限自动降级并 toast 提示;导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
- 单文件视图搜索(0.9.3):输入关键字即时命中计数与首个命中行 active 定位 · Enter/Shift+Enter 命中行间推进回退(行号随动)· 首尾循环 · 大小写不敏感 · 无命中 0/0 且 Enter 不崩溃不推进 · Esc 清空复位 · Ctrl+F 聚焦搜索框 · 命中行被“仅看差异”折叠时计数仍统计全量行、跳转 toast 提示且不推进 · 切选项/换文件重算后命中计数与 active 高亮行自动钳制重定位保持一致(0.10.4,rows 重建行 id 重新编号不再脱节;命中归零清空高亮)
- 文件夹模式搜索与换目录状态(0.10.5):Ctrl+F 聚焦文件名搜索框并 Esc 清空(跨模式一致)· 重新选择新目录对后旧搜索词不残留(视图状态重置生效)
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入(含 Windows 异写路径归一化判定,0.10.2) · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式
- 快照基线(0.9.00.9.1 收尾,0.9.2 对称防呆,0.10.0 内容级对照):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 当前扫描结果截断时加载快照同样被拦截(0.9.2,不弹文件选择——截断缺失文件会被误报已移除) · 对照模式中重扫触发截断自动退出对照并提示(0.9.2) · 快照语义判等口径与当前开关不一致时加载提示含口径差异、一致时无提示(0.9.2) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新 · 快照内容级对照(0.10.0):保存时差异条目双侧内容入 contents(批量读取按路径返回、same 条目不存、单文件超限侧不读该侧 null)/ 内容总量超限整体降级为纯状态快照并提示(vi.mock 压低上限构造)/ 对照模式双击含基线内容条目进入"快照基线 vs 当前"内容对比(左面板快照基线标注、右面板当前文件、有差异徽章)/ v1 快照双击走常规当前文件对比(兼容回归锚) · 保存读取分批进度遮罩"已读取 N / M"0.10.1,第二批挂起构造断言,结束复位) · 双击 same 条目 / 对照模式双击 fixed 条目进入对比后差异文件导航隐藏(0.10.1,游标不在差异序列;双击差异条目导航恢复) · 加载走 snapshot:read 专用读取通道(0.10.2,mock 随通道迁移;保存/加载上限不对称修复的通道基础) · 快照自身截断(手工构造形态 truncated: true)同样被拦截且不进入对照(0.10.4,与保存侧/当前侧对称的三侧防呆收官) · 保存批量读取期间退出文件夹对比:在途保存静默终止、saveReport 不被调用、遮罩即时消失(0.10.4,保存世代号作废;正常保存路径回归锚由读取进度用例覆盖)
- 关于弹框(0.10.2):点击 header ⓘ 按钮打开(版本/作者/双邮箱/组织/开源地址/许可证全量断言,版本号来自 app:get-version · Esc 关闭 · 点击邮箱触发剪贴板复制与 toast 反馈 · 文件夹模式下入口同样可见
- 导出报告增强(0.11.0):快照内容对照模式导出 HTML 报告——头部含对照说明(左侧为快照基线 + 保存时间、右侧为当前文件)与生成器版本号署名(app:get-version 链路)
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`
- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致
- 重路径:进入 computing 且 diff 置空;200ms 去抖到期后才创建 worker 并派发;响应按 jobId 匹配,错误/过期 jobId 被丢弃
- 字符级大字符量重路径:charMode 开启且两侧字符总量超过阈值(少行大文件,行数维度防不住 diffChars 耗时)同样进入重路径;未超阈值/charMode 关闭不受影响;恰好阈值不触发
- 输入变更:已派发的旧 worker 终止重建(等价取消);去抖期内变更则旧任务未创建即取消(零创建与计算开销)
- worker 不可用(环境缺失/工厂返回 null):去抖到期后主线程同步回退
- 卸载:去抖期内卸载则 worker 从未创建;去抖到期后卸载则终止 worker,去抖定时器均被清理
- worker 运行崩溃(onerror 兜底):终止实例并回退主线程同步计算(computing 不再永久卡死);迟到错误(effect 已取消 / jobId 已推进)只终止不回填,新任务结果正常生效
- 工厂:无 Worker 环境返回 null;可用环境返回实例
### 5.7 应用状态持久化(`src/main/windowState.ts`
- parseState:空输入/非法 JSON 回退空状态;合法 bounds 与 lastDir 保留;lastReportDir 合法字符串保留、非字符串/空串忽略(0.11.0 导出目录记忆);字段类型异常(非矩形/非字符串/空串/非有限数值)防御性忽略
- clampBounds:工作区内合法 bounds 原样保留;小于最小尺寸钳到最小值;超出工作区封顶;移出屏幕四向拉回(至少 60px 进入工作区);多显示器偏移工作区同样钳制;非整数取整;工作区小于最小尺寸时尺寸仍保持最小值
- 磁盘读写与窗口事件绑定在主进程入口(覆盖率排除,真实链路由 E2E 覆盖)
### 5.8 解码与编码探测(`src/main/decode.ts`
- looksBinary:空输入 / NUL 立即判定 / 纯可见 ASCII / 控制字符占比超 5% / 常见空白不计入
- decodeTextUTF-8 BOM(剥离 BOM + 标注)/ UTF-16 LE 与 BE BOM / 仅 BOM 无内容 / 短于 BOM 长度走严格 UTF-8 路径 / 无 BOM 合法 UTF-8 与纯 ASCII / GBK 中文回退解码正确 / 空输入 / 含 NUL 非法 UTF-8 的 GBK 回退 + 二进制判定 / 替换符占比超 5% 判二进制
- worker 调度(阈值分流/崩溃回退)在主进程入口(覆盖率排除,大文件 worker 链路由 E2E 覆盖)
### 5.9 文件夹扫描(`src/main/folderScan.ts`,真实临时目录)
- 状态判定:内容一致 same / 同大小内容不同 different / 大小不同 different / 单侧缺失 only(另一侧大小 null)
- 语义判等(0.7.0):semanticEquals 判等口径(空白/换行/空行/跨行重排判等,实质差异与大小写不判等);仅空白差异文本文件判 semantic-same(同大小与不同大小均覆盖);实质/大小写差异仍 different;非文本扩展名与超大小上限不参与(保持 different);未开启时行为与 0.6.2 一致;字节一致文件不触发解码
- 失败降级(0.7.0):单文件 IO 失败(模拟被锁)标记 unreadable 不影响其余条目;目录不可读跳过该目录(该侧按无文件处理,另一侧条目为 only),不再整体抛异常
- 递归子目录按相对路径对齐(统一 / 分隔);二进制内容字节级判定;symlink 跳过;条目字典序排序;total 取两侧较大值
- 边界与选项:两个空目录 / maxFiles 截断(truncated + total + 条目数受限;截断后不再深入子目录,total 为已遍历下限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different
- 受控并发:跨多个并发批次(> 2×SCAN_CONCURRENCY)的混合状态全部判定正确、条目排序与并发执行顺序无关
- 增量重扫(0.8.0,previous 缓存):指纹一致(size+mtime 未变)零 IO 沿用判定(decode 计数零增长)/ 语义开关翻转按 semSame 纯内存组合(different ↔ semantic-same 零 IO,开→关→开三轮)/ 上次未参与语义(semSame null)而本次开启读内容补判 / 指纹变化(内容修改)重新判定且文件增删正确 / 单侧缺失形状变化不误沿用 / 本次截断忽略 previous 且 cache 产出为空 / unreadable 不进缓存(故障恢复后重扫正确判定)
- 忽略规则(0.9.00.10.0 高级 glob0.10.1 字符通配):目录规则整树剪枝(深层子树不枚举、不计数、不进结果与缓存)/ 扩展名通配与名字匹配跳过文件(大小写不敏感)/ 被忽略文件不占用 maxFiles(原本截断的场景忽略后完整扫描)/ 规则变化后重扫缓存未命中的重新纳入条目走全量判定 / 空规则数组与全非法规则不产生任何剪枝 / 多段路径从根锚定剪枝(其他分支同名目录不受影响)/ ** 跨段剪枝与否定救回(父目录未被剪枝时子路径可被反否定)/ 父目录被剪枝后否定规则救不回子项(gitignore 同款语义)/ 段内 ? 单字符与 [abc] 字符类跳过文件(*.lo? 与 *.[ch] 真实目录集成,0.10.1
- 比对进度回调(0.10.0):需读内容的条目按批推进进度(total 恒为需读内容条目数、done 单调递增至 total)/ 全部条目零 IO(单侧缺失)时不触发回调
### 5.10 文件夹视图(`FolderView.tsx`)与条目树(`folderTree.ts`
- 条目树纯逻辑:按路径分段聚合目录/文件(同层字典序);目录聚合子树状态(different > unreadable > only > 语义同 > 相同)与文件计数;折叠目录子树隐藏;过滤(onlyDiff / statusFilter / query 子串不区分大小写 / exts 扩展名集合);空目录剪枝由“过滤后建树”天然保证;relExt 取文件名段扩展名(小写、无扩展名空串、不误取目录名中的点)
- 渲染:目录路径卡片 / 重新扫描、重新选择、导出报告与返回按钮 / 统计徽章(相同/语义同/不同/仅左/仅右/无法读取/总数;语义关闭隐藏语义同、无失败隐藏无法读取)
- 树形交互:目录行点击折叠展开(默认全展开);搜索命中强制展开命中路径、清空恢复用户折叠;无命中提示
- 扩展名过滤(0.8.0):下拉展示全量条目 ext→count(字典序、无扩展名单列);点选过滤与其他条件取交集、计数切 N / 总数;多选并集、再点取消、清除按钮复位;与状态过滤双激活空态提示;点击外部关闭且过滤状态保留
- 忽略规则(0.9.00.10.0 高级 glob 与无效提示,0.10.1 字符通配文案):已有规则时入口标注数量并高亮;下拉打开以当前规则初始化草稿,逐行解析去空白去空行(截断至 50 条)后应用回调并关闭;清空规则回调空数组;点击外部关闭不触发回调;无效规则实时提示"将被忽略的无效规则"(修正/清空后提示消失,不再静默丢弃);提示文案与 title 含 ? 单字符与 [abc] 字符类新语法
- 快照对照模式(0.9.0):变迁统计徽章(已修复/新增差异/新文件/已移除/仍有差异/保持一致)与基线时间替换常规统计行;对照条目平铺全路径渲染、行尾变迁徽章、gone 回退快照数据;零计数变迁不占位;默认隐藏 stable、点击徽章单选过滤再点取消;双击当前条目进单文件对比、已移除条目双击无效;退出对照恢复常规视图;全 stable 空态提示;重扫刷新后过滤的变迁类型计数归零自动复位(0.9.3,残留过滤不再误显空态);对照条目含基线内容时 title 提示"双击查看快照基线内容对照"0.10.0
- 快照归组下拉(0.10.5 布局重构):「快照」入口打开下拉含保存/加载菜单项,菜单项触发回调并关闭;点击外部关闭不触发回调;scanning/saving 时入口禁用(不打开下拉)、其余操作(重扫/重选/导出/返回)不受影响
- 换目录视图状态重置(0.10.5):目录对变化重置搜索词/状态过滤/扩展名过滤/折叠集合/变迁过滤(同目录重扫保留——区分「重扫」与「换目录」)
- 搜索框(0.10.5):Esc 清空并失焦(与单文件搜索框同款)、有内容时显示 × 清空按钮(点击清空并聚焦回输入框)、searchInputRef 转发(Ctrl+F 聚焦用)
- 扫描进度遮罩(0.10.0):progress props 非空时遮罩显示"已比对 N / M"null 时仅基础文案
- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动)
- 扫描中交互防抖(0.9.2):扫描进行中语义判等开关与忽略规则入口禁用(触发重扫的入口,点击不触发回调/不打开下拉);仅看差异开关与搜索仍可用(纯视图过滤不触发重扫);「重新选择」扫描中保持可用(0.9.3 固化:发起新会话作废在途扫描,与「返回文件对比」同类)
- 快照保存中防抖(0.10.2):saving=true 时保存/加载快照按钮禁用(点击不触发回调);重新扫描/重新选择/导出/返回不受影响(仅快照链路防抖)
- 目录行双击(0.8.0):触发 onOpenDir 携带目录相对路径;双击前折叠态自动展开(toggle 残留消除);重新扫描按钮触发 onRescan 且扫描中禁用
- 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果)
- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满
- 窗口尺寸变化:resize 后按新视口高度补齐渲染窗口(stub clientHeight 模拟拉高,DOM 行数随之增加)
### 5.11 E2E 端到端(`e2e/app.spec.ts`,对 build 产物启动真实 Electron
- 启动冒烟:标题与双空态面板、空态无导出入口
- 粘贴两侧:差异渲染 + 统计徽章(主进程剪贴板链路)
- 编码链路:GBK 与 UTF-8 BOM 文件打开(编码标注 + 内容渲染)、大体积 GBK 文件经解码 worker 后台解码完整渲染(状态栏总行数断言)
- 比较选项:忽略大小写与忽略所有空白组合、字符级跨行重组判一致、字符级模式字符粒度统计徽章出现(0.10.5,+0 字 / −0 字)
- 滚动稳定性回归锚(0.7.0):真实文件(行数不等)→ 开字符级进 worker 重路径 → 滚到中部 → 切选项二次重算 → 断言 scrollTop 保持不归零、滚到底内容连续(0.6.3 回退缺陷的固化防线)
- 导航与视图:F7 / Shift+F7、仅看差异折叠、交换左右后面板互换
- 单文件视图搜索(0.9.3):粘贴两侧后 Ctrl+F 聚焦搜索框(真实键盘事件)→ 输入关键字即时计数与行内命中高亮 → Enter 跳转下一命中行(active 行号定位断言)→ Esc 清空复位计数
- 报告导出:stub 保存对话框 → 单文件 HTML 报告真实落盘且含结论与统计;Patch 标准差异真实落盘(unified diff 文件头/hunk 头/上下文与增删行断言,0.11.0);忽略大小写判等污染下 Patch 菜单项禁用(0.11.0
- 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤 / 树形显示)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留)
- 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目)
- 增量重扫(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.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 内容级对照,0.10.5 快照归组下拉交互):保存快照真实落盘(JSON 含版本与条目;保存/加载经「快照」下拉两步触发)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章);截断快照拦截(0.10.4)——手工构造 truncated: true 快照加载被拦截提示"条目不完整无法对照",不进入对照模式
- 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归)
- 关于弹框(0.10.2):点击 header ⓘ 打开弹框——版本号(app.getVersion 真实链路,0.x 形态正则)/作者/双邮箱/组织/开源地址/许可证断言;点击邮箱走主进程剪贴板并 toast;Esc 关闭
- 大快照加载(0.10.2):手工构造 11MB+ 快照 JSONcontents 塞大字符串)→ snapshot:read 专用通道加载成功("已加载快照"出现且无 10MB 拦截提示)——固化保存/加载上限不对称修复
- 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData)
- 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理)
### 5.12 folder:scan IPC 校验(`src/main/scanIpc.ts`0.9.10.10.3 异步化)
- isScanCacheEntries 形态校验:合法缓存数组(rel/status 为字符串)通过;非数组 / 含非对象 / rel 或 status 非字符串拒绝
- resolveScanArgs 缓存源选择(异步签名,loadStored 同步/异步实现均兼容):渲染端携带合法内存缓存原样传递且惰性不读盘 / 内存缓存为空数组(合法形态)同样优先(阻止注入可能过期的盘上缓存)/ 未携带与形态非法回退盘上缓存(惰性读盘恰好一次)/ 盘上缓存为 null 或空数组不注入(全量扫描)/ 异步 loadStored stub(生产 fs.promises 读盘形态)同样正确回退
- 忽略规则校验:复用 isIgnoreRulesArray(合法字符串数组与空数组传递;非数组 / 含非字符串 / 超条数整体忽略)
- 组合场景:内存缓存非法 + 忽略规则合法 + 盘上缓存有效时两者分别正确解析
- 主进程 index.ts 的 handler 壳(IPC 注册与真实读写盘)由 E2E 文件夹用例覆盖
---
## 6. 运行命令
```bash
npm test # 运行全部测试(CI / 提交前)
npm test # 运行全部单元 + 组件测试(CI / 提交前)
npm run test:watch # 监听模式,开发中用
npm run test:e2e # E2E 验收(先 build 再对产物启动真实 Electron;发布前必跑)
```
---
@@ -161,9 +284,10 @@ npm run build
## 9. 演进方向(后续迭代)
- 接入 Playwright 对打包后的 Electron 应用做端到端验收
- 对 IPC 层(open / decode / save-report)补充集成测试
- 大文件性能基准测试(虚拟滚动落地后
- 快照体验:regressed 场景基线内容保存策略(当前仅保存差异条目,基线一致的条目内容不存——需全量保存的体积代价权衡;快照内差异内容的报告导出已于 0.11.0 以"内容对照导出附对照说明"落地)
- 忽略规则:gitignore 更完整子集评估(`\` 转义、前导 `!` 组合语义等,按真实需求驱动;`?` 单字符与 `[abc]` 字符类已于 0.10.1 落地)
- 主进程 IPC 并发场景集成测试(入参校验与缓存注入逻辑已于 0.9.1 单测化:scanIpc;缓存落盘串行化于 0.9.3 修复
- mac 构建实际验证(entitlements 配置就绪但从未打包跑通)
---
+994
View File
@@ -0,0 +1,994 @@
import { test, expect } from '@playwright/test'
import { readFile } from 'fs/promises'
import { join } from 'path'
import {
launchApp,
stubOpenDialogSeq,
stubSaveDialog,
makeTmpDir,
removeTmpDir,
writeTextFixture,
pasteText,
dropFiles
} from './helpers'
/**
* DiffLens E2E 主链路验收(对 build 产物运行真实 Electron):
* 覆盖 jsdom 单测无法触达的主进程解码 / 报告写盘 / 剪贴板 IPC / 偏好持久化生命周期。
* 运行前必须先 npm run build。
*/
test.describe('启动与基础交互', () => {
test('应用启动:窗口标题与双空态面板', async () => {
const { app, window } = await launchApp()
await expect(window).toHaveTitle('DiffLens')
await expect(window.getByText('选择左侧文件')).toBeVisible()
await expect(window.getByText('选择右侧文件')).toBeVisible()
// 空态不出现导出入口
await expect(window.getByRole('button', { name: '导出报告' })).toHaveCount(0)
await app.close()
})
test('粘贴两侧文本:差异渲染与统计徽章', async () => {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'alpha\nbeta\ngamma')
await expect(window.getByRole('button', { name: '导出报告' })).toBeVisible()
await pasteText(window, 'right', 'alpha\nBETA\ngamma')
// 差异徽章与状态栏结论
await expect(window.getByText('有差异')).toBeVisible()
await expect(window.getByText('~1')).toBeVisible()
// 修改行内容渲染(词级高亮行;exact 避免大小写不敏感的重复命中)
await expect(window.getByText('beta', { exact: true })).toBeVisible()
await expect(window.getByText('BETA', { exact: true })).toBeVisible()
await app.close()
})
})
test.describe('文件打开与编码识别(主进程真实解码链路)', () => {
let dir: string
test.beforeAll(async () => {
dir = await makeTmpDir('difflens-e2e-enc-')
})
test.afterAll(async () => {
await removeTmpDir(dir)
})
test('打开 GBK 与 UTF-8 BOM 文件:编码标注与内容正确渲染', async () => {
const gbkPath = await writeTextFixture(
dir,
'gbk.txt',
'中文内容第一行\n第二行数据',
'gbk'
)
const bomPath = await writeTextFixture(dir, 'bom.txt', 'BOM 首行\nBOM 第二行', 'utf8-bom')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[gbkPath], [bomPath]])
await window.getByRole('button', { name: '打开左侧' }).click()
await expect(window.getByText('中文内容第一行')).toBeVisible()
await window.getByRole('button', { name: '打开右侧' }).click()
await expect(window.getByText('BOM 首行')).toBeVisible()
// 面板头编码标注(主进程探测结果;exact 避免文件名 gbk.txt 的子串误匹配)
await expect(window.locator('.pane-meta').filter({ hasText: 'GBK' })).toBeVisible()
await expect(window.locator('.pane-meta').filter({ hasText: 'UTF-8 (BOM)' })).toBeVisible()
await app.close()
})
test('大体积 GBK 文件:后台 worker 解码后完整渲染', async () => {
// 约 300KB(≥ 解码 worker 快路径阈值 256KB):走 worker_threads 后台解码链路
const bigLines = Array.from({ length: 15000 }, (_, i) => `中文大文件行${i}数据`).join('\n')
const bigPath = await writeTextFixture(dir, 'big-gbk.txt', bigLines, 'gbk')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[bigPath]])
await window.getByRole('button', { name: '打开左侧' }).click()
// 首行在视口内;末行在虚拟滚动视口外,以状态栏总行数断言完整性
await expect(window.getByText('中文大文件行0数据')).toBeVisible()
await expect(window.locator('.status-item').filter({ hasText: '15000 行' })).toBeVisible()
await expect(window.locator('.pane-meta').filter({ hasText: 'GBK' })).toBeVisible()
await app.close()
})
})
test.describe('比较选项端到端', () => {
test('忽略大小写与忽略所有空白组合生效', async () => {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'alpha\nbeta')
await pasteText(window, 'right', 'ALPHA\n beta')
// 默认严格对比:两处差异
await expect(window.getByText('有差异')).toBeVisible()
// 仅忽略大小写:仍有行首空白差异
await window.getByLabel('忽略大小写').check()
await expect(window.getByText('有差异')).toBeVisible()
// 再忽略所有空白:判为完全一致
await window.getByLabel('忽略所有空白').check()
await expect(window.getByText('两文件内容完全一致')).toBeVisible()
await app.close()
})
test('字符级对比:跨行重组判为一致,字符粒度统计徽章出现(0.10.5)', async () => {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'ab cd')
await pasteText(window, 'right', 'ab\ncd')
await expect(window.getByText('有差异')).toBeVisible()
await window.getByLabel('字符级对比').check()
await expect(window.getByText('两文件内容完全一致')).toBeVisible()
// 字符级模式追加字符粒度增删徽章(一致场景为 +0 字 / −0 字)
await expect(window.getByText('+0 字')).toBeVisible()
await expect(window.getByText('0 字')).toBeVisible()
await app.close()
})
test('字符级大输入:重算期间滚动位置不回弹(0.6.3 回退缺陷的回归锚)', async () => {
// fixture 严格对齐用户实测场景:真实文件打开 + 行数不等(左 950 / 右 942:改 3 + 删 20 + 插 12),
// 字符量约 4.7 万 > 重输入阈值(5000)→ 开字符级即进 worker 重路径
const dir = await makeTmpDir('difflens-e2e-scroll-')
try {
const pad = (i: number): string => String(i).padStart(4, '0')
const left = Array.from({ length: 950 }, (_, i) => `L${pad(i)}-abcdefghijklmnopqrstuvwxyz`)
const right = [...left]
right[300] = 'L0300-abcdefghijXlmnopqrstuvwxyz'
right.splice(530, 20)
right.splice(812, 0, ...Array.from({ length: 12 }, (_, k) => `N${pad(k)}-newcontent-${k}0987654321`))
const lp = await writeTextFixture(dir, 'left.txt', left.join('\n'))
const rp = await writeTextFixture(dir, 'right.txt', right.join('\n'))
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[lp], [rp]])
await window.getByRole('button', { name: '打开左侧' }).click()
await window.getByText('L0000-abcdefghijklmnopqrstuvwxyz').first().waitFor()
await window.getByRole('button', { name: '打开右侧' }).click()
await window.getByText('L0001-abcdefghijklmnopqrstuvwxyz').first().waitFor()
// 开字符级(首次进入重路径:快路径结果兜底撑住容器高度,不塌陷归零)
await window.getByLabel('字符级对比').check()
await window.getByText('有差异').first().waitFor()
// 滚到中部后二次触发重算(切选项):computing 期间位置必须保持
const scrollSel = '.pane-left .diff-scroll'
await window.evaluate((s) => {
const el = document.querySelector(s)
el.scrollTop = 5000
el.dispatchEvent(new Event('scroll'))
}, scrollSel)
expect(await window.evaluate((s) => document.querySelector(s).scrollTop, scrollSel)).toBe(5000)
await window.getByLabel('忽略大小写').check()
await window.waitForTimeout(150)
// 关键断言:重算期间 scrollTop 不被钳制归零(缺陷版本会多级下坠到 0)
const during = await window.evaluate((s) => document.querySelector(s).scrollTop, scrollSel)
expect(during).toBeGreaterThanOrEqual(4000)
// 重算完成后位置仍稳定
await window.getByText('有差异').first().waitFor()
await window.waitForTimeout(300)
expect(await window.evaluate((s) => document.querySelector(s).scrollTop, scrollSel)).toBe(5000)
// 滚到底部验证内容连续无重复渲染错乱
await window.evaluate((s) => {
const el = document.querySelector(s)
el.scrollTop = el.scrollHeight
el.dispatchEvent(new Event('scroll'))
}, scrollSel)
await window.waitForTimeout(300)
const lastRow = await window.evaluate((s) => {
const sc = document.querySelector(s)
const rows = sc.querySelectorAll('.diff-row')
return rows.length > 0 ? rows[rows.length - 1].textContent : ''
}, scrollSel)
expect(lastRow).toContain('L0949')
await app.close()
} finally {
await removeTmpDir(dir)
}
})
})
test.describe('差异导航与视图', () => {
test('F7 / Shift+F7 导航与仅看差异折叠', async () => {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'a\nX1\nb\nY1\nc')
await pasteText(window, 'right', 'a\nX2\nb\nY2\nc')
await expect(window.getByText('1 / 2')).toBeVisible()
await window.keyboard.press('F7')
await expect(window.getByText('2 / 2')).toBeVisible()
await window.keyboard.press('Shift+F7')
await expect(window.getByText('1 / 2')).toBeVisible()
// 仅看差异:未更改行折叠为提示行
await window.getByLabel('仅看差异').check()
await expect(window.getByText(/已折叠/).first()).toBeVisible()
await app.close()
})
test('单文件视图搜索:Ctrl+F 聚焦、计数跳转、行内高亮与 Esc 清空(0.9.3', async () => {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'alpha\nfind target one\ngamma\nfind target two')
await pasteText(window, 'right', 'alpha\nfind target one\ngamma\nfind target two')
await expect(window.getByText('两文件内容完全一致')).toBeVisible()
// Ctrl+F 聚焦搜索框
await window.keyboard.press('Control+f')
const input = window.getByPlaceholder('搜索内容… (Ctrl+F)')
await expect(input).toBeFocused()
// 输入关键字:即时计数与行内命中高亮
await input.fill('target')
await expect(window.locator('.search-count')).toHaveText('1 / 2')
await expect(window.locator('.search-hit').first()).toBeVisible()
// Enter 跳转下一个命中行(第一个命中行 active 定位)
await window.keyboard.press('Enter')
await expect(window.locator('.search-count')).toHaveText('2 / 2')
await expect(window.locator('.diff-row.active .ln').first()).toHaveText('4')
// Esc 清空关键字(计数复位消失)
await window.keyboard.press('Escape')
await expect(input).toHaveValue('')
await expect(window.locator('.search-count')).toHaveText('')
await app.close()
})
test('交换左右侧后面板文件互换', async () => {
const dir = await makeTmpDir('difflens-e2e-swap-')
try {
const aPath = await writeTextFixture(dir, 'a.txt', 'aaa')
const bPath = await writeTextFixture(dir, 'b.txt', 'bbb')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[aPath], [bPath]])
await window.getByRole('button', { name: '打开左侧' }).click()
await expect(window.locator('.pane-file').filter({ hasText: 'a.txt' })).toBeVisible()
await window.getByRole('button', { name: '打开右侧' }).click()
await expect(window.locator('.pane-file').filter({ hasText: 'b.txt' })).toBeVisible()
await window.getByRole('button', { name: '⇄ 交换左右' }).click()
// 面板头文件名互换(用 .pane-file 定位,避免与状态栏文件名混淆)
const paneFiles = window.locator('.pane-file')
await expect(paneFiles.nth(0)).toHaveText(/b\.txt/)
await expect(paneFiles.nth(1)).toHaveText(/a\.txt/)
await app.close()
} finally {
await removeTmpDir(dir)
}
})
})
test.describe('报告导出(主进程写盘链路)', () => {
test('导出 HTML 报告:文件落盘且包含结论', async () => {
const dir = await makeTmpDir('difflens-e2e-report-')
try {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'same\ndiff-left')
await pasteText(window, 'right', 'same\ndiff-right')
const savePath = join(dir, 'report.html')
await stubSaveDialog(app, savePath)
await window.getByRole('button', { name: '导出报告' }).click()
await window.getByRole('button', { name: 'HTML 报告' }).click()
await expect(window.getByText(/报告已保存/)).toBeVisible()
const html = await readFile(savePath, 'utf-8')
expect(html).toContain('<!doctype html>')
expect(html).toContain('共 1 处不同:修改 1 行')
// 统计卡与未更改行渲染(diff-left 被词级高亮拆分 span,不作整串断言)
expect(html).toContain('~1')
expect(html).toContain('same')
await app.close()
} finally {
await removeTmpDir(dir)
}
})
test('导出 Patch 标准差异:unified diff 落盘且含 hunk 头与增删行(0.11.0', async () => {
const dir = await makeTmpDir('difflens-e2e-patch-')
try {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'ctx1\nold line\nctx2')
await pasteText(window, 'right', 'ctx1\nnew line\nctx2')
const savePath = join(dir, 'changes.patch')
await stubSaveDialog(app, savePath)
await window.getByRole('button', { name: '导出报告' }).click()
await window.getByRole('button', { name: 'Patch 标准差异' }).click()
await expect(window.getByText(/报告已保存/)).toBeVisible()
const patch = await readFile(savePath, 'utf-8')
// 标准 unified diff 结构:文件头 + hunk 头(两侧各 3 行)+ 上下文/增删行
expect(patch).toMatch(/^--- a\//)
expect(patch).toContain('+++ b/')
expect(patch).toContain('@@ -1,3 +1,3 @@')
expect(patch.split('\n')).toContain(' ctx1')
expect(patch.split('\n')).toContain('-old line')
expect(patch.split('\n')).toContain('+new line')
expect(patch.split('\n')).toContain(' ctx2')
await app.close()
} finally {
await removeTmpDir(dir)
}
})
test('开忽略大小写后 Patch 菜单项禁用(判等污染守卫,0.11.0)', async () => {
const { app, window } = await launchApp()
await pasteText(window, 'left', 'same\nBeta')
await pasteText(window, 'right', 'same\nBETA')
await window.getByLabel('忽略大小写').check()
await expect(window.getByText('两文件内容完全一致')).toBeVisible()
await window.getByRole('button', { name: '导出报告' }).click()
// 判等选项把 Beta/BETA 判为相同:unchanged 行两侧文本不同,Patch 无法表达
const item = window.getByRole('button', { name: 'Patch 标准差异' })
await expect(item).toBeDisabled()
await app.close()
})
})
test.describe('文件夹对比(主进程真实扫描链路)', () => {
test('选择两个文件夹:状态判定、过滤与双击进入单文件对比', async () => {
const root = await makeTmpDir('difflens-e2e-folder-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(join(leftDir, 'sub'), { recursive: true })
await mkdir(join(rightDir, 'sub'), { recursive: true })
await wf(join(leftDir, 'same.txt'), 'identical')
await wf(join(rightDir, 'same.txt'), 'identical')
await wf(join(leftDir, 'diff.txt'), 'left content\nsame tail')
await wf(join(rightDir, 'diff.txt'), 'right content\nsame tail')
await wf(join(leftDir, 'only-left.txt'), 'L')
await wf(join(leftDir, 'sub', 'nested.txt'), 'nested L')
await wf(join(rightDir, 'sub', 'nested.txt'), 'nested R')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
await window.getByRole('button', { name: '对比文件夹' }).click()
// 文件夹模式:路径卡片与统计徽章
await expect(window.getByText('相同 1')).toBeVisible()
await expect(window.getByText('不同 2')).toBeVisible()
await expect(window.getByText('仅左 1')).toBeVisible()
// 默认仅看差异:same.txt 被过滤,差异条目可见(树形视图显示文件尾段名)
await expect(window.getByText('diff.txt')).toBeVisible()
await expect(window.getByText('sub')).toBeVisible()
await expect(window.getByText('nested.txt')).toBeVisible()
await expect(window.getByText('same.txt')).toHaveCount(0)
// 双击差异条目进入单文件对比
await window.getByText('diff.txt').dblclick()
await expect(window.getByText('left content')).toBeVisible()
await expect(window.getByText('right content')).toBeVisible()
await expect(window.getByText('有差异')).toBeVisible()
await expect(window.getByRole('button', { name: '← 返回文件夹对比' })).toBeVisible()
// 返回文件夹列表(扫描结果保留)
await window.getByRole('button', { name: '← 返回文件夹对比' }).click()
await expect(window.getByText('相同 1')).toBeVisible()
await expect(window.getByText('diff.txt')).toBeVisible()
await app.close()
} finally {
await removeTmpDir(root)
}
})
test('增量重扫:修改文件后点重新扫描,状态正确更新', async () => {
const root = await makeTmpDir('difflens-e2e-rescan-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(leftDir, { recursive: true })
await mkdir(rightDir, { recursive: true })
await wf(join(leftDir, 'same.txt'), 'identical')
await wf(join(rightDir, 'same.txt'), 'identical')
await wf(join(leftDir, 'diff.txt'), 'left v1')
await wf(join(rightDir, 'diff.txt'), 'right v1')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
await window.getByRole('button', { name: '对比文件夹' }).click()
await expect(window.getByText('相同 1')).toBeVisible()
await expect(window.getByText('不同 1')).toBeVisible()
// 修改右侧 same.txt(真实 mtime 变化)→ 重新扫描(增量)→ 判定更新
await wf(join(rightDir, 'same.txt'), 'identical changed')
await window.getByRole('button', { name: '重新扫描' }).click()
await expect(window.getByText('相同 0')).toBeVisible()
await expect(window.getByText('不同 2')).toBeVisible()
// 改回一致 → 再扫 → 恢复
await wf(join(rightDir, 'same.txt'), 'identical')
await window.getByRole('button', { name: '重新扫描' }).click()
await expect(window.getByText('相同 1')).toBeVisible()
await expect(window.getByText('不同 1')).toBeVisible()
await app.close()
} finally {
await removeTmpDir(root)
}
})
test('语义判等、树形折叠、搜索与文件夹报告导出', async () => {
const root = await makeTmpDir('difflens-e2e-folder2-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(join(leftDir, 'sub'), { recursive: true })
await mkdir(join(rightDir, 'sub'), { recursive: true })
// 仅换行符差异(左 CRLF 右 LF)→ 语义同
await wf(join(leftDir, 'crlf.txt'), 'line1\r\nline2\r\n')
await wf(join(rightDir, 'crlf.txt'), 'line1\nline2\n')
// 实质内容差异 → 不同
await wf(join(leftDir, 'real.txt'), 'value = 1\n')
await wf(join(rightDir, 'real.txt'), 'value = 2\n')
// 完全一致(子目录)
await wf(join(leftDir, 'sub', 'same.txt'), 'identical')
await wf(join(rightDir, 'sub', 'same.txt'), 'identical')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
await window.getByRole('button', { name: '对比文件夹' }).click()
// 语义判等默认开:crlf.txt 计为语义同(统计徽章)
await expect(window.getByText('语义同 1')).toBeVisible()
await expect(window.getByText('不同 1')).toBeVisible()
// 默认仅看差异:语义同被过滤,只剩 real.txt
await expect(window.getByText('real.txt')).toBeVisible()
await expect(window.getByText('crlf.txt')).toHaveCount(0)
// 关闭仅看差异:树形展示(sub 目录行 + 语义同条目标注)
await window.getByLabel('仅看差异').uncheck()
await expect(window.getByText('crlf.txt')).toBeVisible()
await expect(window.getByText('sub')).toBeVisible()
await expect(window.getByText('same.txt')).toBeVisible()
// 目录折叠/展开
await window.getByText('sub').click()
await expect(window.getByText('same.txt')).toHaveCount(0)
await window.getByText('sub').click()
await expect(window.getByText('same.txt')).toBeVisible()
// 搜索过滤
await window.getByPlaceholder('搜索文件名…').fill('crlf')
await expect(window.getByText('crlf.txt')).toBeVisible()
await expect(window.getByText('real.txt')).toHaveCount(0)
await window.getByPlaceholder('搜索文件名…').fill('')
// 导出文件夹报告(真实写盘)
const savePath = join(root, 'folder-report.txt')
await stubSaveDialog(app, savePath)
await window.getByRole('button', { name: '导出报告' }).click()
await window.getByRole('button', { name: '纯文本' }).click()
await expect(window.getByText(/报告已保存/)).toBeVisible()
const txt = await readFile(savePath, 'utf-8')
expect(txt).toContain('DiffLens 文件夹对比报告')
expect(txt).toContain('判定口径:语义判等开启')
expect(txt).toContain('共 1 个文件存在差异')
expect(txt).toContain('语义同')
await app.close()
} finally {
await removeTmpDir(root)
}
})
test('忽略规则:目录整树剪枝不参与对比(应用后重扫)', async () => {
const root = await makeTmpDir('difflens-e2e-ignore-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(join(leftDir, 'node_modules', 'pkg'), { recursive: true })
await mkdir(join(rightDir, 'node_modules', 'pkg'), { recursive: true })
await wf(join(leftDir, 'keep.txt'), 'identical')
await wf(join(rightDir, 'keep.txt'), 'identical')
await wf(join(leftDir, 'node_modules', 'pkg', 'x.js'), 'left')
await wf(join(rightDir, 'node_modules', 'pkg', 'x.js'), 'right')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
await window.getByRole('button', { name: '对比文件夹' }).click()
// 无规则:node_modules 内差异文件参与对比
await expect(window.getByText('不同 1')).toBeVisible()
await expect(window.getByText('x.js')).toBeVisible()
// 应用忽略规则:node_modules/ 整树剪枝
await window.getByRole('button', { name: '忽略规则' }).click()
await window.getByPlaceholder(/每行一条/).fill('node_modules/')
await window.getByText('应用并重扫').click()
// 重扫完成:无差异(keep.txt 一致,node_modules 被忽略)
await expect(window.getByText('不同 0')).toBeVisible()
await expect(window.getByText('没有差异文件(两侧内容全部一致)')).toBeVisible()
await expect(window.getByText('x.js')).toHaveCount(0)
await app.close()
} finally {
await removeTmpDir(root)
}
})
test('忽略规则高级 glob:** 跨段剪枝、否定规则救回与无效规则提示', async () => {
const root = await makeTmpDir('difflens-e2e-glob-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
// build 下深层 temp 目录(** 跨段目标)+ 保留文件 + log 类差异文件
for (const d of [leftDir, rightDir]) {
await mkdir(join(d, 'build', 'a', 'temp'), { recursive: true })
await mkdir(join(d, 'build', 'b', 'deep', 'temp'), { recursive: true })
await wf(join(d, 'build', 'a', 'temp', 't1.txt'), 'L')
await wf(join(d, 'build', 'b', 'deep', 'temp', 't2.txt'), 'L')
await wf(join(d, 'build', 'keep.txt'), 'L')
await wf(join(d, 'app.log'), 'L')
await wf(join(d, 'keep.log'), 'L')
}
await wf(join(rightDir, 'build', 'a', 'temp', 't1.txt'), 'R')
await wf(join(rightDir, 'build', 'b', 'deep', 'temp', 't2.txt'), 'R')
await wf(join(rightDir, 'build', 'keep.txt'), 'R')
await wf(join(rightDir, 'app.log'), 'R')
await wf(join(rightDir, 'keep.log'), 'R')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
await window.getByRole('button', { name: '对比文件夹' }).click()
await expect(window.getByText('不同 5')).toBeVisible()
await window.getByRole('button', { name: '忽略规则' }).click()
// 先输入含无效规则的草稿:提示出现(不再静默丢弃)
await window.getByPlaceholder(/每行一条/).fill('a**b')
await expect(window.getByText(/将被忽略的无效规则:a\*\*b/)).toBeVisible()
// 替换为正式规则:build 下任意深度 temp 剪枝 + *.log 忽略 + keep.log 否定救回
await window
.getByPlaceholder(/每行一条/)
.fill('build/**/temp\n*.log\n!keep.log')
await window.getByText('应用并重扫').click()
// t1/t2 被剪(** 跨段)、app.log 被忽略、keep.log 被否定救回 → 仅剩 keep.txt 与 keep.log 差异
await expect(window.getByText('不同 2')).toBeVisible()
await expect(window.getByText('t1.txt')).toHaveCount(0)
await expect(window.getByText('keep.log')).toBeVisible()
await app.close()
} finally {
await removeTmpDir(root)
}
})
test('扫描进度反馈:大目录比对期间遮罩显示已比对进度', async () => {
const root = await makeTmpDir('difflens-e2e-progress-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(leftDir, { recursive: true })
await mkdir(rightDir, { recursive: true })
// 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}`)
}
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+ \/ 4000/.test(
(await window.evaluate(
() => document.querySelector('.computing-overlay')?.textContent ?? ''
)) ?? ''
),
{ intervals: [50, 100] }
)
.toBe(true)
// 扫描完成:全量判定与遮罩消失
await expect(window.getByText('不同 4000')).toBeVisible()
await expect(window.locator('.computing-overlay')).toHaveCount(0)
await app.close()
} finally {
await removeTmpDir(root)
}
})
test('增量缓存跨会话持久化:落盘文件存在且重启后重扫正确', async () => {
const root = await makeTmpDir('difflens-e2e-cachestore-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(leftDir, { recursive: true })
await mkdir(rightDir, { recursive: true })
await wf(join(leftDir, 'same.txt'), 'identical')
await wf(join(rightDir, 'same.txt'), 'identical')
await wf(join(leftDir, 'diff.txt'), 'left v1')
await wf(join(rightDir, 'diff.txt'), 'right v1')
// 第一实例:扫描后缓存异步落盘 userData/scan-cache.json
const first = await launchApp()
await stubOpenDialogSeq(first.app, [[leftDir], [rightDir]])
await first.window.getByRole('button', { name: '对比文件夹' }).click()
await expect(first.window.getByText('相同 1')).toBeVisible()
const cachePath = await first.app.evaluate(({ app }) => app.getPath('userData')).then(
(userData) => join(userData, 'scan-cache.json')
)
await first.app.close()
// 落盘文件存在且含本目录对的缓存条目(等待异步写盘完成)
let raw = ''
for (let i = 0; i < 20; i++) {
raw = await readFile(cachePath, 'utf-8').catch(() => '')
if (raw.includes('same.txt')) break
await new Promise((r) => setTimeout(r, 200))
}
expect(raw).toContain('same.txt')
expect(raw).toContain('diff.txt')
// 结构为合法存储(版本 + 目录对键)
const stored = JSON.parse(raw)
expect(stored.version).toBe(1)
expect(Object.keys(stored.pairs).length).toBeGreaterThanOrEqual(1)
// 第二实例(同 userData,渲染端无内存缓存):主进程注入盘上缓存,重扫结果正确
const second = await launchApp({ fresh: false })
await stubOpenDialogSeq(second.app, [[leftDir], [rightDir]])
await second.window.getByRole('button', { name: '对比文件夹' }).click()
await expect(second.window.getByText('相同 1')).toBeVisible()
await expect(second.window.getByText('不同 1')).toBeVisible()
await second.app.close()
} finally {
await removeTmpDir(root)
}
})
test('快照基线:保存后修复差异并新增文件,加载快照对照变迁', async () => {
const root = await makeTmpDir('difflens-e2e-snapshot-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(leftDir, { recursive: true })
await mkdir(rightDir, { recursive: true })
await wf(join(leftDir, 'same.txt'), 'identical')
await wf(join(rightDir, 'same.txt'), 'identical')
await wf(join(leftDir, 'diff.txt'), 'left v1')
await wf(join(rightDir, 'diff.txt'), 'right v1')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
await window.getByRole('button', { name: '对比文件夹' }).click()
await expect(window.getByText('相同 1')).toBeVisible()
await expect(window.getByText('不同 1')).toBeVisible()
// 保存快照(真实写盘):v2 结构含差异条目双侧内容(0.10.0 内容级对照);
// 快照归组下拉(0.10.5 布局重构):先开「快照」下拉再点菜单项
const snapPath = join(root, 'baseline.json')
await stubSaveDialog(app, snapPath)
await window.getByRole('button', { name: '快照' }).click()
await window.getByText('保存快照…').click()
await expect(window.getByText(/快照已保存/)).toBeVisible()
const snapRaw = await readFile(snapPath, 'utf-8')
expect(snapRaw).toContain('"version": 2')
expect(snapRaw).toContain('diff.txt')
expect(snapRaw).toContain('left v1')
expect(snapRaw).toContain('right v1')
// 修复 diff.txt(两侧一致)+ 新增 new.txt → 重扫
await wf(join(rightDir, 'diff.txt'), 'left v1')
await wf(join(leftDir, 'new.txt'), 'n')
await wf(join(rightDir, 'new.txt'), 'n')
await window.getByRole('button', { name: '重新扫描' }).click()
await expect(window.getByText('相同 3')).toBeVisible()
// 加载快照对照:diff.txt 已修复、new.txt 新文件
await stubOpenDialogSeq(app, [[snapPath]])
await window.getByRole('button', { name: '快照' }).click()
await window.getByText('加载快照…').click()
await expect(window.getByText(/已加载快照/)).toBeVisible()
await expect(window.getByText('已修复 1')).toBeVisible()
await expect(window.getByText('新文件 1')).toBeVisible()
// 对照条目行(平铺)与变迁标注可见
await expect(window.getByText('diff.txt')).toBeVisible()
await expect(window.getByText('new.txt')).toBeVisible()
// 对照模式导出报告:条目表为变迁对照表(真实写盘断言变迁明细)
const cmpPath = join(root, 'compare-report.txt')
await stubSaveDialog(app, cmpPath)
await window.getByRole('button', { name: '导出报告' }).click()
await window.getByRole('button', { name: '纯文本' }).click()
await expect(window.getByText(/报告已保存/)).toBeVisible()
const cmp = await readFile(cmpPath, 'utf-8')
expect(cmp).toContain('快照对照:修复 1')
expect(cmp).toContain('已修复')
expect(cmp).toContain('新文件')
expect(cmp).toContain('保持一致')
// 退出对照恢复
await window.getByRole('button', { name: '退出对照' }).click()
await expect(window.getByText('相同 3')).toBeVisible()
await app.close()
} finally {
await removeTmpDir(root)
}
})
test('快照内容级对照:保存含内容快照 → 修改文件 → 对照双击查看基线 vs 当前', async () => {
const root = await makeTmpDir('difflens-e2e-snapcontent-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(leftDir, { recursive: true })
await mkdir(rightDir, { recursive: true })
await wf(join(leftDir, 'same.txt'), 'identical')
await wf(join(rightDir, 'same.txt'), 'identical')
await wf(join(leftDir, 'diff.txt'), 'snapshot baseline value A')
await wf(join(rightDir, 'diff.txt'), 'snapshot baseline value B')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[leftDir], [rightDir]])
await window.getByRole('button', { name: '对比文件夹' }).click()
await expect(window.getByText('相同 1')).toBeVisible()
await expect(window.getByText('不同 1')).toBeVisible()
// 保存快照(v2):差异条目双侧内容写入 JSON
const snapPath = join(root, 'baseline.json')
await stubSaveDialog(app, snapPath)
await window.getByRole('button', { name: '快照' }).click()
await window.getByText('保存快照…').click()
await expect(window.getByText(/快照已保存/)).toBeVisible()
const snapRaw = await readFile(snapPath, 'utf-8')
expect(snapRaw).toContain('"version": 2')
expect(snapRaw).toContain('snapshot baseline value A')
expect(snapRaw).toContain('snapshot baseline value B')
// 修改左侧 diff.txt → 重扫(相对快照基线出现内容变化)
await wf(join(leftDir, 'diff.txt'), 'current changed value A')
await window.getByRole('button', { name: '重新扫描' }).click()
await expect(window.getByText('不同 1')).toBeVisible()
// 加载快照对照 → 双击 diff.txt 进入内容对照(左=快照基线,右=当前左侧文件)
await stubOpenDialogSeq(app, [[snapPath]])
await window.getByRole('button', { name: '快照' }).click()
await window.getByText('加载快照…').click()
await expect(window.getByText(/已加载快照/)).toBeVisible()
await window.getByText('diff.txt').dblclick()
await expect(
window.locator('.pane-file').filter({ hasText: '快照 · diff.txt' })
).toBeVisible()
// 左面板渲染快照基线旧词、右面板渲染当前新词(词级高亮拆分为独立段)
await expect(window.getByText('snapshot', { exact: true })).toBeVisible()
await expect(window.getByText('current', { exact: true })).toBeVisible()
await expect(window.getByText('有差异')).toBeVisible()
await app.close()
} finally {
await removeTmpDir(root)
}
})
test('加载自身截断的快照被拦截(手工构造快照的对称防呆,0.10.4)', async () => {
const root = await makeTmpDir('difflens-e2e-snaptrunc-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(leftDir, { recursive: true })
await mkdir(rightDir, { recursive: true })
await wf(join(leftDir, 'same.txt'), 'identical')
await wf(join(rightDir, 'same.txt'), 'identical')
// 手工构造截断快照(自家保存链路在截断时已拦截,此形态仅来自外部):
// 截断快照缺失的文件会被全部误报为"新文件",加载时须拦截
const snap = {
version: 2,
leftDir,
rightDir,
semantic: true,
truncated: true,
total: 9999,
entries: [{ rel: 'same.txt', status: 'same', leftSize: 9, rightSize: 9 }],
savedAt: Date.now()
}
const snapPath = join(root, 'truncated-snapshot.json')
await wf(snapPath, JSON.stringify(snap), 'utf-8')
const { app, window } = await launchApp()
// 对话框调用序:folder:pick 左 → folder:pick 右 → snapshot:read 快照
await stubOpenDialogSeq(app, [[leftDir], [rightDir], [snapPath]])
await window.getByRole('button', { name: '对比文件夹' }).click()
await expect(window.getByText('相同 1')).toBeVisible()
await window.getByRole('button', { name: '快照' }).click()
await window.getByText('加载快照…').click()
// 拦截提示出现,不进入对照模式
await expect(window.getByText(/条目不完整无法对照/)).toBeVisible()
await expect(window.getByText(/已加载快照/)).toHaveCount(0)
await expect(window.getByRole('button', { name: '退出对照' })).toHaveCount(0)
await app.close()
} finally {
await removeTmpDir(root)
}
})
})
test.describe('拖拽导入(真实 Chromium DataTransfer 链路)', () => {
test('空面板拖入文件:内容渲染与文件名显示', async () => {
const { app, window } = await launchApp()
await dropFiles(window, '.pane-empty', [
{ name: 'dropped-left.txt', content: 'drag line 1\ndrag line 2' }
])
// 文件名出现在面板头,内容渲染(file.arrayBuffer → decodeBuffer IPC → 编码探测全链路)
await expect(window.locator('.pane-file').filter({ hasText: 'dropped-left.txt' })).toBeVisible()
await expect(window.getByText('drag line 1').first()).toBeVisible()
await app.close()
})
test('双侧拖入形成对比:差异渲染', async () => {
const { app, window } = await launchApp()
await dropFiles(window, '.pane-empty', [
{ name: 'a.txt', content: 'same\nleft-unique' }
])
// 左侧装载后进入对比视图:右侧为 DiffView 面板(.pane-right),不再是空态
await dropFiles(window, '.pane-right', [
{ name: 'b.txt', content: 'same\nright-unique' }
])
await expect(window.getByText('有差异')).toBeVisible()
await expect(window.getByText('left-unique').first()).toBeVisible()
await expect(window.getByText('right-unique').first()).toBeVisible()
await app.close()
})
test('拖入非文本扩展名被拒绝并提示', async () => {
const { app, window } = await launchApp()
await dropFiles(window, '.pane-empty', [
{ name: 'image.png', content: 'fake-binary', type: 'image/png' }
])
await expect(window.getByText(/仅支持文本文件:image\.png/)).toBeVisible()
// 保持空态(无导出入口)
await expect(window.getByRole('button', { name: '导出报告' })).toHaveCount(0)
await app.close()
})
test('多文件拖入提示已取第一个并加载首个文件', async () => {
const { app, window } = await launchApp()
await dropFiles(window, '.pane-empty', [
{ name: 'first.txt', content: 'first content' },
{ name: 'second.txt', content: 'second content' }
])
await expect(window.getByText(/一次只能导入一个文件/)).toBeVisible()
await expect(
window.locator('.pane-file').filter({ hasText: 'first.txt' })
).toBeVisible()
await app.close()
})
})
test.describe('偏好持久化(跨实例重启)', () => {
test('重启后恢复比较选项与视图开关', async () => {
// 第一实例 fresh 清掉历史残留,建立干净基线后再写入偏好
const first = await launchApp({ fresh: true })
await pasteText(first.window, 'left', 'alpha')
await first.window.getByLabel('忽略大小写').check()
await first.window.getByLabel('仅看差异').check()
await expect(first.window.getByLabel('忽略大小写')).toBeChecked()
// 等 localStorage 写回完成再关闭
await first.window.waitForTimeout(500)
await first.app.close()
// 同一 userData 下重启(不清理):偏好应恢复
const second = await launchApp({ fresh: false })
await pasteText(second.window, 'left', 'alpha')
await expect(second.window.getByLabel('忽略大小写')).toBeChecked()
await expect(second.window.getByLabel('仅看差异')).toBeChecked()
await second.app.close()
})
})
test.describe('关于弹框(0.10.2', () => {
test('点击关于按钮展示应用信息,点击邮箱复制,Esc 关闭', async () => {
const { app, window } = await launchApp()
await window.getByTitle('关于 DiffLens').click()
// 版本号来自主进程 app.getVersion()package.json 为唯一来源,0.x 形态)
await expect(window.locator('.about-version')).toHaveText(/^版本 0\.\d+\.\d+$/)
await expect(window.locator('.about-modal')).toContainText('DiffLens')
await expect(window.locator('.about-modal')).toContainText('thzxx')
await expect(window.locator('.about-modal')).toContainText('stinanimz@gmail.com')
await expect(window.locator('.about-modal')).toContainText('1440196015@qq.com')
await expect(window.locator('.about-modal')).toContainText('MetonaTeam')
await expect(window.locator('.about-modal')).toContainText('https://git.metona.cn/MetonaTeam/DiffLens')
await expect(window.locator('.about-modal')).toContainText('MIT License')
// 点击邮箱复制到剪贴板(主进程剪贴板链路)+ toast 反馈
await window.getByText('stinanimz@gmail.com').click()
await expect(window.getByText(/邮箱已复制到剪贴板/)).toBeVisible()
// Esc 关闭弹框
await window.keyboard.press('Escape')
await expect(window.locator('.about-modal')).toHaveCount(0)
await app.close()
})
})
test.describe('大快照加载(0.10.2 专用读取通道)', () => {
test('超过 10MB 的快照可正常加载对照(通用文件上限不再拦截自家快照)', async () => {
const root = await makeTmpDir('difflens-e2e-bigsnap-')
try {
const leftDir = join(root, 'left')
const rightDir = join(root, 'right')
const { mkdir, writeFile: wf } = await import('fs/promises')
await mkdir(leftDir, { recursive: true })
await mkdir(rightDir, { recursive: true })
await wf(join(leftDir, 'same.txt'), 'identical')
await wf(join(rightDir, 'same.txt'), 'identical')
// 手工构造 11MB+ 快照(contents 塞大字符串):旧通道 file:open 的 10MB 上限会拦截
const snap = {
version: 2,
leftDir,
rightDir,
semantic: true,
truncated: false,
total: 1,
entries: [
{ rel: 'same.txt', status: 'same', leftSize: 9, rightSize: 9 }
],
savedAt: Date.now(),
contents: { 'same.txt': { left: 'x'.repeat(11 * 1024 * 1024), right: null } }
}
const snapPath = join(root, 'big-snapshot.json')
await wf(snapPath, JSON.stringify(snap), 'utf-8')
const { app, window } = await launchApp()
// 对话框调用序:folder:pick 左 → folder:pick 右 → snapshot:read 快照
await stubOpenDialogSeq(app, [[leftDir], [rightDir], [snapPath]])
await window.getByRole('button', { name: '对比文件夹' }).click()
await expect(window.getByText('相同 1')).toBeVisible()
await window.getByRole('button', { name: '快照' }).click()
await window.getByText('加载快照…').click()
// 专用通道(128MB 上限)加载成功:出现对照 toast,而非"文件超过 10MB"拦截
await expect(window.getByText(/已加载快照/)).toBeVisible()
await expect(window.getByText(/文件超过 10MB/)).toHaveCount(0)
await app.close()
} finally {
await removeTmpDir(root)
}
})
})
test.describe('大文件性能基准(耗时上限断言)', () => {
// 口径说明:万级行 diff 在 worker 后台实测为秒级;上限按慢机约 4 倍余量放宽
// 防 CI flaky(用例超时 60s 兜底)。断言失败即性能回归,需排查引擎/IO 退化。
test('5 万行多差异文件:后台计算完成在耗时上限内', async () => {
const dir = await makeTmpDir('difflens-e2e-perf-diff-')
try {
const n = 50000
const pad = (i: number): string => String(i).padStart(5, '0')
const left = Array.from({ length: n }, (_, i) => `L${pad(i)}-${'x'.repeat(30)}`)
const right = [...left]
// 20 处分散修改(每 2500 行一处)
for (let i = 0; i < n; i += 2500) right[i] = `R${pad(i)}-${'y'.repeat(30)}`
const lp = await writeTextFixture(dir, 'big-left.txt', left.join('\n'))
const rp = await writeTextFixture(dir, 'big-right.txt', right.join('\n'))
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[lp], [rp]])
await window.getByRole('button', { name: '打开左侧' }).click()
await window.getByText('L00000-').first().waitFor()
// 计时:打开右侧 → worker 重路径计算完成(有差异徽章出现)
const t0 = Date.now()
await window.getByRole('button', { name: '打开右侧' }).click()
await window.getByText('有差异').first().waitFor()
const elapsed = Date.now() - t0
expect(elapsed).toBeLessThan(15000)
// 完整性:双侧 5 万行均已加载
await expect(window.locator('.status-item').filter({ hasText: '50000 行' })).toHaveCount(2)
await app.close()
} finally {
await removeTmpDir(dir)
}
})
test('大体积 GBK 文件:worker 后台解码渲染在耗时上限内', async () => {
const dir = await makeTmpDir('difflens-e2e-perf-gbk-')
try {
// 约 300KB(≥ 解码 worker 快路径阈值 256KB),15000 行
const bigLines = Array.from({ length: 15000 }, (_, i) => `中文大文件行${i}数据`).join('\n')
const bigPath = await writeTextFixture(dir, 'big-gbk.txt', bigLines, 'gbk')
const { app, window } = await launchApp()
await stubOpenDialogSeq(app, [[bigPath]])
const t0 = Date.now()
await window.getByRole('button', { name: '打开左侧' }).click()
// 完整渲染以状态栏总行数为准(末行在虚拟滚动视口外)
await expect(
window.locator('.status-item').filter({ hasText: '15000 行' })
).toBeVisible()
const elapsed = Date.now() - t0
expect(elapsed).toBeLessThan(10000)
await expect(window.getByText('中文大文件行0数据')).toBeVisible()
await app.close()
} finally {
await removeTmpDir(dir)
}
})
})
+122
View File
@@ -0,0 +1,122 @@
import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test'
import { mkdtemp, writeFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import iconv from 'iconv-lite'
/**
* E2E 辅助:启动构建产物(out/)对应的真实 Electron 应用,
* 以及文件对话框 stub 与临时 fixture 生成。
* Electron 模式复用项目自带 Electron 二进制,无需下载 Playwright 浏览器。
*/
export interface LaunchedApp {
app: ElectronApplication
window: Page
}
/**
* 启动应用并等待首个窗口就绪。
* fresh(默认 true):清除上一轮测试残留的偏好持久化(localStorage 跨实例共享 userData
* 并重载页面,保证以默认选项启动;偏好持久化用例的验证实例显式传 false。
*/
export async function launchApp(options: { fresh?: boolean } = {}): Promise<LaunchedApp> {
const { fresh = true } = options
const app = await electron.launch({ args: ['.'] })
const window = await app.firstWindow()
await window.waitForLoadState('domcontentloaded')
if (fresh) {
await window.evaluate(() => window.localStorage.clear())
await window.reload()
await window.waitForLoadState('domcontentloaded')
}
return { app, window }
}
/**
* 主进程 stubshowOpenDialog 按调用序返回指定路径(每次调用取该组第一个文件)。
* 闭包计数状态保留在主进程,覆盖「打开左侧 → 打开右侧」的连续调用。
*/
export async function stubOpenDialogSeq(app: ElectronApplication, seqPaths: string[][]): Promise<void> {
await app.evaluate(({ dialog }, seq: string[][]) => {
let i = 0
dialog.showOpenDialog = async () => {
const paths = seq[Math.min(i, seq.length - 1)] ?? []
i++
return { canceled: false, filePaths: paths }
}
}, seqPaths)
}
/** 主进程 stubshowSaveDialog 固定返回指定保存路径 */
export async function stubSaveDialog(app: ElectronApplication, savePath: string): Promise<void> {
await app.evaluate(({ dialog }, p: string) => {
dialog.showSaveDialog = async () => ({ canceled: false, filePath: p })
}, savePath)
}
/** 创建临时目录(存放测试 fixture / 导出产物) */
export async function makeTmpDir(prefix = 'difflens-e2e-'): Promise<string> {
return mkdtemp(join(tmpdir(), prefix))
}
/** 清理临时目录(递归、不存在时忽略) */
export async function removeTmpDir(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true })
}
/**
* 写入文本 fixtureencoding 为 'utf8'(可带 bom)或 'gbk'iconv-lite 编码)。
* 返回文件绝对路径。
*/
export async function writeTextFixture(
dir: string,
name: string,
content: string,
encoding: 'utf8' | 'utf8-bom' | 'gbk' = 'utf8'
): Promise<string> {
const path = join(dir, name)
if (encoding === 'gbk') {
await writeFile(path, iconv.encode(content, 'gbk'))
} else if (encoding === 'utf8-bom') {
await writeFile(path, '\ufeff' + content, 'utf8')
} else {
await writeFile(path, content, 'utf8')
}
return path
}
/** 通过「粘贴文本」入口向指定侧粘贴内容(覆盖粘贴弹窗全链路) */
export async function pasteText(window: Page, side: 'left' | 'right', text: string): Promise<void> {
await window.getByRole('button', { name: '粘贴文本' }).click()
await window.getByRole('button', { name: side === 'left' ? '粘贴到左侧' : '粘贴到右侧' }).click()
await window.getByPlaceholder(/粘贴或输入/).fill(text)
await window.getByRole('button', { name: '开始对比' }).click()
}
/**
* 向指定选择器元素派发合成拖放(真实 Chromium 支持 DataTransfer/DragEvent 构造)。
* 覆盖渲染进程拖拽导入全链路:dropPane 走 file.arrayBuffer → decodeBuffer IPC → 编码探测。
* 先派发 dragoverpreventDefault 激活 drop 目标)再派发 drop。
*/
export async function dropFiles(
window: Page,
selector: string,
files: { name: string; content: string; type?: string }[]
): Promise<void> {
await window.evaluate(
({ sel, specs }) => {
const el = document.querySelector(sel)
if (!el) throw new Error(`dropFiles: selector not found: ${sel}`)
const dt = new DataTransfer()
for (const f of specs) {
dt.items.add(new File([f.content], f.name, { type: f.type ?? 'text/plain' }))
}
el.dispatchEvent(
new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt })
)
el.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt }))
},
{ sel: selector, specs: files }
)
}
+7 -1
View File
@@ -14,15 +14,21 @@ 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/**
# 解码 worker 脚本及其依赖 chunkworker_threads 无法加载 asar 内脚本
# Electron 的 asar 补丁不覆盖 worker 线程),主进程代码一并 unpack 保证依赖完整
- out/main/**
win:
executableName: DiffLens
+15 -2
View File
@@ -4,9 +4,22 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
// iconv-lite 打包进产物:解码 worker 随 asarUnpack 加载,unpacked 路径下无法
// 解析 asar 内的 node_modulesElectron 的 asar 补丁不覆盖 worker 线程)
plugins: [externalizeDepsPlugin({ exclude: ['iconv-lite'] })],
build: {
sourcemap: false
sourcemap: false,
rollupOptions: {
// 双入口:主进程 + 解码 workerworker_threads 无法加载 asar 内脚本,需单独产物)
input: {
index: resolve('src/main/index.ts'),
decodeWorker: resolve('src/main/decodeWorker.ts')
},
output: {
entryFileNames: '[name].js',
format: 'cjs'
}
}
}
},
preload: {
+80 -16
View File
@@ -1,12 +1,12 @@
{
"name": "DiffLens",
"version": "0.2.5",
"version": "0.10.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "DiffLens",
"version": "0.2.5",
"version": "0.10.4",
"license": "MIT",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
@@ -18,6 +18,7 @@
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@playwright/test": "^1.62.1",
"@testing-library/jest-dom": "^6.10.0",
"@testing-library/react": "^16.3.2",
"@types/diff": "^7.0.0",
@@ -1720,6 +1721,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -3029,9 +3046,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.14",
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
"version": "2.11.15",
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
"integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3471,7 +3488,7 @@
}
},
"node_modules/chromium-pickle-js": {
"version": "0.2.2",
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz",
"integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==",
"dev": true,
@@ -4229,7 +4246,7 @@
}
},
"node_modules/eastasianwidth": {
"version": "0.2.2",
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
@@ -4443,9 +4460,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.408",
"resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.408.tgz",
"integrity": "sha512-SLoprcYpJ/OH2v2ps0+N5biv9H4/KBT3+YmmDew64TwK5y9j2wv7pMOFY7IorVkyMtEyLSCRlXKLsNlakeAlPw==",
"version": "1.5.411",
"resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz",
"integrity": "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==",
"dev": true,
"license": "ISC"
},
@@ -4880,9 +4897,9 @@
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -5481,7 +5498,7 @@
"license": "MIT"
},
"node_modules/is-unicode-supported": {
"version": "0.2.2",
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
"dev": true,
@@ -6369,7 +6386,7 @@
"optional": true
},
"node_modules/node-api-version": {
"version": "0.2.2",
"version": "0.2.1",
"resolved": "https://registry.npmmirror.com/node-api-version/-/node-api-version-0.2.1.tgz",
"integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==",
"dev": true,
@@ -6715,6 +6732,38 @@
"dev": true,
"license": "ISC"
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/plist": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.1.tgz",
@@ -8111,6 +8160,21 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/vitest/-/vitest-2.1.9.tgz",
@@ -8405,7 +8469,7 @@
}
},
"node_modules/yocto-queue": {
"version": "0.2.2",
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.5.2",
"version": "0.11.0",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
@@ -14,6 +14,8 @@
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"test": "vitest run --coverage",
"test:watch": "vitest",
"test:e2e": "npm run build && playwright test",
"test:e2e:headed": "playwright test --headed",
"build": "electron-vite build",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
@@ -29,6 +31,7 @@
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@playwright/test": "^1.62.1",
"@testing-library/jest-dom": "^6.10.0",
"@testing-library/react": "^16.3.2",
"@types/diff": "^7.0.0",
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from '@playwright/test'
/**
* Playwright E2E 配置:对构建产物(out/)启动真实 Electron 应用做端到端验收。
* - Electron 模式复用项目自带 Electron,无需下载 Playwright 浏览器
* - 不参与 vitest 单测与覆盖率统计;测试前必须先 npm run build
*/
export default defineConfig({
testDir: 'e2e',
// Electron 启动 + 窗口就绪整体放宽,避免慢机误报
timeout: 60_000,
expect: {
timeout: 10_000
},
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [['list']],
outputDir: 'test-results',
use: {
trace: 'off',
headless: false
}
})
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest'
import iconv from 'iconv-lite'
import { decodeText, looksBinary } from './decode'
describe('looksBinary - 二进制启发式', () => {
it('空输入不判为二进制', () => {
expect(looksBinary(new Uint8Array(0))).toBe(false)
})
it('出现 NUL 字节立即判定二进制', () => {
const buf = new Uint8Array([0x61, 0x00, 0x62])
expect(looksBinary(buf)).toBe(true)
})
it('纯可见 ASCII 判为文本', () => {
const buf = new TextEncoder().encode('hello world text')
expect(looksBinary(buf)).toBe(false)
})
it('不可见控制字符占比超过 5% 判为二进制', () => {
// 20 字节中 2 字节为 0x0110%)> 5%;探测窗口默认 8KB,短输入全量统计
const buf = new Uint8Array([0x61, 0x61, 0x01, 0x01, ...Array(16).fill(0x61)])
expect(looksBinary(buf)).toBe(true)
})
it('制表符与换行等常见空白不计入可疑字符', () => {
const buf = new TextEncoder().encode('a\tb\nc\r\nd')
expect(looksBinary(buf)).toBe(false)
})
})
describe('decodeText - BOM 探测', () => {
it('UTF-8 BOM:剥离 BOM 解码并标注', () => {
const body = new TextEncoder().encode('中文内容')
const buf = new Uint8Array([0xef, 0xbb, 0xbf, ...body])
const r = decodeText(buf)
expect(r.encoding).toBe('UTF-8 (BOM)')
expect(r.text).toBe('中文内容')
expect(r.binary).toBe(false)
})
it('UTF-16 LE BOM:剥离 BOM 解码并标注', () => {
// UTF-16 LE'中' = 0x2D 0x4E 小端
const buf = new Uint8Array([0xff, 0xfe, 0x2d, 0x4e])
const r = decodeText(buf)
expect(r.encoding).toBe('UTF-16 LE')
expect(r.text).toBe('中')
})
it('UTF-16 BE BOMiconv 解码并标注', () => {
// '中' 的 UTF-16 BE = 0x4E 0x2D
const buf = new Uint8Array([0xfe, 0xff, 0x4e, 0x2d])
const r = decodeText(buf)
expect(r.encoding).toBe('UTF-16 BE')
expect(r.text).toBe('中')
})
it('仅 BOM 无内容:返回空文本', () => {
const r = decodeText(new Uint8Array([0xef, 0xbb, 0xbf]))
expect(r.encoding).toBe('UTF-8 (BOM)')
expect(r.text).toBe('')
})
it('短于 BOM 长度的输入走严格 UTF-8 路径', () => {
const r = decodeText(new Uint8Array([0xef, 0xbb]))
// 0xEF 0xBB 不是合法 UTF-8 序列开头 → GBK 回退
expect(r.encoding).toBe('GBK')
})
})
describe('decodeText - 严格 UTF-8 与 GBK 回退', () => {
it('无 BOM 合法 UTF-8 中文:标注 UTF-8', () => {
const r = decodeText(new TextEncoder().encode('你好,世界'))
expect(r.encoding).toBe('UTF-8')
expect(r.text).toBe('你好,世界')
expect(r.binary).toBe(false)
})
it('纯 ASCII:标注 UTF-8', () => {
const r = decodeText(new TextEncoder().encode('plain ascii'))
expect(r.encoding).toBe('UTF-8')
expect(r.text).toBe('plain ascii')
})
it('GBK 编码中文:回退 GBK 解码正确', () => {
const r = decodeText(iconv.encode('中文GBK编码内容', 'gbk'))
expect(r.encoding).toBe('GBK')
expect(r.text).toBe('中文GBK编码内容')
expect(r.binary).toBe(false)
})
it('空输入:UTF-8 空文本', () => {
const r = decodeText(new Uint8Array(0))
expect(r.encoding).toBe('UTF-8')
expect(r.text).toBe('')
})
it('含 NUL 字节的非法 UTF-8:GBK 回退且判定二进制(NUL 启发式)', () => {
// 0xFF 破坏严格 UTF-8 触发 GBK 回退;0x00 命中 NUL 启发式判二进制
const buf = new Uint8Array([0x41, 0x00, 0xff, 0x43])
const r = decodeText(buf)
expect(r.encoding).toBe('GBK')
expect(r.binary).toBe(true)
})
it('替换符占比超过 5% 判定二进制(乱码防护)', () => {
// 构造 GBK 解出大量替换符的字节:非法 GBK 序列密集
const buf = new Uint8Array(200).fill(0xff)
const r = decodeText(buf)
expect(r.encoding).toBe('GBK')
expect(r.binary).toBe(true)
})
})
+66
View File
@@ -0,0 +1,66 @@
import iconv from 'iconv-lite'
/**
* 文本解码与编码探测的纯逻辑模块(无 electron 依赖,vitest 直接测试)。
* 入参类型为 Uint8Array:主进程同步路径传 Buffer(其子类),
* 解码 worker 路径经结构化克隆收到 Uint8Array,两侧共用同一实现。
*/
/** 解码结果:文本 + 探测到的编码标注 + 二进制启发式判定 */
export interface DecodeResult {
text: string
encoding: string
binary: boolean
}
/**
* 缓冲区二进制启发式判定:出现 NUL 字节立即判定;
* 否则统计前 8KB 内不可见控制字符占比(> 5% 视为二进制)。
*/
export function looksBinary(buf: Uint8Array): boolean {
const len = Math.min(buf.length, 8000)
if (len === 0) return false
let suspicious = 0
for (let i = 0; i < len; i++) {
const b = buf[i]
if (b === 0) return true
if (b < 0x09 || (b > 0x0d && b < 0x20)) suspicious++
}
return suspicious / len > 0.05
}
// iconv-lite 的类型签名要求 Buffer,运行时仅依赖 Uint8Array 接口(length/下标/subarray),
// 结构化克隆产生的 Uint8Array 直接传入安全,此断言不产生拷贝
const asBuffer = (u: Uint8Array): Buffer => u as unknown as Buffer
/**
* 探测文本编码并解码:优先 BOMUTF-8 / UTF-16 BE / UTF-16 LE),
* 其次严格 UTF-8,失败则回退 GBK 解码。
*/
export function decodeText(buf: Uint8Array): DecodeResult {
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
return { text: new TextDecoder().decode(buf.subarray(3)), encoding: 'UTF-8 (BOM)', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
return { text: iconv.decode(asBuffer(buf.subarray(2)), 'utf16-be'), encoding: 'UTF-16 BE', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
return {
text: new TextDecoder('utf-16le').decode(buf.subarray(2)),
encoding: 'UTF-16 LE',
binary: false
}
}
// 严格 UTF-8 探测
try {
const decoder = new TextDecoder('utf-8', { fatal: true })
const text = decoder.decode(buf)
return { text, encoding: 'UTF-8', binary: false }
} catch {
const text = iconv.decode(asBuffer(buf), 'gbk')
// GBK 覆盖面广,二进制内容也能解出“文字”;用字节启发式 + 替换符占比双保险
const bad = (text.match(/\uFFFD/g) ?? []).length
const binary = looksBinary(buf) || (text.length > 0 && bad / text.length > 0.05)
return { text, encoding: 'GBK', binary }
}
}
+13
View File
@@ -0,0 +1,13 @@
import { parentPort } from 'worker_threads'
import { decodeText } from './decode'
/**
* 解码 worker 入口(worker_threads)。
* 协议与渲染端 diffWorker 同构:请求 { jobId, buffer } → 响应 { jobId, result }。
* 大文件解码(iconv-lite GBK 为纯 JS 实现)在此线程执行,不阻塞主进程事件循环。
* 注意:本文件随构建单独打包并 asarUnpackElectron 的 asar 补丁不覆盖 worker 线程),
* iconv-lite 由构建配置打包进本文件(unpacked 路径下无法解析 asar 内 node_modules)。
*/
parentPort?.on('message', (msg: { jobId: number; buffer: Uint8Array }) => {
parentPort?.postMessage({ jobId: msg.jobId, result: decodeText(msg.buffer) })
})
+821
View File
@@ -0,0 +1,821 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import {
scanFolders,
semanticEquals,
SEMANTIC_MAX_BYTES,
type FolderEntry
} from './folderScan'
/** 以临时目录为根构造两个待对比文件夹 */
let leftDir: string
let rightDir: string
beforeAll(async () => {
const root = await mkdtemp(join(tmpdir(), 'difflens-scan-'))
leftDir = join(root, 'left')
rightDir = join(root, 'right')
await mkdir(leftDir, { recursive: true })
await mkdir(rightDir, { recursive: true })
})
afterAll(async () => {
await rm(join(leftDir, '..'), { recursive: true, force: true })
})
async function put(dir: 'left' | 'right', rel: string, content: string | Buffer): Promise<void> {
const path = join(dir === 'left' ? leftDir : rightDir, rel)
const idx = rel.lastIndexOf('/')
if (idx >= 0) await mkdir(join(path, '..'), { recursive: true })
await writeFile(path, content)
}
const byRel = (entries: FolderEntry[], rel: string): FolderEntry | undefined =>
entries.find((e) => e.rel === rel)
describe('scanFolders - 状态判定', () => {
it('两侧内容一致判为 same,内容不同(同大小)判为 different,大小不同判为 different', async () => {
await put('left', 'same.txt', 'hello')
await put('right', 'same.txt', 'hello')
await put('left', 'content-diff.txt', 'abc')
await put('right', 'content-diff.txt', 'abd')
await put('left', 'size-diff.txt', 'short')
await put('right', 'size-diff.txt', 'much longer text')
const r = await scanFolders(leftDir, rightDir)
expect(r.truncated).toBe(false)
expect(byRel(r.entries, 'same.txt')).toMatchObject({ status: 'same', leftSize: 5, rightSize: 5 })
expect(byRel(r.entries, 'content-diff.txt')?.status).toBe('different')
expect(byRel(r.entries, 'size-diff.txt')).toMatchObject({ status: 'different' })
})
it('单侧缺失判为 only,大小字段另一侧为 null', async () => {
await put('left', 'only-left.txt', 'L')
await put('right', 'only-right.txt', 'R')
const r = await scanFolders(leftDir, rightDir)
expect(byRel(r.entries, 'only-left.txt')).toMatchObject({
status: 'left-only',
leftSize: 1,
rightSize: null
})
expect(byRel(r.entries, 'only-right.txt')).toMatchObject({
status: 'right-only',
leftSize: null,
rightSize: 1
})
})
it('递归子目录条目按相对路径对齐(统一 / 分隔)', async () => {
await put('left', 'src/core/a.ts', 'export const a = 1\n')
await put('right', 'src/core/a.ts', 'export const a = 2\n')
await put('right', 'src/util/b.ts', 'export const b = 1\n')
const r = await scanFolders(leftDir, rightDir)
expect(byRel(r.entries, 'src/core/a.ts')?.status).toBe('different')
expect(byRel(r.entries, 'src/util/b.ts')?.status).toBe('right-only')
})
it('二进制内容同样按字节判定(含 NUL 字节文件)', async () => {
await put('left', 'bin.dat', Buffer.from([0x00, 0x01, 0x02, 0x03]))
await put('right', 'bin.dat', Buffer.from([0x00, 0x01, 0x02, 0x04]))
const r = await scanFolders(leftDir, rightDir)
expect(byRel(r.entries, 'bin.dat')?.status).toBe('different')
})
it('symlink 被跳过(防环且不参与判定)', async () => {
// 建一个指向已存在子目录的 symlink,不应出现在结果中也不应导致递归
await symlink(join(leftDir, 'src'), join(leftDir, 'src-link'), 'dir').catch(() => {})
const r = await scanFolders(leftDir, rightDir)
expect(r.entries.some((e) => e.rel.startsWith('src-link/'))).toBe(false)
})
it('条目按相对路径字典序排序', async () => {
const r = await scanFolders(leftDir, rightDir)
const rels = r.entries.map((e) => e.rel)
expect([...rels].sort()).toEqual(rels)
})
it('total 为两侧枚举文件数的较大值', async () => {
const r = await scanFolders(leftDir, rightDir)
const leftCount = r.entries.filter((e) => e.leftSize !== null).length
const rightCount = r.entries.filter((e) => e.rightSize !== null).length
expect(r.total).toBeGreaterThanOrEqual(Math.max(leftCount, rightCount))
})
})
describe('scanFolders - 边界与选项', () => {
it('两个空目录返回空结果', async () => {
const emptyL = await mkdtemp(join(tmpdir(), 'difflens-empty-l-'))
const emptyR = await mkdtemp(join(tmpdir(), 'difflens-empty-r-'))
try {
const r = await scanFolders(emptyL, emptyR)
expect(r.entries).toEqual([])
expect(r.total).toBe(0)
expect(r.truncated).toBe(false)
} finally {
await rm(emptyL, { recursive: true, force: true })
await rm(emptyR, { recursive: true, force: true })
}
})
it('目录不存在时不再抛异常:该侧按无文件处理(另一侧条目为 only)', async () => {
// 单目录 IO 失败降级(被锁/权限)而非整体失败:大目录下单点锁不丢弃全部结果
const r = await scanFolders(join(leftDir, 'not-exist'), rightDir)
expect(r.entries.length).toBeGreaterThan(0)
expect(r.entries.every((e) => e.status === 'right-only')).toBe(true)
})
it('maxFiles 截断:条目不完整且 truncated 为 true', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-trunc-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-trunc-r-'))
try {
for (let i = 0; i < 5; i++) {
await writeFile(join(l, `f${i}.txt`), String(i))
await writeFile(join(r, `f${i}.txt`), String(i))
}
const res = await scanFolders(l, r, { maxFiles: 2 })
expect(res.truncated).toBe(true)
expect(res.total).toBe(5)
expect(res.entries.length).toBeLessThanOrEqual(2)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('截断后不再深入子目录(凑满即停,total 为已遍历下限)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-stop-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-stop-r-'))
try {
// 根目录 3 个文件 + 子目录 1 个文件,maxFiles=2:凑满后子目录不被遍历
// (readdir 目录内条目顺序不保证,但 3 个根文件计入 total 与顺序无关)
for (let i = 0; i < 3; i++) {
await writeFile(join(l, `f${i}.txt`), String(i))
await writeFile(join(r, `f${i}.txt`), String(i))
}
await mkdir(join(l, 'sub'), { recursive: true })
await mkdir(join(r, 'sub'), { recursive: true })
await writeFile(join(l, 'sub', 'inner.txt'), 'x')
await writeFile(join(r, 'sub', 'inner.txt'), 'x')
const res = await scanFolders(l, r, { maxFiles: 2 })
expect(res.truncated).toBe(true)
// 根目录 3 文件全部计入(当前目录统计完整),sub 未被深入
expect(res.total).toBe(3)
expect(res.entries.some((e) => e.rel.startsWith('sub/'))).toBe(false)
expect(res.entries.length).toBeLessThanOrEqual(2)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('超过 maxContentBytes 的同大小文件:头部采样一致判 same 且带 approximate 标注', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-big-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-big-r-'))
try {
// 100KB 文件、maxContentBytes 压到 1KB:走采样路径;头部 8KB 一致 → same + approximate
const head = Buffer.alloc(8 * 1024, 0x41)
const tailL = Buffer.alloc(100 * 1024 - head.length, 0x01)
const tailR = Buffer.alloc(100 * 1024 - head.length, 0x02)
await writeFile(join(l, 'big.bin'), Buffer.concat([head, tailL]))
await writeFile(join(r, 'big.bin'), Buffer.concat([head, tailR]))
const res = await scanFolders(l, r, { maxContentBytes: 1024 })
expect(byRel(res.entries, 'big.bin')).toMatchObject({ status: 'same', approximate: true })
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('采样头部不同直接判 different', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-head-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-head-r-'))
try {
await writeFile(join(l, 'big.bin'), Buffer.alloc(100 * 1024, 0x01))
await writeFile(join(r, 'big.bin'), Buffer.alloc(100 * 1024, 0x02))
const res = await scanFolders(l, r, { maxContentBytes: 1024 })
expect(byRel(res.entries, 'big.bin')?.status).toBe('different')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
})
describe('scanFolders - 语义判等', () => {
/** 语义判等注入:同步解码(UTF-8 直读)+ 文本扩展名白名单 */
const SEMANTIC = {
maxBytes: SEMANTIC_MAX_BYTES,
isTextFile: (rel: string): boolean => {
const ext = rel.slice(rel.lastIndexOf('.') + 1).toLowerCase()
return ['txt', 'md', 'json', 'ts'].includes(ext)
},
decode: async (buf: Buffer): Promise<string> => buf.toString('utf-8')
}
it('semanticEquals:空白/换行/空行差异判等,跨行重排判等,实质差异与大小写不判等', () => {
expect(semanticEquals('a b', 'ab')).toBe(true)
expect(semanticEquals('a\r\nb', 'a\nb')).toBe(true)
expect(semanticEquals('a\n\n\nb', 'ab')).toBe(true)
expect(semanticEquals('ab cd', 'ab\ncd')).toBe(true)
expect(semanticEquals('abc', 'abd')).toBe(false)
expect(semanticEquals('ABC', 'abc')).toBe(false)
expect(semanticEquals('', '')).toBe(true)
})
it('仅空白/换行差异的文本文件判为 semantic-same(同大小与不同大小均覆盖)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-sem-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-sem-r-'))
try {
// 不同大小:行内空白差异(tab/空格/数量)
await writeFile(join(l, 'ws.txt'), 'a\tb\nc d\n')
await writeFile(join(r, 'ws.txt'), 'a b\nc d\n')
// 不同大小:CRLF vs LF
await writeFile(join(l, 'crlf.txt'), 'line1\r\nline2\r\n')
await writeFile(join(r, 'crlf.txt'), 'line1\nline2\n')
// 排版重排(跨行重组)
await writeFile(join(l, 'reflow.txt'), 'hello world\n')
await writeFile(join(r, 'reflow.txt'), 'hello\nworld\n')
const res = await scanFolders(l, r, { semantic: SEMANTIC })
expect(byRel(res.entries, 'ws.txt')?.status).toBe('semantic-same')
expect(byRel(res.entries, 'crlf.txt')?.status).toBe('semantic-same')
expect(byRel(res.entries, 'reflow.txt')?.status).toBe('semantic-same')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('实质内容差异与大小写差异仍判 different', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-sem2-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-sem2-r-'))
try {
await writeFile(join(l, 'real.txt'), 'value = 1\n')
await writeFile(join(r, 'real.txt'), 'value = 2\n')
await writeFile(join(l, 'case.txt'), 'Config\n')
await writeFile(join(r, 'case.txt'), 'config\n')
const res = await scanFolders(l, r, { semantic: SEMANTIC })
expect(byRel(res.entries, 'real.txt')?.status).toBe('different')
expect(byRel(res.entries, 'case.txt')?.status).toBe('different')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('非文本扩展名与超大小上限的条目不参与语义判等(保持字节级 different', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-sem3-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-sem3-r-'))
try {
// 非文本扩展名(.bin 不在白名单):仅空白差异仍判 different
await writeFile(join(l, 'data.bin'), 'a b\n')
await writeFile(join(r, 'data.bin'), 'ab\n')
// 大小超上限(注入 maxBytes=4):仅空白差异仍判 different
await writeFile(join(l, 'big.txt'), 'aaaa bbbb\n')
await writeFile(join(r, 'big.txt'), 'aaaabbbb\n')
const res = await scanFolders(l, r, {
semantic: { ...SEMANTIC, maxBytes: 4 }
})
expect(byRel(res.entries, 'data.bin')?.status).toBe('different')
expect(byRel(res.entries, 'big.txt')?.status).toBe('different')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('未开启语义判等时空白差异保持字节级 different(行为与 0.6.2 一致)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-sem4-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-sem4-r-'))
try {
await writeFile(join(l, 'ws.txt'), 'a b\n')
await writeFile(join(r, 'ws.txt'), 'ab\n')
const res = await scanFolders(l, r)
expect(byRel(res.entries, 'ws.txt')?.status).toBe('different')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('字节一致的文件不受语义开关影响(仍判 same,不触发解码)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-sem5-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-sem5-r-'))
let decoded = 0
try {
await writeFile(join(l, 'same.txt'), 'same content\n')
await writeFile(join(r, 'same.txt'), 'same content\n')
const res = await scanFolders(l, r, {
semantic: { ...SEMANTIC, decode: async (b) => { decoded++; return b.toString('utf-8') } }
})
expect(byRel(res.entries, 'same.txt')?.status).toBe('same')
expect(decoded).toBe(0)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('单文件 IO 失败(模拟被锁)标记 unreadable,不影响其余条目判定', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-sem6-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-sem6-r-'))
try {
// 正常差异文件 + 失败文件(decode 抛异常模拟读取后置失败,如 Windows 文件锁)
await writeFile(join(l, 'ok.txt'), 'a\n')
await writeFile(join(r, 'ok.txt'), 'b\n')
await writeFile(join(l, 'locked.txt'), 'x y\n')
await writeFile(join(r, 'locked.txt'), 'xy\n')
const res = await scanFolders(l, r, {
semantic: {
...SEMANTIC,
decode: async (b) => {
if (b.toString('utf-8').includes('x')) throw new Error('EBUSY')
return b.toString('utf-8')
}
}
})
expect(byRel(res.entries, 'ok.txt')?.status).toBe('different')
expect(byRel(res.entries, 'locked.txt')?.status).toBe('unreadable')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
})
describe('scanFolders - 受控并发内容比对', () => {
it('跨多个并发批次的混合状态全部判定正确且排序不变', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-conc-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-conc-r-'))
try {
// 40 个大小一致的条目 > 2×SCAN_CONCURRENCY(16),覆盖至少 3 个并发批次;
// 状态混排:偶数 same / 奇数 different / 尾部两个单侧
const total = 40
for (let i = 0; i < total; i++) {
const same = i % 2 === 0
const content = same ? `c${i}` : `L${i}`
const contentR = same ? `c${i}` : `R${i}`
await writeFile(join(l, `f${String(i).padStart(2, '0')}.txt`), content)
await writeFile(join(r, `f${String(i).padStart(2, '0')}.txt`), contentR)
}
await writeFile(join(l, 'zz-only-left.txt'), 'L')
await writeFile(join(r, 'zz-only-right.txt'), 'R')
const res = await scanFolders(l, r)
expect(res.entries).toHaveLength(total + 2)
// 每个条目状态正确(并发写回槽位不丢不错)
for (let i = 0; i < total; i++) {
const rel = `f${String(i).padStart(2, '0')}.txt`
expect(byRel(res.entries, rel)?.status).toBe(i % 2 === 0 ? 'same' : 'different')
}
expect(byRel(res.entries, 'zz-only-left.txt')?.status).toBe('left-only')
expect(byRel(res.entries, 'zz-only-right.txt')?.status).toBe('right-only')
// 排序仍为字典序(输出顺序与并发执行顺序无关)
const rels = res.entries.map((e) => e.rel)
expect([...rels].sort()).toEqual(rels)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
})
describe('scanFolders - 增量重扫(previous 缓存)', () => {
/** 语义判等注入:同步解码 + 计数器(零 IO 断言的核心观察点) */
const semWithCounter = (): {
opts: {
semantic: {
maxBytes: number
isTextFile: (rel: string) => boolean
decode: (b: Buffer) => Promise<string>
}
}
count: () => number
} => {
let decoded = 0
return {
opts: {
semantic: {
maxBytes: SEMANTIC_MAX_BYTES,
isTextFile: (rel: string): boolean => {
const ext = rel.slice(rel.lastIndexOf('.') + 1).toLowerCase()
return ['txt', 'md', 'json', 'ts'].includes(ext)
},
decode: async (b: Buffer): Promise<string> => {
decoded++
return b.toString('utf-8')
}
}
},
count: () => decoded
}
}
it('指纹一致零 IO:same 与语义同条目沿用判定,decode 计数零增长', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-inc1-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-inc1-r-'))
try {
// same(字节一致)+ semantic-same(仅空白差异,需解码判定)
await writeFile(join(l, 'same.txt'), 'identical\n')
await writeFile(join(r, 'same.txt'), 'identical\n')
await writeFile(join(l, 'sem.txt'), 'a b\nc\n')
await writeFile(join(r, 'sem.txt'), 'ab\nc\n')
const sem = semWithCounter()
const first = await scanFolders(l, r, sem.opts)
expect(byRel(first.entries, 'same.txt')?.status).toBe('same')
expect(byRel(first.entries, 'sem.txt')?.status).toBe('semantic-same')
expect(sem.count()).toBe(2) // 首次:sem.txt 双侧各解码一次
// 二次扫描携带缓存:指纹全部一致 → 全部零 IO 沿用
const second = await scanFolders(l, r, { ...sem.opts, previous: first.cache })
expect(byRel(second.entries, 'same.txt')?.status).toBe('same')
expect(byRel(second.entries, 'sem.txt')?.status).toBe('semantic-same')
expect(sem.count()).toBe(2) // 零增长
// 缓存条目被原样再次产出(链式增量可用)
expect(second.cache.length).toBe(2)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('语义开关翻转零 IOsemSame 非 null 条目纯内存组合 different ↔ semantic-same', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-inc2-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-inc2-r-'))
try {
await writeFile(join(l, 'sem.txt'), 'a b\nc\n')
await writeFile(join(r, 'sem.txt'), 'ab\nc\n')
// 第一次:语义开 → semantic-samesemSame = true 入缓存)
const semOn = semWithCounter()
const on1 = await scanFolders(l, r, semOn.opts)
expect(byRel(on1.entries, 'sem.txt')?.status).toBe('semantic-same')
expect(semOn.count()).toBe(2)
// 第二次:语义关 + 缓存 → different(字节不同),零 IO
const off = await scanFolders(l, r, { previous: on1.cache })
expect(byRel(off.entries, 'sem.txt')?.status).toBe('different')
expect(semOn.count()).toBe(2)
// 第三次:语义再开 + 新缓存 → semantic-same,仍零 IO
const semOn2 = semWithCounter()
const on2 = await scanFolders(l, r, { ...semOn2.opts, previous: off.cache })
expect(byRel(on2.entries, 'sem.txt')?.status).toBe('semantic-same')
expect(semOn2.count()).toBe(0)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('上次未参与语义(semSame null)而本次开启:读内容补判', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-inc3-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-inc3-r-'))
try {
await writeFile(join(l, 'sem.txt'), 'a b\n')
await writeFile(join(r, 'sem.txt'), 'ab\n')
// 第一次:语义关 → differentsemSame = null
const off = await scanFolders(l, r)
expect(byRel(off.entries, 'sem.txt')?.status).toBe('different')
// 第二次:语义开 + 缓存 → sem.txt 需补判(decode 2 次)
const sem = semWithCounter()
const on = await scanFolders(l, r, { ...sem.opts, previous: off.cache })
expect(byRel(on.entries, 'sem.txt')?.status).toBe('semantic-same')
expect(sem.count()).toBe(2)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('指纹变化(内容修改)重新判定,文件增删正确处理', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-inc4-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-inc4-r-'))
try {
await writeFile(join(l, 'a.txt'), 'v1\n')
await writeFile(join(r, 'a.txt'), 'v1\n')
await writeFile(join(l, 'gone.txt'), 'x\n')
const first = await scanFolders(l, r)
expect(byRel(first.entries, 'a.txt')?.status).toBe('same')
expect(byRel(first.entries, 'gone.txt')?.status).toBe('left-only')
// 修改右侧 a.txtmtime 变化)+ 删除 gone.txt + 新增 new.txt
await writeFile(join(r, 'a.txt'), 'v2 changed\n')
await rm(join(l, 'gone.txt'))
await writeFile(join(r, 'new.txt'), 'n\n')
const second = await scanFolders(l, r, { previous: first.cache })
expect(byRel(second.entries, 'a.txt')?.status).toBe('different')
expect(second.entries.some((e) => e.rel === 'gone.txt')).toBe(false)
expect(byRel(second.entries, 'new.txt')?.status).toBe('right-only')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('mtime 相同但指纹形状变化(单侧缺失变化)不误沿用', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-inc5-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-inc5-r-'))
try {
await writeFile(join(l, 'a.txt'), 'v1\n')
await writeFile(join(r, 'a.txt'), 'v1\n')
const first = await scanFolders(l, r)
expect(byRel(first.entries, 'a.txt')?.status).toBe('same')
// 删除右侧文件:same → left-only(缓存形状不一致,必须重判)
await rm(join(r, 'a.txt'))
const second = await scanFolders(l, r, { previous: first.cache })
expect(byRel(second.entries, 'a.txt')?.status).toBe('left-only')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('本次截断忽略 previous 且 cache 产出为空(条目不完整不可信)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-inc6-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-inc6-r-'))
try {
for (let i = 0; i < 5; i++) {
await writeFile(join(l, `f${i}.txt`), String(i))
await writeFile(join(r, `f${i}.txt`), String(i))
}
const first = await scanFolders(l, r)
expect(first.cache.length).toBe(5)
// 截断扫描:previous 被忽略(等同全量),cache 为空
const second = await scanFolders(l, r, { maxFiles: 2, previous: first.cache })
expect(second.truncated).toBe(true)
expect(second.cache).toEqual([])
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('unreadable 不进缓存:故障恢复后重扫得到正确判定', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-inc7-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-inc7-r-'))
try {
await writeFile(join(l, 'ok.txt'), 'a\n')
await writeFile(join(r, 'ok.txt'), 'b\n')
await writeFile(join(l, 'locked.txt'), 'x y\n')
await writeFile(join(r, 'locked.txt'), 'xy\n')
// 第一次:locked.txt 解码抛异常(模拟 Windows 文件锁)→ unreadable
const sem = semWithCounter()
const patched = {
semantic: {
...sem.opts.semantic,
decode: async (b: Buffer): Promise<string> => {
if (b.toString('utf-8').includes('x')) throw new Error('EBUSY')
return b.toString('utf-8')
}
}
}
const first = await scanFolders(l, r, patched)
expect(byRel(first.entries, 'ok.txt')?.status).toBe('different')
expect(byRel(first.entries, 'locked.txt')?.status).toBe('unreadable')
// unreadable 不进缓存(cache 仅 1 条 ok.txt
expect(first.cache.map((c) => c.rel)).toEqual(['ok.txt'])
// 第二次:故障恢复(正常 decode+ 缓存 → locked.txt 补判 semantic-same
const second = await scanFolders(l, r, { ...sem.opts, previous: first.cache })
expect(byRel(second.entries, 'ok.txt')?.status).toBe('different')
expect(byRel(second.entries, 'locked.txt')?.status).toBe('semantic-same')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
})
describe('scanFolders - 忽略规则', () => {
it('目录规则整树剪枝:子树不枚举、不计数、不出现在结果与缓存', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign1-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign1-r-'))
try {
await writeFile(join(l, 'keep.txt'), 'a')
await writeFile(join(r, 'keep.txt'), 'a')
// 深层子树:node_modules/pkg/dist/x.js(全部应被剪枝)
for (const d of [l, r]) {
await mkdir(join(d, 'node_modules', 'pkg', 'dist'), { recursive: true })
await writeFile(join(d, 'node_modules', 'pkg', 'dist', 'x.js'), 'x')
await writeFile(join(d, 'node_modules', 'pkg', 'y.js'), 'y')
}
const res = await scanFolders(l, r, { ignore: ['node_modules/'] })
expect(res.entries.map((e) => e.rel)).toEqual(['keep.txt'])
expect(res.total).toBe(1)
expect(res.truncated).toBe(false)
expect(res.cache.map((c) => c.rel)).toEqual(['keep.txt'])
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('扩展名通配与名字匹配跳过文件(大小写不敏感)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign2-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign2-r-'))
try {
for (const d of [l, r]) {
await writeFile(join(d, 'keep.txt'), 'a')
await writeFile(join(d, 'debug.LOG'), 'x')
await writeFile(join(d, 'temp'), 't')
await writeFile(join(d, 'other.txt'), 'o')
}
const res = await scanFolders(l, r, { ignore: ['*.log', 'temp'] })
expect(res.entries.map((e) => e.rel).sort()).toEqual(['keep.txt', 'other.txt'])
expect(res.total).toBe(2)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('被忽略文件不占用 maxFiles:原本截断的场景忽略后完整扫描', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign3-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign3-r-'))
try {
for (let i = 0; i < 5; i++) {
await writeFile(join(l, `f${i}.txt`), String(i))
await writeFile(join(r, `f${i}.txt`), String(i))
}
for (const d of [l, r]) {
await mkdir(join(d, 'dist'), { recursive: true })
for (let i = 0; i < 5; i++) await writeFile(join(d, 'dist', `b${i}.txt`), 'b')
}
// 无忽略 + maxFiles=6:截断
const noIgnore = await scanFolders(l, r, { maxFiles: 6 })
expect(noIgnore.truncated).toBe(true)
// 忽略 dist/ 后仅 5 个文件:完整扫描
const res = await scanFolders(l, r, { maxFiles: 6, ignore: ['dist/'] })
expect(res.truncated).toBe(false)
expect(res.total).toBe(5)
expect(res.entries).toHaveLength(5)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('忽略规则变化后重扫:缓存未命中的重新纳入条目走全量判定', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign4-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign4-r-'))
try {
await writeFile(join(l, 'a.txt'), 'a')
await writeFile(join(r, 'a.txt'), 'a')
await mkdir(join(l, 'dist'), { recursive: true })
await mkdir(join(r, 'dist'), { recursive: true })
await writeFile(join(l, 'dist/x.txt'), 'x')
await writeFile(join(r, 'dist/x.txt'), 'x')
// 第一次:忽略 dist/
const first = await scanFolders(l, r, { ignore: ['dist/'] })
expect(first.entries.map((e) => e.rel)).toEqual(['a.txt'])
// 第二次:去掉忽略规则 + 携带缓存 → dist/x.txt 未命中缓存,全量判定 same
const second = await scanFolders(l, r, { previous: first.cache })
expect(byRel(second.entries, 'a.txt')?.status).toBe('same')
expect(byRel(second.entries, 'dist/x.txt')?.status).toBe('same')
expect(second.cache.map((c) => c.rel).sort()).toEqual(['a.txt', 'dist/x.txt'])
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('多段路径从根锚定剪枝:仅命中该路径(其他分支同名目录不受影响)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign6-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign6-r-'))
try {
for (const d of [l, r]) {
await mkdir(join(d, 'src', 'temp'), { recursive: true })
await mkdir(join(d, 'lib', 'temp'), { recursive: true })
await writeFile(join(d, 'src', 'temp', 'a.txt'), 'a')
await writeFile(join(d, 'lib', 'temp', 'b.txt'), 'b')
await writeFile(join(d, 'src', 'keep.txt'), 'k')
}
const res = await scanFolders(l, r, { ignore: ['src/temp'] })
// src/temp 剪枝;lib/temp 同名目录在其他分支不受影响;被忽略文件不计入 total
expect(res.entries.map((e) => e.rel)).toEqual(['lib/temp/b.txt', 'src/keep.txt'])
expect(res.total).toBe(2)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('** 跨段剪枝与否定救回(父目录未被剪枝时子路径可被反否定)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign7-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign7-r-'))
try {
for (const d of [l, r]) {
await mkdir(join(d, 'build', 'a', 'temp'), { recursive: true })
await mkdir(join(d, 'build', 'special', 'temp'), { recursive: true })
await writeFile(join(d, 'build', 'a', 'temp', 'd1.txt'), 'x')
await writeFile(join(d, 'build', 'special', 'temp', 'd2.txt'), 'y')
await writeFile(join(d, 'keep.txt'), 'k')
}
// build/a/temp 剪枝;build/special/temp 被否定救回(build 与 special 未被剪枝,BFS 正常下钻)
const res = await scanFolders(l, r, { ignore: ['build/**/temp/', '!build/special/temp/'] })
expect(res.entries.map((e) => e.rel)).toEqual(['build/special/temp/d2.txt', 'keep.txt'])
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('父目录被剪枝后否定规则救不回子项(gitignore 同款语义)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign8-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign8-r-'))
try {
for (const d of [l, r]) {
await mkdir(join(d, 'node_modules', 'pkg'), { recursive: true })
await writeFile(join(d, 'node_modules', 'pkg', 'keep.js'), 'k')
await writeFile(join(d, 'app.js'), 'a')
}
// node_modules/ 目录命中即整树剪枝,!node_modules/pkg/keep.js 无法救回
const res = await scanFolders(l, r, { ignore: ['node_modules/', '!node_modules/pkg/keep.js'] })
expect(res.entries.map((e) => e.rel)).toEqual(['app.js'])
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('段内 ? 与字符类规则跳过文件(0.10.1 字符通配集成)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign9-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign9-r-'))
try {
for (const d of [l, r]) {
await writeFile(join(d, 'keep.txt'), 'a')
await writeFile(join(d, 'debug.log'), 'x')
await writeFile(join(d, 'debug.leg'), 'x')
await writeFile(join(d, 'main.c'), 'x')
await writeFile(join(d, 'main.o'), 'x')
}
// *.l?g 命中 .log 与 .leg? 匹配任意单字符);*.[ch] 命中 .c 不命中 .o
const res = await scanFolders(l, r, { ignore: ['*.l?g', '*.[ch]'] })
expect(res.entries.map((e) => e.rel).sort()).toEqual(['keep.txt', 'main.o'])
expect(res.total).toBe(2)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('空规则数组与全非法规则不产生任何剪枝(行为与无规则一致)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-ign5-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-ign5-r-'))
try {
for (const d of [l, r]) {
await writeFile(join(d, 'a.txt'), 'a')
await mkdir(join(d, 'node_modules'), { recursive: true })
await writeFile(join(d, 'node_modules', 'x.js'), 'x')
}
const empty = await scanFolders(l, r, { ignore: [] })
expect(empty.entries).toHaveLength(2)
// 全部为非法规则(空段 / ! 后为空 / 段内 **):不产生任何剪枝
const invalid = await scanFolders(l, r, { ignore: ['# note', 'a//b', '!', 'a**b'] })
expect(invalid.entries).toHaveLength(2)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
})
describe('scanFolders - 比对进度回调(onProgress', () => {
it('需读内容的条目按批推进进度:done 单调递增至 total', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-prog-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-prog-r-'))
try {
// 40 个大小一致条目(需读内容比对)+ 2 个单侧(无 IO)→ tasks = 403 个批次
for (let i = 0; i < 40; i++) {
await writeFile(join(l, `f${String(i).padStart(2, '0')}.txt`), `c${i}`)
await writeFile(join(r, `f${String(i).padStart(2, '0')}.txt`), `c${i}`)
}
await writeFile(join(l, 'only-l.txt'), 'L')
const events: Array<{ done: number; total: number }> = []
const res = await scanFolders(l, r, {
onProgress: (done, total) => events.push({ done, total })
})
expect(res.entries).toHaveLength(41)
expect(events.length).toBeGreaterThan(0)
// total 恒为需读内容的条目总数;done 单调递增且最终到达 total
expect(events.every((e) => e.total === 40)).toBe(true)
for (let i = 1; i < events.length; i++) {
expect(events[i].done).toBeGreaterThan(events[i - 1].done)
}
expect(events[events.length - 1].done).toBe(40)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('全部条目零 IO(缓存命中/单侧缺失)时不触发进度回调', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-prog2-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-prog2-r-'))
try {
await writeFile(join(l, 'only-l.txt'), 'L')
const events: Array<{ done: number; total: number }> = []
await scanFolders(l, r, { onProgress: (done, total) => events.push({ done, total }) })
expect(events).toHaveLength(0)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
})
+417
View File
@@ -0,0 +1,417 @@
import { readdir, stat, readFile, open } from 'fs/promises'
import { join } from 'path'
import { compileIgnoreRules, type IgnoreMatcher } from '../shared/ignoreRules'
/**
* 文件夹对比的纯逻辑模块(仅依赖 node:fs,vitest 以真实临时目录直接测试)。
* 递归枚举两侧目录(跳过 symlink 防环)、按相对路径对齐、字节级内容判定;
* 全部 IO 走异步 fs(libuv 线程池),不阻塞主进程事件循环。
*/
/** 单个文件条目的对比状态 */
export type FolderEntryStatus =
| 'same'
| 'semantic-same'
| 'different'
| 'left-only'
| 'right-only'
| 'unreadable'
export interface FolderEntry {
/** 相对路径(统一 / 分隔,含子目录前缀) */
rel: string
status: FolderEntryStatus
/** 左侧文件字节数;该侧不存在为 null */
leftSize: number | null
/** 右侧文件字节数;该侧不存在为 null */
rightSize: number | null
/** 超过全量比对上限、仅采样头部判定的近似结果(status 为 same 时可能出现) */
approximate?: boolean
}
/** 单侧文件指纹(size + mtimeMs):增量重扫的"内容是否变化"判定依据。
* 与 rsync/make 同款取舍:mtime 未变即视为内容未变(手动改回时间戳的极端场景不防) */
export interface FileFingerprint {
size: number
mtime: number
}
/**
* 增量重扫的缓存条目:上次扫描的单条判定结果(按双侧指纹沿用,避免重复 IO)。
* - status 为 same(含采样近似)/ only 类:指纹一致即可零 IO 沿用;
* - 字节不同类(different / semantic-same):semSame 非 null 时语义开关翻转可纯内存
* 组合出新状态(different ↔ semantic-same 零 IO),null 表示上次未参与语义判定;
* - unreadable(被锁/权限)不进缓存:下次重扫重试。
*/
export interface ScanCacheEntry {
rel: string
leftFp: FileFingerprint | null
rightFp: FileFingerprint | null
status: 'same' | 'semantic-same' | 'different' | 'left-only' | 'right-only'
approximate?: boolean
/** 上次是否参与过语义判等及其结果(null = 未参与:语义关闭 / 非文本 / 超大小上限) */
semSame: boolean | null
}
export interface ScanResult {
/** 全部条目(按相对路径字典序排序) */
entries: FolderEntry[]
/** 因超出文件数量上限被截断(条目不完整,界面提示人工确认) */
truncated: boolean
/** 枚举发现的文件总数(截断时为已遍历下限:凑满上限即停止深入,不再统计剩余子树) */
total: number
/** 本次结果生成的增量重扫缓存(unreadable 不含;截断时为空数组——条目不完整不可信) */
cache: ScanCacheEntry[]
}
export interface ScanOptions {
/** 单侧文件数量上限(默认 10000) */
maxFiles?: number
/** 全量字节比对的文件大小上限(默认 10MB,超出仅采样头部 8KB 近似判定) */
maxContentBytes?: number
/** 语义判等(可选):字节级判 different 的文本文件二次判等,剔除空白后一致 → semantic-same */
semantic?: SemanticOptions
/** 增量重扫缓存(可选):上次扫描返回的 cache,指纹一致的条目零 IO 沿用判定 */
previous?: ScanCacheEntry[]
/**
* 忽略规则(gitignore 简化子集含高级 glob,可选):按完整相对路径判定——
* 命中的目录整树剪枝(不枚举不计数,被忽略项不占用 maxFiles 也不计入 total/truncated)、
* 命中的文件跳过。支持多段路径 / `**` 跨段 / 段内 `*` 通配 / `!` 否定(最后匹配优先)。
* 规则变化的缓存失效由指纹机制天然覆盖:未命中条目走全量判定。
*/
ignore?: string[]
/**
* 比对进度回调(可选):需读内容的条目按受控并发分批比对,每批完成后触发一次;
* done 为已完成条目数,total 为需读内容的条目总数(缓存命中的零 IO 条目不计入)。
*/
onProgress?: (done: number, total: number) => void
}
/**
* 语义判等的依赖注入(folderScan 保持纯逻辑可测,解码与扩展名判定由调用方注入)。
* 生产环境 decode 走主进程解码 worker(大缓冲不阻塞事件循环),测试注入同步实现。
*/
export interface SemanticOptions {
/** 参与语义判等的单侧文件大小上限 */
maxBytes: number
/** 相对路径是否为文本文件(按扩展名判定,与打开文件对话框的清单对齐) */
isTextFile: (rel: string) => boolean
/** 解码文件字节为文本(BOM/编码探测由注入实现处理) */
decode: (buf: Buffer) => Promise<string>
}
/** 语义判等参与文件的大小上限(2MB):覆盖绝大多数文本源码/配置;超大文件维持字节级判定 */
export const SEMANTIC_MAX_BYTES = 2 * 1024 * 1024
/**
* 语义判等:剔除全部空白(含换行,与字符级对比的判等语义一致)后拼接比较;
* 大小写敏感(大小写差异在代码中多为实质差异,不忽略)。
* 换行符 CRLF/LF、行尾空白、缩进、空行数量、排版重排(含跨行重组)均不构成差异。
*/
export function semanticEquals(left: string, right: string): boolean {
const n = (s: string): string => s.replace(/\s+/g, '')
return n(left) === n(right)
}
export const FOLDER_MAX_FILES = 10000
export const FOLDER_MAX_CONTENT_BYTES = 10 * 1024 * 1024
/** 近似判定的采样头部字节数 */
const SAMPLE_BYTES = 8 * 1024
/**
* 内容比对的受控并发数:单侧缺失/大小不同直接判定(无 IO),
* 大小一致的条目按此并发分批读取比对(批内 Promise.all,批间串行)。
* 大目录下避免逐文件串行等待 IO;批内左右两文件也并行,实际排队 IO 约 2 倍并发。
*/
export const SCAN_CONCURRENCY = 16
/**
* 手写 BFS 遍历目录树收集普通文件(相对路径统一 / 分隔,跳过 symlink 防环),
* 凑满 maxFiles 即停止深入:readdir({recursive:true}) 只能枚举完整棵树、无法提前终止,
* 误选超大目录(如含 node_modules)时枚举本身耗时数秒。
* 截断后 total 为已遍历部分的文件数下限(剩余子树不再统计)。
*/
async function listFiles(
root: string,
maxFiles: number,
ignore?: IgnoreMatcher
): Promise<{ files: Map<string, FileFingerprint>; truncated: boolean; total: number }> {
const rels: string[] = []
let total = 0
let truncated = false
const queue: Array<{ dir: string; prefix: string }> = [{ dir: root, prefix: '' }]
while (queue.length > 0) {
const { dir, prefix } = queue.shift()!
// 单个目录不可读(权限/被锁):跳过该目录继续扫描其余部分,不整体失败
let dirents
try {
dirents = await readdir(dir, { withFileTypes: true })
} catch {
continue
}
for (const d of dirents) {
// Dirent 为 lstat 语义:symlink 既非 file 也非 directory,天然跳过(防环)
if (d.isDirectory()) {
// 忽略规则按完整相对路径判定(支持多段/**/否定):命中目录整树剪枝
const rel = prefix === '' ? d.name : `${prefix}/${d.name}`
if (ignore?.matchesPath(rel, true)) continue
queue.push({ dir: join(dir, d.name), prefix: rel })
} else if (d.isFile()) {
// 命中的文件跳过(不计数,不占用 maxFiles)
const rel = prefix === '' ? d.name : `${prefix}/${d.name}`
if (ignore?.matchesPath(rel, false)) continue
total++
if (rels.length >= maxFiles) {
truncated = true
} else {
rels.push(rel)
}
}
}
// 凑满即停:不再处理队列中剩余的子目录(当前目录统计完整,保证确定性)
if (truncated) break
}
// 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束);
// 保留 size + mtimeMs 作为增量重扫指纹;单文件 stat 失败(被锁/权限)跳过该条目,不整体失败
const stats = await Promise.allSettled(rels.map((rel) => stat(join(root, rel))))
const files = new Map<string, FileFingerprint>()
rels.forEach((rel, i) => {
const s = stats[i]
if (s.status === 'fulfilled') {
files.set(rel, { size: s.value.size, mtime: s.value.mtimeMs })
}
})
return { files, truncated, total }
}
/** 读取文件头部指定字节数(近似判定采样) */
async function readHead(path: string, bytes: number): Promise<Buffer> {
const fh = await open(path, 'r')
try {
const buf = Buffer.alloc(bytes)
const { bytesRead } = await fh.read(buf, 0, bytes, 0)
return buf.subarray(0, bytesRead)
} finally {
await fh.close()
}
}
/**
* 对比两个文件夹:按相对路径对齐条目并判定内容异同。
* 判定规则:单侧缺失 → only;大小一致且 ≤ maxContentBytes → 全量字节比对;
* 大小不同(≤ 语义上限)或字节不同 → 字节级 different(开启语义判等时二次判等,
* 剔除空白后一致改判 semantic-same);超过全量比对上限 → 头部 8KB 采样近似判定。
* 需读内容的条目按 SCAN_CONCURRENCY 受控并发执行(输出与顺序和串行实现完全一致);
* 单文件/单目录 IO 失败(被锁/权限,如 Windows Defender 占用)降级处理——
* 文件标记 unreadable、目录跳过,不整体失败(大目录下单点锁不应丢弃全部结果)。
*
* 增量重扫(options.previous,本次枚举未截断时启用):双侧指纹(size+mtime)与上次
* 一致的条目零 IO 沿用判定——same/only 直接沿用;字节不同类在语义开关翻转时按缓存
* semSame 纯内存组合(different ↔ semantic-same),仅"上次未参与语义判定而本次需要"
* 的条目读内容补判。上次或本次截断、unreadable 条目不享用缓存(全量重判/重试)。
*/
export async function scanFolders(
leftDir: string,
rightDir: string,
options: ScanOptions = {}
): Promise<ScanResult> {
const maxFiles = options.maxFiles ?? FOLDER_MAX_FILES
const maxContentBytes = options.maxContentBytes ?? FOLDER_MAX_CONTENT_BYTES
const semantic = options.semantic
// 忽略规则编译一次双侧共用;空数组(无有效规则)不构造匹配器,走原有路径
const ignore =
options.ignore !== undefined && options.ignore.length > 0
? compileIgnoreRules(options.ignore)
: undefined
const left = await listFiles(leftDir, maxFiles, ignore)
const right = await listFiles(rightDir, maxFiles, ignore)
/** 条目是否参与语义判等:双侧存在、均在大小上限内、文本扩展名 */
const semanticEligible = (rel: string, lSize: number, rSize: number): boolean =>
semantic !== undefined &&
lSize <= semantic.maxBytes &&
rSize <= semantic.maxBytes &&
semantic.isTextFile(rel)
/** 本次或上次截断:条目集不完整,缓存不可信(本次产出的 cache 也置空) */
const useCache = options.previous !== undefined && !left.truncated && !right.truncated
const prevMap = new Map((useCache ? options.previous! : []).map((c) => [c.rel, c]))
const rels = [...new Set([...left.files.keys(), ...right.files.keys()])].sort()
// 预分配结果槽位:缓存命中/无 IO 的条目直接定案,需读内容的条目收集为任务并发执行
const entries: FolderEntry[] = new Array(rels.length)
const cache: Array<ScanCacheEntry | undefined> = new Array(rels.length)
const tasks: { idx: number; run: () => Promise<void> }[] = []
/** 双侧指纹与缓存条目一致(含单侧缺失形状一致;mtime 未变视为内容未变) */
const fpHit = (
prev: ScanCacheEntry,
lFp: FileFingerprint | undefined,
rFp: FileFingerprint | undefined
): boolean => {
if ((prev.leftFp === null) !== (lFp === undefined)) return false
if ((prev.rightFp === null) !== (rFp === undefined)) return false
if (lFp !== undefined && (prev.leftFp!.size !== lFp.size || prev.leftFp!.mtime !== lFp.mtime))
return false
if (rFp !== undefined && (prev.rightFp!.size !== rFp.size || prev.rightFp!.mtime !== rFp.mtime))
return false
return true
}
rels.forEach((rel, idx) => {
const lFp = left.files.get(rel)
const rFp = right.files.get(rel)
const lSize = lFp !== undefined ? lFp.size : null
const rSize = rFp !== undefined ? rFp.size : null
const prev = prevMap.get(rel)
// ===== 增量缓存命中:零 IO 组合判定 =====
if (prev !== undefined && fpHit(prev, lFp, rFp)) {
if (prev.status === 'same') {
entries[idx] = {
rel,
status: 'same',
leftSize: lSize,
rightSize: rSize,
...(prev.approximate ? { approximate: true } : {})
}
cache[idx] = prev
return
}
if (prev.status === 'left-only' || prev.status === 'right-only') {
entries[idx] = { rel, status: prev.status, leftSize: lSize, rightSize: rSize }
cache[idx] = prev
return
}
// 字节不同类:语义关闭或不适用 → different;开启且上次参与过 → 按 semSame 组合;
// 开启但上次未参与(semSame null)→ 落到全量判定补判
const semActive =
semantic !== undefined && semanticEligible(rel, lSize ?? 0, rSize ?? 0)
if (!semActive) {
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
cache[idx] = { ...prev, status: 'different' }
return
}
if (prev.semSame !== null) {
const status = prev.semSame ? 'semantic-same' : 'different'
entries[idx] = { rel, status, leftSize: lSize, rightSize: rSize }
cache[idx] = { ...prev, status }
return
}
}
// ===== 全量判定(缓存未命中 / 语义补判)=====
if (lFp === undefined) {
entries[idx] = { rel, status: 'right-only', leftSize: null, rightSize: rSize }
cache[idx] = {
rel,
leftFp: null,
rightFp: rFp ?? null,
status: 'right-only',
semSame: null
}
} else if (rFp === undefined) {
entries[idx] = { rel, status: 'left-only', leftSize: lSize, rightSize: null }
cache[idx] = { rel, leftFp: lFp, rightFp: null, status: 'left-only', semSame: null }
} else if (lFp.size !== rFp.size && !semanticEligible(rel, lFp.size, rFp.size)) {
// 大小不同且不参与语义判等(未开启/超上限/非文本):无需读内容直接定案
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
cache[idx] = { rel, leftFp: lFp, rightFp: rFp, status: 'different', semSame: null }
} else if (lFp.size === rFp.size && lFp.size > maxContentBytes) {
// 大小一致但超过全量比对上限:头部采样近似判定,结果带 approximate 标注;
// 单文件读取失败(被锁/权限,如 Windows Defender 占用)标记 unreadable,不整体失败
tasks.push({
idx,
run: async () => {
try {
const [lHead, rHead] = await Promise.all([
readHead(join(leftDir, rel), SAMPLE_BYTES),
readHead(join(rightDir, rel), SAMPLE_BYTES)
])
const same = lHead.equals(rHead)
entries[idx] = same
? { rel, status: 'same', leftSize: lSize, rightSize: rSize, approximate: true }
: { rel, status: 'different', leftSize: lSize, rightSize: rSize }
cache[idx] = {
rel,
leftFp: lFp,
rightFp: rFp,
status: same ? 'same' : 'different',
...(same ? { approximate: true } : {}),
semSame: null
}
} catch {
entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize }
}
}
})
} else {
// 大小一致(≤ 上限)全量字节比对;大小不同且均在语义上限内读两侧全量。
// 字节 different 且开启语义判等时:同一批读取上接语义判等(避免二次 IO);
// 单文件读取/解码失败(被锁/权限)标记 unreadable,不整体失败
tasks.push({
idx,
run: async () => {
try {
const [lBuf, rBuf] = await Promise.all([
readFile(join(leftDir, rel)),
readFile(join(rightDir, rel))
])
if (lBuf.equals(rBuf)) {
entries[idx] = { rel, status: 'same', leftSize: lSize, rightSize: rSize }
cache[idx] = { rel, leftFp: lFp, rightFp: rFp, status: 'same', semSame: null }
return
}
if (semanticEligible(rel, lFp!.size, rFp!.size)) {
const [lt, rt] = await Promise.all([semantic!.decode(lBuf), semantic!.decode(rBuf)])
if (semanticEquals(lt, rt)) {
entries[idx] = { rel, status: 'semantic-same', leftSize: lSize, rightSize: rSize }
cache[idx] = {
rel,
leftFp: lFp,
rightFp: rFp,
status: 'semantic-same',
semSame: true
}
return
}
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
cache[idx] = {
rel,
leftFp: lFp,
rightFp: rFp,
status: 'different',
semSame: false
}
return
}
entries[idx] = { rel, status: 'different', leftSize: lSize, rightSize: rSize }
cache[idx] = { rel, leftFp: lFp, rightFp: rFp, status: 'different', semSame: null }
} catch {
entries[idx] = { rel, status: 'unreadable', leftSize: lSize, rightSize: rSize }
}
}
})
}
})
// 受控并发分批执行(批内并行、批间串行;单任务失败已在任务内部降级为 unreadable);
// 每批完成触发一次进度回调(调用方推送 UI 进度,异常由调用方防御)
let done = 0
for (let i = 0; i < tasks.length; i += SCAN_CONCURRENCY) {
await Promise.all(tasks.slice(i, i + SCAN_CONCURRENCY).map((t) => t.run()))
done = Math.min(i + SCAN_CONCURRENCY, tasks.length)
try {
options.onProgress?.(done, tasks.length)
} catch {
// 进度回调异常不影响扫描
}
}
const truncated = left.truncated || right.truncated
return {
entries,
truncated,
total: Math.max(left.total, right.total),
// 截断时条目不完整:cache 置空防止下次误沿用(unreadable 槽位本就为 undefined
cache: truncated ? [] : (cache.filter((c) => c !== undefined) as ScanCacheEntry[])
}
}
+360 -60
View File
@@ -1,14 +1,120 @@
import { join } from 'path'
import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme } from 'electron'
import { join, dirname, sep } from 'path'
import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme, screen } from 'electron'
import { Worker } from 'worker_threads'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import iconv from 'iconv-lite'
import fs from 'fs'
import { parseState, clampBounds, type AppState } from './windowState'
import { decodeText, type DecodeResult } from './decode'
import { scanFolders, SEMANTIC_MAX_BYTES, SCAN_CONCURRENCY, type ScanCacheEntry } from './folderScan'
import { resolveScanArgs } from './scanIpc'
import {
parseScanCacheStore,
putScanCache,
getScanCache,
emptyScanCacheStore,
type ScanCacheStoreData
} from './scanCacheStore'
import { TEXT_EXTENSIONS } from '../shared/textExtensions'
/**
* 相对路径是否为文本文件(按扩展名,语义判等文本判定共用):
* 扩展名清单来自 src/shared/textExtensions(与渲染端拖拽入口共用同一源文件);
* 无扩展名的文件按文本放行(与渲染端 isTextFile 口径一致)。
*/
function isTextRel(rel: string): boolean {
const dot = rel.lastIndexOf('.')
if (dot < 0) return true
return TEXT_EXTENSIONS.includes(rel.slice(dot + 1).toLowerCase())
}
/** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */
const stateFile = (): string => join(app.getPath('userData'), 'window-state.json')
/** 文件夹扫描增量缓存的跨会话持久化文件,位于 userData 目录 */
const scanCacheFile = (): string => join(app.getPath('userData'), 'scan-cache.json')
/** 读取持久化扫描缓存存储:文件不存在/损坏回退空存储,不影响扫描。
* 异步读盘(渲染端未携带内存缓存时在 folder:scan handler 内惰性触发)——
* 缓存文件可达 MB 级(LRU 8 对 × 万级条目),同步读会阻塞主进程事件循环 */
async function loadScanCacheStore(): Promise<ScanCacheStoreData> {
try {
return parseScanCacheStore(await fs.promises.readFile(scanCacheFile(), 'utf-8'))
} catch {
return emptyScanCacheStore()
}
}
/**
* 落盘扫描缓存(读-改-写,异步 fire-and-forget):
* 失败静默(目录只读/磁盘满等,跨会话增量记忆失效不影响本次结果)。
* 写操作经内存 promise 链串行执行(每次追加到链尾):两次扫描近乎同时
* 完成时各自的读-改-写按序落盘,避免后写整体覆盖先写丢失 LRU 目录对。
*/
let scanCacheWrites: Promise<void> = Promise.resolve()
function persistScanCache(leftDir: string, rightDir: string, cache: ScanCacheEntry[]): void {
scanCacheWrites = scanCacheWrites.then(() => writeScanCache(leftDir, rightDir, cache))
}
async function writeScanCache(
leftDir: string,
rightDir: string,
cache: ScanCacheEntry[]
): Promise<void> {
try {
const next = putScanCache(await loadScanCacheStore(), leftDir, rightDir, cache, Date.now())
await fs.promises.writeFile(scanCacheFile(), JSON.stringify(next), 'utf-8')
} catch {
// 忽略写入失败
}
}
/** 当前生效的应用状态(启动时从磁盘读取,运行中增量更新) */
let appState: AppState = {}
/** 读取持久化状态:文件不存在/损坏一律回退空状态,不影响启动 */
function loadState(): AppState {
try {
return parseState(fs.readFileSync(stateFile(), 'utf-8'))
} catch {
return {}
}
}
/** 序列化当前应用状态(同步/异步写盘共用) */
function serializeState(): string {
return JSON.stringify(appState)
}
/** 写回持久化状态(close 事件专用:同步写保证 app 退出前落盘完成) */
function saveStateSync(): void {
try {
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
const bounds = appState.bounds ? clampBounds(appState.bounds, wa) : null
// 主窗口
const mainWindow = new BrowserWindow({
width: 1280,
height: 820,
width: bounds?.width ?? 1280,
height: bounds?.height ?? 820,
x: bounds?.x,
y: bounds?.y,
minWidth: 900,
minHeight: 600,
show: false,
@@ -24,6 +130,15 @@ function createWindow(): void {
}
})
// 关闭时记忆窗口普通状态 bounds:最大化/全屏时 getNormalBounds 仍返回普通状态的
// 尺寸位置(本次会话调整过的窗口位置不因最大化关闭而丢失);最小化时不记录
mainWindow.on('close', () => {
if (mainWindow.isMinimized()) return
const b = mainWindow.getNormalBounds()
appState.bounds = { x: b.x, y: b.y, width: b.width, height: b.height }
saveStateSync()
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
@@ -50,90 +165,124 @@ function createWindow(): void {
/** 单侧文件大小上限(10MB):超限直接拦截,避免超大文件卡死界面 */
const MAX_FILE_SIZE = 10 * 1024 * 1024
/**
* 缓冲区二进制启发式判定:出现 NUL 字节立即判定;
* 否则统计前 8KB 内不可见控制字符占比(> 5% 视为二进制)。
*/
function looksBinary(buf: Buffer): boolean {
const len = Math.min(buf.length, 8000)
if (len === 0) return false
let suspicious = 0
for (let i = 0; i < len; i++) {
const b = buf[i]
if (b === 0) return true
if (b < 0x09 || (b > 0x0d && b < 0x20)) suspicious++
}
return suspicious / len > 0.05
}
/** 解码快路径阈值(256KB):小缓冲同步解码(毫秒级),大缓冲派发 worker 后台解码 */
const DECODE_FAST_PATH_BYTES = 256 * 1024
/**
* 探测文本编码:优先 BOM,其次严格 UTF-8,
* 失败则回退 GBK 解码,返回 { text, encoding, binary }。
* 解码 workerworker_threads)单例管理。
* 大文件解码(GBK 纯 JS 解码可达数百毫秒)移入后台线程,避免阻塞主进程事件循环;
* 崩溃时拒绝在途任务并销毁实例(下次请求重建),调用方回退主进程同步解码。
*/
function decodeText(buf: Buffer): { text: string; encoding: string; binary: boolean } {
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
return { text: buf.subarray(3).toString('utf8'), encoding: 'UTF-8 (BOM)', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
return { text: iconv.decode(buf.subarray(2), 'utf16-be'), encoding: 'UTF-16 BE', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
return { text: buf.subarray(2).toString('utf16le'), encoding: 'UTF-16 LE', binary: false }
}
// 严格 UTF-8 探测
let decodeWorker: Worker | null = null
let decodeJobSeq = 0
const pendingDecode = new Map<
number,
{ resolve: (r: DecodeResult) => void; reject: (e: Error) => void }
>()
/** 解码 worker 脚本路径:打包后位于 asar.unpackedElectron 的 asar 补丁不覆盖 worker 线程) */
function decodeWorkerPath(): string {
const p = join(__dirname, 'decodeWorker.js')
return p.includes(`app.asar${sep}`) ? p.replace(`app.asar${sep}`, `app.asar.unpacked${sep}`) : p
}
function getDecodeWorker(): Worker {
if (decodeWorker) return decodeWorker
const w = new Worker(decodeWorkerPath())
w.on('message', (msg: { jobId: number; result: DecodeResult }) => {
pendingDecode.get(msg.jobId)?.resolve(msg.result)
pendingDecode.delete(msg.jobId)
})
w.on('error', (err: Error) => {
for (const { reject } of pendingDecode.values()) reject(err)
pendingDecode.clear()
const dead = decodeWorker
decodeWorker = null
dead?.terminate().catch(() => {})
})
// 意外退出兜底:拒绝全部在途任务(error 分支已处理时此处为 no-op),下次请求重建
w.on('exit', () => {
for (const { reject } of pendingDecode.values()) reject(new Error('decode worker exited'))
pendingDecode.clear()
if (decodeWorker === w) decodeWorker = null
})
decodeWorker = w
return w
}
/** 大缓冲派发 worker 解码;worker 创建/运行失败时回退主进程同步解码(行为不降级) */
async function decodeMaybeWorker(buf: Buffer): Promise<DecodeResult> {
if (buf.length < DECODE_FAST_PATH_BYTES) return decodeText(buf)
const jobId = ++decodeJobSeq
return new Promise<DecodeResult>((resolve, reject) => {
let w: Worker
try {
w = getDecodeWorker()
} catch (e) {
reject(e instanceof Error ? e : new Error('decode worker unavailable'))
return
}
pendingDecode.set(jobId, { resolve, reject })
w.postMessage({ jobId, buffer: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) })
}).catch(() => decodeText(buf))
}
/** 按路径读取并解码文本文件(file:open 与 file:read-by-path 共用;10MB 上限) */
async function readTextFile(
filePath: string
): Promise<
| { path: string; name: string; text: string; encoding: string; binary: boolean }
| { error: 'too-large' | 'read-failed'; name: string; size?: number }
> {
const name = filePath.split(/[\\/]/).pop() ?? filePath
let buf: Buffer
try {
const decoder = new TextDecoder('utf-8', { fatal: true })
const text = decoder.decode(buf)
return { text, encoding: 'UTF-8', binary: false }
const st = await fs.promises.stat(filePath)
if (st.size > MAX_FILE_SIZE) {
return { error: 'too-large', name, size: st.size }
}
buf = await fs.promises.readFile(filePath)
} catch {
const text = iconv.decode(buf, 'gbk')
// GBK 覆盖面广,二进制内容也能解出“文字”;用字节启发式 + 替换符占比双保险
const bad = (text.match(/\uFFFD/g) ?? []).length
const binary = looksBinary(buf) || (text.length > 0 && bad / text.length > 0.05)
return { text, encoding: 'GBK', binary }
return { error: 'read-failed', name }
}
const { text, encoding, binary } = await decodeMaybeWorker(buf)
return { path: filePath, name, text, encoding, binary }
}
/** IPC: 打开文件对话框并读取内容 */
ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
const result = await dialog.showOpenDialog({
title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`,
// 记忆上次成功打开文件所在目录;无记录时传 undefined 走系统默认(最近使用位置)
defaultPath: appState.lastDir,
properties: ['openFile'],
filters: [
// 与渲染进程 TEXT_EXTENSIONSsrc/renderer/src/diff/textUtils.ts)完全对齐;两进程无法共享模块,改动需双向同步
{ name: '文本文件', 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'] },
{ name: '文本文件', extensions: TEXT_EXTENSIONS },
// 兜底入口:允许打开任意扩展名文件
{ name: '所有文件', extensions: ['*'] }
]
})
if (result.canceled || result.filePaths.length === 0) return null
const filePath = result.filePaths[0]
const name = filePath.split(/[\\/]/).pop() ?? filePath
let buf: Buffer
try {
const stat = await fs.promises.stat(filePath)
if (stat.size > MAX_FILE_SIZE) {
return { error: 'too-large', name, size: stat.size }
}
buf = await fs.promises.readFile(filePath)
} catch {
return { error: 'read-failed', name }
}
const { text, encoding, binary } = decodeText(buf)
return { path: filePath, name, text, encoding, binary }
// 记录所在目录供下次对话框起始定位(与窗口 bounds 同存一个状态文件)
appState.lastDir = dirname(filePath)
saveStateAsync()
return readTextFile(filePath)
})
/** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑) */
/** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑,大缓冲走 worker */
ipcMain.handle('file:decode-buffer', async (_event, buffer: ArrayBuffer) => {
return decodeText(Buffer.from(buffer))
return decodeMaybeWorker(Buffer.from(buffer))
})
/** IPC: 保存差异报告(系统保存对话框 + 写入内容) */
/** IPC: 保存差异报告(系统保存对话框 + 写入内容)
* 对话框起始目录记忆上次成功导出位置(频繁导出无需每次重新导航;
* 与 file:open 的 lastDir 语义不同,独立字段分开记忆) */
ipcMain.handle('file:save-report', async (_event, content: string, defaultName: string) => {
const ext = defaultName.split('.').pop() ?? 'txt'
const result = await dialog.showSaveDialog({
title: '保存差异报告',
defaultPath: join(app.getPath('documents'), defaultName),
defaultPath: join(appState.lastReportDir ?? app.getPath('documents'), defaultName),
filters: [
{ name: '差异报告', extensions: [ext] },
{ name: '所有文件', extensions: ['*'] }
@@ -142,6 +291,8 @@ ipcMain.handle('file:save-report', async (_event, content: string, defaultName:
if (result.canceled || !result.filePath) return { ok: false, path: null }
// 异步写盘,避免大报告(MB 级 HTML)同步写入时阻塞主进程事件循环
await fs.promises.writeFile(result.filePath, content, 'utf-8')
appState.lastReportDir = dirname(result.filePath)
saveStateAsync()
return { ok: true, path: result.filePath }
})
@@ -157,12 +308,161 @@ ipcMain.handle('clipboard:write', (_event, text: string) => {
return true
})
/** IPC: 选择文件夹(文件夹对比入口;记忆上次目录) */
ipcMain.handle('folder:pick', async () => {
const result = await dialog.showOpenDialog({
title: '选择要对比的文件夹',
defaultPath: appState.lastDir,
properties: ['openDirectory']
})
if (result.canceled || result.filePaths.length === 0) return null
const dirPath = result.filePaths[0]
appState.lastDir = dirPath
saveStateAsync()
return dirPath
})
/**
* IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等 + 忽略规则 + 增量缓存跨会话持久化;异步 fs 与解码 worker 不阻塞主进程)。
* scanId(渲染端扫描会话序号)原样携带在进度事件中:渲染端只显示最新会话的进度,
* 旧扫描迟到的进度事件天然被丢弃(与渲染端结果竞态守卫同一套序号,不引入新竞态面)。
*/
ipcMain.handle(
'folder:scan',
async (
event,
leftDir: string,
rightDir: string,
semantic: boolean,
previous?: unknown,
ignoreRules?: unknown,
scanId?: unknown
) => {
try {
// 入参防御性校验与增量缓存源选择(内存缓存优先,未携带/非法回退 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 抛错,进度丢失无影响
const onProgress = (done: number, total: number): void => {
try {
if (!event.sender.isDestroyed()) {
event.sender.send('folder:scan-progress', { scanId: id, done, total })
}
} catch {
// 窗口已销毁等:忽略
}
}
const result = await scanFolders(leftDir, rightDir, {
...args,
onProgress,
...(semantic
? {
// 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线)
semantic: {
maxBytes: SEMANTIC_MAX_BYTES,
isTextFile: isTextRel,
decode: async (buf) => (await decodeMaybeWorker(buf)).text
}
}
: {})
})
// 扫描成功且未截断:缓存异步落盘(LRU,失败静默;下次启动同目录对可增量重扫)
if (!result.truncated) persistScanCache(leftDir, rightDir, result.cache)
return result
} catch {
// 目录不存在/不可读等:转为错误标记,渲染端提示
return { error: 'scan-failed' as const }
}
}
)
/** IPC: 按绝对路径读取文本文件(文件夹对比双击进入单文件对比;与 file:open 同一读取管线) */
ipcMain.handle('file:read-by-path', async (_event, filePath: string) => {
return readTextFile(filePath)
})
/**
* 快照文件的读取上限(128MB):保存侧内容总量上限 32M 字符(中文 JSON 化后可达 ~96MB),
* 走通用读取管线的 10MB 上限会拦截自家保存的大快照(保存成功却无法加载的自相矛盾),
* 故快照加载走本专用通道;上限仍防御异常超大文件拖垮渲染进程。
*/
const SNAPSHOT_MAX_BYTES = 128 * 1024 * 1024
/**
* IPC: 选择并读取快照文件(专用通道,与 file:open 分离)。
* - 读取上限 128MB(快照为应用自有 JSON,可大于通用文件的 10MB);
* - 快照始终以 UTF-8 写盘,直接按 UTF-8 解码,无需编码探测;
* - 不写 lastDir(快照目录与常用文件目录语义不同,避免污染文件对话框记忆)。
*/
ipcMain.handle('snapshot:read', async () => {
const result = await dialog.showOpenDialog({
title: '选择快照文件',
properties: ['openFile'],
filters: [
{ name: '快照文件', extensions: ['json'] },
{ name: '所有文件', extensions: ['*'] }
]
})
if (result.canceled || result.filePaths.length === 0) return null
const filePath = result.filePaths[0]
const name = filePath.split(/[\\/]/).pop() ?? filePath
let buf: Buffer
try {
const st = await fs.promises.stat(filePath)
if (st.size > SNAPSHOT_MAX_BYTES) {
return { error: 'too-large' as const, name, size: st.size }
}
buf = await fs.promises.readFile(filePath)
} catch {
return { error: 'read-failed' as const, name }
}
return { path: filePath, name, text: buf.toString('utf-8'), encoding: 'UTF-8', binary: false }
})
/** IPC: 应用版本号(package.json 的 version 为唯一来源,dev 与打包行为一致) */
ipcMain.handle('app:get-version', () => app.getVersion())
/** 批量读取的路径数上限(渲染端快照保存按差异条目收集,超出说明异常输入,整体拒绝) */
const READ_BATCH_MAX_PATHS = 256
/**
* IPC: 批量读取多个文本文件(快照保存收集差异条目内容用)。
* 与单文件读取同一管线(10MB 上限/编码探测/解码 worker),受控并发分批;
* 单文件失败该条目返回错误对象不整体失败(与 folderScan 的单点降级哲学一致),
* 渲染端把失败条目按"该侧无内容"处理。
*/
ipcMain.handle('folder:read-batch', async (_event, paths: unknown) => {
if (
!Array.isArray(paths) ||
paths.length === 0 ||
paths.length > READ_BATCH_MAX_PATHS ||
!paths.every((p) => typeof p === 'string' && p.length > 0)
) {
return { error: 'invalid' as const }
}
const results = new Array<Awaited<ReturnType<typeof readTextFile>>>(paths.length)
for (let i = 0; i < paths.length; i += SCAN_CONCURRENCY) {
const batch = paths.slice(i, i + SCAN_CONCURRENCY)
await Promise.all(
batch.map(async (p, k) => {
results[i + k] = await readTextFile(p)
})
)
}
return { files: results }
})
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.metonateam.difflens')
// 强制暗色系统主题:Windows 标题栏/滚动条等系统控件跟随应用暗色科幻风格
nativeTheme.themeSource = 'dark'
// 读取持久化应用状态(窗口 bounds + 上次文件目录;损坏数据回退空状态)
appState = loadState()
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest'
import type { ScanCacheEntry } from './folderScan'
import {
parseScanCacheStore,
putScanCache,
getScanCache,
pairKey,
emptyScanCacheStore,
SCAN_CACHE_PAIRS_MAX,
SCAN_CACHE_VERSION
} from './scanCacheStore'
/** 构造一条合法缓存条目 */
function entry(rel: string, status: ScanCacheEntry['status'] = 'same'): ScanCacheEntry {
return { rel, leftFp: { size: 1, mtime: 100 }, rightFp: { size: 1, mtime: 100 }, status, semSame: null }
}
describe('pairKey - 目录对键归一化', () => {
it('反斜杠转斜杠、去尾分隔符', () => {
expect(pairKey('C:\\left\\', 'D:\\right\\')).toBe('c:/left|d:/right')
})
it('Windows 盘符大小写归一(其余路径段保留原大小写)', () => {
expect(pairKey('d:/Foo', 'D:/Foo')).toBe('d:/Foo|d:/Foo')
expect(pairKey('D:\\Foo', 'd:/Foo')).toBe('d:/Foo|d:/Foo')
// 大小写敏感文件系统的不同路径不撞键
expect(pairKey('/home/A', '/home/B')).toBe('/home/A|/home/B')
expect(pairKey('/home/A', '/home/a')).not.toBe(pairKey('/home/A', '/home/A'))
})
})
describe('parseScanCacheStore - 防御性解析', () => {
it('空输入 / 非法 JSON / 版本不符回退空存储', () => {
expect(parseScanCacheStore(null)).toEqual(emptyScanCacheStore())
expect(parseScanCacheStore('')).toEqual(emptyScanCacheStore())
expect(parseScanCacheStore('{broken json!!')).toEqual(emptyScanCacheStore())
expect(parseScanCacheStore(JSON.stringify({ version: 999, pairs: {} }))).toEqual(
emptyScanCacheStore()
)
})
it('顶层结构异常(pairs 非对象)回退空存储', () => {
expect(parseScanCacheStore(JSON.stringify({ version: 1, pairs: [] }))).toEqual(
emptyScanCacheStore()
)
expect(parseScanCacheStore(JSON.stringify({ version: 1, pairs: 'x' }))).toEqual(
emptyScanCacheStore()
)
})
it('形态异常的 pair 被丢弃,合法 pair 保留', () => {
const raw = JSON.stringify({
version: 1,
pairs: {
'a|b': { cache: [entry('x.txt')], savedAt: 100 },
bad: { cache: 'not-array', savedAt: 100 },
bad2: { cache: [entry('y.txt')], savedAt: 'no-time' },
bad3: null
}
})
const data = parseScanCacheStore(raw)
expect(Object.keys(data.pairs)).toEqual(['a|b'])
expect(getScanCache(data, 'a', 'b')).toEqual([entry('x.txt')])
})
it('单条异常 entry 被过滤,其余条目保留', () => {
const good = entry('keep.txt')
const raw = JSON.stringify({
version: 1,
pairs: {
'a|b': {
cache: [
good,
{ rel: 'x', status: 'unknown-status', leftFp: null, rightFp: null, semSame: null },
{ rel: '', status: 'same', leftFp: null, rightFp: null, semSame: null },
{ rel: 'y', status: 'same', leftFp: { size: 'big', mtime: 1 }, rightFp: null, semSame: null },
{ rel: 'z', status: 'same', leftFp: null, rightFp: null, semSame: 'maybe' },
'garbage'
],
savedAt: 200
}
}
})
const data = parseScanCacheStore(raw)
expect(getScanCache(data, 'a', 'b')).toEqual([good])
})
})
describe('putScanCache / getScanCache - 写入读取与 LRU', () => {
it('写入后可按归一化键读取(盘上路径写法差异同键)', () => {
let data = emptyScanCacheStore()
data = putScanCache(data, 'C:\\left', 'D:\\right', [entry('a.txt')], 1000)
expect(getScanCache(data, 'c:/left', 'd:/right')).toEqual([entry('a.txt')])
})
it('未写入的目录对返回 null', () => {
const data = putScanCache(emptyScanCacheStore(), 'a', 'b', [entry('a.txt')], 1000)
expect(getScanCache(data, 'x', 'y')).toBeNull()
})
it('写入条目为深拷贝(源数组后续修改不影响存储)', () => {
const src = [entry('a.txt')]
const data = putScanCache(emptyScanCacheStore(), 'a', 'b', src, 1000)
src[0].rel = 'mutated.txt'
expect(getScanCache(data, 'a', 'b')![0].rel).toBe('a.txt')
})
it('同目录对重复写入覆盖旧缓存', () => {
let data = putScanCache(emptyScanCacheStore(), 'a', 'b', [entry('old.txt')], 1000)
data = putScanCache(data, 'a', 'b', [entry('new.txt')], 2000)
expect(getScanCache(data, 'a', 'b')!.map((c) => c.rel)).toEqual(['new.txt'])
expect(Object.keys(data.pairs)).toHaveLength(1)
})
it(`超上限(${SCAN_CACHE_PAIRS_MAX} 对)按 savedAt 淘汰最旧,当前写入对保留`, () => {
let data = emptyScanCacheStore()
// 先写满 8 对,时间递增
for (let i = 0; i < SCAN_CACHE_PAIRS_MAX; i++) {
data = putScanCache(data, `l${i}`, `r${i}`, [entry(`f${i}.txt`)], 1000 + i)
}
// 第 9 对写入:最旧的 l0|r0 被淘汰
data = putScanCache(data, 'newL', 'newR', [entry('new.txt')], 9000)
expect(Object.keys(data.pairs)).toHaveLength(SCAN_CACHE_PAIRS_MAX)
expect(getScanCache(data, 'l0', 'r0')).toBeNull()
expect(getScanCache(data, 'l1', 'r1')).not.toBeNull()
expect(getScanCache(data, 'newL', 'newR')).not.toBeNull()
})
it('重写旧目录对更新其 savedAt:下次淘汰跳过它', () => {
let data = emptyScanCacheStore()
for (let i = 0; i < SCAN_CACHE_PAIRS_MAX; i++) {
data = putScanCache(data, `l${i}`, `r${i}`, [entry(`f${i}.txt`)], 1000 + i)
}
// 刷新最旧的 l0savedAt 1000 → 8000
data = putScanCache(data, 'l0', 'r0', [entry('fresh.txt')], 8000)
// 新对写入:淘汰的应是最旧的 l1(1001)
data = putScanCache(data, 'newL', 'newR', [entry('new.txt')], 9000)
expect(getScanCache(data, 'l0', 'r0')![0].rel).toBe('fresh.txt')
expect(getScanCache(data, 'l1', 'r1')).toBeNull()
})
})
describe('putScanCache 产出的存储可被 parseScanCacheStore 往返', () => {
it('序列化 → 解析后条目与键完整保留', () => {
let data = emptyScanCacheStore()
data = putScanCache(data, 'C:\\l', 'D:\\r', [entry('a.txt', 'different')], 12345)
const parsed = parseScanCacheStore(JSON.stringify(data))
expect(parsed.version).toBe(SCAN_CACHE_VERSION)
expect(getScanCache(parsed, 'c:/l', 'd:/r')).toEqual([entry('a.txt', 'different')])
})
})
+132
View File
@@ -0,0 +1,132 @@
import type { ScanCacheEntry } from './folderScan'
import { normDir } from '../shared/pathNorm'
/**
* 文件夹扫描增量缓存的跨会话持久化纯数据模块(无 electron/fs 依赖,vitest 直接测试)。
* 磁盘读写由主进程调用方完成(userData/scan-cache.json)。
*
* 结构:{ version, pairs: { [目录对键]: { cache, savedAt } } }
* - 目录对键归一化:反斜杠→斜杠、去尾分隔符、Windows 盘符小写
* D:\Foo 与 d:/Foo 同键;大小写敏感文件系统的路径不强制小写,避免误撞键);
* - LRU 上限 8 对,按 savedAt 淘汰最旧(正在写入的目录对永不淘汰);
* - 解析全部防御性校验:JSON 非法 / 版本不符 → 整体回退空存储;
* 单条缓存条目形态异常 → 丢弃该条(pair 内其余条目保留)。
*/
/** 存储结构版本(不兼容变更时递增,旧文件整体作废) */
export const SCAN_CACHE_VERSION = 1
/** 保存的目录对数量上限(超出按 savedAt 淘汰最旧) */
export const SCAN_CACHE_PAIRS_MAX = 8
export interface StoredScanCache {
cache: ScanCacheEntry[]
/** 写入时间戳(LRU 淘汰依据) */
savedAt: number
}
export interface ScanCacheStoreData {
version: number
pairs: Record<string, StoredScanCache>
}
/** 空存储(解析失败/文件不存在时的回退) */
export const emptyScanCacheStore = (): ScanCacheStoreData => ({
version: SCAN_CACHE_VERSION,
pairs: {}
})
/** 目录对键:两个归一化路径(shared/pathNorm)以 | 拼接(| 不出现在路径合法字符中) */
export function pairKey(leftDir: string, rightDir: string): string {
return `${normDir(leftDir)}|${normDir(rightDir)}`
}
/** 缓存条目的合法状态值 */
const CACHE_STATUSES = ['same', 'semantic-same', 'different', 'left-only', 'right-only']
function isFingerprint(v: unknown): v is { size: number; mtime: number } {
if (typeof v !== 'object' || v === null) return false
const f = v as Record<string, unknown>
return (
typeof f.size === 'number' &&
Number.isFinite(f.size) &&
typeof f.mtime === 'number' &&
Number.isFinite(f.mtime)
)
}
/** 单条缓存条目形态校验(盘上数据不可信,逐字段防御) */
function isCacheEntry(v: unknown): v is ScanCacheEntry {
if (typeof v !== 'object' || v === null) return false
const e = v as Record<string, unknown>
if (typeof e.rel !== 'string' || e.rel === '') return false
if (typeof e.status !== 'string' || !CACHE_STATUSES.includes(e.status)) return false
if (e.leftFp !== null && !isFingerprint(e.leftFp)) return false
if (e.rightFp !== null && !isFingerprint(e.rightFp)) return false
if (e.semSame !== null && typeof e.semSame !== 'boolean') return false
if (e.approximate !== undefined && typeof e.approximate !== 'boolean') return false
return true
}
/**
* 解析持久化文本为存储对象:JSON 非法 / 版本不符 / 顶层结构异常回退空存储;
* 单个 pair 或单条 entry 异常仅丢弃该部分,其余保留。
*/
export function parseScanCacheStore(raw: string | null | undefined): ScanCacheStoreData {
if (!raw) return emptyScanCacheStore()
try {
const obj = JSON.parse(raw) as Record<string, unknown>
if (obj.version !== SCAN_CACHE_VERSION) return emptyScanCacheStore()
if (typeof obj.pairs !== 'object' || obj.pairs === null || Array.isArray(obj.pairs)) {
return emptyScanCacheStore()
}
const pairs: Record<string, StoredScanCache> = {}
for (const [key, val] of Object.entries(obj.pairs as Record<string, unknown>)) {
if (typeof val !== 'object' || val === null) continue
const p = val as Record<string, unknown>
if (!Array.isArray(p.cache) || typeof p.savedAt !== 'number' || !Number.isFinite(p.savedAt)) {
continue
}
const cache = p.cache.filter(isCacheEntry)
pairs[key] = { cache, savedAt: p.savedAt }
}
return { version: SCAN_CACHE_VERSION, pairs }
} catch {
return emptyScanCacheStore()
}
}
/**
* 写入/更新一对目录的缓存(LRU 上限淘汰):
* 深拷贝条目(防调用方后续复用引用);超上限按 savedAt 升序淘汰最旧,
* 当前写入的目录对(savedAt = now,最新)不在淘汰之列。
*/
export function putScanCache(
data: ScanCacheStoreData,
leftDir: string,
rightDir: string,
cache: ScanCacheEntry[],
now: number
): ScanCacheStoreData {
const pairs: Record<string, StoredScanCache> = { ...data.pairs }
const key = pairKey(leftDir, rightDir)
pairs[key] = { cache: cache.map((c) => ({ ...c })), savedAt: now }
const keys = Object.keys(pairs)
if (keys.length > SCAN_CACHE_PAIRS_MAX) {
const others = keys
.filter((k) => k !== key)
.sort((a, b) => pairs[a].savedAt - pairs[b].savedAt)
const overflow = keys.length - SCAN_CACHE_PAIRS_MAX
for (let i = 0; i < overflow && i < others.length; i++) delete pairs[others[i]]
}
return { version: SCAN_CACHE_VERSION, pairs }
}
/** 读取一对目录的缓存(不存在返回 null,调用方按无缓存全量扫描) */
export function getScanCache(
data: ScanCacheStoreData,
leftDir: string,
rightDir: string
): ScanCacheEntry[] | null {
const hit = data.pairs[pairKey(leftDir, rightDir)]
return hit !== undefined ? hit.cache : null
}
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest'
import type { ScanCacheEntry } from './folderScan'
import { isScanCacheEntries, resolveScanArgs } from './scanIpc'
/** 构造一条合法缓存条目(IPC 形态校验只看 rel/status 字符串字段) */
function entry(rel: string): ScanCacheEntry {
return { rel, leftFp: { size: 1, mtime: 100 }, rightFp: { size: 1, mtime: 100 }, status: 'same', semSame: null }
}
/** 盘上缓存读取 stub:返回指定缓存并记录调用 */
function storedLoader(cache: ScanCacheEntry[] | null): (() => ScanCacheEntry[] | null) & { calls: number } {
const f = (): ScanCacheEntry[] | null => {
f.calls++
return cache
}
f.calls = 0
return f
}
describe('isScanCacheEntries - IPC 形态校验', () => {
it('合法缓存数组通过(rel/status 为字符串)', () => {
expect(isScanCacheEntries([])).toBe(true)
expect(isScanCacheEntries([entry('a.txt'), entry('b.txt')])).toBe(true)
})
it('非数组 / 含非对象 / rel 或 status 非字符串拒绝', () => {
expect(isScanCacheEntries(null)).toBe(false)
expect(isScanCacheEntries(undefined)).toBe(false)
expect(isScanCacheEntries('cache')).toBe(false)
expect(isScanCacheEntries([entry('a.txt'), null])).toBe(false)
expect(isScanCacheEntries([entry('a.txt'), 'garbage'])).toBe(false)
expect(isScanCacheEntries([{ rel: 42, status: 'same' }])).toBe(false)
expect(isScanCacheEntries([{ rel: 'a.txt', status: 1 }])).toBe(false)
expect(isScanCacheEntries([{ rel: 'a.txt' }])).toBe(false)
})
})
describe('resolveScanArgs - 缓存源选择与忽略规则校验', () => {
it('渲染端携带合法内存缓存:原样传递且不读盘(惰性)', async () => {
const stored = storedLoader([entry('disk.txt')])
const mem = [entry('mem.txt')]
const r = await resolveScanArgs(mem, undefined, stored)
expect(r.previous).toBe(mem)
expect(stored.calls).toBe(0)
})
it('内存缓存为空数组(合法形态):使用空数组,同样不注入盘上缓存', async () => {
// 空数组是同目录上次扫描的最新形态(如空目录对),应优先于可能过期的盘上缓存
const stored = storedLoader([entry('disk.txt')])
const r = await resolveScanArgs([], undefined, stored)
expect(r.previous).toEqual([])
expect(stored.calls).toBe(0)
})
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 = await resolveScanArgs(bad, undefined, stored)
expect(r.previous).toBe(disk)
expect(stored.calls).toBe(1)
}
})
it('盘上缓存为 null 或空数组:不注入 previous(全量扫描)', async () => {
expect((await resolveScanArgs(undefined, undefined, storedLoader(null))).previous).toBeUndefined()
expect((await resolveScanArgs(undefined, undefined, storedLoader([]))).previous).toBeUndefined()
})
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((await resolveScanArgs(undefined, [], storedLoader(null))).ignore).toEqual([])
})
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(
(
await resolveScanArgs(
undefined,
Array.from({ length: 51 }, () => 'a'),
storedLoader(null)
)
).ignore
).toBeUndefined()
})
it('组合:内存缓存非法 + 忽略规则合法 + 盘上缓存有效', async () => {
const disk = [entry('disk.txt')]
const stored = storedLoader(disk)
const r = await resolveScanArgs([{ rel: 'bad' }], ['dist/'], stored)
expect(r.previous).toBe(disk)
expect(r.ignore).toEqual(['dist/'])
expect(stored.calls).toBe(1)
})
})
+58
View File
@@ -0,0 +1,58 @@
import type { ScanCacheEntry } from './folderScan'
import { isIgnoreRulesArray } from '../shared/ignoreRules'
/**
* folder:scan IPC 入参的防御性校验与增量缓存源选择(纯逻辑,无 electron/fs 依赖)。
* 渲染端经 IPC 传入的数据不可信(跨进程边界),逐字段校验后由主进程 handler 组装扫描参数;
* 此前该逻辑内联在 index.ts(覆盖率排除),抽出后可被 vitest 直接测试。
*/
/** previous(增量重扫缓存)是否为可接受的 IPC 形态:数组且每条含字符串 rel/status。
* 只做形态级校验(渲染端数据来自上次扫描结果或损坏的偏好存储);字段级合法性
* (指纹形态等)由 scanFolders 的指纹比对与 scanCacheStore 的解析校验兜底 */
export function isScanCacheEntries(v: unknown): v is ScanCacheEntry[] {
return (
Array.isArray(v) &&
v.every(
(c) =>
typeof c === 'object' &&
c !== null &&
typeof (c as ScanCacheEntry).rel === 'string' &&
typeof (c as ScanCacheEntry).status === 'string'
)
)
}
export interface ResolvedScanArgs {
/** 增量重扫缓存:渲染端携带的内存缓存优先(含空数组——同目录上次扫描的最新形态,
* 阻止注入可能过期的盘上缓存);形态非法/未携带时回退盘上持久化缓存;两者皆无则缺省 */
previous?: ScanCacheEntry[]
/** 忽略规则:仅接受合法字符串数组形态(isIgnoreRulesArray 校验,空数组合法),非法即忽略 */
ignore?: string[]
}
/**
* 组合 folder:scan 的入参校验与缓存源选择:
* - previous 形态异常(非数组 / 条目缺字符串 rel/status)一律不沿用;
* - previous 未携带或非法时经 loadStored 惰性读取盘上持久化缓存(非空才注入),
* 惰性求值(await 仅在需要回退时发生)保证渲染端携带有效内存缓存时不产生读盘 IO;
* - loadStored 可同步或异步(生产实现为 fs.promises 异步读盘,测试注入同步实现),
* 异步化保证 IPC handler 内不再有同步 IO 阻塞主进程事件循环;
* - ignoreRules 复用 isIgnoreRulesArray(数组形态 + 条数/单条长度上限)。
*/
export async function resolveScanArgs(
previous: unknown,
ignoreRules: unknown,
loadStored: () => ScanCacheEntry[] | null | Promise<ScanCacheEntry[] | null>
): Promise<ResolvedScanArgs> {
const mem = isScanCacheEntries(previous) ? previous : undefined
const fallback = async (): Promise<ScanCacheEntry[] | undefined> => {
const stored = await loadStored()
return stored !== null && stored.length > 0 ? stored : undefined
}
const previousOut = mem !== undefined ? mem : await fallback()
return {
...(previousOut !== undefined ? { previous: previousOut } : {}),
...(isIgnoreRulesArray(ignoreRules) ? { ignore: ignoreRules } : {})
}
}
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest'
import { parseState, clampBounds, MIN_WIDTH, MIN_HEIGHT, type Rect } from './windowState'
/** 1920×1040 工作区(常见全高清屏去掉任务栏) */
const work: Rect = { x: 0, y: 0, width: 1920, height: 1040 }
describe('parseState', () => {
it('空输入回退空状态', () => {
expect(parseState(null)).toEqual({})
expect(parseState(undefined)).toEqual({})
expect(parseState('')).toEqual({})
})
it('非法 JSON 回退空状态', () => {
expect(parseState('{broken json!!')).toEqual({})
})
it('合法 bounds 与 lastDir 被保留', () => {
const s = parseState(
JSON.stringify({ bounds: { x: 10, y: 20, width: 1000, height: 700 }, lastDir: 'C:/docs' })
)
expect(s.bounds).toEqual({ x: 10, y: 20, width: 1000, height: 700 })
expect(s.lastDir).toBe('C:/docs')
})
it('bounds 字段类型异常或缺失时被忽略', () => {
expect(parseState(JSON.stringify({ bounds: { x: 'a', y: 2, width: 1000, height: 700 } })).bounds).toBeUndefined()
expect(parseState(JSON.stringify({ bounds: { x: 1, y: 2, width: 1000 } })).bounds).toBeUndefined()
expect(parseState(JSON.stringify({ bounds: null })).bounds).toBeUndefined()
})
it('bounds 含 NaN/Infinity 等非有限数值时被忽略', () => {
const s = parseState(
'{"bounds":{"x":NaN,"y":2,"width":1000,"height":700}}'
)
expect(s.bounds).toBeUndefined()
})
it('lastDir 非字符串或空串时被忽略', () => {
expect(parseState(JSON.stringify({ lastDir: 42 })).lastDir).toBeUndefined()
expect(parseState(JSON.stringify({ lastDir: '' })).lastDir).toBeUndefined()
})
it('lastReportDir 合法字符串被保留;非字符串或空串被忽略(0.11.0)', () => {
const s = parseState(JSON.stringify({ lastReportDir: 'C:/export' }))
expect(s.lastReportDir).toBe('C:/export')
expect(parseState(JSON.stringify({ lastReportDir: 42 })).lastReportDir).toBeUndefined()
expect(parseState(JSON.stringify({ lastReportDir: '' })).lastReportDir).toBeUndefined()
expect(parseState(JSON.stringify({})).lastReportDir).toBeUndefined()
})
})
describe('clampBounds', () => {
it('工作区内合法 bounds 原样保留', () => {
const b = { x: 100, y: 50, width: 1280, height: 820 }
expect(clampBounds(b, work)).toEqual(b)
})
it('小于最小尺寸时钳制到最小值', () => {
const c = clampBounds({ x: 100, y: 100, width: 400, height: 300 }, work)
expect(c.width).toBe(MIN_WIDTH)
expect(c.height).toBe(MIN_HEIGHT)
})
it('超出工作区尺寸时封顶到工作区大小', () => {
const c = clampBounds({ x: 0, y: 0, width: 4000, height: 3000 }, work)
expect(c.width).toBe(work.width)
expect(c.height).toBe(work.height)
})
it('窗口移出屏幕左侧时拉回(右边缘至少 60px 进入工作区)', () => {
const c = clampBounds({ x: -2000, y: 100, width: 1000, height: 700 }, work)
expect(c.x).toBe(work.x - 1000 + 60)
})
it('窗口移出屏幕右侧时拉回(左边缘至少 60px 进入工作区)', () => {
const c = clampBounds({ x: 5000, y: 100, width: 1000, height: 700 }, work)
expect(c.x).toBe(work.x + work.width - 60)
})
it('窗口移出屏幕下方时拉回', () => {
const c = clampBounds({ x: 100, y: 5000, width: 1000, height: 700 }, work)
expect(c.y).toBe(work.y + work.height - 60)
})
it('带偏移的工作区(多显示器副屏)同样钳制', () => {
const side: Rect = { x: 1920, y: 200, width: 1080, height: 900 }
const c = clampBounds({ x: 0, y: 0, width: 1000, height: 700 }, side)
// 左边缘至少 60px 进入副屏
expect(c.x).toBe(side.x - 1000 + 60)
})
it('非整数尺寸与坐标被取整', () => {
const c = clampBounds({ x: 10.6, y: 20.2, width: 1000.7, height: 700.1 }, work)
expect(Number.isInteger(c.x)).toBe(true)
expect(Number.isInteger(c.y)).toBe(true)
expect(Number.isInteger(c.width)).toBe(true)
expect(Number.isInteger(c.height)).toBe(true)
})
it('工作区小于最小尺寸时尺寸仍保持最小值', () => {
const tiny: Rect = { x: 0, y: 0, width: 800, height: 500 }
const c = clampBounds({ x: 0, y: 0, width: 1000, height: 700 }, tiny)
expect(c.width).toBe(MIN_WIDTH)
expect(c.height).toBe(MIN_HEIGHT)
})
})
+75
View File
@@ -0,0 +1,75 @@
/**
* 应用状态持久化的纯数据模块:窗口尺寸/位置记忆 + 文件对话框上次目录记忆。
* 不依赖 electron 与 fs(磁盘读写由主进程调用方完成),校验逻辑可被 vitest 直接测试。
*/
/** 矩形区域(窗口 bounds 与屏幕工作区共用结构,字段与 Electron Rectangle 一致) */
export interface Rect {
x: number
y: number
width: number
height: number
}
/** 持久化的应用状态 */
export interface AppState {
/** 上次关闭时的窗口 bounds */
bounds?: Rect
/** 上次成功打开文件所在目录(file:open 对话框 defaultPath */
lastDir?: string
/** 上次成功导出报告所在目录(file:save-report 对话框 defaultPath;与 lastDir 语义不同分开记忆) */
lastReportDir?: string
}
/** 最小窗口尺寸(与 BrowserWindow 的 minWidth/minHeight 保持一致,改动需双向同步) */
export const MIN_WIDTH = 900
export const MIN_HEIGHT = 600
function isRect(v: unknown): v is Rect {
if (typeof v !== 'object' || v === null) return false
const r = v as Record<string, unknown>
return (
typeof r.x === 'number' &&
Number.isFinite(r.x) &&
typeof r.y === 'number' &&
Number.isFinite(r.y) &&
typeof r.width === 'number' &&
Number.isFinite(r.width) &&
typeof r.height === 'number' &&
Number.isFinite(r.height)
)
}
/**
* 解析持久化文本为状态对象:JSON 非法 / 字段类型异常一律回退空状态,
* 保证损坏数据不影响启动。
*/
export function parseState(raw: string | null | undefined): AppState {
if (!raw) return {}
try {
const obj = JSON.parse(raw) as AppState
const state: AppState = {}
if (isRect(obj.bounds)) state.bounds = obj.bounds
if (typeof obj.lastDir === 'string' && obj.lastDir !== '') state.lastDir = obj.lastDir
if (typeof obj.lastReportDir === 'string' && obj.lastReportDir !== '') {
state.lastReportDir = obj.lastReportDir
}
return state
} catch {
return {}
}
}
/**
* 把窗口 bounds 钳制到屏幕工作区:
* - 尺寸不小于最小值、不超过工作区大小(工作区小于最小值时以最小值为准);
* - 位置保证窗口至少 60px 落在工作区内(分辨率/显示器变更后不出现“看不见的窗口”);
* - 结果取整(BrowserWindow 接受整数坐标)。
*/
export function clampBounds(b: Rect, workArea: Rect): Rect {
const width = Math.min(Math.max(b.width, MIN_WIDTH), Math.max(workArea.width, MIN_WIDTH))
const height = Math.min(Math.max(b.height, MIN_HEIGHT), Math.max(workArea.height, MIN_HEIGHT))
const x = Math.min(Math.max(b.x, workArea.x - width + 60), workArea.x + workArea.width - 60)
const y = Math.min(Math.max(b.y, workArea.y - height + 60), workArea.y + workArea.height - 60)
return { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) }
}
+108 -1
View File
@@ -22,6 +22,68 @@ export interface FileOpenError {
/** 打开文件结果:成功数据 / 失败原因 / 用户取消(null) */
export type FileOpenResult = FileData | FileOpenError | null
/** 文件夹对比条目状态(与主进程 folderScan 对齐;semantic-same = 字节不同但剔除空白后内容一致;unreadable = 读取失败被锁/权限) */
export type FolderEntryStatus =
| 'same'
| 'semantic-same'
| 'different'
| 'left-only'
| 'right-only'
| 'unreadable'
/** 文件夹对比单个条目 */
export interface FolderEntry {
rel: string
status: FolderEntryStatus
leftSize: number | null
rightSize: number | null
approximate?: boolean
}
/** 文件夹扫描结果 */
export interface FolderScanResult {
entries: FolderEntry[]
truncated: boolean
total: number
/** 增量重扫缓存(与主进程 folderScan 的 ScanCacheEntry 对齐;截断时为空数组)。
* 下次扫描同目录时携带可零 IO 沿用指纹一致的判定 */
cache?: FolderScanCacheEntry[]
}
/** 单侧文件指纹(size + mtimeMs),增量重扫的变更判定依据 */
export interface FolderFileFingerprint {
size: number
mtime: number
}
/** 增量重扫缓存条目(与主进程 folderScan 对齐;unreadable 不进缓存) */
export interface FolderScanCacheEntry {
rel: string
leftFp: FolderFileFingerprint | null
rightFp: FolderFileFingerprint | null
status: 'same' | 'semantic-same' | 'different' | 'left-only' | 'right-only'
approximate?: boolean
/** 上次是否参与过语义判等及其结果(null = 未参与);语义开关翻转时非 null 可零 IO 组合 */
semSame: boolean | null
}
/** 文件夹扫描失败(目录不存在/不可读) */
export interface FolderScanError {
error: 'scan-failed'
}
/** 扫描进度事件负载(folder:scan-progress 推送;scanId 与渲染端扫描会话序号对应) */
export interface FolderScanProgress {
scanId: number
done: number
total: number
}
/** 批量读取结果:全部路径的读取结果(按入参顺序;单文件失败为错误对象,不整体失败) */
export interface ReadBatchResult {
files: FileOpenResult[]
}
/** 主进程暴露给渲染进程的安全 API */
const api = {
openFile: (side?: 'left' | 'right'): Promise<FileOpenResult> =>
@@ -33,7 +95,52 @@ const api = {
showInFolder: (filePath: string): Promise<boolean> =>
ipcRenderer.invoke('file:show-in-folder', filePath),
setClipboard: (text: string): Promise<boolean> =>
ipcRenderer.invoke('clipboard:write', text)
ipcRenderer.invoke('clipboard:write', text),
pickFolder: (): Promise<string | null> =>
ipcRenderer.invoke('folder:pick'),
scanFolder: (
leftDir: string,
rightDir: string,
semantic: boolean,
previous?: FolderScanCacheEntry[] | null,
/** 忽略规则(gitignore 简化子集含高级 glob;命中的目录整树剪枝、文件跳过) */
ignoreRules?: string[] | null,
/** 扫描会话序号(渲染端 scanSeq;进度事件携带回传,渲染端只显示最新会话的进度) */
scanId?: number
): Promise<FolderScanResult | FolderScanError> =>
ipcRenderer.invoke(
'folder:scan',
leftDir,
rightDir,
semantic,
previous ?? null,
ignoreRules ?? null,
scanId ?? -1
),
/**
* 订阅扫描进度推送(首个 push 型 IPC):返回取消订阅函数。
* 事件负载带 scanId,渲染端按当前会话序号过滤,旧扫描迟到的进度被丢弃。
*/
onScanProgress: (cb: (data: FolderScanProgress) => void): (() => void) => {
const listener = (_e: unknown, data: FolderScanProgress): void => cb(data)
ipcRenderer.on('folder:scan-progress', listener)
return (): void => {
ipcRenderer.removeListener('folder:scan-progress', listener)
}
},
readByPath: (filePath: string): Promise<FileOpenResult> =>
ipcRenderer.invoke('file:read-by-path', filePath),
/** 批量读取文本文件(快照保存收集差异条目内容用;单文件失败为错误对象不整体失败) */
readFilesByPaths: (paths: string[]): Promise<ReadBatchResult | { error: 'invalid' }> =>
ipcRenderer.invoke('folder:read-batch', paths),
/**
* 选择并读取快照文件(专用通道):读取上限 128MB(通用文件 10MB 上限会拦截
* 自家保存的大快照),UTF-8 直读不做编码探测,不写 lastDir 记忆。
* 返回结构与 openFile 一致(成功数据 / 失败原因 / 用户取消 null)。
*/
readSnapshot: (): Promise<FileOpenResult> => ipcRenderer.invoke('snapshot:read'),
/** 应用版本号(主进程 app.getVersion()package.json 的 version 为唯一来源) */
getVersion: (): Promise<string> => ipcRenderer.invoke('app:get-version')
}
export type DiffLensApi = typeof api
+1141 -136
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15
View File
@@ -8,6 +8,21 @@ describe('main 入口', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="root"></div>'
vi.resetModules()
// App 挂载期订阅扫描进度推送(onScanProgress):入口冒烟需要可用的 api 桩
window.api = {
openFile: async () => null,
decodeBuffer: async () => ({ text: '', encoding: 'UTF-8', binary: false }),
saveReport: async () => ({ ok: false, path: null }),
showInFolder: async () => true,
setClipboard: async () => true,
pickFolder: async () => null,
scanFolder: async () => ({ entries: [], truncated: false, total: 0 }),
onScanProgress: (): (() => void) => () => {},
readByPath: async () => null,
readFilesByPaths: async () => ({ files: [] }),
readSnapshot: async () => null,
getVersion: async () => '0.0.0-test'
} as typeof window.api
})
it('挂载 DiffLens 应用(覆盖 main.tsx 入口)', async () => {
@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import AboutModal, { ABOUT_INFO } from './AboutModal'
function setup(overrides?: { version?: string }): { onClose: ReturnType<typeof vi.fn>; onCopy: ReturnType<typeof vi.fn> } {
const onClose = vi.fn()
const onCopy = vi.fn()
render(
<AboutModal version={overrides?.version ?? '0.10.2'} onClose={onClose} onCopy={onCopy} />
)
return { onClose, onCopy }
}
describe('AboutModal - 渲染', () => {
it('展示应用名、版本与全部元信息(作者/邮箱/组织/开源地址/许可证)', () => {
setup()
expect(screen.getByText(ABOUT_INFO.name)).toBeInTheDocument()
expect(screen.getByText('版本 0.10.2')).toBeInTheDocument()
expect(screen.getByText(ABOUT_INFO.description)).toBeInTheDocument()
expect(screen.getByText(ABOUT_INFO.author)).toBeInTheDocument()
for (const m of ABOUT_INFO.emails) {
expect(screen.getByText(m)).toBeInTheDocument()
}
expect(screen.getByText(ABOUT_INFO.organization)).toBeInTheDocument()
expect(screen.getByText(ABOUT_INFO.repoUrl)).toBeInTheDocument()
expect(screen.getByText(ABOUT_INFO.license)).toBeInTheDocument()
expect(screen.getByText(/© 2026 MetonaTeam/)).toBeInTheDocument()
})
it('版本号未取得时显示占位(版本 —)', () => {
setup({ version: '' })
expect(screen.getByText('版本 —')).toBeInTheDocument()
})
})
describe('AboutModal - 复制交互', () => {
it('点击邮箱触发 onCopy(携带邮箱与标签)', () => {
const { onCopy } = setup()
fireEvent.click(screen.getByText('stinanimz@gmail.com'))
expect(onCopy).toHaveBeenCalledWith('stinanimz@gmail.com', '邮箱')
fireEvent.click(screen.getByText('1440196015@qq.com'))
expect(onCopy).toHaveBeenCalledWith('1440196015@qq.com', '邮箱')
})
it('点击开源地址/作者/组织触发 onCopy', () => {
const { onCopy } = setup()
fireEvent.click(screen.getByText(ABOUT_INFO.repoUrl))
expect(onCopy).toHaveBeenCalledWith(ABOUT_INFO.repoUrl, '开源地址')
fireEvent.click(screen.getByText(ABOUT_INFO.author))
expect(onCopy).toHaveBeenCalledWith(ABOUT_INFO.author, '作者')
fireEvent.click(screen.getByText(ABOUT_INFO.organization))
expect(onCopy).toHaveBeenCalledWith(ABOUT_INFO.organization, '组织')
})
it('许可证为纯展示不可点击(不触发 onCopy)', () => {
const { onCopy } = setup()
fireEvent.click(screen.getByText(ABOUT_INFO.license))
expect(onCopy).not.toHaveBeenCalled()
})
})
describe('AboutModal - 关闭交互', () => {
it('Esc 触发 onClose', () => {
const { onClose } = setup()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('点击遮罩触发 onClose;点击弹框内容区不关闭', () => {
const { onClose } = setup()
fireEvent.mouseDown(screen.getByText(ABOUT_INFO.name))
expect(onClose).not.toHaveBeenCalled()
fireEvent.mouseDown(document.querySelector('.modal-backdrop')!)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('点击右上角关闭按钮触发 onClose', () => {
const { onClose } = setup()
fireEvent.click(screen.getByRole('button', { name: '✕' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('卸载后 Esc 监听移除(不再触发 onClose', () => {
const onClose = vi.fn()
const onCopy = vi.fn()
const { unmount } = render(
<AboutModal version="0.10.2" onClose={onClose} onCopy={onCopy} />
)
unmount()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).not.toHaveBeenCalled()
})
})
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, type ReactElement } from 'react'
import LogoIcon from './LogoIcon'
interface AboutModalProps {
/** 应用版本号(App 经 app:get-version IPC 获取;未取得时传空串显示占位) */
version: string
onClose: () => void
/** 复制到剪贴板(App 实现 setClipboard + toast 反馈) */
onCopy: (text: string, label: string) => void
}
/** 关于弹框的展示信息(应用自有元信息,随版本演进同步维护) */
export const ABOUT_INFO = {
name: 'DiffLens',
/** 应用介绍(与 README 首段口径一致) */
description:
'精美酷炫的开源文本对比桌面应用——行级 + 词级 + 字符级差异定位、文件夹递归对比、快照基线对照,帮助你快速看穿两份文本之间的所有差异。',
author: 'thzxx',
emails: ['stinanimz@gmail.com', '1440196015@qq.com'],
organization: 'MetonaTeam',
repoUrl: 'https://git.metona.cn/MetonaTeam/DiffLens',
license: 'MIT License'
} as const
/**
* 「关于」弹框:应用介绍、版本、作者、邮箱、组织、开源地址与许可证。
* 关闭方式与粘贴弹窗同语言:Esc / 点击遮罩 / 右上角关闭按钮;
* 邮箱与开源地址点击复制到剪贴板(复用主进程剪贴板管线,不引入外部跳转)。
*/
export default function AboutModal({
version,
onClose,
onCopy
}: AboutModalProps): ReactElement {
// Esc 关闭
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div
className="modal-backdrop"
onMouseDown={onClose}
onContextMenu={(e) => {
e.preventDefault()
onClose()
}}
>
<div className="about-modal" onMouseDown={(e) => e.stopPropagation()}>
<button className="about-close" title="关闭" onClick={onClose}>
</button>
<div className="about-logo">
<LogoIcon size={40} />
</div>
<div className="about-name">{ABOUT_INFO.name}</div>
<div className="about-version"> {version !== '' ? version : '—'}</div>
<p className="about-desc">{ABOUT_INFO.description}</p>
<div className="about-rows">
<div className="about-row">
<span className="about-k"></span>
<button
className="about-v"
title="点击复制"
onClick={() => onCopy(ABOUT_INFO.author, '作者')}
>
{ABOUT_INFO.author}
</button>
</div>
<div className="about-row">
<span className="about-k"></span>
<span className="about-vs">
{ABOUT_INFO.emails.map((m) => (
<button
key={m}
className="about-v"
title="点击复制"
onClick={() => onCopy(m, '邮箱')}
>
{m}
</button>
))}
</span>
</div>
<div className="about-row">
<span className="about-k"></span>
<button
className="about-v"
title="点击复制"
onClick={() => onCopy(ABOUT_INFO.organization, '组织')}
>
{ABOUT_INFO.organization}
</button>
</div>
<div className="about-row">
<span className="about-k"></span>
<button
className="about-v"
title="点击复制"
onClick={() => onCopy(ABOUT_INFO.repoUrl, '开源地址')}
>
{ABOUT_INFO.repoUrl}
</button>
</div>
<div className="about-row">
<span className="about-k"></span>
<span className="about-v plain">{ABOUT_INFO.license}</span>
</div>
</div>
<div className="about-foot">© 2026 MetonaTeam · thzxx · DiffLens</div>
</div>
</div>
)
}
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
import ContextMenu, { type ContextMenuItem } from './ContextMenu'
describe('ContextMenu', () => {
@@ -96,4 +96,78 @@ describe('ContextMenu', () => {
expect(menu.style.left).toBe('10px')
expect(menu.style.top).toBe('10px')
})
describe('键盘导航', () => {
const mkItems = (actions: { label: string; disabled?: boolean }[]) =>
actions.map((a) => ({ label: a.label, disabled: a.disabled, action: vi.fn() }))
it('ArrowDown 依次高亮(首项起步);ArrowUp 从末项起步', () => {
const items = mkItems([{ label: 'A' }, { label: 'B' }, { label: 'C' }])
render(<ContextMenu x={10} y={10} items={items} onClose={vi.fn()} />)
// 无高亮时按下 → 第一项
fireEvent.keyDown(document, { key: 'ArrowDown' })
expect(screen.getByText('A')).toHaveClass('active')
// 再按下 → 第二项
fireEvent.keyDown(document, { key: 'ArrowDown' })
expect(screen.getByText('B')).toHaveClass('active')
expect(screen.getByText('A')).not.toHaveClass('active')
// 无高亮语义验证:重新渲染后按上 → 末项
cleanup()
render(<ContextMenu x={10} y={10} items={mkItems([{ label: 'A' }, { label: 'B' }, { label: 'C' }])} onClose={vi.fn()} />)
fireEvent.keyDown(document, { key: 'ArrowUp' })
expect(screen.getByText('C')).toHaveClass('active')
})
it('首尾循环导航', () => {
const items = mkItems([{ label: 'A' }, { label: 'B' }])
render(<ContextMenu x={10} y={10} items={items} onClose={vi.fn()} />)
fireEvent.keyDown(document, { key: 'ArrowDown' }) // → A(索引 0
fireEvent.keyDown(document, { key: 'ArrowUp' }) // 循环回末项 B
expect(screen.getByText('B')).toHaveClass('active')
fireEvent.keyDown(document, { key: 'ArrowDown' }) // 循环回首项 A
expect(screen.getByText('A')).toHaveClass('active')
})
it('方向键跳过禁用项', () => {
const items = mkItems([{ label: 'A' }, { label: 'B', disabled: true }, { label: 'C' }])
render(<ContextMenu x={10} y={10} items={items} onClose={vi.fn()} />)
fireEvent.keyDown(document, { key: 'ArrowDown' }) // → A
fireEvent.keyDown(document, { key: 'ArrowDown' }) // B 禁用 → 跳到 C
expect(screen.getByText('C')).toHaveClass('active')
expect(screen.getByText('B')).not.toHaveClass('active')
})
it('Enter 触发高亮项并关闭;无高亮时 Enter 不触发', () => {
const action = vi.fn()
const onClose = vi.fn()
// 被测 spy 挂在第二项:两次 ArrowDown 高亮到该项后 Enter 触发
const items: ContextMenuItem[] = [{ label: 'A', action: vi.fn() }, { label: 'B', action }]
render(<ContextMenu x={10} y={10} items={items} onClose={onClose} />)
// 无高亮:Enter 无操作
fireEvent.keyDown(document, { key: 'Enter' })
expect(action).not.toHaveBeenCalled()
// 高亮第二项后 Enter 触发
fireEvent.keyDown(document, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'Enter' })
expect(action).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('鼠标悬停同步高亮', () => {
const items = mkItems([{ label: 'A' }, { label: 'B' }])
render(<ContextMenu x={10} y={10} items={items} onClose={vi.fn()} />)
fireEvent.mouseEnter(screen.getByText('B'))
expect(screen.getByText('B')).toHaveClass('active')
})
it('全部禁用时方向键不高亮任何项(不崩溃)', () => {
const items = mkItems([{ label: 'A', disabled: true }, { label: 'B', disabled: true }])
render(<ContextMenu x={10} y={10} items={items} onClose={vi.fn()} />)
fireEvent.keyDown(document, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'ArrowUp' })
expect(screen.getByText('A')).not.toHaveClass('active')
expect(screen.getByText('B')).not.toHaveClass('active')
})
})
})
+33 -4
View File
@@ -21,15 +21,43 @@ export default function ContextMenu({
}: ContextMenuProps): ReactElement {
const menuRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ left: x, top: y })
// 键盘导航高亮项(-1 = 无高亮;disabled 项不可选中)
const [active, setActive] = useState(-1)
// Esc 关闭菜单
// Esc 关闭菜单;↑/↓ 在可选项间移动高亮(首尾循环、跳过禁用项);Enter 触发高亮项
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
if (e.key === 'Escape') {
onClose()
return
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
const dir = e.key === 'ArrowDown' ? 1 : -1
setActive((prev) => {
if (items.length === 0) return prev
// 无高亮时按方向从首项/末项出发;有高亮从当前项出发循环找下一个可选项
const start = prev < 0 ? (dir === 1 ? -1 : items.length) : prev
let n = start
for (let i = 0; i < items.length; i++) {
n = (n + dir + items.length) % items.length
if (!items[n].disabled) return n
}
return prev // 全部禁用:保持不动
})
return
}
if (e.key === 'Enter') {
const it = active >= 0 ? items[active] : undefined
if (it && !it.disabled) {
it.action()
onClose()
}
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
}, [items, active, onClose])
// 渲染后实测菜单尺寸,溢出视口时收回边界内(右/下各留 8px 边距)
useLayoutEffect(() => {
@@ -64,8 +92,9 @@ export default function ContextMenu({
{items.map((it, i) => (
<button
key={i}
className={'ctx-item' + (it.disabled ? ' disabled' : '')}
className={'ctx-item' + (active === i ? ' active' : '') + (it.disabled ? ' disabled' : '')}
disabled={it.disabled}
onMouseEnter={() => setActive(i)}
onClick={() => {
if (!it.disabled) {
it.action()
+82 -1
View File
@@ -9,13 +9,18 @@ beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
})
function renderDiff(items: DisplayItem[], activeRowId: string | null = null) {
function renderDiff(
items: DisplayItem[],
activeRowId: string | null = null,
searchQuery?: string
) {
return render(
<DiffView
items={items}
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
activeRowId={activeRowId}
searchQuery={searchQuery}
onOpen={vi.fn()}
onRowContext={vi.fn()}
onDropFile={vi.fn()}
@@ -305,3 +310,79 @@ describe('DiffView - 折叠提示行', () => {
expect(screen.getAllByText('两文件内容完全一致,已折叠全部未更改行').length).toBe(2)
})
})
describe('DiffView - 搜索命中高亮', () => {
it('纯文本行命中子串高亮(大小写不敏感,两侧各自渲染)', () => {
const row: DiffRow = {
id: 's0',
rowKind: 'unchanged',
isChanged: false,
left: { lineNo: 1, text: 'find TARGET here', segs: null },
right: { lineNo: 1, text: 'target elsewhere', segs: null }
}
const { container } = renderDiff([row], null, 'target')
const hits = container.querySelectorAll('.search-hit')
// 左右各一处命中(左侧 TARGET 大写命中)
expect(hits).toHaveLength(2)
expect(hits[0].textContent).toBe('TARGET')
expect(hits[1].textContent).toBe('target')
})
it('同一行多处命中全部拆分高亮', () => {
const row: DiffRow = {
id: 's1',
rowKind: 'unchanged',
isChanged: false,
left: { lineNo: 1, text: 'ab ab ab', segs: null },
right: { lineNo: 1, text: 'ab ab ab', segs: null }
}
const { container } = renderDiff([row], null, 'ab')
// 每侧 3 处命中
expect(container.querySelectorAll('.search-hit')).toHaveLength(6)
})
it('词级 segs 行内命中同样高亮(seg 文本内拆分)', () => {
const modRow: DiffRow = {
id: 's2',
rowKind: 'modified',
isChanged: true,
left: {
lineNo: 1,
text: 'keep target old',
segs: [
{ text: 'keep ', kind: 'common' },
{ text: 'target', kind: 'delete' },
{ text: ' old', kind: 'common' }
]
},
right: {
lineNo: 1,
text: 'keep new old',
segs: [
{ text: 'keep ', kind: 'common' },
{ text: 'new', kind: 'insert' },
{ text: ' old', kind: 'common' }
]
}
}
const { container } = renderDiff([modRow], null, 'target')
const hits = container.querySelectorAll('.search-hit')
// 仅左侧 delete seg 内命中一处
expect(hits).toHaveLength(1)
expect(hits[0].textContent).toBe('target')
})
it('无命中不渲染高亮 span;未传 searchQuery 不高亮', () => {
const row: DiffRow = {
id: 's3',
rowKind: 'unchanged',
isChanged: false,
left: { lineNo: 1, text: 'plain', segs: null },
right: { lineNo: 1, text: 'plain', segs: null }
}
const miss = renderDiff([row], null, 'zzz')
expect(miss.container.querySelectorAll('.search-hit')).toHaveLength(0)
const none = renderDiff([row])
expect(none.container.querySelectorAll('.search-hit')).toHaveLength(0)
})
})
+38 -5
View File
@@ -44,6 +44,8 @@ interface DiffViewProps {
leftMeta: PaneMeta | null
rightMeta: PaneMeta | null
activeRowId: string | null
/** 视图内文本搜索(大小写不敏感,非空时行内命中子串高亮) */
searchQuery?: string
onOpen: (side: 'left' | 'right') => void
onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => void
onDropFile: (side: 'left' | 'right', files: File[]) => void
@@ -62,18 +64,44 @@ function renderWithWs(text: string): ReactNode[] {
]
}
function renderCellText(cell: SideCell): ReactNode {
/** 搜索命中子串包裹高亮 span(大小写不敏感,多处命中全部拆分);无命中返回原文本单节点 */
function renderWithHits(text: string, query: string): ReactNode[] {
if (query === '') return [text]
const lower = text.toLowerCase()
const lq = query.toLowerCase()
const out: ReactNode[] = []
let from = 0
let i = lower.indexOf(lq)
let key = 0
while (i >= 0) {
if (i > from) out.push(text.slice(from, i))
out.push(
<span className="search-hit" key={`hit-${key++}`}>
{text.slice(i, i + query.length)}
</span>
)
from = i + query.length
i = lower.indexOf(lq, from)
}
if (out.length === 0) return [text]
if (from < text.length) out.push(text.slice(from))
return out
}
function renderCellText(cell: SideCell, query: string): ReactNode {
if (cell.segs) {
return cell.segs.map((s, i) => (
<span key={i} className={'seg seg-' + s.kind}>
{s.text}
{query !== '' ? renderWithHits(s.text, query) : s.text}
</span>
))
}
if (cell.text === null) {
return <span className="plain-empty">&#8203;</span>
}
return <span className="plain">{renderWithWs(cell.text)}</span>
// 搜索激活时命中高亮优先(行尾空白标注与命中拆分不叠加,避免嵌套冲突)
const body = query !== '' ? renderWithHits(cell.text, query) : renderWithWs(cell.text)
return <span className="plain">{body}</span>
}
function SidePanel({
@@ -86,7 +114,8 @@ function SidePanel({
scrollRef,
onScroll,
activeRowId,
minWidth
minWidth,
searchQuery
}: {
items: DisplayItem[]
side: 'left' | 'right'
@@ -98,6 +127,7 @@ function SidePanel({
onScroll: () => void
activeRowId: string | null
minWidth: number | undefined
searchQuery: string
}): ReactElement {
// 当前渲染窗口 [start, end),随滚动位置按固定行高换算
const [range, setRange] = useState({ start: 0, end: 0 })
@@ -182,7 +212,7 @@ function SidePanel({
<div className={cls} data-row={row.id} key={row.id + '-' + side}>
<span className={'ln ' + side}>{cell.lineNo ?? ''}</span>
<span className="tx" onContextMenu={(e) => onRowContext(e, side, cell)}>
{renderCellText(cell)}
{renderCellText(cell, searchQuery)}
</span>
</div>
)
@@ -198,6 +228,7 @@ export default function DiffView({
leftMeta,
rightMeta,
activeRowId,
searchQuery,
onOpen,
onRowContext,
onDropFile
@@ -277,6 +308,7 @@ export default function DiffView({
onScroll={makeScrollSync('left')}
activeRowId={activeRowId}
minWidth={leftMinW}
searchQuery={searchQuery ?? ''}
/>
<SidePanel
items={items}
@@ -289,6 +321,7 @@ export default function DiffView({
onScroll={makeScrollSync('right')}
activeRowId={activeRowId}
minWidth={rightMinW}
searchQuery={searchQuery ?? ''}
/>
</div>
</div>
@@ -0,0 +1,36 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import ErrorBoundary from './ErrorBoundary'
// React 抛渲染错误时向 console.error 输出组件栈噪音,测试中静音
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
/** 渲染期必抛错的子组件 */
function Boom(): never {
throw new Error('boom in render')
}
describe('ErrorBoundary', () => {
it('子组件渲染抛错时显示错误面板与重载按钮(不白屏)', () => {
render(
<ErrorBoundary>
<Boom />
</ErrorBoundary>
)
expect(screen.getByText('界面渲染出现异常')).toBeInTheDocument()
expect(screen.getByText('boom in render')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '重新加载应用' })).toBeInTheDocument()
})
it('子组件正常渲染时原样透传(零侵入)', () => {
render(
<ErrorBoundary>
<div>ok-content</div>
</ErrorBoundary>
)
expect(screen.getByText('ok-content')).toBeInTheDocument()
expect(screen.queryByText('界面渲染出现异常')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,42 @@
import { Component, type ReactNode } from 'react'
interface ErrorBoundaryProps {
children: ReactNode
}
interface ErrorBoundaryState {
error: Error | null
}
/**
* 顶层渲染错误兜底:任何子组件渲染期抛错时显示错误面板与一键重载,
* 替代整窗白屏死机(用户至少能自助恢复)。
* 捕获范围:渲染与生命周期阶段的错误(React ErrorBoundary 的固有边界);
* 事件回调内的异常不经过此边界,由各回调自身的 try/catch 防御。
*/
export default class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error }
}
componentDidCatch(error: Error): void {
console.error('渲染错误(ErrorBoundary 捕获):', error)
}
render(): ReactNode {
if (this.state.error !== null) {
return (
<div className="error-boundary">
<h2></h2>
<p className="error-message">{this.state.error.message || String(this.state.error)}</p>
<button className="btn primary" onClick={() => window.location.reload()}>
</button>
</div>
)
}
return this.props.children
}
}
@@ -0,0 +1,804 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { createRef } from 'react'
import FolderView, { FOLDER_ROW_HEIGHT } from './FolderView'
import type { FolderEntry } from '../../../preload/index'
import { diffSnapshot, type FolderSnapshot } from '../diff/snapshotDiff'
const ENTRIES: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 5, rightSize: 5 },
{ rel: 'sem.txt', status: 'semantic-same', leftSize: 5, rightSize: 6 },
{ rel: 'b.txt', status: 'different', leftSize: 3, rightSize: 9 },
{ rel: 'only-left.txt', status: 'left-only', leftSize: 1, rightSize: null },
{ rel: 'sub/only-right.txt', status: 'right-only', leftSize: null, rightSize: 7 },
{ rel: 'sub/deep/diff.ts', status: 'different', leftSize: 2, rightSize: 4 }
]
/** 构造 FolderView 全量 props(覆盖缺省值,测试按需覆写) */
function fullProps(entries: FolderEntry[] = ENTRIES, overrides?: Record<string, unknown>) {
return {
leftDir: 'C:/left',
rightDir: 'D:/right',
entries,
truncated: false,
total: entries.length,
scanning: false,
onlyDiff: true,
onOnlyDiffChange: vi.fn(),
semantic: true,
onSemanticChange: vi.fn(),
ignoreRules: [] as string[],
onIgnoreRulesChange: vi.fn(),
snapshot: null as FolderSnapshot | null,
snapshotDiff: null as ReturnType<typeof diffSnapshot> | null,
onSaveSnapshot: vi.fn(),
onLoadSnapshot: vi.fn(),
onExitSnapshot: vi.fn(),
onReopen: vi.fn(),
onRescan: vi.fn(),
onExit: vi.fn(),
onExport: vi.fn(),
onOpenEntry: vi.fn(),
onOpenDir: vi.fn(),
...overrides
}
}
function renderView(
entries: FolderEntry[] = ENTRIES,
onlyDiff = true,
semantic = true
): ReturnType<typeof render> {
return render(<FolderView {...fullProps(entries, { onlyDiff, semantic })} />)
}
describe('FolderView - 树形渲染', () => {
it('渲染两侧目录路径与操作按钮', () => {
renderView()
expect(screen.getAllByText('C:/left').length).toBeGreaterThan(0)
expect(screen.getAllByText('D:/right').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: '重新选择' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '返回文件对比' })).toBeInTheDocument()
// 快照归组下拉(0.10.5 布局重构):入口常驻,菜单项打开后可见
expect(screen.getByRole('button', { name: '快照' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '快照' }))
expect(screen.getByText('保存快照…')).toBeInTheDocument()
expect(screen.getByText('加载快照…')).toBeInTheDocument()
})
it('目录聚合为树形:根层文件与目录混排(字典序),目录行显示聚合状态与计数', () => {
const { container } = renderView(ENTRIES, false)
// 根层(关闭过滤后 a.txt/sem.txt/b.txt/only-left.txt/sub 可见)
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.getByText('sub')).toBeInTheDocument()
const dirRow = container.querySelector('.folder-row.dir')
expect(dirRow).not.toBeNull()
// sub 子树含 differentdiff.ts)→ 聚合徽章“不同”
expect(dirRow!.textContent).toContain('不同')
expect(dirRow!.textContent).toContain('2 项')
})
it('子目录文件默认展开可见(深层文件渲染)', () => {
renderView(ENTRIES, false)
expect(screen.getByText('deep')).toBeInTheDocument()
expect(screen.getByText('diff.ts')).toBeInTheDocument()
expect(screen.getByText('only-right.txt')).toBeInTheDocument()
})
it('点击目录行折叠/展开子树', () => {
renderView(ENTRIES, false)
expect(screen.getByText('diff.ts')).toBeInTheDocument()
fireEvent.click(screen.getByText('sub'))
// sub 折叠:其下全部子行隐藏
expect(screen.queryByText('only-right.txt')).not.toBeInTheDocument()
expect(screen.queryByText('deep')).not.toBeInTheDocument()
// 再次点击展开恢复
fireEvent.click(screen.getByText('sub'))
expect(screen.getByText('diff.ts')).toBeInTheDocument()
})
it('双击目录行触发 onOpenDir(携带目录相对路径)', () => {
const onOpenDir = vi.fn()
render(<FolderView {...fullProps(ENTRIES, { onlyDiff: false, onOpenDir })} />)
fireEvent.dblClick(screen.getByText('sub'))
expect(onOpenDir).toHaveBeenCalledWith('sub')
})
it('双击目录行前该目录处于折叠态:进入时自动展开(消除双击 toggle 残留)', () => {
renderView(ENTRIES, false)
// 先折叠 sub
fireEvent.click(screen.getByText('sub'))
expect(screen.queryByText('diff.ts')).not.toBeInTheDocument()
// 双击 subonOpenDir 触发且目录恢复展开(返回列表后子行可见)
fireEvent.dblClick(screen.getByText('sub'))
expect(screen.getByText('diff.ts')).toBeInTheDocument()
})
it('双击文件条目触发 onOpenEntry(携带原始条目)', () => {
const onOpenEntry = vi.fn()
render(<FolderView {...fullProps(ENTRIES, { onOpenEntry })} />)
fireEvent.dblClick(screen.getByText('b.txt'))
expect(onOpenEntry).toHaveBeenCalledWith(ENTRIES[2])
})
})
describe('FolderView - 统计徽章与过滤', () => {
it('渲染统计徽章(相同/语义同/不同/仅左/仅右/总数)', () => {
renderView(ENTRIES, false)
expect(screen.getByText('相同 1')).toBeInTheDocument()
expect(screen.getByText('语义同 1')).toBeInTheDocument()
expect(screen.getByText('不同 2')).toBeInTheDocument()
expect(screen.getByText('仅左 1')).toBeInTheDocument()
expect(screen.getByText('仅右 1')).toBeInTheDocument()
expect(screen.getByText('共 6 项')).toBeInTheDocument()
})
it('语义关闭时不渲染语义同徽章', () => {
renderView(ENTRIES, false, false)
expect(screen.queryByText(/语义同/)).not.toBeInTheDocument()
})
it('仅看差异开启时隐藏 same 与 semantic-same 条目(含其目录剪枝)', () => {
const { rerender } = renderView()
expect(screen.queryByText('a.txt')).not.toBeInTheDocument()
expect(screen.queryByText('sem.txt')).not.toBeInTheDocument()
expect(screen.getByText('b.txt')).toBeInTheDocument()
expect(screen.getByText('sub')).toBeInTheDocument()
rerender(<FolderView {...fullProps(ENTRIES, { onlyDiff: false })} />)
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.getByText('sem.txt')).toBeInTheDocument()
})
it('点击徽章开启状态过滤:只看该状态且徽章激活', () => {
renderView(ENTRIES, false)
fireEvent.click(screen.getByText('不同 2'))
// 只剩 different 条目:b.txt 与 sub/deep/diff.ts
expect(screen.getByText('b.txt')).toBeInTheDocument()
expect(screen.queryByText('a.txt')).not.toBeInTheDocument()
expect(screen.queryByText('only-left.txt')).not.toBeInTheDocument()
expect(screen.getByText('diff.ts')).toBeInTheDocument()
// 激活态样式
expect(screen.getByText('不同 2').className).toContain('active')
// 再次点击取消过滤
fireEvent.click(screen.getByText('不同 2'))
expect(screen.getByText('a.txt')).toBeInTheDocument()
})
it('仅看差异开启时点击徽章:自动关闭仅看差异(回调传 false)', () => {
const onOnlyDiffChange = vi.fn()
render(<FolderView {...fullProps(ENTRIES, { onOnlyDiffChange })} />)
fireEvent.click(screen.getByText('相同 1'))
expect(onOnlyDiffChange).toHaveBeenCalledWith(false)
})
it('过滤后无文件的目录不渲染(剪枝)', () => {
renderView(ENTRIES, false)
// 只看仅右:唯一 right-only 在 sub 下 → sub 保留
fireEvent.click(screen.getByText('仅右 1'))
expect(screen.getByText('sub')).toBeInTheDocument()
expect(screen.getByText('only-right.txt')).toBeInTheDocument()
expect(screen.queryByText('deep')).not.toBeInTheDocument()
// 只看相同:a.txt 在根层,sub 无 same 文件 → sub 剪掉
fireEvent.click(screen.getByText('仅右 1'))
fireEvent.click(screen.getByText('相同 1'))
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.queryByText('sub')).not.toBeInTheDocument()
})
})
describe('FolderView - 搜索', () => {
it('输入关键字过滤文件并强制展开命中路径', () => {
renderView(ENTRIES, false)
fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: 'diff.ts' } })
expect(screen.getByText('diff.ts')).toBeInTheDocument()
expect(screen.getByText('sub')).toBeInTheDocument()
expect(screen.getByText('deep')).toBeInTheDocument()
expect(screen.queryByText('a.txt')).not.toBeInTheDocument()
})
it('搜索不区分大小写且匹配目录名中段', () => {
renderView(ENTRIES, false)
fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: 'DEEP' } })
expect(screen.getByText('diff.ts')).toBeInTheDocument()
expect(screen.queryByText('a.txt')).not.toBeInTheDocument()
})
it('折叠目录后搜索仍展开命中路径,清空搜索恢复折叠', () => {
renderView(ENTRIES, false)
fireEvent.click(screen.getByText('sub'))
expect(screen.queryByText('diff.ts')).not.toBeInTheDocument()
// 搜索强制展开
fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: 'diff' } })
expect(screen.getByText('diff.ts')).toBeInTheDocument()
// 清空搜索恢复折叠
fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: '' } })
expect(screen.queryByText('diff.ts')).not.toBeInTheDocument()
expect(screen.getByText('sub')).toBeInTheDocument()
})
it('无命中给出提示', () => {
renderView(ENTRIES, false)
fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: 'zzz' } })
expect(screen.getByText('没有匹配“zzz”的文件')).toBeInTheDocument()
})
it('Esc 清空搜索关键字并失焦(与单文件搜索框行为一致,0.10.5)', () => {
renderView(ENTRIES, false)
const input = screen.getByPlaceholderText(/搜索文件名…/)
fireEvent.change(input, { target: { value: 'diff' } })
expect(screen.getByText('diff.ts')).toBeInTheDocument()
input.focus()
fireEvent.keyDown(input, { key: 'Escape' })
expect(input).toHaveValue('')
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(document.activeElement).not.toBe(input)
})
it('有内容时显示清空按钮,点击清空并保持焦点(0.10.5)', () => {
renderView(ENTRIES, false)
fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: 'diff' } })
expect(screen.getByTitle('清空搜索(Esc')).toBeInTheDocument()
fireEvent.click(screen.getByTitle('清空搜索(Esc'))
const input = screen.getByPlaceholderText(/搜索文件名…/) as HTMLInputElement
expect(input).toHaveValue('')
expect(screen.getByText('a.txt')).toBeInTheDocument()
})
it('searchInputRef 转发到搜索框(Ctrl+F 聚焦用,0.10.5', () => {
const ref = createRef<HTMLInputElement>()
render(<FolderView {...fullProps(ENTRIES, { searchInputRef: ref })} />)
expect(ref.current).toBe(screen.getByPlaceholderText(/搜索文件名…/))
})
it('目录对变化重置搜索词/状态过滤/扩展名过滤/折叠集合;同目录重扫保留(0.10.5)', () => {
// 建立残留状态:搜索词 + 折叠 sub + 状态过滤
const { rerender } = renderView(ENTRIES, false)
fireEvent.click(screen.getByText('sub'))
expect(screen.queryByText('diff.ts')).not.toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText(/搜索文件名…/), { target: { value: 'diff' } })
// 搜索强制展开命中路径:diff.ts 可见
expect(screen.getByText('diff.ts')).toBeInTheDocument()
// 同目录重扫(leftDir/rightDir 不变,entries 变化):状态保留(diff.ts 仍受搜索过滤)
rerender(
<FolderView
{...fullProps([...ENTRIES], { onlyDiff: false })}
/>
)
expect(screen.getByText('diff.ts')).toBeInTheDocument()
// 重新选择新目录对:搜索/折叠全部重置(无搜索过滤、目录默认展开)
rerender(
<FolderView
{...fullProps(ENTRIES, { onlyDiff: false, leftDir: 'C:/new-left', rightDir: 'D:/new-right' })}
/>
)
const input = screen.getByPlaceholderText(/搜索文件名…/) as HTMLInputElement
expect(input).toHaveValue('')
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.getByText('only-right.txt')).toBeInTheDocument()
})
})
describe('FolderView - 扩展名过滤', () => {
const EXT_ENTRIES: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'b.txt', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'c.md', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'noext', status: 'left-only', leftSize: 1, rightSize: null }
]
it('下拉展示全量条目的扩展名分布与计数(字典序,无扩展名单列)', () => {
render(<FolderView {...fullProps(EXT_ENTRIES, { onlyDiff: false })} />)
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
// 字典序:(无扩展名)/ .md / .txt,各带计数
expect(screen.getByText('(无扩展名)')).toBeInTheDocument()
const md = screen.getByText('.md')
expect(md.textContent).toContain('1')
const txt = screen.getByText('.txt')
expect(txt.textContent).toContain('2')
})
it('点选扩展名过滤条目:交集过滤生效,计数切换为 N / 总数', () => {
render(<FolderView {...fullProps(EXT_ENTRIES, { onlyDiff: false })} />)
expect(screen.getByText('共 4 项')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
fireEvent.click(screen.getByText('.txt'))
// 只剩 txt 条目(a.txt 同状态被关掉 onlyDiff 后可见)
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.getByText('b.txt')).toBeInTheDocument()
expect(screen.queryByText('c.md')).not.toBeInTheDocument()
expect(screen.queryByText('noext')).not.toBeInTheDocument()
expect(screen.getByText('共 2 / 4 项')).toBeInTheDocument()
// 入口按钮显示已选数量
expect(screen.getByRole('button', { name: /扩展名 · 1/ })).toBeInTheDocument()
})
it('多选扩展名取并集,再次点击取消,清除按钮复位', () => {
render(<FolderView {...fullProps(EXT_ENTRIES, { onlyDiff: false })} />)
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
fireEvent.click(screen.getByText('.txt'))
fireEvent.click(screen.getByText('.md'))
expect(screen.getByText('b.txt')).toBeInTheDocument()
expect(screen.getByText('c.md')).toBeInTheDocument()
expect(screen.queryByText('noext')).not.toBeInTheDocument()
expect(screen.getByText('共 3 / 4 项')).toBeInTheDocument()
// 取消 .txt
fireEvent.click(screen.getByText('.txt'))
expect(screen.queryByText('b.txt')).not.toBeInTheDocument()
expect(screen.getByText('c.md')).toBeInTheDocument()
// 清除按钮整体复位
fireEvent.click(screen.getByText('清除扩展名过滤'))
expect(screen.getByText('共 4 项')).toBeInTheDocument()
expect(screen.getByText('noext')).toBeInTheDocument()
})
it('与状态过滤交集:双激活时无结果的空态提示', () => {
render(<FolderView {...fullProps(EXT_ENTRIES, { onlyDiff: false })} />)
// 状态过滤:只看相同(仅 a.txt)
fireEvent.click(screen.getByText('相同 1'))
// 扩展名过滤:.md(与 a.txt 无交集)
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
fireEvent.click(screen.getByText('.md'))
expect(screen.getByText('当前过滤条件下没有文件')).toBeInTheDocument()
})
it('点击外部区域关闭扩展名下拉(过滤状态保留)', () => {
render(<FolderView {...fullProps(EXT_ENTRIES, { onlyDiff: false })} />)
fireEvent.click(screen.getByRole('button', { name: /扩展名/ }))
fireEvent.click(screen.getByText('.txt'))
fireEvent.mouseDown(document.body)
expect(screen.queryByText('.md')).not.toBeInTheDocument()
// 过滤仍在生效
expect(screen.queryByText('c.md')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /扩展名 · 1/ })).toBeInTheDocument()
})
})
describe('FolderView - 忽略规则', () => {
it('已有规则时入口按钮标注数量并高亮', () => {
render(<FolderView {...fullProps(ENTRIES, { ignoreRules: ['node_modules/', '*.log'] })} />)
expect(screen.getByRole('button', { name: '忽略规则 · 2' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '忽略规则 · 2' }).className).toContain('filtered')
})
it('下拉打开时以当前规则初始化草稿,逐行解析后应用并关闭', () => {
const onIgnoreRulesChange = vi.fn()
render(
<FolderView
{...fullProps(ENTRIES, { ignoreRules: ['dist/'], onIgnoreRulesChange })}
/>
)
fireEvent.click(screen.getByRole('button', { name: '忽略规则 · 1' }))
const ta = screen.getByPlaceholderText(/每行一条/) as HTMLTextAreaElement
expect(ta.value).toBe('dist/')
fireEvent.change(ta, { target: { value: 'node_modules/\n\n *.log \ntemp' } })
fireEvent.click(screen.getByText('应用并重扫'))
// 空行与空白被清理
expect(onIgnoreRulesChange).toHaveBeenCalledWith(['node_modules/', '*.log', 'temp'])
expect(screen.queryByText('应用并重扫')).not.toBeInTheDocument()
})
it('清空规则回调空数组并关闭', () => {
const onIgnoreRulesChange = vi.fn()
render(
<FolderView
{...fullProps(ENTRIES, { ignoreRules: ['dist/'], onIgnoreRulesChange })}
/>
)
fireEvent.click(screen.getByRole('button', { name: '忽略规则 · 1' }))
fireEvent.click(screen.getByText('清空规则'))
expect(onIgnoreRulesChange).toHaveBeenCalledWith([])
expect(screen.queryByText('清空规则')).not.toBeInTheDocument()
})
it('点击外部区域关闭忽略规则下拉(不触发回调)', () => {
const onIgnoreRulesChange = vi.fn()
render(<FolderView {...fullProps(ENTRIES, { onIgnoreRulesChange })} />)
fireEvent.click(screen.getByRole('button', { name: '忽略规则' }))
expect(screen.getByText('应用并重扫')).toBeInTheDocument()
fireEvent.mouseDown(document.body)
expect(screen.queryByText('应用并重扫')).not.toBeInTheDocument()
expect(onIgnoreRulesChange).not.toHaveBeenCalled()
})
it('规则条数超上限截断至 50 条', () => {
const onIgnoreRulesChange = vi.fn()
render(<FolderView {...fullProps(ENTRIES, { onIgnoreRulesChange })} />)
fireEvent.click(screen.getByRole('button', { name: '忽略规则' }))
const many = Array.from({ length: 60 }, (_, i) => `d${i}/`).join('\n')
fireEvent.change(screen.getByPlaceholderText(/每行一条/), { target: { value: many } })
fireEvent.click(screen.getByText('应用并重扫'))
expect(onIgnoreRulesChange).toHaveBeenCalledWith(
Array.from({ length: 50 }, (_, i) => `d${i}/`)
)
})
it('无效规则实时提示(不再静默丢弃),修正/清空后提示消失', () => {
render(<FolderView {...fullProps(ENTRIES, { onIgnoreRulesChange: vi.fn() })} />)
fireEvent.click(screen.getByRole('button', { name: '忽略规则' }))
const ta = screen.getByPlaceholderText(/每行一条/)
// 输入合法与非法混合规则
fireEvent.change(ta, { target: { value: 'node_modules/\na**b\n!' } })
expect(screen.getByText(/将被忽略的无效规则:a\*\*b、!/)).toBeInTheDocument()
// 修正非法规则后提示消失
fireEvent.change(ta, { target: { value: 'node_modules/\nbuild/**/cache' } })
expect(screen.queryByText(/将被忽略/)).not.toBeInTheDocument()
// 清空草稿同样消失
fireEvent.change(ta, { target: { value: '' } })
expect(screen.queryByText(/将被忽略/)).not.toBeInTheDocument()
})
})
describe('FolderView - 快照对照模式', () => {
/** 快照基线:a.txt different、b.txt same、gone.txt different(当前已移除) */
const SNAP_ENTRIES: FolderEntry[] = [
{ rel: 'a.txt', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'b.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'gone.txt', status: 'different', leftSize: 1, rightSize: 2 }
]
const CUR_ENTRIES: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 2, rightSize: 2 },
{ rel: 'b.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'new.txt', status: 'right-only', leftSize: null, rightSize: 3 }
]
const snap: FolderSnapshot = {
version: 1,
leftDir: 'C:/left',
rightDir: 'D:/right',
semantic: true,
truncated: false,
total: 3,
entries: SNAP_ENTRIES,
savedAt: 1700000000000
}
const snapDiff = diffSnapshot(SNAP_ENTRIES, CUR_ENTRIES)
// 变迁:a.txt fixed、gone.txt gone、new.txt new、b.txt stable
it('对照模式渲染变迁统计徽章与基线信息,替换常规统计行', () => {
render(
<FolderView {...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: snapDiff })} />
)
expect(screen.getByText('已修复 1')).toBeInTheDocument()
expect(screen.getByText('新文件 1')).toBeInTheDocument()
expect(screen.getByText('已移除 1')).toBeInTheDocument()
expect(screen.getByText(/保持一致 1/)).toBeInTheDocument()
expect(screen.getByText(/基线 /)).toBeInTheDocument()
expect(screen.getByRole('button', { name: '退出对照' })).toBeInTheDocument()
// 常规统计行被替换
expect(screen.queryByText(/相同 \d/)).not.toBeInTheDocument()
expect(screen.queryByLabelText('仅看差异')).not.toBeInTheDocument()
})
it('对照条目平铺全路径渲染,行尾带变迁徽章;零计数变迁不占位', () => {
render(
<FolderView {...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: snapDiff })} />
)
// 全路径显示(非树形文件名)
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.getByText('new.txt')).toBeInTheDocument()
// 已移除条目仍可见(当前不存在,回退快照数据)并带徽章
expect(screen.getByText('gone.txt')).toBeInTheDocument()
expect(screen.getByText('已移除')).toBeInTheDocument()
expect(screen.getByText('已修复')).toBeInTheDocument()
// 零计数变迁不渲染
expect(screen.queryByText(/新增差异 \d/)).not.toBeInTheDocument()
expect(screen.queryByText(/仍有差异 \d/)).not.toBeInTheDocument()
})
it('点击变迁徽章过滤该类条目,再点取消', () => {
render(
<FolderView {...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: snapDiff })} />
)
expect(screen.getByText('a.txt')).toBeInTheDocument()
fireEvent.click(screen.getByText('已修复 1'))
// 只剩 fixed 类(a.txt
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.queryByText('new.txt')).not.toBeInTheDocument()
expect(screen.queryByText('gone.txt')).not.toBeInTheDocument()
// 再次点击取消过滤
fireEvent.click(screen.getByText('已修复 1'))
expect(screen.getByText('new.txt')).toBeInTheDocument()
})
it('双击当前存在的对照条目触发 onOpenEntry;已移除条目双击无效', () => {
const onOpenEntry = vi.fn()
render(
<FolderView
{...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: snapDiff, onOpenEntry })}
/>
)
fireEvent.dblClick(screen.getByText('a.txt'))
expect(onOpenEntry).toHaveBeenCalledWith(CUR_ENTRIES[0])
fireEvent.dblClick(screen.getByText('gone.txt'))
expect(onOpenEntry).toHaveBeenCalledTimes(1)
})
it('退出对照恢复常规视图', () => {
const onExitSnapshot = vi.fn()
const { rerender } = render(
<FolderView
{...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: snapDiff, onExitSnapshot })}
/>
)
fireEvent.click(screen.getByRole('button', { name: '退出对照' }))
expect(onExitSnapshot).toHaveBeenCalledTimes(1)
// App 清空状态后传 null:恢复常规统计行
rerender(<FolderView {...fullProps(CUR_ENTRIES, { snapshot: null, snapshotDiff: null })} />)
expect(screen.getByText('相同 2')).toBeInTheDocument()
expect(screen.queryByText(/已修复 \d/)).not.toBeInTheDocument()
})
it('重扫刷新后过滤的变迁类型计数归零:过滤自动复位显示全部(0.9.3)', () => {
const { rerender } = render(
<FolderView {...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: snapDiff })} />
)
// 过滤“已修复”:只剩 a.txt
fireEvent.click(screen.getByText('已修复 1'))
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.queryByText('new.txt')).not.toBeInTheDocument()
// 重扫后的新对照:基线全部无差异 → fixed 归零(new / gone 仍在)
const noFixedBase: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 2, rightSize: 2 },
{ rel: 'b.txt', status: 'same', leftSize: 1, rightSize: 1 }
]
const refreshed = diffSnapshot(noFixedBase, CUR_ENTRIES)
rerender(
<FolderView {...fullProps(CUR_ENTRIES, { snapshot: snap, snapshotDiff: refreshed })} />
)
// 残留的 fixed 过滤已自动复位:非 stable 条目重新可见
//new.txt 回归;a/b 此刻为 stable 被默认隐藏属正常行为)
expect(screen.getByText('new.txt')).toBeInTheDocument()
})
it('对照条目全一致时空态提示', () => {
// 双侧均无差异(same)→ 全部 stable → 默认隐藏 → 空态提示
const allSame: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'b.txt', status: 'same', leftSize: 1, rightSize: 1 }
]
const stableDiff = diffSnapshot(allSame, allSame)
render(
<FolderView
{...fullProps(allSame, { snapshot: snap, snapshotDiff: stableDiff })}
/>
)
expect(screen.getByText('与快照基线完全一致,没有任何变迁')).toBeInTheDocument()
})
it('对照条目含基线内容时 title 提示内容对照(v2 快照)', () => {
// v2 快照带 contentsa.txt 有基线内容 → title 提示;new.txt 无内容 → 不提示
const snapV2: FolderSnapshot = {
...snap,
version: 2,
contents: { 'a.txt': { left: 'baseline', right: null } }
}
render(
<FolderView {...fullProps(CUR_ENTRIES, { snapshot: snapV2, snapshotDiff: snapDiff })} />
)
const rowWith = screen.getByText('a.txt').closest('.folder-row') as HTMLElement
expect(rowWith.title).toContain('双击查看快照基线内容对照')
const rowWithout = screen.getByText('new.txt').closest('.folder-row') as HTMLElement
expect(rowWithout.title).not.toContain('双击查看快照基线内容对照')
})
})
describe('FolderView - 其他', () => {
it('切换语义判等开关触发 onSemanticChange', () => {
const onSemanticChange = vi.fn()
render(<FolderView {...fullProps(ENTRIES, { onSemanticChange })} />)
fireEvent.click(screen.getByLabelText('语义判等'))
expect(onSemanticChange).toHaveBeenCalledWith(false)
})
it('点击重新扫描触发 onRescan;扫描中禁用', () => {
const onRescan = vi.fn()
const { rerender } = render(<FolderView {...fullProps(ENTRIES, { onRescan })} />)
fireEvent.click(screen.getByRole('button', { name: '重新扫描' }))
expect(onRescan).toHaveBeenCalledTimes(1)
rerender(<FolderView {...fullProps(ENTRIES, { scanning: true, onRescan })} />)
expect(screen.getByRole('button', { name: '重新扫描' })).toBeDisabled()
// 快照下拉入口扫描中禁用(入口即拦截,菜单不再打开)
expect(screen.getByRole('button', { name: '快照' })).toBeDisabled()
})
it('扫描中重新选择保持可用(发起新会话并作废在途扫描,0.9.3 固化)', () => {
const onReopen = vi.fn()
const { rerender } = render(<FolderView {...fullProps(ENTRIES, { onReopen })} />)
fireEvent.click(screen.getByRole('button', { name: '重新选择' }))
expect(onReopen).toHaveBeenCalledTimes(1)
// 扫描中仍可点击(竞态守卫丢弃迟到结果;大目录扫描慢时允许放弃改选)
rerender(<FolderView {...fullProps(ENTRIES, { scanning: true, onReopen })} />)
expect(screen.getByRole('button', { name: '重新选择' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '重新选择' }))
expect(onReopen).toHaveBeenCalledTimes(2)
})
it('快照下拉菜单项触发保存/加载回调并关闭菜单(0.10.5 归组下拉)', () => {
const onSaveSnapshot = vi.fn()
const onLoadSnapshot = vi.fn()
render(
<FolderView {...fullProps(ENTRIES, { onSaveSnapshot, onLoadSnapshot })} />
)
fireEvent.click(screen.getByRole('button', { name: '快照' }))
fireEvent.click(screen.getByText('保存快照…'))
expect(onSaveSnapshot).toHaveBeenCalledTimes(1)
expect(screen.queryByText('保存快照…')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '快照' }))
fireEvent.click(screen.getByText('加载快照…'))
expect(onLoadSnapshot).toHaveBeenCalledTimes(1)
})
it('点击外部区域关闭快照下拉(不触发回调)', () => {
const onSaveSnapshot = vi.fn()
render(<FolderView {...fullProps(ENTRIES, { onSaveSnapshot })} />)
fireEvent.click(screen.getByRole('button', { name: '快照' }))
expect(screen.getByText('保存快照…')).toBeInTheDocument()
fireEvent.mouseDown(document.body)
expect(screen.queryByText('保存快照…')).not.toBeInTheDocument()
expect(onSaveSnapshot).not.toHaveBeenCalled()
})
it('快照保存中禁用快照入口与菜单项,其余操作不受影响(0.10.2 按钮级防抖的归组形态)', () => {
const onSaveSnapshot = vi.fn()
const onLoadSnapshot = vi.fn()
render(
<FolderView
{...fullProps(ENTRIES, { saving: true, onSaveSnapshot, onLoadSnapshot })}
/>
)
// 入口禁用(点击不打开下拉)
expect(screen.getByRole('button', { name: '快照' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '快照' }))
expect(screen.queryByText('保存快照…')).not.toBeInTheDocument()
// 保存中仅是快照链路防抖:重新扫描/重新选择/导出/返回不受影响
expect(screen.getByRole('button', { name: '重新扫描' })).toBeEnabled()
expect(screen.getByRole('button', { name: '重新选择' })).toBeEnabled()
expect(screen.getByRole('button', { name: '导出报告' })).toBeEnabled()
expect(screen.getByRole('button', { name: '返回文件对比' })).toBeEnabled()
})
it('导出报告菜单选择格式触发 onExport 并关闭菜单', () => {
const onExport = vi.fn()
render(<FolderView {...fullProps(ENTRIES, { onExport })} />)
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
fireEvent.click(screen.getByText('HTML 报告'))
expect(onExport).toHaveBeenCalledWith('html')
expect(screen.queryByText('HTML 报告')).not.toBeInTheDocument()
})
it('扫描中禁用导出按钮', () => {
render(<FolderView {...fullProps(ENTRIES, { scanning: true })} />)
expect(screen.getByRole('button', { name: '导出报告' })).toBeDisabled()
})
it('扫描中禁用语义判等开关(触发重扫的入口防抖)', () => {
const onSemanticChange = vi.fn()
const { rerender } = render(
<FolderView {...fullProps(ENTRIES, { scanning: true, onSemanticChange })} />
)
const sw = screen.getByLabelText('语义判等') as HTMLInputElement
// 浏览器保证 disabled 表单控件不响应点击(jsdom 会派发事件与浏览器不一致,
// 断言以 disabled 属性为准——与 0.5.4 被包含选项的同款处理)
expect(sw).toBeDisabled()
// 开关容器呈禁用视觉态(与被包含选项同款样式)
expect(sw.closest('label')).toHaveClass('disabled')
// 非扫描态对照:开关可用且触发回调(防抖仅在扫描中生效)
rerender(<FolderView {...fullProps(ENTRIES, { scanning: false, onSemanticChange })} />)
expect(screen.getByLabelText('语义判等')).not.toBeDisabled()
fireEvent.click(screen.getByLabelText('语义判等'))
expect(onSemanticChange).toHaveBeenCalledWith(false)
})
it('扫描进度显示在遮罩(progress props),无进度时仅基础文案', () => {
const { rerender } = render(
<FolderView {...fullProps(ENTRIES, { scanning: true, progress: { done: 3, total: 9 } })} />
)
expect(screen.getByText(/正在扫描文件夹… 已比对 3 \/ 9/)).toBeInTheDocument()
// 进度清空(比对完成或尚未开始):回到基础文案
rerender(<FolderView {...fullProps(ENTRIES, { scanning: true, progress: null })} />)
expect(screen.getByText('正在扫描文件夹…')).toBeInTheDocument()
expect(screen.queryByText(/已比对/)).not.toBeInTheDocument()
})
it('扫描中禁用忽略规则入口(应用规则会触发重扫)', () => {
const onIgnoreRulesChange = vi.fn()
render(
<FolderView {...fullProps(ENTRIES, { scanning: true, onIgnoreRulesChange })} />
)
const btn = screen.getByRole('button', { name: /忽略规则/ })
expect(btn).toBeDisabled()
// 禁用按钮点击不打开规则下拉、不触发回调
fireEvent.click(btn)
expect(screen.queryByText('应用并重扫')).not.toBeInTheDocument()
expect(onIgnoreRulesChange).not.toHaveBeenCalled()
})
it('扫描中仅看差异开关与搜索仍可用(纯视图过滤不触发重扫)', () => {
const onOnlyDiffChange = vi.fn()
render(
<FolderView {...fullProps(ENTRIES, { scanning: true, onOnlyDiffChange })} />
)
expect(screen.getByLabelText('仅看差异')).not.toBeDisabled()
const search = screen.getByPlaceholderText(/搜索文件名…/)
expect(search).not.toBeDisabled()
fireEvent.click(screen.getByLabelText('仅看差异'))
expect(onOnlyDiffChange).toHaveBeenCalledTimes(1)
})
it('点击外部区域关闭导出菜单', () => {
render(<FolderView {...fullProps(ENTRIES)} />)
fireEvent.click(screen.getByRole('button', { name: '导出报告' }))
expect(screen.getByText('纯文本')).toBeInTheDocument()
fireEvent.mouseDown(document.body)
expect(screen.queryByText('纯文本')).not.toBeInTheDocument()
})
it('文件大小格式化(B/KB 与缺失占位)', () => {
renderView(
[...ENTRIES, { rel: 'big.bin', status: 'same', leftSize: 2048, rightSize: 2048 }],
false
)
expect(screen.getAllByText('2.0 KB').length).toBe(2)
expect(screen.getAllByText('—').length).toBeGreaterThan(0)
})
it('近似判定条目渲染 ≈ 标注', () => {
renderView([{ rel: 'huge.bin', status: 'same', leftSize: 1, rightSize: 1, approximate: true }], false)
expect(screen.getByText('≈')).toBeInTheDocument()
})
it('截断时展示文件过多提示', () => {
render(
<FolderView {...fullProps(ENTRIES.slice(0, 2), { truncated: true, total: 9999 })} />
)
expect(screen.getByText(/文件过多/)).toBeInTheDocument()
})
it('全部一致且开启仅看差异时给出空态提示', () => {
renderView([{ rel: 'x.txt', status: 'same', leftSize: 1, rightSize: 1 }])
expect(screen.getByText('没有差异文件(两侧内容全部一致)')).toBeInTheDocument()
})
it('空文件夹给出空态提示', () => {
renderView([])
expect(screen.getByText('两个文件夹内都没有文件')).toBeInTheDocument()
})
it('虚拟滚动:大列表仅渲染可见窗口且总高度撑满(树形扁平化行数)', () => {
// 500 个子目录(各含 1 文件)+ 500 个根层文件 = 展开后 500 目录行 + 1000 文件行 = 1500 行
const many: FolderEntry[] = Array.from({ length: 500 }, (_, i) => ({
rel: `d${i}/f${i}.txt`,
status: 'different' as const,
leftSize: i,
rightSize: i + 1
}))
for (let i = 0; i < 500; i++) {
many.push({ rel: `r${i}.txt`, status: 'same' as const, leftSize: 1, rightSize: 1 })
}
const { container } = renderView(many, false)
const rows = container.querySelectorAll('.folder-row')
expect(rows.length).toBeGreaterThan(0)
expect(rows.length).toBeLessThan(100)
const sizer = container.querySelector('.folder-scroll > div') as HTMLElement
expect(sizer.style.height).toBe(`${1500 * FOLDER_ROW_HEIGHT}px`)
})
it('窗口尺寸变化后按新视口高度补齐渲染窗口(resize 重算)', () => {
// 200 个根层文件(无目录,扁平行);jsdom 无布局走 600px 兜底视口
const many: FolderEntry[] = Array.from({ length: 200 }, (_, i) => ({
rel: `f${i}.txt`,
status: 'different' as const,
leftSize: 1,
rightSize: 2
}))
const { container } = renderView(many, false)
const scroll = container.querySelector('.folder-scroll') as HTMLElement
expect(scroll).not.toBeNull()
const count = (): number => container.querySelectorAll('.folder-row').length
const before = count()
expect(before).toBeGreaterThan(0)
// stub clientHeight 模拟窗口拉高(600 → 1400):resize 后渲染窗口必须扩大
Object.defineProperty(scroll, 'clientHeight', { get: () => 1400, configurable: true })
fireEvent(window, new Event('resize'))
expect(count()).toBeGreaterThan(before + 20)
})
})
+804
View File
@@ -0,0 +1,804 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactElement,
type RefObject
} from 'react'
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
import { type ReportFormat } from '../diff/report'
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, IGNORE_RULES_MAX } from '../../../shared/ignoreRules'
import { buildFolderTree, filterEntries, flattenFolderTree, relExt } from './folderTree'
/** 文件夹列表虚拟行高(px),与 global.css 的 --folder-row-height 保持一致,改动需双向同步 */
export const FOLDER_ROW_HEIGHT = 26
/** 上下各多渲染的缓冲行数 */
const OVERSCAN = 8
/** 视口高度兜底值(jsdom 无布局时使用) */
const FALLBACK_VIEWPORT = 600
/** 树形缩进步进(px / 深度) */
const TREE_INDENT = 16
export interface FolderViewProps {
leftDir: string
rightDir: string
entries: FolderEntry[]
truncated: boolean
total: number
scanning: boolean
/** 快照保存中(批量读取差异条目内容期间):保存/加载快照入口禁用(保存遮罩不拦截交互,按钮级防抖) */
saving?: boolean
/** Ctrl+F 聚焦用:文件名搜索输入框 ref(App 持有并转发到 input;缺省时内部自持供清空按钮聚焦) */
searchInputRef?: RefObject<HTMLInputElement>
/** 比对进度(主进程推送;null = 尚无进度态,仅扫描中遮罩展示) */
progress?: { done: number; total: number } | null
/** 仅看差异(隐藏两侧一致的文件);与状态过滤互斥(后者开启时本开关自动关闭) */
onlyDiff: boolean
onOnlyDiffChange: (v: boolean) => void
/** 语义判等开关(字节不同但剔除空白后一致的文件计为“语义同”);切换后重新扫描 */
semantic: boolean
onSemanticChange: (v: boolean) => void
/** 忽略规则(gitignore 简化子集:`dir/` 目录剪枝、`*.ext` 通配、`name` 名字匹配);应用后重新扫描 */
ignoreRules: string[]
onIgnoreRulesChange: (rules: string[]) => void
/** 快照基线(null = 非对照模式;对照模式接管列表渲染) */
snapshot: FolderSnapshot | null
/** 快照对照结果(null = 非对照模式) */
snapshotDiff: SnapshotDiffResult | null
/** 保存当前对比结果为快照基线 */
onSaveSnapshot: () => void
/** 加载快照文件并与当前结果对照 */
onLoadSnapshot: () => void
/** 退出快照对照 */
onExitSnapshot: () => void
/** 重新选择两侧文件夹 */
onReopen: () => void
/** 不重选目录直接重新扫描(增量:指纹一致的条目零 IO 沿用) */
onRescan: () => void
/** 返回文件对比模式(清空文件夹状态) */
onExit: () => void
/** 导出文件夹对比报告 */
onExport: (fmt: ReportFormat) => void
/** 双击条目进入单文件对比 */
onOpenEntry: (entry: FolderEntry) => void
/** 双击目录行进入该目录的聚合对比(差异文件导航限定在子树内) */
onOpenDir: (dirPath: string) => void
}
/** 导出格式清单(与单文件报告一致) */
const FORMATS: { fmt: ReportFormat; label: string }[] = [
{ fmt: 'html', label: 'HTML 报告' },
{ fmt: 'txt', label: '纯文本' },
{ fmt: 'md', label: 'Markdown' }
]
const STATUS_META: Record<FolderEntryStatus, { icon: string; label: string; cls: string }> = {
same: { icon: '●', label: '相同', cls: 'st-same' },
'semantic-same': { icon: '◐', label: '语义同', cls: 'st-sem' },
different: { icon: '●', label: '不同', cls: 'st-diff' },
'left-only': { icon: '◀', label: '仅左侧', cls: 'st-left' },
'right-only': { icon: '▶', label: '仅右侧', cls: 'st-right' },
unreadable: { icon: '⊘', label: '无法读取', cls: 'st-unread' }
}
/** 状态过滤徽章与统计的展示配置(顺序即渲染顺序) */
const BADGES: { status: FolderEntryStatus; countKey: keyof ReturnType<typeof countFolderStats>; text: string }[] = [
{ status: 'same', countKey: 'same', text: '相同' },
{ status: 'semantic-same', countKey: 'semanticSame', text: '语义同' },
{ status: 'different', countKey: 'different', text: '不同' },
{ status: 'left-only', countKey: 'leftOnly', text: '仅左' },
{ status: 'right-only', countKey: 'rightOnly', text: '仅右' },
{ status: 'unreadable', countKey: 'unreadable', text: '无法读取' }
]
/** 快照变迁类型的展示配置(顺序即徽章渲染顺序;标签复用 snapshotDiff 的共享映射) */
const SNAP_CHANGES: { key: SnapshotChange; text: string }[] = [
{ key: 'fixed', text: SNAPSHOT_CHANGE_LABEL.fixed },
{ key: 'regressed', text: SNAPSHOT_CHANGE_LABEL.regressed },
{ key: 'new', text: SNAPSHOT_CHANGE_LABEL.new },
{ key: 'gone', text: SNAPSHOT_CHANGE_LABEL.gone },
{ key: 'changed', text: SNAPSHOT_CHANGE_LABEL.changed },
{ key: 'stable', text: SNAPSHOT_CHANGE_LABEL.stable }
]
export default function FolderView({
leftDir,
rightDir,
entries,
truncated,
total,
scanning,
saving = false,
searchInputRef,
progress,
onlyDiff,
onOnlyDiffChange,
semantic,
onSemanticChange,
ignoreRules,
onIgnoreRulesChange,
snapshot,
snapshotDiff,
onSaveSnapshot,
onLoadSnapshot,
onExitSnapshot,
onReopen,
onRescan,
onExit,
onExport,
onOpenEntry,
onOpenDir
}: FolderViewProps): ReactElement {
// 导出下拉(点击外部 / Esc 关闭)
const [exportOpen, setExportOpen] = useState(false)
const exportWrapRef = useRef<HTMLDivElement>(null)
const closeExport = useCallback((): void => setExportOpen(false), [])
useDismiss(exportWrapRef, closeExport)
// 快照下拉(保存/加载归组;点击外部 / Esc 关闭)
const [snapOpen, setSnapOpen] = useState(false)
const snapWrapRef = useRef<HTMLDivElement>(null)
const closeSnap = useCallback((): void => setSnapOpen(false), [])
useDismiss(snapWrapRef, closeSnap)
// 内部搜索框 ref(清空按钮点击后聚焦用;外部传入 searchInputRef 时共用同一个)
const innerSearchRef = useRef<HTMLInputElement>(null)
const folderSearchRef = searchInputRef ?? innerSearchRef
// 搜索(相对路径子串,不区分大小写;对照模式同样生效)
const [query, setQuery] = useState('')
// 状态精确过滤(null = 不过滤;与 onlyDiff 互斥)
const [statusFilter, setStatusFilter] = useState<Set<FolderEntryStatus> | null>(null)
// 扩展名精确过滤(null = 不过滤;与其他过滤条件取交集)
const [extFilter, setExtFilter] = useState<Set<string> | null>(null)
// 扩展名下拉(点击外部 / Esc 关闭)
const [extOpen, setExtOpen] = useState(false)
const extWrapRef = useRef<HTMLDivElement>(null)
const closeExt = useCallback((): void => setExtOpen(false), [])
useDismiss(extWrapRef, closeExt)
// 折叠的目录集合(默认全展开;空集合 = 全部展开)
const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(new Set())
// 忽略规则下拉(草稿文本域;打开时以当前规则初始化,应用时解析为规则数组并触发重扫)
const [ignoreOpen, setIgnoreOpen] = useState(false)
const [ignoreDraft, setIgnoreDraft] = useState('')
const ignoreWrapRef = useRef<HTMLDivElement>(null)
const closeIgnore = useCallback((): void => setIgnoreOpen(false), [])
useDismiss(ignoreWrapRef, closeIgnore)
// 草稿的无效规则清单(实时提示,不再静默丢弃):逐行 trim 后编译,收集非法规则原文
const ignoreInvalid = useMemo(() => {
const lines = ignoreDraft
.split('\n')
.map((r) => r.trim())
.filter((r) => r !== '')
return compileIgnoreRules(lines).invalid
}, [ignoreDraft])
// 快照对照模式的变迁类型过滤(null = 不过滤;单选 toggle
const [changeFilter, setChangeFilter] = useState<SnapshotChange | null>(null)
// 目录对变化(重新选择新目录对):重置全部视图层状态——旧搜索词 / 状态过滤 /
// 扩展名过滤 / 折叠集合 / 变迁过滤对新目录无意义,残留会静默生效(旧目录的
// 折叠路径会让新目录同名目录意外折叠、旧关键字继续过滤新列表)。
// 同目录重扫(leftDir/rightDir 不变)不触发,保留用户视图状态。
useEffect(() => {
setQuery('')
setStatusFilter(null)
setExtFilter(null)
setCollapsedDirs(new Set())
setChangeFilter(null)
}, [leftDir, rightDir])
/** 应用忽略规则草稿:逐行解析、去空白、去空行,截断至上限后回调重扫 */
const applyIgnoreDraft = useCallback((): void => {
const rules = ignoreDraft
.split('\n')
.map((r) => r.trim())
.filter((r) => r !== '')
.slice(0, IGNORE_RULES_MAX)
setIgnoreOpen(false)
onIgnoreRulesChange(rules)
}, [ignoreDraft, onIgnoreRulesChange])
/** 清空全部忽略规则(立即生效并重扫) */
const clearIgnore = useCallback((): void => {
setIgnoreDraft('')
setIgnoreOpen(false)
onIgnoreRulesChange([])
}, [onIgnoreRulesChange])
// 统计(条目集不变时不重算;口径与报告导出共用同一实现,始终基于全量条目)
const stats = useMemo(() => countFolderStats(entries), [entries])
/** 全量条目的扩展名分布(字典序),扩展名过滤下拉的数据源 */
const extStats = useMemo(() => {
const m = new Map<string, number>()
for (const e of entries) {
const x = relExt(e.rel)
m.set(x, (m.get(x) ?? 0) + 1)
}
return [...m.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))
}, [entries])
/** 过滤 → 建树(剪枝:过滤后无文件的目录不出现) */
const rows = useMemo(() => {
const filtered = filterEntries(entries, { onlyDiff, statusFilter, query, exts: extFilter })
const tree = buildFolderTree(filtered)
const q = query.trim()
// 搜索激活时强制全部展开(命中路径完整可见);清空搜索恢复用户折叠状态
const isExpanded = (dirPath: string): boolean =>
q !== '' ? true : !collapsedDirs.has(dirPath)
return flattenFolderTree(tree, isExpanded)
}, [entries, onlyDiff, statusFilter, query, extFilter, collapsedDirs])
/**
* stable"保持一致" */
const snapList = useMemo(() => {
if (snapshotDiff === null) return null
const q = query.trim().toLowerCase()
return snapshotDiff.entries.filter((e) => {
if (changeFilter === null) {
if (e.change === 'stable') return false
} else if (e.change !== changeFilter) {
return false
}
if (q !== '' && !e.rel.toLowerCase().includes(q)) return false
return true
})
}, [snapshotDiff, query, changeFilter])
/** 过滤后的文件数("共 N / 总数"计数用,不含目录行) */
const filteredCount = useMemo(
() => filterEntries(entries, { onlyDiff, statusFilter, query, exts: extFilter }).length,
[entries, onlyDiff, statusFilter, query, extFilter]
)
const toggleDir = useCallback((path: string): void => {
setCollapsedDirs((prev) => {
const next = new Set(prev)
if (next.has(path)) next.delete(path)
else next.add(path)
return next
})
}, [])
/** 仅看差异开关:开启时清空状态精确过滤(两机制互斥) */
const handleOnlyDiff = useCallback(
(v: boolean): void => {
if (v) setStatusFilter(null)
onOnlyDiffChange(v)
},
[onOnlyDiffChange]
)
/** 徽章点击:状态精确过滤 toggle;onlyDiff 开启时自动关闭 */
const handleBadge = useCallback(
(st: FolderEntryStatus): void => {
setStatusFilter((prev) => {
const next = new Set(prev ?? [])
if (next.has(st)) next.delete(st)
else next.add(st)
return next.size === 0 ? null : next
})
if (onlyDiff) onOnlyDiffChange(false)
},
[onlyDiff, onOnlyDiffChange]
)
/** 扩展名下拉项点击:toggle 过滤集合(空集合回落 null = 不过滤) */
const handleExtToggle = useCallback((ext: string): void => {
setExtFilter((prev) => {
const next = new Set(prev ?? [])
if (next.has(ext)) next.delete(ext)
else next.add(ext)
return next.size === 0 ? null : next
})
}, [])
/** 快照变迁徽章点击:单选 toggle(再点取消) */
const handleSnapBadge = useCallback((change: SnapshotChange): void => {
setChangeFilter((prev) => (prev === change ? null : change))
}, [])
// 对照结果刷新(重扫)后过滤的变迁类型计数归零:过滤自动复位为全部
// (徽章已不渲染,残留过滤会让列表显示空态——0.9.3 收尾)
useEffect(() => {
if (changeFilter !== null && snapshotDiff !== null && snapshotDiff.stats[changeFilter] === 0) {
setChangeFilter(null)
}
}, [snapshotDiff, changeFilter])
// 虚拟滚动:固定行高换算可见窗口(与 DiffView 同思路;对照模式与树形模式共用窗口状态)
const scrollRef = useRef<HTMLDivElement>(null)
const [range, setRange] = useState({ start: 0, end: 0 })
const listCount = snapList !== null ? snapList.length : rows.length
const updateRange = (): void => {
const el = scrollRef.current
if (!el) return
const vh = el.clientHeight || FALLBACK_VIEWPORT
const start = Math.max(0, Math.floor(el.scrollTop / FOLDER_ROW_HEIGHT) - OVERSCAN)
const end = Math.min(listCount, Math.ceil((el.scrollTop + vh) / FOLDER_ROW_HEIGHT) + OVERSCAN)
setRange((prev) => (prev.start === start && prev.end === end ? prev : { start, end }))
}
useLayoutEffect(() => {
updateRange()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listCount])
// 窗口尺寸变化时按新视口高度补齐渲染窗口(与 DiffView 同思路;
// 缺失该监听时拉伸窗口变高后底部出现空白区,滚动一下才恢复)
useEffect(() => {
const onResize = (): void => updateRange()
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listCount])
const q = query.trim()
return (
<div className="folder-view">
{/* 两侧文件夹卡片 + 横排操作区(快照归组下拉;单行头部把高度留给条目列表) */}
<div className="folder-head">
<div className="folder-card left" title={leftDir}>
<span className="pane-dot left" />
<span className="folder-path">{leftDir}</span>
</div>
<div className="folder-card right" title={rightDir}>
<span className="pane-dot right" />
<span className="folder-path">{rightDir}</span>
</div>
<div className="folder-actions">
<button
className="btn ghost"
disabled={scanning}
title={scanning ? '正在扫描文件夹' : '重新扫描当前两个目录(增量:未变更的文件直接沿用判定)'}
onClick={onRescan}
>
</button>
{/*
*/}
<button
className="btn ghost"
title="重新选择两个文件夹进行对比(扫描中点击将放弃当前扫描)"
onClick={onReopen}
>
</button>
<div className="export-wrap" ref={snapWrapRef}>
<button
className="btn ghost"
disabled={scanning || saving}
title={
scanning
? '正在扫描文件夹'
: saving
? '正在保存快照'
: '保存当前对比结果为快照基线 / 加载之前保存的快照对照'
}
onClick={() => setSnapOpen((o) => !o)}
>
</button>
{snapOpen && (
<div className="export-menu">
<button
className="ctx-item"
disabled={scanning || saving}
title={
saving
? '正在保存快照'
: '保存当前对比结果为快照基线(JSON 文件),供日后加载对照'
}
onClick={() => {
setSnapOpen(false)
onSaveSnapshot()
}}
>
</button>
<button
className="ctx-item"
disabled={scanning || saving}
title={
saving
? '正在保存快照'
: '加载之前保存的快照,与当前结果对照(已修复 / 新增差异 / 新文件 / 已移除)'
}
onClick={() => {
setSnapOpen(false)
onLoadSnapshot()
}}
>
</button>
</div>
)}
</div>
<div className="export-wrap" ref={exportWrapRef}>
<button
className="btn ghost"
disabled={scanning}
title={scanning ? '正在扫描文件夹' : '导出文件夹对比报告(HTML / 纯文本 / Markdown'}
onClick={() => setExportOpen((o) => !o)}
>
</button>
{exportOpen && (
<div className="export-menu">
{FORMATS.map((f) => (
<button
key={f.fmt}
className="ctx-item"
onClick={() => {
setExportOpen(false)
onExport(f.fmt)
}}
>
{f.label}
</button>
))}
</div>
)}
</div>
<button className="btn ghost" onClick={onExit}>
</button>
</div>
</div>
{/* 工具行:对照模式渲染变迁统计,普通模式渲染状态统计 + 搜索 + 开关 */}
{snapshotDiff !== null && snapshot !== null ? (
<div className="folder-toolbar">
<div className="folder-stats">
{SNAP_CHANGES.map((c) => {
const n = snapshotDiff.stats[c.key]
// 零计数与保持一致的常态不占位(保持一致仅在存在时展示)
if (n === 0) return null
const active = changeFilter === c.key
return (
<button
key={c.key}
className={`fstat snap-${c.key}${active ? ' active' : ''}`}
title={
active
? `点击取消“${c.text}”过滤`
: `点击只看“${c.text}”的条目(基于 ${new Date(snapshot.savedAt).toLocaleString('zh-CN')} 的快照基线)`
}
onClick={() => handleSnapBadge(c.key)}
>
{c.text} {n}
</button>
)
})}
<span className="fstat muted" title="对照条目总数(快照与当前结果的并集)">
{snapList !== null ? `${snapList.length} / ${snapshotDiff.entries.length}` : snapshotDiff.entries.length}
</span>
</div>
<span className="fstat muted" title="快照基线的保存时间与判定口径">
线 {new Date(snapshot.savedAt).toLocaleString('zh-CN')}
{snapshot.semantic ? ' · 语义判等' : ''}
</span>
<div className="search-box">
<input
ref={folderSearchRef}
className="folder-search"
type="text"
value={query}
placeholder="搜索文件名… (Ctrl+F)"
spellCheck={false}
title="在对照条目的相对路径内搜索(不区分大小写):Esc 清空"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
if (query !== '') e.preventDefault()
setQuery('')
e.currentTarget.blur()
}
}}
/>
{query.trim() !== '' && (
<button
className="search-clear"
title="清空搜索(Esc"
onClick={() => {
setQuery('')
folderSearchRef.current?.focus()
}}
>
×
</button>
)}
</div>
<button className="btn ghost" onClick={onExitSnapshot} title="退出快照对照,恢复常规视图">
退
</button>
</div>
) : (
<div className="folder-toolbar">
<div className="folder-stats">
{BADGES.map((b) => {
// 语义关闭时语义同恒为 0,徽章隐藏(与旧版一致)
if (b.status === 'semantic-same' && !semantic) return null
// 无法读取为异常态:仅在出现时展示,平时不占位
if (b.status === 'unreadable' && stats.unreadable === 0) return null
const active = statusFilter?.has(b.status) ?? false
return (
<button
key={b.status}
className={`fstat ${STATUS_META[b.status].cls}${active ? ' active' : ''}`}
title={
active
? `点击取消“${STATUS_META[b.status].label}”过滤`
: `点击只看“${STATUS_META[b.status].label}”的文件`
}
onClick={() => handleBadge(b.status)}
>
{b.text} {stats[b.countKey]}
</button>
)
})}
<span className="fstat muted" title="条目总数(扩展名/状态过滤不影响统计徽章口径)">
{extFilter !== null || statusFilter !== null ? `${filteredCount} / ${entries.length}` : entries.length}
</span>
</div>
<div className="export-wrap" ref={extWrapRef}>
<button
className={'btn ghost' + (extFilter !== null ? ' filtered' : '')}
title="按扩展名过滤条目(可多选,与其他过滤条件取交集)"
onClick={() => setExtOpen((o) => !o)}
>
{extFilter !== null ? ` · ${extFilter.size}` : ''}
</button>
{extOpen && (
<div className="export-menu ext-menu">
{extStats.map(([ext, n]) => {
const on = extFilter?.has(ext) ?? false
return (
<button
key={ext}
className={'ctx-item' + (on ? ' active' : '')}
title={on ? `点击取消“${ext === '' ? '无扩展名' : '.' + ext}”过滤` : `点击只看“${ext === '' ? '无扩展名' : '.' + ext}”的文件`}
onClick={() => handleExtToggle(ext)}
>
{ext === '' ? '(无扩展名)' : `.${ext}`}
<span className="ext-count">{n}</span>
</button>
)
})}
{extFilter !== null && (
<button className="ctx-item ext-clear" onClick={() => setExtFilter(null)}>
</button>
)}
</div>
)}
</div>
<div className="export-wrap" ref={ignoreWrapRef}>
<button
className={'btn ghost' + (ignoreRules.length > 0 ? ' filtered' : '')}
disabled={scanning}
title={
scanning
? '正在扫描文件夹'
: '忽略规则(gitignore 简化子集含高级 glob):node_modules/ 目录整树剪枝、*.log 通配、lo?g 单字符、*.[ch] 字符类、src/temp 多段路径、build/**/cache 跨段、!keep.log 否定;应用后重新扫描'
}
onClick={() => {
setIgnoreDraft(ignoreRules.join('\n'))
setIgnoreOpen((o) => !o)
}}
>
{ignoreRules.length > 0 ? ` · ${ignoreRules.length}` : ''}
</button>
{ignoreOpen && (
<div className="export-menu ignore-menu">
<textarea
className="ignore-input"
value={ignoreDraft}
rows={8}
spellCheck={false}
placeholder={
'每行一条(gitignore 简化子集):\nnode_modules/ 目录整树剪枝\n*.log 扩展名通配\nlo?g ? 单字符\n*.[ch] [abc] 字符类\nsrc/temp 多段路径(从根)\nbuild/**/cache ** 跨段\n!keep.log 否定(最后匹配优先)'
}
onChange={(e) => setIgnoreDraft(e.target.value)}
/>
{ignoreInvalid.length > 0 && (
<div className="ignore-invalid" title="以下规则不被支持,应用时将被忽略">
{ignoreInvalid.join('、')}
</div>
)}
<button className="ctx-item" onClick={applyIgnoreDraft}>
</button>
<button className="ctx-item ext-clear" onClick={clearIgnore}>
</button>
</div>
)}
</div>
<div className="search-box">
<input
ref={folderSearchRef}
className="folder-search"
type="text"
value={query}
placeholder="搜索文件名… (Ctrl+F)"
spellCheck={false}
title="在全部条目的相对路径内搜索(不区分大小写):Esc 清空"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
if (query !== '') e.preventDefault()
setQuery('')
e.currentTarget.blur()
}
}}
/>
{query.trim() !== '' && (
<button
className="search-clear"
title="清空搜索(Esc"
onClick={() => {
setQuery('')
folderSearchRef.current?.focus()
}}
>
×
</button>
)}
</div>
<label
className={'switch' + (scanning ? ' disabled' : '')}
title={
scanning
? '正在扫描文件夹'
: '字节不同但剔除空白(换行符/缩进/空行)后内容一致的文件计为“语义同”,不计入差异;切换后重新扫描'
}
>
<input
type="checkbox"
checked={semantic}
disabled={scanning}
onChange={(e) => onSemanticChange(e.target.checked)}
/>
</label>
<label className="switch" title="隐藏两侧内容一致的文件(含语义同),专注差异">
<input
type="checkbox"
checked={onlyDiff}
onChange={(e) => handleOnlyDiff(e.target.checked)}
/>
</label>
{truncated && (
<span className="fstat warn" title="文件数量超出上限,以下仅展示部分条目">
{total}+ {entries.length}
</span>
)}
</div>
)}
{/* 条目列表:对照模式平铺对照条目,普通模式目录树 + 虚拟滚动 */}
<div className="folder-scroll" ref={scrollRef} onScroll={updateRange}>
<div style={{ height: listCount * FOLDER_ROW_HEIGHT }}>
{snapList !== null
? snapList.slice(range.start, range.end).map((e) => {
// 展示条目:当前结果优先,已移除条目回退快照数据
const display = e.current ?? e.baseline!
const meta = STATUS_META[display.status]
const sc = SNAP_CHANGES.find((c) => c.key === e.change)!
// 快照含基线内容时双击进入"快照基线 vs 当前"的内容对照(App 判定 contents
const hasContents =
e.current !== null &&
snapshot?.contents?.[e.rel] !== undefined &&
(snapshot.contents[e.rel].left !== null || snapshot.contents[e.rel].right !== null)
return (
<div
className={'folder-row ' + meta.cls}
key={e.rel}
style={{ paddingLeft: 18 }}
title={`${e.rel} · 快照对照:${sc.text}${e.current ? '' : '(当前已不存在,大小为快照值)'}${hasContents ? ' · 双击查看快照基线内容对照' : ''}`}
onDoubleClick={() => {
if (e.current) onOpenEntry(e.current)
}}
>
<span className="folder-st-icon">{meta.icon}</span>
<span className="folder-rel">{e.rel}</span>
<span className={`snap-badge snap-b-${e.change}`}>{sc.text}</span>
<span className="folder-size">{fmtSize(display.leftSize)}</span>
<span className="folder-size">{fmtSize(display.rightSize)}</span>
</div>
)
})
: rows.slice(range.start, range.end).map((row) => {
const n = row.node
const indent = { paddingLeft: 18 + row.depth * TREE_INDENT }
if (n.isDir) {
const collapsed = q === '' && collapsedDirs.has(n.path)
const meta = STATUS_META[n.agg ?? 'same']
return (
<div
className={`folder-row dir ${meta.cls}`}
key={`dir-${n.path}`}
title={`${n.path}/ · ${n.fileCount ?? 0} 个文件 · 双击对比此目录内的差异文件`}
onClick={() => toggleDir(n.path)}
onDoubleClick={() => {
// 双击前两次单击 toggle 会回到原折叠态:进入聚合对比前确保该目录展开
setCollapsedDirs((prev) => {
if (!prev.has(n.path)) return prev
const next = new Set(prev)
next.delete(n.path)
return next
})
onOpenDir(n.path)
}}
>
<span className="folder-caret">{collapsed ? '▶' : '▼'}</span>
<span className="folder-rel">{n.name}</span>
<span className={`folder-agg ${meta.cls}`}>{meta.label}</span>
<span className="folder-dircount">{n.fileCount ?? 0} </span>
</div>
)
}
const e = n.entry!
const meta = STATUS_META[e.status]
return (
<div
className={'folder-row ' + meta.cls}
key={e.rel}
style={indent}
title={`双击对比:${e.rel}${e.status === 'semantic-same' ? '(语义同:仅空白/换行差异)' : ''}${e.approximate ? '(超大文件,近似判定)' : ''}`}
onDoubleClick={() => onOpenEntry(e)}
>
<span className="folder-st-icon">{meta.icon}</span>
<span className="folder-rel">{n.name}</span>
{e.approximate && <span className="folder-approx"></span>}
<span className="folder-size">{fmtSize(e.leftSize)}</span>
<span className="folder-size">{fmtSize(e.rightSize)}</span>
</div>
)
})}
</div>
{!scanning && listCount === 0 && (
<div className="folder-empty-note">
{snapList !== null
? q !== ''
? `没有匹配“${q}”的条目`
: changeFilter !== null
? `没有“${SNAP_CHANGES.find((c) => c.key === changeFilter)!.text}”的条目`
: '与快照基线完全一致,没有任何变迁'
: q !== ''
? `没有匹配“${q}”的文件`
: entries.length === 0
? '两个文件夹内都没有文件'
: statusFilter !== null || extFilter !== null
? '当前过滤条件下没有文件'
: '没有差异文件(两侧内容全部一致)'}
</div>
)}
</div>
{scanning && (
<div className="computing-overlay">
<span className="computing-ring" />
<span>
{progress ? ` 已比对 ${progress.done} / ${progress.total}` : ''}
</span>
</div>
)}
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactElement } from 'react'
/**
* Logo + 线
* size App 18 / 40
*/
export default function LogoIcon({ size = 18 }: { size?: number }): ReactElement {
const s = size / 18
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="6.2" stroke="#fff" strokeWidth={2.1 * Math.max(s, 0.7)} />
<line
x1="15.5"
y1="15.5"
x2="20.5"
y2="20.5"
stroke="#fff"
strokeWidth={2.4 * Math.max(s, 0.7)}
strokeLinecap="round"
/>
<path
d="M5 11h3.4M5 14h2"
stroke="#030712"
strokeWidth={1.6 * Math.max(s, 0.7)}
strokeLinecap="round"
/>
</svg>
)
}
+231 -7
View File
@@ -1,19 +1,49 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { createRef } from 'react'
import Toolbar from './Toolbar'
import { DiffSummary } from '../diff/diffEngine'
const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 }
const summary: DiffSummary = {
changedLines: 3,
inserted: 1,
deleted: 1,
modified: 1,
charsAdded: 12,
charsDeleted: 8
}
function setup(extra?: { computing?: boolean; charMode?: boolean }) {
function setup(extra?: {
computing?: boolean
charMode?: boolean
charModeDowngraded?: boolean
charModePartial?: boolean
baseOptions?: Partial<import('../diff/diffEngine').DiffOptions>
onOptionBlocked?: (label: string) => void
searchQuery?: string
searchIndex?: number
searchCount?: number
onSearchNav?: (dir: 1 | -1) => void
canPatch?: boolean
}) {
const onOptionsChange = vi.fn()
const onNav = vi.fn()
const onExport = vi.fn()
const onOnlyDiffChange = vi.fn()
const onSwap = vi.fn()
const onSearchChange = vi.fn()
const onSearchNav = extra?.onSearchNav ?? vi.fn()
const searchInputRef = createRef<HTMLInputElement>()
const utils = render(
<Toolbar
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: extra?.charMode ?? false }}
options={{
trimWhitespace: false,
ignoreCase: false,
ignoreBlankLines: false,
ignoreAllWhitespace: false,
...extra?.baseOptions,
charMode: extra?.charMode ?? false
}}
onOptionsChange={onOptionsChange}
summary={summary}
navIndex={0}
@@ -24,9 +54,29 @@ function setup(extra?: { computing?: boolean; charMode?: boolean }) {
onlyDiff={false}
onOnlyDiffChange={onOnlyDiffChange}
onSwap={onSwap}
onOptionBlocked={extra?.onOptionBlocked}
charModeDowngraded={extra?.charModeDowngraded ?? false}
charModePartial={extra?.charModePartial ?? false}
searchQuery={extra?.searchQuery ?? ''}
onSearchChange={onSearchChange}
searchIndex={extra?.searchIndex ?? 0}
searchCount={extra?.searchCount ?? 0}
onSearchNav={onSearchNav}
searchInputRef={searchInputRef}
canPatch={extra?.canPatch}
/>
)
return { onOptionsChange, onNav, onExport, onOnlyDiffChange, onSwap, ...utils }
return {
onOptionsChange,
onNav,
onExport,
onOnlyDiffChange,
onSwap,
onSearchChange,
onSearchNav,
searchInputRef,
...utils
}
}
describe('Toolbar', () => {
@@ -37,6 +87,18 @@ describe('Toolbar', () => {
expect(screen.getByText('~1')).toBeInTheDocument()
})
it('字符级开启时追加字符粒度增删徽章(0.10.5)', () => {
setup({ charMode: true })
expect(screen.getByText('+12 字')).toBeInTheDocument()
expect(screen.getByText('8 字')).toBeInTheDocument()
})
it('字符级关闭时不渲染字符粒度徽章', () => {
setup()
expect(screen.queryByText('+12 字')).not.toBeInTheDocument()
expect(screen.queryByText('8 字')).not.toBeInTheDocument()
})
it('切换忽略大小写触发 onOptionsChange', () => {
const { onOptionsChange } = setup()
fireEvent.click(screen.getByLabelText('忽略大小写'))
@@ -67,11 +129,81 @@ describe('Toolbar', () => {
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: true })
})
// 字符级开启时空白选项 label 带有“已包含”徽标,getByLabelText 需正则前缀匹配
it('字符级对比开启时三个空白选项被禁用(语义已被包含)', () => {
setup({ charMode: true })
expect(screen.getByLabelText('忽略行首尾空白')).toBeDisabled()
expect(screen.getByLabelText('忽略空行')).toBeDisabled()
expect(screen.getByLabelText('忽略所有空白')).toBeDisabled()
expect(screen.getByLabelText(/^忽略行首尾空白/)).toBeDisabled()
expect(screen.getByLabelText(/^忽略空行/)).toBeDisabled()
expect(screen.getByLabelText(/^忽略所有空白/)).toBeDisabled()
})
it('字符级对比开启时空白选项灰显勾选(语义已生效)并渲染“已包含”徽标', () => {
const { container } = setup({ charMode: true })
// 灰显勾选:checked 仅为视觉呈现,表达语义已随字符级生效
expect(screen.getByLabelText(/^忽略行首尾空白/)).toBeChecked()
expect(screen.getByLabelText(/^忽略空行/)).toBeChecked()
expect(screen.getByLabelText(/^忽略所有空白/)).toBeChecked()
// 徽标常显(无需悬停 title)
expect(screen.getAllByText('已包含')).toHaveLength(3)
expect(container.querySelectorAll('.switch.disabled')).toHaveLength(3)
})
it('字符级对比关闭时空白选项恢复用户原状态且不渲染徽标', () => {
const { container } = setup({ baseOptions: { ignoreBlankLines: true } })
// 用户原状态:仅忽略空行勾选
expect(screen.getByLabelText('忽略行首尾空白')).not.toBeChecked()
expect(screen.getByLabelText('忽略空行')).toBeChecked()
expect(screen.getByLabelText('忽略所有空白')).not.toBeChecked()
expect(screen.queryByText('已包含')).not.toBeInTheDocument()
expect(container.querySelector('.switch.disabled')).toBeNull()
})
it('点击被包含的空白选项触发 onOptionBlocked 且不改写选项状态', () => {
const onOptionBlocked = vi.fn()
const { onOptionsChange } = setup({ charMode: true, onOptionBlocked })
// 点击 label 本身(贴近真实用户点文字的行为;onClick preventDefault 阻止激活转发的干扰)
const label = screen.getByLabelText(/^忽略所有空白/).closest('label') as HTMLElement
fireEvent.click(label)
expect(onOptionBlocked).toHaveBeenCalledWith('忽略所有空白')
expect(onOptionsChange).not.toHaveBeenCalled()
})
it('字符级对比关闭时点击空白选项不触发 onOptionBlocked', () => {
const onOptionBlocked = vi.fn()
setup({ onOptionBlocked })
fireEvent.click(screen.getByLabelText('忽略所有空白'))
expect(onOptionBlocked).not.toHaveBeenCalled()
})
it('字符级因内容超限降级时开关旁显示“已降级”标记', () => {
setup({ charMode: true, charModeDowngraded: true })
expect(screen.getByText('已降级')).toBeInTheDocument()
})
it('字符级部分降级时显示“部分降级”标记(不出现整体降级标记)', () => {
setup({ charMode: true, charModePartial: true })
expect(screen.getByText('部分降级')).toBeInTheDocument()
expect(screen.queryByText('已降级')).not.toBeInTheDocument()
})
it('整体降级优先呈现:同时标记时只显示“已降级”', () => {
setup({ charMode: true, charModePartial: true, charModeDowngraded: true })
expect(screen.getByText('已降级')).toBeInTheDocument()
expect(screen.queryByText('部分降级')).not.toBeInTheDocument()
})
it('字符级关闭或未降级时不显示“部分降级”标记', () => {
setup({ charMode: true })
expect(screen.queryByText('部分降级')).not.toBeInTheDocument()
setup({ charModePartial: true })
expect(screen.queryByText('部分降级')).not.toBeInTheDocument()
})
it('未降级或字符级关闭时不显示“已降级”标记', () => {
setup({ charMode: true })
expect(screen.queryByText('已降级')).not.toBeInTheDocument()
setup({ charModeDowngraded: true })
expect(screen.queryByText('已降级')).not.toBeInTheDocument()
})
it('字符级对比开启时忽略大小写仍可用', () => {
@@ -118,6 +250,34 @@ describe('Toolbar', () => {
expect(onExport).toHaveBeenCalledWith('html')
})
it('导出菜单含 Patch 项:可导出时点击触发 onExport("patch")0.11.0', () => {
const { onExport } = setup({ canPatch: true })
fireEvent.click(screen.getByText('导出报告'))
const item = screen.getByText('Patch 标准差异')
expect(item).not.toBeDisabled()
fireEvent.click(item)
expect(onExport).toHaveBeenCalledWith('patch')
})
it('canPatch 为 false 时 Patch 项禁用且点击不触发导出(判等污染提示,0.11.0)', () => {
const { onExport } = setup({ canPatch: false })
fireEvent.click(screen.getByText('导出报告'))
const item = screen.getByText('Patch 标准差异')
expect(item).toBeDisabled()
expect(item).toHaveAttribute(
'title',
expect.stringContaining('Patch 无法表达')
)
fireEvent.click(item)
expect(onExport).not.toHaveBeenCalled()
})
it('未传 canPatch 时 Patch 项默认可用(缺省 true', () => {
setup()
fireEvent.click(screen.getByText('导出报告'))
expect(screen.getByText('Patch 标准差异')).not.toBeDisabled()
})
it('导出菜单向上弹出(工具栏贴窗口底部,防止溢出底边)', () => {
const { container } = setup()
fireEvent.click(screen.getByText('导出报告'))
@@ -145,3 +305,67 @@ describe('Toolbar', () => {
expect(screen.getByText('导出报告')).toBeDisabled()
})
})
describe('Toolbar - 视图内搜索框', () => {
it('未搜索时不显示命中计数', () => {
setup()
expect(screen.getByPlaceholderText('搜索内容… (Ctrl+F)')).toHaveValue('')
expect(screen.getByPlaceholderText('搜索内容… (Ctrl+F)')).toBeVisible()
// 搜索计数为空串(差异导航计数与此无关,限定 .search-count 断言)
expect(document.querySelector('.search-count')?.textContent).toBe('')
})
it('搜索中显示当前序号与命中总数', () => {
setup({ searchQuery: 'alpha', searchIndex: 1, searchCount: 5 })
expect(screen.getByText('2 / 5')).toBeInTheDocument()
})
it('无命中显示 0 / N', () => {
setup({ searchQuery: 'zzz', searchCount: 0 })
expect(screen.getByText('0 / 0')).toBeInTheDocument()
})
it('输入触发 onSearchChange', () => {
const { onSearchChange } = setup()
fireEvent.change(screen.getByPlaceholderText('搜索内容… (Ctrl+F)'), {
target: { value: 'target' }
})
expect(onSearchChange).toHaveBeenCalledWith('target')
})
it('Enter 触发 onSearchNav(1)Shift+Enter 触发 onSearchNav(-1)', () => {
const { onSearchNav } = setup({ searchQuery: 'a', searchCount: 2 })
const input = screen.getByPlaceholderText('搜索内容… (Ctrl+F)')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSearchNav).toHaveBeenCalledWith(1)
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
expect(onSearchNav).toHaveBeenCalledWith(-1)
})
it('Esc 清空关键字并失焦', () => {
const { onSearchChange } = setup({ searchQuery: 'stale' })
const input = screen.getByPlaceholderText('搜索内容… (Ctrl+F)')
input.focus()
expect(document.activeElement).toBe(input)
fireEvent.keyDown(input, { key: 'Escape' })
expect(onSearchChange).toHaveBeenCalledWith('')
expect(document.activeElement).not.toBe(input)
})
it('有内容时显示清空按钮,点击清空并保持焦点(0.10.5)', () => {
const { onSearchChange } = setup({ searchQuery: 'find' })
expect(screen.getByTitle('清空搜索(Esc')).toBeInTheDocument()
fireEvent.click(screen.getByTitle('清空搜索(Esc'))
expect(onSearchChange).toHaveBeenCalledWith('')
})
it('未搜索时不渲染清空按钮', () => {
setup({ searchQuery: '' })
expect(screen.queryByTitle('清空搜索(Esc')).not.toBeInTheDocument()
})
it('searchInputRef 转发到输入框(Ctrl+F 聚焦用)', () => {
const { searchInputRef } = setup()
expect(searchInputRef.current).toBe(screen.getByPlaceholderText('搜索内容… (Ctrl+F)'))
})
})
+152 -22
View File
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState, type ReactElement } from 'react'
import { useCallback, useRef, useState, type ReactElement, type RefObject } from 'react'
import type { DiffOptions, DiffSummary } from '../diff/diffEngine'
import { type ReportFormat } from '../diff/report'
import { useDismiss } from '../hooks/useDismiss'
@@ -6,7 +6,8 @@ import { useDismiss } from '../hooks/useDismiss'
const FORMATS: { fmt: ReportFormat; label: string }[] = [
{ fmt: 'html', label: 'HTML 报告' },
{ fmt: 'txt', label: '纯文本' },
{ fmt: 'md', label: 'Markdown' }
{ fmt: 'md', label: 'Markdown' },
{ fmt: 'patch', label: 'Patch 标准差异' }
]
interface ToolbarProps {
@@ -24,6 +25,25 @@ interface ToolbarProps {
onOnlyDiffChange: (v: boolean) => void
/** 交换左右两侧 */
onSwap: () => void
/** 点击被字符级对比包含(禁用中)的空白选项时通知(App 弹 toast 解释) */
onOptionBlocked?: (label: string) => void
/** 字符级对比因内容超限自动降级为行级(开关旁显示“已降级”标记) */
charModeDowngraded?: boolean
/** 字符级对比部分降级(部分超限组按行级,其余组仍字符级;显示“部分降级”标记) */
charModePartial?: boolean
/** 视图内文本搜索关键字(空串 = 未搜索) */
searchQuery: string
onSearchChange: (v: string) => void
/** 当前命中序号(0 基;无命中为 0) */
searchIndex: number
/** 命中总数 */
searchCount: number
/** 命中行之间跳转(1 下一个 / -1 上一个,循环) */
onSearchNav: (dir: 1 | -1) => void
/** Ctrl+F 聚焦用:搜索输入框 refApp 持有并转发到 input */
searchInputRef: RefObject<HTMLInputElement>
/** Patchunified diff)是否可从当前结果生成:判等选项污染 unchanged 行时为 false(菜单项禁用) */
canPatch?: boolean
}
export default function Toolbar({
@@ -37,7 +57,17 @@ export default function Toolbar({
computing,
onlyDiff,
onOnlyDiffChange,
onSwap
onSwap,
onOptionBlocked,
charModeDowngraded = false,
charModePartial = false,
searchQuery,
onSearchChange,
searchIndex,
searchCount,
onSearchNav,
searchInputRef,
canPatch = true
}: ToolbarProps): ReactElement {
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
const [exportOpen, setExportOpen] = useState(false)
@@ -48,25 +78,37 @@ export default function Toolbar({
const canNav = navCount > 0
// 被包含选项的 label:灰显 + 点击解释(checkbox 本身 disabled 不响应,label 捕获点击给反馈)
const blockedLabel = (label: string): ((e: { preventDefault(): void }) => void) | undefined =>
options.charMode
? (e) => {
// 阻止 label 默认激活行为,统一走解释反馈
e.preventDefault()
onOptionBlocked?.(label)
}
: undefined
return (
<div className="toolbar">
<div className="tool-group">
<span className="tool-label"></span>
<label
className="switch"
className={'switch' + (options.charMode ? ' disabled' : '')}
title={
options.charMode
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
: '忽略行首尾空白差异'
}
onClick={blockedLabel('忽略行首尾空白')}
>
<input
type="checkbox"
checked={options.trimWhitespace}
checked={options.charMode ? true : options.trimWhitespace}
disabled={options.charMode}
onChange={(e) => set({ trimWhitespace: e.target.checked })}
/>
{options.charMode && <span className="opt-badge"></span>}
</label>
<label className="switch">
<input
@@ -77,40 +119,44 @@ export default function Toolbar({
</label>
<label
className="switch"
className={'switch' + (options.charMode ? ' disabled' : '')}
title={
options.charMode
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
: '忽略空行差异(两侧空白行互相判等)'
}
onClick={blockedLabel('忽略空行')}
>
<input
type="checkbox"
checked={options.ignoreBlankLines ?? false}
checked={options.charMode ? true : (options.ignoreBlankLines ?? false)}
disabled={options.charMode}
onChange={(e) => set({ ignoreBlankLines: e.target.checked })}
/>
{options.charMode && <span className="opt-badge"></span>}
</label>
<label
className="switch"
className={'switch' + (options.charMode ? ' disabled' : '')}
title={
options.charMode
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
: '忽略行内全部空白(空格/制表符/全角空格),仅比较实际文字;换行结构仍参与对比'
}
onClick={blockedLabel('忽略所有空白')}
>
<input
type="checkbox"
checked={options.ignoreAllWhitespace ?? false}
checked={options.charMode ? true : (options.ignoreAllWhitespace ?? false)}
disabled={options.charMode}
onChange={(e) => set({ ignoreAllWhitespace: e.target.checked })}
/>
{options.charMode && <span className="opt-badge"></span>}
</label>
<label
className="switch"
title="空白与换行结构全部不参与判等,差异以字符粒度定位(跨行重组也能判等;超大内容自动降级行级对比"
title="空白与换行结构全部不参与判等,差异以字符粒度定位(跨行重组也能判等);相同内容自动锚定跳过计算,大文件不再整篇受限;变更组预算随输入规模自适应;差异过大时降级行级对比"
>
<input
type="checkbox"
@@ -118,6 +164,19 @@ export default function Toolbar({
onChange={(e) => set({ charMode: e.target.checked })}
/>
{options.charMode && charModeDowngraded && (
<span className="opt-badge warn" title="内容或差异过大,本次对比已自动降级为行级(忽略所有空白 + 忽略空行语义)">
</span>
)}
{options.charMode && !charModeDowngraded && charModePartial && (
<span
className="opt-badge warn"
title="部分差异过大的块已按行级对比,其余差异块仍为字符级高亮"
>
</span>
)}
</label>
</div>
@@ -136,6 +195,47 @@ export default function Toolbar({
</button>
</div>
<div className="tool-group search-group">
<div className="search-box">
<input
ref={searchInputRef}
className="diff-search"
type="text"
value={searchQuery}
placeholder="搜索内容… (Ctrl+F)"
spellCheck={false}
title="在两侧全部行内搜索(大小写不敏感):Enter 下一个 / Shift+Enter 上一个 / Esc 清空"
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
// Esc 清空并交还焦点(仅当有内容时阻止默认避免冒泡副作用)
if (searchQuery !== '') e.preventDefault()
onSearchChange('')
e.currentTarget.blur()
} else if (e.key === 'Enter') {
e.preventDefault()
onSearchNav(e.shiftKey ? -1 : 1)
}
}}
/>
{searchQuery.trim() !== '' && (
<button
className="search-clear"
title="清空搜索(Esc"
onClick={() => {
onSearchChange('')
searchInputRef.current?.focus()
}}
>
×
</button>
)}
</div>
<span className="search-count">
{searchQuery.trim() === '' ? '' : `${searchCount > 0 ? searchIndex + 1 : 0} / ${searchCount}`}
</span>
</div>
<div className="tool-group">
<button
className="nav-btn"
@@ -169,18 +269,32 @@ export default function Toolbar({
</button>
{exportOpen && (
<div className="export-menu up">
{FORMATS.map((f) => (
<button
key={f.fmt}
className="ctx-item"
onClick={() => {
setExportOpen(false)
onExport(f.fmt)
}}
>
{f.label}
</button>
))}
{FORMATS.map((f) => {
// Patch 的上下文行要求两侧文本严格一致:判等选项(忽略大小写/所有空白/
// 字符级等)把不同文本判为相同时无法表达,禁用并解释
const disabled = f.fmt === 'patch' && !canPatch
return (
<button
key={f.fmt}
className="ctx-item"
disabled={disabled}
title={
disabled
? '当前比较选项把部分不同内容判为相同(如忽略大小写、忽略空白、字符级),Patch 无法表达;关闭相关选项后可导出'
: f.fmt === 'patch'
? '标准 unified diff 格式(git diff 风格),可直接用于 git apply / patch'
: undefined
}
onClick={() => {
if (disabled) return
setExportOpen(false)
onExport(f.fmt)
}}
>
{f.label}
</button>
)
})}
</div>
)}
</div>
@@ -197,6 +311,22 @@ export default function Toolbar({
<span className="badge mod">
<span className="dot" />~{summary.modified}
</span>
{options.charMode && (
<>
<span
className="badge chars"
title="字符粒度新增统计(码点数):新增行整行 + 修改行的新增段;超长防护行不计"
>
+{summary.charsAdded}
</span>
<span
className="badge chars"
title="字符粒度删除统计(码点数):删除行整行 + 修改行的删除段;超长防护行不计"
>
{summary.charsDeleted}
</span>
</>
)}
</div>
</div>
)
@@ -0,0 +1,180 @@
import { describe, it, expect } from 'vitest'
import type { FolderEntry } from '../../../preload/index'
import {
buildFolderTree,
filterEntries,
flattenFolderTree,
collectEntries,
relExt
} from './folderTree'
const E: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'sub/only-right.txt', status: 'right-only', leftSize: null, rightSize: 7 },
{ rel: 'sub/deep/x.ts', status: 'different', leftSize: 2, rightSize: 3 },
{ rel: 'z.md', status: 'semantic-same', leftSize: 1, rightSize: 2 }
]
describe('buildFolderTree', () => {
it('按路径分段聚合目录与文件,同层名称字典序', () => {
const root = buildFolderTree(E)
// 根层:a.txt / sub / z.md(字典序)
expect(root.children.map((c) => c.name)).toEqual(['a.txt', 'sub', 'z.md'])
expect(root.children.map((c) => c.isDir)).toEqual([false, true, false])
// sub 层:deep 目录 + only-right.txt
const sub = root.children[1]
expect(sub.children.map((c) => c.name)).toEqual(['deep', 'only-right.txt'])
// deep 层:x.ts
expect(sub.children[0].children[0].entry?.rel).toBe('sub/deep/x.ts')
})
it('目录聚合子树状态(different 优先)与文件计数', () => {
const root = buildFolderTree(E)
const deep = root.children[1].children[0]
expect(deep.agg).toBe('different')
expect(deep.fileCount).toBe(1)
const sub = root.children[1]
expect(sub.agg).toBe('different')
expect(sub.fileCount).toBe(2)
expect(root.agg).toBe('different')
expect(root.fileCount).toBe(4)
})
it('仅同种子树聚合为 same;only 与语义同优先级次之', () => {
const root = buildFolderTree([
{ rel: 'd/s.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'd2/l.txt', status: 'left-only', leftSize: 1, rightSize: null },
{ rel: 'd2/sem.txt', status: 'semantic-same', leftSize: 1, rightSize: 2 }
])
expect(root.children[0].agg).toBe('same')
// left-only(3) > semantic-same(2)
expect(root.children[1].agg).toBe('left-only')
})
it('空条目集返回空根', () => {
const root = buildFolderTree([])
expect(root.children).toHaveLength(0)
expect(root.fileCount).toBe(0)
})
})
describe('flattenFolderTree', () => {
it('全展开:深度递增、目录行在子行之前', () => {
const rows = flattenFolderTree(buildFolderTree(E), () => true)
expect(rows.map((r) => r.node.name)).toEqual([
'a.txt', 'sub', 'deep', 'x.ts', 'only-right.txt', 'z.md'
])
expect(rows.map((r) => r.depth)).toEqual([0, 0, 1, 2, 1, 0])
})
it('折叠目录:子树整体隐藏', () => {
const rows = flattenFolderTree(buildFolderTree(E), (p) => p !== 'sub/deep')
expect(rows.map((r) => r.node.name)).toEqual(['a.txt', 'sub', 'deep', 'only-right.txt', 'z.md'])
})
it('折叠顶层目录:整个子树隐藏', () => {
const rows = flattenFolderTree(buildFolderTree(E), (p) => p !== 'sub')
expect(rows.map((r) => r.node.name)).toEqual(['a.txt', 'sub', 'z.md'])
})
})
describe('filterEntries', () => {
it('onlyDiff 隐藏 same 与 semantic-same', () => {
const out = filterEntries(E, { onlyDiff: true, statusFilter: null, query: '' })
expect(out.map((e) => e.rel)).toEqual(['sub/only-right.txt', 'sub/deep/x.ts'])
})
it('statusFilter 只保留集合内状态', () => {
const out = filterEntries(E, {
onlyDiff: false,
statusFilter: new Set(['right-only', 'semantic-same']),
query: ''
})
expect(out.map((e) => e.rel)).toEqual(['sub/only-right.txt', 'z.md'])
})
it('query 子串搜索不区分大小写', () => {
const out = filterEntries(E, { onlyDiff: false, statusFilter: null, query: 'X.TS' })
expect(out.map((e) => e.rel)).toEqual(['sub/deep/x.ts'])
})
it('query 匹配路径中段(目录名)', () => {
const out = filterEntries(E, { onlyDiff: false, statusFilter: null, query: 'deep' })
expect(out.map((e) => e.rel)).toEqual(['sub/deep/x.ts'])
})
it('组合:onlyDiff + query 交集', () => {
const out = filterEntries(E, { onlyDiff: true, statusFilter: null, query: 'z' })
expect(out).toHaveLength(0)
})
it('空白 query 不过滤', () => {
const out = filterEntries(E, { onlyDiff: false, statusFilter: null, query: ' ' })
expect(out).toHaveLength(E.length)
})
})
describe('filterEntries - 扩展名过滤', () => {
const X: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'b.TXT', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'sub/x.ts', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'noext', status: 'left-only', leftSize: 1, rightSize: null },
{ rel: 'd/noext2', status: 'right-only', leftSize: null, rightSize: 1 }
]
it('relExt 取文件名段扩展名(小写;无扩展名为空串;不误取目录名中的点)', () => {
expect(relExt('a.txt')).toBe('txt')
expect(relExt('A.TXT')).toBe('txt')
expect(relExt('sub/x.ts')).toBe('ts')
expect(relExt('noext')).toBe('')
expect(relExt('v1.2.3/md')).toBe('')
expect(relExt('archive.tar.gz')).toBe('gz')
})
it('exts 非空时只保留集合内扩展名(大小写不敏感,无扩展名归空串类目)', () => {
const out = filterEntries(X, {
onlyDiff: false,
statusFilter: null,
query: '',
exts: new Set(['txt'])
})
expect(out.map((e) => e.rel)).toEqual(['a.txt', 'b.TXT'])
const noExt = filterEntries(X, {
onlyDiff: false,
statusFilter: null,
query: '',
exts: new Set([''])
})
expect(noExt.map((e) => e.rel)).toEqual(['noext', 'd/noext2'])
})
it('多选扩展名取并集,与其他条件取交集(onlyDiff + exts', () => {
const out = filterEntries(X, {
onlyDiff: true,
statusFilter: null,
query: '',
exts: new Set(['txt', 'ts'])
})
// onlyDiff 过滤掉 same 的 a.txt,剩 b.TXT 与 sub/x.ts
expect(out.map((e) => e.rel)).toEqual(['b.TXT', 'sub/x.ts'])
})
it('exts 为 null 或缺省时不过滤(向后兼容)', () => {
expect(filterEntries(X, { onlyDiff: false, statusFilter: null, query: '', exts: null })).toHaveLength(5)
expect(filterEntries(X, { onlyDiff: false, statusFilter: null, query: '' })).toHaveLength(5)
})
})
describe('collectEntries', () => {
it('收集子树全部文件条目', () => {
const root = buildFolderTree(E)
const sub = root.children[1]
expect(collectEntries(sub).map((e) => e.rel)).toEqual(['sub/deep/x.ts', 'sub/only-right.txt'])
})
it('文件节点返回自身', () => {
const root = buildFolderTree(E)
expect(collectEntries(root.children[0]).map((e) => e.rel)).toEqual(['a.txt'])
})
})
+161
View File
@@ -0,0 +1,161 @@
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
/**
* A5 FolderView
* rel /
*/
/** 树节点(目录或文件) */
export interface TreeNode {
/** 显示名(路径最后一段;根节点为空串) */
name: string
/** 相对完整路径(文件为其 rel;目录为其前缀路径,无尾斜杠) */
path: string
isDir: boolean
/** 文件节点挂原始条目 */
entry?: FolderEntry
/** 子节点(目录节点独有;同层按名称字典序) */
children: TreeNode[]
/** 目录节点:子树叶子的状态聚合(优先级 different > unreadable > only > semantic-same > same */
agg?: FolderEntryStatus
/** 目录节点:子树文件总数 */
fileCount?: number
}
/** 扁平化后的虚拟滚动行 */
export interface TreeRow {
node: TreeNode
/** 缩进深度(根的直接子节点为 0) */
depth: number
}
/** 状态聚合优先级:数值大者优先(目录徽章展示最值得关注的子树状态:different > unreadable > only > 语义同 > 相同) */
const AGG_RANK: Record<FolderEntryStatus, number> = {
different: 5,
unreadable: 4,
'left-only': 3,
'right-only': 3,
'semantic-same': 2,
same: 1
}
/** 条目过滤条件 */
export interface EntryFilter {
/** 隐藏两侧一致的文件(same 与 semantic-same */
onlyDiff: boolean
/** 状态精确过滤(null 为不过滤;非空时只保留集合内状态) */
statusFilter: Set<FolderEntryStatus> | null
/** 相对路径子串搜索(不区分大小写;空串不过滤) */
query: string
/** 扩展名精确过滤(null 为不过滤;非空时只保留集合内扩展名,与其他条件取交集) */
exts?: Set<string> | null
}
/** 条目的扩展名(小写不含点;仅取文件名段,无扩展名为空串——无扩展名文件归独立类目) */
export function relExt(rel: string): string {
const name = rel.slice(rel.lastIndexOf('/') + 1)
const dot = name.lastIndexOf('.')
if (dot < 0) return ''
return name.slice(dot + 1).toLowerCase()
}
/** 按视图条件过滤条目(文件层过滤,树构建之前执行;全部条件取交集) */
export function filterEntries(entries: FolderEntry[], f: EntryFilter): FolderEntry[] {
const q = f.query.trim().toLowerCase()
return entries.filter((e) => {
if (f.onlyDiff && (e.status === 'same' || e.status === 'semantic-same')) return false
if (f.statusFilter !== null && !f.statusFilter.has(e.status)) return false
if (q !== '' && !e.rel.toLowerCase().includes(q)) return false
if (f.exts != null && !f.exts.has(relExt(e.rel))) return false
return true
})
}
/** 递归排序子节点(名称字典序)并聚合目录状态与文件计数 */
function finalize(node: TreeNode): void {
if (!node.isDir) return
node.children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
let agg: FolderEntryStatus = 'same'
let rank = 0
let count = 0
for (const c of node.children) {
finalize(c)
if (c.isDir) {
count += c.fileCount ?? 0
const r = AGG_RANK[c.agg ?? 'same']
if (r > rank) {
rank = r
agg = c.agg ?? 'same'
}
} else {
count++
const r = AGG_RANK[c.entry!.status]
if (r > rank) {
rank = r
agg = c.entry!.status
}
}
}
node.agg = agg
node.fileCount = count
}
/**
* rel '/'
*
*/
export function buildFolderTree(entries: FolderEntry[]): TreeNode {
const root: TreeNode = { name: '', path: '', isDir: true, children: [] }
for (const e of entries) {
const segs = e.rel.split('/')
let cur = root
for (let i = 0; i < segs.length; i++) {
const isLeaf = i === segs.length - 1
const name = segs[i]
const path = segs.slice(0, i + 1).join('/')
let child = cur.children.find((c) => c.name === name && c.isDir === !isLeaf)
if (!child) {
child = isLeaf
? { name, path, isDir: false, children: [], entry: e }
: { name, path, isDir: true, children: [] }
cur.children.push(child)
}
cur = child
}
}
finalize(root)
return root
}
/**
*
* isExpanded path
*/
export function flattenFolderTree(
root: TreeNode,
isExpanded: (dirPath: string) => boolean
): TreeRow[] {
const rows: TreeRow[] = []
const walk = (node: TreeNode, depth: number): void => {
for (const c of node.children) {
rows.push({ node: c, depth })
if (c.isDir && isExpanded(c.path)) walk(c, depth + 1)
}
}
walk(root, 0)
return rows
}
/** 收集一棵(子)树内的全部文件条目(目录行双击进入等场景) */
export function collectEntries(node: TreeNode): FolderEntry[] {
if (!node.isDir) return node.entry ? [node.entry] : []
const out: FolderEntry[] = []
const walk = (n: TreeNode): void => {
for (const c of n.children) {
if (c.isDir) walk(c)
else if (c.entry) out.push(c.entry)
}
}
walk(node)
return out
}
+394 -18
View File
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest'
import { computeDiff, CHAR_DIFF_MAX_CHARS, type Seg } from './diffEngine'
import {
computeDiff,
charGroupBudget,
CHAR_DIFF_MAX_CHARS,
CHAR_DIFF_GROUP_TOTAL_CHARS,
CHAR_DIFF_BUDGET_MAX,
type Seg
} from './diffEngine'
/** 拼接 segs 文本(信息无损校验:拼接结果必须等于原始行文本) */
const joinSegs = (segs: Seg[] | null): string => (segs ?? []).map((s) => s.text).join('')
@@ -436,20 +443,14 @@ describe('computeDiff - 字符级对比(差异定位与回映射)', () => {
})
describe('computeDiff - 字符级对比(超限降级)', () => {
it('归一化流超限自动降级行级并携带降级标志', () => {
const left = 'a'.repeat(100001)
const right = 'a'.repeat(100001)
const result = computeDiff(left, right, { charMode: true })
expect(result.charModeDowngraded).toBe(true)
// 降级行级采用“忽略所有空白 + 忽略空行”语义:全 a 单行仍判一致
expect(result.summary.changedLines).toBe(0)
})
it('降级结果与等效行级选项直接计算一致', () => {
const left = 'a'.repeat(100001) + '\nb'
const right = 'a'.repeat(100001) + '\nc'
it('变更组流超限自动降级行级并携带降级标志', () => {
// 单行全差异:组流 = (half+1) × 2 = CHAR_DIFF_MAX_CHARS + 2,超单组上限
const half = CHAR_DIFF_MAX_CHARS / 2
const left = 'a'.repeat(half + 1)
const right = 'b'.repeat(half + 1)
const result = computeDiff(left, right, { charMode: true })
expect(result.charModeDowngraded).toBe(true)
// 降级行级采用“忽略所有空白 + 忽略空行”语义:单行全不同判 modified
const direct = computeDiff(left, right, {
ignoreAllWhitespace: true,
ignoreBlankLines: true
@@ -457,13 +458,54 @@ describe('computeDiff - 字符级对比(超限降级)', () => {
expect(result.summary).toEqual(direct.summary)
})
it('恰好阈值不降级', () => {
const half = CHAR_DIFF_MAX_CHARS / 2
const { summary, charModeDowngraded } = computeDiff('a'.repeat(half), 'a'.repeat(half), {
charMode: true
it('编辑距离超限快速降级(总量未超但差异过大)', () => {
// 内部参数注入小阈值(10):生产默认 5000,触发截断需跑满上限轮数迭代,测试以小值毫秒级构造
const left = 'a'.repeat(2000)
const right = 'b'.repeat(2000)
const result = computeDiff(left, right, { charMode: true, charDiffMaxEdit: 10 })
expect(result.charModeDowngraded).toBe(true)
// 降级结果与等效行级选项直接计算一致
const direct = computeDiff(left, right, {
ignoreAllWhitespace: true,
ignoreBlankLines: true
})
expect(result.summary).toEqual(direct.summary)
})
it('编辑距离在限内正常完成字符级对比', () => {
const left = 'x'.repeat(4000)
const right = 'y'.repeat(100) + 'x'.repeat(3900)
const { summary, charModeDowngraded } = computeDiff(left, right, { charMode: true })
expect(charModeDowngraded).toBeUndefined()
expect(summary.changedLines).toBe(0)
expect(summary.changedLines).toBeGreaterThan(0)
})
it('大输入仅局部差异:行锚定后字符级完成,不降级(分块能力回归)', () => {
// 旧实现(整篇流 diffChars)此输入必超限降级;分块后相同大行被锚定,
// 唯一变更组只有第 2 行(b → c),字符级精确定位
const left = 'a'.repeat(100001) + '\nb'
const right = 'a'.repeat(100001) + '\nc'
const result = computeDiff(left, right, { charMode: true })
expect(result.charModeDowngraded).toBeUndefined()
expect(result.summary.changedLines).toBe(1)
expect(result.summary.modified).toBe(1)
const m = result.rows.find((r) => r.rowKind === 'modified')!
expect(m.left.lineNo).toBe(2)
expect(m.right.lineNo).toBe(2)
expect(m.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('b'))).toBe(true)
expect(m.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('c'))).toBe(true)
// 首行 10 万字符的相同行锚定输出
expect(result.rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(1)
})
it('变更组流恰好阈值不降级', () => {
// 单行组流 = half + half = 恰好 CHAR_DIFF_MAX_CHARS> 才降),编辑距离 1
const half = CHAR_DIFF_MAX_CHARS / 2
const left = 'a'.repeat(half - 1) + 'b'
const right = 'a'.repeat(half)
const { summary, charModeDowngraded } = computeDiff(left, right, { charMode: true })
expect(charModeDowngraded).toBeUndefined()
expect(summary.changedLines).toBe(1)
})
it('未开启 charMode 时不携带降级标志', () => {
@@ -472,6 +514,204 @@ describe('computeDiff - 字符级对比(超限降级)', () => {
})
})
describe('computeDiff - 字符级对比(分块锚定)', () => {
it('大文件全同内容:行锚定判等,不降级且无差异(归一化流总量远超单组上限)', () => {
// 20 万归一化字符(远超 CHAR_DIFF_MAX_CHARS 的 5 万),内容全同
const text = Array.from({ length: 2000 }, () => 'a'.repeat(100)).join('\n')
const { summary, charModeDowngraded, rows } = computeDiff(text, text, { charMode: true })
expect(charModeDowngraded).toBeUndefined()
expect(summary.changedLines).toBe(0)
expect(rows).toHaveLength(2000)
expect(rows.every((r) => r.rowKind === 'unchanged')).toBe(true)
})
it('大文件局部差异:差异行 modified 精确定位,其余行锚定 unchanged', () => {
const base = Array.from({ length: 1000 }, (_, i) => `line-${i}-content`)
const modified = [...base]
modified[500] = 'line-500-CONTENT'
const { rows, summary, charModeDowngraded } = computeDiff(base.join('\n'), modified.join('\n'), {
charMode: true
})
expect(charModeDowngraded).toBeUndefined()
expect(summary.changedLines).toBe(1)
expect(summary.modified).toBe(1)
const m = rows.find((r) => r.rowKind === 'modified')!
expect(m.left.lineNo).toBe(501)
expect(m.right.lineNo).toBe(501)
expect(m.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('content'))).toBe(true)
expect(m.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('CONTENT'))).toBe(true)
expect(rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(999)
const lNos = rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
const rNos = rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null)
expect([...lNos].sort((a, b) => a - b)).toEqual(lNos)
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
})
it('多个变更组穿插锚行:各组独立回映射,行号全局单调', () => {
const base = Array.from({ length: 30 }, (_, i) => `row${i}`)
const modified = [...base]
modified[5] = 'row5X'
modified[15] = 'row15X'
modified[25] = 'row25X'
const { rows, summary, charModeDowngraded } = computeDiff(
base.join('\n'),
modified.join('\n'),
{ charMode: true }
)
expect(charModeDowngraded).toBeUndefined()
expect(summary.modified).toBe(3)
expect(summary.changedLines).toBe(3)
const mods = rows.filter((r) => r.rowKind === 'modified')
expect(mods.map((r) => r.left.lineNo)).toEqual([6, 16, 26])
expect(mods.map((r) => r.right.lineNo)).toEqual([6, 16, 26])
expect(rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(27)
const lNos = rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
const rNos = rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null)
expect([...lNos].sort((a, b) => a - b)).toEqual(lNos)
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
})
it('组总流超预算:预算内组字符级、预算外组行级降级(部分降级,不再整体降级)', async () => {
// 8 个变更组,每组流 6500 + 6501 = 13001(在单组上限内,编辑距离 1),总流 104008:
// 自适应预算 = max(10 万, 52004) = 10 万(小输入取下限,与历史固定预算行为一致),
// 前 7 组消耗 91007,第 8 组 13001 > 剩余 8993 → 行级配对(partial
const build = (withX: boolean): string => {
const lines: string[] = []
for (let i = 0; i < 8; i++) {
lines.push(`same-${i}`)
lines.push('a'.repeat(6500) + (withX ? 'X' : ''))
}
return lines.join('\n')
}
const result = computeDiff(build(false), build(true), { charMode: true })
// 部分降级:不再整体降级
expect(result.charModeDowngraded).toBeUndefined()
expect(result.charModePartial).toBe(true)
// 8 个组各 1:1 行配对输出 modified:前 7 组字符级(segs 字符粒度),
// 第 8 组行级配对(超长行 > 5000 触发词级防护,segs 为 null,行仍 modified
expect(result.summary.modified).toBe(8)
expect(result.summary.deleted).toBe(0)
expect(result.summary.inserted).toBe(0)
const mods = result.rows.filter((r) => r.rowKind === 'modified')
expect(mods).toHaveLength(8)
// 前 7 组字符级 segs 拼接无损;第 8 组词级防护 segs 为 null
const noSegs = mods.filter((m) => m.left.segs === null)
expect(noSegs).toHaveLength(1)
for (const m of mods) {
if (m.left.segs === null) continue
expect(joinSegs(m.left.segs)).toBe(m.left.text)
expect(joinSegs(m.right.segs)).toBe(m.right.text)
}
// 行号单调
const lNos = result.rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
const rNos = result.rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null)
expect([...lNos].sort((a, b) => a - b)).toEqual(lNos)
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
})
it('单组流超上限:仅该组行级降级,其余组字符级(partial)', () => {
// mid 锚行分隔两个变更组:组 1(cat↔cap)流小字符级;组 2 单行全差异 50002 > 上限 → 行级
const left = ['head', 'cat', 'mid', 'a'.repeat(25001)]
const right = ['head', 'cap', 'mid', 'b'.repeat(25001)]
const result = computeDiff(left.join('\n'), right.join('\n'), { charMode: true })
expect(result.charModeDowngraded).toBeUndefined()
expect(result.charModePartial).toBe(true)
// 组 1(cat↔cap)字符级精确定位:1 个 modified,字符粒度高亮
const cat = result.rows.find(
(r) => r.rowKind === 'modified' && r.left.text === 'cat'
)!
expect(cat.right.text).toBe('cap')
expect(cat.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('t'))).toBe(true)
expect(cat.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('p'))).toBe(true)
// 组 2 行级配对:a…a ↔ b…b 配 modified25001 > 5000 触发词级防护,segs 为 null
const big = result.rows.find(
(r) => r.rowKind === 'modified' && (r.left.text ?? '').startsWith('a')
)!
expect(big.right.text).toBe('b'.repeat(25001))
expect(big.left.segs).toBeNull()
// 锚行保持 unchangedhead 与 mid
expect(result.rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(2)
})
it('单组编辑距离超限:仅该组行级降级,其余组字符级(partial,预算未耗尽)', () => {
// charDiffMaxEdit 注入 10mid 锚行分隔:组 1(cat↔cap)编辑距离 1 成功;
// 组 2a*2000 ↔ b*2000)编辑距离 4000 超限 → 行级
const left = ['x', 'cat', 'mid', 'a'.repeat(2000)]
const right = ['x', 'cap', 'mid', 'b'.repeat(2000)]
const result = computeDiff(left.join('\n'), right.join('\n'), {
charMode: true,
charDiffMaxEdit: 10
})
expect(result.charModeDowngraded).toBeUndefined()
expect(result.charModePartial).toBe(true)
// 组 1 字符级完成(cat↔cap 1 个 modified
const cat = result.rows.find((r) => r.rowKind === 'modified' && r.left.text === 'cat')!
expect(cat.right.text).toBe('cap')
expect(cat.left.segs!.some((s) => s.kind === 'delete')).toBe(true)
// 组 2 行级配对 modified(词级 segs
const big = result.rows.find(
(r) => r.rowKind === 'modified' && (r.left.text ?? '').startsWith('a')
)!
expect(big.right.text).toBe('b'.repeat(2000))
})
it('全部组超限:整体降级行级(charModeDowngraded,不出现 partial', () => {
// 两个组都单行全差异超 5 万 → 零组完成字符级 → 整体降级
const half = 25001
const left = ['anchor', 'a'.repeat(half), 'mid', 'c'.repeat(half)]
const right = ['anchor', 'b'.repeat(half), 'mid', 'd'.repeat(half)]
const result = computeDiff(left.join('\n'), right.join('\n'), { charMode: true })
expect(result.charModeDowngraded).toBe(true)
expect(result.charModePartial).toBeUndefined()
// 降级结果与等效行级选项直接计算一致
const direct = computeDiff(left.join('\n'), right.join('\n'), {
ignoreAllWhitespace: true,
ignoreBlankLines: true
})
expect(result.summary).toEqual(direct.summary)
})
it('行级降级组内行数不等:相邻块按序配对,多余侧 added/removed', () => {
// mid 锚行分隔:组 1small↔small)字符级保底;
// 组 2 左 [x*15000, only] 右 [y*15000, r1, r2]——组流 3 万在限内但编辑距离
// 超 5000(x…↔y… 全替换)→ 行级配对:x…↔y… 与 only↔r1 配 modifiedr2 多余 added
const left = ['head', 'small-l', 'mid', 'x'.repeat(15000), 'only-left-line']
const right = ['head', 'small-r', 'mid', 'y'.repeat(15000), 'r-line-1', 'r-line-2']
const result = computeDiff(left.join('\n'), right.join('\n'), { charMode: true })
expect(result.charModeDowngraded).toBeUndefined()
expect(result.charModePartial).toBe(true)
const mods = result.rows.filter((r) => r.rowKind === 'modified')
// 组 1 small 配对 + 组 2 两对(x…↔y…、only↔r1)
expect(mods).toHaveLength(3)
expect(mods.some((m) => m.left.text === 'small-l' && m.right.text === 'small-r')).toBe(true)
expect(mods.some((m) => (m.left.text ?? '').startsWith('x'))).toBe(true)
expect(mods.some((m) => m.left.text === 'only-left-line' && m.right.text === 'r-line-1')).toBe(true)
// 多余右侧 r2 → added;无 removed
const added = result.rows.filter((r) => r.rowKind === 'added')
expect(added.map((r) => r.right.text)).toEqual(['r-line-2'])
expect(result.summary.deleted).toBe(0)
// 锚行 head/mid unchanged
expect(result.rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(2)
// 行号单调
const lNos = result.rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
const rNos = result.rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null)
expect([...lNos].sort((a, b) => a - b)).toEqual(lNos)
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
})
it('跨行重组落在变更组内:组内字符流判等,锚行 zip 输出', () => {
// 行归一化无交集('ab cd' vs 'ab\ncd')→ 唯一变更组即全文,
// 组内流全等 → 锚行输出,行为与旧整篇路径一致
const { rows, summary } = computeDiff('ab cd', 'ab\ncd', { charMode: true })
expect(summary.changedLines).toBe(0)
expect(rows).toHaveLength(2)
expect(rows[0].left.lineNo).toBe(1)
expect(rows[0].right.lineNo).toBe(1)
expect(rows[1].left.lineNo).toBeNull()
expect(rows[1].right.lineNo).toBe(2)
})
})
describe('computeDiff - 字符级对比(字符换位专项)', () => {
/**
* ab ba jsdiff Myers
@@ -586,3 +826,139 @@ describe('computeDiff - 字符级对比(字符换位专项)', () => {
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
})
})
describe('charGroupBudget - 预算自适应(函数边界)', () => {
it('小输入与空输入取下限(与历史固定预算一致)', () => {
expect(charGroupBudget(0)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
expect(charGroupBudget(1000)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
expect(charGroupBudget(CHAR_DIFF_GROUP_TOTAL_CHARS)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
expect(charGroupBudget(CHAR_DIFF_GROUP_TOTAL_CHARS * 2)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
})
it('大输入按总组流一半放宽(向上取整)', () => {
// 总流 200002 → ceil(100001)
expect(charGroupBudget(200002)).toBe(100001)
// 总流 480800 → 240400
expect(charGroupBudget(480800)).toBe(240400)
})
it('超上限封顶', () => {
expect(charGroupBudget(CHAR_DIFF_BUDGET_MAX * 2)).toBe(CHAR_DIFF_BUDGET_MAX)
expect(charGroupBudget(100_000_000)).toBe(CHAR_DIFF_BUDGET_MAX)
})
it('非有限输入回退下限', () => {
expect(charGroupBudget(NaN)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
expect(charGroupBudget(Infinity)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
expect(charGroupBudget(-5)).toBe(CHAR_DIFF_GROUP_TOTAL_CHARS)
})
})
describe('computeDiff - 预算自适应行为固化', () => {
it('大输入跨行重组组:预算随总流放宽,完成字符级的组多于固定预算', () => {
// 每组左右字符流完全一致但行结构不同(左 1 行 / 右 2 行):
// - 字符级组:组内流全等 → 无变更字符 → 全部行按锚行输出(unchanged,0 变更行)
// - 行级组:行归一化无判等行 → 左 1 行与右 2 行配对 → modified 1 + added 1
// 组流 = (600+1)×2 = 1202400 组总流 480800 → 自适应预算 240400 →
// 前 200 组(240400/1202=200 整)字符级,第 201 组起行级。
// 历史固定预算 10 万只能完成 83 组——行为放宽由此固化。
const build = (side: 'left' | 'right'): string => {
const lines: string[] = []
for (let i = 0; i < 400; i++) {
lines.push(`same-${i}`)
if (side === 'left') {
lines.push('a'.repeat(600) + 'K')
} else {
lines.push('a'.repeat(300))
lines.push('a'.repeat(300) + 'K')
}
}
return lines.join('\n')
}
const result = computeDiff(build('left'), build('right'), { charMode: true })
expect(result.charModeDowngraded).toBeUndefined()
expect(result.charModePartial).toBe(true)
// 200 个行级组各产出 1 modified + 1 added;字符级组 0 变更行
expect(result.summary.modified).toBe(200)
expect(result.summary.inserted).toBe(200)
expect(result.summary.changedLines).toBe(400)
// 行号单调
const lNos = result.rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
const rNos = result.rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null)
expect([...lNos].sort((a, b) => a - b)).toEqual(lNos)
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
})
it('总流未超下限两倍:行为与历史固定预算完全一致(回归锚)', () => {
// 总流 104008 < 20 万:预算仍为下限 10 万,部分降级切分与 0.8.0 固化行为一致
const build = (withX: boolean): string => {
const lines: string[] = []
for (let i = 0; i < 8; i++) {
lines.push(`same-${i}`)
lines.push('a'.repeat(6500) + (withX ? 'X' : ''))
}
return lines.join('\n')
}
const result = computeDiff(build(false), build(true), { charMode: true })
expect(result.charModePartial).toBe(true)
expect(
result.rows.filter((r) => r.rowKind === 'modified' && r.left.segs === null)
).toHaveLength(1)
})
})
describe('computeDiff - 字符粒度增删统计(0.10.5', () => {
it('行级 added / removed 行按整行文本码点计数', () => {
// 新增行 '新增行'3 码点)+ 删除行 'gone'4 码点)
const { summary } = computeDiff('gone\nkeep', 'keep\n新增行')
expect(summary.inserted).toBe(1)
expect(summary.charsAdded).toBe(3)
expect(summary.deleted).toBe(1)
expect(summary.charsDeleted).toBe(4)
})
it('modified 行按 segs 的增删段计数,common 段不计', () => {
// 'hello world' → 'hello moon'world(5) 删除、moon(4) 新增
const { summary } = computeDiff('hello world', 'hello moon')
expect(summary.modified).toBe(1)
expect(summary.charsAdded).toBe(4)
expect(summary.charsDeleted).toBe(5)
})
it('全角与 emoji 按码点计数(代理对不拆分)', () => {
// '中文' → '中😀':文(1 码点)删除、😀(1 码点)新增;UTF-16 长度下 😀 为 2
const { summary } = computeDiff('中文', '中😀')
expect(summary.charsAdded).toBe(1)
expect(summary.charsDeleted).toBe(1)
})
it('字符级模式:modified 行的字符高亮段计入统计(跨行重组)', () => {
// 组内回映射:'abcXYZdef' → 'abcdef' 删除 XYZ(3 码点)
const { summary } = computeDiff('abcXYZdef', 'abcdef', { charMode: true })
expect(summary.modified).toBe(1)
expect(summary.charsDeleted).toBe(3)
expect(summary.charsAdded).toBe(0)
})
it('超长行防护(segs 为 null)的 modified 行不计入字符统计', () => {
const { summary } = computeDiff('a'.repeat(5001), 'b'.repeat(5001))
expect(summary.modified).toBe(1)
expect(summary.charsAdded).toBe(0)
expect(summary.charsDeleted).toBe(0)
})
it('内容完全一致时字符统计为零', () => {
const { summary } = computeDiff('same\nsame2', 'same\nsame2', { charMode: true })
expect(summary.charsAdded).toBe(0)
expect(summary.charsDeleted).toBe(0)
})
it('行级与字符级路径的统计口径一致(同输入同统计)', () => {
const left = 'a\nbb\nccc'
const right = 'a\nxx\nccc\ndddd'
const lineLevel = computeDiff(left, right)
const charMode = computeDiff(left, right, { charMode: true })
expect(charMode.summary.charsAdded).toBe(lineLevel.summary.charsAdded)
expect(charMode.summary.charsDeleted).toBe(lineLevel.summary.charsDeleted)
})
})
+418 -62
View File
@@ -1,4 +1,4 @@
import { diffArrays, diffChars, diffWordsWithSpace } from 'diff'
import { diffArrays, diffChars, diffWordsWithSpace, type Change } from 'diff'
/** 词级分段类型 */
export type SegKind = 'common' | 'insert' | 'delete'
@@ -32,6 +32,14 @@ export interface DiffSummary {
inserted: number
deleted: number
modified: number
/**
* added modified segs
* segs null modified
* emoji / CJK 2 "字符"
*/
charsAdded: number
/** 字符粒度的删除量(码点数):removed 行计左侧整行文本、modified 行计 segs 的删除段 */
charsDeleted: number
}
export interface DiffOptions {
@@ -46,9 +54,17 @@ export interface DiffOptions {
/**
* diff
* ignoreAllWhitespace
* CHAR_DIFF_MAX_CHARS charModeDowngraded
* CHAR_DIFF_MAX_CHARSCHAR_DIFF_MAX_EDIT
* charModeDowngraded
* charGroupBudget
*/
charMode?: boolean
/**
*
* 使 CHAR_DIFF_MAX_EDIT
* Myers
*/
charDiffMaxEdit?: number
}
export interface DiffResult {
@@ -56,12 +72,56 @@ export interface DiffResult {
summary: DiffSummary
/** 字符级对比因内容超限自动降级为行级对比时为 true(供界面与报告提示) */
charModeDowngraded?: boolean
/**
*
* CHAR_DIFF_MAX_CHARS / /
* charModeDowngraded
*/
charModePartial?: boolean
}
function emptyLine(): SideCell {
return { lineNo: null, text: null, segs: null }
}
/**
* +
* [...text].lengthadded removed
* modified segs / segs
* segs null modified
*/
function summarize(rows: DiffRow[]): DiffSummary {
let changedLines = 0
let inserted = 0
let deleted = 0
let modified = 0
let charsAdded = 0
let charsDeleted = 0
for (const r of rows) {
if (r.isChanged) changedLines++
if (r.rowKind === 'added') {
inserted++
if (r.right.text !== null) charsAdded += [...r.right.text].length
} else if (r.rowKind === 'removed') {
deleted++
if (r.left.text !== null) charsDeleted += [...r.left.text].length
} else if (r.rowKind === 'modified') {
modified++
if (r.right.segs !== null) {
for (const s of r.right.segs) {
if (s.kind === 'insert') charsAdded += [...s.text].length
}
}
if (r.left.segs !== null) {
for (const s of r.left.segs) {
if (s.kind === 'delete') charsDeleted += [...s.text].length
}
}
}
}
return { changedLines, inserted, deleted, modified, charsAdded, charsDeleted }
}
/** 带原始行号的行(忽略空行模式下空行被剔除,行号保留原值以维持定位) */
interface NumberedLine {
text: string
@@ -112,7 +172,7 @@ export function computeDiff(
if (options.charMode) {
const charResult = computeCharDiff(leftText, rightText, options)
if (charResult) return charResult
// 归一化流超限:降级为行级对比,选项取字符级语义的最接近子集
// 内容超限或差异过大:降级为行级对比,选项取字符级语义的最接近子集
// (全部空白忽略 + 空行忽略;换行结构差异是行级架构无法消除的边界)
const result = computeLineDiff(leftText, rightText, {
...options,
@@ -138,7 +198,6 @@ function computeLineDiff(
rightText: string,
options: DiffOptions = {}
): DiffResult {
let rowSeq = 0
const leftOrig = splitLines(leftText)
const rightOrig = splitLines(rightText)
@@ -173,7 +232,7 @@ function computeLineDiff(
const flushRemoved = (): void => {
for (const r of queuedRemoved) {
rows.push({
id: `r${rowSeq++}`,
id: `r${rows.length}`,
rowKind: 'removed',
isChanged: true,
left: { lineNo: r.lineNo, text: r.text, segs: null },
@@ -197,7 +256,7 @@ function computeLineDiff(
? { left: null, right: null }
: wordSegments(l.text, r.text)
rows.push({
id: `r${rowSeq++}`,
id: `r${rows.length}`,
rowKind: 'modified',
isChanged: true,
left: { lineNo: l.lineNo, text: l.text, segs: lSegs },
@@ -205,7 +264,7 @@ function computeLineDiff(
})
} else if (l) {
rows.push({
id: `r${rowSeq++}`,
id: `r${rows.length}`,
rowKind: 'removed',
isChanged: true,
left: { lineNo: l.lineNo, text: l.text, segs: null },
@@ -213,7 +272,7 @@ function computeLineDiff(
})
} else if (r) {
rows.push({
id: `r${rowSeq++}`,
id: `r${rows.length}`,
rowKind: 'added',
isChanged: true,
left: emptyLine(),
@@ -244,7 +303,7 @@ function computeLineDiff(
} else {
for (const r of addedRows) {
rows.push({
id: `r${rowSeq++}`,
id: `r${rows.length}`,
rowKind: 'added',
isChanged: true,
left: emptyLine(),
@@ -260,7 +319,7 @@ function computeLineDiff(
const lp = leftUsed[leftPtr + i]
const rp = rightUsed[rightPtr + i]
rows.push({
id: `r${rowSeq++}`,
id: `r${rows.length}`,
rowKind: 'unchanged',
isChanged: false,
left: { lineNo: lp.lineNo, text: lp.text, segs: null },
@@ -273,23 +332,64 @@ function computeLineDiff(
}
if (queuedRemoved.length > 0) flushRemoved()
const summary: DiffSummary = {
changedLines: rows.filter((r) => r.isChanged).length,
inserted: rows.filter((r) => r.rowKind === 'added').length,
deleted: rows.filter((r) => r.rowKind === 'removed').length,
modified: rows.filter((r) => r.rowKind === 'modified').length
}
return { rows, summary }
return { rows, summary: summarize(rows) }
}
/** ============ 字符级对比 ============ */
/**
*
* diffChars O(ND)
*
* diffChars O(ND)
* 0.6.3 + diffChars
*
*
*/
export const CHAR_DIFF_MAX_CHARS = 200000
export const CHAR_DIFF_MAX_CHARS = 50000
/**
* diffChars maxEditLength
* undefined
*
* 5000 × 5 5 worker
*/
export const CHAR_DIFF_MAX_EDIT = 5000
/**
*
*
*/
export const CHAR_DIFF_GROUP_TOTAL_CHARS = CHAR_DIFF_MAX_CHARS * 2
/**
* ×
* worker
*/
export const CHAR_DIFF_BUDGET_MAX = CHAR_DIFF_MAX_CHARS * 20
/**
*
* [CHAR_DIFF_GROUP_TOTAL_CHARS, CHAR_DIFF_BUDGET_MAX]
*
*
*/
export function charGroupBudget(totalGroupChars: number): number {
if (!Number.isFinite(totalGroupChars) || totalGroupChars <= 0) {
return CHAR_DIFF_GROUP_TOTAL_CHARS
}
return Math.min(
CHAR_DIFF_BUDGET_MAX,
Math.max(CHAR_DIFF_GROUP_TOTAL_CHARS, Math.ceil(totalGroupChars / 2))
)
}
/**
* worker
* CHAR_DIFF_MAX_EDIT ×
* 5000 × 5000 0.5 0.5.5
* JSON / base64
*
*/
export const CHAR_HEAVY_INPUT_CHARS = 5000
/** 空白字符判定:与 ignoreAllWhitespace 的 \s 语义一致(含换行、制表符、全角空格) */
function isWsChar(ch: string): boolean {
@@ -306,21 +406,30 @@ interface CharStream {
cols: number[]
}
function buildCharStream(lines: string[], norm: (ch: string) => string): CharStream {
/**
*
*
* lineIdx 0
*/
function groupStream(
orig: string[],
lineIdx: number[],
norm: (ch: string) => string
): CharStream {
const chars: string[] = []
const lineNos: number[] = []
const cols: number[] = []
lines.forEach((text, i) => {
for (const li of lineIdx) {
let col = 0
for (const ch of text) {
for (const ch of orig[li]) {
if (!isWsChar(ch)) {
chars.push(norm(ch))
lineNos.push(i + 1)
lineNos.push(li + 1)
cols.push(col)
}
col++
}
})
}
return { text: chars.join(''), lineNos, cols }
}
@@ -358,16 +467,19 @@ function charSegs(text: string, stat: Map<number, number> | undefined): Seg[] {
}
/**
* diffChars DiffRow
* +
*
* DiffRow//
* - / common
* common modified segs common
* - modifiedsegs removed / added commitModified
* - zip unchanged unchanged
*
*
* diffChars
* diffChars
*
* CHAR_DIFF_MAX_CHARS null
* removed/added
* diffChars +
* CHAR_DIFF_MAX_CHARS CHAR_DIFF_MAX_EDIT
* / charModePartial
* null charModeDowngraded
*
*
*/
function computeCharDiff(
leftText: string,
@@ -377,18 +489,263 @@ function computeCharDiff(
const leftOrig = splitLines(leftText)
const rightOrig = splitLines(rightText)
// 大小写归一化在构造流时完成:toLowerCase 展开为多字符的字符(如 İ→i̇)退回原样,
// 避免归一化后字符数变化破坏流索引
// 避免归一化后字符数变化破坏流索引(第一阶段行判等用整串 toLowerCase,两侧同样
// 变换不影响判等一致性;流索引稳定性仍由逐字符守卫保证)
const normCh = (ch: string): string => {
if (!options.ignoreCase) return ch
const lo = ch.toLowerCase()
return lo.length === 1 ? lo : ch
}
const left = buildCharStream(leftOrig, normCh)
const right = buildCharStream(rightOrig, normCh)
if (left.text.length + right.text.length > CHAR_DIFF_MAX_CHARS) return null
const parts = diffChars(left.text, right.text)
// ===== 第一阶段:行归一化锚定 =====
const normLine = (s: string): string => {
const t = s.replace(/\s+/g, '')
return options.ignoreCase ? t.toLowerCase() : t
}
const lineParts = diffArrays(leftOrig.map(normLine), rightOrig.map(normLine))
// 输出时间线:锚行(行归一化判等,左右原文各自展示)与变更组占位按序交替
const timeline: Array<
| { kind: 'anchor'; ln: number; rn: number }
| { kind: 'group'; g: number }
> = []
const groups: { l: number[]; r: number[] }[] = []
let lPtr = 0
let rPtr = 0
for (const part of lineParts) {
const len = part.value.length
if (!part.added && !part.removed) {
for (let i = 0; i < len; i++) {
timeline.push({ kind: 'anchor', ln: lPtr + i, rn: rPtr + i })
}
lPtr += len
rPtr += len
} else {
// 连续 removed/added 行块归为一个变更组(timeline 尾部已是本组则不重复占位)
if (groups.length === 0 || timeline[timeline.length - 1].kind !== 'group') {
groups.push({ l: [], r: [] })
timeline.push({ kind: 'group', g: groups.length - 1 })
}
const g = groups[groups.length - 1]
if (part.removed) {
for (let i = 0; i < len; i++) g.l.push(lPtr + i)
lPtr += len
} else {
for (let i = 0; i < len; i++) g.r.push(rPtr + i)
rPtr += len
}
}
}
// ===== 第二阶段:变更组字符流 diff(逐组判定 + 预算制部分降级)=====
const streams = groups.map((g) => ({
ls: groupStream(leftOrig, g.l, normCh),
rs: groupStream(rightOrig, g.r, normCh),
// 组内行号集合(1 基):限定 emitGroupRows 的锚行/尾部输出范围
gl: new Set(g.l.map((i) => i + 1)),
gr: new Set(g.r.map((i) => i + 1))
}))
/**
* + CHAR_DIFF_MAX_CHARS
* diffChars / /
* charGroupBudget
* null
*
*/
const groupParts: Array<Change[] | null> = []
const totalGroupChars = streams.reduce(
(acc, s) => acc + s.ls.text.length + s.rs.text.length,
0
)
let budget = charGroupBudget(totalGroupChars)
let anyChar = false
for (const { ls, rs } of streams) {
const n = ls.text.length + rs.text.length
if (n <= CHAR_DIFF_MAX_CHARS && n <= budget) {
// maxEditLength 封顶 Myers 迭代轮数(编辑距离),防止 O(ND) 无界计算卡死;
// jsdiff 超限时运行时返回 undefined(类型签名未标注,真值判断兜底)
const parts = diffChars(ls.text, rs.text, {
maxEditLength: options.charDiffMaxEdit ?? CHAR_DIFF_MAX_EDIT
})
if (parts) {
groupParts.push(parts)
budget -= n
anyChar = true
continue
}
}
groupParts.push(null)
}
if (groups.length > 0 && !anyChar) return null
// ===== 按时间线输出:锚行 + 变更组回映射(字符级组 / 行级降级组分发)=====
const rows: DiffRow[] = []
const lineLevelGroups = new Set<number>()
for (const item of timeline) {
if (item.kind === 'anchor') {
rows.push({
id: `r${rows.length}`,
rowKind: 'unchanged',
isChanged: false,
left: { lineNo: item.ln + 1, text: leftOrig[item.ln], segs: null },
right: { lineNo: item.rn + 1, text: rightOrig[item.rn], segs: null }
})
} else {
const { ls, rs, gl, gr } = streams[item.g]
const parts = groupParts[item.g]
if (parts) {
emitGroupRows(ls, rs, leftOrig, rightOrig, parts, rows, gl, gr)
} else {
lineLevelGroups.add(item.g)
emitGroupLineLevel(groups[item.g], leftOrig, rightOrig, normLine, rows)
}
}
}
return {
rows,
summary: summarize(rows),
// 存在行级降级组且也有字符级组:部分降级(供界面与报告提示)
...(lineLevelGroups.size > 0 ? { charModePartial: true } : {})
}
}
/**
* +
* LCS unchanged/ modified
* commitModified removed/added
*
*/
function emitGroupLineLevel(
g: { l: number[]; r: number[] },
leftOrig: string[],
rightOrig: string[],
normLine: (s: string) => string,
rows: DiffRow[]
): void {
const leftLines = g.l.map((i) => ({ text: leftOrig[i], lineNo: i + 1 }))
const rightLines = g.r.map((i) => ({ text: rightOrig[i], lineNo: i + 1 }))
const parts = diffArrays(
leftLines.map((p) => normLine(p.text)),
rightLines.map((p) => normLine(p.text))
)
let li = 0
let ri = 0
let queuedRemoved: NumberedLine[] = []
const flushRemoved = (): void => {
for (const r of queuedRemoved) {
rows.push({
id: `r${rows.length}`,
rowKind: 'removed',
isChanged: true,
left: { lineNo: r.lineNo, text: r.text, segs: null },
right: emptyLine()
})
}
queuedRemoved = []
}
const commitModified = (addedRows: NumberedLine[]): void => {
const count = Math.max(queuedRemoved.length, addedRows.length)
for (let i = 0; i < count; i++) {
const l = queuedRemoved[i]
const r = addedRows[i]
if (l && r) {
const skipWordDiff =
l.text.length > WORD_DIFF_MAX_LEN || r.text.length > WORD_DIFF_MAX_LEN
const { left: lSegs, right: rSegs } = skipWordDiff
? { left: null, right: null }
: wordSegments(l.text, r.text)
rows.push({
id: `r${rows.length}`,
rowKind: 'modified',
isChanged: true,
left: { lineNo: l.lineNo, text: l.text, segs: lSegs },
right: { lineNo: r.lineNo, text: r.text, segs: rSegs }
})
} else if (l) {
rows.push({
id: `r${rows.length}`,
rowKind: 'removed',
isChanged: true,
left: { lineNo: l.lineNo, text: l.text, segs: null },
right: emptyLine()
})
} else if (r) {
rows.push({
id: `r${rows.length}`,
rowKind: 'added',
isChanged: true,
left: emptyLine(),
right: { lineNo: r.lineNo, text: r.text, segs: null }
})
}
}
queuedRemoved = []
}
for (const part of parts) {
const value = part.value as string[]
if (part.removed) {
for (let i = 0; i < value.length; i++) queuedRemoved.push(leftLines[li + i])
li += value.length
} else if (part.added) {
const added: NumberedLine[] = []
for (let i = 0; i < value.length; i++) added.push(rightLines[ri + i])
ri += value.length
if (queuedRemoved.length > 0) commitModified(added)
else {
for (const r of added) {
rows.push({
id: `r${rows.length}`,
rowKind: 'added',
isChanged: true,
left: emptyLine(),
right: { lineNo: r.lineNo, text: r.text, segs: null }
})
}
}
} else {
if (queuedRemoved.length > 0) flushRemoved()
for (let i = 0; i < value.length; i++) {
const lp = leftLines[li + i]
const rp = rightLines[ri + i]
rows.push({
id: `r${rows.length}`,
rowKind: 'unchanged',
isChanged: false,
left: { lineNo: lp.lineNo, text: lp.text, segs: null },
right: { lineNo: rp.lineNo, text: rp.text, segs: null }
})
}
li += value.length
ri += value.length
}
}
if (queuedRemoved.length > 0) flushRemoved()
}
/**
* diffChars DiffRow rows
*
* DiffRow//
* - / common
* common modified segs common
* - modifiedsegs removed / added commitModified
* - zip unchanged unchanged
*
*/
function emitGroupRows(
left: CharStream,
right: CharStream,
leftOrig: string[],
rightOrig: string[],
parts: Change[],
rows: DiffRow[],
groupL: Set<number>,
groupR: Set<number>
): void {
// 流区间块序列:common 为 eq 块,连续 removed/added 合并为 ne 块(与行级 parts 循环同构)
interface Block {
eq: boolean
@@ -520,8 +877,6 @@ function computeCharDiff(
}
}
let rowSeq = 0
const rows: DiffRow[] = []
let pendingL: number[] = []
let pendingR: number[] = []
// 已输出进变更组的行:同行内多处变更(ne-eq-ne 落在同一行,如换位、同行两处替换)
@@ -537,7 +892,7 @@ function computeCharDiff(
const rn = pendingR[i]
if (ln !== undefined && rn !== undefined) {
rows.push({
id: `r${rowSeq++}`,
id: `r${rows.length}`,
rowKind: 'modified',
isChanged: true,
left: {
@@ -555,7 +910,7 @@ function computeCharDiff(
doneR.add(rn)
} else if (ln !== undefined) {
rows.push({
id: `r${rowSeq++}`,
id: `r${rows.length}`,
rowKind: 'removed',
isChanged: true,
left: { lineNo: ln, text: leftOrig[ln - 1], segs: null },
@@ -564,7 +919,7 @@ function computeCharDiff(
doneL.add(ln)
} else if (rn !== undefined) {
rows.push({
id: `r${rowSeq++}`,
id: `r${rows.length}`,
rowKind: 'added',
isChanged: true,
left: emptyLine(),
@@ -584,7 +939,7 @@ function computeCharDiff(
const ln = la[i] ?? null
const rn = ra[i] ?? null
rows.push({
id: `r${rowSeq++}`,
id: `r${rows.length}`,
rowKind: 'unchanged',
isChanged: false,
left: ln === null ? emptyLine() : { lineNo: ln, text: leftOrig[ln - 1], segs: null },
@@ -593,21 +948,22 @@ function computeCharDiff(
}
}
// 行游标推进:锚段右边界 = 下一 ne 块首字符行 - 1(流尽则为文件末行)
// 行游标推进:锚段右边界 = 下一 ne 块首字符行 - 1(流尽则为组内末行)
// 锚行与尾部范围限定在组内行集合(组外行由时间线的锚行/其他组负责,防止重复输出)
let lCursor = 1
let rCursor = 1
for (const b of blocks) {
if (b.eq) {
flushChange()
const lEnd = b.l1 < left.lineNos.length ? left.lineNos[b.l1] - 1 : leftOrig.length
const rEnd = b.r1 < right.lineNos.length ? right.lineNos[b.r1] - 1 : rightOrig.length
const lEnd = b.l1 < left.lineNos.length ? left.lineNos[b.l1] - 1 : maxLine(groupL)
const rEnd = b.r1 < right.lineNos.length ? right.lineNos[b.r1] - 1 : maxLine(groupR)
const la: number[] = []
for (let ln = lCursor; ln <= lEnd; ln++) {
if (!pairedL.has(ln)) la.push(ln)
if (groupL.has(ln) && !pairedL.has(ln)) la.push(ln)
}
const ra: number[] = []
for (let rn = rCursor; rn <= rEnd; rn++) {
if (!pairedR.has(rn)) ra.push(rn)
if (groupR.has(rn) && !pairedR.has(rn)) ra.push(rn)
}
emitAnchors(la, ra)
lCursor = lEnd + 1
@@ -633,23 +989,23 @@ function computeCharDiff(
}
flushChange()
// 尾部剩余行(最后一块之后的空白行/未覆盖行)按锚行输出
// 尾部剩余行(组内未被覆盖的空白行等)按锚行输出(同样限定组内行集合)
const tailLa: number[] = []
for (let ln = lCursor; ln <= leftOrig.length; ln++) {
if (!pairedL.has(ln)) tailLa.push(ln)
for (let ln = lCursor; ln <= maxLine(groupL); ln++) {
if (groupL.has(ln) && !pairedL.has(ln)) tailLa.push(ln)
}
const tailRa: number[] = []
for (let rn = rCursor; rn <= rightOrig.length; rn++) {
if (!pairedR.has(rn)) tailRa.push(rn)
for (let rn = rCursor; rn <= maxLine(groupR); rn++) {
if (groupR.has(rn) && !pairedR.has(rn)) tailRa.push(rn)
}
emitAnchors(tailLa, tailRa)
}
const summary: DiffSummary = {
changedLines: rows.filter((r) => r.isChanged).length,
inserted: rows.filter((r) => r.rowKind === 'added').length,
deleted: rows.filter((r) => r.rowKind === 'removed').length,
modified: rows.filter((r) => r.rowKind === 'modified').length
/** 行号集合的最大行(空集合为 0,即无锚行可输出) */
function maxLine(lines: Set<number>): number {
let m = 0
for (const ln of lines) {
if (ln > m) m = ln
}
return { rows, summary }
return m
}
+317
View File
@@ -0,0 +1,317 @@
import { describe, it, expect } from 'vitest'
import type { FolderEntry } from '../../../preload/index'
import {
buildFolderReport,
countFolderStats,
fmtSize,
plainFolderSummary
} from './folderReport'
import { diffSnapshot } from './snapshotDiff'
const ENTRIES: FolderEntry[] = [
{ rel: 'a.txt', status: 'same', leftSize: 5, rightSize: 5 },
{ rel: 'sem.txt', status: 'semantic-same', leftSize: 5, rightSize: 6 },
{ rel: 'b.txt', status: 'different', leftSize: 3, rightSize: 9 },
{ rel: 'only-l.txt', status: 'left-only', leftSize: 1, rightSize: null },
{ rel: 'sub/only-r.ts', status: 'right-only', leftSize: null, rightSize: 7 }
]
const CTX = { leftDir: 'C:/left', rightDir: 'D:/right', semantic: true, truncated: false, total: 5 }
describe('countFolderStats', () => {
it('六类状态计数正确', () => {
expect(
countFolderStats([
...ENTRIES,
{ rel: 'locked.txt', status: 'unreadable' as const, leftSize: 1, rightSize: 1 }
])
).toEqual({
same: 1,
semanticSame: 1,
different: 1,
leftOnly: 1,
rightOnly: 1,
unreadable: 1
})
})
it('空条目集全部为零', () => {
expect(countFolderStats([])).toEqual({
same: 0,
semanticSame: 0,
different: 0,
leftOnly: 0,
rightOnly: 0,
unreadable: 0
})
})
})
describe('fmtSize - 人类可读文件大小', () => {
it('null 占位为 —', () => {
expect(fmtSize(null)).toBe('—')
})
it('B / KB / MB / GB / TB 各档正确换算(含边界值)', () => {
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')
expect(fmtSize(1024 * 1024 * 1024 * 1024)).toBe('1.0 TB')
expect(fmtSize(1.5 * 1024 * 1024 * 1024 * 1024)).toBe('1.5 TB')
})
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')
})
it('TB 级文件不再显示为 1024.0 GB0.10.4 档位延续修复锚)', () => {
// TB 级(虚拟机镜像/备份归档)与 GB 修复同源:大小字段照常显示,档位逐级补全
expect(fmtSize(3 * 1024 * 1024 * 1024 * 1024)).toBe('3.0 TB')
expect(fmtSize(1024 * 1024 * 1024 * 1024)).not.toBe('1024.0 GB')
})
})
describe('plainFolderSummary', () => {
it('空文件夹给出无文件结论', () => {
expect(plainFolderSummary(countFolderStats([]), 0)).toBe('两个文件夹内都没有文件')
})
it('完全一致(无语义同)', () => {
const stats = countFolderStats([{ rel: 'a', status: 'same', leftSize: 1, rightSize: 1 }])
expect(plainFolderSummary(stats, 1)).toBe('两文件夹内容完全一致')
})
it('完全一致但有语义同时附加说明', () => {
const stats = countFolderStats([
{ rel: 'a', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'b', status: 'semantic-same', leftSize: 1, rightSize: 2 }
])
expect(plainFolderSummary(stats, 2)).toBe('两文件夹内容完全一致(其中 1 个文件仅空白/换行差异)')
})
it('存在差异时完整罗列并附语义同说明', () => {
const s = countFolderStats(ENTRIES)
expect(plainFolderSummary(s, ENTRIES.length)).toBe(
'共 3 个文件存在差异:内容不同 1 个、仅左侧 1 个、仅右侧 1 个(另有 1 个仅空白/换行差异,不计入不同)'
)
})
it('无语义同时不附加说明', () => {
const stats = countFolderStats([{ rel: 'b', status: 'different', leftSize: 1, rightSize: 2 }])
expect(plainFolderSummary(stats, 1)).toBe('共 1 个文件存在差异:内容不同 1 个')
})
})
describe('buildFolderReport - HTML', () => {
it('输出合法文档并包含目录、统计与条目明细', () => {
const html = buildFolderReport(ENTRIES, CTX, 'html')
expect(html).toContain('<!doctype html>')
expect(html).toContain('DiffLens 文件夹对比报告')
expect(html).toContain('C:/left')
expect(html).toContain('D:/right')
expect(html).toContain('判定口径:语义判等开启')
expect(html).toContain('共 3 个文件存在差异')
expect(html).toContain('sub/only-r.ts')
expect(html).toContain('st-sem')
expect(html).toContain('语义同')
})
it('语义关闭时判定口径为字节级严格比对', () => {
const html = buildFolderReport(ENTRIES, { ...CTX, semantic: false }, 'html')
expect(html).toContain('判定口径:字节级严格比对')
})
it('截断时输出警示行', () => {
const html = buildFolderReport(ENTRIES, { ...CTX, truncated: true, total: 9999 }, 'html')
expect(html).toContain('文件数量超出上限')
expect(html).toContain('9999')
})
it('空条目集给出空提示', () => {
const html = buildFolderReport([], { ...CTX, total: 0 }, 'html')
expect(html).toContain('两个文件夹内都没有文件')
})
it('完全一致时 verdict 为绿色(ok', () => {
const html = buildFolderReport(
[{ rel: 'a', status: 'same', leftSize: 1, rightSize: 1 }],
CTX,
'html'
)
expect(html).toContain('verdict ok')
expect(html).toContain('两文件夹内容完全一致')
})
it('approximate 条目渲染 ≈ 标注', () => {
const html = buildFolderReport(
[{ rel: 'big.bin', status: 'same', leftSize: 1, rightSize: 1, approximate: true }],
CTX,
'html'
)
expect(html).toContain('≈')
})
})
describe('buildFolderReport - 纯文本', () => {
it('头部包含目录/口径/结论/统计,条目行含状态与大小', () => {
const txt = buildFolderReport(ENTRIES, CTX, 'txt')
expect(txt).toContain('====== DiffLens 文件夹对比报告 ======')
expect(txt).toContain('左侧目录:C:/left')
expect(txt).toContain('判定口径:语义判等开启')
expect(txt).toContain('结论:共 3 个文件存在差异')
expect(txt).toContain('相同 1 | 语义同 1 | 不同 1 | 仅左 1 | 仅右 1 | 共 5 项')
expect(txt).toContain('sub/only-r.ts')
expect(txt).toContain('语义同')
expect(txt).toContain('仅左侧')
})
it('超长路径截断对齐(宽度封顶 60)', () => {
const longRel = `${'d'.repeat(80)}.txt`
const txt = buildFolderReport(
[{ rel: longRel, status: 'same', leftSize: 1, rightSize: 1 }],
CTX,
'txt'
)
expect(txt).toContain('…')
expect(txt).not.toContain('d'.repeat(80))
})
it('截断时输出警示行', () => {
const txt = buildFolderReport(ENTRIES, { ...CTX, truncated: true, total: 10000 }, 'txt')
expect(txt).toContain('⚠ 文件过多(10000+')
})
})
describe('buildFolderReport - Markdown', () => {
it('输出表格化报告(状态/路径/大小)与结论引用块', () => {
const md = buildFolderReport(ENTRIES, CTX, 'md')
expect(md.startsWith('# DiffLens 文件夹对比报告')).toBe(true)
expect(md).toContain('> 结论:共 3 个文件存在差异')
expect(md).toContain('| 状态 | 相对路径 | 左侧大小 | 右侧大小 |')
expect(md).toContain('`sub/only-r.ts`')
expect(md).toContain('| 语义同 |')
})
it('路径中的管道符被转义保持表格结构', () => {
const md = buildFolderReport(
[{ rel: 'a|b.txt', status: 'same', leftSize: 1, rightSize: 1 }],
CTX,
'md'
)
expect(md).toContain('a\\|b.txt')
})
it('空条目集表格给出占位行', () => {
const md = buildFolderReport([], { ...CTX, total: 0 }, 'md')
expect(md).toContain('两个文件夹内都没有文件')
})
})
describe('buildFolderReport - 元信息增强(0.11.0', () => {
it('忽略规则进入三格式头部说明(有规则时;未提供时无该行)', () => {
const rCtx = { ...CTX, ignoreRules: ['node_modules/', '*.log'] }
expect(buildFolderReport(ENTRIES, rCtx, 'txt')).toContain(
'忽略规则:node_modules/、*.log(命中项不参与对比)'
)
expect(buildFolderReport(ENTRIES, rCtx, 'md')).toContain('> 忽略规则:node_modules/、*.log')
expect(buildFolderReport(ENTRIES, rCtx, 'html')).toContain('忽略规则:node_modules/、*.log')
// 未提供时无该行(回归锚)
expect(buildFolderReport(ENTRIES, CTX, 'txt')).not.toContain('忽略规则')
})
it('忽略规则超 5 条时截断为前 5 条 + 计数', () => {
const rules = ['a/', 'b/', 'c/', 'd/', 'e/', 'f/', 'g/']
const txt = buildFolderReport(ENTRIES, { ...CTX, ignoreRules: rules }, 'txt')
expect(txt).toContain('忽略规则:a/、b/、c/、d/、e/ 等 7 条(命中项不参与对比)')
})
it('空规则数组不输出忽略规则行(与未提供一致)', () => {
expect(buildFolderReport(ENTRIES, { ...CTX, ignoreRules: [] }, 'txt')).not.toContain('忽略规则')
})
it('版本号进入三格式尾部署名(未提供时保持历史格式)', () => {
const vCtx = { ...CTX, version: '0.11.0' }
expect(buildFolderReport(ENTRIES, vCtx, 'txt')).toContain('生成于 DiffLens v0.11.0')
expect(buildFolderReport(ENTRIES, vCtx, 'md')).toContain('> 生成于 DiffLens v0.11.0')
expect(buildFolderReport(ENTRIES, vCtx, 'html')).toContain('v0.11.0')
expect(buildFolderReport(ENTRIES, CTX, 'txt')).toContain('生成于 DiffLens')
expect(buildFolderReport(ENTRIES, CTX, 'txt')).not.toContain('v0.')
})
})
describe('buildFolderReport - 快照对照表(snapshotEntries', () => {
const SNAP: FolderEntry[] = [
{ rel: 'fixed.txt', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'gone.txt', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'stable.txt', status: 'same', leftSize: 3, rightSize: 3 }
]
const CUR: FolderEntry[] = [
{ rel: 'fixed.txt', status: 'same', leftSize: 2, rightSize: 2 },
{ rel: 'new.txt', status: 'right-only', leftSize: null, rightSize: 5 },
{ rel: 'stable.txt', status: 'same', leftSize: 3, rightSize: 3 }
]
const snapshotEntries = diffSnapshot(SNAP, CUR).entries
const SNAP_CTX = {
...CTX,
snapshotNote: '修复 1 · 新增差异 0 · 新文件 1 · 已移除 1 · 仍有差异 0(基线 2026/1/1',
snapshotEntries
}
it('HTML:表头切换为变迁对照列,行含变迁标签与当前/基线状态配色', () => {
const html = buildFolderReport(CUR, SNAP_CTX, 'html')
expect(html).toContain('<td>变迁</td>')
expect(html).toContain('<td>当前状态</td>')
expect(html).toContain('<td>基线状态</td>')
// 行级:fixed(不同→相同)与 stable(相同→相同)
expect(html).toContain('chg-fixed')
expect(html).toContain('已修复')
expect(html).toContain('chg-stable')
expect(html).toContain('保持一致')
// 普通条目表头不再出现
expect(html).not.toContain('<td>状态</td>')
})
it('HTML:gone 条目当前状态为 —,大小回退基线数据;new 条目基线状态为 —', () => {
const html = buildFolderReport(CUR, SNAP_CTX, 'html')
// 按表格行分块断言(tr 与单元格内容不在同一物理行)
const trOf = (rel: string): string => html.split('<tr').find((c) => c.includes(rel)) ?? ''
const goneRow = trOf('gone.txt')
expect(goneRow).toContain('chg-gone')
expect(goneRow).toContain('已移除')
expect(goneRow).toContain('—')
// gone 大小取基线(1 B / 2 B
expect(goneRow).toContain('1 B')
expect(goneRow).toContain('2 B')
const newRow = trOf('new.txt')
expect(newRow).toContain('新文件')
expect(newRow).toContain('—')
})
it('纯文本:行含变迁标签与当前/基线状态,快照口径说明保留', () => {
const txt = buildFolderReport(CUR, SNAP_CTX, 'txt')
expect(txt).toContain('快照对照:修复 1')
expect(txt).toContain('已修复 相同 不同')
expect(txt.split('\n').some((l) => l.includes('gone.txt'))).toBe(true)
// gone 行:当前状态为 —(基线仍为 不同)
expect(txt.split('\n').some((l) => l.includes('已移除') && l.includes('—'))).toBe(true)
})
it('Markdown:表格切换为变迁列结构', () => {
const md = buildFolderReport(CUR, SNAP_CTX, 'md')
expect(md).toContain('| 变迁 | 相对路径 | 当前状态 | 基线状态 | 左侧大小 | 右侧大小 |')
expect(md).toContain('| 已修复 |')
expect(md).toContain('| 已移除 |')
expect(md).not.toContain('| 状态 | 相对路径 | 左侧大小 | 右侧大小 |')
})
it('不传 snapshotEntries 时保持普通条目表(回归锚)', () => {
const html = buildFolderReport(ENTRIES, CTX, 'html')
expect(html).toContain('<td>状态</td>')
expect(html).not.toContain('<td>变迁</td>')
})
})
+403
View File
@@ -0,0 +1,403 @@
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
import { esc, footLine, ignoreRulesLine, REPORT_CSS, REPORT_EXT, type ReportFormat } from './report'
import { SNAPSHOT_CHANGE_LABEL, type SnapshotDiffEntry } from './snapshotDiff'
/**
* HTML / / Markdown
* REPORT_CSS 0
* snapshotEntries stable
* stable
*/
export type { ReportFormat }
/** 文件夹报告上下文 */
export interface FolderReportContext {
leftDir: string
rightDir: string
/** 语义判等是否开启(报告头部说明判定口径) */
semantic: boolean
/** 条目因数量上限被截断(报告头部警示) */
truncated: boolean
/** 枚举总数(截断时为已遍历下限) */
total: number
/** 快照对照说明(快照对照模式导出时附注变迁统计与基线时间) */
snapshotNote?: string
/** 快照对照条目(对照模式导出时提供:条目表替换为变迁对照表) */
snapshotEntries?: SnapshotDiffEntry[]
/** 生成器版本号(尾部署名;可追溯报告由哪个版本产出) */
version?: string
/** 生效的忽略规则(非空时头部说明——被忽略条目不进报告,脱离应用后仍可辨识缺失原因) */
ignoreRules?: string[]
}
/** 六类状态统计 */
export interface FolderStats {
same: number
semanticSame: number
different: number
leftOnly: number
rightOnly: number
unreadable: number
}
export function countFolderStats(entries: FolderEntry[]): FolderStats {
const s: FolderStats = {
same: 0,
semanticSame: 0,
different: 0,
leftOnly: 0,
rightOnly: 0,
unreadable: 0
}
for (const e of entries) {
if (e.status === 'same') s.same++
else if (e.status === 'semantic-same') s.semanticSame++
else if (e.status === 'different') s.different++
else if (e.status === 'left-only') s.leftOnly++
else if (e.status === 'right-only') s.rightOnly++
else s.unreadable++
}
return s
}
/** 一句话结论:给不熟悉状态符号的人看 */
export function plainFolderSummary(stats: FolderStats, entriesLen: number): string {
if (entriesLen === 0) return '两个文件夹内都没有文件'
const diffTotal = stats.different + stats.leftOnly + stats.rightOnly
if (diffTotal === 0) {
return stats.semanticSame > 0
? `两文件夹内容完全一致(其中 ${stats.semanticSame} 个文件仅空白/换行差异)`
: '两文件夹内容完全一致'
}
const parts: string[] = []
if (stats.different > 0) parts.push(`内容不同 ${stats.different}`)
if (stats.leftOnly > 0) parts.push(`仅左侧 ${stats.leftOnly}`)
if (stats.rightOnly > 0) parts.push(`仅右侧 ${stats.rightOnly}`)
const semNote = stats.semanticSame > 0 ? `(另有 ${stats.semanticSame} 个仅空白/换行差异,不计入不同)` : ''
const unreadNote = stats.unreadable > 0 ? `;另有 ${stats.unreadable} 个文件无法读取(被占用/权限)` : ''
return `${diffTotal} 个文件存在差异:${parts.join('、')}${semNote}${unreadNote}`
}
/** 状态的人话标签 */
const STATUS_LABEL: Record<FolderEntryStatus, string> = {
same: '相同',
'semantic-same': '语义同',
different: '不同',
'left-only': '仅左侧',
'right-only': '仅右侧',
unreadable: '无法读取'
}
/**
* FolderView
* GB/TB 10MB
* GB / 1024.0 MB
* TB / 1024.0 GB
*/
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`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
if (n < 1024 * 1024 * 1024 * 1024) return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
return `${(n / 1024 / 1024 / 1024 / 1024).toFixed(1)} TB`
}
/** ============ HTML 报告 ============ */
/** 文件夹报告追加样式:条目表状态列配色(叠加在 REPORT_CSS 之上) */
const FOLDER_EXTRA_CSS = `
.f-table { border-collapse:collapse; width:100%; table-layout:fixed;
font-family:Consolas,'JetBrains Mono',monospace; font-size:12.5px;
border:1px solid rgba(148,163,255,.14); border-radius:12px; overflow:hidden; }
.f-table col.c-st { width:76px; }
.f-table col.c-chg { width:88px; }
.f-table col.c-ls { width:88px; }
.f-table col.c-rs { width:88px; }
.f-table thead td { font-family:'Segoe UI','Microsoft YaHei',sans-serif; font-size:11px; color:#74809a;
background:rgba(124,108,255,.08); border-bottom:1px solid rgba(148,163,255,.18);
padding:6px 10px; text-align:left; letter-spacing:1px; }
.f-table td { border-bottom:1px solid rgba(148,163,255,.08); padding:4px 10px;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.f-table td.st { text-align:center; font-family:'Segoe UI','Microsoft YaHei',sans-serif;
font-weight:700; font-size:12px; border-radius:4px; }
.f-table td.sz { text-align:right; color:#74809a; font-variant-numeric:tabular-nums; }
.f-table td.rel { word-break:break-all; white-space:normal; }
.f-table tr.st-same td.st { color:#7ee2a8; background:rgba(34,197,94,.10); }
.f-table tr.st-sem td.st { color:#22d3ee; background:rgba(34,211,238,.10); }
.f-table tr.st-diff td.st { color:#f7a9a9; background:rgba(248,113,113,.10); }
.f-table tr.st-left td.st { color:#c9bfff; background:rgba(124,108,255,.12); }
.f-table tr.st-right td.st { color:#c9bfff; background:rgba(124,108,255,.12); }
.f-table tr.st-unread td.st { color:#f6bd87; background:rgba(245,158,11,.10); }
.f-table td.chg { text-align:center; font-family:'Segoe UI','Microsoft YaHei',sans-serif;
font-weight:700; font-size:12px; border-radius:4px; }
.f-table tr.chg-fixed td.chg, .f-table tr.chg-new td.chg { color:#7ee2a8; background:rgba(34,197,94,.10); }
.f-table tr.chg-regressed td.chg { color:#f7a9a9; background:rgba(248,113,113,.10); }
.f-table tr.chg-changed td.chg { color:#f6bd87; background:rgba(245,158,11,.10); }
.f-table tr.chg-gone td.chg, .f-table tr.chg-stable td.chg { color:#74809a; background:rgba(148,163,255,.06); }
.f-table tr:hover { background:rgba(34,211,238,.05); }
.trunc-note { color:#f6bd87; font-size:12.5px; padding:8px 14px; border-radius:10px;
background:rgba(245,158,11,.10); border:1px solid rgba(245,158,11,.35); margin-bottom:14px; }
@media print {
.f-table { border-color:#ccc; }
.f-table td { border-color:#eee; }
.f-table thead td { background:#f3f3f6; color:#666; }
.trunc-note { color:#8a5a00; background:#fff7e6; border-color:#e8c07a; }
.f-table tr.chg-fixed td.chg, .f-table tr.chg-new td.chg { color:#15803d; background:#e9f9ef; }
.f-table tr.chg-regressed td.chg { color:#b91c1c; background:#fdeeee; }
.f-table tr.chg-changed td.chg { color:#b45309; background:#fdf4e5; }
.f-table tr.chg-gone td.chg, .f-table tr.chg-stable td.chg { color:#888; background:#f3f3f6; }
}
`
/** 变迁对照表的行 HTML(当前/基线状态缺失显示 —;大小取 current ?? baseline 与界面对照行一致) */
function snapshotRowHtml(e: SnapshotDiffEntry): string {
const display = e.current ?? e.baseline!
const cur = e.current ? STATUS_LABEL[e.current.status] : '—'
const base = e.baseline ? STATUS_LABEL[e.baseline.status] : '—'
return `<tr class="chg-${e.change}">
<td class="chg">${SNAPSHOT_CHANGE_LABEL[e.change]}</td>
<td class="rel">${esc(e.rel)}</td>
<td class="st">${cur}</td>
<td class="st">${base}</td>
<td class="sz">${fmtSize(display.leftSize)}</td>
<td class="sz">${fmtSize(display.rightSize)}</td>
</tr>`
}
function buildFolderHtml(
entries: FolderEntry[],
ctx: FolderReportContext,
stats: FolderStats
): string {
const verdictOk = stats.different + stats.leftOnly + stats.rightOnly === 0
const semLine = ctx.semantic
? '判定口径:语义判等开启(空白/换行差异计为语义同,不计入不同)'
: '判定口径:字节级严格比对(任何字节差异均计为不同)'
// 对照模式:条目表替换为变迁对照表(全量含 stable),列结构随之切换
const snaps = ctx.snapshotEntries
const cols = snaps
? '<colgroup><col class="c-chg"><col><col class="c-st"><col class="c-st"><col class="c-ls"><col class="c-rs"></colgroup>'
: '<colgroup><col class="c-st"><col><col class="c-ls"><col class="c-rs"></colgroup>'
const headRow = snaps
? '<tr><td>变迁</td><td>相对路径</td><td>当前状态</td><td>基线状态</td><td style="text-align:right">左侧大小</td><td style="text-align:right">右侧大小</td></tr>'
: '<tr><td>状态</td><td>相对路径</td><td style="text-align:right">左侧大小</td><td style="text-align:right">右侧大小</td></tr>'
const rowsHtml = snaps
? snaps.map(snapshotRowHtml).join('\n')
: entries
.map((e) => {
const cls =
e.status === 'same'
? 'st-same'
: e.status === 'semantic-same'
? 'st-sem'
: e.status === 'different'
? 'st-diff'
: e.status === 'left-only'
? 'st-left'
: e.status === 'right-only'
? 'st-right'
: 'st-unread'
const approx = e.approximate ? ' <i title="超大文件,头部采样近似判定">≈</i>' : ''
return `<tr class="${cls}">
<td class="st">${STATUS_LABEL[e.status]}</td>
<td class="rel">${esc(e.rel)}${approx}</td>
<td class="sz">${fmtSize(e.leftSize)}</td>
<td class="sz">${fmtSize(e.rightSize)}</td>
</tr>`
})
.join('\n')
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DiffLens </title>
<style>${REPORT_CSS}${FOLDER_EXTRA_CSS}</style>
</head>
<body>
<div class="wrap">
<header class="hero">
<div class="hero-title">📁 DiffLens </div>
<div class="hero-time"> ${new Date().toLocaleString('zh-CN')}</div>
<div class="hero-opts">${esc(semLine)}</div>
${
ctx.ignoreRules && ctx.ignoreRules.length > 0
? `<div class="hero-opts">忽略规则:${esc(ignoreRulesLine(ctx.ignoreRules))}(命中项不参与对比)</div>`
: ''
}
${ctx.snapshotNote ? `<div class="hero-opts">快照对照:${esc(ctx.snapshotNote)}</div>` : ''}
<div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainFolderSummary(stats, entries.length))}</div>
<div class="files">
<div class="file-card left">
<div class="file-side"></div>
<div class="file-name">${esc(ctx.leftDir)}</div>
</div>
<div class="files-arrow"></div>
<div class="file-card right">
<div class="file-side"></div>
<div class="file-name">${esc(ctx.rightDir)}</div>
</div>
</div>
<div class="stats">
<div class="stat same"><div class="stat-num">${stats.same}</div><div class="stat-label"></div></div>
<div class="stat rate"><div class="stat-num">${stats.semanticSame}</div><div class="stat-label"></div></div>
<div class="stat del"><div class="stat-num">${stats.different}</div><div class="stat-label"></div></div>
<div class="stat"><div class="stat-num">${stats.leftOnly}</div><div class="stat-label"></div></div>
<div class="stat"><div class="stat-num">${stats.rightOnly}</div><div class="stat-label"></div></div>
${stats.unreadable > 0 ? `<div class="stat mod"><div class="stat-num">${stats.unreadable}</div><div class="stat-label">无法读取</div></div>` : ''}
<div class="stat ins"><div class="stat-num">${entries.length}</div><div class="stat-label"></div></div>
</div>
</header>
${ctx.truncated ? `<div class="trunc-note">⚠ 文件数量超出上限(已发现 ${ctx.total}+ 个),以下仅包含部分条目,请人工确认完整性</div>` : ''}
<table class="f-table">
${cols}
<thead>${headRow}</thead>
<tbody>
${
snaps
? snaps.length === 0
? '<tr><td colspan="6" style="text-align:center;color:#74809a;padding:24px">快照与当前结果都没有条目</td></tr>'
: rowsHtml
: entries.length === 0
? '<tr><td colspan="4" style="text-align:center;color:#74809a;padding:24px">两个文件夹内都没有文件</td></tr>'
: rowsHtml
}
</tbody>
</table>
<div class="foot"> DiffLens · = /${ctx.version ? ` · v${esc(ctx.version)}` : ''}</div>
</div>
</body>
</html>`
}
/** ============ 纯文本报告 ============ */
function buildFolderTxt(
entries: FolderEntry[],
ctx: FolderReportContext,
stats: FolderStats
): string {
const lines = [
'====== DiffLens 文件夹对比报告 ======',
`左侧目录:${ctx.leftDir}`,
`右侧目录:${ctx.rightDir}`,
`对比时间:${new Date().toLocaleString('zh-CN')}`,
`判定口径:${ctx.semantic ? '语义判等开启(空白/换行差异计为语义同)' : '字节级严格比对'}`,
...(ctx.ignoreRules && ctx.ignoreRules.length > 0
? [`忽略规则:${ignoreRulesLine(ctx.ignoreRules)}(命中项不参与对比)`]
: []),
...(ctx.snapshotNote ? [`快照对照:${ctx.snapshotNote}`] : []),
`结论:${plainFolderSummary(stats, entries.length)}`,
`统计:相同 ${stats.same} 语义同 ${stats.semanticSame} 不同 ${stats.different} 仅左 ${stats.leftOnly} 仅右 ${stats.rightOnly}${stats.unreadable > 0 ? ` 无法读取 ${stats.unreadable}` : ''} ${entries.length}`
]
if (ctx.truncated) {
lines.push(`⚠ 文件过多(${ctx.total}+),以下仅包含部分条目,请人工确认完整性`)
}
lines.push('----------------------------------')
const snaps = ctx.snapshotEntries
if (snaps) {
// 变迁对照表:变迁 / 当前状态 / 基线状态 / 相对路径 / 大小(全量含 stable)
if (snaps.length === 0) {
lines.push('快照与当前结果都没有条目')
} else {
const width = Math.min(60, Math.max(...snaps.map((e) => e.rel.length)))
for (const e of snaps) {
const display = e.current ?? e.baseline!
const rel = e.rel.length > width ? e.rel.slice(0, width - 1) + '…' : e.rel.padEnd(width)
const cur = e.current ? STATUS_LABEL[e.current.status] : '—'
const base = e.baseline ? STATUS_LABEL[e.baseline.status] : '—'
lines.push(
`${SNAPSHOT_CHANGE_LABEL[e.change].padEnd(4)} ${cur.padEnd(3)} ${base.padEnd(3)} ${rel} ${fmtSize(display.leftSize).padStart(9)} ${fmtSize(display.rightSize).padStart(9)}`
)
}
}
} else if (entries.length === 0) {
lines.push('两个文件夹内都没有文件')
} else {
// 路径列宽对齐(封顶 60,超长路径截断保持可读)
const width = Math.min(60, Math.max(...entries.map((e) => e.rel.length)))
for (const e of entries) {
const rel = e.rel.length > width ? e.rel.slice(0, width - 1) + '…' : e.rel.padEnd(width)
lines.push(
`${STATUS_LABEL[e.status].padEnd(3)} ${rel} ${fmtSize(e.leftSize).padStart(9)} ${fmtSize(e.rightSize).padStart(9)}${e.approximate ? ' ≈' : ''}`
)
}
}
lines.push('----------------------------------')
lines.push(footLine(ctx.version))
return lines.join('\n')
}
/** ============ Markdown 报告 ============ */
function buildFolderMd(
entries: FolderEntry[],
ctx: FolderReportContext,
stats: FolderStats
): string {
const out: string[] = [
'# DiffLens 文件夹对比报告',
'',
`> 左侧目录:${ctx.leftDir}`,
`> 右侧目录:${ctx.rightDir}`,
`> 对比时间:${new Date().toLocaleString('zh-CN')}`,
`> 判定口径:${ctx.semantic ? '语义判等开启(空白/换行差异计为语义同)' : '字节级严格比对'}`,
...(ctx.ignoreRules && ctx.ignoreRules.length > 0
? [`> 忽略规则:${ignoreRulesLine(ctx.ignoreRules)}(命中项不参与对比)`]
: []),
...(ctx.snapshotNote ? [`> 快照对照:${ctx.snapshotNote}`] : []),
`> 结论:${plainFolderSummary(stats, entries.length)}`,
`> 统计:相同 ${stats.same} 语义同 ${stats.semanticSame} 不同 ${stats.different} 仅左 ${stats.leftOnly} 仅右 ${stats.rightOnly}${stats.unreadable > 0 ? ` 无法读取 ${stats.unreadable}` : ''} ${entries.length}`,
''
]
if (ctx.truncated) {
out.push(`> ⚠ 文件过多(${ctx.total}+),以下仅包含部分条目,请人工确认完整性`, '')
}
const snaps = ctx.snapshotEntries
if (snaps) {
// 变迁对照表(全量含 stable)
out.push('| 变迁 | 相对路径 | 当前状态 | 基线状态 | 左侧大小 | 右侧大小 |', '| --- | --- | --- | --- | --- | --- |')
if (snaps.length === 0) {
out.push('| (空) | 快照与当前结果都没有条目 | — | — | — | — |')
} else {
for (const e of snaps) {
const display = e.current ?? e.baseline!
const rel = e.rel.replace(/\|/g, '\\|')
const cur = e.current ? STATUS_LABEL[e.current.status] : '—'
const base = e.baseline ? STATUS_LABEL[e.baseline.status] : '—'
out.push(`| ${SNAPSHOT_CHANGE_LABEL[e.change]} | \`${rel}\` | ${cur} | ${base} | ${fmtSize(display.leftSize)} | ${fmtSize(display.rightSize)} |`)
}
}
} else {
out.push('| 状态 | 相对路径 | 左侧大小 | 右侧大小 |', '| --- | --- | --- | --- |')
if (entries.length === 0) {
out.push('| (空) | 两个文件夹内都没有文件 | — | — |')
} else {
for (const e of entries) {
const rel = e.rel.replace(/\|/g, '\\|')
out.push(`| ${STATUS_LABEL[e.status]} | \`${rel}\`${e.approximate ? ' ≈' : ''} | ${fmtSize(e.leftSize)} | ${fmtSize(e.rightSize)} |`)
}
}
}
out.push('', `> ${footLine(ctx.version)}`)
return out.join('\n')
}
/** 按格式生成文件夹对比报告字符串 */
export function buildFolderReport(
entries: FolderEntry[],
ctx: FolderReportContext,
format: ReportFormat
): string {
const stats = countFolderStats(entries)
if (format === 'html') return buildFolderHtml(entries, ctx, stats)
if (format === 'txt') return buildFolderTxt(entries, ctx, stats)
return buildFolderMd(entries, ctx, stats)
}
/** 文件夹报告扩展名(与单文件报告一致) */
export { REPORT_EXT }
+239 -13
View File
@@ -4,6 +4,8 @@ import {
buildHtmlReport,
buildTxtReport,
buildMarkdownReport,
buildPatchReport,
canBuildPatch,
buildReport,
buildSegments,
plainSummary,
@@ -82,21 +84,21 @@ describe('buildSegments - 差异分块', () => {
describe('plainSummary - 人话总结', () => {
it('无差异时给出一致结论', () => {
expect(plainSummary({ changedLines: 0, inserted: 0, deleted: 0, modified: 0 })).toBe(
'两文件内容完全一致'
)
expect(
plainSummary({ changedLines: 0, inserted: 0, deleted: 0, modified: 0, charsAdded: 0, charsDeleted: 0 })
).toBe('两文件内容完全一致')
})
it('为 0 的项被省略', () => {
expect(plainSummary({ changedLines: 2, inserted: 2, deleted: 0, modified: 0 })).toBe(
'共 2 处不同:新增 2 行'
)
expect(
plainSummary({ changedLines: 2, inserted: 2, deleted: 0, modified: 0, charsAdded: 5, charsDeleted: 0 })
).toBe('共 2 处不同:新增 2 行')
})
it('三类变更齐全时完整罗列', () => {
expect(plainSummary({ changedLines: 9, inserted: 4, deleted: 3, modified: 2 })).toBe(
'共 9 处不同:新增 4 行、删除 3 行、修改 2 行'
)
expect(
plainSummary({ changedLines: 9, inserted: 4, deleted: 3, modified: 2, charsAdded: 10, charsDeleted: 8 })
).toBe('共 9 处不同:新增 4 行、删除 3 行、修改 2 行')
})
})
@@ -187,14 +189,25 @@ describe('plainOptions - 比较选项描述', () => {
it('字符级对比单独描述并遮蔽全部空白类选项', () => {
expect(
plainOptions({ charMode: true, trimWhitespace: true, ignoreAllWhitespace: true, ignoreBlankLines: true })
).toBe('字符级对比(忽略全部空白与换行)')
).toBe('字符级对比(忽略全部空白与换行,大内容自动分块计算,预算随规模自适应')
})
it('字符级对比与忽略大小写组合列出', () => {
expect(plainOptions({ charMode: true, ignoreCase: true })).toBe(
'字符级对比(忽略全部空白与换行)、忽略大小写'
'字符级对比(忽略全部空白与换行,大内容自动分块计算,预算随规模自适应)、忽略大小写'
)
})
it('字符级部分降级时描述标注部分块按行级对比', () => {
expect(plainOptions({ charMode: true }, { charModePartial: true })).toBe(
'字符级对比(忽略全部空白与换行;部分差异过大的块按行级对比)'
)
expect(plainOptions({ charMode: true, ignoreCase: true }, { charModePartial: true })).toBe(
'字符级对比(忽略全部空白与换行;部分差异过大的块按行级对比)、忽略大小写'
)
// 未开启字符级时 partial 参数无效果
expect(plainOptions({}, { charModePartial: true })).toBe('无(严格逐字符对比)')
})
})
describe('报告头部比较选项说明', () => {
@@ -235,14 +248,35 @@ describe('buildTxtReport', () => {
expect(txt).toContain('结论:共 1 处不同:新增 1 行')
})
it('字符级模式的统计行追加字符粒度统计;行级模式不追加(0.10.5)', () => {
// 引擎用行级路径构造(词级 world↔moon:+4/5 码点),报告层按 ctx.options.charMode 决定是否展示
const { rows, summary } = computeDiff('hello world', 'hello moon')
expect(summary.charsAdded).toBe(4)
expect(summary.charsDeleted).toBe(5)
const txt = buildTxtReport(rows, { ...ctx, options: { charMode: true } }, summary)
expect(txt).toContain('变更统计:新增 0 | 删除 0 | 修改 1 | 字符 +4 / 5')
// HTML 统计卡同步追加
const html = buildHtmlReport(rows, { ...ctx, options: { charMode: true } }, summary)
expect(html).toContain('字符增删')
expect(html).toContain('+4 / 5')
// Markdown 头部引用块同步
const md = buildMarkdownReport(rows, { ...ctx, options: { charMode: true } }, summary)
expect(md).toContain('> 变更统计:新增 0 | 删除 0 | 修改 1 | 字符 +4 / 5')
// 行级模式(未传 options)不含字符统计
const plain = buildTxtReport(rows, ctx, summary)
expect(plain).toContain('变更统计:新增 0 删除 0 | 修改 1\n')
expect(plain).not.toContain('字符 +')
})
it('行号按最大位数右对齐填充', () => {
const left = Array.from({ length: 12 }, (_, i) => `l${i}`).join('\n')
const right = left.replace('l1', 'changed')
const { rows, summary } = computeDiff(left, right)
const txt = buildTxtReport(rows, ctx, summary)
// 最大行号 12 → 宽度 2:行号 2 左补空格,行号 12 原样(修改行左右行号同样填充)
// 最大行号 12 → 宽度 2:行号 2 左补空格(修改行左右行号同样填充)
expect(txt).toContain('~ 2 | l1 ==> 2 | changed')
expect(txt).toContain(' 12 | l11')
// 段外相同行按分块省略:上下文 3 行展示 l0..l4,尾部省略 l5..l11 共 7 行
expect(txt).toContain('⋯ 省略 7 行相同内容 ⋯')
})
it('行号位数一致时无额外填充', () => {
@@ -281,11 +315,203 @@ describe('buildReport / REPORT_EXT', () => {
expect(buildReport(rows, ctx, summary, 'html')).toContain('<!doctype html>')
expect(buildReport(rows, ctx, summary, 'txt')).toContain('DiffLens')
expect(buildReport(rows, ctx, summary, 'md')).toContain('# DiffLens 差异报告')
expect(buildReport(rows, ctx, summary, 'patch')).toContain('@@ ')
})
it('扩展名映射正确', () => {
expect(REPORT_EXT.html).toBe('html')
expect(REPORT_EXT.txt).toBe('txt')
expect(REPORT_EXT.md).toBe('md')
expect(REPORT_EXT.patch).toBe('patch')
})
})
describe('TXT / Markdown 分块省略(0.11.0', () => {
/** 30 行文件两处远距修改:分两段,段间 11 行相同省略(与 buildSegments 既有用例同构) */
const makeRows = (): ReturnType<typeof computeDiff> => {
const kinds = Array.from({ length: 30 }, (_, i) => `l${i}`)
const right = [...kinds]
right[2] = 'X2'
right[20] = 'X20'
return computeDiff(kinds.join('\n'), right.join('\n'))
}
it('TXT:段间相同行以省略提示行替代,段内行保留', () => {
const { rows, summary } = makeRows()
const txt = buildTxtReport(rows, ctx, summary)
expect(txt).toContain('⋯ 省略 11 行相同内容 ⋯')
// 段内上下文行保留(变更 l2 的上下文 l0..l5)
expect(txt).toContain('| l0')
expect(txt).toContain('| l5')
// 省略段内的行不再逐行输出
expect(txt).not.toContain('| l10')
})
it('TXT:完全一致场景保持全量展示(留档语义,无省略)', () => {
const { rows, summary } = computeDiff('a\nb\nc', 'a\nb\nc')
const txt = buildTxtReport(rows, ctx, summary)
expect(txt).toContain('| a')
expect(txt).toContain('| c')
expect(txt).not.toContain('省略')
})
it('Markdown:段间省略提示行 + 每段独立表格(重复表头)', () => {
const { rows, summary } = makeRows()
const md = buildMarkdownReport(rows, ctx, summary)
expect(md).toContain('⋯ 省略 11 行相同内容 ⋯')
// 两段 → 表头出现两次
expect(md.split('| 类型 | 左行号 | 左文件 | 右行号 | 右文件 |').length).toBe(3)
// 省略段内的行不进表格
expect(md).not.toContain('`l10`')
})
it('Markdown:单段场景仅一个表格(回归锚)', () => {
const { rows, summary } = computeDiff('a', 'b')
const md = buildMarkdownReport(rows, ctx, summary)
expect(md.split('| 类型 | 左行号 | 左文件 | 右行号 | 右文件 |').length).toBe(2)
expect(md).not.toContain('省略')
})
})
describe('buildPatchReport - 标准 unified diff0.11.0', () => {
it('输出文件头与 hunk 头,modified 行展开为 -旧/+新,unchanged 为上下文行', () => {
const { rows } = computeDiff('line1\nline2\nline3', 'line1\nLINE2\nline3')
const patch = buildPatchReport(rows, ctx)
const lines = patch.split('\n')
expect(lines[0]).toBe('--- a/a.txt')
expect(lines[1]).toBe('+++ b/b.txt')
// 单处修改:一个 hunk,起始行号 1(上下文 line1 在前)
expect(patch).toContain('@@ -1,3 +1,3 @@')
expect(lines).toContain(' line1')
expect(lines).toContain('-line2')
expect(lines).toContain('+LINE2')
expect(lines).toContain(' line3')
})
it('多段差异输出多个 hunk,行号各自锚定', () => {
const kinds = Array.from({ length: 30 }, (_, i) => `l${i}`)
const right = [...kinds]
right[2] = 'X2'
right[20] = 'X20'
const { rows } = computeDiff(kinds.join('\n'), right.join('\n'))
const patch = buildPatchReport(rows, ctx)
const hunks = patch.split('\n').filter((l) => l.startsWith('@@'))
expect(hunks).toHaveLength(2)
// 第一段 [0,5]:左行号 1..6;第二段 [17,23]:左行号 18..24
expect(hunks[0]).toBe('@@ -1,6 +1,6 @@')
expect(hunks[1]).toBe('@@ -18,7 +18,7 @@')
})
it('整行增删:added 输出 + 行、removed 输出 - 行', () => {
const { rows } = computeDiff('a\nb', 'a\nc\nd')
const patch = buildPatchReport(rows, ctx)
const lines = patch.split('\n')
expect(lines).toContain('-b')
expect(lines).toContain('+c')
expect(lines).toContain('+d')
// 纯删除行(未与新增配对):removed 单行输出 -,上下文行原样
const del = computeDiff('a\nb\nc', 'a\nc\nd')
const delLines = buildPatchReport(del.rows, ctx).split('\n')
expect(delLines).toContain('-b')
expect(delLines).toContain(' a')
expect(delLines).toContain(' c')
expect(delLines).toContain('+d')
})
it('尾部无换行:按 unified diff 惯例输出标记行', () => {
const { rows } = computeDiff('a\nb', 'a\nB')
const patch = buildPatchReport(rows, ctx, { leftNoNewline: true, rightNoNewline: true })
const lines = patch.split('\n')
// 左右两侧末行均无换行:-b 与 +B 之后各跟一条标记
expect(lines[lines.indexOf('-b') + 1]).toBe('\\ No newline at end of file')
expect(lines[lines.indexOf('+B') + 1]).toBe('\\ No newline at end of file')
})
it('仅一侧尾部无换行时只输出该侧标记', () => {
const { rows } = computeDiff('a\nb', 'a\nB')
const patch = buildPatchReport(rows, ctx, { rightNoNewline: true })
expect(patch).toContain('\\ No newline at end of file')
// 标记紧跟 +B(最后一个右侧来源行)且全文仅一条
const lines = patch.split('\n')
expect(lines[lines.indexOf('+B') + 1]).toBe('\\ No newline at end of file')
expect(lines.filter((l) => l === '\\ No newline at end of file')).toHaveLength(1)
})
it('无变更时仅输出文件头(空 diff)', () => {
const { rows } = computeDiff('same', 'same')
const patch = buildPatchReport(rows, ctx)
expect(patch).toBe('--- a/a.txt\n+++ b/b.txt')
})
it('文件头优先使用完整路径(有 leftPath/rightPath 时)', () => {
const { rows } = computeDiff('a', 'b')
const patch = buildPatchReport(rows, {
...ctx,
leftPath: 'C:/work/old.txt',
rightPath: 'C:/work/new.txt'
})
expect(patch).toContain('--- a/C:/work/old.txt')
expect(patch).toContain('+++ b/C:/work/new.txt')
})
})
describe('canBuildPatch - 判等污染判定(0.11.0', () => {
it('严格对比下的行集恒可导出', () => {
const { rows } = computeDiff('a\nb\nc', 'a\nB\nc')
expect(canBuildPatch(rows)).toBe(true)
})
it('忽略大小写把不同文本判为相同时不可导出', () => {
const { rows } = computeDiff('a\nb\nc', 'a\nB\nc', { ignoreCase: true })
expect(canBuildPatch(rows)).toBe(false)
})
it('判等选项开启但未触发实际判等差异时仍可导出', () => {
// 差异行 real 与 REAL 在忽略大小写下仍不同;相同行文本一致 → 无污染
const { rows } = computeDiff('same\nreal diff', 'same\nreal diff x', { ignoreCase: true })
expect(canBuildPatch(rows)).toBe(true)
})
it('字符级跨行重组判等的行集不可导出(上下文行两侧文本不同)', () => {
const { rows } = computeDiff('ab cd', 'ab\ncd', { charMode: true })
expect(canBuildPatch(rows)).toBe(false)
})
it('完全一致的空变更行集可导出', () => {
const { rows } = computeDiff('same', 'same')
expect(canBuildPatch(rows)).toBe(true)
})
})
describe('报告元信息增强(0.11.0', () => {
const { rows, summary } = computeDiff('a', 'b')
it('版本号进入三格式尾部署名(未提供时保持历史格式)', () => {
const vCtx = { ...ctx, version: '0.11.0' }
expect(buildTxtReport(rows, vCtx, summary)).toContain('生成于 DiffLens v0.11.0')
expect(buildMarkdownReport(rows, vCtx, summary)).toContain('> 生成于 DiffLens v0.11.0')
expect(buildHtmlReport(rows, vCtx, summary)).toContain('v0.11.0')
// 未提供时与历史格式一致(回归锚)
expect(buildTxtReport(rows, ctx, summary)).toContain('生成于 DiffLens')
expect(buildTxtReport(rows, ctx, summary)).not.toContain('v0.')
})
it('对照说明进入头部(TXT/MD/HTML 三格式)', () => {
const nCtx = { ...ctx, compareNote: '左侧为快照基线(保存于 2026/8/20),右侧为当前文件' }
expect(buildTxtReport(rows, nCtx, summary)).toContain(
'对照说明:左侧为快照基线(保存于 2026/8/20),右侧为当前文件'
)
expect(buildMarkdownReport(rows, nCtx, summary)).toContain('> 对照说明:左侧为快照基线')
expect(buildHtmlReport(rows, nCtx, summary)).toContain('对照说明:')
// 未提供时无该行(回归锚)
expect(buildTxtReport(rows, ctx, summary)).not.toContain('对照说明')
})
it('HTML 文件卡行数优先取原始文本口径(ctx.leftLines', () => {
// 原始文本 3 行;ctx 声明 3 行时文件卡用 3(rows 口径在忽略空行时会偏小)
const lCtx = { ...ctx, leftLines: 3, rightLines: 5 }
const html = buildHtmlReport(rows, lCtx, summary)
expect(html).toContain('3 行')
expect(html).toContain('5 行')
})
})
+228 -46
View File
@@ -1,6 +1,6 @@
import { DiffOptions, DiffRow, DiffSummary, SideCell } from './diffEngine'
export type ReportFormat = 'html' | 'txt' | 'md'
export type ReportFormat = 'html' | 'txt' | 'md' | 'patch'
export interface ReportContext {
/** 左侧文件显示名;未选择为空串 */
@@ -17,9 +17,20 @@ export interface ReportContext {
rightPath?: string
/** 本次对比生效的比较选项(用于报告头部说明,可选) */
options?: DiffOptions
/** 字符级对比部分降级(部分超限块按行级配对,其余仍字符级;头部说明标注) */
charModePartial?: boolean
/** 生成器版本号(App 经 app:get-version 获取;尾部署名“生成于 DiffLens vX.Y.Z”) */
version?: string
/** 对照说明(快照基线内容对照场景:注明左侧为快照基线及其保存时间、右侧为当前文件) */
compareNote?: string
/** 左侧原始文本行数(countLines 口径;忽略空行开启时 rows 统计会偏小,优先用本字段) */
leftLines?: number
/** 右侧原始文本行数 */
rightLines?: number
}
const esc = (s: string): string =>
/** HTML 转义(文件夹报告复用) */
export const esc = (s: string): string =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
/** 格式化行号:传 width 时按位数右对齐(左补空格,用于纯文本报告对齐);null 返回空串 */
@@ -119,10 +130,18 @@ export function plainSummary(summary: DiffSummary): string {
}
/** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */
export function plainOptions(options?: DiffOptions): string {
// 字符级对比为最强空白语义(全部空白与换行都不参与判等),单独描述并遮蔽空白类子选项
export function plainOptions(
options?: DiffOptions,
extra?: { charModePartial?: boolean }
): string {
// 字符级对比为最强空白语义(全部空白与换行都不参与判等),单独描述并遮蔽空白类子选项;
// 大内容按“行锚定 + 变更组分块”计算,不再整篇受字符量上限约束,总预算随输入规模自适应;
// 部分降级(逐组预算制)时标注部分差异过大的块按行级对比
if (options?.charMode) {
const on = ['字符级对比(忽略全部空白与换行)']
const base = extra?.charModePartial
? '字符级对比(忽略全部空白与换行;部分差异过大的块按行级对比)'
: '字符级对比(忽略全部空白与换行,大内容自动分块计算,预算随规模自适应)'
const on = [base]
if (options.ignoreCase) on.push('忽略大小写')
return on.join('、')
}
@@ -147,15 +166,31 @@ const headLines = (
ctx.rightPath ? ` [${ctx.rightPath}]` : ''
}`,
`对比时间:${new Date().toLocaleString('zh-CN')}`,
`比较选项:${plainOptions(ctx.options)}`,
`比较选项:${plainOptions(ctx.options, { charModePartial: ctx.charModePartial })}`,
// 对照说明(快照基线内容对照场景):报告脱离应用后仍可辨识左侧数据来源与时间点
...(ctx.compareNote ? [`对照说明:${ctx.compareNote}`] : []),
`结论:${plainSummary(summary)}`,
`变更统计:新增 ${summary.inserted} 删除 ${summary.deleted} 修改 ${summary.modified}`
`变更统计:新增 ${summary.inserted} 删除 ${summary.deleted} 修改 ${summary.modified}` +
// 字符级模式追加字符粒度统计(码点数;超长防护行无法细分不计入)
(ctx.options?.charMode
? ` 字符 +${summary.charsAdded} / ${summary.charsDeleted}`
: '')
]
/** 尾部署名行:携带生成器版本号(可追溯报告由哪个版本产出;未提供时与历史格式一致;文件夹报告复用) */
export const footLine = (version?: string): string =>
`生成于 DiffLens${version ? ` v${version}` : ''}`
/** 忽略规则说明行文案(文件夹报告头部;超 5 条截断避免单行过长,全文规则可在应用内查看) */
export const ignoreRulesLine = (rules: string[]): string => {
const head = rules.slice(0, 5).join('、')
return rules.length > 5 ? `${head}${rules.length}` : head
}
/** ============ HTML 报告 ============ */
/** 报告内嵌样式:延续应用暗色科幻主题,附带打印亮色与窄屏适配(零 JS,任意浏览器可开) */
const REPORT_CSS = `
/** 报告内嵌样式:延续应用暗色科幻主题,附带打印亮色与窄屏适配(零 JS,任意浏览器可开);文件夹报告复用并追加条目表样式 */
export const REPORT_CSS = `
* { margin:0; padding:0; box-sizing:border-box; }
html { scroll-behavior:smooth; }
body {
@@ -433,8 +468,9 @@ export function buildHtmlReport(
const segs = buildSegments(rows)
const unchanged = rows.length - summary.changedLines
const rate = rows.length > 0 ? `${((summary.changedLines / rows.length) * 100).toFixed(1)}%` : '—'
const leftLines = rows.filter((r) => r.left.lineNo !== null).length
const rightLines = rows.filter((r) => r.right.lineNo !== null).length
// 行数优先取原始文本口径(ctx 传入):忽略空行开启时 rows 内空行被剔除、统计偏小
const leftLines = ctx.leftLines ?? rows.filter((r) => r.left.lineNo !== null).length
const rightLines = ctx.rightLines ?? rows.filter((r) => r.right.lineNo !== null).length
const verdictOk = summary.changedLines === 0
// 明细:段前折叠省略行 + 段内行(含块标题行);最后补尾部折叠
@@ -479,7 +515,8 @@ export function buildHtmlReport(
<header class="hero">
<div class="hero-title">🔍 DiffLens </div>
<div class="hero-time"> ${new Date().toLocaleString('zh-CN')}</div>
<div class="hero-opts">${esc(plainOptions(ctx.options))}</div>
<div class="hero-opts">${esc(plainOptions(ctx.options, { charModePartial: ctx.charModePartial }))}</div>
${ctx.compareNote ? `<div class="hero-opts">对照说明:${esc(ctx.compareNote)}</div>` : ''}
<div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainSummary(summary))}</div>
<div class="files">
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)}
@@ -492,6 +529,11 @@ export function buildHtmlReport(
${statCard('mod', '修改行', `~${summary.modified}`)}
${statCard('same', '未更改行', unchanged)}
${statCard('rate', '行变更率', rate)}
${
ctx.options?.charMode
? statCard('rate', '字符增删', `+${summary.charsAdded} / ${summary.charsDeleted}`)
: ''
}
</div>
<div class="legend">
<span><i class="sw add"></i>+ </span>
@@ -507,13 +549,44 @@ export function buildHtmlReport(
<thead><tr><td class="tag"></td><td class="lnl">#</td><td></td><td class="lnr">#</td><td></td></tr></thead>
${emptyBody}
</table>
<div class="foot"> DiffLens · </div>
<div class="foot"> DiffLens · ${ctx.version ? ` · v${esc(ctx.version)}` : ''}</div>
</div>
</body>
</html>`
}
/** ============ 纯文本报告 ============ */
/** 分块省略提示行(TXT / Markdown 共用文案):与 HTML 折叠段同语义——段间相同行不再逐行输出 */
export const SEGMENT_SKIP_NOTE = (n: number): string => `⋯ 省略 ${n} 行相同内容 ⋯`
/** 把一段行区间渲染为 TXT 行式文本(行号右对齐共用逻辑) */
function txtSegmentLines(slice: DiffRow[], width: number): string[] {
const out: string[] = []
for (const row of slice) {
const tag = typeTag(row.rowKind)
if (row.rowKind === 'modified') {
// 修改行:左/右并置
const l = padNo(row.left.lineNo, width)
const r = padNo(row.right.lineNo, width)
out.push(`~ ${l} | ${row.left.text ?? ''} ==> ${r} | ${row.right.text ?? ''}`)
} else {
const no = padNo(
row.rowKind === 'added' ? row.right.lineNo : row.left.lineNo,
width
)
const text = row.rowKind === 'added' ? row.right.text : row.left.text
out.push(`${tag} ${no} | ${text ?? ''}`)
}
}
return out
}
/**
* HTML buildSegments 3
*
*
*/
export function buildTxtReport(
rows: DiffRow[],
ctx: ReportContext,
@@ -530,41 +603,28 @@ export function buildTxtReport(
}
const width = String(maxNo).length
for (const row of rows) {
const tag = typeTag(row.rowKind)
if (row.rowKind === 'modified') {
// 修改行:左/右并置
const l = padNo(row.left.lineNo, width)
const r = padNo(row.right.lineNo, width)
lines.push(`~ ${l} | ${row.left.text ?? ''} ==> ${r} | ${row.right.text ?? ''}`)
} else {
const no = padNo(
row.rowKind === 'added' ? row.right.lineNo : row.left.lineNo,
width
)
const text = row.rowKind === 'added' ? row.right.text : row.left.text
lines.push(`${tag} ${no} | ${text ?? ''}`)
}
// 分块输出:段前省略(skippedBefore)+ 段内行;最后一段补尾部省略(skippedAfter
const segs = buildSegments(rows)
for (const s of segs) {
if (s.skippedBefore > 0) lines.push(SEGMENT_SKIP_NOTE(s.skippedBefore))
lines.push(...txtSegmentLines(rows.slice(s.start, s.end + 1), width))
}
if (segs.length > 0) {
const last = segs[segs.length - 1]
if (last.skippedAfter > 0) lines.push(SEGMENT_SKIP_NOTE(last.skippedAfter))
}
lines.push('----------------------------------')
lines.push('生成于 DiffLens')
lines.push(footLine(ctx.version))
return lines.join('\n')
}
/** ============ Markdown 报告 ============ */
export function buildMarkdownReport(
rows: DiffRow[],
ctx: ReportContext,
summary: DiffSummary
): string {
const head = headLines(ctx, summary, '# DiffLens 差异报告')
// 标题独立成行(不套进引用块),其余元信息行以引用块展示
const [title, ...meta] = head
const out: string[] = [title, '', ...meta.map((h) => `> ${h}`), '']
out.push('| 类型 | 左行号 | 左文件 | 右行号 | 右文件 |')
out.push('| --- | --- | --- | --- | --- |')
for (const row of rows) {
/** 单个行区间的 Markdown 表格(含表头;管道符转义与空行占位沿用既有规则) */
function mdSegmentTable(slice: DiffRow[]): string[] {
const out: string[] = ['| 类型 | 左行号 | 左文件 | 右行号 | 右文件 |', '| --- | --- | --- | --- | --- |']
for (const row of slice) {
const tag = typeTag(row.rowKind)
const lno = padNo(row.left.lineNo)
const rno = padNo(row.right.lineNo)
@@ -575,8 +635,127 @@ export function buildMarkdownReport(
const rc = rno ? rtext : '>'
out.push(`| ${tag} | ${lno} | \`${lc}\` | ${rno} | \`${rc}\` |`)
}
out.push('')
out.push('> 生成于 DiffLens')
return out
}
/**
* Markdown HTML/TXT 3
* Markdown
*
*/
export function buildMarkdownReport(
rows: DiffRow[],
ctx: ReportContext,
summary: DiffSummary
): string {
const head = headLines(ctx, summary, '# DiffLens 差异报告')
// 标题独立成行(不套进引用块),其余元信息行以引用块展示
const [title, ...meta] = head
const out: string[] = [title, '', ...meta.map((h) => `> ${h}`), '']
const segs = buildSegments(rows)
segs.forEach((s, i) => {
if (i > 0 || s.skippedBefore > 0) out.push('', '')
if (s.skippedBefore > 0) out.push(SEGMENT_SKIP_NOTE(s.skippedBefore))
out.push(...mdSegmentTable(rows.slice(s.start, s.end + 1)))
})
if (segs.length > 0) {
const last = segs[segs.length - 1]
if (last.skippedAfter > 0) out.push('', SEGMENT_SKIP_NOTE(last.skippedAfter))
}
out.push('', `> ${footLine(ctx.version)}`)
return out.join('\n')
}
/** ============ Patch(标准 unified diff)报告 ============ */
/**
* Patch //
* unchangedunified diff
* patch
*/
export function canBuildPatch(rows: DiffRow[]): boolean {
for (const r of rows) {
if (!r.isChanged && r.left.text !== r.right.text) return false
}
return true
}
/** 侧文本:unchanged 行两侧一致(canBuildPatch 已保证非污染;空槽行取非空侧) */
const ctxText = (row: DiffRow): string => row.left.text ?? row.right.text ?? ''
/**
* unified diff git diff git apply / patch
* - `--- a/{名}` / `+++ b/{名}`
* - buildSegments 3 hunk`@@ -l,c +l,c @@`
* - modified `-旧` `+新` removed/added `-`/`+`unchanged
* - / diff
* - unified diff `\ No newline at end of file`
* tail DiffRow
*/
export function buildPatchReport(
rows: DiffRow[],
ctx: ReportContext,
tail?: { leftNoNewline?: boolean; rightNoNewline?: boolean }
): string {
const l = ctx.leftPath ?? ctx.leftName ?? 'left'
const r = ctx.rightPath ?? ctx.rightName ?? 'right'
const out: string[] = [`--- a/${l}`, `+++ b/${r}`]
// 段内行序列:每行记录来源侧(L=旧/R=新),供尾部无换行标记定位;
// modified 行展开为 -旧/+新 两行,body 与 rows 索引不一一对应,经 rowBodyStart 映射
interface BodyLine {
out: string
fromLeft: boolean
fromRight: boolean
}
const body: BodyLine[] = []
/** rows[i] 在 body 中的起始索引 */
const rowBodyStart: number[] = []
for (const row of rows) {
rowBodyStart.push(body.length)
if (row.rowKind === 'unchanged') {
body.push({ out: ` ${ctxText(row)}`, fromLeft: true, fromRight: true })
} else if (row.rowKind === 'removed') {
body.push({ out: `-${row.left.text ?? ''}`, fromLeft: true, fromRight: false })
} else if (row.rowKind === 'added') {
body.push({ out: `+${row.right.text ?? ''}`, fromLeft: false, fromRight: true })
} else {
// modified:旧文本行在前、新文本行在后(unified diff 惯例)
body.push({ out: `-${row.left.text ?? ''}`, fromLeft: true, fromRight: false })
body.push({ out: `+${row.right.text ?? ''}`, fromLeft: false, fromRight: true })
}
}
/** rows[i] 的 body 结束索引(不含) */
const bodyEndOfRow = (i: number): number =>
i + 1 < rows.length ? rowBodyStart[i + 1] : body.length
// 尾部无换行标记位置:每侧最后一个来源行之后(预计算避免逐行回扫)
let lastLeftIdx = -1
let lastRightIdx = -1
body.forEach((b, i) => {
if (b.fromLeft) lastLeftIdx = i
if (b.fromRight) lastRightIdx = i
})
// 按 hunk 输出:每段计算双侧起始行号与行数(行号取段内首个非空槽;count 为 0 时
// start 按惯例指向前一行的插入位置——此处取 0,覆盖文件开头新增的常见场景);
// 无变更段(完全一致场景的单段全展示)不输出 hunk——与 git diff 空输出一致
const segs = buildSegments(rows)
for (const s of segs) {
if (s.changes === 0) continue
const slice = rows.slice(s.start, s.end + 1)
const leftNos = slice.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
const rightNos = slice.map((r) => r.right.lineNo).filter((n): n is number => n !== null)
const lStart = leftNos.length > 0 ? leftNos[0] : 0
const rStart = rightNos.length > 0 ? rightNos[0] : 0
out.push(`@@ -${lStart},${leftNos.length} +${rStart},${rightNos.length} @@`)
for (let i = rowBodyStart[s.start]; i < bodyEndOfRow(s.end); i++) {
const b = body[i]
out.push(b.out)
if (tail?.leftNoNewline && i === lastLeftIdx) out.push('\\ No newline at end of file')
if (tail?.rightNoNewline && i === lastRightIdx) out.push('\\ No newline at end of file')
}
}
return out.join('\n')
}
@@ -585,15 +764,18 @@ export function buildReport(
rows: DiffRow[],
ctx: ReportContext,
summary: DiffSummary,
format: ReportFormat
format: ReportFormat,
tail?: { leftNoNewline?: boolean; rightNoNewline?: boolean }
): string {
if (format === 'html') return buildHtmlReport(rows, ctx, summary)
if (format === 'txt') return buildTxtReport(rows, ctx, summary)
return buildMarkdownReport(rows, ctx, summary)
if (format === 'md') return buildMarkdownReport(rows, ctx, summary)
return buildPatchReport(rows, ctx, tail)
}
export const REPORT_EXT: Record<ReportFormat, string> = {
html: 'html',
txt: 'txt',
md: 'md'
md: 'md',
patch: 'patch'
}
+236
View File
@@ -0,0 +1,236 @@
import { describe, it, expect } from 'vitest'
import type { FolderEntry } from '../../../preload/index'
import {
parseSnapshot,
diffSnapshot,
snapshotDirsMatch,
isDiffStatus,
SNAPSHOT_VERSION,
SNAPSHOT_FILE_MAX_BYTES,
SNAPSHOT_CONTENTS_MAX_CHARS,
type FolderSnapshot
} from './snapshotDiff'
/** 构造合法快照(v1:无 contents 字段,验证向后兼容加载) */
function makeSnapshot(overrides?: Partial<FolderSnapshot>): FolderSnapshot {
return {
version: 1,
leftDir: 'C:/left',
rightDir: 'D:/right',
semantic: true,
truncated: false,
total: 2,
entries: [
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'b.txt', status: 'different', leftSize: 1, rightSize: 2 }
],
savedAt: 1700000000000,
...overrides
}
}
describe('parseSnapshot - 解析校验', () => {
it('合法快照完整解析', () => {
const snap = makeSnapshot()
const parsed = parseSnapshot(JSON.stringify(snap))
expect(parsed).toEqual(snap)
})
it('非法 JSON / 版本不符 / 顶层字段异常回退 null', () => {
expect(parseSnapshot('{broken json!!')).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), version: 99 }))).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), leftDir: 42 }))).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), semantic: 'yes' }))).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), savedAt: 'no-time' }))).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), total: null }))).toBeNull()
})
it('任一条目形态异常整体回退 null(绝不部分加载)', () => {
const bad1 = JSON.stringify({
...makeSnapshot(),
entries: [
{ rel: 'a.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'b.txt', status: 'unknown', leftSize: 1, rightSize: 2 }
]
})
expect(parseSnapshot(bad1)).toBeNull()
const bad2 = JSON.stringify({
...makeSnapshot(),
entries: [{ rel: '', status: 'same', leftSize: 1, rightSize: 1 }]
})
expect(parseSnapshot(bad2)).toBeNull()
const bad3 = JSON.stringify({
...makeSnapshot(),
entries: [{ rel: 'a', status: 'same', leftSize: 'big', rightSize: 1 }]
})
expect(parseSnapshot(bad3)).toBeNull()
})
it('条目 approximate 可选字段类型校验', () => {
const ok = JSON.stringify({
...makeSnapshot(),
entries: [{ rel: 'big.bin', status: 'same', leftSize: 1, rightSize: 1, approximate: true }]
})
expect(parseSnapshot(ok)).not.toBeNull()
const bad = JSON.stringify({
...makeSnapshot(),
entries: [{ rel: 'big.bin', status: 'same', leftSize: 1, rightSize: 1, approximate: 'yes' }]
})
expect(parseSnapshot(bad)).toBeNull()
})
})
describe('parseSnapshot - v2 内容字段(contents', () => {
const v2Snap = (): FolderSnapshot => ({
...makeSnapshot({ version: 2 }),
contents: {
'b.txt': { left: 'baseline left', right: 'baseline right' },
'c.txt': { left: null, right: 'only right content' }
}
})
it('v2 含 contents 完整解析(含 null 侧)', () => {
const parsed = parseSnapshot(JSON.stringify(v2Snap()))
expect(parsed).toEqual(v2Snap())
})
it('v2 无 contents 字段合法(纯状态快照:超限降级 / 全部读取失败的保存形态)', () => {
const snap = makeSnapshot({ version: 2 })
const parsed = parseSnapshot(JSON.stringify(snap))
expect(parsed).toEqual(snap)
expect(parsed?.contents).toBeUndefined()
})
it('v1(无 contents)与 v2 均可加载,其余版本拒绝', () => {
expect(parseSnapshot(JSON.stringify(makeSnapshot()))).not.toBeNull()
expect(parseSnapshot(JSON.stringify(makeSnapshot({ version: 2 }))))?.not.toBeNull()
// 非法版本号经展开构造(version 字面量类型为 1|2,直接传入 3/0 会编译报错)
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), version: 3 }))).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot(), version: 0 }))).toBeNull()
})
it('contents 形态异常整体回退 null(绝不部分加载)', () => {
// contents 非对象
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot({ version: 2 }), contents: 'x' }))).toBeNull()
expect(parseSnapshot(JSON.stringify({ ...makeSnapshot({ version: 2 }), contents: [] }))).toBeNull()
// 单条记录 left/right 非字符串也非 null
expect(
parseSnapshot(
JSON.stringify({
...makeSnapshot({ version: 2 }),
contents: { 'b.txt': { left: 42, right: 'ok' } }
})
)
).toBeNull()
expect(
parseSnapshot(
JSON.stringify({
...makeSnapshot({ version: 2 }),
contents: { 'b.txt': { left: 'ok', right: true } }
})
)
).toBeNull()
// 单条记录非对象
expect(
parseSnapshot(
JSON.stringify({ ...makeSnapshot({ version: 2 }), contents: { 'b.txt': 'plain' } })
)
).toBeNull()
})
it('内容保存上限常量(单文件 2MB 对齐语义判等上限;总量 32MB)', () => {
expect(SNAPSHOT_VERSION).toBe(2)
expect(SNAPSHOT_FILE_MAX_BYTES).toBe(2 * 1024 * 1024)
expect(SNAPSHOT_CONTENTS_MAX_CHARS).toBe(32 * 1024 * 1024)
})
})
describe('isDiffStatus / snapshotDirsMatch', () => {
it('different / only / unreadable 计为有差异,same / semantic-same 不计', () => {
expect(isDiffStatus('different')).toBe(true)
expect(isDiffStatus('left-only')).toBe(true)
expect(isDiffStatus('right-only')).toBe(true)
expect(isDiffStatus('unreadable')).toBe(true)
expect(isDiffStatus('same')).toBe(false)
expect(isDiffStatus('semantic-same')).toBe(false)
})
it('目录对一致才可对照', () => {
const snap = makeSnapshot()
expect(snapshotDirsMatch(snap, 'C:/left', 'D:/right')).toBe(true)
expect(snapshotDirsMatch(snap, 'C:/other', 'D:/right')).toBe(false)
expect(snapshotDirsMatch(snap, 'C:/left', 'D:/other')).toBe(false)
})
it('路径归一化后比较:盘符大小写/反斜杠/尾分隔符的同目录异写视为一致', () => {
const snap = makeSnapshot()
// Windows 大小写不敏感文件系统:C:\left 与 c:/left/ 为同一目录
expect(snapshotDirsMatch(snap, 'c:/left/', 'd:\\right\\')).toBe(true)
expect(snapshotDirsMatch(snap, 'C:\\left', 'D:\\right')).toBe(true)
// 大小写敏感文件系统:路径段大小写不同仍是不同目录(不误判一致)
expect(snapshotDirsMatch(snap, 'C:/Left', 'D:/right')).toBe(false)
expect(snapshotDirsMatch(snap, 'C:/left', 'D:/Right')).toBe(false)
})
})
describe('diffSnapshot - 状态变迁对照', () => {
const base: FolderEntry[] = [
{ rel: 'fixed.txt', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'regr.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'gone.txt', status: 'different', leftSize: 1, rightSize: 2 },
{ rel: 'changed.txt', status: 'left-only', leftSize: 1, rightSize: null },
{ rel: 'stable.txt', status: 'same', leftSize: 1, rightSize: 1 },
{ rel: 'sem-fixed.txt', status: 'different', leftSize: 1, rightSize: 2 }
]
const cur: FolderEntry[] = [
{ rel: 'fixed.txt', status: 'same', leftSize: 2, rightSize: 2 },
{ rel: 'regr.txt', status: 'different', leftSize: 1, rightSize: 9 },
{ rel: 'new.txt', status: 'right-only', leftSize: null, rightSize: 3 },
{ rel: 'changed.txt', status: 'different', leftSize: 1, rightSize: 5 },
{ rel: 'stable.txt', status: 'semantic-same', leftSize: 1, rightSize: 2 },
{ rel: 'sem-fixed.txt', status: 'semantic-same', leftSize: 1, rightSize: 2 }
]
it('六类变迁全部正确判定并统计', () => {
const r = diffSnapshot(base, cur)
expect(r.stats).toEqual({
fixed: 2, // fixed.txt、sem-fixed.txt(→ semantic-same 也算无差异)
regressed: 1, // regr.txt
new: 1, // new.txt
gone: 1, // gone.txt
changed: 1, // changed.txtleft-only → different
stable: 1 // stable.txtsame → semantic-same 双方均无差异)
})
})
it('条目按 rel 字典序排序并携带双侧原始条目', () => {
const r = diffSnapshot(base, cur)
expect(r.entries.map((e) => e.rel)).toEqual([...r.entries.map((e) => e.rel)].sort())
const gone = r.entries.find((e) => e.rel === 'gone.txt')!
expect(gone.current).toBeNull()
expect(gone.baseline?.status).toBe('different')
const added = r.entries.find((e) => e.rel === 'new.txt')!
expect(added.baseline).toBeNull()
expect(added.current?.status).toBe('right-only')
const both = r.entries.find((e) => e.rel === 'stable.txt')!
expect(both.current?.status).toBe('semantic-same')
expect(both.baseline?.status).toBe('same')
})
it('双侧空集 / 单侧空集', () => {
expect(diffSnapshot([], []).entries).toEqual([])
const onlyCur = diffSnapshot([], cur)
expect(onlyCur.stats.new).toBe(cur.length)
expect(onlyCur.stats.stable).toBe(0)
const onlyBase = diffSnapshot(base, [])
expect(onlyBase.stats.gone).toBe(base.length)
})
it('unreadable 计为有差异(恢复读取后判 same → fixed', () => {
const r = diffSnapshot(
[{ rel: 'locked.txt', status: 'unreadable', leftSize: 1, rightSize: 1 }],
[{ rel: 'locked.txt', status: 'same', leftSize: 1, rightSize: 1 }]
)
expect(r.entries[0].change).toBe('fixed')
})
})
+217
View File
@@ -0,0 +1,217 @@
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
import { normDir } from '../../../shared/pathNorm'
/**
* 线
*
* v2 contents
* "快照基线内容 vs 当前内容"
*/
/** 快照结构版本(不兼容变更时递增并让旧文件作废;v1 无 contents 字段,加载时功能降级为纯状态对照) */
export const SNAPSHOT_VERSION = 2
/**
* SEMANTIC_MAX_BYTES
*
*/
export const SNAPSHOT_FILE_MAX_BYTES = 2 * 1024 * 1024
/**
*
* toast
*/
export const SNAPSHOT_CONTENTS_MAX_CHARS = 32 * 1024 * 1024
/** 单个差异条目的双侧内容(对应侧快照时刻不存在或超限为 null) */
export interface SnapshotContents {
left: string | null
right: string | null
}
/** 快照文件结构(JSON 落盘,与扫描结果同构 + 目录对与时间戳 + 可选差异内容) */
export interface FolderSnapshot {
version: 1 | 2
leftDir: string
rightDir: string
/** 保存时的语义判等开关(对照口径说明用) */
semantic: boolean
truncated: boolean
total: number
entries: FolderEntry[]
/** 保存时间戳 */
savedAt: number
/** v2 可选:差异条目的双侧内容(key 为 rel;仅 isDiffStatus 条目;超限/总量降级时缺失) */
contents?: Record<string, SnapshotContents>
}
/** 条目状态的合法值(解析校验共用) */
const ENTRY_STATUSES: FolderEntryStatus[] = [
'same',
'semantic-same',
'different',
'left-only',
'right-only',
'unreadable'
]
function isEntry(v: unknown): v is FolderEntry {
if (typeof v !== 'object' || v === null) return false
const e = v as Record<string, unknown>
if (typeof e.rel !== 'string' || e.rel === '') return false
if (typeof e.status !== 'string' || !ENTRY_STATUSES.includes(e.status as FolderEntryStatus)) {
return false
}
if (e.leftSize !== null && typeof e.leftSize !== 'number') return false
if (e.rightSize !== null && typeof e.rightSize !== 'number') return false
if (e.approximate !== undefined && typeof e.approximate !== 'boolean') return false
return true
}
/** 单条内容记录形态校验(left/right 为字符串或 null */
function isContents(v: unknown): v is SnapshotContents {
if (typeof v !== 'object' || v === null) return false
const c = v as Record<string, unknown>
return (
(c.left === null || typeof c.left === 'string') &&
(c.right === null || typeof c.right === 'string')
)
}
/**
* JSON JSON / 1 2/
* 退 null
* v1 contents
*/
export function parseSnapshot(raw: string): FolderSnapshot | null {
try {
const obj = JSON.parse(raw) as Record<string, unknown>
if (obj.version !== 1 && obj.version !== 2) return null
if (typeof obj.leftDir !== 'string' || typeof obj.rightDir !== 'string') return null
if (typeof obj.semantic !== 'boolean' || typeof obj.truncated !== 'boolean') return null
if (typeof obj.total !== 'number' || !Number.isFinite(obj.total)) return null
if (typeof obj.savedAt !== 'number' || !Number.isFinite(obj.savedAt)) return null
if (!Array.isArray(obj.entries) || !obj.entries.every(isEntry)) return null
// contents 可选;存在时任一条目形态异常整体回退 null(与 entries 的绝不部分加载口径一致)
let contents: Record<string, SnapshotContents> | undefined
if (obj.contents !== undefined) {
if (typeof obj.contents !== 'object' || obj.contents === null || Array.isArray(obj.contents)) {
return null
}
const map: Record<string, SnapshotContents> = {}
for (const [rel, val] of Object.entries(obj.contents as Record<string, unknown>)) {
if (!isContents(val)) return null
map[rel] = val
}
contents = map
}
return {
version: obj.version,
leftDir: obj.leftDir,
rightDir: obj.rightDir,
semantic: obj.semantic,
truncated: obj.truncated,
total: obj.total,
entries: obj.entries,
savedAt: obj.savedAt,
...(contents !== undefined ? { contents } : {})
}
} catch {
return null
}
}
/** 状态是否计为"有差异"(不同 / 仅一侧 / 无法读取) */
export function isDiffStatus(status: FolderEntryStatus): boolean {
return (
status === 'different' || status === 'left-only' || status === 'right-only' || status === 'unreadable'
)
}
/** 状态变迁类型 */
export type SnapshotChange =
/** 快照有差异 → 现在无差异(same / semantic-same */
| 'fixed'
/** 快照无差异 → 现在有差异 */
| 'regressed'
/** 快照不存在 → 现在存在 */
| 'new'
/** 快照存在 → 现在不存在 */
| 'gone'
/** 快照有差异 → 现在仍有差异 */
| 'changed'
/** 双方都无差异 */
| 'stable'
export interface SnapshotDiffEntry {
rel: string
change: SnapshotChange
/** 当前条目(gone 时为 null */
current: FolderEntry | null
/** 快照条目(new 时为 null */
baseline: FolderEntry | null
}
export interface SnapshotDiffStats {
fixed: number
regressed: number
new: number
gone: number
changed: number
stable: number
}
export interface SnapshotDiffResult {
/** 全部对照条目(按 rel 字典序) */
entries: SnapshotDiffEntry[]
stats: SnapshotDiffStats
}
/** 变迁类型的人话标签(FolderView 徽章与文件夹报告共用同一实现) */
export const SNAPSHOT_CHANGE_LABEL: Record<SnapshotChange, string> = {
fixed: '已修复',
regressed: '新增差异',
new: '新文件',
gone: '已移除',
changed: '仍有差异',
stable: '保持一致'
}
/** 快照与当前扫描的目录对是否一致(路径归一化后比较;不一致时对照无意义,调用方拦截) */
export function snapshotDirsMatch(
baseline: FolderSnapshot,
leftDir: string,
rightDir: string
): boolean {
return normDir(baseline.leftDir) === normDir(leftDir) && normDir(baseline.rightDir) === normDir(rightDir)
}
/**
* 线 rel
* "是否有差异"isDiffStatus
* fixed / regressed changed
* new / gone stable
*/
export function diffSnapshot(
baseline: FolderEntry[],
current: FolderEntry[]
): SnapshotDiffResult {
const baseMap = new Map(baseline.map((e) => [e.rel, e]))
const curMap = new Map(current.map((e) => [e.rel, e]))
const rels = [...new Set([...baseMap.keys(), ...curMap.keys()])].sort()
const stats: SnapshotDiffStats = { fixed: 0, regressed: 0, new: 0, gone: 0, changed: 0, stable: 0 }
const entries = rels.map((rel) => {
const b = baseMap.get(rel) ?? null
const c = curMap.get(rel) ?? null
let change: SnapshotChange
if (b === null) change = 'new'
else if (c === null) change = 'gone'
else if (isDiffStatus(b.status) && !isDiffStatus(c.status)) change = 'fixed'
else if (!isDiffStatus(b.status) && isDiffStatus(c.status)) change = 'regressed'
else if (isDiffStatus(b.status) && isDiffStatus(c.status)) change = 'changed'
else change = 'stable'
stats[change]++
return { rel, change, current: c, baseline: b }
})
return { entries, stats }
}
+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 }
/**
*
+150 -2
View File
@@ -1,7 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useDiff } from './useDiff'
import { computeDiff, type DiffResult, type DiffOptions } from '../diff/diffEngine'
import {
computeDiff,
CHAR_HEAVY_INPUT_CHARS,
type DiffResult,
type DiffOptions
} from '../diff/diffEngine'
import type { DiffWorkerFactory } from '../diff/createDiffWorker'
/** 模拟 Worker:记录派发请求,可手动回传结果或触发崩溃;terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */
@@ -46,6 +51,8 @@ function heavyText(lines: number): string {
// 注意:renderHook 回调内联对象(如 {})会在每次渲染生成新引用并触发 effect 双跑,
// 测试中必须将 left/right/options 提升为稳定引用(生产中 options 为 state,天然稳定)
const NO_OPTIONS: DiffOptions = {}
const CHAR_ON: DiffOptions = { charMode: true }
const CHAR_OFF: DiffOptions = { charMode: false }
beforeEach(() => {
vi.useFakeTimers()
@@ -71,6 +78,90 @@ describe('useDiff - 快路径', () => {
})
})
describe('useDiff - 字符级大字符量重路径', () => {
it('少行大字符量输入(charMode 开启)进入重路径后台计算', () => {
const factory = makeFactory()
// 2 行 × 约 3 万字符 ≈ 6 万字符:行数远低于快路径阈值,但字符量超过 CHAR_HEAVY_INPUT_CHARS5000
const big = 'a'.repeat(30000) + '\n' + 'b'.repeat(30000)
const { result } = renderHook(() => useDiff(big, big, CHAR_ON, factory))
expect(result.current.computing).toBe(true)
expect(result.current.diff).toBeNull()
// 去抖期内尚未创建 worker
expect(factory.workers).toHaveLength(0)
act(() => {
vi.advanceTimersByTime(250)
})
expect(factory.workers).toHaveLength(1)
const w = factory.workers[0]
const res = computeDiff(big, big, CHAR_ON)
act(() => {
w.respond(w.posted[0].jobId, res)
})
expect(result.current.diff).toEqual(res)
expect(result.current.computing).toBe(false)
})
it('charMode 开启但字符量未超阈值仍走快路径', () => {
const factory = vi.fn(makeFactory())
const { result } = renderHook(() => useDiff('ab\ncd', 'ab\ncd', CHAR_ON, factory))
expect(result.current.computing).toBe(false)
expect(result.current.diff).not.toBeNull()
expect(result.current.diff!.summary.changedLines).toBe(0)
expect(factory).not.toHaveBeenCalled()
})
it('charMode 关闭时大字符量输入不触发重路径(行数维度判定不变)', () => {
const factory = vi.fn(makeFactory())
const big = 'a'.repeat(60000)
const { result } = renderHook(() => useDiff(big, big, CHAR_OFF, factory))
expect(result.current.computing).toBe(false)
expect(result.current.diff).not.toBeNull()
expect(factory).not.toHaveBeenCalled()
})
it('恰好阈值不触发重路径,超过阈值触发', () => {
const factory = makeFactory()
// 两侧各 CHAR_HEAVY_INPUT_CHARS / 2 = 恰好 5000:不触发
const at = 'a'.repeat(CHAR_HEAVY_INPUT_CHARS / 2)
const hook1 = renderHook(() => useDiff(at, at, CHAR_ON, factory))
expect(hook1.result.current.computing).toBe(false)
// 一侧超出 1 字符(5001 + 5000):触发
const over = 'a'.repeat(CHAR_HEAVY_INPUT_CHARS / 2 + 1)
const hook2 = renderHook(() => useDiff(over, at, CHAR_ON, factory))
expect(hook2.result.current.computing).toBe(true)
})
it('快路径后开启字符级进入重路径:首算期间用快路径结果兜底(容器高度不塌陷)', () => {
const factory = makeFactory()
// 先以快路径(charMode 关)完成一次计算:结果被记录
const left = 'x'.repeat(4000) + '\nmore'
const right = 'x'.repeat(4000) + '\ntext'
const hook = renderHook(
({ options }: { options: DiffOptions }) => useDiff(left, right, options, factory),
{ initialProps: { options: { charMode: false } } }
)
expect(hook.result.current.computing).toBe(false)
const fast = hook.result.current.diff
expect(fast).not.toBeNull()
// 开字符级(字符量 > 5000 触发重路径):首算期间 asyncDiff 为 null
// 但文本未变 → 快路径结果兜底展示(滚动容器高度稳定,不回弹到顶)
hook.rerender({ options: { charMode: true } })
expect(hook.result.current.computing).toBe(true)
expect(hook.result.current.diff).toEqual(fast)
// worker 完成后替换为字符级结果
act(() => {
vi.advanceTimersByTime(250)
})
const w = factory.workers[0]
const res = computeDiff(left, right, { charMode: true })
act(() => {
w.respond(w.posted[0].jobId, res)
})
expect(hook.result.current.diff).toEqual(res)
expect(hook.result.current.computing).toBe(false)
})
})
describe('useDiff - worker 后台计算', () => {
it('重输入先进入 computing,去抖到期才创建 worker 并派发,回传后更新', () => {
const factory = makeFactory()
@@ -212,7 +303,7 @@ describe('useDiff - worker 后台计算', () => {
expect(result.current.computing).toBe(false)
})
it('迟到错误(输入已变化)只终止不回填,新任务结果正常生效', () => {
it('迟到错误(输入已变化)只终止不回填,新任务结果正常生效', async () => {
const factory = makeFactory()
const hook = renderHook((p: string) => useDiff(p, 'x', NO_OPTIONS, factory), {
initialProps: heavyText(2100)
@@ -241,4 +332,61 @@ describe('useDiff - worker 后台计算', () => {
expect(hook.result.current.diff).toEqual(res)
expect(hook.result.current.computing).toBe(false)
})
it('仅选项变化时保留旧结果撑住容器高度(防滚动塌陷回弹),新结果到达后替换', () => {
const factory = makeFactory()
const left = heavyText(2100)
const right = heavyText(2100)
const hook = renderHook(
({ options }: { options: DiffOptions }) => useDiff(left, right, options, factory),
{ initialProps: { options: { ignoreCase: false } } }
)
// 首次计算完成(首算无旧结果,computing 期间 diff 为 null
act(() => {
vi.advanceTimersByTime(250)
})
const w1 = factory.workers[0]
const res1 = computeDiff('a', 'b', {})
act(() => {
w1.respond(w1.posted[0].jobId, res1)
})
expect(hook.result.current.diff).toEqual(res1)
expect(hook.result.current.computing).toBe(false)
// 仅切换选项(文本不变):旧结果保留(虚拟滚动容器高度不塌陷)+ computing 开始
hook.rerender({ options: { ignoreCase: true } })
expect(hook.result.current.computing).toBe(true)
expect(hook.result.current.diff).toEqual(res1)
// 新结果到达后替换
act(() => {
vi.advanceTimersByTime(250)
})
const w2 = factory.workers[1]
const res2 = computeDiff('p', 'q', { ignoreCase: true })
act(() => {
w2.respond(w2.posted[0].jobId, res2)
})
expect(hook.result.current.diff).toEqual(res2)
expect(hook.result.current.computing).toBe(false)
})
it('文本变化时清空旧结果(防止展示与新面板不对应的过期差异)', () => {
const factory = makeFactory()
const hook = renderHook(
(p: string) => useDiff(p, 'x', NO_OPTIONS, factory),
{ initialProps: heavyText(2100) }
)
act(() => {
vi.advanceTimersByTime(250)
})
const w1 = factory.workers[0]
const res1 = computeDiff('a', 'b', {})
act(() => {
w1.respond(w1.posted[0].jobId, res1)
})
expect(hook.result.current.diff).toEqual(res1)
// 文本变化:旧结果立即清空
hook.rerender(heavyText(2300))
expect(hook.result.current.diff).toBeNull()
expect(hook.result.current.computing).toBe(true)
})
})
+46 -8
View File
@@ -1,5 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { computeDiff, type DiffOptions, type DiffResult } from '../diff/diffEngine'
import {
computeDiff,
CHAR_HEAVY_INPUT_CHARS,
type DiffOptions,
type DiffResult
} from '../diff/diffEngine'
import { createDiffWorker, type DiffWorkerFactory } from '../diff/createDiffWorker'
import type { DiffWorkerResponse } from '../diff/diffWorker'
import { countLines } from '../diff/textUtils'
@@ -31,25 +36,48 @@ export function useDiff(
workerFactory?: DiffWorkerFactory
): DiffState {
const heavy = useMemo(
() => countLines(left) + countLines(right) > FAST_PATH_MAX_LINES,
[left, right]
() =>
countLines(left) + countLines(right) > FAST_PATH_MAX_LINES ||
// 字符级对比的耗时由字符量×编辑距离决定:少行大字符量输入(压缩 JSON / base64 单行)
// 行数维度防不住,需按字符量兜底走 worker 后台计算
(options.charMode === true && left.length + right.length > CHAR_HEAVY_INPUT_CHARS),
[left, right, options]
)
const factory = workerFactory ?? createDiffWorker
// 快路径:同步计算,渲染当帧即得结果
// 最近一次计算成功的结果与其输入文本:重路径计算期间用它撑住虚拟滚动容器高度
// (快路径结果与 worker 结果都写入;文本一致时作为重路径的兜底展示)
const lastResultRef = useRef<{ left: string; right: string; result: DiffResult } | null>(null)
// 快路径:同步计算,渲染当帧即得结果(并记录到 lastResultRef
const fastDiff = useMemo(() => {
if (!heavy) return computeDiff(left, right, options)
if (!heavy) {
const r = computeDiff(left, right, options)
lastResultRef.current = { left, right, result: r }
return r
}
return null
}, [left, right, options, heavy])
const [asyncDiff, setAsyncDiff] = useState<DiffResult | null>(null)
const [computing, setComputing] = useState(false)
const jobIdRef = useRef(0)
// 上一次重路径派发时的输入文本:区分「文本变化」与「仅选项变化」(见 effect 内说明)
const lastInputsRef = useRef<{ left: string; right: string } | null>(null)
useEffect(() => {
if (!heavy) return
// 重输入先清空旧结果:避免交换左右等场景下展示与新面板不对应的过期差异
setAsyncDiff(null)
// 重路径计算期间保留旧结果撑住虚拟滚动容器高度:items 清空会使容器高度塌陷为 0,
// 浏览器把 scrollTop 钳制归 0(计算完成后也不恢复)——切换选项/开启字符级触发重算时
// 视图回弹到顶部(0.6.2 及之前版本的实际缺陷,用户实测“开字符级后向下滚动回弹”)。
// 旧结果来源:worker 完成的 asyncDiff 与快路径结果(首算时 asyncDiff 尚为 null)。
// 文本变化(打开新文件/交换左右)不兜底:避免展示与新面板不对应的过期差异,
// 此时滚动位置随内容重建归位属预期行为。
const textChanged =
lastInputsRef.current !== null &&
(lastInputsRef.current.left !== left || lastInputsRef.current.right !== right)
if (textChanged) setAsyncDiff(null)
lastInputsRef.current = { left, right }
setComputing(true)
// effect 世代标志:cleanup 后置位,迟到回调(含去抖期内输入已变、jobId 尚未推进的窗口)一律丢弃
let cancelled = false
@@ -68,6 +96,7 @@ export function useDiff(
w.onmessage = (e: MessageEvent<DiffWorkerResponse>): void => {
// 丢弃过期任务:输入已变化时旧计算结果不再生效
if (e.data.jobId !== jobId || cancelled) return
lastResultRef.current = { left, right, result: e.data.result }
setAsyncDiff(e.data.result)
setComputing(false)
}
@@ -88,5 +117,14 @@ export function useDiff(
}
}, [left, right, options, heavy, factory])
return heavy ? { diff: asyncDiff, computing } : { diff: fastDiff, computing: false }
// 重路径输出:优先 worker 结果;计算中且文本未变时用最近一次结果兜底(撑住容器高度,
// 防 scrollTop 塌陷回弹;文本已变化时不兜底,避免过期差异误导)
if (heavy) {
if (asyncDiff !== null) return { diff: asyncDiff, computing }
const last = lastResultRef.current
const fallback =
last !== null && last.left === left && last.right === right ? last.result : null
return { diff: fallback, computing }
}
return { diff: fastDiff, computing: false }
}
+4 -1
View File
@@ -1,10 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import ErrorBoundary from './components/ErrorBoundary'
import './styles/global.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
)
+698 -18
View File
@@ -3,7 +3,6 @@
--bg: #0a0e17;
--bg-deep: #070b12;
--panel: #0e1420;
--panel-2: #101828;
--surface: rgba(148, 163, 255, 0.06);
--surface-hover: rgba(124, 108, 255, 0.14);
--border: rgba(148, 163, 255, 0.14);
@@ -13,7 +12,6 @@
--muted: #74809a;
--accent: #8b7cff;
--accent-2: #22d3ee;
--pink: #f472b6;
/* diff 语义色 */
--add-bg: rgba(34, 197, 94, 0.13);
@@ -265,14 +263,6 @@ button:disabled {
min-height: 0;
}
.diff-empty-pane {
flex: 1;
display: grid;
place-items: center;
color: var(--muted);
border-left: 1px solid var(--border);
}
.pane-mid {
display: flex;
flex-direction: column;
@@ -305,12 +295,14 @@ button:disabled {
background: rgba(0, 0, 0, 0.12);
}
/* 行号列 sticky 悬浮于内容之上背景必须不透明否则横向滚动时行文字会
透过半透明行号列叠印等效合成值 = 原半透明叠加预先合成到面板底色 #0a0e17 */
.ln {
flex: 0 0 46px;
text-align: right;
padding-right: 8px;
color: #4d5a74;
background: rgba(124, 108, 255, 0.05);
background: #101323;
border-right: 1px solid var(--border);
position: sticky;
left: 0;
@@ -332,7 +324,7 @@ button:disabled {
}
.diff-row.modified .ln {
color: var(--mod-fg);
background: rgba(245, 158, 11, 0.16);
background: #453314;
border-right-color: rgba(245, 158, 11, 0.3);
}
.diff-row.added {
@@ -340,7 +332,7 @@ button:disabled {
}
.diff-row.added .ln {
color: var(--add-fg);
background: rgba(34, 197, 94, 0.14);
background: #103c29;
border-right-color: rgba(34, 197, 94, 0.3);
}
.diff-row.removed {
@@ -348,7 +340,7 @@ button:disabled {
}
.diff-row.removed .ln {
color: var(--del-fg);
background: rgba(248, 113, 113, 0.13);
background: #42252c;
border-right-color: rgba(248, 113, 113, 0.3);
}
@@ -384,7 +376,7 @@ button:disabled {
background: rgba(148, 163, 255, 0.04);
}
.diff-row.fold .ln {
background: rgba(148, 163, 255, 0.03);
background: #141927;
}
.fold-note {
color: var(--muted);
@@ -465,6 +457,35 @@ button:disabled {
transform: rotate(45deg);
}
/* 被字符级对比包含的选项:整行灰显 + 不可点光标(勾选仅为视觉呈现,语义已随字符级生效) */
.switch.disabled {
opacity: 0.55;
cursor: not-allowed;
}
.switch.disabled input {
cursor: not-allowed;
}
/* 选项状态徽标:已包含 / 已降级 */
.opt-badge {
display: inline-block;
font-size: 10px;
line-height: 1.5;
padding: 0 6px;
border-radius: 999px;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--border);
letter-spacing: 0.5px;
user-select: none;
white-space: nowrap;
}
.opt-badge.warn {
color: var(--mod-fg);
background: var(--mod-bg);
border-color: rgba(245, 158, 11, 0.3);
}
/* 导航按钮 */
.nav-btn {
width: 28px;
@@ -491,6 +512,16 @@ button:disabled {
text-align: center;
}
/* header 内的差异文件导航(从文件夹进入单文件对比时出现) */
.diff-file-nav {
padding: 0 2px;
}
.diff-file-label {
font-size: 11px;
color: var(--muted);
letter-spacing: 1px;
}
/* 统计徽章 */
.stats {
display: flex;
@@ -599,12 +630,152 @@ button:disabled {
bottom: calc(100% + 6px);
}
.toast {
/* ============ 扩展名过滤下拉(folder-toolbar 内,向下弹出) ============ */
.export-menu.ext-menu {
min-width: 190px;
max-height: 320px;
overflow: auto;
}
/* ============ 忽略规则下拉(每行一条的文本域 + 操作按钮) ============ */
.export-menu.ignore-menu {
min-width: 300px;
padding: 8px;
}
.ignore-input {
display: block;
width: 100%;
min-height: 130px;
padding: 8px 10px;
margin-bottom: 6px;
border-radius: 8px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
color: var(--text);
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
outline: none;
resize: vertical;
}
.ignore-input:focus {
border-color: var(--accent);
}
.ignore-input::placeholder {
color: var(--muted);
}
/* 忽略规则下拉内的无效规则提示(琥珀警示,与截断警示同语言) */
.ignore-invalid {
font-size: 11.5px;
line-height: 1.5;
padding: 6px 8px;
margin-bottom: 6px;
border-radius: 6px;
color: var(--mod-fg);
background: var(--mod-bg);
border: 1px solid rgba(245, 158, 11, 0.3);
word-break: break-all;
}
/* ============ 快照对照(变迁统计徽章与条目标注) ============ */
.fstat.snap-fixed {
color: var(--add-fg);
border-color: rgba(34, 197, 94, 0.3);
}
.fstat.snap-regressed {
color: var(--del-fg);
border-color: rgba(248, 113, 113, 0.3);
}
.fstat.snap-new {
color: var(--add-fg);
}
.fstat.snap-gone {
color: var(--muted);
}
.fstat.snap-changed {
color: var(--mod-fg);
border-color: rgba(245, 158, 11, 0.3);
}
.fstat.snap-stable {
color: var(--muted);
font-weight: 500;
}
/* 条目行内的变迁徽章 */
.snap-badge {
flex: none;
font-size: 10.5px;
padding: 0 7px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
font-weight: 500;
white-space: nowrap;
}
.snap-badge.snap-b-fixed {
color: var(--add-fg);
}
.snap-badge.snap-b-regressed {
color: var(--del-fg);
}
.snap-badge.snap-b-new {
color: var(--add-fg);
}
.snap-badge.snap-b-gone {
color: var(--muted);
}
.snap-badge.snap-b-changed {
color: var(--mod-fg);
}
.snap-badge.snap-b-stable {
color: var(--muted);
}
/* 激活(已选入过滤集合)的扩展名项:与状态徽章激活态同语言 */
.ctx-item.active {
color: #fff;
background: var(--surface-hover);
}
/* 扩展名项内右侧的计数(等宽数字,弱化) */
.ctx-item .ext-count {
float: right;
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.ctx-item.ext-clear {
color: var(--accent-2);
border-top: 1px solid var(--border);
border-radius: 0 0 6px 6px;
margin-top: 3px;
}
/* 过滤激活时的入口按钮强调(与常规 ghost 区分,提示过滤在生效) */
.btn.ghost.filtered {
color: var(--accent-2);
border-color: rgba(34, 211, 238, 0.4);
background: rgba(34, 211, 238, 0.07);
}
/* ============ toast 队列(最多 3 条堆叠,各自独立计时) ============ */
.toast-wrap {
position: fixed;
left: 50%;
bottom: 46px;
transform: translateX(-50%);
z-index: 1100;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
max-width: 72vw;
pointer-events: none;
}
.toast {
max-width: 72vw;
padding: 9px 16px;
border-radius: 10px;
@@ -616,16 +787,17 @@ button:disabled {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: auto;
animation: toast-in 0.2s ease;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translate(-50%, 8px);
transform: translateY(8px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
transform: translateY(0);
}
}
@@ -701,6 +873,126 @@ button:disabled {
border-top: 1px solid var(--border);
}
/* ============ 关于弹框 ============ */
/* header 右侧的关于图标按钮(紧凑正方形) */
.about-btn {
padding: 5px 7px;
}
.about-modal {
position: relative;
width: min(480px, 90vw);
background: rgba(16, 22, 34, 0.98);
border: 1px solid var(--border-strong);
border-radius: 14px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 30px rgba(124, 108, 255, 0.2);
overflow: hidden;
z-index: 1001;
padding: 26px 28px 18px;
text-align: center;
}
.about-close {
position: absolute;
top: 10px;
right: 12px;
width: 26px;
height: 26px;
border-radius: 7px;
display: grid;
place-items: center;
font-size: 13px;
color: var(--muted);
transition: all 0.15s ease;
}
.about-close:hover {
color: var(--text);
background: var(--surface-hover);
}
.about-logo {
width: 62px;
height: 62px;
margin: 0 auto 10px;
border-radius: 18px;
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: 0 0 24px rgba(124, 108, 255, 0.55);
}
.about-name {
font-size: 20px;
font-weight: 700;
letter-spacing: 1px;
background: linear-gradient(90deg, #c9bfff, var(--accent-2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.about-version {
margin-top: 3px;
font-size: 12px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.about-desc {
margin: 14px 4px 16px;
font-size: 12.5px;
line-height: 1.7;
color: var(--text);
text-align: left;
}
.about-rows {
display: flex;
flex-direction: column;
gap: 7px;
padding: 12px 14px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--border);
text-align: left;
}
.about-row {
display: flex;
align-items: center;
gap: 12px;
min-height: 24px;
}
.about-k {
flex: 0 0 64px;
font-size: 11px;
color: var(--muted);
letter-spacing: 1px;
}
.about-vs {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.about-v {
padding: 2px 8px;
margin-left: -8px;
border-radius: 6px;
font-family: var(--mono);
font-size: 12px;
color: var(--accent-2);
text-align: left;
word-break: break-all;
transition: background 0.13s ease;
}
button.about-v:hover {
background: var(--surface-hover);
}
.about-v.plain {
color: var(--text);
cursor: default;
}
.about-foot {
margin-top: 14px;
font-size: 11px;
color: var(--muted);
letter-spacing: 1px;
}
/* ============ 状态栏 ============ */
.status-bar {
flex: 0 0 auto;
@@ -752,6 +1044,365 @@ button:disabled {
background: rgba(34, 211, 238, 0.12);
}
/* ============ 文件夹对比视图 ============ */
.folder-view {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
}
.folder-head {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 18px 6px;
}
.folder-card {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--border);
}
.folder-path {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 操作区横排0.10.5 布局重构此前 6 按钮竖排把头部撑高约 200px
小窗口下列表区被严重压缩快照归组下拉后横排单行约 40px */
.folder-actions {
flex: none;
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
}
.folder-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
padding: 6px 18px 8px;
}
.folder-stats {
display: flex;
gap: 10px;
}
.fstat {
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
padding: 3px 10px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
white-space: nowrap;
}
.fstat.muted {
color: var(--muted);
font-weight: 500;
}
.fstat.warn {
color: var(--mod-fg);
background: var(--mod-bg);
border-color: rgba(245, 158, 11, 0.3);
font-weight: 500;
}
/* 徽章可点击(状态精确过滤):激活态高亮 */
button.fstat {
cursor: pointer;
font-family: inherit;
transition: all 0.15s ease;
}
button.fstat:hover {
border-color: var(--border-strong);
}
button.fstat.active {
color: #fff;
background: var(--surface-hover);
border-color: var(--accent);
box-shadow: 0 0 10px rgba(124, 108, 255, 0.25);
}
/* 文件名搜索框 */
.folder-search {
width: 180px;
padding: 5px 12px;
border-radius: 8px;
font-size: 12px;
font-family: var(--mono);
color: var(--text);
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
outline: none;
transition: border-color 0.15s ease;
}
.folder-search:focus {
border-color: var(--accent);
}
.folder-search::placeholder {
color: var(--muted);
}
/* ============ 单文件视图搜索(Ctrl+F ============ */
/* 输入框与文件夹搜索同款视觉语言 */
.diff-search {
width: 170px;
padding: 5px 24px 5px 12px;
border-radius: 8px;
font-size: 12px;
font-family: var(--mono);
color: var(--text);
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
outline: none;
transition: border-color 0.15s ease;
}
.diff-search:focus {
border-color: var(--accent);
}
.diff-search::placeholder {
color: var(--muted);
}
/* ============ 搜索框清空按钮(两个搜索框共用) ============ */
/* 包一层相对定位容器;有内容时显示 × 一键清空(与 Esc 清空同语义的鼠标流入口) */
.search-box {
position: relative;
display: inline-flex;
align-items: center;
}
.search-box .diff-search,
.search-box .folder-search {
padding-right: 24px;
}
.search-clear {
position: absolute;
right: 4px;
width: 16px;
height: 16px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 12px;
line-height: 1;
color: var(--muted);
background: rgba(148, 163, 255, 0.12);
transition: all 0.13s ease;
}
.search-clear:hover {
color: var(--text);
background: var(--surface-hover);
}
/* 字符粒度增删统计徽章(字符级模式追加,青色系与行级徽章区分) */
.badge.chars {
color: var(--accent-2);
background: rgba(34, 211, 238, 0.10);
border: 1px solid rgba(34, 211, 238, 0.22);
font-weight: 600;
}
/* 命中计数(x / y,等宽数字;未搜索时为空不占宽) */
.search-count {
font-size: 12px;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-width: 46px;
text-align: center;
}
/* 行内命中子串高亮(黄色系,与增删改三色及选中青色不冲突) */
.search-hit {
background: rgba(250, 204, 21, 0.30);
border-radius: 2px;
color: #fde68a;
}
.folder-scroll {
flex: 1;
overflow: auto;
position: relative;
border-top: 1px solid var(--border);
}
.folder-row {
display: flex;
align-items: center;
gap: 10px;
height: 26px;
line-height: 26px;
font-family: var(--mono);
font-size: 12.5px;
padding: 0 18px;
cursor: default;
user-select: none;
}
.folder-row:hover {
background: var(--surface-hover);
}
.folder-st-icon {
flex: none;
width: 16px;
text-align: center;
font-size: 10px;
}
.folder-row.st-same .folder-st-icon {
color: var(--add-fg);
}
/* 语义同:字节不同但剔除空白后一致(青色弱化,介于相同与不同之间) */
.folder-row.st-sem .folder-st-icon {
color: var(--accent-2);
}
.folder-row.st-sem {
background: rgba(34, 211, 238, 0.07);
}
.fstat.st-sem {
color: var(--accent-2);
}
/* 无法读取:文件被占用/权限不足(琥珀警示) */
.folder-row.st-unread .folder-st-icon {
color: var(--mod-fg);
}
.folder-row.st-unread {
background: rgba(245, 158, 11, 0.07);
}
.fstat.st-unread {
color: var(--mod-fg);
}
.folder-agg.st-unread {
color: var(--mod-fg);
}
.folder-row.st-diff .folder-st-icon {
color: var(--del-fg);
}
.folder-row.st-diff {
background: var(--del-bg);
}
.folder-row.st-left .folder-st-icon {
color: var(--accent);
}
.folder-row.st-right .folder-st-icon {
color: var(--accent-2);
}
.folder-rel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-approx {
flex: none;
color: var(--mod-fg);
font-size: 13px;
cursor: help;
}
/* 目录行(树形):折叠箭头 + 名称 + 子树聚合状态 + 计数 */
.folder-row.dir {
cursor: pointer;
background: rgba(148, 163, 255, 0.04);
font-weight: 600;
}
.folder-caret {
flex: none;
width: 16px;
font-size: 10px;
color: var(--muted);
text-align: center;
}
.folder-row.dir .folder-rel {
font-weight: 700;
}
.folder-agg {
flex: none;
font-size: 10.5px;
padding: 0 7px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
font-weight: 500;
}
.folder-agg.st-same {
color: var(--add-fg);
}
.folder-agg.st-sem {
color: var(--accent-2);
}
.folder-agg.st-diff {
color: var(--del-fg);
}
.folder-agg.st-left,
.folder-agg.st-right {
color: var(--accent);
}
.folder-dircount {
flex: none;
font-size: 11px;
color: var(--muted);
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.folder-size {
flex: none;
width: 76px;
text-align: right;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.folder-empty-note {
padding: 40px 0;
text-align: center;
color: var(--muted);
font-size: 13px;
letter-spacing: 1px;
}
/* ============ 后台计算遮罩 ============ */
.computing-overlay {
position: absolute;
@@ -783,3 +1434,32 @@ button:disabled {
transform: rotate(360deg);
}
}
/* ============ 顶层渲染错误兜底面板(ErrorBoundary ============ */
.error-boundary {
height: 100%;
display: grid;
place-content: center;
justify-items: center;
gap: 14px;
padding: 30px;
text-align: center;
}
.error-boundary h2 {
color: var(--mod-fg);
font-size: 16px;
letter-spacing: 1px;
}
.error-boundary .error-message {
max-width: 70vw;
padding: 10px 14px;
border-radius: 10px;
background: var(--del-bg);
border: 1px solid rgba(248, 113, 113, 0.3);
color: var(--del-fg);
font-family: var(--mono);
font-size: 12.5px;
word-break: break-all;
/* 错误信息需可复制上报(应用全局 user-select: none 的例外) */
user-select: text;
}
+317
View File
@@ -0,0 +1,317 @@
import { describe, it, expect } from 'vitest'
import {
compileIgnoreRules,
isIgnoreRulesArray,
IGNORE_RULES_MAX,
IGNORE_RULE_MAX_LEN
} from './ignoreRules'
/** 便捷断言:路径是否被忽略(isDir 缺省 false */
function ignored(rules: string[], rel: string, isDir = false): boolean {
return compileIgnoreRules(rules).matchesPath(rel, isDir)
}
describe('compileIgnoreRules - 名字与目录规则(既有语义回归)', () => {
it('单段名字匹配:任意层级命中文件与目录', () => {
expect(ignored(['temp'], 'temp')).toBe(true)
expect(ignored(['temp'], 'a/temp')).toBe(true)
expect(ignored(['temp'], 'a/b/temp', true)).toBe(true)
expect(ignored(['temp'], 'temp.txt')).toBe(false)
expect(ignored(['temp'], 'temporary')).toBe(false)
})
it('尾 / 目录专用:文件不命中', () => {
expect(ignored(['node_modules/'], 'node_modules', true)).toBe(true)
expect(ignored(['node_modules/'], 'node_modules')).toBe(false)
expect(ignored(['node_modules/'], 'pkg/node_modules', true)).toBe(true)
expect(ignored(['node_modules/'], 'a.txt')).toBe(false)
})
it('大小写不敏感(双侧)', () => {
expect(ignored(['NODE_MODULES/'], 'node_modules', true)).toBe(true)
expect(ignored(['node_modules/'], 'Node_Modules', true)).toBe(true)
expect(ignored(['*.log'], 'a.LOG')).toBe(true)
})
it('扩展名通配 *.ext:任意层级后缀命中(段内 * 可匹配零字符,对齐 gitignore', () => {
expect(ignored(['*.log'], 'app.log')).toBe(true)
expect(ignored(['*.log'], 'sub/app.log')).toBe(true)
expect(ignored(['*.log'], 'app.log.txt')).toBe(false)
expect(ignored(['*.log'], '.log')).toBe(true)
// 非目录专用的通配规则同样可命中目录名
expect(ignored(['*.log'], 'dump.log', true)).toBe(true)
})
it('目录专用通配(*.log/)仅匹配以 .log 结尾的目录名', () => {
expect(ignored(['*.log/'], 'debug.log', true)).toBe(true)
expect(ignored(['*.log/'], 'logs', true)).toBe(false)
expect(ignored(['*.log/'], 'debug.log')).toBe(false)
})
it('多条规则任一命中即可', () => {
const m = compileIgnoreRules(['temp', 'dist/', '*.tmp'])
expect(m.matchesPath('dist', true)).toBe(true)
expect(m.matchesPath('a.tmp')).toBe(true)
expect(m.matchesPath('temp')).toBe(true)
expect(m.matchesPath('keep.txt')).toBe(false)
})
})
describe('compileIgnoreRules - 多段路径与前导 / 锚定', () => {
it('多段路径从根锚定匹配(模式精确匹配路径;子树忽略由目录剪枝实现,与 gitignore 一致)', () => {
expect(ignored(['src/temp'], 'src/temp')).toBe(true)
expect(ignored(['src/temp'], 'src/temp', true)).toBe(true) // 目录命中 → BFS 整树剪枝
expect(ignored(['src/temp'], 'src/temp/x.ts')).toBe(false) // 模式不匹配子路径(剪枝后不会被查询)
expect(ignored(['src/temp'], 'src/deep/temp')).toBe(false) // 中间层不命中
expect(ignored(['src/temp'], 'lib/temp')).toBe(false) // 其他分支不命中
})
it('多段路径尾 / 目录专用', () => {
expect(ignored(['src/temp/'], 'src/temp', true)).toBe(true)
expect(ignored(['src/temp/'], 'src/temp')).toBe(false)
})
it('前导 / 锚定:仅根级匹配(无前导 / 的单段名为任意层级)', () => {
expect(ignored(['/build'], 'build', true)).toBe(true)
expect(ignored(['/build'], 'build')).toBe(true) // 无尾 / 也匹配文件
expect(ignored(['/build'], 'build/out/x.js')).toBe(false) // 子树由目录剪枝实现
expect(ignored(['/build'], 'packages/app/build', true)).toBe(false)
expect(ignored(['build'], 'packages/app/build', true)).toBe(true)
})
})
describe('compileIgnoreRules - ** 跨段通配', () => {
it('build、**、cache 三段模式:build 下任意深度(含零段)的 cache', () => {
expect(ignored(['build/**/cache'], 'build/cache', true)).toBe(true)
expect(ignored(['build/**/cache'], 'build/a/cache', true)).toBe(true)
expect(ignored(['build/**/cache'], 'build/a/b/cache', true)).toBe(true)
expect(ignored(['build/**/cache'], 'build/a/b/cache/x.js')).toBe(false) // 子树由 cache 目录剪枝实现
expect(ignored(['build/**/cache'], 'dist/a/cache', true)).toBe(false)
expect(ignored(['build/**/cache'], 'build/a/temp', true)).toBe(false)
})
it('尾部 ** 需至少一段:a/** 不匹配 a 自身(对齐 gitignore', () => {
expect(ignored(['a/**'], 'a')).toBe(false)
expect(ignored(['a/**'], 'a', true)).toBe(false)
expect(ignored(['a/**'], 'a/b')).toBe(true)
expect(ignored(['a/**'], 'a/b/c')).toBe(true)
expect(ignored(['a/**'], 'x/a/b')).toBe(false) // 多段锚定根
})
it('前缀 **/:显式写法与单段名等价(任意层级)', () => {
expect(ignored(['**/temp'], 'temp', true)).toBe(true)
expect(ignored(['**/temp'], 'a/b/temp', true)).toBe(true)
expect(ignored(['**/temp'], 'a/temptation')).toBe(false)
})
it('** 与段内通配组合', () => {
expect(ignored(['**/*.min.js'], 'app.min.js')).toBe(true)
expect(ignored(['**/*.min.js'], 'dist/app.min.js')).toBe(true)
expect(ignored(['**/*.min.js'], 'app.js')).toBe(false)
})
})
describe('compileIgnoreRules - 段内 * 通配', () => {
it('* 位置不限,可匹配零字符(glob 惯例)', () => {
expect(ignored(['a*b'], 'ab')).toBe(true)
expect(ignored(['a*b'], 'axb')).toBe(true)
expect(ignored(['a*b'], 'a-very-long-b')).toBe(true)
expect(ignored(['a*b'], 'a/b')).toBe(false) // 不跨段
expect(ignored(['*cache'], 'my-cache')).toBe(true)
expect(ignored(['temp*'], 'temporary')).toBe(true)
})
it('段内多个 * 组合', () => {
expect(ignored(['a*c*e'], 'abcde')).toBe(true)
expect(ignored(['a*c*e'], 'ace')).toBe(true) // 段内 * 可匹配零字符
expect(ignored(['a*c*e'], 'abcd')).toBe(false)
})
})
describe('compileIgnoreRules - 段内 ? 单字符与字符类(0.10.1', () => {
it('? 匹配恰好一个任意字符(不足/超出均不命中)', () => {
expect(ignored(['lo?g'], 'loog')).toBe(true)
expect(ignored(['lo?g'], 'loXg')).toBe(true)
expect(ignored(['lo?g'], 'log')).toBe(false)
expect(ignored(['lo?g'], 'looog')).toBe(false)
expect(ignored(['?.txt'], 'a.txt')).toBe(true)
expect(ignored(['?.txt'], 'ab.txt')).toBe(false)
})
it('? 不跨段(单段模式只与整段匹配)', () => {
// 单段规则按路径段整体匹配:'a?c' 不命中 'a/c'(两段)
expect(ignored(['a?c'], 'a/c')).toBe(false)
expect(ignored(['src/??/temp'], 'src/ab/temp', true)).toBe(true)
expect(ignored(['src/??/temp'], 'src/a/temp', true)).toBe(false)
})
it('? 与 * 组合', () => {
expect(ignored(['*.?s'], 'a.ts')).toBe(true)
expect(ignored(['*.?s'], 'a.tsx')).toBe(false)
expect(ignored(['a?*c'], 'abxc')).toBe(true)
})
it('[abc] 字符类命中类内单字符', () => {
expect(ignored(['*.[ch]'], 'main.c')).toBe(true)
expect(ignored(['*.[ch]'], 'main.h')).toBe(true)
expect(ignored(['*.[ch]'], 'main.o')).toBe(false)
expect(ignored(['*.[ch]'], 'main.cc')).toBe(false)
})
it('[a-z] 范围类', () => {
expect(ignored(['file[0-9].txt'], 'file5.txt')).toBe(true)
expect(ignored(['file[0-9].txt'], 'fileA.txt')).toBe(false)
expect(ignored(['[a-c]x'], 'bx')).toBe(true)
expect(ignored(['[a-c]x'], 'dx')).toBe(false)
})
it('[!abc] 取反类', () => {
expect(ignored(['[!a].txt'], 'b.txt')).toBe(true)
expect(ignored(['[!a].txt'], 'a.txt')).toBe(false)
expect(ignored(['[!0-9]x'], 'ax')).toBe(true)
expect(ignored(['[!0-9]x'], '5x')).toBe(false)
})
it('字符类大小写不敏感([A-Z] 等效 [a-z],双侧小写化后匹配)', () => {
expect(ignored(['[A-Z].txt'], 'g.txt')).toBe(true)
expect(ignored(['[A-Z].txt'], 'G.TXT')).toBe(true)
expect(ignored(['[A-Z].txt'], '5.txt')).toBe(false)
})
it('未闭合 [ 与孤立 [ 按字面量方括号处理(gitignore 同款)', () => {
expect(ignored(['a[b'], 'a[b')).toBe(true)
expect(ignored(['a[b'], 'aab')).toBe(false)
expect(ignored(['x['], 'x[')).toBe(true)
})
it('空类体 [] 与 [!] 退化为字面量方括号', () => {
expect(ignored(['a[]b'], 'a[]b')).toBe(true)
expect(ignored(['a[]b'], 'ab')).toBe(false)
expect(ignored(['a[!]b'], 'a[!]b')).toBe(true)
})
it('无法构成合法正则的类体(反转范围 [z-a])整段回退字面量匹配,不崩溃', () => {
expect(ignored(['[z-a].txt'], '[z-a].txt')).toBe(true)
expect(ignored(['[z-a].txt'], 'a.txt')).toBe(false)
})
it('字符通配与前导 / 锚定组合(仅根级匹配)', () => {
expect(ignored(['/build[0-9]'], 'build7')).toBe(true)
expect(ignored(['/build[0-9]'], 'build7/dist')).toBe(false)
expect(ignored(['/build[0-9]'], 'pkg/build7')).toBe(false)
})
it('既有 * 语义与段内 ** 非法判定不受影响(回归锚)', () => {
expect(ignored(['a*b'], 'axb')).toBe(true)
expect(ignored(['a*b'], 'ab')).toBe(true)
expect(ignored(['*.log'], 'app.log')).toBe(true)
expect(ignored(['a**b'], 'axxb')).toBe(false)
})
})
describe('compileIgnoreRules - 否定规则(! 最后匹配优先)', () => {
it('否定救回:同路径最后一条匹配规则生效', () => {
const m = compileIgnoreRules(['*.log', '!keep.log'])
expect(m.matchesPath('app.log')).toBe(true)
expect(m.matchesPath('keep.log')).toBe(false)
expect(m.matchesPath('sub/keep.log')).toBe(false) // *.log 与 !keep.log 均按路径命中
})
it('否定顺序反转:! 在前则被后规则重新忽略', () => {
expect(ignored(['!keep.log', '*.log'], 'keep.log')).toBe(true)
})
it('否定目录路径本身(父目录被剪枝时子项实际救不回,gitignore 同款语义)', () => {
const m = compileIgnoreRules(['temp/', '!temp/keep/'])
expect(m.matchesPath('temp', true)).toBe(true) // temp 目录被忽略 → BFS 剪枝,keep 实际救不回
expect(m.matchesPath('temp/keep', true)).toBe(false) // 匹配器层面不忽略(剪枝后不会被查询)
})
it('** + 否定:深层路径可被精确否定救回(父目录未被剪枝时)', () => {
const m = compileIgnoreRules(['build/**/temp/', '!build/special/temp/'])
expect(m.matchesPath('build/a/temp', true)).toBe(true)
expect(m.matchesPath('build/special/temp', true)).toBe(false)
expect(m.matchesPath('build/special/temp/x.js')).toBe(false)
})
it('父目录被剪枝后子项无法反否定(BFS 剪枝语义,与 gitignore 一致)', () => {
// node_modules/ 忽略 node_modules 目录本身 → BFS 剪枝,keep 永远不被枚举
// 匹配器层面体现为:node_modules/keep.js 不因 !node_modules/keep.js 而改变
// node_modules 目录的判定(剪枝依据)只受匹配 node_modules 路径的规则影响
const m = compileIgnoreRules(['node_modules/', '!node_modules/keep.js'])
expect(m.matchesPath('node_modules', true)).toBe(true) // 目录仍被剪枝
expect(m.matchesPath('node_modules/keep.js')).toBe(false) // 假如被枚举到则不忽略
})
it('否定 + 前导 / 锚定组合', () => {
const m = compileIgnoreRules(['/dist', '!/dist'])
expect(m.matchesPath('dist', true)).toBe(false)
})
})
describe('compileIgnoreRules - invalid 无效规则清单', () => {
it('注释与空白行不计入 invalid', () => {
const m = compileIgnoreRules(['', ' ', '# 构建产物', 'dist/'])
expect(m.invalid).toEqual([])
expect(m.matchesPath('dist', true)).toBe(true)
})
it('段内 ** 为非法(仅允许整段 **)', () => {
const m = compileIgnoreRules(['a**b', 'build/**'])
expect(m.invalid).toEqual(['a**b'])
expect(m.matchesPath('axxb')).toBe(false)
expect(m.matchesPath('build/x')).toBe(true)
})
it('空段(a//b、仅 /、! 后为空)非法', () => {
const m = compileIgnoreRules(['a//b', '/', '!', '!/', 'ok/'])
expect(m.invalid).toEqual(['a//b', '/', '!', '!/'])
expect(m.matchesPath('ok', true)).toBe(true)
})
it('规则首尾空白被 trimtrim 后仍非法的按原文 trim 记录)', () => {
const m = compileIgnoreRules([' node_modules/ ', ' a**b '])
expect(m.matchesPath('node_modules', true)).toBe(true)
expect(m.invalid).toEqual(['a**b'])
})
it('空数组与全非法输入产出永不匹配的匹配器', () => {
const empty = compileIgnoreRules([])
expect(empty.matchesPath('node_modules', true)).toBe(false)
expect(empty.matchesPath('a.log')).toBe(false)
expect(empty.invalid).toEqual([])
const allInvalid = compileIgnoreRules(['/', '!', 'a**b'])
expect(allInvalid.matchesPath('anything')).toBe(false)
expect(allInvalid.invalid).toEqual(['/', '!', 'a**b'])
})
it('超长单条规则(> 500 字符)被忽略并记入 invalid', () => {
const long = 'x'.repeat(IGNORE_RULE_MAX_LEN + 1)
const m = compileIgnoreRules([long])
expect(m.matchesPath(long)).toBe(false)
expect(m.invalid).toEqual([long])
})
it('条数超上限截断(第 51 条有效规则起不生效)', () => {
const rules = Array.from({ length: IGNORE_RULES_MAX + 5 }, (_, i) => `d${i}/`)
const m = compileIgnoreRules(rules)
expect(m.matchesPath('d0', true)).toBe(true)
expect(m.matchesPath(`d${IGNORE_RULES_MAX - 1}`, true)).toBe(true)
expect(m.matchesPath(`d${IGNORE_RULES_MAX}`, true)).toBe(false)
})
})
describe('isIgnoreRulesArray - IPC 防御性校验', () => {
it('合法字符串数组通过', () => {
expect(isIgnoreRulesArray([])).toBe(true)
expect(isIgnoreRulesArray(['node_modules/', '*.log', 'build/**/cache', '!keep.log'])).toBe(true)
})
it('非数组 / 含非字符串 / 超条数 / 超长条目拒绝', () => {
expect(isIgnoreRulesArray(null)).toBe(false)
expect(isIgnoreRulesArray('node_modules/')).toBe(false)
expect(isIgnoreRulesArray(['a', 42])).toBe(false)
expect(isIgnoreRulesArray(Array.from({ length: IGNORE_RULES_MAX + 1 }, () => 'a'))).toBe(false)
expect(isIgnoreRulesArray(['x'.repeat(IGNORE_RULE_MAX_LEN + 1)])).toBe(false)
})
})
+224
View File
@@ -0,0 +1,224 @@
/**
* electron/fs vitest
* src/shared folderScan FolderView
* preload
*
* gitignore 0.10.0 glob0.10.1
* - `name`
* - `name/`
* - `*.ext` / `a*b` `*` /
* - `a?c` / `lo?g` `?`
* - `*.[ch]` / `log[0-9].txt` / `[!abc]` `!`
* - `src/temp`
* - `/build` `/` `/`
* - `**` build**cache build
* cache `**` `a/**` a gitignore
* - `!pattern` gitignore
*
*
* - Windows
* - `#`
* - BFS gitignore
* - `**``!` invalid UI
*/
/** 规则条数上限(UI 与主进程 IPC 双重防御) */
export const IGNORE_RULES_MAX = 50
/** 单条规则长度上限 */
export const IGNORE_RULE_MAX_LEN = 500
/** 编译后的匹配器:对完整相对路径判定(含否定规则仲裁) */
export interface IgnoreMatcher {
/** 相对路径(统一 / 分隔,含文件/目录自身)是否被忽略;isDir 缺省按文件判定 */
matchesPath: (rel: string, isDir?: boolean) => boolean
/** 被跳过的无效规则原文(供 UI 提示;注释与空白行不计入) */
invalid: readonly string[]
}
/** 编译后的单段形态:字面量(=== 快路径)/ 段内通配(正则)/ 跨段通配 */
interface SegPattern {
kind: 'literal' | 'wild' | 'globstar'
/** literal 的字面值(已小写) */
lit?: string
/** wild 的整段正则(已锚定 ^$) */
re?: RegExp
}
interface CompiledRule {
/** 段序列(已编译;已小写) */
segs: SegPattern[]
/** 目录专用(尾部 / */
dirOnly: boolean
/** 否定规则(! 前缀) */
negate: boolean
/** 从根锚定(多段或前导 /);否则任意层级起匹配 */
anchored: boolean
}
/** 单个非通配字符的正则转义(段内通配构造共用;`*` `?` `[` 由调用方先行分流) */
const escCh = (c: string): string => (/[$()*+.?[\\\]^{|}]/.test(c) ? '\\' + c : c)
/**
* seg[start] `[abc]` / `[a-z]` / `[!abc]`
* `[]` / `[!]` null
* `[` gitignore
* `-` `^` JS
*/
function parseCharClass(seg: string, start: number): { re: string; next: number } | null {
let j = start + 1
let neg = false
if (seg[j] === '!' || seg[j] === '^') {
neg = true
j++
}
let body = ''
while (j < seg.length && seg[j] !== ']') {
body += seg[j] === '\\' ? '\\\\' : seg[j]
j++
}
if (j >= seg.length || body === '') return null
return { re: `[${neg ? '^' : ''}${body}]`, next: j + 1 }
}
/**
*
* `*` = 0 `?` = `[...]` =
* / [z-a]退
*/
function wildSegRegex(seg: string): string {
let out = ''
let i = 0
while (i < seg.length) {
const c = seg[i]
if (c === '*') {
out += '.*'
i++
} else if (c === '?') {
out += '.'
i++
} else if (c === '[') {
const cls = parseCharClass(seg, i)
if (cls === null) {
out += '\\['
i++
} else {
out += cls.re
i = cls.next
}
} else {
out += escCh(c)
i++
}
}
return out
}
/** 编译单条规则文本(调用方已做 trim);返回 null 表示无效或为注释/空白行 */
function compileOne(rule: string): CompiledRule | null {
if (rule.length === 0 || rule.length > IGNORE_RULE_MAX_LEN) return null
if (rule.startsWith('#')) return null
let body = rule
let negate = false
if (body.startsWith('!')) {
negate = true
body = body.slice(1)
}
let dirOnly = false
if (body.endsWith('/')) {
dirOnly = true
body = body.slice(0, -1)
}
let anchored = false
if (body.startsWith('/')) {
anchored = true
body = body.slice(1)
}
if (body.length === 0) return null
const rawSegs = body.split('/')
if (rawSegs.some((s) => s.length === 0)) return null
if (rawSegs.some((s) => s.includes('**') && s !== '**')) return null
if (rawSegs.length > 1) anchored = true
const lower = rawSegs.map((s) => s.toLowerCase())
const segs: SegPattern[] = lower.map((s) => {
if (s === '**') return { kind: 'globstar' }
// 含段内通配字符(* ? [)才走正则编译,纯字面量走 === 快路径
if (!/[*?[]/.test(s)) return { kind: 'literal', lit: s }
try {
return { kind: 'wild', re: new RegExp('^' + wildSegRegex(s) + '$') }
} catch {
// 类体无法构成合法正则(如反转范围 [z-a]):整段退回字面量匹配,永不崩溃
return { kind: 'literal', lit: s }
}
})
return { segs, dirOnly, negate, anchored }
}
/** DP
* `**` `**` `a/**` a gitignore */
function matchSegs(pat: SegPattern[], path: string[]): boolean {
const P = pat.length
const L = path.length
// dp[i][j] = pat[i..] 匹配 path[j..]
const dp: boolean[][] = Array.from({ length: P + 1 }, () => new Array<boolean>(L + 1).fill(false))
dp[P][L] = true
for (let i = P - 1; i >= 0; i--) {
for (let j = L; j >= 0; j--) {
const seg = pat[i]
if (seg.kind === 'globstar') {
dp[i][j] =
i === P - 1
? j < L // 尾部 **:吞掉剩余全部段(至少一段)
: dp[i + 1][j] || (j < L && dp[i][j + 1]) // 非尾部:跨零段或多段
} else if (seg.kind === 'literal') {
dp[i][j] = j < L && path[j] === seg.lit && dp[i + 1][j + 1]
} else {
dp[i][j] = j < L && seg.re!.test(path[j]) && dp[i + 1][j + 1]
}
}
}
return dp[0][0]
}
/** 单条规则是否匹配路径(dirOnly 仅匹配目录;非锚定规则任意起点起匹配) */
function ruleMatches(rule: CompiledRule, pathSegs: string[], isDir: boolean): boolean {
if (rule.dirOnly && !isDir) return false
if (rule.anchored) return matchSegs(rule.segs, pathSegs)
for (let k = 0; k < pathSegs.length; k++) {
if (matchSegs(rule.segs, pathSegs.slice(k))) return true
}
return false
}
/** 规则数组是否为可接受的 IPC 形态(folder:scan 防御性校验共用) */
export function isIgnoreRulesArray(v: unknown): v is string[] {
return (
Array.isArray(v) &&
v.length <= IGNORE_RULES_MAX &&
v.every((r) => typeof r === 'string' && r.length <= IGNORE_RULE_MAX_LEN)
)
}
/** 编译规则串数组为匹配器(空数组 / 全部非法 → 永不匹配的空匹配器) */
export function compileIgnoreRules(rules: readonly string[]): IgnoreMatcher {
const compiled: CompiledRule[] = []
const invalid: string[] = []
for (const r of rules) {
if (compiled.length >= IGNORE_RULES_MAX) break
if (typeof r !== 'string') continue
const trimmed = r.trim()
const c = compileOne(trimmed)
if (c) compiled.push(c)
else if (trimmed !== '' && !trimmed.startsWith('#')) invalid.push(trimmed)
}
const matchesPath = (rel: string, isDir?: boolean): boolean => {
if (rel === '') return false
const dir = isDir === true
const segs = rel.toLowerCase().split('/')
// 否定仲裁:从后往前找第一条匹配的规则,其否定性决定结果(gitignore 语义)
for (let i = compiled.length - 1; i >= 0; i--) {
if (ruleMatches(compiled[i], segs, dir)) return !compiled[i].negate
}
return false
}
return { matchesPath, invalid }
}
+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'
]
+2 -1
View File
@@ -3,7 +3,8 @@
"include": [
"electron.vite.config.*",
"src/main/**/*",
"src/preload/**/*"
"src/preload/**/*",
"src/shared/**/*"
],
"compilerOptions": {
"composite": true,
+2 -1
View File
@@ -4,7 +4,8 @@
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
"src/preload/*.d.ts",
"src/shared/**/*"
],
"compilerOptions": {
"composite": true,
+13 -3
View File
@@ -13,18 +13,28 @@ export default defineConfig({
environment: 'jsdom',
globals: false,
setupFiles: ['./src/renderer/src/test/setup.ts'],
include: ['src/renderer/src/**/*.{test,spec}.{ts,tsx}'],
include: [
'src/renderer/src/**/*.{test,spec}.{ts,tsx}',
// 主进程纯逻辑模块(无 electron 依赖)与两进程共享纯逻辑模块的单元测试
'src/main/**/*.{test,spec}.{ts,tsx}',
'src/shared/**/*.{test,spec}.{ts,tsx}'
],
exclude: ['node_modules', 'dist', 'out'],
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary'],
include: ['src/renderer/src/**/*.{ts,tsx}'],
include: ['src/renderer/src/**/*.{ts,tsx}', 'src/main/**/*.ts', 'src/shared/**/*.ts'],
exclude: [
'src/renderer/src/**/*.{test,spec}.{ts,tsx}',
'src/renderer/src/test/**',
'src/renderer/src/env.d.ts',
// worker 线程入口无法在 jsdom 执行,其调用的 computeDiff 已由 diffEngine 测试覆盖
'src/renderer/src/diff/diffWorker.ts'
'src/renderer/src/diff/diffWorker.ts',
// 主进程入口依赖 electron 运行时,无法在 jsdom 测试(窗口/IPC 逻辑随发布人工自测)
'src/main/index.ts',
// 解码 worker 线程入口(真实链路由 E2E 编码用例覆盖),其调用的
// decodeText 已由 decode 测试覆盖
'src/main/decodeWorker.ts'
],
all: true,
// CI 门禁:四项覆盖率不得低于 80%,否则测试失败