比较选项
-
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('忽略所有空白')