diff --git a/README.md b/README.md index 70c14cb..a5b0143 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异 - **交换左右侧**:一键互换两侧内容与视角 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 -- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”,适配弱差异场景 +- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 - **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 @@ -96,7 +96,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.4.2 +- 版本:0.4.3 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index ddddfcb..9f07bec 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.4.2`** +当前基线版本:**`0.4.3`** --- @@ -68,7 +68,8 @@ z = 补丁版本号(Patch) 0.3.2 ← 报告对齐与性能收尾(已发布):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性 0.4.0 ← 差异计算 worker 化(已发布):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用 0.4.1 ← worker 创建开销优化与文档对齐(已发布):worker 延迟至去抖到期才创建(去抖期内取消零创建开销,无 Worker 回退同样走去抖) · 质量门禁文档补 npm test · README 架构树补 hooks · Markdown 报告标题移出引用块 · 安装包排除开发期文件 -0.4.2 ← 导出菜单溢出修复与报告可读性重设计(当前):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论 +0.4.2 ← 导出菜单溢出修复与报告可读性重设计(已发布):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论 +0.4.3 ← 忽略所有空白比较选项与报告选项说明(当前):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标) 0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比) ... 0.y.z ← 长期停留,永不进入 1.x diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 9e752fc..a796123 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -77,7 +77,7 @@ src/renderer/src/ - 完全一致 → 全部 `unchanged`,无差异 - 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`) - 词级:同一删除/新增配对为 `modified` 并产生左右 `segs` -- 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines` 生效与关闭 +- 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines`、`ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文 - 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs - 行切分:结尾换行不产生多余空行 @@ -87,6 +87,7 @@ src/renderer/src/ - Markdown:表格化、管道符转义保持表格结构、引用块含人话结论 - 差异分块(`buildSegments`):空行集 / 无变更单段全展示 / 上下文 3 行与边界收敛 / 相邻区间合并 / 远距分两段省略数正确 / 自定义 context - 人话总结(`plainSummary`):无差异一致结论 / 为 0 项省略 / 三类齐全完整罗列 +- 比较选项描述(`plainOptions`)与头部说明行:无选项严格对比文案 / 生效项罗列 / 忽略所有空白遮蔽行首尾空白;TXT / Markdown / HTML 三格式头部含“比较选项”行 - 格式分发与扩展名映射(`REPORT_EXT`) ### 5.3 文本工具(`textUtils.ts`) @@ -107,6 +108,7 @@ src/renderer/src/ - 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复 - F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占 - 交换左右侧后面板文件互换 +- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 ### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`) diff --git a/package.json b/package.json index b47b5d5..9b96ead 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.4.2", + "version": "0.4.3", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index eea4503..2dbff6a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -94,7 +94,8 @@ export default function App(): ReactElement { const [options, setOptions] = useState({ trimWhitespace: false, ignoreCase: false, - ignoreBlankLines: false + ignoreBlankLines: false, + ignoreAllWhitespace: false }) // 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行) const [onlyDiff, setOnlyDiff] = useState(false) @@ -319,7 +320,8 @@ export default function App(): ReactElement { leftEncoding: paneL?.meta.encoding ?? '', rightEncoding: paneR?.meta.encoding ?? '', leftPath: paneL?.path || undefined, - rightPath: paneR?.path || undefined + rightPath: paneR?.path || undefined, + options } const content = buildReport(rows, ctx, summary, fmt) const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}` @@ -338,7 +340,7 @@ export default function App(): ReactElement { }) } }, - [diff, rows, paneL, paneR, summary, showToast] + [diff, rows, paneL, paneR, summary, options, showToast] ) const anyPane = paneL !== null || paneR !== null diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index ce01500..510498d 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -490,6 +490,51 @@ describe('App - 仅看差异视图', () => { }) }) +describe('App - 忽略所有空白', () => { + it('开启后仅行内空白不同的两侧文本判为完全一致', async () => { + window.api = mockApi({ + openFile: async (side) => + side === 'left' + ? { path: '/tmp/a.js', name: 'a.js', text: 'const x = 1;\nif (x) {\n doSomething( x )\n}', encoding: 'UTF-8', binary: false } + : { path: '/tmp/b.js', name: 'b.js', text: 'const x = 1;\nif(x){\n\tdoSomething(x)\n}', encoding: 'UTF-8', binary: false } + }) + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + fireEvent.click(screen.getByText('打开右侧')) + await screen.findAllByText('b.js') + // 默认严格对比:报差异 + expect(screen.getByText('有差异')).toBeInTheDocument() + // 开启忽略所有空白后:判为完全一致 + fireEvent.click(screen.getByLabelText('忽略所有空白')) + expect(await screen.findByText('两文件内容完全一致')).toBeInTheDocument() + }) + + it('导出报告携带比较选项说明', async () => { + let saved = '' + window.api = mockApi({ + openFile: async () => ({ + path: '/tmp/a.txt', + name: 'a.txt', + text: 'line1\nline2', + encoding: 'UTF-8', + binary: false + }), + saveReport: async (content: string) => { + saved = content + return { ok: true, path: 'C:/docs/r.txt' } + } + }) + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + fireEvent.click(screen.getByText('导出报告')) + fireEvent.click(screen.getByText('纯文本')) + await screen.findByText(/报告已保存/) + expect(saved).toContain('比较选项:无(严格逐字符对比)') + }) +}) + describe('App - F7 / Shift+F7 差异导航快捷键', () => { it('F7 跳到下一处差异,Shift+F7 回到上一处', async () => { render() diff --git a/src/renderer/src/components/Toolbar.test.tsx b/src/renderer/src/components/Toolbar.test.tsx index 0de8c32..3379c01 100644 --- a/src/renderer/src/components/Toolbar.test.tsx +++ b/src/renderer/src/components/Toolbar.test.tsx @@ -13,7 +13,7 @@ function setup(extra?: { computing?: boolean }) { const onSwap = vi.fn() const utils = render( { it('切换忽略大小写触发 onOptionsChange', () => { const { onOptionsChange } = setup() fireEvent.click(screen.getByLabelText('忽略大小写')) - expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false }) + expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false, ignoreAllWhitespace: false }) }) it('切换忽略行首尾空白触发 onOptionsChange', () => { const { onOptionsChange } = setup() fireEvent.click(screen.getByLabelText('忽略行首尾空白')) - expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false }) + expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false }) }) it('切换忽略空行触发 onOptionsChange', () => { const { onOptionsChange } = setup() fireEvent.click(screen.getByLabelText('忽略空行')) - expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true }) + expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true, ignoreAllWhitespace: false }) + }) + + it('切换忽略所有空白触发 onOptionsChange', () => { + const { onOptionsChange } = setup() + fireEvent.click(screen.getByLabelText('忽略所有空白')) + expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: true }) }) it('切换仅看差异触发 onOnlyDiffChange(true)', () => { diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index d953216..efbb15d 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -76,6 +76,17 @@ export default function Toolbar({ /> 忽略空行 +
diff --git a/src/renderer/src/diff/diffEngine.test.ts b/src/renderer/src/diff/diffEngine.test.ts index afaa474..f39fb1c 100644 --- a/src/renderer/src/diff/diffEngine.test.ts +++ b/src/renderer/src/diff/diffEngine.test.ts @@ -172,6 +172,76 @@ describe('computeDiff - 超长行词级防护', () => { }) }) +describe('computeDiff - 忽略所有空白', () => { + it('行内空格差异不再产生差异', () => { + const { summary } = computeDiff('a b c', 'abc', { ignoreAllWhitespace: true }) + expect(summary.changedLines).toBe(0) + }) + + it('制表符与全角空格同样被忽略', () => { + expect(computeDiff('a\tb', 'ab', { ignoreAllWhitespace: true }).summary.changedLines).toBe(0) + expect(computeDiff('a b', 'ab', { ignoreAllWhitespace: true }).summary.changedLines).toBe(0) + }) + + it('开启后行首尾空白差异自然被覆盖(trim 子集)', () => { + const { summary } = computeDiff(' value ', 'value', { ignoreAllWhitespace: true }) + expect(summary.changedLines).toBe(0) + }) + + it('缩进与符号间空白差异判等(代码场景)', () => { + const { summary } = computeDiff( + ' if (x) { return 1; }', + 'if(x){return 1;}', + { ignoreAllWhitespace: true } + ) + expect(summary.changedLines).toBe(0) + }) + + it('多行文本仅行内空白不同判为一致', () => { + const { summary } = computeDiff('a b\nc d\ne', 'ab\nc d\ne', { ignoreAllWhitespace: true }) + expect(summary.changedLines).toBe(0) + }) + + it('实际文字差异仍被识别且保留词级高亮', () => { + const { rows, summary } = computeDiff('a b', 'a c', { ignoreAllWhitespace: true }) + expect(summary.modified).toBe(1) + const m = rows.find((r) => r.rowKind === 'modified')! + expect(m.left.segs).not.toBeNull() + expect(m.right.segs).not.toBeNull() + }) + + it('展示文本始终为原始行(不因归一化而改写)', () => { + const { rows } = computeDiff(' a b ', 'a b', { ignoreAllWhitespace: true }) + expect(rows[0].left.text).toBe(' a b ') + expect(rows[0].right.text).toBe('a b') + }) + + it('换行结构差异不受该选项影响(行级对比边界)', () => { + const { summary } = computeDiff('ab cd', 'ab\ncd', { ignoreAllWhitespace: true }) + expect(summary.changedLines).toBeGreaterThan(0) + }) + + it('空行数量差异不受该选项影响(需配合忽略空行)', () => { + const { summary } = computeDiff('a\n\nb', 'a\nb', { ignoreAllWhitespace: true }) + expect(summary.deleted).toBe(1) + const both = computeDiff('a\n\nb', 'a\nb', { ignoreAllWhitespace: true, ignoreBlankLines: true }) + expect(both.summary.changedLines).toBe(0) + }) + + it('与忽略大小写组合生效', () => { + const { summary } = computeDiff('A B', 'ab', { + ignoreAllWhitespace: true, + ignoreCase: true + }) + expect(summary.changedLines).toBe(0) + }) + + it('未开启时行内空格差异仍被识别', () => { + const { summary } = computeDiff('a b c', 'abc') + expect(summary.changedLines).toBeGreaterThan(0) + }) +}) + describe('computeDiff - 行切分细节', () => { it('结尾换行不产生多余空行', () => { const { rows, summary } = computeDiff('a\n', 'a') diff --git a/src/renderer/src/diff/diffEngine.ts b/src/renderer/src/diff/diffEngine.ts index 0710dae..a5b09e6 100644 --- a/src/renderer/src/diff/diffEngine.ts +++ b/src/renderer/src/diff/diffEngine.ts @@ -41,6 +41,8 @@ export interface DiffOptions { ignoreCase?: boolean /** 忽略空行差异(两侧空白行互相判等,不产生增删差异) */ ignoreBlankLines?: boolean + /** 忽略全部行内空白(空格/制表符/全角空格等),仅比较实际文字内容;换行结构仍参与对比 */ + ignoreAllWhitespace?: boolean } export interface DiffResult { @@ -94,7 +96,7 @@ export function splitLines(text: string): string[] { * 计算文本差异。 * diffArrays 以“归一化行”做行级 LCS,得到增/删/同; * 删除段与新增段配对为“修改”行并做词级内联高亮。 - * trimWhitespace / ignoreCase 只影响“是否判等”,展示文本始终取原始行; + * trimWhitespace / ignoreCase / ignoreAllWhitespace 只影响“是否判等”,展示文本始终取原始行; * ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。 */ export function computeDiff( @@ -117,7 +119,9 @@ export function computeDiff( : rightSeq const norm = (s: string): string => { - let v = options.trimWhitespace ? s.trim() : s + // 忽略所有空白为 trimWhitespace 的超集:开启后行内空白一并剔除,trim 不再重复处理 + let v = options.ignoreAllWhitespace ? s.replace(/\s+/g, '') : s + if (!options.ignoreAllWhitespace && options.trimWhitespace) v = v.trim() if (options.ignoreCase) v = v.toLowerCase() return v } diff --git a/src/renderer/src/diff/report.test.ts b/src/renderer/src/diff/report.test.ts index 6f5b6c3..0fe2e5f 100644 --- a/src/renderer/src/diff/report.test.ts +++ b/src/renderer/src/diff/report.test.ts @@ -7,6 +7,7 @@ import { buildReport, buildSegments, plainSummary, + plainOptions, REPORT_EXT } from './report' @@ -164,6 +165,55 @@ describe('buildHtmlReport', () => { }) }) +describe('plainOptions - 比较选项描述', () => { + it('未传或空选项时说明为严格对比', () => { + expect(plainOptions(undefined)).toBe('无(严格逐字符对比)') + expect(plainOptions({})).toBe('无(严格逐字符对比)') + }) + + it('列出全部生效选项', () => { + expect( + plainOptions({ ignoreAllWhitespace: true, ignoreCase: true, ignoreBlankLines: true }) + ).toBe('忽略所有空白、忽略大小写、忽略空行') + expect(plainOptions({ trimWhitespace: true, ignoreCase: true })).toBe( + '忽略行首尾空白、忽略大小写' + ) + }) + + it('忽略所有空白遮蔽行首尾空白(超集不重复列出)', () => { + expect(plainOptions({ ignoreAllWhitespace: true, trimWhitespace: true })).toBe('忽略所有空白') + }) +}) + +describe('报告头部比较选项说明', () => { + const { rows, summary } = computeDiff('a b', 'ab', { ignoreAllWhitespace: true }) + const optCtx = { + ...ctx, + options: { ignoreAllWhitespace: true, ignoreCase: true } + } + + it('纯文本头部包含比较选项行', () => { + const txt = buildTxtReport(rows, optCtx, summary) + expect(txt).toContain('比较选项:忽略所有空白、忽略大小写') + }) + + it('Markdown 头部包含比较选项行(引用块内)', () => { + const md = buildMarkdownReport(rows, optCtx, summary) + expect(md).toContain('> 比较选项:忽略所有空白、忽略大小写') + }) + + it('HTML Hero 区包含比较选项徽标', () => { + const html = buildHtmlReport(rows, optCtx, summary) + expect(html).toContain('hero-opts') + expect(html).toContain('比较选项:忽略所有空白、忽略大小写') + }) + + it('未传选项时头部说明为严格对比', () => { + const txt = buildTxtReport(rows, ctx, summary) + expect(txt).toContain('比较选项:无(严格逐字符对比)') + }) +}) + describe('buildTxtReport', () => { it('输出带增删改标记与人话结论的行式文本', () => { const { rows, summary } = computeDiff('a', 'a\nb') diff --git a/src/renderer/src/diff/report.ts b/src/renderer/src/diff/report.ts index 4f17dfb..7e7dd34 100644 --- a/src/renderer/src/diff/report.ts +++ b/src/renderer/src/diff/report.ts @@ -1,4 +1,4 @@ -import { DiffRow, DiffSummary, SideCell } from './diffEngine' +import { DiffOptions, DiffRow, DiffSummary, SideCell } from './diffEngine' export type ReportFormat = 'html' | 'txt' | 'md' @@ -15,6 +15,8 @@ export interface ReportContext { leftPath?: string /** 右侧文件完整路径(可选) */ rightPath?: string + /** 本次对比生效的比较选项(用于报告头部说明,可选) */ + options?: DiffOptions } const esc = (s: string): string => @@ -116,6 +118,17 @@ export function plainSummary(summary: DiffSummary): string { return `共 ${summary.changedLines} 处不同:${parts.join('、')}` } +/** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */ +export function plainOptions(options?: DiffOptions): string { + const on: string[] = [] + // 忽略所有空白为行首尾空白的超集,同时开启时只列前者,避免误导 + if (options?.ignoreAllWhitespace) on.push('忽略所有空白') + else if (options?.trimWhitespace) on.push('忽略行首尾空白') + if (options?.ignoreCase) on.push('忽略大小写') + if (options?.ignoreBlankLines) on.push('忽略空行') + return on.length > 0 ? on.join('、') : '无(严格逐字符对比)' +} + const headLines = ( ctx: ReportContext, summary: DiffSummary, @@ -128,6 +141,7 @@ const headLines = ( ctx.rightPath ? ` [${ctx.rightPath}]` : '' }`, `对比时间:${new Date().toLocaleString('zh-CN')}`, + `比较选项:${plainOptions(ctx.options)}`, `结论:${plainSummary(summary)}`, `变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}` ] @@ -165,6 +179,11 @@ const REPORT_CSS = ` -webkit-background-clip:text; background-clip:text; color:transparent; } .hero-time { color:#74809a; font-size:12px; margin:2px 0 10px; } + .hero-opts { + color:#74809a; font-size:12px; margin:0 0 10px; + padding:4px 10px; border-radius:8px; display:inline-block; + background:rgba(148,163,255,.05); border:1px solid rgba(148,163,255,.14); + } .verdict { font-size:16px; font-weight:700; color:#f6bd87; padding:8px 14px; border-radius:10px; margin-bottom:14px; @@ -311,7 +330,8 @@ const REPORT_CSS = ` @media print { body { background:#fff; color:#111; padding:0; } .hero { border-color:#ccc; background:#fafafa; box-shadow:none; } - .hero-title { color:#3a2f8f; -webkit-text-fill-color:#3a2f8f; } + .hero-title { color:#3a2f8f; -webkit-text-fill-color:#3a2f8f; } + .hero-opts { background:#fafafa; border-color:#ddd; color:#666; } .verdict { color:#8a5a00; background:#fff7e6; border-color:#e8c07a; } .verdict.ok { color:#116639; background:#effaf3; border-color:#9fdcb6; } .file-card, .stat, .toc, .legend { background:#fafafa; border-color:#ddd; } @@ -453,6 +473,7 @@ export function buildHtmlReport(
🔍 DiffLens 差异报告
生成于 ${new Date().toLocaleString('zh-CN')}
+
比较选项:${esc(plainOptions(ctx.options))}
${esc(plainSummary(summary))}
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)}