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),文档四件套同步
This commit is contained in:
@@ -12,9 +12,9 @@ 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 旧快照兼容加载(纯状态对照)
|
||||
- **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**,保存期间批量读取实时显示"已读取 N / M"进度,保存中防重复触发),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么",导出的报告头部注明基线来源与保存时间);内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);**大快照专用读取通道**(加载上限 128MB,通用文件的 10MB 上限不拦截自家保存的大快照,且不污染文件对话框的目录记忆);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照、自身截断的快照同样禁止加载(三侧防对照误报),保存期间退出文件夹对比自动终止在途保存(不弹名不符实的保存对话框),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照)
|
||||
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
|
||||
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览,文件大小 B/KB/MB/GB/TB 人类可读)
|
||||
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 忽略规则说明 + 统计概览,文件大小 B/KB/MB/GB/TB 人类可读)
|
||||
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
|
||||
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应
|
||||
- **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径
|
||||
@@ -34,7 +34,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
||||
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
||||
- **渲染异常兜底**:任何组件渲染错误由顶层 ErrorBoundary 捕获,显示可复制的错误信息与一键重载按钮,不白屏死机
|
||||
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
|
||||
- **导出一键报告**:单文件与文件夹对比均可导出 HTML / 纯文本 / Markdown 三种格式。单文件报告始终包含全量行,HTML 为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(点击展开零丢失),并支持打印与窄屏;文件夹报告含判定口径说明与全量条目清单
|
||||
- **导出一键报告**:单文件可导出 HTML / 纯文本 / Markdown / **Patch 标准差异** 四种格式,文件夹对比可导出前三者。单文件报告为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(HTML 点击展开零丢失;TXT/Markdown 段间省略提示行);**Patch 为标准 unified diff**(git diff 风格,含 hunk 头与尾部无换行标记),产物可直接 `git apply` / `patch` 使用——判等类选项(忽略大小写/空白/字符级)污染上下文行时该项自动禁用并说明原因;报告尾部带生成器版本号;文件夹报告含判定口径、忽略规则说明与全量条目清单;快照基线内容对照导出的报告注明基线来源与保存时间;导出对话框记忆上次导出目录
|
||||
- **拖拽与按钮双入口**:支持文件拖拽导入(E2E 覆盖真实拖放链路),也可通过界面按钮选择文件或粘贴文本
|
||||
- **右键菜单键盘导航**:行级右键菜单支持 ↑/↓ 移动高亮(跳过禁用项、首尾循环)、Enter 触发、Esc 关闭,鼠标悬停与键盘高亮同步
|
||||
- **滚动联动**:左右两个面板滚动位置自动同步
|
||||
@@ -117,7 +117,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
|
||||
- 作者:thzxx
|
||||
- 组织:MetonaTeam
|
||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||
- 版本:0.10.5
|
||||
- 版本:0.11.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-1
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch)
|
||||
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
||||
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
||||
|
||||
当前基线版本:**`0.10.5`**
|
||||
当前基线版本:**`0.11.0`**
|
||||
|
||||
---
|
||||
|
||||
@@ -156,6 +156,13 @@ z = 补丁版本号(Patch)
|
||||
- 换目录视图状态重置(确定性缺陷修复):重新选择新目录对后,旧搜索词/状态过滤/扩展名过滤/折叠集合/变迁过滤此前全部残留并静默生效(旧目录的折叠路径让新目录同名目录意外折叠、旧关键字继续过滤新列表)——现目录对变化即重置全部视图层状态;同目录重扫(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 diff(git diff 风格,`--- a/文件` / `@@ -l,c +l,c @@` hunk 头 / 上下文 3 行 / modified 行展开 -旧+新 / 尾部无换行 `\ No newline at end of file` 标记),产物可直接 git apply / patch 消费;默认文件名 DiffLens-patch-{时间戳}.patch;hunk 结构复用 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
|
||||
```
|
||||
|
||||
+15
-10
@@ -45,7 +45,7 @@ vitest.config.ts # Vitest 配置(jsdom + 测试范围含
|
||||
playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑)
|
||||
e2e/
|
||||
├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口/合成拖放)
|
||||
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项含字符粒度统计徽章/导航/导出写盘/文件夹对比含单行头部与快照归组下拉/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照与大快照专用通道与截断快照拦截/关于弹框/拖拽导入/偏好重启)
|
||||
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项含字符粒度统计徽章/导航/导出写盘含 Patch 落盘与判等禁用/文件夹对比含单行头部与快照归组下拉/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照与大快照专用通道与截断快照拦截/关于弹框/拖拽导入/偏好重启)
|
||||
src/shared/
|
||||
├── ignoreRules.ts # 忽略规则匹配器纯逻辑(gitignore 简化子集含高级 glob 与段内字符通配——? 单字符/[abc] 字符类;主进程扫描剪枝与渲染端无效提示共用,段模式预编译 + DP 段序列匹配 + 否定仲裁)
|
||||
├── ignoreRules.test.ts # 忽略规则单元测试(名字/目录/通配回归 + 多段/锚定/跨段/段内通配/否定仲裁/无效清单/上限/IPC 校验 + ? 与字符类专项)
|
||||
@@ -73,9 +73,9 @@ src/renderer/src/
|
||||
│ ├── diffWorker.ts # diff 计算 Web Worker(内联打包,覆盖率排除,逻辑由 diffEngine 覆盖)
|
||||
│ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化)
|
||||
│ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退
|
||||
│ ├── report.test.ts # 单文件报告生成测试
|
||||
│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注;对照模式条目表替换为变迁对照表;fmtSize 大小格式化含 GB·TB 档,列表与报告共用)
|
||||
│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable/快照变迁对照表/fmtSize 各档与 GB·TB 修复锚)
|
||||
│ ├── report.test.ts # 单文件报告生成测试(含 0.11.0:Patch 构建器/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 # 文本扩展名与行数统计测试
|
||||
@@ -95,9 +95,9 @@ src/renderer/src/
|
||||
│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt)
|
||||
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉含无效规则提示与进度遮罩/快照对照模式含变迁过滤归零复位与内容对照 title/目录双击/重新扫描与重新选择/快照保存中 saving 禁用/双击/导出菜单/虚拟滚动/resize 重算)
|
||||
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
|
||||
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记/搜索框计数与 Enter·Shift+Enter·Esc 键盘交互/ref 转发)
|
||||
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/Patch 菜单项可用与判等禁用+title 解释(0.11.0)/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记/搜索框计数与 Enter·Shift+Enter·Esc 键盘交互/ref 转发)
|
||||
└── __tests__/
|
||||
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索(含重算后计数与高亮一致·命中归零清空)/Ctrl+F/文件夹模式进出与双击(含同目录异写归一化防呆)/语义判等链路/差异文件导航(含游标不在序列时导航隐藏)/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截(加载走 snapshot:read 专用通道·快照自身截断拦截·保存中退出静默终止)/快照内容级对照(v2 保存含内容与读取进度·超限降级·双击基线vs当前·v1 兼容)/扫描进度显示与过期丢弃/关于弹框(打开·元信息·复制·双模式可见·Esc 关闭))
|
||||
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索(含重算后计数与高亮一致·命中归零清空)/Ctrl+F/文件夹模式进出与双击(含同目录异写归一化防呆)/语义判等链路/差异文件导航(含游标不在序列时导航隐藏)/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截(加载走 snapshot:read 专用通道·快照自身截断拦截·保存中退出静默终止)/快照内容级对照(v2 保存含内容与读取进度·超限降级·双击基线vs当前·v1 兼容·导出报告含对照说明与版本号(0.11.0))/扫描进度显示与过期丢弃/关于弹框(打开·元信息·复制·双模式可见·Esc 关闭))
|
||||
└── main.test.ts # 入口挂载冒烟测试
|
||||
```
|
||||
|
||||
@@ -126,7 +126,11 @@ src/renderer/src/
|
||||
- 人话总结(`plainSummary`):无差异一致结论 / 为 0 项省略 / 三类齐全完整罗列
|
||||
- 比较选项描述(`plainOptions`)与头部说明行:无选项严格对比文案 / 生效项罗列 / 忽略所有空白遮蔽行首尾空白;TXT / Markdown / HTML 三格式头部含“比较选项”行
|
||||
- 字符粒度统计行(0.10.5):字符级模式(ctx.options.charMode)时统计行追加「| 字符 +A / −D」、HTML 追加「字符增删」统计卡;行级模式不追加
|
||||
- 格式分发与扩展名映射(`REPORT_EXT`)
|
||||
- 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`)
|
||||
- 文本扩展名白名单放行 / 拒绝 / 大小写不敏感
|
||||
@@ -159,6 +163,7 @@ src/renderer/src/
|
||||
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入(含 Windows 异写路径归一化判定,0.10.2) · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式
|
||||
- 快照基线(0.9.0,0.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 一致
|
||||
@@ -171,7 +176,7 @@ src/renderer/src/
|
||||
- 工厂:无 Worker 环境返回 null;可用环境返回实例
|
||||
|
||||
### 5.7 应用状态持久化(`src/main/windowState.ts`)
|
||||
- parseState:空输入/非法 JSON 回退空状态;合法 bounds 与 lastDir 保留;字段类型异常(非矩形/非字符串/空串/非有限数值)防御性忽略
|
||||
- parseState:空输入/非法 JSON 回退空状态;合法 bounds 与 lastDir 保留;lastReportDir 合法字符串保留、非字符串/空串忽略(0.11.0 导出目录记忆);字段类型异常(非矩形/非字符串/空串/非有限数值)防御性忽略
|
||||
- clampBounds:工作区内合法 bounds 原样保留;小于最小尺寸钳到最小值;超出工作区封顶;移出屏幕四向拉回(至少 60px 进入工作区);多显示器偏移工作区同样钳制;非整数取整;工作区小于最小尺寸时尺寸仍保持最小值
|
||||
- 磁盘读写与窗口事件绑定在主进程入口(覆盖率排除,真实链路由 E2E 覆盖)
|
||||
|
||||
@@ -218,7 +223,7 @@ src/renderer/src/
|
||||
- 滚动稳定性回归锚(0.7.0):真实文件(行数不等)→ 开字符级进 worker 重路径 → 滚到中部 → 切选项二次重算 → 断言 scrollTop 保持不归零、滚到底内容连续(0.6.3 回退缺陷的固化防线)
|
||||
- 导航与视图:F7 / Shift+F7、仅看差异折叠、交换左右后面板互换
|
||||
- 单文件视图搜索(0.9.3):粘贴两侧后 Ctrl+F 聚焦搜索框(真实键盘事件)→ 输入关键字即时计数与行内命中高亮 → Enter 跳转下一命中行(active 行号定位断言)→ Esc 清空复位计数
|
||||
- 报告导出:stub 保存对话框 → 单文件 HTML 报告真实落盘且含结论与统计
|
||||
- 报告导出:stub 保存对话框 → 单文件 HTML 报告真实落盘且含结论与统计;Patch 标准差异真实落盘(unified diff 文件头/hunk 头/上下文与增删行断言,0.11.0);忽略大小写判等污染下 Patch 菜单项禁用(0.11.0)
|
||||
- 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤 / 树形显示)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留)
|
||||
- 文件夹新功能(0.7.0):语义判等徽章与过滤(CRLF/LF 差异计语义同)→ 树形折叠展开 → 搜索 → 文件夹报告 TXT 真实落盘(判定口径 + 结论 + 全量条目)
|
||||
- 增量重扫(0.8.0):真实目录修改文件后点「重新扫描」状态正确更新(相同→不同→改回相同,真实 mtime 变化走指纹判定)
|
||||
@@ -279,7 +284,7 @@ npm run build
|
||||
|
||||
## 9. 演进方向(后续迭代)
|
||||
|
||||
- 快照体验:regressed 场景基线内容保存策略(当前仅保存差异条目,基线一致的条目内容不存——需全量保存的体积代价权衡) · 快照内差异内容的报告导出
|
||||
- 快照体验:regressed 场景基线内容保存策略(当前仅保存差异条目,基线一致的条目内容不存——需全量保存的体积代价权衡;快照内差异内容的报告导出已于 0.11.0 以"内容对照导出附对照说明"落地)
|
||||
- 忽略规则:gitignore 更完整子集评估(`\` 转义、前导 `!` 组合语义等,按真实需求驱动;`?` 单字符与 `[abc]` 字符类已于 0.10.1 落地)
|
||||
- 主进程 IPC 并发场景集成测试(入参校验与缓存注入逻辑已于 0.9.1 单测化:scanIpc;缓存落盘串行化于 0.9.3 修复)
|
||||
- mac 构建实际验证(entitlements 配置就绪但从未打包跑通)
|
||||
|
||||
@@ -270,6 +270,45 @@ test.describe('报告导出(主进程写盘链路)', () => {
|
||||
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('文件夹对比(主进程真实扫描链路)', () => {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.10.5",
|
||||
"version": "0.11.0",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
+6
-2
@@ -275,12 +275,14 @@ ipcMain.handle('file:decode-buffer', async (_event, buffer: ArrayBuffer) => {
|
||||
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: ['*'] }
|
||||
@@ -289,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 }
|
||||
})
|
||||
|
||||
|
||||
@@ -40,6 +40,14 @@ describe('parseState', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface AppState {
|
||||
bounds?: Rect
|
||||
/** 上次成功打开文件所在目录(file:open 对话框 defaultPath) */
|
||||
lastDir?: string
|
||||
/** 上次成功导出报告所在目录(file:save-report 对话框 defaultPath;与 lastDir 语义不同分开记忆) */
|
||||
lastReportDir?: string
|
||||
}
|
||||
|
||||
/** 最小窗口尺寸(与 BrowserWindow 的 minWidth/minHeight 保持一致,改动需双向同步) */
|
||||
@@ -49,6 +51,9 @@ export function parseState(raw: string | null | undefined): 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 {}
|
||||
|
||||
@@ -12,7 +12,13 @@ import DiffView, { type DisplayItem, type FoldMarker, isFold } from './component
|
||||
import Toolbar from './components/Toolbar'
|
||||
import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
|
||||
import TextInputModal from './components/TextInputModal'
|
||||
import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report'
|
||||
import {
|
||||
buildReport,
|
||||
canBuildPatch,
|
||||
REPORT_EXT,
|
||||
type ReportFormat,
|
||||
type ReportContext
|
||||
} from './diff/report'
|
||||
import { buildFolderReport } from './diff/folderReport'
|
||||
import {
|
||||
parseSnapshot,
|
||||
@@ -536,6 +542,9 @@ export default function App(): ReactElement {
|
||||
semantic: folderSemantic,
|
||||
truncated: folderState.truncated,
|
||||
total: folderState.total,
|
||||
// 生成器版本号 + 生效的忽略规则(被忽略条目不进报告,头部可辨识缺失原因)
|
||||
...(appVersion !== '' ? { version: appVersion } : {}),
|
||||
...(folderIgnoreRules.length > 0 ? { ignoreRules: folderIgnoreRules } : {}),
|
||||
...(snapshotDiff !== null
|
||||
? {
|
||||
snapshotNote: `修复 ${snapshotDiff.stats.fixed} · 新增差异 ${snapshotDiff.stats.regressed} · 新文件 ${snapshotDiff.stats.new} · 已移除 ${snapshotDiff.stats.gone} · 仍有差异 ${snapshotDiff.stats.changed}(基线 ${new Date(
|
||||
@@ -564,7 +573,7 @@ export default function App(): ReactElement {
|
||||
})
|
||||
}
|
||||
},
|
||||
[folderState, folderSemantic, snapshotDiff, snapshot, showToast]
|
||||
[folderState, folderSemantic, snapshotDiff, snapshot, showToast, appVersion, folderIgnoreRules]
|
||||
)
|
||||
|
||||
/** 保存当前对比结果为快照基线(JSON 落盘,复用报告保存管线)。
|
||||
@@ -1165,15 +1174,32 @@ export default function App(): ReactElement {
|
||||
[paneL, paneR]
|
||||
)
|
||||
|
||||
// 状态栏行数(面板内容不变时不重算;切分规则与 diff 引擎一致;
|
||||
// 声明提前——exportReport 的依赖数组在声明期求值引用)
|
||||
const leftLines = useMemo(() => (paneL ? countLines(paneL.text) : 0), [paneL])
|
||||
const rightLines = useMemo(() => (paneR ? countLines(paneR.text) : 0), [paneR])
|
||||
|
||||
// Patch(unified diff)可导出性:判等类选项把两侧不同文本判为 unchanged 时不可表达
|
||||
const canPatch = useMemo(() => canBuildPatch(rows), [rows])
|
||||
|
||||
const exportReport = useCallback(
|
||||
async (fmt: ReportFormat) => {
|
||||
// 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御
|
||||
if (!diff) return
|
||||
// Patch 兜底守卫(菜单项已禁用):判等污染的行集无法产出可应用的 unified diff
|
||||
if (fmt === 'patch' && !canPatch) {
|
||||
showToast('当前比较选项把部分不同内容判为相同,Patch 无法表达,请关闭相关选项后导出')
|
||||
return
|
||||
}
|
||||
// 字符级对比降级时,报告头部按实际生效语义描述:
|
||||
// 整体降级按行级语义;部分降级保持字符级语义并标注部分块按行级
|
||||
const reportOptions: DiffOptions = diff.charModeDowngraded
|
||||
? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true, trimWhitespace: false }
|
||||
: options
|
||||
// 快照基线内容对照场景:左侧面板为快照基线(encoding 为 0.10.0 起的既定标记),
|
||||
// 报告头部注明数据来源与基线保存时间——脱离应用后仍可辨识
|
||||
const isSnapshotCompare =
|
||||
snapshot !== null && paneL !== null && paneL.meta.encoding === '快照基线'
|
||||
const ctx: ReportContext = {
|
||||
leftName: paneL?.meta.name ?? '',
|
||||
rightName: paneR?.meta.name ?? '',
|
||||
@@ -1184,10 +1210,32 @@ export default function App(): ReactElement {
|
||||
options: reportOptions,
|
||||
...(diff.charModePartial && !diff.charModeDowngraded
|
||||
? { charModePartial: true }
|
||||
: {}),
|
||||
// 生成器版本号(可追溯)与原始文本行数(忽略空行开启时 rows 统计偏小)
|
||||
...(appVersion !== '' ? { version: appVersion } : {}),
|
||||
...(leftLines > 0 ? { leftLines } : {}),
|
||||
...(rightLines > 0 ? { rightLines } : {}),
|
||||
...(isSnapshotCompare
|
||||
? {
|
||||
compareNote: `左侧为快照基线(保存于 ${new Date(
|
||||
snapshot!.savedAt
|
||||
).toLocaleString('zh-CN')}),右侧为当前文件`
|
||||
}
|
||||
: {})
|
||||
}
|
||||
const content = buildReport(rows, ctx, summary, fmt)
|
||||
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
|
||||
// 尾部无换行标记(patch 格式专用):splitLines 吃掉了尾部换行信息,按原文判定
|
||||
const tail =
|
||||
fmt === 'patch'
|
||||
? {
|
||||
leftNoNewline: (paneL?.text ?? '') !== '' && !paneL!.text.endsWith('\n'),
|
||||
rightNoNewline: (paneR?.text ?? '') !== '' && !paneR!.text.endsWith('\n')
|
||||
}
|
||||
: undefined
|
||||
const content = buildReport(rows, ctx, summary, fmt, tail)
|
||||
const defaultName =
|
||||
fmt === 'patch'
|
||||
? `DiffLens-patch-${stamp()}.${REPORT_EXT[fmt]}`
|
||||
: `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
|
||||
let res: { ok: boolean; path: string | null }
|
||||
try {
|
||||
res = await window.api.saveReport(content, defaultName)
|
||||
@@ -1203,7 +1251,7 @@ export default function App(): ReactElement {
|
||||
})
|
||||
}
|
||||
},
|
||||
[diff, rows, paneL, paneR, summary, options, showToast]
|
||||
[diff, rows, paneL, paneR, summary, options, showToast, canPatch, appVersion, leftLines, rightLines, snapshot]
|
||||
)
|
||||
|
||||
const anyPane = paneL !== null || paneR !== null
|
||||
@@ -1232,10 +1280,6 @@ export default function App(): ReactElement {
|
||||
const leftMeta = paneL?.meta ?? null
|
||||
const rightMeta = paneR?.meta ?? null
|
||||
|
||||
// 状态栏行数(面板内容不变时不重算;切分规则与 diff 引擎一致)
|
||||
const leftLines = useMemo(() => (paneL ? countLines(paneL.text) : 0), [paneL])
|
||||
const rightLines = useMemo(() => (paneR ? countLines(paneR.text) : 0), [paneR])
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
@@ -1419,6 +1463,7 @@ export default function App(): ReactElement {
|
||||
searchCount={searchHits.length}
|
||||
onSearchNav={goSearch}
|
||||
searchInputRef={searchInputRef}
|
||||
canPatch={canPatch}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -2553,6 +2553,62 @@ describe('App - 快照内容级对照(v2)', () => {
|
||||
expect(screen.getByText('有差异')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('快照内容对照导出报告:头部含对照说明(基线来源与保存时间)与生成器版本号(0.11.0)', async () => {
|
||||
const curEntries = [
|
||||
{ rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
|
||||
{ rel: 'b.txt', status: 'different' as const, leftSize: 3, rightSize: 5 }
|
||||
]
|
||||
const snapJson = JSON.stringify({
|
||||
version: 2,
|
||||
leftDir: 'C:/left-dir',
|
||||
rightDir: 'D:/right-dir',
|
||||
semantic: true,
|
||||
truncated: false,
|
||||
total: 2,
|
||||
savedAt: 1700000000000,
|
||||
entries: curEntries,
|
||||
contents: { 'b.txt': { left: 'alpha baseline', right: 'alpha baseline-r' } }
|
||||
})
|
||||
const dirs = ['C:/left-dir', 'D:/right-dir']
|
||||
let call = 0
|
||||
let savedContent = ''
|
||||
window.api = mockApi({
|
||||
pickFolder: async () => dirs[call++] ?? null,
|
||||
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 2 }),
|
||||
readSnapshot: async () => ({
|
||||
path: '/tmp/snap.json',
|
||||
name: 'snap.json',
|
||||
text: snapJson,
|
||||
encoding: 'UTF-8',
|
||||
binary: false
|
||||
}),
|
||||
readByPath: async (p: string) =>
|
||||
p === 'C:/left-dir/b.txt'
|
||||
? { path: p, name: 'b.txt', text: 'alpha current', encoding: 'UTF-8', binary: false }
|
||||
: null,
|
||||
saveReport: async (content: string) => {
|
||||
savedContent = content
|
||||
return { ok: true, path: 'C:/docs/DiffLens-report.html' }
|
||||
}
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('对比文件夹'))
|
||||
await screen.findByText('b.txt')
|
||||
fireEvent.click(screen.getByRole('button', { name: '快照' }))
|
||||
fireEvent.click(screen.getByText('加载快照…'))
|
||||
await screen.findByText(/已加载快照/)
|
||||
fireEvent.dblClick(screen.getByText('b.txt'))
|
||||
await screen.findByText('快照 · b.txt', { selector: '.pane-file' })
|
||||
// 导出 HTML:报告头部应注明左侧为快照基线及其保存时间,尾部含生成器版本号
|
||||
fireEvent.click(screen.getByText('导出报告'))
|
||||
fireEvent.click(screen.getByText('HTML 报告'))
|
||||
await screen.findByText(/报告已保存/)
|
||||
expect(savedContent).toContain('对照说明:左侧为快照基线')
|
||||
expect(savedContent).toContain('右侧为当前文件')
|
||||
// 版本号来自 app:get-version(mock 固定值 9.9.9-test)
|
||||
expect(savedContent).toContain('v9.9.9-test')
|
||||
})
|
||||
|
||||
it('对照模式双击无基线内容的条目(v1 快照):走常规当前文件双击', async () => {
|
||||
const curEntries = [
|
||||
{ rel: 'a.txt', status: 'same' as const, leftSize: 3, rightSize: 3 },
|
||||
|
||||
@@ -24,6 +24,7 @@ function setup(extra?: {
|
||||
searchIndex?: number
|
||||
searchCount?: number
|
||||
onSearchNav?: (dir: 1 | -1) => void
|
||||
canPatch?: boolean
|
||||
}) {
|
||||
const onOptionsChange = vi.fn()
|
||||
const onNav = vi.fn()
|
||||
@@ -62,6 +63,7 @@ function setup(extra?: {
|
||||
searchCount={extra?.searchCount ?? 0}
|
||||
onSearchNav={onSearchNav}
|
||||
searchInputRef={searchInputRef}
|
||||
canPatch={extra?.canPatch}
|
||||
/>
|
||||
)
|
||||
return {
|
||||
@@ -248,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('导出报告'))
|
||||
|
||||
@@ -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 {
|
||||
@@ -41,6 +42,8 @@ interface ToolbarProps {
|
||||
onSearchNav: (dir: 1 | -1) => void
|
||||
/** Ctrl+F 聚焦用:搜索输入框 ref(App 持有并转发到 input) */
|
||||
searchInputRef: RefObject<HTMLInputElement>
|
||||
/** Patch(unified diff)是否可从当前结果生成:判等选项污染 unchanged 行时为 false(菜单项禁用) */
|
||||
canPatch?: boolean
|
||||
}
|
||||
|
||||
export default function Toolbar({
|
||||
@@ -63,7 +66,8 @@ export default function Toolbar({
|
||||
searchIndex,
|
||||
searchCount,
|
||||
onSearchNav,
|
||||
searchInputRef
|
||||
searchInputRef,
|
||||
canPatch = true
|
||||
}: ToolbarProps): ReactElement {
|
||||
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
@@ -265,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>
|
||||
|
||||
@@ -212,6 +212,38 @@ describe('buildFolderReport - Markdown', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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 },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FolderEntry, FolderEntryStatus } from '../../../preload/index'
|
||||
import { esc, REPORT_CSS, REPORT_EXT, type ReportFormat } from './report'
|
||||
import { esc, footLine, ignoreRulesLine, REPORT_CSS, REPORT_EXT, type ReportFormat } from './report'
|
||||
import { SNAPSHOT_CHANGE_LABEL, type SnapshotDiffEntry } from './snapshotDiff'
|
||||
|
||||
/**
|
||||
@@ -25,6 +25,10 @@ export interface FolderReportContext {
|
||||
snapshotNote?: string
|
||||
/** 快照对照条目(对照模式导出时提供:条目表替换为变迁对照表) */
|
||||
snapshotEntries?: SnapshotDiffEntry[]
|
||||
/** 生成器版本号(尾部署名;可追溯报告由哪个版本产出) */
|
||||
version?: string
|
||||
/** 生效的忽略规则(非空时头部说明——被忽略条目不进报告,脱离应用后仍可辨识缺失原因) */
|
||||
ignoreRules?: string[]
|
||||
}
|
||||
|
||||
/** 六类状态统计 */
|
||||
@@ -220,6 +224,11 @@ function buildFolderHtml(
|
||||
<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">
|
||||
@@ -259,7 +268,7 @@ ${
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="foot">由 DiffLens 生成 · 语义同 = 字节不同但剔除空白/换行后内容一致(双击条目可查看单文件差异明细)</div>
|
||||
<div class="foot">由 DiffLens 生成 · 语义同 = 字节不同但剔除空白/换行后内容一致(双击条目可查看单文件差异明细)${ctx.version ? ` · v${esc(ctx.version)}` : ''}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -278,6 +287,9 @@ function buildFolderTxt(
|
||||
`右侧目录:${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} 项`
|
||||
@@ -316,7 +328,7 @@ function buildFolderTxt(
|
||||
}
|
||||
}
|
||||
lines.push('----------------------------------')
|
||||
lines.push('生成于 DiffLens')
|
||||
lines.push(footLine(ctx.version))
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
@@ -334,6 +346,9 @@ function buildFolderMd(
|
||||
`> 右侧目录:${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} 项`,
|
||||
@@ -368,7 +383,7 @@ function buildFolderMd(
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('', '> 生成于 DiffLens')
|
||||
out.push('', `> ${footLine(ctx.version)}`)
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
buildHtmlReport,
|
||||
buildTxtReport,
|
||||
buildMarkdownReport,
|
||||
buildPatchReport,
|
||||
canBuildPatch,
|
||||
buildReport,
|
||||
buildSegments,
|
||||
plainSummary,
|
||||
@@ -271,9 +273,10 @@ describe('buildTxtReport', () => {
|
||||
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('行号位数一致时无额外填充', () => {
|
||||
@@ -312,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 diff(0.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 行')
|
||||
})
|
||||
})
|
||||
+199
-37
@@ -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 {
|
||||
/** 左侧文件显示名;未选择为空串 */
|
||||
@@ -19,6 +19,14 @@ export interface ReportContext {
|
||||
options?: DiffOptions
|
||||
/** 字符级对比部分降级(部分超限块按行级配对,其余仍字符级;头部说明标注) */
|
||||
charModePartial?: boolean
|
||||
/** 生成器版本号(App 经 app:get-version 获取;尾部署名“生成于 DiffLens vX.Y.Z”) */
|
||||
version?: string
|
||||
/** 对照说明(快照基线内容对照场景:注明左侧为快照基线及其保存时间、右侧为当前文件) */
|
||||
compareNote?: string
|
||||
/** 左侧原始文本行数(countLines 口径;忽略空行开启时 rows 统计会偏小,优先用本字段) */
|
||||
leftLines?: number
|
||||
/** 右侧原始文本行数 */
|
||||
rightLines?: number
|
||||
}
|
||||
|
||||
/** HTML 转义(文件夹报告复用) */
|
||||
@@ -159,6 +167,8 @@ const headLines = (
|
||||
}`,
|
||||
`对比时间:${new Date().toLocaleString('zh-CN')}`,
|
||||
`比较选项:${plainOptions(ctx.options, { charModePartial: ctx.charModePartial })}`,
|
||||
// 对照说明(快照基线内容对照场景):报告脱离应用后仍可辨识左侧数据来源与时间点
|
||||
...(ctx.compareNote ? [`对照说明:${ctx.compareNote}`] : []),
|
||||
`结论:${plainSummary(summary)}`,
|
||||
`变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}` +
|
||||
// 字符级模式追加字符粒度统计(码点数;超长防护行无法细分不计入)
|
||||
@@ -167,6 +177,16 @@ const headLines = (
|
||||
: '')
|
||||
]
|
||||
|
||||
/** 尾部署名行:携带生成器版本号(可追溯报告由哪个版本产出;未提供时与历史格式一致;文件夹报告复用) */
|
||||
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,任意浏览器可开);文件夹报告复用并追加条目表样式 */
|
||||
@@ -448,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
|
||||
|
||||
// 明细:段前折叠省略行 + 段内行(含块标题行);最后补尾部折叠
|
||||
@@ -495,6 +516,7 @@ export function buildHtmlReport(
|
||||
<div class="hero-title">🔍 DiffLens 差异报告</div>
|
||||
<div class="hero-time">生成于 ${new Date().toLocaleString('zh-CN')}</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)}
|
||||
@@ -527,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,
|
||||
@@ -550,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)
|
||||
@@ -595,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 是否可从当前行集生成:判等类比较选项(忽略大小写/所有空白/字符级等)会把
|
||||
* 两侧实际不同的文本判为 unchanged(上下文行),unified 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')
|
||||
}
|
||||
|
||||
@@ -605,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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user