diff --git a/README.md b/README.md index a5b0143..bd2093c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **交换左右侧**:一键互换两侧内容与视角 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景 +- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮,超 20 万字符自动降级行级对比并提示 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 - **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 @@ -37,7 +38,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 | 桌面壳 | Electron | | 构建 | electron-vite (Vite 5) | | 打包 | electron-builder | -| Diff 引擎 | jsdiff(行级 LCS + 词级词法 diff) | +| Diff 引擎 | jsdiff(行级 LCS + 词级词法 diff + 字符级 diff 与行回映射) | | 后台计算 | Web Worker(Vite 内联打包) | | 编码 | iconv-lite | @@ -96,7 +97,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.4.3 +- 版本:0.5.0 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 9f07bec..45a700c 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.4.3`** +当前基线版本:**`0.5.0`** --- @@ -69,8 +69,9 @@ z = 补丁版本号(Patch) 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.3 ← 忽略所有空白比较选项与报告选项说明(当前):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标) -0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比) +0.4.3 ← 忽略所有空白比较选项与报告选项说明(已发布):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标) +0.5.0 ← 字符级对比模式(当前):新增“字符级对比”开关(空白与换行结构全部不参与判等,两侧归一化为字符流 diff,跨行重组也能判等) · 字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow,视图/报告/导航零改动) · 归一化流超 20 万字符自动降级行级对比并提示(报告头部按实际语义描述) · 字符级开启时三个空白选项置灰(语义已包含) +0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比) ... 0.y.z ← 长期停留,永不进入 1.x ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index a796123..a5811d6 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -60,7 +60,7 @@ src/renderer/src/ │ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出) │ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) │ ├── TextInputModal.test.tsx # 粘贴文本弹窗 -│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用) +│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动置灰) └── __tests__/ ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/大输入回退) └── main.test.ts # 入口挂载冒烟测试 @@ -78,6 +78,7 @@ src/renderer/src/ - 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`) - 词级:同一删除/新增配对为 `modified` 并产生左右 `segs` - 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines`、`ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文 +- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);降级组(归一化流超 20 万字符自动降级行级并带 `charModeDowngraded` 标志 / 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志) - 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs - 行切分:结尾换行不产生多余空行 @@ -108,6 +109,7 @@ src/renderer/src/ - 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复 - F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占 - 交换左右侧后面板文件互换 +- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项禁用;内容超限自动降级并 toast 提示;导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项 - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 diff --git a/package.json b/package.json index 9b96ead..8a5f164 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.4.3", + "version": "0.5.0", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2dbff6a..9bdd994 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -95,7 +95,8 @@ export default function App(): ReactElement { trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, - ignoreAllWhitespace: false + ignoreAllWhitespace: false, + charMode: false }) // 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行) const [onlyDiff, setOnlyDiff] = useState(false) @@ -212,6 +213,13 @@ export default function App(): ReactElement { // diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径 const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options) + // 字符级对比因内容超限自动降级为行级时提示(依赖 diff 对象,同一结果只提示一次) + useEffect(() => { + if (diff?.charModeDowngraded) { + showToast('内容较大,字符级对比已自动降级为行级对比') + } + }, [diff, showToast]) + const rows = useMemo(() => diff?.rows ?? [], [diff]) const changedRows = useMemo(() => rows.filter((r) => r.isChanged), [rows]) const summary: DiffSummary = diff?.summary ?? EMPTY_SUMMARY @@ -314,6 +322,10 @@ export default function App(): ReactElement { async (fmt: ReportFormat) => { // 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御 if (!diff) return + // 字符级对比降级时,报告头部按实际生效的行级语义描述(全部空白忽略 + 空行忽略) + const reportOptions: DiffOptions = diff.charModeDowngraded + ? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true } + : options const ctx: ReportContext = { leftName: paneL?.meta.name ?? '', rightName: paneR?.meta.name ?? '', @@ -321,7 +333,7 @@ export default function App(): ReactElement { rightEncoding: paneR?.meta.encoding ?? '', leftPath: paneL?.path || undefined, rightPath: paneR?.path || undefined, - options + options: reportOptions } const content = buildReport(rows, ctx, summary, fmt) const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}` diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 510498d..1a3d05b 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -570,6 +570,111 @@ describe('App - F7 / Shift+F7 差异导航快捷键', () => { }) }) +describe('App - 字符级对比', () => { + it('开启后跨行重组判为完全一致(行级对比的边界在此补全)', async () => { + window.api = mockApi({ + openFile: async (side) => + side === 'left' + ? { path: '/tmp/a.txt', name: 'a.txt', text: 'ab cd', encoding: 'UTF-8', binary: false } + : { path: '/tmp/b.txt', name: 'b.txt', text: 'ab\ncd', encoding: 'UTF-8', binary: false } + }) + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + fireEvent.click(screen.getByText('打开右侧')) + await screen.findAllByText('b.txt') + // 默认行级对比:换行结构差异报“有差异” + expect(screen.getByText('有差异')).toBeInTheDocument() + // 开启字符级对比后:判为完全一致 + fireEvent.click(screen.getByLabelText('字符级对比')) + expect(await screen.findByText('两文件内容完全一致')).toBeInTheDocument() + }) + + it('开启时三个空白选项被禁用', async () => { + window.api = mockApi() + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + expect(screen.getByLabelText('忽略行首尾空白')).toBeEnabled() + fireEvent.click(screen.getByLabelText('字符级对比')) + expect(screen.getByLabelText('忽略行首尾空白')).toBeDisabled() + expect(screen.getByLabelText('忽略空行')).toBeDisabled() + expect(screen.getByLabelText('忽略所有空白')).toBeDisabled() + expect(screen.getByLabelText('忽略大小写')).toBeEnabled() + }) + + it('内容超限时自动降级行级并给出提示', async () => { + // 1900 行 × 106 字符 ≈ 201,400 归一化字符(超过 20 万阈值); + // 总行数 1901 未超过快路径阈值,同步计算即时降级 + const big = Array.from({ length: 1900 }, () => 'a'.repeat(106)).join('\n') + window.api = mockApi({ + openFile: async () => ({ + path: '/tmp/big.txt', + name: 'big.txt', + text: big, + encoding: 'UTF-8', + binary: false + }) + }) + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + 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.getByLabelText('字符级对比')) + fireEvent.click(screen.getByText('导出报告')) + fireEvent.click(screen.getByText('纯文本')) + await screen.findByText(/报告已保存/) + expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行)') + }) + + it('降级后导出报告按实际生效的行级语义描述选项', async () => { + let saved = '' + const big = Array.from({ length: 1900 }, () => 'a'.repeat(106)).join('\n') + window.api = mockApi({ + openFile: async (side) => + side === 'left' + ? { path: '/tmp/big.txt', name: 'big.txt', text: big, encoding: 'UTF-8', binary: false } + : { path: '/tmp/b.txt', name: 'b.txt', text: 'b', 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('打开右侧')) + await screen.findAllByText('b.txt') + fireEvent.click(screen.getByLabelText('字符级对比')) + await screen.findByText(/已自动降级为行级对比/) + fireEvent.click(screen.getByText('导出报告')) + fireEvent.click(screen.getByText('纯文本')) + await screen.findByText(/报告已保存/) + expect(saved).toContain('比较选项:忽略所有空白、忽略空行') + }) +}) + describe('App - 交换左右侧', () => { it('交换后左右面板文件互换', async () => { window.api = mockApi({ diff --git a/src/renderer/src/components/Toolbar.test.tsx b/src/renderer/src/components/Toolbar.test.tsx index 3379c01..4ba0630 100644 --- a/src/renderer/src/components/Toolbar.test.tsx +++ b/src/renderer/src/components/Toolbar.test.tsx @@ -5,7 +5,7 @@ import { DiffSummary } from '../diff/diffEngine' const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 } -function setup(extra?: { computing?: boolean }) { +function setup(extra?: { computing?: boolean; charMode?: boolean }) { const onOptionsChange = vi.fn() const onNav = vi.fn() const onExport = vi.fn() @@ -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, ignoreAllWhitespace: false }) + expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: false }) }) it('切换忽略行首尾空白触发 onOptionsChange', () => { const { onOptionsChange } = setup() fireEvent.click(screen.getByLabelText('忽略行首尾空白')) - expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false }) + expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: false }) }) it('切换忽略空行触发 onOptionsChange', () => { const { onOptionsChange } = setup() fireEvent.click(screen.getByLabelText('忽略空行')) - expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true, ignoreAllWhitespace: false }) + expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true, ignoreAllWhitespace: false, charMode: false }) }) it('切换忽略所有空白触发 onOptionsChange', () => { const { onOptionsChange } = setup() fireEvent.click(screen.getByLabelText('忽略所有空白')) - expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: true }) + expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: true, charMode: false }) + }) + + it('切换字符级对比触发 onOptionsChange', () => { + const { onOptionsChange } = setup() + fireEvent.click(screen.getByLabelText('字符级对比')) + expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: true }) + }) + + it('字符级对比开启时三个空白选项被禁用(语义已被包含)', () => { + setup({ charMode: true }) + expect(screen.getByLabelText('忽略行首尾空白')).toBeDisabled() + expect(screen.getByLabelText('忽略空行')).toBeDisabled() + expect(screen.getByLabelText('忽略所有空白')).toBeDisabled() + }) + + it('字符级对比开启时忽略大小写仍可用', () => { + setup({ charMode: true }) + expect(screen.getByLabelText('忽略大小写')).toBeEnabled() + expect(screen.getByLabelText('字符级对比')).toBeEnabled() + }) + + it('字符级对比关闭时空白选项恢复可用', () => { + setup() + expect(screen.getByLabelText('忽略行首尾空白')).toBeEnabled() + expect(screen.getByLabelText('忽略空行')).toBeEnabled() + expect(screen.getByLabelText('忽略所有空白')).toBeEnabled() }) it('切换仅看差异触发 onOnlyDiffChange(true)', () => { diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index efbb15d..cca047b 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -52,10 +52,18 @@ export default function Toolbar({
比较选项 -
diff --git a/src/renderer/src/diff/diffEngine.test.ts b/src/renderer/src/diff/diffEngine.test.ts index f39fb1c..b1abe0d 100644 --- a/src/renderer/src/diff/diffEngine.test.ts +++ b/src/renderer/src/diff/diffEngine.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { computeDiff } from './diffEngine' +import { computeDiff, CHAR_DIFF_MAX_CHARS } from './diffEngine' describe('computeDiff - 空文本', () => { it('空 vs 空 不产生任何行', () => { @@ -249,4 +249,222 @@ describe('computeDiff - 行切分细节', () => { expect(rows[0].rowKind).toBe('unchanged') expect(summary.changedLines).toBe(0) }) +}) + +describe('computeDiff - 字符级对比(判等)', () => { + it('跨行重组一致:换行位置不同判为一致(行级对比的边界在此补全)', () => { + const { summary } = computeDiff('ab cd', 'ab\ncd', { charMode: true }) + expect(summary.changedLines).toBe(0) + }) + + it('空行数量差异不构成差异', () => { + const { summary } = computeDiff('a\n\nb', 'ab', { charMode: true }) + expect(summary.changedLines).toBe(0) + }) + + it('行内空白差异不构成差异(隐含忽略所有空白)', () => { + const { summary } = computeDiff('a b\tc', 'abc', { charMode: true }) + expect(summary.changedLines).toBe(0) + }) + + it('全角空格与中文内容正常判等', () => { + const { summary } = computeDiff('你好 世界', '你好世界', { charMode: true }) + expect(summary.changedLines).toBe(0) + }) + + it('与忽略大小写组合生效', () => { + const { summary } = computeDiff('AB CD', 'ab\ncd', { charMode: true, ignoreCase: true }) + expect(summary.changedLines).toBe(0) + }) + + it('实际字符差异仍被识别', () => { + const { summary } = computeDiff('abc', 'abd', { charMode: true }) + expect(summary.changedLines).toBe(1) + }) + + it('完全一致且行结构相同:常规 unchanged 配对', () => { + const { rows, summary } = computeDiff('a\nb', 'a\nb', { charMode: true }) + expect(summary.changedLines).toBe(0) + expect(rows).toHaveLength(2) + expect(rows[0]).toMatchObject({ rowKind: 'unchanged' }) + expect(rows[0].left.lineNo).toBe(1) + expect(rows[0].right.lineNo).toBe(1) + expect(rows[1].left.lineNo).toBe(2) + expect(rows[1].right.lineNo).toBe(2) + }) + + it('一致但行数不等:短侧空槽仍为 unchanged,行数取较大侧', () => { + const { rows, summary } = computeDiff('a\n\nb', 'ab', { charMode: true }) + expect(summary.changedLines).toBe(0) + expect(rows).toHaveLength(3) + expect(rows[1].left.lineNo).toBe(2) + expect(rows[1].right.lineNo).toBeNull() + expect(rows.every((r) => r.rowKind === 'unchanged')).toBe(true) + }) + + it('展示文本始终为原始行(不因归一化改写)', () => { + const { rows } = computeDiff(' a b ', 'a b', { charMode: true }) + expect(rows[0].left.text).toBe(' a b ') + expect(rows[0].right.text).toBe('a b') + }) +}) + +describe('computeDiff - 字符级对比(差异定位与回映射)', () => { + it('行内替换配对 modified,segs 含 common/删除/新增段', () => { + const { rows, summary } = computeDiff('cat', 'cap', { charMode: 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() + expect(m.left.segs!.some((s) => s.kind === 'common' && s.text === 'ca')).toBe(true) + expect(m.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('t'))).toBe(true) + expect(m.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('p'))).toBe(true) + }) + + it('行内删除显示为 modified(对侧行回拉),删除字符精确高亮', () => { + const { rows, summary } = computeDiff('abcXYZdef', 'abcdef', { charMode: true }) + expect(summary.modified).toBe(1) + expect(summary.deleted).toBe(0) + const m = rows.find((r) => r.rowKind === 'modified')! + expect(m.left.lineNo).toBe(1) + expect(m.right.lineNo).toBe(1) + expect(m.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('XYZ'))).toBe(true) + // 右侧无新增字符:整行 common + expect(m.right.segs!.every((s) => s.kind === 'common')).toBe(true) + }) + + it('行内新增显示为 modified,新增字符精确高亮', () => { + const { rows, summary } = computeDiff('abcdef', 'abcXYZdef', { charMode: true }) + expect(summary.modified).toBe(1) + expect(summary.inserted).toBe(0) + const m = rows.find((r) => r.rowKind === 'modified')! + expect(m.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('XYZ'))).toBe(true) + expect(m.left.segs!.every((s) => s.kind === 'common')).toBe(true) + }) + + it('行尾追加(流尾跨界)同样回拉配对 modified', () => { + const { rows, summary } = computeDiff('abc', 'abcXYZ', { charMode: true }) + expect(summary.modified).toBe(1) + const m = rows.find((r) => r.rowKind === 'modified')! + expect(m.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('XYZ'))).toBe(true) + }) + + it('行首插入(流首跨界)同样回拉配对 modified', () => { + const { summary } = computeDiff('abc', 'XYZabc', { charMode: true }) + expect(summary.modified).toBe(1) + }) + + it('整行删除(跨行不跨界)保持 removed 语义,不误配对', () => { + const { rows, summary } = computeDiff('aa\nbb\ncc', 'aa\ncc', { charMode: true }) + expect(summary.changedLines).toBe(1) + expect(summary.deleted).toBe(1) + const rem = rows.find((r) => r.rowKind === 'removed')! + expect(rem.left.lineNo).toBe(2) + expect(rem.left.text).toBe('bb') + // 其余行锚定配对 + const anchor = rows.filter((r) => r.rowKind === 'unchanged') + expect(anchor.map((r) => [r.left.lineNo, r.right.lineNo])).toEqual([ + [1, 1], + [3, 2] + ]) + }) + + it('整行新增保持 added 语义', () => { + const { rows, summary } = computeDiff('aa\ncc', 'aa\nbb\ncc', { charMode: true }) + expect(summary.inserted).toBe(1) + const add = rows.find((r) => r.rowKind === 'added')! + expect(add.right.lineNo).toBe(2) + expect(add.right.text).toBe('bb') + }) + + it('多个独立修改块各自配对 modified,锚行穿插', () => { + const { rows, summary } = computeDiff('a\nX\nb\nY\nc', 'a\nZ\nb\nW\nc', { charMode: true }) + expect(summary.modified).toBe(2) + expect(summary.changedLines).toBe(2) + const mods = rows.filter((r) => r.rowKind === 'modified') + expect(mods[0].left.text).toBe('X') + expect(mods[0].right.text).toBe('Z') + expect(mods[1].left.text).toBe('Y') + expect(mods[1].right.text).toBe('W') + expect(rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(3) + }) + + it('删除文本中间的空白归入删除段(空白继承规则)', () => { + const { rows, summary } = computeDiff('hello big world', 'hello world', { charMode: true }) + expect(summary.modified).toBe(1) + const m = rows.find((r) => r.rowKind === 'modified')! + const del = m.left.segs!.find((s) => s.kind === 'delete')! + // 'big' 与其后空白一并划入删除段 + expect(del.text).toContain('big') + }) + + it('跨行变更配对:共享字符行锚定,变更行配 modified', () => { + const { rows, summary } = computeDiff('ab', 'a\nx', { charMode: true }) + expect(summary.changedLines).toBe(1) + expect(summary.modified).toBe(1) + const m = rows.find((r) => r.rowKind === 'modified')! + expect(m.left.text).toBe('ab') + expect(m.right.text).toBe('x') + expect(m.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('b'))).toBe(true) + // 双侧行号序列各自单调不减(空槽跳过) + const lNos = rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null) + const rNos = rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null) + expect([...lNos].sort((a, b) => a - b)).toEqual(lNos) + expect([...rNos].sort((a, b) => a - b)).toEqual(rNos) + }) + + it('空 vs 非空全部为新增行', () => { + const { rows, summary } = computeDiff('', 'a\nb', { charMode: true }) + expect(summary.inserted).toBe(2) + expect(rows.every((r) => r.rowKind === 'added')).toBe(true) + }) + + it('空 vs 空 不产生任何行', () => { + const { rows, summary } = computeDiff('', '', { charMode: true }) + expect(rows).toHaveLength(0) + expect(summary.changedLines).toBe(0) + }) + + it('忽略大小写只影响判等,diff 定位按原字符', () => { + const { summary } = computeDiff('Cat', 'car', { charMode: true, ignoreCase: true }) + // 'ca' 一致,t 与 r 为一处修改 + expect(summary.modified).toBe(1) + }) +}) + +describe('computeDiff - 字符级对比(超限降级)', () => { + it('归一化流超限自动降级行级并携带降级标志', () => { + const left = 'a'.repeat(100001) + const right = 'a'.repeat(100001) + const result = computeDiff(left, right, { charMode: true }) + expect(result.charModeDowngraded).toBe(true) + // 降级行级采用“忽略所有空白 + 忽略空行”语义:全 a 单行仍判一致 + expect(result.summary.changedLines).toBe(0) + }) + + it('降级结果与等效行级选项直接计算一致', () => { + const left = 'a'.repeat(100001) + '\nb' + const right = 'a'.repeat(100001) + '\nc' + const result = computeDiff(left, right, { charMode: true }) + expect(result.charModeDowngraded).toBe(true) + const direct = computeDiff(left, right, { + ignoreAllWhitespace: true, + ignoreBlankLines: true + }) + expect(result.summary).toEqual(direct.summary) + }) + + it('恰好阈值不降级', () => { + const half = CHAR_DIFF_MAX_CHARS / 2 + const { summary, charModeDowngraded } = computeDiff('a'.repeat(half), 'a'.repeat(half), { + charMode: true + }) + expect(charModeDowngraded).toBeUndefined() + expect(summary.changedLines).toBe(0) + }) + + it('未开启 charMode 时不携带降级标志', () => { + const { charModeDowngraded } = computeDiff('a', 'b', {}) + expect(charModeDowngraded).toBeUndefined() + }) }) \ No newline at end of file diff --git a/src/renderer/src/diff/diffEngine.ts b/src/renderer/src/diff/diffEngine.ts index a5b09e6..74b8512 100644 --- a/src/renderer/src/diff/diffEngine.ts +++ b/src/renderer/src/diff/diffEngine.ts @@ -1,4 +1,4 @@ -import { diffArrays, diffWordsWithSpace } from 'diff' +import { diffArrays, diffChars, diffWordsWithSpace } from 'diff' /** 词级分段类型 */ export type SegKind = 'common' | 'insert' | 'delete' @@ -43,11 +43,19 @@ export interface DiffOptions { ignoreBlankLines?: boolean /** 忽略全部行内空白(空格/制表符/全角空格等),仅比较实际文字内容;换行结构仍参与对比 */ ignoreAllWhitespace?: boolean + /** + * 字符级对比:全部空白与换行结构都不参与判等,两侧全文归一化为字符流做 diff, + * 差异以字符粒度回映射到行(跨行重组也能判等)。隐含 ignoreAllWhitespace 的全部语义; + * 归一化流超限(CHAR_DIFF_MAX_CHARS)时自动降级为行级对比并置 charModeDowngraded。 + */ + charMode?: boolean } export interface DiffResult { rows: DiffRow[] summary: DiffSummary + /** 字符级对比因内容超限自动降级为行级对比时为 true(供界面与报告提示) */ + charModeDowngraded?: boolean } function emptyLine(): SideCell { @@ -93,13 +101,39 @@ export function splitLines(text: string): string[] { } /** - * 计算文本差异。 - * diffArrays 以“归一化行”做行级 LCS,得到增/删/同; + * 计算文本差异(统一入口)。 + * charMode 开启时走字符级对比(超限自动降级行级并携带降级标志);否则走行级对比。 + */ +export function computeDiff( + leftText: string, + rightText: string, + options: DiffOptions = {} +): DiffResult { + if (options.charMode) { + const charResult = computeCharDiff(leftText, rightText, options) + if (charResult) return charResult + // 归一化流超限:降级为行级对比,选项取字符级语义的最接近子集 + // (全部空白忽略 + 空行忽略;换行结构差异是行级架构无法消除的边界) + const result = computeLineDiff(leftText, rightText, { + ...options, + charMode: false, + ignoreAllWhitespace: true, + ignoreBlankLines: true, + trimWhitespace: false + }) + result.charModeDowngraded = true + return result + } + return computeLineDiff(leftText, rightText, options) +} + +/** + * 行级对比:diffArrays 以“归一化行”做行级 LCS,得到增/删/同; * 删除段与新增段配对为“修改”行并做词级内联高亮。 * trimWhitespace / ignoreCase / ignoreAllWhitespace 只影响“是否判等”,展示文本始终取原始行; * ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。 */ -export function computeDiff( +function computeLineDiff( leftText: string, rightText: string, options: DiffOptions = {} @@ -246,5 +280,363 @@ export function computeDiff( modified: rows.filter((r) => r.rowKind === 'modified').length } + return { rows, summary } +} + +/** ============ 字符级对比 ============ */ + +/** + * 字符级对比的字符总量上限(两侧归一化流合计): + * diffChars 为 O(ND),超限后计算可能耗时失控,自动降级为行级对比。 + */ +export const CHAR_DIFF_MAX_CHARS = 200000 + +/** 空白字符判定:与 ignoreAllWhitespace 的 \s 语义一致(含换行、制表符、全角空格) */ +function isWsChar(ch: string): boolean { + return /\s/.test(ch) +} + +/** 归一化字符流:剔除全部空白后的连续字符,并记录每个字符的原始行号与码点列号 */ +interface CharStream { + /** 归一化字符连成的字符串(供 diffChars 直接消费) */ + text: string + /** 与 text 逐字符对应的原始行号(1 基) */ + lineNos: number[] + /** 与 text 逐字符对应的原始码点列号(0 基) */ + cols: number[] +} + +function buildCharStream(lines: string[], norm: (ch: string) => string): CharStream { + const chars: string[] = [] + const lineNos: number[] = [] + const cols: number[] = [] + lines.forEach((text, i) => { + let col = 0 + for (const ch of text) { + if (!isWsChar(ch)) { + chars.push(norm(ch)) + lineNos.push(i + 1) + cols.push(col) + } + col++ + } + }) + return { text: chars.join(''), lineNos, cols } +} + +/** + * 按行内字符状态重建高亮分段(modified 行的 segs): + * 非空白字符查状态表(缺省 common);空白字符继承前一个非空白字符的状态(行首空白按 common), + * 保证被删除文本中间的空白归入删除段、新增文本中间的空白归入新增段。 + */ +function charSegs(text: string, stat: Map | undefined): Seg[] { + const segs: Seg[] = [] + let cur: SegKind | null = null + let buf = '' + let lastStat = 0 + let col = 0 + for (const ch of text) { + let st: number + if (isWsChar(ch)) { + st = lastStat + } else { + st = stat?.get(col) ?? 0 + lastStat = st + } + const kind: SegKind = st === 1 ? 'delete' : st === 2 ? 'insert' : 'common' + if (kind !== cur) { + if (cur !== null) segs.push({ text: buf, kind: cur }) + cur = kind + buf = ch + } else { + buf += ch + } + col++ + } + if (cur !== null) segs.push({ text: buf, kind: cur }) + return segs +} + +/** + * 字符级对比:两侧全文归一化为字符流 → diffChars 对齐 → 字符差异回映射为 DiffRow。 + * + * 回映射规则(输出仍为 DiffRow,视图/报告/导航零改动): + * - 含变更(删除/新增)字符的行为“变更行”(跨界行——既有 common 又有变更字符——也划归变更行, + * 其 common 部分在 modified 行的 segs 中以 common 段呈现,信息不丢失); + * - 连续变更行组配对:等长配 modified(segs 按整行重建),多余侧降级 removed / added(与行级 commitModified 同构); + * - 变更组之间的行(含纯空白行)为“锚行”,左右按序 zip 配对输出 unchanged,短侧空槽仍标 unchanged + * (字符级语义下纯空白行不构成差异)。 + * + * 内容超限(CHAR_DIFF_MAX_CHARS)返回 null,由调用方降级行级。 + */ +function computeCharDiff( + leftText: string, + rightText: string, + options: DiffOptions +): DiffResult | null { + const leftOrig = splitLines(leftText) + const rightOrig = splitLines(rightText) + // 大小写归一化在构造流时完成:toLowerCase 展开为多字符的字符(如 İ→i̇)退回原样, + // 避免归一化后字符数变化破坏流索引 + const normCh = (ch: string): string => { + if (!options.ignoreCase) return ch + const lo = ch.toLowerCase() + return lo.length === 1 ? lo : ch + } + const left = buildCharStream(leftOrig, normCh) + const right = buildCharStream(rightOrig, normCh) + if (left.text.length + right.text.length > CHAR_DIFF_MAX_CHARS) return null + + const parts = diffChars(left.text, right.text) + + // 流区间块序列:common 为 eq 块,连续 removed/added 合并为 ne 块(与行级 parts 循环同构) + interface Block { + eq: boolean + l0: number + l1: number + r0: number + r1: number + /** removed-only 时回拉的对侧配对行(见下方预处理说明) */ + pullR?: number + /** added-only 时回拉的对侧配对行 */ + pullL?: number + } + const blocks: Block[] = [] + let li = 0 + let ri = 0 + for (const part of parts) { + const len = part.value.length + const isEq = !part.added && !part.removed + const last = blocks[blocks.length - 1] + if (!last || last.eq !== isEq) { + blocks.push({ eq: isEq, l0: li, l1: li, r0: ri, r1: ri }) + } + const b = blocks[blocks.length - 1] + if (isEq) { + b.l1 += len + b.r1 += len + li += len + ri += len + } else if (part.removed) { + b.l1 += len + li += len + } else { + b.r1 += len + ri += len + } + } + + // ne 块字符状态与涉及行集合(跨界行划归变更组) + const changedL = new Set() + const changedR = new Set() + for (const b of blocks) { + if (b.eq) continue + for (let i = b.l0; i < b.l1; i++) changedL.add(left.lineNos[i]) + for (let i = b.r0; i < b.r1; i++) changedR.add(right.lineNos[i]) + } + + // 已划归变更组的行(含跨界行与回拉行),锚段输出时排除 + const pairedL = new Set(changedL) + const pairedR = new Set(changedR) + + // 单侧变更块(removed-only / added-only)的对侧行回拉: + // 变更行与相邻 eq 块同行(跨界延续,如行内删除 abcXYZdef → abcdef)时, + // 把对侧对齐行拉入变更组配对,使行内增删显示为 modified(字符高亮)而非整行 removed/added; + // 跨行整行增删(变更行与相邻 eq 块不同行)不回拉,维持 removed/added 语义。 + for (let bi = 0; bi < blocks.length; bi++) { + const b = blocks[bi] + if (b.eq) continue + const pe = bi > 0 && blocks[bi - 1].eq ? blocks[bi - 1] : undefined + const nx = bi + 1 < blocks.length && blocks[bi + 1].eq ? blocks[bi + 1] : undefined + if (b.l0 < b.l1 && b.r0 === b.r1) { + // removed-only:左侧变更行跨界时回拉右侧对齐行 + const firstL = left.lineNos[b.l0] + const lastL = left.lineNos[b.l1 - 1] + const crossL = + (pe !== undefined && firstL === left.lineNos[pe.l1 - 1]) || + (nx !== undefined && lastL === left.lineNos[nx.l0]) + if (!crossL) continue + const rn = + b.r0 < right.lineNos.length + ? right.lineNos[b.r0] + : right.lineNos.length > 0 + ? right.lineNos[right.lineNos.length - 1] + : 0 + if (rn < 1) continue + const crossR = + (pe !== undefined && rn === right.lineNos[pe.r1 - 1]) || + (nx !== undefined && rn === right.lineNos[nx.r0]) + if (crossR) { + b.pullR = rn + pairedR.add(rn) + } + } else if (b.r0 < b.r1 && b.l0 === b.l1) { + // added-only:右侧变更行跨界时回拉左侧对齐行 + const firstR = right.lineNos[b.r0] + const lastR = right.lineNos[b.r1 - 1] + const crossR = + (pe !== undefined && firstR === right.lineNos[pe.r1 - 1]) || + (nx !== undefined && lastR === right.lineNos[nx.r0]) + if (!crossR) continue + const ln = + b.l0 < left.lineNos.length + ? left.lineNos[b.l0] + : left.lineNos.length > 0 + ? left.lineNos[left.lineNos.length - 1] + : 0 + if (ln < 1) continue + const crossL = + (pe !== undefined && ln === left.lineNos[pe.l1 - 1]) || + (nx !== undefined && ln === left.lineNos[nx.l0]) + if (crossL) { + b.pullL = ln + pairedL.add(ln) + } + } + } + + // 行内变更字符的列位状态(行号 → 列号 → 1 删除 / 2 新增),供 segs 重建 + const statByLineL = new Map>() + const statByLineR = new Map>() + for (const b of blocks) { + if (b.eq) continue + for (let i = b.l0; i < b.l1; i++) { + const ln = left.lineNos[i] + let m = statByLineL.get(ln) + if (!m) { + m = new Map() + statByLineL.set(ln, m) + } + m.set(left.cols[i], 1) + } + for (let i = b.r0; i < b.r1; i++) { + const rn = right.lineNos[i] + let m = statByLineR.get(rn) + if (!m) { + m = new Map() + statByLineR.set(rn, m) + } + m.set(right.cols[i], 2) + } + } + + let rowSeq = 0 + const rows: DiffRow[] = [] + let pendingL: number[] = [] + let pendingR: number[] = [] + + /** 变更行组配对输出(与行级 commitModified 同构:等长 modified / 多余降级 removed·added) */ + const flushChange = (): void => { + const n = Math.max(pendingL.length, pendingR.length) + for (let i = 0; i < n; i++) { + const ln = pendingL[i] + const rn = pendingR[i] + if (ln !== undefined && rn !== undefined) { + rows.push({ + id: `r${rowSeq++}`, + rowKind: 'modified', + isChanged: true, + left: { + lineNo: ln, + text: leftOrig[ln - 1], + segs: charSegs(leftOrig[ln - 1], statByLineL.get(ln)) + }, + right: { + lineNo: rn, + text: rightOrig[rn - 1], + segs: charSegs(rightOrig[rn - 1], statByLineR.get(rn)) + } + }) + } else if (ln !== undefined) { + rows.push({ + id: `r${rowSeq++}`, + rowKind: 'removed', + isChanged: true, + left: { lineNo: ln, text: leftOrig[ln - 1], segs: null }, + right: emptyLine() + }) + } else if (rn !== undefined) { + rows.push({ + id: `r${rowSeq++}`, + rowKind: 'added', + isChanged: true, + left: emptyLine(), + right: { lineNo: rn, text: rightOrig[rn - 1], segs: null } + }) + } + } + pendingL = [] + pendingR = [] + } + + /** 锚行组按序 zip 配对输出 unchanged(短侧空槽,纯空白行不构成差异) */ + const emitAnchors = (la: number[], ra: number[]): void => { + const n = Math.max(la.length, ra.length) + for (let i = 0; i < n; i++) { + const ln = la[i] ?? null + const rn = ra[i] ?? null + rows.push({ + id: `r${rowSeq++}`, + rowKind: 'unchanged', + isChanged: false, + left: ln === null ? emptyLine() : { lineNo: ln, text: leftOrig[ln - 1], segs: null }, + right: rn === null ? emptyLine() : { lineNo: rn, text: rightOrig[rn - 1], segs: null } + }) + } + } + + // 行游标推进:锚段右边界 = 下一 ne 块首字符行 - 1(流尽则为文件末行) + let lCursor = 1 + let rCursor = 1 + for (const b of blocks) { + if (b.eq) { + flushChange() + const lEnd = b.l1 < left.lineNos.length ? left.lineNos[b.l1] - 1 : leftOrig.length + const rEnd = b.r1 < right.lineNos.length ? right.lineNos[b.r1] - 1 : rightOrig.length + const la: number[] = [] + for (let ln = lCursor; ln <= lEnd; ln++) { + if (!pairedL.has(ln)) la.push(ln) + } + const ra: number[] = [] + for (let rn = rCursor; rn <= rEnd; rn++) { + if (!pairedR.has(rn)) ra.push(rn) + } + emitAnchors(la, ra) + lCursor = lEnd + 1 + rCursor = rEnd + 1 + } else { + // 变更行收集(ne 块内行号单调不减,尾去重即可);单侧变更块带上回拉的对侧配对行 + for (let i = b.l0; i < b.l1; i++) { + const ln = left.lineNos[i] + if (pendingL[pendingL.length - 1] !== ln) pendingL.push(ln) + } + for (let i = b.r0; i < b.r1; i++) { + const rn = right.lineNos[i] + if (pendingR[pendingR.length - 1] !== rn) pendingR.push(rn) + } + if (b.pullR !== undefined && pendingR.length === 0) pendingR.push(b.pullR) + if (b.pullL !== undefined && pendingL.length === 0) pendingL.push(b.pullL) + } + } + flushChange() + + // 尾部剩余行(最后一块之后的空白行/未覆盖行)按锚行输出 + const tailLa: number[] = [] + for (let ln = lCursor; ln <= leftOrig.length; ln++) { + if (!pairedL.has(ln)) tailLa.push(ln) + } + const tailRa: number[] = [] + for (let rn = rCursor; rn <= rightOrig.length; rn++) { + if (!pairedR.has(rn)) tailRa.push(rn) + } + emitAnchors(tailLa, tailRa) + + const summary: DiffSummary = { + changedLines: rows.filter((r) => r.isChanged).length, + inserted: rows.filter((r) => r.rowKind === 'added').length, + deleted: rows.filter((r) => r.rowKind === 'removed').length, + modified: rows.filter((r) => r.rowKind === 'modified').length + } + return { rows, summary } } \ No newline at end of file diff --git a/src/renderer/src/diff/report.test.ts b/src/renderer/src/diff/report.test.ts index 0fe2e5f..decb5ca 100644 --- a/src/renderer/src/diff/report.test.ts +++ b/src/renderer/src/diff/report.test.ts @@ -183,6 +183,18 @@ describe('plainOptions - 比较选项描述', () => { it('忽略所有空白遮蔽行首尾空白(超集不重复列出)', () => { expect(plainOptions({ ignoreAllWhitespace: true, trimWhitespace: true })).toBe('忽略所有空白') }) + + it('字符级对比单独描述并遮蔽全部空白类选项', () => { + expect( + plainOptions({ charMode: true, trimWhitespace: true, ignoreAllWhitespace: true, ignoreBlankLines: true }) + ).toBe('字符级对比(忽略全部空白与换行)') + }) + + it('字符级对比与忽略大小写组合列出', () => { + expect(plainOptions({ charMode: true, ignoreCase: true })).toBe( + '字符级对比(忽略全部空白与换行)、忽略大小写' + ) + }) }) describe('报告头部比较选项说明', () => { diff --git a/src/renderer/src/diff/report.ts b/src/renderer/src/diff/report.ts index 7e7dd34..ed90797 100644 --- a/src/renderer/src/diff/report.ts +++ b/src/renderer/src/diff/report.ts @@ -120,6 +120,12 @@ export function plainSummary(summary: DiffSummary): string { /** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */ export function plainOptions(options?: DiffOptions): string { + // 字符级对比为最强空白语义(全部空白与换行都不参与判等),单独描述并遮蔽空白类子选项 + if (options?.charMode) { + const on = ['字符级对比(忽略全部空白与换行)'] + if (options.ignoreCase) on.push('忽略大小写') + return on.join('、') + } const on: string[] = [] // 忽略所有空白为行首尾空白的超集,同时开启时只列前者,避免误导 if (options?.ignoreAllWhitespace) on.push('忽略所有空白')