{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "diff-viewer",
  "title": "Diff Viewer",
  "description": "Side-by-side or unified text diff with word-level highlights and collapsed unchanged regions.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/ui/diff-viewer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronRightIcon } from \"lucide-react\"\n\nimport {\n  collapseUnchanged,\n  diffLines,\n  pairLines,\n  type DiffLine,\n} from \"@/registry/default/lib/diff\"\nimport { cn } from \"@/registry/default/lib/utils\"\n\ntype DiffMode = \"split\" | \"unified\"\n\nconst NUMBER_CLASS =\n  \"px-2 py-0.5 text-right align-top text-muted-foreground tabular-nums select-none\"\n\nconst LINE_CLASSES: Record<DiffLine[\"type\"], string> = {\n  equal: \"\",\n  add: \"bg-[color-mix(in_oklab,var(--chart-2)_15%,transparent)]\",\n  remove: \"bg-destructive/10\",\n}\n\nconst SIGNS: Record<DiffLine[\"type\"], string> = {\n  equal: \" \",\n  add: \"+\",\n  remove: \"-\",\n}\n\nconst SCREEN_READER_LABELS: Record<DiffLine[\"type\"], string> = {\n  equal: \"\",\n  add: \"Added: \",\n  remove: \"Removed: \",\n}\n\nfunction lineCount(count: number) {\n  return `${count} ${count === 1 ? \"line\" : \"lines\"}`\n}\n\nfunction LineText({ line }: { line: DiffLine }) {\n  if (!line.segments) return line.text\n\n  const Changed = line.type === \"add\" ? \"ins\" : \"del\"\n  return line.segments.map((segment, index) =>\n    segment.changed ? (\n      <Changed\n        key={index}\n        className={cn(\n          \"rounded-sm no-underline\",\n          line.type === \"add\"\n            ? \"bg-[color-mix(in_oklab,var(--chart-2)_35%,transparent)]\"\n            : \"bg-destructive/25\"\n        )}\n      >\n        {segment.text}\n      </Changed>\n    ) : (\n      segment.text\n    )\n  )\n}\n\nfunction LineCell({ line, className }: { line: DiffLine; className?: string }) {\n  return (\n    <td\n      className={cn(\n        \"px-2 py-0.5 align-top wrap-anywhere whitespace-pre-wrap\",\n        className\n      )}\n    >\n      <span aria-hidden className=\"mr-2 text-muted-foreground select-none\">\n        {SIGNS[line.type]}\n      </span>\n      <span className=\"sr-only\">{SCREEN_READER_LABELS[line.type]}</span>\n      <LineText line={line} />\n    </td>\n  )\n}\n\nfunction SplitSide({ line }: { line: DiffLine | null }) {\n  if (!line) {\n    return <td colSpan={2} className=\"bg-muted/50\" />\n  }\n  return (\n    <>\n      <td className={cn(NUMBER_CLASS, LINE_CLASSES[line.type])}>\n        {line.oldNumber ?? line.newNumber}\n      </td>\n      <LineCell line={line} className={LINE_CLASSES[line.type]} />\n    </>\n  )\n}\n\nfunction SplitRows({ lines }: { lines: DiffLine[] }) {\n  return pairLines(lines).map((pair) => (\n    <tr key={`${pair.old?.oldNumber}:${pair.new?.newNumber}`}>\n      <SplitSide line={pair.old} />\n      <SplitSide line={pair.new} />\n    </tr>\n  ))\n}\n\nfunction UnifiedRows({ lines }: { lines: DiffLine[] }) {\n  return lines.map((line) => (\n    <tr\n      key={`${line.oldNumber}:${line.newNumber}`}\n      className={LINE_CLASSES[line.type]}\n    >\n      <td className={NUMBER_CLASS}>{line.oldNumber}</td>\n      <td className={NUMBER_CLASS}>{line.newNumber}</td>\n      <LineCell line={line} />\n    </tr>\n  ))\n}\n\nfunction DiffViewer({\n  oldValue,\n  newValue,\n  mode = \"split\",\n  contextLines = 3,\n  className,\n  \"aria-label\": ariaLabel = \"Diff\",\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  oldValue: string\n  newValue: string\n  mode?: DiffMode\n  contextLines?: number\n}) {\n  const lines = React.useMemo(\n    () => diffLines(oldValue, newValue),\n    [oldValue, newValue]\n  )\n  const blocks = React.useMemo(\n    () => collapseUnchanged(lines, contextLines),\n    [lines, contextLines]\n  )\n  const [expandedIds, setExpandedIds] = React.useState<number[]>([])\n  const [expandedFor, setExpandedFor] = React.useState(lines)\n  if (expandedFor !== lines) {\n    setExpandedFor(lines)\n    setExpandedIds([])\n  }\n\n  const addedCount = lines.filter((line) => line.type === \"add\").length\n  const removedCount = lines.filter((line) => line.type === \"remove\").length\n  const columnCount = mode === \"split\" ? 4 : 3\n  const Rows = mode === \"split\" ? SplitRows : UnifiedRows\n\n  function toggle(id: number) {\n    setExpandedIds((current) =>\n      current.includes(id)\n        ? current.filter((expandedId) => expandedId !== id)\n        : [...current, id]\n    )\n  }\n\n  return (\n    <div\n      data-slot=\"diff-viewer\"\n      data-mode={mode}\n      className={cn(\n        \"overflow-auto rounded-lg border font-mono text-xs\",\n        className\n      )}\n      {...props}\n    >\n      <table aria-label={ariaLabel} className=\"w-full table-fixed\">\n        <caption className=\"sr-only\">\n          {addedCount === 0 && removedCount === 0\n            ? \"No changes\"\n            : `${lineCount(addedCount)} added, ${lineCount(removedCount)} removed`}\n        </caption>\n        <colgroup>\n          {mode === \"split\" ? (\n            <>\n              <col className=\"w-12\" />\n              <col />\n              <col className=\"w-12\" />\n              <col />\n            </>\n          ) : (\n            <>\n              <col className=\"w-12\" />\n              <col className=\"w-12\" />\n              <col />\n            </>\n          )}\n        </colgroup>\n        <thead className=\"sr-only\">\n          <tr>\n            {mode === \"split\" ? (\n              <>\n                <th scope=\"col\">Old line number</th>\n                <th scope=\"col\">Old content</th>\n                <th scope=\"col\">New line number</th>\n                <th scope=\"col\">New content</th>\n              </>\n            ) : (\n              <>\n                <th scope=\"col\">Old line number</th>\n                <th scope=\"col\">New line number</th>\n                <th scope=\"col\">Content</th>\n              </>\n            )}\n          </tr>\n        </thead>\n        {blocks.map((block, index) => {\n          if (block.kind === \"lines\") {\n            return (\n              <tbody key={`lines-${index}`}>\n                <Rows lines={block.lines} />\n              </tbody>\n            )\n          }\n\n          const isExpanded = expandedIds.includes(block.id)\n          return (\n            <tbody key={`unchanged-${block.id}`}>\n              <tr>\n                <td colSpan={columnCount} className=\"bg-muted/50\">\n                  <button\n                    type=\"button\"\n                    aria-expanded={isExpanded}\n                    onClick={() => toggle(block.id)}\n                    className=\"flex w-full items-center gap-1.5 px-2 py-1 text-left font-sans text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset\"\n                  >\n                    <ChevronRightIcon\n                      className={cn(\n                        \"size-3.5 transition-transform duration-200\",\n                        isExpanded && \"rotate-90\"\n                      )}\n                    />\n                    {isExpanded ? \"Hide\" : \"Show\"} {block.lines.length}{\" \"}\n                    unchanged lines\n                  </button>\n                </td>\n              </tr>\n              {isExpanded && <Rows lines={block.lines} />}\n            </tbody>\n          )\n        })}\n      </table>\n    </div>\n  )\n}\n\nexport { DiffViewer, type DiffMode }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/diff.ts",
      "content": "export type DiffSegment = { text: string; changed: boolean }\n\nexport type DiffLine = {\n  type: \"equal\" | \"add\" | \"remove\"\n  text: string\n  oldNumber: number | null\n  newNumber: number | null\n  segments?: DiffSegment[]\n}\n\nexport type DiffPair = { old: DiffLine | null; new: DiffLine | null }\n\nexport type DiffBlock =\n  | { kind: \"lines\"; lines: DiffLine[] }\n  | { kind: \"unchanged\"; id: number; lines: DiffLine[] }\n\ntype Edit = DiffLine[\"type\"]\n\nconst MAX_EDIT_DISTANCE = 2000\n\nfunction backtrack(trace: Int32Array[], oldLength: number, newLength: number) {\n  const edits: Edit[] = []\n  let x = oldLength\n  let y = newLength\n\n  for (let distance = trace.length - 1; distance > 0; distance--) {\n    const furthest = trace[distance]\n    const diagonal = x - y\n    const at = (k: number) => furthest[k + distance + 1]\n    const movedDown =\n      diagonal === -distance ||\n      (diagonal !== distance && at(diagonal - 1) < at(diagonal + 1))\n    const previousDiagonal = movedDown ? diagonal + 1 : diagonal - 1\n    const previousX = at(previousDiagonal)\n    const previousY = previousX - previousDiagonal\n\n    while (x > previousX && y > previousY) {\n      edits.push(\"equal\")\n      x--\n      y--\n    }\n    edits.push(movedDown ? \"add\" : \"remove\")\n    x = previousX\n    y = previousY\n  }\n\n  for (; x > 0; x--) edits.push(\"equal\")\n  return edits.reverse()\n}\n\nfunction shortestEdits(oldItems: string[], newItems: string[]): Edit[] {\n  const oldLength = oldItems.length\n  const newLength = newItems.length\n  const limit = Math.min(oldLength + newLength, MAX_EDIT_DISTANCE)\n  const center = limit + 1\n  const furthest = new Int32Array(2 * limit + 3)\n  const trace: Int32Array[] = []\n\n  for (let distance = 0; distance <= limit; distance++) {\n    trace.push(furthest.slice(center - distance - 1, center + distance + 2))\n\n    for (let diagonal = -distance; diagonal <= distance; diagonal += 2) {\n      const movesDown =\n        diagonal === -distance ||\n        (diagonal !== distance &&\n          furthest[center + diagonal - 1] < furthest[center + diagonal + 1])\n      let x = movesDown\n        ? furthest[center + diagonal + 1]\n        : furthest[center + diagonal - 1] + 1\n      let y = x - diagonal\n\n      while (x < oldLength && y < newLength && oldItems[x] === newItems[y]) {\n        x++\n        y++\n      }\n      furthest[center + diagonal] = x\n\n      if (x >= oldLength && y >= newLength) {\n        return backtrack(trace, oldLength, newLength)\n      }\n    }\n  }\n\n  return [\n    ...oldItems.map((): Edit => \"remove\"),\n    ...newItems.map((): Edit => \"add\"),\n  ]\n}\n\nexport function diffSequences(oldItems: string[], newItems: string[]): Edit[] {\n  const shortest = Math.min(oldItems.length, newItems.length)\n  let prefix = 0\n  while (prefix < shortest && oldItems[prefix] === newItems[prefix]) prefix++\n\n  let suffix = 0\n  while (\n    suffix < shortest - prefix &&\n    oldItems[oldItems.length - 1 - suffix] ===\n      newItems[newItems.length - 1 - suffix]\n  ) {\n    suffix++\n  }\n\n  return [\n    ...Array.from({ length: prefix }, (): Edit => \"equal\"),\n    ...shortestEdits(\n      oldItems.slice(prefix, oldItems.length - suffix),\n      newItems.slice(prefix, newItems.length - suffix)\n    ),\n    ...Array.from({ length: suffix }, (): Edit => \"equal\"),\n  ]\n}\n\nfunction tokenize(text: string) {\n  return text.match(/\\s+|[\\p{L}\\p{N}_]+|[^\\s\\p{L}\\p{N}_]/gu) ?? []\n}\n\nfunction appendSegment(\n  segments: DiffSegment[],\n  text: string,\n  changed: boolean\n) {\n  const last = segments.at(-1)\n  if (last?.changed === changed) last.text += text\n  else segments.push({ text, changed })\n}\n\nexport function diffWords(oldText: string, newText: string) {\n  const oldTokens = tokenize(oldText)\n  const newTokens = tokenize(newText)\n  const oldSegments: DiffSegment[] = []\n  const newSegments: DiffSegment[] = []\n  let oldIndex = 0\n  let newIndex = 0\n  let hasSharedWord = false\n\n  for (const edit of diffSequences(oldTokens, newTokens)) {\n    if (edit === \"equal\") {\n      hasSharedWord ||= oldTokens[oldIndex].trim() !== \"\"\n      appendSegment(oldSegments, oldTokens[oldIndex++], false)\n      appendSegment(newSegments, newTokens[newIndex++], false)\n    } else if (edit === \"remove\") {\n      appendSegment(oldSegments, oldTokens[oldIndex++], true)\n    } else {\n      appendSegment(newSegments, newTokens[newIndex++], true)\n    }\n  }\n\n  return hasSharedWord ? { old: oldSegments, new: newSegments } : null\n}\n\nfunction splitLines(text: string) {\n  return text === \"\" ? [] : text.split(/\\r?\\n/)\n}\n\nexport function diffLines(oldText: string, newText: string) {\n  const oldLines = splitLines(oldText)\n  const newLines = splitLines(newText)\n  const lines: DiffLine[] = []\n  let removed: DiffLine[] = []\n  let added: DiffLine[] = []\n  let oldIndex = 0\n  let newIndex = 0\n\n  function flushChanges() {\n    removed.forEach((line, index) => {\n      const partner = added[index]\n      const words = partner && diffWords(line.text, partner.text)\n      if (!words) return\n      line.segments = words.old\n      partner.segments = words.new\n    })\n    lines.push(...removed, ...added)\n    removed = []\n    added = []\n  }\n\n  for (const edit of diffSequences(oldLines, newLines)) {\n    if (edit === \"remove\") {\n      removed.push({\n        type: \"remove\",\n        text: oldLines[oldIndex++],\n        oldNumber: oldIndex,\n        newNumber: null,\n      })\n    } else if (edit === \"add\") {\n      added.push({\n        type: \"add\",\n        text: newLines[newIndex++],\n        oldNumber: null,\n        newNumber: newIndex,\n      })\n    } else {\n      flushChanges()\n      lines.push({\n        type: \"equal\",\n        text: oldLines[oldIndex++],\n        oldNumber: oldIndex,\n        newNumber: ++newIndex,\n      })\n    }\n  }\n  flushChanges()\n\n  return lines\n}\n\nexport function pairLines(lines: DiffLine[]) {\n  const pairs: DiffPair[] = []\n  let index = 0\n\n  while (index < lines.length) {\n    if (lines[index].type === \"equal\") {\n      pairs.push({ old: lines[index], new: lines[index] })\n      index++\n      continue\n    }\n    const removed: DiffLine[] = []\n    const added: DiffLine[] = []\n    for (; lines[index]?.type === \"remove\"; index++) removed.push(lines[index])\n    for (; lines[index]?.type === \"add\"; index++) added.push(lines[index])\n    for (let row = 0; row < Math.max(removed.length, added.length); row++) {\n      pairs.push({ old: removed[row] ?? null, new: added[row] ?? null })\n    }\n  }\n\n  return pairs\n}\n\nexport function collapseUnchanged(lines: DiffLine[], contextLines: number) {\n  const blocks: DiffBlock[] = []\n  let visible: DiffLine[] = []\n\n  function flushVisible() {\n    if (visible.length > 0) blocks.push({ kind: \"lines\", lines: visible })\n    visible = []\n  }\n\n  let start = 0\n  while (start < lines.length) {\n    let end = start\n    const isEqual = lines[start].type === \"equal\"\n    while (end < lines.length && (lines[end].type === \"equal\") === isEqual)\n      end++\n\n    const run = lines.slice(start, end)\n    const leading = start === 0 ? 0 : contextLines\n    const trailing = end === lines.length ? 0 : contextLines\n    const hiddenCount = run.length - leading - trailing\n\n    if (isEqual && hiddenCount > 1) {\n      visible.push(...run.slice(0, leading))\n      flushVisible()\n      blocks.push({\n        kind: \"unchanged\",\n        id: start,\n        lines: run.slice(leading, leading + hiddenCount),\n      })\n      visible.push(...run.slice(leading + hiddenCount))\n    } else {\n      visible.push(...run)\n    }\n    start = end\n  }\n  flushVisible()\n\n  return blocks\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}