Address findings from 2 review agents on the edit-history diff: - Perf: diffWords is O(n*m); cap at 2000 tokens/side and fall back to a coarse whole-block replaced diff above that, so a very large multi-edit message can't freeze the main thread. Memoize the per-row diff in DiffText. (Added a unit test for the coarse fallback.) - Perceivability (a11y/design): the added-word <ins> highlight was color-fill only, which is faint against the modal surface in the lotus themes. Add a Success.ContainerLine border + horizontal padding (so the rounded corners read as a chip) + box-decoration-break: clone for clean wrapping, so the "added" cue survives low fill contrast. - Consistency: a media/no-body edit now renders "(no text)" in diff mode too (matched the toggle-off view; was blank). - Softened the code comment's screen-reader claim (bare <ins>/<del> aren't announced by default). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
82 lines
2.8 KiB
TypeScript
82 lines
2.8 KiB
TypeScript
// Word-level text diff for the Edit History viewer. Pure and dependency-free.
|
|
|
|
export type DiffSegment = {
|
|
type: 'equal' | 'added' | 'removed';
|
|
text: string;
|
|
};
|
|
|
|
// Split into a sequence of tokens where each token is either a run of
|
|
// whitespace or a run of non-whitespace, so word boundaries and the original
|
|
// spacing/newlines are both preserved when segments are re-joined.
|
|
const tokenize = (text: string): string[] => text.match(/\s+|\S+/g) ?? [];
|
|
|
|
// Above this many tokens per side the O(n*m) LCS table gets expensive/large, so
|
|
// we fall back to a coarse whole-block "replaced" diff. Message bodies this long
|
|
// are rare; the exact word diff isn't worth a multi-million-cell allocation.
|
|
const MAX_DIFF_TOKENS = 2000;
|
|
|
|
const coarseDiff = (oldText: string, newText: string): DiffSegment[] => {
|
|
const segments: DiffSegment[] = [];
|
|
if (oldText) segments.push({ type: 'removed', text: oldText });
|
|
if (newText) segments.push({ type: 'added', text: newText });
|
|
return segments;
|
|
};
|
|
|
|
/**
|
|
* Word-level diff of `oldText` → `newText` via a classic LCS. Returns segments
|
|
* in reading order: `equal` runs, `removed` runs (present only in old), and
|
|
* `added` runs (present only in new). Consecutive same-type tokens are merged.
|
|
* Pure — never mutates its inputs. Whitespace is preserved.
|
|
*/
|
|
export function diffWords(oldText: string, newText: string): DiffSegment[] {
|
|
const a = tokenize(oldText);
|
|
const b = tokenize(newText);
|
|
if (a.length > MAX_DIFF_TOKENS || b.length > MAX_DIFF_TOKENS) {
|
|
return coarseDiff(oldText, newText);
|
|
}
|
|
const n = a.length;
|
|
const m = b.length;
|
|
|
|
// lcs[i][j] = length of the longest common subsequence of a[i..] and b[j..].
|
|
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
|
for (let i = n - 1; i >= 0; i -= 1) {
|
|
for (let j = m - 1; j >= 0; j -= 1) {
|
|
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
}
|
|
}
|
|
|
|
const raw: { type: DiffSegment['type']; text: string }[] = [];
|
|
let i = 0;
|
|
let j = 0;
|
|
while (i < n && j < m) {
|
|
if (a[i] === b[j]) {
|
|
raw.push({ type: 'equal', text: a[i] });
|
|
i += 1;
|
|
j += 1;
|
|
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
raw.push({ type: 'removed', text: a[i] });
|
|
i += 1;
|
|
} else {
|
|
raw.push({ type: 'added', text: b[j] });
|
|
j += 1;
|
|
}
|
|
}
|
|
while (i < n) {
|
|
raw.push({ type: 'removed', text: a[i] });
|
|
i += 1;
|
|
}
|
|
while (j < m) {
|
|
raw.push({ type: 'added', text: b[j] });
|
|
j += 1;
|
|
}
|
|
|
|
// Merge consecutive same-type tokens into segments.
|
|
const segments: DiffSegment[] = [];
|
|
raw.forEach((tok) => {
|
|
const last = segments[segments.length - 1];
|
|
if (last && last.type === tok.type) last.text += tok.text;
|
|
else segments.push({ type: tok.type, text: tok.text });
|
|
});
|
|
return segments;
|
|
}
|