diff --git a/README.md b/README.md index 6ee580a..f5ead48 100644 --- a/README.md +++ b/README.md @@ -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 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index a730505..f4d1c62 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -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 ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 4ae3d74..2a94d6f 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -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 配置就绪但从未打包跑通) diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 770600b..ac6ecc1 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -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('文件夹对比(主进程真实扫描链路)', () => { diff --git a/package.json b/package.json index c0f839a..8c5cb00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.10.5", + "version": "0.11.0", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index 9959d3e..048939c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 } }) diff --git a/src/main/windowState.test.ts b/src/main/windowState.test.ts index 689fcdf..79ab34a 100644 --- a/src/main/windowState.test.ts +++ b/src/main/windowState.test.ts @@ -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', () => { diff --git a/src/main/windowState.ts b/src/main/windowState.ts index 7d3e287..95db474 100644 --- a/src/main/windowState.ts +++ b/src/main/windowState.ts @@ -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 {} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 94d335b..706f8bd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 (
@@ -1419,6 +1463,7 @@ export default function App(): ReactElement { searchCount={searchHits.length} onSearchNav={goSearch} searchInputRef={searchInputRef} + canPatch={canPatch} /> ) : ( diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 4a01ef9..a74ad12 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -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() + 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 }, diff --git a/src/renderer/src/components/Toolbar.test.tsx b/src/renderer/src/components/Toolbar.test.tsx index 6a93cf9..b28713d 100644 --- a/src/renderer/src/components/Toolbar.test.tsx +++ b/src/renderer/src/components/Toolbar.test.tsx @@ -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('导出报告')) diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index 8d050a5..aa691c0 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -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 + /** 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): void => onOptionsChange({ ...options, ...patch }) const [exportOpen, setExportOpen] = useState(false) @@ -265,18 +269,32 @@ export default function Toolbar({ {exportOpen && (
- {FORMATS.map((f) => ( - - ))} + {FORMATS.map((f) => { + // Patch 的上下文行要求两侧文本严格一致:判等选项(忽略大小写/所有空白/ + // 字符级等)把不同文本判为相同时无法表达,禁用并解释 + const disabled = f.fmt === 'patch' && !canPatch + return ( + + ) + })}
)}
diff --git a/src/renderer/src/diff/folderReport.test.ts b/src/renderer/src/diff/folderReport.test.ts index 97c5880..1fd0b5f 100644 --- a/src/renderer/src/diff/folderReport.test.ts +++ b/src/renderer/src/diff/folderReport.test.ts @@ -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 }, diff --git a/src/renderer/src/diff/folderReport.ts b/src/renderer/src/diff/folderReport.ts index eecf3c0..25ead30 100644 --- a/src/renderer/src/diff/folderReport.ts +++ b/src/renderer/src/diff/folderReport.ts @@ -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(
📁 DiffLens 文件夹对比报告
生成于 ${new Date().toLocaleString('zh-CN')}
${esc(semLine)}
+ ${ + ctx.ignoreRules && ctx.ignoreRules.length > 0 + ? `
忽略规则:${esc(ignoreRulesLine(ctx.ignoreRules))}(命中项不参与对比)
` + : '' + } ${ctx.snapshotNote ? `
快照对照:${esc(ctx.snapshotNote)}
` : ''}
${esc(plainFolderSummary(stats, entries.length))}
@@ -259,7 +268,7 @@ ${ } -
由 DiffLens 生成 · 语义同 = 字节不同但剔除空白/换行后内容一致(双击条目可查看单文件差异明细)
+
由 DiffLens 生成 · 语义同 = 字节不同但剔除空白/换行后内容一致(双击条目可查看单文件差异明细)${ctx.version ? ` · v${esc(ctx.version)}` : ''}
` @@ -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') } diff --git a/src/renderer/src/diff/report.test.ts b/src/renderer/src/diff/report.test.ts index dbf488b..46fb945 100644 --- a/src/renderer/src/diff/report.test.ts +++ b/src/renderer/src/diff/report.test.ts @@ -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('') 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 => { + 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 行') }) }) \ No newline at end of file diff --git a/src/renderer/src/diff/report.ts b/src/renderer/src/diff/report.ts index 8abbccb..929b980 100644 --- a/src/renderer/src/diff/report.ts +++ b/src/renderer/src/diff/report.ts @@ -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(
🔍 DiffLens 差异报告
生成于 ${new Date().toLocaleString('zh-CN')}
比较选项:${esc(plainOptions(ctx.options, { charModePartial: ctx.charModePartial }))}
+ ${ctx.compareNote ? `
对照说明:${esc(ctx.compareNote)}
` : ''}
${esc(plainSummary(summary))}
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)} @@ -527,13 +549,44 @@ export function buildHtmlReport( #左文件(原始)#右文件(对比) ${emptyBody} -
由 DiffLens 生成 · 相同内容段已折叠,点击可展开查看全部
+
由 DiffLens 生成 · 相同内容段已折叠,点击可展开查看全部${ctx.version ? ` · v${esc(ctx.version)}` : ''}
` } /** ============ 纯文本报告 ============ */ + +/** 分块省略提示行(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 = { html: 'html', txt: 'txt', - md: 'md' + md: 'md', + patch: 'patch' }