0
0
Fork 0

tui: add line-based diff hunk computation

computeHunks() returns Hunk[] (add | del | ctx) with line numbers on
the new side for add/ctx and the old side for del.

Both inputs are normalized to end with a newline before calling
jsdiff's diffLines, otherwise a missing EOF newline is treated as a
token boundary and the last line shows up as a spurious del+add
pair.
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-28 21:54:20 +02:00
parent ba40f10e6e
commit 4d22232ec0
2 changed files with 86 additions and 0 deletions

39
src/tui/shared/diff.ts Normal file
View file

@ -0,0 +1,39 @@
import { diffLines } from 'diff';
import { Hunk } from './types.js';
export function computeHunks(oldText: string, newText: string): Hunk[] {
// jsdiff treats trailing-newline presence as a token boundary, so a missing
// EOF newline shows up as a spurious del+add pair on the last line. Normalize.
const a = oldText.endsWith('\n') ? oldText : oldText + '\n';
const b = newText.endsWith('\n') ? newText : newText + '\n';
const parts = diffLines(a, b);
const out: Hunk[] = [];
let oldLine = 1;
let newLine = 1;
for (const part of parts) {
const lines = part.value.split('\n');
if (lines.length > 0 && lines[lines.length - 1] === '') {
lines.pop();
}
if (part.added) {
for (const text of lines) {
out.push({ kind: 'add', lineNo: newLine, text });
newLine++;
}
} else if (part.removed) {
for (const text of lines) {
out.push({ kind: 'del', lineNo: oldLine, text });
oldLine++;
}
} else {
for (const text of lines) {
out.push({ kind: 'ctx', lineNo: newLine, text });
oldLine++;
newLine++;
}
}
}
return out;
}

47
tests/tui/diff.test.ts Normal file
View file

@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { computeHunks } from '../../src/tui/shared/diff.ts';
describe('computeHunks', () => {
it('returns ctx hunks when both sides are identical', () => {
const a = 'one\ntwo\nthree';
const hunks = computeHunks(a, a);
expect(hunks.every((h) => h.kind === 'ctx')).toBe(true);
expect(hunks).toHaveLength(3);
});
it('marks added lines', () => {
const a = 'one\ntwo';
const b = 'one\ntwo\nthree';
const hunks = computeHunks(a, b);
const added = hunks.filter((h) => h.kind === 'add');
expect(added).toHaveLength(1);
expect(added[0].text).toBe('three');
});
it('marks deleted lines', () => {
const a = 'one\ntwo\nthree';
const b = 'one\ntwo';
const hunks = computeHunks(a, b);
const deleted = hunks.filter((h) => h.kind === 'del');
expect(deleted).toHaveLength(1);
expect(deleted[0].text).toBe('three');
});
it('handles a substitution as del + add', () => {
const a = 'a\nb\nc';
const b = 'a\nB\nc';
const hunks = computeHunks(a, b);
expect(hunks.some((h) => h.kind === 'del' && h.text === 'b')).toBe(true);
expect(hunks.some((h) => h.kind === 'add' && h.text === 'B')).toBe(true);
});
it('reports add/ctx line numbers against the new side, del against the old side', () => {
const a = 'a\nb\nc';
const b = 'a\nx\nc';
const hunks = computeHunks(a, b);
const del = hunks.find((h) => h.kind === 'del')!;
const add = hunks.find((h) => h.kind === 'add')!;
expect(del.lineNo).toBe(2);
expect(add.lineNo).toBe(2);
});
});