feat: v0.5.0 字符级对比模式 - 新增字符级对比开关(空白与换行结构全部不参与判等、两侧归一化为字符流 diff、跨行重组也能判等)、字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow 视图/报告/导航零改动)、归一化流超 20 万字符自动降级行级对比并提示、字符级开启时三个空白选项置灰

This commit is contained in:
2026-08-18 10:56:43 +08:00
parent e639dfe88d
commit 74afeaeb84
12 changed files with 830 additions and 23 deletions
+3 -2
View File
@@ -18,6 +18,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **交换左右侧**:一键互换两侧内容与视角 - **交换左右侧**:一键互换两侧内容与视角
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮,超 20 万字符自动降级行级对比并提示
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿 - **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
@@ -37,7 +38,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
| 桌面壳 | Electron | | 桌面壳 | Electron |
| 构建 | electron-vite (Vite 5) | | 构建 | electron-vite (Vite 5) |
| 打包 | electron-builder | | 打包 | electron-builder |
| Diff 引擎 | jsdiff(行级 LCS + 词级词法 diff | | Diff 引擎 | jsdiff(行级 LCS + 词级词法 diff + 字符级 diff 与行回映射 |
| 后台计算 | Web WorkerVite 内联打包) | | 后台计算 | Web WorkerVite 内联打包) |
| 编码 | iconv-lite | | 编码 | iconv-lite |
@@ -96,7 +97,7 @@ src/
- 作者:thzxx - 作者:thzxx
- 组织:MetonaTeam - 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE) - 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.4.3 - 版本:0.5.0
--- ---
+4 -3
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `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.0 ← 差异计算 worker 化(已发布):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用
0.4.1 ← worker 创建开销优化与文档对齐(已发布):worker 延迟至去抖到期才创建(去抖期内取消零创建开销,无 Worker 回退同样走去抖) · 质量门禁文档补 npm test · README 架构树补 hooks · Markdown 报告标题移出引用块 · 安装包排除开发期文件 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.4.3 ← 忽略所有空白比较选项与报告选项说明(已发布):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标)
0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比 0.5.0 ← 字符级对比模式(当前):新增“字符级对比”开关(空白与换行结构全部不参与判等,两侧归一化为字符流 diff,跨行重组也能判等) · 字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow,视图/报告/导航零改动) · 归一化流超 20 万字符自动降级行级对比并提示(报告头部按实际语义描述) · 字符级开启时三个空白选项置灰(语义已包含
0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
... ...
0.y.z ← 长期停留,永不进入 1.x 0.y.z ← 长期停留,永不进入 1.x
``` ```
+3 -1
View File
@@ -60,7 +60,7 @@ src/renderer/src/
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出) │ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出)
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) │ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗 │ ├── TextInputModal.test.tsx # 粘贴文本弹窗
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用) │ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动置灰
└── __tests__/ └── __tests__/
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/大输入回退) ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/大输入回退)
└── main.test.ts # 入口挂载冒烟测试 └── main.test.ts # 入口挂载冒烟测试
@@ -78,6 +78,7 @@ src/renderer/src/
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null` - 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs` - 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
- 忽略选项:`ignoreCase``trimWhitespace``ignoreBlankLines``ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文 - 忽略选项:`ignoreCase``trimWhitespace``ignoreBlankLines``ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文
- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);降级组(归一化流超 20 万字符自动降级行级并带 `charModeDowngraded` 标志 / 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志)
- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs - 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs
- 行切分:结尾换行不产生多余空行 - 行切分:结尾换行不产生多余空行
@@ -108,6 +109,7 @@ src/renderer/src/
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复 - 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占 - F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
- 交换左右侧后面板文件互换 - 交换左右侧后面板文件互换
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项禁用;内容超限自动降级并 toast 提示;导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.4.3", "version": "0.5.0",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+14 -2
View File
@@ -95,7 +95,8 @@ export default function App(): ReactElement {
trimWhitespace: false, trimWhitespace: false,
ignoreCase: false, ignoreCase: false,
ignoreBlankLines: false, ignoreBlankLines: false,
ignoreAllWhitespace: false ignoreAllWhitespace: false,
charMode: false
}) })
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行) // 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
const [onlyDiff, setOnlyDiff] = useState(false) const [onlyDiff, setOnlyDiff] = useState(false)
@@ -212,6 +213,13 @@ export default function App(): ReactElement {
// diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径 // diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径
const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options) const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options)
// 字符级对比因内容超限自动降级为行级时提示(依赖 diff 对象,同一结果只提示一次)
useEffect(() => {
if (diff?.charModeDowngraded) {
showToast('内容较大,字符级对比已自动降级为行级对比')
}
}, [diff, showToast])
const rows = useMemo(() => diff?.rows ?? [], [diff]) const rows = useMemo(() => diff?.rows ?? [], [diff])
const changedRows = useMemo(() => rows.filter((r) => r.isChanged), [rows]) const changedRows = useMemo(() => rows.filter((r) => r.isChanged), [rows])
const summary: DiffSummary = diff?.summary ?? EMPTY_SUMMARY const summary: DiffSummary = diff?.summary ?? EMPTY_SUMMARY
@@ -314,6 +322,10 @@ export default function App(): ReactElement {
async (fmt: ReportFormat) => { async (fmt: ReportFormat) => {
// 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御 // 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御
if (!diff) return if (!diff) return
// 字符级对比降级时,报告头部按实际生效的行级语义描述(全部空白忽略 + 空行忽略)
const reportOptions: DiffOptions = diff.charModeDowngraded
? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true }
: options
const ctx: ReportContext = { const ctx: ReportContext = {
leftName: paneL?.meta.name ?? '', leftName: paneL?.meta.name ?? '',
rightName: paneR?.meta.name ?? '', rightName: paneR?.meta.name ?? '',
@@ -321,7 +333,7 @@ export default function App(): ReactElement {
rightEncoding: paneR?.meta.encoding ?? '', rightEncoding: paneR?.meta.encoding ?? '',
leftPath: paneL?.path || undefined, leftPath: paneL?.path || undefined,
rightPath: paneR?.path || undefined, rightPath: paneR?.path || undefined,
options options: reportOptions
} }
const content = buildReport(rows, ctx, summary, fmt) const content = buildReport(rows, ctx, summary, fmt)
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}` const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
+105
View File
@@ -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(<App />)
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(<App />)
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(<App />)
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(<App />)
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(<App />)
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 - 交换左右侧', () => { describe('App - 交换左右侧', () => {
it('交换后左右面板文件互换', async () => { it('交换后左右面板文件互换', async () => {
window.api = mockApi({ window.api = mockApi({
+32 -6
View File
@@ -5,7 +5,7 @@ import { DiffSummary } from '../diff/diffEngine'
const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 } 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 onOptionsChange = vi.fn()
const onNav = vi.fn() const onNav = vi.fn()
const onExport = vi.fn() const onExport = vi.fn()
@@ -13,7 +13,7 @@ function setup(extra?: { computing?: boolean }) {
const onSwap = vi.fn() const onSwap = vi.fn()
const utils = render( const utils = render(
<Toolbar <Toolbar
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false }} options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: extra?.charMode ?? false }}
onOptionsChange={onOptionsChange} onOptionsChange={onOptionsChange}
summary={summary} summary={summary}
navIndex={0} navIndex={0}
@@ -40,25 +40,51 @@ describe('Toolbar', () => {
it('切换忽略大小写触发 onOptionsChange', () => { it('切换忽略大小写触发 onOptionsChange', () => {
const { onOptionsChange } = setup() const { onOptionsChange } = setup()
fireEvent.click(screen.getByLabelText('忽略大小写')) 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', () => { it('切换忽略行首尾空白触发 onOptionsChange', () => {
const { onOptionsChange } = setup() const { onOptionsChange } = setup()
fireEvent.click(screen.getByLabelText('忽略行首尾空白')) 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', () => { it('切换忽略空行触发 onOptionsChange', () => {
const { onOptionsChange } = setup() const { onOptionsChange } = setup()
fireEvent.click(screen.getByLabelText('忽略空行')) 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', () => { it('切换忽略所有空白触发 onOptionsChange', () => {
const { onOptionsChange } = setup() const { onOptionsChange } = setup()
fireEvent.click(screen.getByLabelText('忽略所有空白')) 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)', () => { it('切换仅看差异触发 onOnlyDiffChange(true)', () => {
+35 -3
View File
@@ -52,10 +52,18 @@ export default function Toolbar({
<div className="toolbar"> <div className="toolbar">
<div className="tool-group"> <div className="tool-group">
<span className="tool-label"></span> <span className="tool-label"></span>
<label className="switch"> <label
className="switch"
title={
options.charMode
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
: '忽略行首尾空白差异'
}
>
<input <input
type="checkbox" type="checkbox"
checked={options.trimWhitespace} checked={options.trimWhitespace}
disabled={options.charMode}
onChange={(e) => set({ trimWhitespace: e.target.checked })} onChange={(e) => set({ trimWhitespace: e.target.checked })}
/> />
@@ -68,25 +76,49 @@ export default function Toolbar({
/> />
</label> </label>
<label className="switch"> <label
className="switch"
title={
options.charMode
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
: '忽略空行差异(两侧空白行互相判等)'
}
>
<input <input
type="checkbox" type="checkbox"
checked={options.ignoreBlankLines ?? false} checked={options.ignoreBlankLines ?? false}
disabled={options.charMode}
onChange={(e) => set({ ignoreBlankLines: e.target.checked })} onChange={(e) => set({ ignoreBlankLines: e.target.checked })}
/> />
</label> </label>
<label <label
className="switch" className="switch"
title="忽略行内全部空白(空格/制表符/全角空格),仅比较实际文字;换行结构仍参与对比" title={
options.charMode
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
: '忽略行内全部空白(空格/制表符/全角空格),仅比较实际文字;换行结构仍参与对比'
}
> >
<input <input
type="checkbox" type="checkbox"
checked={options.ignoreAllWhitespace ?? false} checked={options.ignoreAllWhitespace ?? false}
disabled={options.charMode}
onChange={(e) => set({ ignoreAllWhitespace: e.target.checked })} onChange={(e) => set({ ignoreAllWhitespace: e.target.checked })}
/> />
</label> </label>
<label
className="switch"
title="空白与换行结构全部不参与判等,差异以字符粒度定位(跨行重组也能判等;超大内容自动降级行级对比)"
>
<input
type="checkbox"
checked={options.charMode ?? false}
onChange={(e) => set({ charMode: e.target.checked })}
/>
</label>
</div> </div>
<div className="tool-group"> <div className="tool-group">
+219 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { computeDiff } from './diffEngine' import { computeDiff, CHAR_DIFF_MAX_CHARS } from './diffEngine'
describe('computeDiff - 空文本', () => { describe('computeDiff - 空文本', () => {
it('空 vs 空 不产生任何行', () => { it('空 vs 空 不产生任何行', () => {
@@ -249,4 +249,222 @@ describe('computeDiff - 行切分细节', () => {
expect(rows[0].rowKind).toBe('unchanged') expect(rows[0].rowKind).toBe('unchanged')
expect(summary.changedLines).toBe(0) 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('行内替换配对 modifiedsegs 含 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()
})
}) })
+396 -4
View File
@@ -1,4 +1,4 @@
import { diffArrays, diffWordsWithSpace } from 'diff' import { diffArrays, diffChars, diffWordsWithSpace } from 'diff'
/** 词级分段类型 */ /** 词级分段类型 */
export type SegKind = 'common' | 'insert' | 'delete' export type SegKind = 'common' | 'insert' | 'delete'
@@ -43,11 +43,19 @@ export interface DiffOptions {
ignoreBlankLines?: boolean ignoreBlankLines?: boolean
/** 忽略全部行内空白(空格/制表符/全角空格等),仅比较实际文字内容;换行结构仍参与对比 */ /** 忽略全部行内空白(空格/制表符/全角空格等),仅比较实际文字内容;换行结构仍参与对比 */
ignoreAllWhitespace?: boolean ignoreAllWhitespace?: boolean
/**
* 字符级对比:全部空白与换行结构都不参与判等,两侧全文归一化为字符流做 diff,
* 差异以字符粒度回映射到行(跨行重组也能判等)。隐含 ignoreAllWhitespace 的全部语义;
* 归一化流超限(CHAR_DIFF_MAX_CHARS)时自动降级为行级对比并置 charModeDowngraded。
*/
charMode?: boolean
} }
export interface DiffResult { export interface DiffResult {
rows: DiffRow[] rows: DiffRow[]
summary: DiffSummary summary: DiffSummary
/** 字符级对比因内容超限自动降级为行级对比时为 true(供界面与报告提示) */
charModeDowngraded?: boolean
} }
function emptyLine(): SideCell { 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 只影响“是否判等”,展示文本始终取原始行; * trimWhitespace / ignoreCase / ignoreAllWhitespace 只影响“是否判等”,展示文本始终取原始行;
* ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。 * ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。
*/ */
export function computeDiff( function computeLineDiff(
leftText: string, leftText: string,
rightText: string, rightText: string,
options: DiffOptions = {} options: DiffOptions = {}
@@ -246,5 +280,363 @@ export function computeDiff(
modified: rows.filter((r) => r.rowKind === 'modified').length 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<number, number> | 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<number>()
const changedR = new Set<number>()
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<number, Map<number, number>>()
const statByLineR = new Map<number, Map<number, number>>()
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 } return { rows, summary }
} }
+12
View File
@@ -183,6 +183,18 @@ describe('plainOptions - 比较选项描述', () => {
it('忽略所有空白遮蔽行首尾空白(超集不重复列出)', () => { it('忽略所有空白遮蔽行首尾空白(超集不重复列出)', () => {
expect(plainOptions({ ignoreAllWhitespace: true, trimWhitespace: true })).toBe('忽略所有空白') 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('报告头部比较选项说明', () => { describe('报告头部比较选项说明', () => {
+6
View File
@@ -120,6 +120,12 @@ export function plainSummary(summary: DiffSummary): string {
/** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */ /** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */
export function plainOptions(options?: DiffOptions): string { export function plainOptions(options?: DiffOptions): string {
// 字符级对比为最强空白语义(全部空白与换行都不参与判等),单独描述并遮蔽空白类子选项
if (options?.charMode) {
const on = ['字符级对比(忽略全部空白与换行)']
if (options.ignoreCase) on.push('忽略大小写')
return on.join('、')
}
const on: string[] = [] const on: string[] = []
// 忽略所有空白为行首尾空白的超集,同时开启时只列前者,避免误导 // 忽略所有空白为行首尾空白的超集,同时开启时只列前者,避免误导
if (options?.ignoreAllWhitespace) on.push('忽略所有空白') if (options?.ignoreAllWhitespace) on.push('忽略所有空白')