GrDiff

  • code
  • code
  • API · 14 props
  • ⌨ keys
import { GrDiff } from '@feugene/granularity-code/components/GrDiff'

Overview

When to take it

  • An audit log: two versions of a record side by side, showing exactly what was edited.
  • The revisions of a document: “how this one differs from the previous”.
  • The configs of two environments: production against staging, line by line.
  • An answer from the backend with a ready diff: it arrives in hunks, and there is nothing to compute again.

The value does not have to be a string: an object is serialised with a stable order of keys. Without that two objects with the same content and a different order would give invented differences — an edit where there was none.

When to take something else

TaskComponent
Show code without a comparisonGrCodeBlock
Let the code be editedGrCodeEditor
Walk someone else’s unknown with branches expandedGrJsonViewer (the core)
Accept or reject a block of changesnothing: that is a conflict resolver, a different component

Examples 3 each with a live demo and its code

  • An entry of an audit log

    The records are objects, not strings: the diff serialises them in a stable key order, so the shuffled keys of the «after» side invent no changes — only what was edited shows. mode="split" puts both versions side by side, and the summary slot words the count for the log.

    Subscription sub_4F2K · Northwind TradersAudit log · last three changes
    Plan upgraded by Mina Ortiz · 3 lines out, 7 in
    {
    {
    removed "discount": null,
    added "discount": {
    removed "plan": "Team",
    added "code": "UPGRADE10",
    added "percent": 10,
    added "until": "2026-12-31"
    added },
    added "plan": "Business",
    "price": {
    "price": {
    removed "amount": 588,
    added "amount": 1188,
    "currency": "EUR",
    "currency": "EUR",
    "period": "month"
    "period": "month"
    },
    },
  • A revision of an email template

    Prose is compared as text with wrap, and inside a changed line the changed words are marked — «7 days» → «14 days» reads as one edit, not a new paragraph. The layout switches between unified and split, and the row-actions slot gets each line: here a reviewer flags lines to discuss before approving.

    «Payment failed» email · revision 13Edited by Lena Novak · sent to every customer once approved
    Lines added: 3, removed: 3
    1removedSubject: Your payment for {{invoice.number}} did not go through
    1addedSubject: Action needed: payment for {{invoice.number}} failed
    22
    33Hi {{customer.name}},
    44
    5removedWe tried to charge your card ending in {{card.last4}} for {{invoice.amount}}, but the bank declined it.
    5addedWe tried to charge your card ending in {{card.last4}} for {{invoice.amount}}, but your bank declined it.
    66
    7removedPlease update your payment details within 7 days to keep your subscription active.
    7addedPlease update your payment details within 14 days to keep your subscription active. We will try the card again on {{retry.date}}.
    88
    99Update payment details: {{billing.url}}
    1010
    Flag a changed line to discuss it before approval.
  • Two environments, one long file

    A file of a hundred and eighty lines with three edits opens on those edits: context keeps two lines around each and folds the rest into gaps, expand-step opens twenty more from either edge, and expand reports each press. max-height makes the area a scroller of its own. Compared with itself, the summary says the versions match, and the file shows whole: with no edits there is nothing to fold around.

    billing-service/config.yaml180 lines · production is the reference
    3 settings differ from staging
    1818 option_02: "database-2"
    1919 option_03: true
    20removed option_04: 40
    20added option_04: 10
    2121 option_05: "database-5"
    2222 option_06: true
    43 lines hidden
    6666 option_05: "payments-5"
    6767 option_06: true
    68removed option_07: "sepa,card"
    68added option_07: "sepa,card,instant"
    6969 option_08: "payments-8"
    7070 option_09: true
    105 lines hidden
    176176 option_10: 50
    177177 option_11: "features-11"
    178removed option_12: false
    178added option_12: true
    179179 option_13: 65
    180180 option_14: "features-14"

API generated from the package · cannot drift

Props

PropTypedefaultDescription
languagestring | undefinedundefinedThe language for line highlighting. The same dictionary as the block's. Not set — `json` when values are compared and `text` when strings are: we serialize the object ourselves and know exactly what came out.
highlighterGrCodeTokenizer | undefinedundefinedHighlighting for languages beyond the built-in ones. Unset — taken from the application's `provide`/`inject` (`GR_CODE_HIGHLIGHTER_KEY`).
maxHeightstring | number | undefinedundefinedThe height after which vertical scrolling switches on; a number is in pixels.
wrapboolean | undefinedundefinedWrap long lines instead of scrolling horizontally. Unset — from `GrConfigProvider`, otherwise `false`.
lineNumbersboolean | undefinedundefinedThe line numbers of both sides in the gutter. Unset — from `GrConfigProvider`, otherwise `true`.
ariaLabelstring | undefinedundefinedThe name of the comparison area. An unnamed one is announced by a screen reader as just "region".
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe size step: the type size and the padding of the lines. `md` by default; taken from `GrConfigProvider`.
modeGrDiffMode | undefinedundefinedThe layout: `unified` is one column with `+`/`−` signs, `split` puts "before" and "after" side by side. Unset — from `GrConfigProvider`, otherwise `unified`.
contextnumber | undefinedundefinedUnchanged lines around a change. `0` — changes only, `Infinity` — everything.
expandStepnumber | undefinedundefinedHow many lines one click on a gap opens.
budgetnumber | undefinedGR_DIFF_DEFAULT_BUDGETThe algorithm's work limit. Beyond it the diff gets coarser instead of freezing the tab.
beforeunknown—The left side. Not a string — it is serialized with a stable key order.
afterunknown—The right side.
hunksGrDiffHunk[] | undefined—A ready-made diff. Stronger than `before`/`after`: there is nothing to recompute.

Slots

SlotTypeDescription
summary{ added: number; removed: number; degraded: boolean; }—
empty{}—
row-actions{ line: GrDiffLine; }—

Events

EventSignatureDescription
budgetExceeded(event: "budgetExceeded"): void—
expand(event: "expand", gapId: string, edge: GrDiffGapEdge): void—

Methods

MethodsSignatureDescription
scrollToRow(index: number, align?: GrVirtualAlign | undefined) => voidScroll to a row by index — for the consumer's "next change".

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrDiffMode, GrDiffHunk, GrDiffProps } from '@feugene/granularity-code/components/GrDiff'

Filedist/types/components/GrDiff/config.d.ts

grDiffConfigconst
const grDiffConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDiff">;

Filedist/types/components/GrDiff/GrDiff.vue.d.ts

GrDiffModetype
type GrDiffMode = 'unified' | 'split';
GrDiffHunkinterface
interface GrDiffHunk {
    op: 'equal' | 'add' | 'remove';
    lines: string[];
}
GrDiffPropsinterface
interface GrDiffProps {
    before?: unknown;
    after?: unknown;
    hunks?: GrDiffHunk[];
    mode?: GrDiffMode;
    language?: string;
    highlighter?: GrCodeTokenizer;
    context?: number;
    expandStep?: number;
    budget?: number;
    lineNumbers?: boolean;
    wrap?: boolean;
    maxHeight?: string | number;
    size?: GrComponentSize;
    ariaLabel?: string;
}
GrDiffEmitsinterface
interface GrDiffEmits {
    (e: 'expand', gapId: string, edge: GrDiffGapEdge): void;
    (e: 'budgetExceeded'): void;
}

Filedist/types/components/GrDiff/defaults.d.ts

GrDiffConfigurablePropsinterface
interface GrDiffConfigurableProps {
    size: GrComponentSize;
    mode: 'unified' | 'split';
    wrap: boolean;
    lineNumbers: boolean;
    context: number;
    expandStep: number;
}

Filedist/types/components/GrDiff/safelist.d.ts

grDiffSafelistconst
const grDiffSafelist: string[];

Accessibility

APG pattern

область + кнопки пропусков

Usage from the library prose

The diff is computed with an algorithm of its own rather than with @codemirror/merge

A ready merge view would give less code on our side and a mandatory CodeMirror for whoever came simply to look at what changed. Of the three scenarios of the package, reading a diff is the most frequent: the log is opened by everyone, and the config is edited by one administrator in a hundred. There is no point making reading pay for the editor.

The second argument is the styling: someone else’s merge view has a palette and classes of its own, and our tokens get in there by overriding foreign selectors and hold until the next minor of the library.

The budget: why a comparison has a limit

The complexity of Myers’s algorithm depends on the edit distance rather than on the length of the input. Two files of ten thousand lines each, differing entirely, are a frozen tab, and it is the user who will see it rather than the developer.

Beyond the limit (budget) the parsing goes into a coarse pass: the common prefix and suffix remain, and the middle is counted as replaced entirely. The diff stays correct — simply less detailed — and reports that with a budgetExceeded emit and a line in the summary. That is better than a frozen tab and more honest than silence.

Word-level highlighting

Inside a changed line the changed word is marked. Without that an edit of one word reads as “the whole line is different”, and the diff stops answering its own question.

A line with no spaces — a long base64, a minified JSON — is exempted from the word-level parsing: it degenerates into a character-level one, highlights every other sign and costs a lot. Such a line is shown more honestly as changed in full.

The pair for the parsing is taken from the block of the edit as a whole rather than from the neighbouring rows. diffLines outputs a block with the deletions first and the additions after (-a -b -c +A +B +C), and counting by neighbours would bring together -c and +A — lines that have nothing to do with each other. The k-th deletion stands against the k-th addition; a block of unequal length is padded with an empty side.

Collapsing

A diff of a config of a thousand lines with one edit is obliged to open showing that edit. context lines are left around every change, and the rest is folded into an expandable gap.

context: 0 leaves the changes alone, and Infinity folds nothing.

A gap is expanded in steps from either edge, as in a code review: the piece you need is looked for next to the edit rather than by unfolding the whole file. The strip of the gap carries two buttons — ”↓ N” opens N lines at the start of the gap, ”↑ N” at the end — and a counter between them. The size of the step is set by expandStep (10 by default) and, like context, is configured for the application through GrConfigProvider.

A remainder smaller than the step is opened in full, and the strip turns into a single button: a button that will open nothing more is a dead end people run into exactly once and then stop believing it.

Expanding from the top holds the gap in place: the lines stand above it, and without a correction for their height the content moves down by exactly as much — the user pressed “show more” and lost sight of the very edit they were looking at. The increment is taken by measurement rather than by computing “lines × height”: the height of a line depends on the type size and on wrapping.

Expanding one gap does not touch its neighbours: their identifier is the position of the first line of the section rather than of the first hidden one and not an ordinal number. Count it by the hidden ones and it would change at every step, and the state would lose its own gap: a second press would open it again from scratch.

Accessibility

The summary is declared a live region: without it a screen reader reads a stream of lines without understanding that a comparison is in front of it.

Colour is not the only carrier of meaning — otherwise that is WCAG 1.4.1. What was added and what was removed are told apart by a sign in the gutter (+ / −): a diff is read on monochrome printouts as well. The sign stands in both modes: in split it is in every column of its own rather than only in unified.

The comparison area is a scroller and is therefore reachable from the keyboard.

Virtualisation requires spacers

A long diff is cut by a rendering window, and useVirtualList gives away only the heights of the spacers as variables — the pseudo-elements themselves are declared by the component, in its own <style>. Without that rule there is nobody to read the variables: the container stays one window tall, there is no scrolling at all, and out of a thousand lines the first dozen are reachable. The markup is valid in the process, and the failure is visible only to the eye — which is why the rule is held by the virtualSpacer.test.ts gate.

The row-actions slot

The row is given away by the slot as a whole — for a “copy the line” button, a link to a discussion, the mark of a reviewer. The slot receives line and works in both modes; in split the line of the right side arrives, and for a lone deletion the line of the left one.

Limits

It does not edit. It does not resolve conflicts. It does not parse a unified diff from git: hunks are accepted as a typed structure of their own, because a parser of a unified diff has edges (\ No newline at end of file, the arithmetic of the header, binary files), and a parser that is wrong at an edge is worse than a missing one — it does not fail, it shows a wrong comparison.