Skip to content

diff-lines

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/diff-lines.json
See it liveThe changes and the rows they pair into, printed rather than drawn - including the insertion that pushes a filtered pair of columns out of step.→

Split out of DiffView like tree-rows and line-scale: a product that wants to know how much moved between two drafts — to put a number in a list, to decide whether to offer the comparison at all — should not render a component to find out.

import { countChanges, diffLines, rows } from '@/components/ui/diff-lines'
const changes = diffLines(before, after)
countChanges(changes) // { added: 2, removed: 1 }
rows(changes) // one row per line pair, for two columns

rows is the part that matters, and the part a hand-written diff leaves out. Drawing two columns by filtering the change list twice gives two columns of different lengths the moment anything is inserted — from there the reader is comparing line 4 against line 3, and nothing about it looks wrong. Pairing makes the alignment structural: a row is one object with two sides, so the columns cannot drift.

rows(diffLines('a\nb', 'x\na\nb'))
// [ { before: null, after: 'x', kind: 'added', beforeLine: null, afterLine: 1 },
// { before: 'a', after: 'a', kind: 'same', beforeLine: 1, afterLine: 2 },
// { before: 'b', after: 'b', kind: 'same', beforeLine: 2, afterLine: 3 } ]

null is a side with nothing there, and an empty string is not. A blank line somebody wrote is a line; conflating the two draws it as a deletion.

A rewrite is paired into one row. A removal with an insertion right behind it is a line that was changed, and showing the old and the new opposite each other is what the comparison is for. The row keeps kind: 'removed' with both sides set, which is how DiffView knows to mark it ~.

Line numbers are of the texts, not of the rows. The rows include the gaps and the files do not, so a gutter that counted rows would send the reader one line wrong after every insertion.

Plain longest-common-subsequence, exact rather than heuristic. The bodies are a page or two, so the O(n·m) table costs nothing. Past LIMIT lines a side (2,000 — four million cells) it gives up and returns the two texts whole, because the honest answer is “too long to compare” rather than a frozen window.

Lines, not words, for the reason DiffView states: prose is revised by the line.

diffLines(before, after) Change[] — same, added or removed, in order
rows(changes) DiffRow[] — one per line pair, with both sides and both numbers
countChanges(changes) { added, removed }
LIMIT 2000 — lines per side before it gives up
interface DiffRow {
before: string | null
after: string | null
kind: 'same' | 'added' | 'removed'
beforeLine: number | null
afterLine: number | null
}

Accessibility

The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 4 rules applied and passed, and none failed. The accessibility report has every item.

History

Each release that changed ui/diff-lines.tsx - the file a product copies - newest first.

  1. v0.34.1
    • fix keep a module's description through shadcn add a2182f9
  2. v0.25.0
    • feat markdown through one stylesheet, and code whose colours are yours 5f27eb8