diff --git a/src/libs/diff.js b/src/libs/diff.js new file mode 100644 index 0000000..800a09e --- /dev/null +++ b/src/libs/diff.js @@ -0,0 +1,661 @@ +/** + * 文本比对算法(零依赖) + * + * 两层结构: + * ① 行级 diff:LCS 求最长公共子序列,回溯出 equal / delete / insert / replace 操作 + * ② 字符级 diff:仅对 replace 行再做一次 LCS,标出行内真正变化的片段 + * + * 不引入 npm diff 库的原因:UI 展示完全自绘,需要精确控制分段边界; + * 且本项目包体已优化过(61.5MB),为一个纯计算模块再加依赖不划算。 + */ + +// 行数超过该阈值时,先按哈希分桶缩小 LCS 矩阵规模,避免大文本卡死页面 +const HASH_BUCKET_THRESHOLD = 1500 +// LCS 矩阵单元格总数上限,超过则退化为「按唯一锚点切段」策略 +const MATRIX_CELL_LIMIT = 4000000 + +/** + * 规范化一行:忽略空白模式下去掉首尾空白、压缩连续空白,且纯空白行归一为空串。 + * 这样「只有空白差异的行」与「空行」在 LCS 眼里完全一致,不会被判成删除+新增。 + * @param {String} line 原始行 + * @param {Boolean} ignoreWhitespace 是否忽略空白 + * @returns {String} + */ +function normalizeLine (line, ignoreWhitespace) { + if (!ignoreWhitespace) return line + const trimmed = line.replace(/\s+/g, ' ').replace(/^\s+|\s+$/g, '') + return trimmed +} + +/** + * 计算 LCS 长度矩阵 + * @param {Array} a 序列 A + * @param {Array} b 序列 B + * @returns {Array} 二维数组,m[i][j] = a[i..] 与 b[j..] 的 LCS 长度 + */ +function buildLcsMatrix (a, b) { + const m = a.length + const n = b.length + const matrix = new Array(m + 1) + for (let i = 0; i <= m; i++) matrix[i] = new Array(n + 1).fill(0) + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + if (a[i] === b[j]) { + matrix[i][j] = matrix[i + 1][j + 1] + 1 + } else { + matrix[i][j] = Math.max(matrix[i + 1][j], matrix[i][j + 1]) + } + } + } + return matrix +} + +/** + * 行级 diff:返回操作序列 + * @param {Array} leftLines 左侧行数组 + * @param {Array} rightLines 右侧行数组 + * @returns {Array} ops,形如 {type:'equal'|'delete'|'insert'|'replace', left, right, leftIndex, rightIndex} + */ +export function diffLines (leftLines, rightLines) { + const m = leftLines.length + const n = rightLines.length + + // 空文本直接短路 + if (m === 0 && n === 0) return [] + if (m === 0) { + return rightLines.map((text, i) => ({ type: 'insert', left: null, right: text, leftIndex: -1, rightIndex: i })) + } + if (n === 0) { + return leftLines.map((text, i) => ({ type: 'delete', left: text, right: null, leftIndex: i, rightIndex: -1 })) + } + + // 规模过大时按哈希锚点切段,每段单独做 LCS,避免构建超大矩阵 + if (m + n > HASH_BUCKET_THRESHOLD || m * n > MATRIX_CELL_LIMIT) { + return diffByAnchors(leftLines, rightLines) + } + + // 先按 LCS 锚点把序列切成「相等段 / 变更块」,再在变更块内部做配对 + return buildOpsFromMatrix(leftLines, rightLines) +} + +/** + * 先剥离公共前后缀,再对中间变更区求 LCS 锚点并切成若干变更块, + * 每个块内部再配对成 replace。这一步是保证「连续多行全变」不被错配的关键: + * 若直接按 LCS 回溯顺序流式输出删除,删除会全部堆到插入之前,配对必然错位。 + * @param {Array} leftLines + * @param {Array} rightLines + * @returns {Array} ops + */ +function buildOpsFromMatrix (leftLines, rightLines) { + // 先剥离公共前缀与公共后缀:这两部分在任何 tie-break 策略下都是稳定锚点, + // 剥离后中间才是真正的变更区,避免「删除优先」的回溯造成左右方向不对称 + let head = 0 + const minLen = Math.min(leftLines.length, rightLines.length) + while (head < minLen && leftLines[head] === rightLines[head]) head++ + + let tail = 0 + while ( + tail < minLen - head + && leftLines[leftLines.length - 1 - tail] === rightLines[rightLines.length - 1 - tail] + ) tail++ + + const ops = [] + // 公共前缀 + for (let k = 0; k < head; k++) { + ops.push({ + type: 'equal', + left: leftLines[k], + right: rightLines[k], + leftIndex: k, + rightIndex: k + }) + } + + // 中间变更区:用 LCS 找出剩余的等值锚点 + const midL = leftLines.slice(head, leftLines.length - tail) + const midR = rightLines.slice(head, rightLines.length - tail) + const midMatrix = buildLcsMatrix(midL, midR) + const anchors = [] + let i = 0 + let j = 0 + while (i < midL.length && j < midR.length) { + if (midL[i] === midR[j]) { + anchors.push({ i: i, j: j }) + i++ + j++ + } else if (midMatrix[i + 1][j] >= midMatrix[i][j + 1]) { + i++ + } else { + j++ + } + } + + let li = 0 + let ri = 0 + anchors.forEach(anchor => { + if (anchor.i > li || anchor.j > ri) { + // 传入的是 midL/midR 切片段,左右两侧都要补上「公共前缀长度 head」的偏移 + ops.push.apply(ops, pairBlock(midL, midR, li, anchor.i, ri, anchor.j, head, head)) + } + ops.push({ + type: 'equal', + left: midL[anchor.i], + right: midR[anchor.j], + leftIndex: head + anchor.i, + rightIndex: head + anchor.j + }) + li = anchor.i + 1 + ri = anchor.j + 1 + }) + if (li < midL.length || ri < midR.length) { + ops.push.apply(ops, pairBlock(midL, midR, li, midL.length, ri, midR.length, head, head)) + } + + // 公共后缀 + for (let k = 0; k < tail; k++) { + const li2 = leftLines.length - tail + k + const ri2 = rightLines.length - tail + k + ops.push({ + type: 'equal', + left: leftLines[li2], + right: rightLines[ri2], + leftIndex: li2, + rightIndex: ri2 + }) + } + return ops +} + +/** + * 对一个变更块内的删除行与新增行做贪心配对 + * @param {Array} leftLines 左侧完整数组 + * @param {Array} rightLines 右侧完整数组 + * @param {Number} li 块在左数组中的起始下标 + * @param {Number} lEnd 块在左数组中的结束下标(含) + * @param {Number} ri 块在右数组中的起始下标 + * @param {Number} rEnd 块在右数组中的结束下标(含) + * @param {Number} baseLeft 左侧块数组下标的补偿偏移 + * @param {Number} baseRight 右侧块数组下标的补偿偏移 + * @returns {Array} ops + */ +function pairBlock (leftLines, rightLines, li, lEnd, ri, rEnd, baseLeft, baseRight) { + const leftBlock = leftLines.slice(li, lEnd) + const rightBlock = rightLines.slice(ri, rEnd) + // 块内下标 → 原始下标。两侧补偿可能不同(公共前后缀长度不一致) + const lToOrig = k => (baseLeft || 0) + li + k + const rToOrig = k => (baseRight || 0) + ri + k + const ops = [] + if (leftBlock.length === 0) { + rightBlock.forEach((text, k) => { + ops.push({ type: 'insert', left: null, right: text, leftIndex: -1, rightIndex: rToOrig(k) }) + }) + return ops + } + if (rightBlock.length === 0) { + leftBlock.forEach((text, k) => { + ops.push({ type: 'delete', left: text, right: null, leftIndex: lToOrig(k), rightIndex: -1 }) + }) + return ops + } + + // 块内两侧长度不等时,长出的部分是纯新增/删除,短的部分按位置对齐配对。 + // 位置对齐是主策略:像 ["a"]→["A"] 这种整体重写,字符相似度为 0, + // 若用相似度卡阈值会导致该行被判成「删除+新增」而非「修改」,与直觉不符。 + const offset = ri - li + const pairs = [] + const usedRight = new Array(rightBlock.length).fill(false) + const usedLeft = new Array(leftBlock.length).fill(false) + + // 第一轮:位置对齐(leftIndex - li 与 rightIndex - ri 相同)直接配对 + for (let x = 0; x < leftBlock.length; x++) { + const y = x + offset + if (y >= 0 && y < rightBlock.length && !usedRight[y]) { + usedLeft[x] = true + usedRight[y] = true + pairs.push({ x: x, y: y, score: similarity(leftBlock[x], rightBlock[y]) }) + } + } + // 第二轮:剩余行用相似度择优配对,仅接受达到阈值的(避免无关行硬凑成修改) + const rest = [] + for (let x = 0; x < leftBlock.length; x++) { + if (usedLeft[x]) continue + for (let y = 0; y < rightBlock.length; y++) { + if (usedRight[y]) continue + const score = similarity(leftBlock[x], rightBlock[y]) + if (score >= SIMILARITY_THRESHOLD) rest.push({ x: x, y: y, score: score }) + } + } + rest.sort((p, q) => q.score - p.score) + rest.forEach(c => { + if (!usedLeft[c.x] && !usedRight[c.y]) { + usedLeft[c.x] = true + usedRight[c.y] = true + pairs.push(c) + } + }) + pairs.sort((p, q) => p.x - q.x) + + let x = 0 + let y = 0 + pairs.forEach(pair => { + while (x < pair.x) { + ops.push({ type: 'delete', left: leftBlock[x], right: null, leftIndex: lToOrig(x), rightIndex: -1 }) + x++ + } + while (y < pair.y) { + ops.push({ type: 'insert', left: null, right: rightBlock[y], rightIndex: rToOrig(y), leftIndex: -1 }) + y++ + } + ops.push({ + type: 'replace', + left: leftBlock[pair.x], + right: rightBlock[pair.y], + leftIndex: lToOrig(pair.x), + rightIndex: rToOrig(pair.y) + }) + x = pair.x + 1 + y = pair.y + 1 + }) + while (x < leftBlock.length) { + ops.push({ type: 'delete', left: leftBlock[x], right: null, leftIndex: lToOrig(x), rightIndex: -1 }) + x++ + } + while (y < rightBlock.length) { + ops.push({ type: 'insert', left: null, right: rightBlock[y], leftIndex: -1, rightIndex: rToOrig(y) }) + y++ + } + return ops +} + +// 相似度达到该阈值才认定为「同一行的修改」,低于则视为无关的新增/删除。 +// 0.34 而非 0.5:中文按字符算相似度偏低,改动几个词时容易掉到 0.5 以下被误判成删+增 +const SIMILARITY_THRESHOLD = 0.34 + +/** + * 两行的相似度(0~1):基于共有字符比例的快速估算 + * @param {String} a + * @param {String} b + * @returns {Number} + */ +function similarity (a, b) { + if (a === b) return 1 + const len = Math.max(a.length, b.length) + if (len === 0) return 1 + const short = a.length < b.length ? a : b + const long = a.length < b.length ? b : a + let hit = 0 + const pool = long.split('') + for (let k = 0; k < short.length; k++) { + const idx = pool.indexOf(short[k]) + if (idx > -1) { + hit++ + pool.splice(idx, 1) + } + } + return (hit * 2) / (a.length + b.length) +} + +/** + * 大文本降级策略:以「左右都出现且上下位置单调递增的相同行为锚点」切段, + * 锚点之间的小块各自做 LCS。效果接近完整 LCS,但矩阵规模被限制在单段内。 + * @param {Array} leftLines + * @param {Array} rightLines + * @returns {Array} + */ +function diffByAnchors (leftLines, rightLines) { + const rightPos = new Map() + rightLines.forEach((text, idx) => { + if (!rightPos.has(text)) rightPos.set(text, []) + rightPos.get(text).push(idx) + }) + + // 单调匹配出锚点序列:LCS of (left lines, unique right lines) + const anchorCand = [] + const cursor = new Map() + for (let i = 0; i < leftLines.length; i++) { + const text = leftLines[i] + const positions = rightPos.get(text) + if (!positions) continue + const used = cursor.get(text) || 0 + if (used < positions.length) { + cursor.set(text, used + 1) + anchorCand.push({ leftIndex: i, rightIndex: positions[used] }) + } + } + // 保证锚点本身单调递增,剔除交叉的候选 + const anchors = [] + let lastRight = -1 + for (const cand of anchorCand) { + if (cand.rightIndex > lastRight) { + anchors.push(cand) + lastRight = cand.rightIndex + } + } + + const ops = [] + let li = 0 + let ri = 0 + anchors.forEach(anchor => { + const segL = leftLines.slice(li, anchor.leftIndex) + const segR = rightLines.slice(ri, anchor.rightIndex) + if (segL.length || segR.length) { + diffLines(segL, segR).forEach(op => { + ops.push({ + type: op.type, + left: op.left, + right: op.right, + leftIndex: op.leftIndex >= 0 ? li + op.leftIndex : -1, + rightIndex: op.rightIndex >= 0 ? ri + op.rightIndex : -1 + }) + }) + } + ops.push({ + type: 'equal', + left: leftLines[anchor.leftIndex], + right: rightLines[anchor.rightIndex], + leftIndex: anchor.leftIndex, + rightIndex: anchor.rightIndex + }) + li = anchor.leftIndex + 1 + ri = anchor.rightIndex + 1 + }) + + const tailL = leftLines.slice(li) + const tailR = rightLines.slice(ri) + if (tailL.length || tailR.length) { + diffLines(tailL, tailR).forEach(op => { + ops.push({ + type: op.type, + left: op.left, + right: op.right, + leftIndex: op.leftIndex >= 0 ? li + op.leftIndex : -1, + rightIndex: op.rightIndex >= 0 ? ri + op.rightIndex : -1 + }) + }) + } + return mergeAdjacentOps(ops) +} + +/** + * 把紧邻的 delete + insert 合并成 replace,让「修改行」能左右并排显示 + * @param {Array} ops + * @returns {Array} + */ +function mergeAdjacentOps (ops) { + const merged = [] + let i = 0 + while (i < ops.length) { + const cur = ops[i] + const next = ops[i + 1] + if (cur && next && cur.type === 'delete' && next.type === 'insert') { + // 连续的删除/插入块两两配对,配对后剩余的保持原类型 + const delRun = [] + const insRun = [] + let k = i + while (k < ops.length && ops[k].type === 'delete') { delRun.push(ops[k]); k++ } + let k2 = k + while (k2 < ops.length && ops[k2].type === 'insert') { insRun.push(ops[k2]); k2++ } + const pairs = Math.min(delRun.length, insRun.length) + for (let p = 0; p < pairs; p++) { + merged.push({ + type: 'replace', + left: delRun[p].left, + right: insRun[p].right, + leftIndex: delRun[p].leftIndex, + rightIndex: insRun[p].rightIndex + }) + } + for (let p = pairs; p < delRun.length; p++) merged.push(delRun[p]) + for (let p = pairs; p < insRun.length; p++) merged.push(insRun[p]) + i = k2 + continue + } + if (cur) merged.push(cur) + i++ + } + return merged +} + +/** + * 字符级 diff:把两个字符串切成片段数组,标记哪些片段变了 + * @param {String} a 旧串 + * @param {String} b 新串 + * @returns {{left:Array, right:Array}} 两边片段数组,元素形如 {text, changed} + */ +export function diffChars (a, b) { + if (a === b) { + return { left: [{ text: a, changed: false }], right: [{ text: b, changed: false }] } + } + // 字符数过大时不做字符级细化,只整体标记,避免 O(n*m) 卡顿 + if (a.length > 1200 || b.length > 1200) { + return { + left: [{ text: a, changed: true }], + right: [{ text: b, changed: true }] + } + } + const aArr = a.split('') + const bArr = b.split('') + const matrix = buildLcsMatrix(aArr, bArr) + const leftParts = [] + const rightParts = [] + let i = 0 + let j = 0 + while (i < aArr.length && j < bArr.length) { + if (aArr[i] === bArr[j]) { + pushPart(leftParts, aArr[i], false) + pushPart(rightParts, bArr[j], false) + i++ + j++ + } else if (matrix[i + 1][j] >= matrix[i][j + 1]) { + pushPart(leftParts, aArr[i], true) + i++ + } else { + pushPart(rightParts, bArr[j], true) + j++ + } + } + while (i < aArr.length) { pushPart(leftParts, aArr[i], true); i++ } + while (j < bArr.length) { pushPart(rightParts, bArr[j], true); j++ } + return { left: leftParts, right: rightParts } +} + +/** + * 相邻同标记片段合并,减少 DOM 节点 + */ +function pushPart (parts, text, changed) { + const last = parts[parts.length - 1] + if (last && last.changed === changed) { + last.text += text + } else { + parts.push({ text: text, changed: changed }) + } +} + +/** + * 对外主入口:比对两段文本,产出可直接渲染的行级结果 + * @param {String} leftText 左侧(原始)文本 + * @param {String} rightText 右侧(新)文本 + * @param {Object} options + * @param {Boolean} options.ignoreWhitespace 是否忽略空白差异 + * @returns {{rows:Array, stats:Object}} + */ +export function diffText (leftText, rightText, options) { + const ignoreWhitespace = !!(options && options.ignoreWhitespace) + const leftLines = splitLines(leftText) + const rightLines = splitLines(rightText) + // 参与 LCS 的是规范化后的行,展示时仍用原始文本 + const leftKeys = leftLines.map(line => normalizeLine(line, ignoreWhitespace)) + const rightKeys = rightLines.map(line => normalizeLine(line, ignoreWhitespace)) + + // 忽略空白模式下,纯空白行两侧都不参与比较: + // 否则「左侧多一个空行」会被 LCS 判成删除,勾选了忽略空白却仍报差异,违背预期 + let filteredLeft = leftKeys + let filteredRight = rightKeys + if (ignoreWhitespace) { + filteredLeft = leftKeys.filter(key => key !== '') + filteredRight = rightKeys.filter(key => key !== '') + } + + const ops = diffLines(filteredLeft, filteredRight) + // 操作里的下标指向过滤后的数组,需要映射回原始行号供展示 + const leftNoMap = buildNoMap(leftKeys, ignoreWhitespace) + const rightNoMap = buildNoMap(rightKeys, ignoreWhitespace) + + const stats = { added: 0, removed: 0, changed: 0, unchanged: 0 } + const rows = ops.map(op => { + if (op.type === 'equal') { + stats.unchanged++ + return { + type: 'equal', + leftNo: leftNoMap[op.leftIndex], + rightNo: rightNoMap[op.rightIndex], + leftParts: [{ text: leftLines[leftNoMap[op.leftIndex] - 1], changed: false }], + rightParts: [{ text: rightLines[rightNoMap[op.rightIndex] - 1], changed: false }] + } + } + if (op.type === 'delete') { + stats.removed++ + const no = leftNoMap[op.leftIndex] + return { + type: 'delete', + leftNo: no, + rightNo: -1, + leftParts: [{ text: leftLines[no - 1], changed: false }], + rightParts: null + } + } + if (op.type === 'insert') { + stats.added++ + const no = rightNoMap[op.rightIndex] + return { + type: 'insert', + leftNo: -1, + rightNo: no, + leftParts: null, + rightParts: [{ text: rightLines[no - 1], changed: false }] + } + } + // replace:细化到字符级 + stats.changed++ + const leftNo = leftNoMap[op.leftIndex] + const rightNo = rightNoMap[op.rightIndex] + const leftTextLine = leftLines[leftNo - 1] + const rightTextLine = rightLines[rightNo - 1] + const parts = diffChars(leftTextLine, rightTextLine) + return { + type: 'replace', + leftNo: leftNo, + rightNo: rightNo, + leftParts: parts.left, + rightParts: parts.right + } + }) + + return { rows: rows, stats: stats } +} + +/** + * 生成「过滤后下标 → 原始行号(1 起)」的映射, + * 忽略空白时空白行被剔除,展示行号仍需对应用户输入的真实位置 + * @param {Array} keys 规范化后的全部行 + * @param {Boolean} ignoreWhitespace + * @returns {Array} + */ +function buildNoMap (keys, ignoreWhitespace) { + if (!ignoreWhitespace) { + return keys.map((line, idx) => idx + 1) + } + const map = [] + keys.forEach((key, idx) => { + if (key !== '') map.push(idx + 1) + }) + return map +} + +/** + * 文本转行数组,保留空行(末尾换行不额外产生空行) + * @param {String} text + * @returns {Array} + */ +export function splitLines (text) { + if (text === '' || text === null || text === undefined) return [] + const normalized = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const lines = normalized.split('\n') + // 末尾换行符是行终止符,不算一条空行 + if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop() + return lines +} + +/** + * 导出为 unified diff 文本,便于粘到终端或提交信息里 + * @param {Array} rows + * @param {String} leftLabel + * @param {String} rightLabel + * @returns {String} + */ +export function toUnifiedDiff (rows, leftLabel, rightLabel) { + const lines = ['--- ' + (leftLabel || '左侧'), '+++ ' + (rightLabel || '右侧')] + + // 先把 rows 拍平成标准 diff 行序列,同时累计左右各自的行号游标 + const seq = [] + rows.forEach(row => { + if (row.type === 'equal') { + seq.push({ sign: ' ', text: plainText(row.leftParts), lNo: row.leftNo, rNo: row.rightNo }) + } else if (row.type === 'delete') { + seq.push({ sign: '-', text: plainText(row.leftParts), lNo: row.leftNo, rNo: -1 }) + } else if (row.type === 'insert') { + seq.push({ sign: '+', text: plainText(row.rightParts), lNo: -1, rNo: row.rightNo }) + } else { + seq.push({ sign: '-', text: plainText(row.leftParts), lNo: row.leftNo, rNo: -1 }) + seq.push({ sign: '+', text: plainText(row.rightParts), lNo: -1, rNo: row.rightNo }) + } + }) + + const CONTEXT = 3 + // 找出所有变更行的下标;相邻(含 CONTEXT 间隔内)的变更合并为一个 hunk + const changeIdx = [] + seq.forEach((item, idx) => { + if (item.sign !== ' ') changeIdx.push(idx) + }) + if (changeIdx.length === 0) { + return lines.join('\n') + } + + const groups = [] + let start = Math.max(0, changeIdx[0] - CONTEXT) + let end = Math.min(seq.length - 1, changeIdx[0] + CONTEXT) + for (let k = 1; k < changeIdx.length; k++) { + const idx = changeIdx[k] + if (idx - end <= CONTEXT * 2) { + end = Math.min(seq.length - 1, idx + CONTEXT) + } else { + groups.push([start, end]) + start = Math.max(0, idx - CONTEXT) + end = Math.min(seq.length - 1, idx + CONTEXT) + } + } + groups.push([start, end]) + + groups.forEach(([from, to]) => { + const slice = seq.slice(from, to + 1) + // hunk 的起始行号取该分区内第一条带行号的记录 + let lStart = 0 + let rStart = 0 + slice.forEach(item => { + if (lStart === 0 && item.lNo > 0) lStart = item.lNo + if (rStart === 0 && item.rNo > 0) rStart = item.rNo + }) + const lCount = slice.filter(i => i.sign !== '+').length + const rCount = slice.filter(i => i.sign !== '-').length + lines.push('@@ -' + lStart + ',' + lCount + ' +' + rStart + ',' + rCount + ' @@') + slice.forEach(item => { + lines.push(item.sign + item.text) + }) + }) + return lines.join('\n') +} + +function plainText (parts) { + if (!parts) return '' + return parts.map(p => p.text).join('') +} \ No newline at end of file diff --git a/src/view/tools/text-diff.vue b/src/view/tools/text-diff.vue new file mode 100644 index 0000000..4bd708e --- /dev/null +++ b/src/view/tools/text-diff.vue @@ -0,0 +1,608 @@ + + + + + \ No newline at end of file