From 4fd8506ffb62f967cd172c30a30bce4c535a17bc Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Thu, 20 Aug 2026 13:54:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.11.0=20=E5=AF=BC=E5=87=BA=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E5=85=A8=E9=9D=A2=E5=A2=9E=E5=BC=BA=20-=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9EPatch=E6=A0=87=E5=87=86=E5=B7=AE=E5=BC=82=E7=AC=AC?= =?UTF-8?q?=E5=9B=9B=E6=A0=BC=E5=BC=8F(=E6=A0=87=E5=87=86unified=20diff?= =?UTF-8?q?=E5=90=ABhunk=E5=A4=B4/=E4=B8=8A=E4=B8=8B=E6=96=873=E8=A1=8C/mo?= =?UTF-8?q?dified=E5=B1=95=E5=BC=80-=E6=97=A7+=E6=96=B0/=E5=B0=BE=E9=83=A8?= =?UTF-8?q?=E6=97=A0=E6=8D=A2=E8=A1=8C=E6=A0=87=E8=AE=B0,=E4=BA=A7?= =?UTF-8?q?=E7=89=A9=E5=8F=AF=E7=9B=B4=E6=8E=A5git=20apply;canBuildPatch?= =?UTF-8?q?=E9=80=90=E8=A1=8C=E5=88=A4=E5=AE=9A=E5=88=A4=E7=AD=89=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E6=B1=A1=E6=9F=93,=E4=B8=8D=E5=8F=AF=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=97=B6=E8=8F=9C=E5=8D=95=E7=A6=81=E7=94=A8+title?= =?UTF-8?q?=E8=A7=A3=E9=87=8A+App=E5=8F=8C=E4=BF=9D=E9=99=A9=E6=8B=A6?= =?UTF-8?q?=E6=88=AA)=E3=80=81TXT/Markdown=E6=8A=A5=E5=91=8A=E5=88=86?= =?UTF-8?q?=E5=9D=97=E7=9C=81=E7=95=A5(=E5=A4=8D=E7=94=A8buildSegments?= =?UTF-8?q?=E6=AE=B5=E9=97=B4=E7=9C=81=E7=95=A5=E6=8F=90=E7=A4=BA=E8=A1=8C?= =?UTF-8?q?,MD=E6=AF=8F=E6=AE=B5=E7=8B=AC=E7=AB=8B=E6=88=90=E8=A1=A8;?= =?UTF-8?q?=E5=AE=8C=E5=85=A8=E4=B8=80=E8=87=B4=E4=BF=9D=E6=8C=81=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E7=95=99=E6=A1=A3=E8=AF=AD=E4=B9=89)=E3=80=81?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=85=83=E4=BF=A1=E6=81=AF=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?(=E7=89=88=E6=9C=AC=E5=8F=B7=E8=BF=9B=E5=B0=BE=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E5=90=8D/=E6=96=87=E4=BB=B6=E5=A4=B9=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E5=BF=BD=E7=95=A5=E8=A7=84=E5=88=99=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E8=A1=8C=E8=B6=855=E6=9D=A1=E6=88=AA=E6=96=AD/=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=9F=BA=E7=BA=BF=E5=86=85=E5=AE=B9=E5=AF=B9=E7=85=A7?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E9=99=84=E5=AF=B9=E7=85=A7=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E5=90=AB=E4=BF=9D=E5=AD=98=E6=97=B6=E9=97=B4/HTML=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=8D=A1=E8=A1=8C=E6=95=B0=E4=BF=AE=E6=AD=A3=E4=B8=BA?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E6=96=87=E6=9C=AC=E5=8F=A3=E5=BE=84)?= =?UTF-8?q?=E3=80=81=E5=AF=BC=E5=87=BA=E7=9B=AE=E5=BD=95=E8=AE=B0=E5=BF=86?= =?UTF-8?q?(windowState=E6=96=B0=E5=A2=9ElastReportDir=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E4=BA=8ElastDir),=E6=96=B0=E5=A2=9E33=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E4=B8=8E2=E6=9D=A1E2E(666=E5=8D=95=E6=B5=8B+32E2E=E5=85=A8?= =?UTF-8?q?=E8=BF=87=E8=A6=86=E7=9B=96=E7=8E=8797.85/93.06/93.7/97.85),?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=9B=9B=E4=BB=B6=E5=A5=97=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- docs/应用开发与版本迭代规范.md | 9 +- docs/测试策略与方案.md | 25 +- e2e/app.spec.ts | 39 +++ package.json | 2 +- src/main/index.ts | 8 +- src/main/windowState.test.ts | 8 + src/main/windowState.ts | 5 + src/renderer/src/App.tsx | 63 ++++- src/renderer/src/__tests__/App.test.tsx | 56 +++++ src/renderer/src/components/Toolbar.test.tsx | 30 +++ src/renderer/src/components/Toolbar.tsx | 46 ++-- src/renderer/src/diff/folderReport.test.ts | 32 +++ src/renderer/src/diff/folderReport.ts | 23 +- src/renderer/src/diff/report.test.ts | 199 +++++++++++++++- src/renderer/src/diff/report.ts | 236 ++++++++++++++++--- 16 files changed, 705 insertions(+), 84 deletions(-) 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 (