Files
UI/packages/skin/ui/src/diffView.ts
T

100 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { h } from '@ui-arch/frame';
import type { DiffViewProps } from './types.js';
// 一条差异:same 原样、add 只在右边、del 只在左边
type DiffOp = { type: 'same' | 'add' | 'del'; text: string };
// 比较用的一行:text 是显示原文,key 是比对键(忽略空白模式下把行内空白压成一个空格再 trim)
type Row = { text: string; key: string };
// LCS 计算上限:中间段行数乘积超过这个值就退化为「整段替换」,避免大文件把主线程卡住
const DP_LIMIT = 400_000;
/*** 比对键归一:行内连续空白压成一个空格并去掉首尾空白;显示仍用原文,缩进不丢 ***/
function normalize(line: string): string {
return line.replace(/\s+/g, ' ').trim();
}
/*** 切行:末尾换行不产生一行空内容(否则每个文件都会多出一条假的空行差异) ***/
function toRows(text: string, ignore: boolean): Row[] {
const lines = text.split(/\r?\n/);
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop(); // 尾部换行忽略
return lines.map((line) => ({ text: line, key: ignore ? normalize(line) : line }));
}
/*** 中间段做 LCS 对齐:先剪掉公共前后缀,剩下的才真算,日常改动只占几行 ***/
function alignMiddle(a: Row[], b: Row[]): DiffOp[] {
if (!a.length || !b.length) return [...a.map((r) => ({ type: 'del' as const, text: r.text })), ...b.map((r) => ({ type: 'add' as const, text: r.text }))];
if (a.length * b.length > DP_LIMIT) return [...a.map((r) => ({ type: 'del' as const, text: r.text })), ...b.map((r) => ({ type: 'add' as const, text: r.text }))]; // 超限:整段替换,语义仍正确
const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0)); // dp[i][j]:a 前 i 行与 b 前 j 行的 LCS 长度
for (let i = a.length - 1; i >= 0; i -= 1) {
for (let j = b.length - 1; j >= 0; j -= 1) {
dp[i][j] = a[i].key === b[j].key ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const ops: DiffOp[] = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i].key === b[j].key) { ops.push({ type: 'same', text: a[i].text }); i += 1; j += 1; continue; }
if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 'del', text: a[i].text }); i += 1; continue; }
ops.push({ type: 'add', text: b[j].text }); j += 1;
}
for (; i < a.length; i += 1) ops.push({ type: 'del', text: a[i].text });
for (; j < b.length; j += 1) ops.push({ type: 'add', text: b[j].text });
return ops;
}
/*** 逐行差异:剪公共前后缀后对齐中间段,输出完整 same / add / del 序列 ***/
function diffLines(before: string, after: string, ignore: boolean): DiffOp[] {
const a = toRows(before, ignore);
const b = toRows(after, ignore);
let head = 0;
while (head < a.length && head < b.length && a[head].key === b[head].key) head += 1; // 公共前缀
let tail = 0;
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail].key === b[b.length - 1 - tail].key) tail += 1; // 公共后缀
const midA = a.slice(head, a.length - tail);
const midB = b.slice(head, b.length - tail);
return [
...a.slice(0, head).map((r) => ({ type: 'same' as const, text: r.text })),
...alignMiddle(midA, midB),
...a.slice(a.length - tail).map((r) => ({ type: 'same' as const, text: r.text })),
];
}
/*** 文本差异对比:行级对齐 + 增删着色 + 双行号列,超限时退化为整段替换(只影响精度不影响语义) ***/
export function diffView(props: DiffViewProps): HTMLElement {
const ignore = props.ignoreWhitespace === true; // 只在显式开启时归一行内空白
const ops = diffLines(props.before, props.after, ignore);
const added = ops.filter((o) => o.type === 'add').length; // 新增行数:统计条与读屏共用
const removed = ops.filter((o) => o.type === 'del').length; // 删除行数
let oldNo = 0;
let newNo = 0;
const rows = ops.map((op) => {
if (op.type !== 'add') oldNo += 1; // same 与 del 占旧行号
if (op.type !== 'del') newNo += 1; // same 与 add 占新行号
const sign = op.type === 'add' ? '+' : op.type === 'del' ? '−' : ' ';
return h(
'div',
{ class: `ua-diff-row is-${op.type}` },
h('span', { class: 'ua-diff-no' }, op.type === 'add' ? '' : String(oldNo)),
h('span', { class: 'ua-diff-no' }, op.type === 'del' ? '' : String(newNo)),
h('span', { class: 'ua-diff-sign', 'aria-hidden': 'true' }, sign),
h('span', { class: 'ua-diff-text' }, op.text || ' '),
);
});
const stats = props.showStats === false
? null
: h(
'div',
{ class: 'ua-diff-stats' },
h('span', { class: 'ua-diff-add' }, `+${added}`),
h('span', { class: 'ua-diff-del' }, `−${removed}`),
ignore ? h('span', { class: 'ua-diff-note' }, '已忽略行内空白差异') : null, // 归一口径必须写在脸上,否则「为什么不报这几行」说不清
);
return h(
'div',
{ class: 'ua-diff', role: 'group', 'aria-label': props.ariaLabel ?? `文本差异:新增 ${added} 行,删除 ${removed} 行` },
stats,
h('div', { class: 'ua-diff-body' }, ...rows),
);
}