Files
uopi/web/src/lib/linediff.ts
T
2026-06-21 23:50:03 +02:00

100 lines
3.1 KiB
TypeScript

// Line-based diff (git style) used by the version history diff viewer. A simple
// Longest-Common-Subsequence over lines yields a sequence of equal/added/removed
// rows that can be rendered as a unified diff or split side-by-side.
export type DiffOp = 'equal' | 'add' | 'del';
export interface DiffRow {
op: DiffOp;
// Line content. For 'equal' both sides are identical (text).
text: string;
// 1-based line numbers in the left/right document; null when absent on a side.
leftNo: number | null;
rightNo: number | null;
}
function splitLines(s: string): string[] {
// Drop a single trailing newline so a file ending in "\n" doesn't yield a
// spurious empty final row.
const t = s.endsWith('\n') ? s.slice(0, -1) : s;
return t.length === 0 ? [] : t.split('\n');
}
// diffLines computes the LCS table and walks it back into ordered rows.
export function diffLines(leftText: string, rightText: string): DiffRow[] {
const a = splitLines(leftText);
const b = splitLines(rightText);
const n = a.length;
const m = b.length;
// lcs[i][j] = length of LCS of a[i:] and b[j:].
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
}
}
const rows: DiffRow[] = [];
let i = 0;
let j = 0;
let la = 1;
let lb = 1;
while (i < n && j < m) {
if (a[i] === b[j]) {
rows.push({ op: 'equal', text: a[i], leftNo: la++, rightNo: lb++ });
i++; j++;
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
rows.push({ op: 'del', text: a[i], leftNo: la++, rightNo: null });
i++;
} else {
rows.push({ op: 'add', text: b[j], leftNo: null, rightNo: lb++ });
j++;
}
}
while (i < n) rows.push({ op: 'del', text: a[i++], leftNo: la++, rightNo: null });
while (j < m) rows.push({ op: 'add', text: b[j++], leftNo: null, rightNo: lb++ });
return rows;
}
export interface DiffStats { added: number; removed: number; }
export function diffStats(rows: DiffRow[]): DiffStats {
let added = 0;
let removed = 0;
for (const r of rows) {
if (r.op === 'add') added++;
else if (r.op === 'del') removed++;
}
return { added, removed };
}
// sideBySide pairs rows into aligned [left, right] columns: a del with the next
// add becomes one changed row; otherwise each row occupies one side.
export interface SideRow {
left: DiffRow | null;
right: DiffRow | null;
}
export function sideBySide(rows: DiffRow[]): SideRow[] {
const out: SideRow[] = [];
for (let k = 0; k < rows.length; k++) {
const r = rows[k];
if (r.op === 'equal') {
out.push({ left: r, right: r });
} else if (r.op === 'del') {
// Pair consecutive del/add as a single changed line when possible.
const next = rows[k + 1];
if (next && next.op === 'add') {
out.push({ left: r, right: next });
k++;
} else {
out.push({ left: r, right: null });
}
} else {
out.push({ left: null, right: r });
}
}
return out;
}