Files
cinny/src/app/utils/wordDiff.ts
T
jaredandClaude Opus 5 2bdb2eb4cb
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Canceled after 1m37s
feat(messages): word diff of the last edit on "(edited)" hover (#144)
Hovering or focusing "(edited)" shows a tooltip with only the most recent
edit as a word diff — removed words struck, added words bold — plus a
+N/−N summary. Clicking still opens the full history viewer. On touch, a
long-press on the label shows the same diff as a popout (a plain tap opens
the viewer; the message's own long-press action sheet is not triggered).

utils/wordDiff.ts is a unit-tested LCS over words that ignores whitespace-
only changes and gives up past 400 words. Only plain-text bodies are
diffed; formatted edits fall back to the viewer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:34:38 -04:00

79 lines
2.6 KiB
TypeScript

/**
* [Gitea #144] Word-level diff for the "(edited)" hover. Plain text only; the
* caller decides what to do with formatted bodies. LCS over word tokens, with
* whitespace preserved on the token that precedes it so the rendered result
* reads like the original message.
*/
export type DiffOp = 'same' | 'add' | 'del';
export type DiffToken = { op: DiffOp; text: string };
/** Above this many words (before + after) we give up: O(n·m) memory and an unreadable tooltip. */
export const WORD_DIFF_LIMIT = 400;
const tokenize = (text: string): string[] => text.match(/\S+\s*|\s+/g) ?? [];
const word = (token: string) => token.trim();
/**
* Diff `before` → `after` into runs. Returns `undefined` when the texts are
* identical or too long to diff meaningfully (caller should fall back to the
* full history viewer).
*/
export function wordDiff(before: string, after: string): DiffToken[] | undefined {
if (before === after) return undefined;
const a = tokenize(before);
const b = tokenize(after);
if (a.length + b.length > WORD_DIFF_LIMIT) return undefined;
// LCS table on trimmed words so whitespace-only changes don't count as edits.
const n = a.length;
const m = b.length;
const lcs: Uint16Array[] = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
for (let i = n - 1; i >= 0; i -= 1) {
for (let j = m - 1; j >= 0; j -= 1) {
lcs[i][j] =
word(a[i]) === word(b[j]) ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
}
}
const out: DiffToken[] = [];
const push = (op: DiffOp, text: string) => {
const last = out[out.length - 1];
if (last && last.op === op) last.text += text;
else out.push({ op, text });
};
let i = 0;
let j = 0;
while (i < n && j < m) {
if (word(a[i]) === word(b[j])) {
push('same', b[j]);
i += 1;
j += 1;
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
push('del', a[i]);
i += 1;
} else {
push('add', b[j]);
j += 1;
}
}
while (i < n) push('del', a[i++]);
while (j < m) push('add', b[j++]);
// Only whitespace moved around: nothing worth showing.
if (out.every((t) => t.op === 'same' || t.text.trim() === '')) return undefined;
return out;
}
/** Number of changed words in a diff (for a compact summary). */
export const countChanges = (tokens: DiffToken[]): { added: number; removed: number } => {
let added = 0;
let removed = 0;
tokens.forEach((t) => {
const words = t.text.trim() === '' ? 0 : t.text.trim().split(/\s+/).length;
if (t.op === 'add') added += words;
if (t.op === 'del') removed += words;
});
return { added, removed };
};