@ai-matrx/diff
v0.3.7
Published
The AI Matrx diff product: a line-level LCS text engine, a structural tree/array engine, and the complete React viewer — split/inline/highlight views, word-level highlights, folding, per-hunk accept/reject merging, and the structured entity diff with its
Maintainers
Readme
@ai-matrx/diff
The complete AI Matrx diff product: two pure engines plus the React viewer built on them.
@ai-matrx/diff/react— the whole UI.DiffViewer(the core everything else composes),TextDiff,DiffReview(per-hunk merge),InlineTextDiff,AnimatedDiffReveal, and the structured entity viewerDiffViewerShellwith its field adapters. No icon library, no UI kit, no editor dependency.The engines below are pure logic, zero dependencies, no DOM, nothing at import time:
@ai-matrx/diff/text— line-level LCS text diff. One computation emits both the unified (inline) and side-by-side (rows) representations, with word/char-level highlight segments on changed line pairs and a hunk model that turns the diff into an accept/reject merge tool.@ai-matrx/diff/structural— generic tree/array structural diff. Deep-diffs two plain objects into a tree of typed change nodes with pluggable per-field identity keys for array matching.
The root (@ai-matrx/diff) re-exports both engines. /react is a separate
subpath so a headless consumer never pays for the UI.
npm install @ai-matrx/diffReact viewer
import { DiffViewer, DiffReview } from "@ai-matrx/diff/react";
// Read-only. Fills its container: render it as a route, a window, a modal, a
// sheet, or any region of a page.
<DiffViewer
original={a}
modified={b}
engine="auto" // "auto" | "light" | "monaco"
language="typescript" // drives auto selection; omit for prose/markdown
originalLabel="Before"
modifiedLabel="After"
defaultView="split" // "split" | "inline" | "highlight"
/>
// Merge. Every hunk is pending / applied / rejected; Apply hands back the text.
<DiffReview original={a} modified={b} onApply={(merged) => save(merged)} />Views: split (side-by-side), inline (unified), highlight (single-pane —
the new document as flowing prose with only the changed regions tinted; the
reader's view, not a code diff). Unchanged context folds to a few lines around
each change and expands per run; Prev/Next jump between changes; Swap flips
which side is the baseline. The diff is computed ONCE, so resolving, expanding
and navigating never re-run the LCS.
Structured (entity) diff
import { computeDiff } from "@ai-matrx/diff/structural";
import {
DiffViewerShell,
createAdapterRegistry,
TextFieldAdapter,
TagsFieldAdapter,
} from "@ai-matrx/diff/react";
const adapters = createAdapterRegistry();
adapters.register("instructions", TextFieldAdapter);
adapters.register("tags", TagsFieldAdapter);
<DiffViewerShell
diffResult={computeDiff(oldRecord, newRecord)}
oldValue={oldRecord}
newValue={newRecord}
oldLabel="v3"
newLabel="v4"
adapters={adapters}
temporalMetadata={dates} // optional per-side / per-field provenance
/>Fields with no registered adapter fall back to the package's
DefaultFieldAdapter — never dropped. Provenance rows are explicit about what
they do not know (loading, unavailable) and never substitute a record-wide
timestamp for a missing field date.
Styling — the token contract
Components consume colour ONLY through the semantic token vocabulary
(--card, --muted-foreground, --border, --primary, --success,
--destructive, …). Two host steps:
/* Tailwind v4: compile the package's utilities. */
@source "../node_modules/@ai-matrx/diff/dist";// Fresh app with no token vocabulary of its own — gives every token a
// sensible light/dark value. Apps that already define the AI Matrx tokens
// must NOT import this.
import "@ai-matrx/diff/styles.css";Red-means-removed / green-means-added is deliberate and non-configurable (it is the universal diff convention, not a brand colour); everything else — surfaces, borders, text, fills — follows the host's tokens.
The one injection seam: a heavy (code editor) renderer
The package carries no code-editor dependency. Register one and DiffViewer
routes source code and very large inputs to it:
import { setHeavyDiffRenderer, MONACO_DIFF_EDITOR_OPTIONS } from "@ai-matrx/diff/react";
setHeavyDiffRenderer(MyMonacoDiff); // once, at module scopeWith none registered the light engine renders a complete diff — that is the
working default, not a stand-in. Only an explicit engine="monaco" that
cannot be honoured is degraded, and it says so in the toolbar and through
onEngineResolved.
Text engine
import { computeTextDiff, applyHunks } from "@ai-matrx/diff/text";
const diff = computeTextDiff("the quick fox\nsecond line", "the slow fox\nsecond line");
diff.stats; // { additions: 0, deletions: 0, modifications: 1, unchanged: 1 }
diff.inline; // unified view: one entry per line per side (removed, then added)
diff.rows; // split view: one aligned row per pair, left/right cells
diff.rows[0].left.segments;
// word-level highlights on the modified pair:
// [{ type: "unchanged", value: "the " }, { type: "removed", value: "quick" },
// { type: "unchanged", value: " fox" }]
diff.whitespaceOnly; // true when only whitespace differs
// Merge tool: accept/reject hunks, get the merged text back.
// Accept-all === modified, accept-none === original — exactly.
applyHunks(original, modified, [0, 2]); // accept hunks 0 and 2, keep the rest oldBehavior notes: removed/added lines in the same change block pair up as modifications
(with intra-line segments when the pair is at least 25% similar); inputs too large for
the dense LCS matrix degrade safely to a whole-block replace instead of OOMing;
ignoreTrailingWhitespace is a display option — the hunk/merge model always diffs raw
lines so merges round-trip byte-exactly.
Structural engine
import { computeDiff, filterChangesDeep } from "@ai-matrx/diff/structural";
const result = computeDiff(
{ title: "Old", items: [{ id: "a", qty: 1 }, { id: "b", qty: 2 }] },
{ title: "New", items: [{ id: "b", qty: 2 }, { id: "a", qty: 5 }] },
{ identityKeys: { items: "id" } }, // or (item, index) => string
);
result.hasChanges; // true
result.stats; // { added, removed, modified, unchanged, total }
result.root; // DiffNode tree: title modified; items modified with children:
// "a" modified (qty 1 → 5), "b" reordered
filterChangesDeep(result.root); // prune everything unchangedBehavior notes: object key order is ignored by default (opt in per path via
orderSensitiveObjectPaths); underscore-prefixed keys are compared by default — schema
contract keys like __kind are data (skipUnderscorePrefix: true is an explicit opt-in
presentation filter); heterogeneous arrays ([{...}, "str"], nested arrays, nulls) never
throw — a mismatched pair is a modified leaf; when a configured identity key is not
unique, the engine falls back to lossless positional matching rather than silently
dropping items; excludePaths takes bare names for root fields or dotted paths for
nested ones.
Design
- Pure engines, dual ESM/CJS with matching type declarations, verified with a
packed-tarball install loading every entry point through both
importandrequire. The/reactchunks carry the"use client"boundary; the engine entries deliberately do not, so they stay legal inside a React Server Component. The tarball gate checks both directions. - Both engines run happily during render; every quadratic path is capped and degrades instead of throwing or hanging.
- Strict types throughout:
exactOptionalPropertyTypes,noUncheckedIndexedAccess, zeroany. react/react-domare OPTIONAL peers — only/reactneeds them.
License
MIT
Review sessions
DiffReview requires every hunk to be accepted or rejected before Apply. For a
host-owned session, create it from @ai-matrx/diff/text, pass session and an
onTransition reducer boundary; otherwise pass original and modified for
the source-keyed internal session. Set disabled while the host is pending.
