Files
FabledScribe/frontend/src/utils/diff.ts
T
bvandeusenandClaude Opus 5 a8b2040216
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m20s
CI & Build / Build & push image (push) Successful in 40s
feat(rules): the edit history is visible in the slide-over (#3243, milestone 323 step 4)
Rule 27: a history nobody can read is not shipped. `RuleHistoryPanel.vue`
sits below the fields in `RuleEditorSlideOver`, where a rule is read in full
— not on the list row, where a history entry point would compete with the
row's job.

REUSE, DECIDED FIELD BY FIELD RATHER THAN ALL AT ONCE.

DiffView.vue is reused unchanged: it takes DiffLine[] and nothing
note-shaped.

HistoryPanel.vue is NOT, and its props are the reason — noteId +
currentBody, a NoteVersion carrying tags and pin columns, a fetch of
/api/notes/…, a restore emit, pin/unpin buttons. Rules have no tags, no
pins, and deliberately no restore, and a rule's text is EIGHT fields rather
than one body, which changes the reader's question from "what changed" to
"which fields moved". Recorded here rather than forked silently, per #3207.

THE FORK THAT WAS ALREADY THERE. The LCS walk existed three times —
privately in useAssist.ts, and again inside HistoryPanel.vue and
VersionHistorySection.vue — character-identical apart from quote style,
because computeDiff was never exported. Rather than add a fourth copy, it
moves to utils/diff.ts and the three become imports; the extraction was
verified equivalent to all three before anything was deleted. DiffLine is
re-exported from useAssist so its existing importers are untouched.

WHAT A ROW SHOWS: when, and which fields moved. A version holds the text the
edit REPLACED, so the edit is the step from a row to the next NEWER state —
the row above it, or, for the newest row, the rule as it stands now.
Comparing against the row below would attribute every change to the wrong
edit. A field nobody has fetched yet reads as neither changed nor unchanged.

An edit that touched verify_with is badged "check reset", because that edit
silently cleared verified_at (milestone 312) and put the rule back at the
top of the staleness sweep — a moment visible nowhere else.

The badge is a 12% color-mix TINT, not solid `--fs-warning`.
`--fs-warning-fg` is defined in theme.css as "warning TEXT on a warning
tint", so painting it over the solid token is exactly the same-hue contrast
failure #3141 records. Every var() the component references resolves against
theme.css, checked before pushing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 12:52:07 -04:00

51 lines
2.1 KiB
TypeScript

/**
* Line diff — one copy, for every surface that shows what changed.
*
* WHY THIS FILE EXISTS. The same LCS walk was written out three times:
* privately in `useAssist.ts`, and again inside `HistoryPanel.vue` and
* `VersionHistorySection.vue`. The three were character-identical apart from
* quote style — nobody had diverged them on purpose, they were simply copied
* because `computeDiff` was never exported. Milestone 323 needed a fourth
* consumer (a rule's edit history), and a fourth copy is the cost #3207
* records: a fix or an improvement now has to be found in N places by someone
* who does not know N.
*/
export interface DiffLine {
type: "equal" | "delete" | "insert";
text: string;
}
/**
* Diff `a` against `b`, line by line.
*
* `delete` lines come from `a`, `insert` lines from `b` — so the caller
* decides which side reads as "before" by which argument it passes. Every
* caller here passes the CURRENT text as `a` and the older text as `b`, so a
* deletion is what the old version had and an insertion is what replaced it.
*
* O(m·n) in time and memory: fine for a note or a rule, and deliberately not
* generalised further, since nothing here diffs a file of thousands of lines.
*/
export function computeDiff(a: string, b: string): DiffLine[] {
const aLines = a.split("\n");
const bLines = b.split("\n");
const m = aLines.length, n = bLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--)
for (let j = n - 1; j >= 0; j--)
dp[i][j] = aLines[i] === bLines[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
const result: DiffLine[] = [];
let i = 0, j = 0;
while (i < m && j < n) {
if (aLines[i] === bLines[j]) { result.push({ type: "equal", text: aLines[i++] }); j++; }
else if (dp[i + 1][j] >= dp[i][j + 1]) result.push({ type: "delete", text: aLines[i++] });
else result.push({ type: "insert", text: bLines[j++] });
}
while (i < m) result.push({ type: "delete", text: aLines[i++] });
while (j < n) result.push({ type: "insert", text: bLines[j++] });
return result;
}