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
+14 -2
View File
@@ -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]}`
+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 - 交换左右侧', () => {
it('交换后左右面板文件互换', async () => {
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 }
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(
<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}
summary={summary}
navIndex={0}
@@ -40,25 +40,51 @@ describe('Toolbar', () => {
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)', () => {
+35 -3
View File
@@ -52,10 +52,18 @@ export default function Toolbar({
<div className="toolbar">
<div className="tool-group">
<span className="tool-label"></span>
<label className="switch">
<label
className="switch"
title={
options.charMode
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
: '忽略行首尾空白差异'
}
>
<input
type="checkbox"
checked={options.trimWhitespace}
disabled={options.charMode}
onChange={(e) => set({ trimWhitespace: e.target.checked })}
/>
@@ -68,25 +76,49 @@ export default function Toolbar({
/>
</label>
<label className="switch">
<label
className="switch"
title={
options.charMode
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
: '忽略空行差异(两侧空白行互相判等)'
}
>
<input
type="checkbox"
checked={options.ignoreBlankLines ?? false}
disabled={options.charMode}
onChange={(e) => set({ ignoreBlankLines: e.target.checked })}
/>
</label>
<label
className="switch"
title="忽略行内全部空白(空格/制表符/全角空格),仅比较实际文字;换行结构仍参与对比"
title={
options.charMode
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
: '忽略行内全部空白(空格/制表符/全角空格),仅比较实际文字;换行结构仍参与对比'
}
>
<input
type="checkbox"
checked={options.ignoreAllWhitespace ?? false}
disabled={options.charMode}
onChange={(e) => set({ ignoreAllWhitespace: e.target.checked })}
/>
</label>
<label
className="switch"
title="空白与换行结构全部不参与判等,差异以字符粒度定位(跨行重组也能判等;超大内容自动降级行级对比)"
>
<input
type="checkbox"
checked={options.charMode ?? false}
onChange={(e) => set({ charMode: e.target.checked })}
/>
</label>
</div>
<div className="tool-group">
+219 -1
View File
@@ -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('行内替换配对 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'
@@ -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<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 }
}
+12
View File
@@ -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('报告头部比较选项说明', () => {
+6
View File
@@ -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('忽略所有空白')