From 4d22232ec0987eec56ae4007a6778f721f3d738d Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 21:54:20 +0200 Subject: [PATCH] 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. --- src/tui/shared/diff.ts | 39 +++++++++++++++++++++++++++++++++++ tests/tui/diff.test.ts | 47 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/tui/shared/diff.ts create mode 100644 tests/tui/diff.test.ts diff --git a/src/tui/shared/diff.ts b/src/tui/shared/diff.ts new file mode 100644 index 0000000..35e96f9 --- /dev/null +++ b/src/tui/shared/diff.ts @@ -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; +} diff --git a/tests/tui/diff.test.ts b/tests/tui/diff.test.ts new file mode 100644 index 0000000..7642598 --- /dev/null +++ b/tests/tui/diff.test.ts @@ -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); + }); +});