feat(edit-history): word-level diff view
The Edit History modal listed each version's full text with no indication of what changed. Add a word-level diff: each edit highlights the words added (green) and removed (struck-through red) relative to the previous version, so a one-word fix is obvious at a glance. - New pure, dependency-free diffWords (LCS over word/whitespace tokens) in utils/textDiff.ts, with 8 unit tests (insert/delete/replace, whitespace preserved, empty, no-mutation, word-not-char granularity). - EditHistoryModal renders each edit via a DiffText component using semantic <ins>/<del> (screen-reader-meaningful) styled with folds Success/Critical tokens. A "Highlight changes" header toggle (default on) switches back to full text, which keeps the rich formatted render; the Original row is always the plain baseline. - Diff is plain-text (body) only by design; formatted markup isn't diffed (the toggle restores the rich view), and media/no-body edits diff as empty strings gracefully. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { diffWords, DiffSegment } from './textDiff';
|
||||
|
||||
// Helper: reconstruct the old / new text from segments to prove correctness.
|
||||
const oldText = (segs: DiffSegment[]) =>
|
||||
segs
|
||||
.filter((s) => s.type !== 'added')
|
||||
.map((s) => s.text)
|
||||
.join('');
|
||||
const newText = (segs: DiffSegment[]) =>
|
||||
segs
|
||||
.filter((s) => s.type !== 'removed')
|
||||
.map((s) => s.text)
|
||||
.join('');
|
||||
|
||||
test('identical text yields a single equal segment', () => {
|
||||
const out = diffWords('hello world', 'hello world');
|
||||
assert.deepEqual(out, [{ type: 'equal', text: 'hello world' }]);
|
||||
});
|
||||
|
||||
test('pure insertion marks only the new words as added', () => {
|
||||
const out = diffWords('hello world', 'hello there world');
|
||||
assert.equal(oldText(out), 'hello world');
|
||||
assert.equal(newText(out), 'hello there world');
|
||||
assert.deepEqual(
|
||||
out.filter((s) => s.type === 'added').map((s) => s.text.trim()),
|
||||
['there'],
|
||||
);
|
||||
assert.equal(
|
||||
out.some((s) => s.type === 'removed'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('pure deletion marks only the dropped words as removed', () => {
|
||||
const out = diffWords('hello there world', 'hello world');
|
||||
assert.equal(
|
||||
out.some((s) => s.type === 'added'),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
out.filter((s) => s.type === 'removed').map((s) => s.text.trim()),
|
||||
['there'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a word replacement is a removed run followed by an added run', () => {
|
||||
const out = diffWords('hello world', 'hi world');
|
||||
// Reconstructs both sides.
|
||||
assert.equal(oldText(out), 'hello world');
|
||||
assert.equal(newText(out), 'hi world');
|
||||
const removed = out.filter((s) => s.type === 'removed').map((s) => s.text.trim());
|
||||
const added = out.filter((s) => s.type === 'added').map((s) => s.text.trim());
|
||||
assert.deepEqual(removed, ['hello']);
|
||||
assert.deepEqual(added, ['hi']);
|
||||
});
|
||||
|
||||
test('whitespace and newlines are preserved in reconstruction', () => {
|
||||
const a = ' line one\nline two ';
|
||||
const b = ' line one\nline three ';
|
||||
const out = diffWords(a, b);
|
||||
assert.equal(oldText(out), a);
|
||||
assert.equal(newText(out), b);
|
||||
});
|
||||
|
||||
test('empty <-> non-empty', () => {
|
||||
assert.deepEqual(diffWords('', 'new text'), [{ type: 'added', text: 'new text' }]);
|
||||
assert.deepEqual(diffWords('old text', ''), [{ type: 'removed', text: 'old text' }]);
|
||||
assert.deepEqual(diffWords('', ''), []);
|
||||
});
|
||||
|
||||
test('diffWords does not mutate its inputs', () => {
|
||||
const a = 'alpha beta';
|
||||
const b = 'alpha gamma';
|
||||
diffWords(a, b);
|
||||
assert.equal(a, 'alpha beta');
|
||||
assert.equal(b, 'alpha gamma');
|
||||
});
|
||||
|
||||
test('word-level granularity (not character-level)', () => {
|
||||
const out = diffWords('cat', 'cats');
|
||||
// "cat" and "cats" are different tokens → full removed + added, no partial.
|
||||
assert.deepEqual(
|
||||
out.map((s) => s.type),
|
||||
['removed', 'added'],
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user