{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"edit-diff-view","type":"registry:ui","title":"Edit Diff View","description":"A streamed AI edit as hunks you accept or reject one by one — id-keyed decisions, word-level marks, and buttons that stay disabled while a hunk is still arriving.","author":"Scrim UI (https://scrimui.dev)","categories":["feedback"],"docs":"https://scrimui.dev/components/edit-diff-view","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/edit-diff-view/edit-diff-view.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * An AI edit, reviewable hunk by hunk.\n *\n * Every diff tool shows you what changed. Almost none of them survive the way\n * an AI edit actually arrives — streamed, in pieces, while the reader is\n * already deciding. The four traps and their answers:\n *\n *  1. HUNK BOUNDARIES MOVE UNDER THE CURSOR. A diff that is still streaming\n *     re-splits itself as more arrives, and an index-keyed \"accept\" then\n *     lands on a different change than the one the reader was looking at.\n *     Here a hunk is an `edit` segment with a caller-given **id**, decisions\n *     are keyed by that id, and segments are only ever appended — so the\n *     hunk a button belongs to cannot change after the button is read.\n *  2. DECIDING ON AN INCOMPLETE HUNK CORRUPTS THE DOCUMENT. An edit whose\n *     replacement text has not finished arriving is not decidable — accept\n *     it and the merged document gets half a function. A hunk with\n *     `complete: false` renders its caret and its buttons stay disabled\n *     until the caller marks it done.\n *  3. PARTIAL ACCEPTANCE MUST LEAVE A COHERENT DOCUMENT. The edit is a list\n *     of segments — `context` (unchanged text) and `edit` (original →\n *     edited) — so `buildMergedDocument` can always produce the whole file:\n *     context verbatim, accepted edits take their replacement, rejected and\n *     undecided edits keep the original. There is no state in which the\n *     output is not a complete document.\n *  4. WORD-LEVEL NOISE HIDES THE ACTUAL CHANGE. Line-level red/green on a\n *     one-word change repaints two whole lines to move one token. Within a\n *     paired removed/added line, only the differing words are marked; the\n *     line pairings are positional, and when the counts differ the extra\n *     lines fall back to whole-line marking rather than a guessed alignment.\n */\n\n/* ------------------------------------------------------------------ */\n/* Model                                                               */\n/* ------------------------------------------------------------------ */\n\n/**\n * The whole edit, in document order. Streaming appends segments; an `edit`\n * segment still arriving has `complete: false` and may have its `edited`\n * text grow — but its id, and therefore every decision made about it, never\n * moves.\n */\nexport type DiffSegment =\n  | { type: \"context\"; text: string }\n  | {\n      type: \"edit\";\n      id: string;\n      /** What was there, as one multi-line string. */\n      original: string;\n      /** What the model proposes instead. */\n      edited: string;\n      /** What this edit is conceptually about — a function name, a section. */\n      context?: string;\n      /** False while this hunk is still streaming. Defaults to true. */\n      complete?: boolean;\n    };\n\nexport type DiffDecision = \"accepted\" | \"rejected\";\n\nexport type DiffDecisions = Record<string, DiffDecision>;\n\n/**\n * The merged document for a set of decisions: context verbatim, accepted\n * edits take `edited`, everything else keeps `original`. Rejected and\n * undecided hunks are indistinguishable in the output on purpose — a\n * document is not a review screen, and \"not decided yet\" must never leak\n * half an edit into it.\n */\nexport function buildMergedDocument(segments: DiffSegment[], decisions: DiffDecisions): string {\n  return segments\n    .map((segment) => {\n      if (segment.type === \"context\") return segment.text;\n      return decisions[segment.id] === \"accepted\" ? segment.edited : segment.original;\n    })\n    .join(\"\");\n}\n\n/* ------------------------------------------------------------------ */\n/* Word-level diff                                                     */\n/* ------------------------------------------------------------------ */\n\ntype WordPart = { text: string; changed: boolean };\n\n/** Split keeping the whitespace, so re-joining the parts is lossless. */\nfunction wordsOf(line: string): string[] {\n  return line.split(/(\\s+)/).filter((part) => part.length > 0);\n}\n\n/**\n * Mark the words that differ between a removed line and its paired added\n * line. Longest-common-subsequence over words: unchanged words are the\n * subsequence, everything else is marked on its own side. Lines are short\n * enough that the quadratic table is never the bottleneck.\n */\nfunction diffWords(removed: string, added: string): { removed: WordPart[]; added: WordPart[] } {\n  const a = wordsOf(removed);\n  const b = wordsOf(added);\n\n  const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));\n  for (let i = a.length - 1; i >= 0; i--) {\n    for (let j = b.length - 1; j >= 0; j--) {\n      table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);\n    }\n  }\n\n  const removedParts: WordPart[] = [];\n  const addedParts: WordPart[] = [];\n  let i = 0;\n  let j = 0;\n  while (i < a.length && j < b.length) {\n    if (a[i] === b[j]) {\n      removedParts.push({ text: a[i], changed: false });\n      addedParts.push({ text: b[j], changed: false });\n      i++;\n      j++;\n    } else if (table[i + 1][j] >= table[i][j + 1]) {\n      removedParts.push({ text: a[i], changed: true });\n      i++;\n    } else {\n      addedParts.push({ text: b[j], changed: true });\n      j++;\n    }\n  }\n  while (i < a.length) removedParts.push({ text: a[i++], changed: true });\n  while (j < b.length) addedParts.push({ text: b[j++], changed: true });\n\n  return { removed: removedParts, added: addedParts };\n}\n\n/* ------------------------------------------------------------------ */\n/* Line rendering                                                      */\n/* ------------------------------------------------------------------ */\n\nfunction LineParts({ parts, side }: { parts: WordPart[]; side: \"removed\" | \"added\" }) {\n  return (\n    <>\n      {parts.map((part, i) =>\n        part.changed ? (\n          <span\n            key={i}\n            className={\n              side === \"removed\"\n                ? \"rounded-[2px] bg-red-300/70 dark:bg-red-500/40\"\n                : \"rounded-[2px] bg-emerald-300/70 dark:bg-emerald-500/40\"\n            }\n          >\n            {part.text}\n          </span>\n        ) : (\n          <React.Fragment key={i}>{part.text}</React.Fragment>\n        ),\n      )}\n    </>\n  );\n}\n\nfunction DiffLine({\n  line,\n  side,\n  paired,\n  wordDiff,\n}: {\n  line: string;\n  side: \"removed\" | \"added\";\n  /** The word parts when this line has a pair on the other side. */\n  paired: WordPart[] | null;\n  wordDiff: boolean;\n}) {\n  return (\n    <div\n      className={`flex px-3 leading-6 ${\n        side === \"removed\"\n          ? \"bg-red-50 text-red-900 dark:bg-red-950/40 dark:text-red-200\"\n          : \"bg-emerald-50 text-emerald-950 dark:bg-emerald-950/40 dark:text-emerald-100\"\n      }`}\n    >\n      <span\n        aria-hidden\n        className={`w-4 shrink-0 select-none text-center ${\n          side === \"removed\" ? \"text-red-400 dark:text-red-500\" : \"text-emerald-500 dark:text-emerald-500\"\n        }`}\n      >\n        {side === \"removed\" ? \"−\" : \"+\"}\n      </span>\n      <span className=\"whitespace-pre-wrap break-all\">\n        {paired && wordDiff ? <LineParts parts={paired} side={side} /> : line === \"\" ? \" \" : line}\n      </span>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Hunk                                                                */\n/* ------------------------------------------------------------------ */\n\nfunction Hunk({\n  segment,\n  decision,\n  wordDiff,\n  onDecide,\n}: {\n  segment: Extract<DiffSegment, { type: \"edit\" }>;\n  decision: DiffDecision | undefined;\n  wordDiff: boolean;\n  onDecide: (id: string, decision: DiffDecision) => void;\n}) {\n  const complete = segment.complete !== false;\n\n  const removedLines = segment.original.replace(/\\n$/, \"\").split(\"\\n\");\n  const addedLines = segment.edited.replace(/\\n$/, \"\").split(\"\\n\");\n\n  /* Positional pairing: line i of the original against line i of the edit.\n     When the counts differ the unpaired remainder marks whole lines, which\n     is honest — guessing an alignment would be prettier and wrong. */\n  const pairs = Math.min(removedLines.length, addedLines.length);\n  const wordParts = React.useMemo(() => {\n    if (!wordDiff) return null;\n    return Array.from({ length: pairs }, (_, i) => diffWords(removedLines[i], addedLines[i]));\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [segment.original, segment.edited, wordDiff]);\n\n  return (\n    <div\n      data-decision={decision}\n      className=\"overflow-hidden rounded-xl border border-zinc-200 bg-white data-[decision=accepted]:border-emerald-300 data-[decision=rejected]:border-zinc-200 data-[decision=rejected]:opacity-60 dark:border-zinc-800 dark:bg-zinc-900 dark:data-[decision=accepted]:border-emerald-800\"\n    >\n      <div className=\"flex items-center gap-2 border-b border-zinc-100 px-3 py-2 dark:border-zinc-800\">\n        <span className=\"min-w-0 flex-1 truncate font-mono text-[11px] text-zinc-500 dark:text-zinc-400\">\n          {segment.context ?? \"Edit\"}\n        </span>\n\n        {!complete && (\n          <span className=\"flex shrink-0 items-center gap-1.5 text-[11px] text-zinc-400 dark:text-zinc-500\">\n            <span className=\"inline-block h-3 w-[6px] animate-pulse rounded-[2px] bg-zinc-400 dark:bg-zinc-500\" aria-hidden />\n            arriving\n          </span>\n        )}\n        {decision === \"accepted\" && (\n          <span className=\"shrink-0 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300\">\n            Accepted\n          </span>\n        )}\n        {decision === \"rejected\" && (\n          <span className=\"shrink-0 rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400\">\n            Rejected\n          </span>\n        )}\n\n        {/* Disabled while the hunk is still arriving: accepting half an edit\n            is how a merged document ends up with half a function. */}\n        <div className=\"flex shrink-0 gap-1\">\n          <button\n            type=\"button\"\n            disabled={!complete}\n            onClick={() => onDecide(segment.id, decision === \"accepted\" ? \"rejected\" : \"accepted\")}\n            title={complete ? (decision === \"accepted\" ? \"Undo — reject instead\" : \"Accept this hunk\") : \"Still arriving\"}\n            aria-pressed={decision === \"accepted\"}\n            className={`h-7 rounded-lg px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${\n              decision === \"accepted\"\n                ? \"bg-emerald-600 text-white hover:bg-emerald-700\"\n                : \"border border-zinc-200 text-zinc-600 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n            }`}\n          >\n            Accept\n          </button>\n          <button\n            type=\"button\"\n            disabled={!complete}\n            onClick={() => onDecide(segment.id, decision === \"rejected\" ? \"accepted\" : \"rejected\")}\n            title={complete ? (decision === \"rejected\" ? \"Undo — accept instead\" : \"Reject this hunk\") : \"Still arriving\"}\n            aria-pressed={decision === \"rejected\"}\n            className={`h-7 rounded-lg px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${\n              decision === \"rejected\"\n                ? \"bg-zinc-700 text-white hover:bg-zinc-800 dark:bg-zinc-600 dark:hover:bg-zinc-500\"\n                : \"border border-zinc-200 text-zinc-600 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n            }`}\n          >\n            Reject\n          </button>\n        </div>\n      </div>\n\n      <div className=\"py-1.5 font-mono text-[12.5px]\">\n        {removedLines.map((line, i) => (\n          <DiffLine\n            key={`r${i}`}\n            line={line}\n            side=\"removed\"\n            wordDiff={wordDiff}\n            paired={wordParts && i < pairs ? wordParts[i].removed : null}\n          />\n        ))}\n        {addedLines.map((line, i) => (\n          <DiffLine\n            key={`a${i}`}\n            line={line}\n            side=\"added\"\n            wordDiff={wordDiff}\n            paired={wordParts && i < pairs ? wordParts[i].added : null}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* View                                                                */\n/* ------------------------------------------------------------------ */\n\nexport type EditDiffViewProps = {\n  segments: DiffSegment[];\n  /** Controlled decisions. Omit to let the view track them internally. */\n  decisions?: DiffDecisions;\n  onDecide?: (hunkId: string, decision: DiffDecision) => void;\n  /** Called with the full set after Accept all / Reject all — the buttons\n   *  only touch hunks that have finished arriving. */\n  onDecideAll?: (decision: DiffDecision) => void;\n  /** Shown in the header, next to the count. */\n  fileName?: string;\n  /** True while segments are still being appended. */\n  streaming?: boolean;\n  wordDiff?: boolean;\n  /** Collapse unchanged text between hunks to a gap line. Default true. */\n  collapseContext?: boolean;\n  className?: string;\n};\n\nexport function EditDiffView({\n  segments,\n  decisions: controlled,\n  onDecide,\n  onDecideAll,\n  fileName,\n  streaming = false,\n  wordDiff = true,\n  collapseContext = true,\n  className = \"\",\n}: EditDiffViewProps) {\n  const [internal, setInternal] = React.useState<DiffDecisions>({});\n  const decisions = controlled ?? internal;\n\n  function decide(id: string, decision: DiffDecision) {\n    if (controlled) {\n      onDecide?.(id, decision);\n    } else {\n      setInternal((current) => ({ ...current, [id]: decision }));\n      onDecide?.(id, decision);\n    }\n  }\n\n  const edits = segments.filter((s): s is Extract<DiffSegment, { type: \"edit\" }> => s.type === \"edit\");\n  const completeEdits = edits.filter((e) => e.complete !== false);\n  const decidedCount = edits.filter((e) => decisions[e.id]).length;\n\n  function decideAll(decision: DiffDecision) {\n    if (controlled) {\n      for (const e of completeEdits) onDecide?.(e.id, decision);\n    } else {\n      setInternal((current) => {\n        const next = { ...current };\n        for (const e of completeEdits) next[e.id] = decision;\n        return next;\n      });\n    }\n    onDecideAll?.(decision);\n  }\n\n  return (\n    <div className={`rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950 ${className}`}>\n      <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1 px-3.5 py-2.5\">\n        <span className=\"min-w-0 truncate font-mono text-[12px] text-zinc-600 dark:text-zinc-300\">\n          {fileName ?? \"Proposed edits\"}\n        </span>\n        <span className=\"text-[11px] text-zinc-400 dark:text-zinc-500\" aria-live=\"polite\">\n          {decidedCount} of {edits.length} decided\n          {streaming ? \" · still arriving\" : \"\"}\n        </span>\n        <div className=\"ml-auto flex gap-1.5\">\n          <button\n            type=\"button\"\n            onClick={() => decideAll(\"accepted\")}\n            disabled={completeEdits.length === 0}\n            className=\"h-7 rounded-lg border border-zinc-200 px-2.5 text-[12px] font-medium text-zinc-600 transition-colors hover:bg-zinc-100 disabled:opacity-40 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n          >\n            Accept all\n          </button>\n          <button\n            type=\"button\"\n            onClick={() => decideAll(\"rejected\")}\n            disabled={completeEdits.length === 0}\n            className=\"h-7 rounded-lg border border-zinc-200 px-2.5 text-[12px] font-medium text-zinc-600 transition-colors hover:bg-zinc-100 disabled:opacity-40 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n          >\n            Reject all\n          </button>\n        </div>\n      </div>\n\n      <div className=\"space-y-2 px-2 pb-2\">\n        {segments.map((segment, i) => {\n          if (segment.type === \"edit\") {\n            return (\n              <Hunk\n                key={segment.id}\n                segment={segment}\n                decision={decisions[segment.id]}\n                wordDiff={wordDiff}\n                onDecide={decide}\n              />\n            );\n          }\n          if (!collapseContext) {\n            return (\n              <div key={i} className=\"px-3 font-mono text-[12.5px] leading-6 whitespace-pre-wrap text-zinc-500 dark:text-zinc-400\">\n                {segment.text.replace(/\\n$/, \"\")}\n              </div>\n            );\n          }\n          const lines = segment.text.replace(/\\n$/, \"\").split(\"\\n\").length;\n          return (\n            <div\n              key={i}\n              className=\"select-none px-3 py-1 text-center text-[11px] tracking-wide text-zinc-400 dark:text-zinc-500\"\n            >\n              ··· {lines} unchanged {lines === 1 ? \"line\" : \"lines\"} ···\n            </div>\n          );\n        })}\n\n        {edits.length === 0 && (\n          <p className=\"px-3 py-8 text-center text-[13px] text-zinc-400 dark:text-zinc-500\">\n            {streaming ? \"Waiting for the first edit…\" : \"No edits.\"}\n          </p>\n        )}\n      </div>\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/edit-diff-view.tsx"}]}