{
  "name": "diff-view",
  "dependencies": [],
  "registryDependencies": [
    "syntax"
  ],
  "files": [
    {
      "path": "diff-view.tsx",
      "content": "\"use client\";\r\n\r\nimport { css, themeVars as theme } from \"@yugnex/core\";\r\nimport { useMemo, type ReactNode } from \"react\";\r\nimport { languageFromFilename, tokenizeLine, TokenLine, type ScanState, type SyntaxLanguage, type Token } from \"./syntax\";\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Myers diff\r\n * ------------------------------------------------------------------ */\r\n\r\nexport type EditKind = \"equal\" | \"insert\" | \"delete\";\r\n\r\nexport interface Edit<T> {\r\n  kind: EditKind;\r\n  value: T;\r\n  /** 0-based index in the original sequence; undefined for inserts. */\r\n  oldIndex?: number;\r\n  /** 0-based index in the new sequence; undefined for deletes. */\r\n  newIndex?: number;\r\n}\r\n\r\n/**\r\n * Myers' O(ND) diff.\r\n *\r\n * Walks the edit graph one D (edit distance) at a time, recording the furthest\r\n * reaching path per diagonal, then backtracks through the saved traces to\r\n * recover the actual edit script. Chosen over a naive LCS table because the\r\n * table is O(N*M) in *memory* — a 3000-line file against its edited self is\r\n * nine million cells, versus Myers touching work proportional to the number of\r\n * changes, which for a generated patch is small.\r\n */\r\nexport function myersDiff<T>(a: T[], b: T[], equals: (x: T, y: T) => boolean = Object.is): Array<Edit<T>> {\r\n  const n = a.length;\r\n  const m = b.length;\r\n\r\n  if (n === 0 && m === 0) return [];\r\n  if (n === 0) return b.map((value, i) => ({ kind: \"insert\" as const, value, newIndex: i }));\r\n  if (m === 0) return a.map((value, i) => ({ kind: \"delete\" as const, value, oldIndex: i }));\r\n\r\n  const max = n + m;\r\n  const offset = max;\r\n  // v[k + offset] = furthest x reached on diagonal k.\r\n  const v = new Int32Array(2 * max + 1);\r\n  const trace: Int32Array[] = [];\r\n\r\n  let found = -1;\r\n\r\n  for (let d = 0; d <= max; d++) {\r\n    trace.push(v.slice());\r\n\r\n    for (let k = -d; k <= d; k += 2) {\r\n      // Pick the move that gets us furthest: down (insert) or right (delete).\r\n      let x: number;\r\n      if (k === -d || (k !== d && (v[k - 1 + offset] as number) < (v[k + 1 + offset] as number))) {\r\n        x = v[k + 1 + offset] as number;\r\n      } else {\r\n        x = (v[k - 1 + offset] as number) + 1;\r\n      }\r\n      let y = x - k;\r\n\r\n      // Slide down the diagonal for free while the elements match.\r\n      while (x < n && y < m && equals(a[x] as T, b[y] as T)) {\r\n        x++;\r\n        y++;\r\n      }\r\n\r\n      v[k + offset] = x;\r\n\r\n      if (x >= n && y >= m) {\r\n        found = d;\r\n        break;\r\n      }\r\n    }\r\n\r\n    if (found !== -1) break;\r\n  }\r\n\r\n  if (found === -1) {\r\n    // Unreachable for well-formed input, but returning a whole-file replace is\r\n    // a defensible fallback rather than throwing at render time.\r\n    return [\r\n      ...a.map((value, i) => ({ kind: \"delete\" as const, value, oldIndex: i })),\r\n      ...b.map((value, i) => ({ kind: \"insert\" as const, value, newIndex: i })),\r\n    ];\r\n  }\r\n\r\n  // Backtrack through the traces to build the script.\r\n  const edits: Array<Edit<T>> = [];\r\n  let x = n;\r\n  let y = m;\r\n\r\n  for (let d = found; d > 0; d--) {\r\n    const prev = trace[d] as Int32Array;\r\n    const k = x - y;\r\n\r\n    let prevK: number;\r\n    if (k === -d || (k !== d && (prev[k - 1 + offset] as number) < (prev[k + 1 + offset] as number))) {\r\n      prevK = k + 1;\r\n    } else {\r\n      prevK = k - 1;\r\n    }\r\n\r\n    const prevX = prev[prevK + offset] as number;\r\n    const prevY = prevX - prevK;\r\n\r\n    // Everything above the branch point on this diagonal is unchanged.\r\n    while (x > prevX && y > prevY) {\r\n      x--;\r\n      y--;\r\n      edits.push({ kind: \"equal\", value: a[x] as T, oldIndex: x, newIndex: y });\r\n    }\r\n\r\n    if (d > 0) {\r\n      if (x === prevX) {\r\n        y--;\r\n        edits.push({ kind: \"insert\", value: b[y] as T, newIndex: y });\r\n      } else {\r\n        x--;\r\n        edits.push({ kind: \"delete\", value: a[x] as T, oldIndex: x });\r\n      }\r\n    }\r\n  }\r\n\r\n  // Any remaining prefix matched.\r\n  while (x > 0 && y > 0) {\r\n    x--;\r\n    y--;\r\n    edits.push({ kind: \"equal\", value: a[x] as T, oldIndex: x, newIndex: y });\r\n  }\r\n\r\n  return edits.reverse();\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Word-level diff\r\n * ------------------------------------------------------------------ */\r\n\r\n/**\r\n * Splits into words plus their trailing whitespace/punctuation, so a rename\r\n * highlights the identifier rather than the whole line. Keeping delimiters as\r\n * their own tokens means the reassembled line is byte-identical to the input.\r\n */\r\nfunction splitWords(line: string): string[] {\r\n  return line.match(/[A-Za-z0-9_$]+|\\s+|[^A-Za-z0-9_$\\s]/g) ?? [];\r\n}\r\n\r\nexport interface WordSpan {\r\n  text: string;\r\n  changed: boolean;\r\n}\r\n\r\n/** Word-level diff of one changed line, for highlighting only what moved. */\r\nexport function diffWords(before: string, after: string): { before: WordSpan[]; after: WordSpan[] } {\r\n  const edits = myersDiff(splitWords(before), splitWords(after));\r\n\r\n  const beforeSpans: WordSpan[] = [];\r\n  const afterSpans: WordSpan[] = [];\r\n\r\n  for (const edit of edits) {\r\n    if (edit.kind === \"equal\") {\r\n      beforeSpans.push({ text: edit.value, changed: false });\r\n      afterSpans.push({ text: edit.value, changed: false });\r\n    } else if (edit.kind === \"delete\") {\r\n      beforeSpans.push({ text: edit.value, changed: true });\r\n    } else {\r\n      afterSpans.push({ text: edit.value, changed: true });\r\n    }\r\n  }\r\n\r\n  return { before: mergeSpans(beforeSpans), after: mergeSpans(afterSpans) };\r\n}\r\n\r\n/** Collapses adjacent spans of the same kind so the DOM stays small. */\r\nfunction mergeSpans(spans: WordSpan[]): WordSpan[] {\r\n  const out: WordSpan[] = [];\r\n  for (const span of spans) {\r\n    const last = out[out.length - 1];\r\n    if (last && last.changed === span.changed) last.text += span.text;\r\n    else out.push({ ...span });\r\n  }\r\n  return out;\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Hunks\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface DiffLine {\r\n  kind: EditKind;\r\n  /** 1-based line number in the original file. */\r\n  oldNumber?: number;\r\n  /** 1-based line number in the new file. */\r\n  newNumber?: number;\r\n  text: string;\r\n  /** Set on a delete/insert pair recognised as one edited line. */\r\n  wordSpans?: WordSpan[];\r\n}\r\n\r\nexport interface DiffHunk {\r\n  /** 1-based start line in the original file. */\r\n  oldStart: number;\r\n  /** 1-based start line in the new file. */\r\n  newStart: number;\r\n  lines: DiffLine[];\r\n}\r\n\r\nexport interface ComputeDiffOptions {\r\n  /** Unchanged lines kept either side of a change. */\r\n  context?: number;\r\n}\r\n\r\n/**\r\n * Diffs two file contents into hunks with per-line numbering and intra-line\r\n * word spans.\r\n *\r\n * A delete immediately followed by an insert is treated as one *modified*\r\n * line and given word-level spans — which is what makes a renamed variable\r\n * light up as one word instead of the entire line reading as replaced.\r\n */\r\nexport function computeDiff(before: string, after: string, options: ComputeDiffOptions = {}): DiffHunk[] {\r\n  const { context = 3 } = options;\r\n\r\n  const beforeLines = before.length === 0 ? [] : before.replace(/\\n$/, \"\").split(\"\\n\");\r\n  const afterLines = after.length === 0 ? [] : after.replace(/\\n$/, \"\").split(\"\\n\");\r\n\r\n  const edits = myersDiff(beforeLines, afterLines, (x, y) => x === y);\r\n\r\n  // Pair adjacent delete/insert runs so each becomes a word-diffed line.\r\n  const lines: DiffLine[] = [];\r\n  for (let i = 0; i < edits.length; i++) {\r\n    const edit = edits[i] as Edit<string>;\r\n\r\n    if (edit.kind === \"equal\") {\r\n      lines.push({\r\n        kind: \"equal\",\r\n        text: edit.value,\r\n        oldNumber: (edit.oldIndex ?? 0) + 1,\r\n        newNumber: (edit.newIndex ?? 0) + 1,\r\n      });\r\n      continue;\r\n    }\r\n\r\n    if (edit.kind === \"delete\") {\r\n      const deletes: Array<Edit<string>> = [];\r\n      let j = i;\r\n      while (j < edits.length && (edits[j] as Edit<string>).kind === \"delete\") {\r\n        deletes.push(edits[j] as Edit<string>);\r\n        j++;\r\n      }\r\n      const inserts: Array<Edit<string>> = [];\r\n      while (j < edits.length && (edits[j] as Edit<string>).kind === \"insert\") {\r\n        inserts.push(edits[j] as Edit<string>);\r\n        j++;\r\n      }\r\n\r\n      const pairs = Math.min(deletes.length, inserts.length);\r\n      for (let p = 0; p < deletes.length; p++) {\r\n        const del = deletes[p] as Edit<string>;\r\n        const ins = p < pairs ? (inserts[p] as Edit<string>) : undefined;\r\n        lines.push({\r\n          kind: \"delete\",\r\n          text: del.value,\r\n          oldNumber: (del.oldIndex ?? 0) + 1,\r\n          wordSpans: ins ? diffWords(del.value, ins.value).before : undefined,\r\n        });\r\n      }\r\n      for (let p = 0; p < inserts.length; p++) {\r\n        const ins = inserts[p] as Edit<string>;\r\n        const del = p < pairs ? (deletes[p] as Edit<string>) : undefined;\r\n        lines.push({\r\n          kind: \"insert\",\r\n          text: ins.value,\r\n          newNumber: (ins.newIndex ?? 0) + 1,\r\n          wordSpans: del ? diffWords(del.value, ins.value).after : undefined,\r\n        });\r\n      }\r\n\r\n      i = j - 1;\r\n      continue;\r\n    }\r\n\r\n    // A lone insert with no preceding delete.\r\n    lines.push({ kind: \"insert\", text: edit.value, newNumber: (edit.newIndex ?? 0) + 1 });\r\n  }\r\n\r\n  return groupIntoHunks(lines, context);\r\n}\r\n\r\n/** Keeps `context` unchanged lines around each change and drops the rest. */\r\nfunction groupIntoHunks(lines: DiffLine[], context: number): DiffHunk[] {\r\n  const changedAt = lines.map((line) => line.kind !== \"equal\");\r\n  if (!changedAt.some(Boolean)) return [];\r\n\r\n  const keep = new Array<boolean>(lines.length).fill(false);\r\n  for (let i = 0; i < lines.length; i++) {\r\n    if (!changedAt[i]) continue;\r\n    for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) {\r\n      keep[j] = true;\r\n    }\r\n  }\r\n\r\n  const hunks: DiffHunk[] = [];\r\n  let current: DiffLine[] = [];\r\n\r\n  const flush = () => {\r\n    if (current.length === 0) return;\r\n    const first = current[0] as DiffLine;\r\n    hunks.push({\r\n      oldStart: first.oldNumber ?? 1,\r\n      newStart: first.newNumber ?? 1,\r\n      lines: current,\r\n    });\r\n    current = [];\r\n  };\r\n\r\n  for (let i = 0; i < lines.length; i++) {\r\n    if (keep[i]) current.push(lines[i] as DiffLine);\r\n    else flush();\r\n  }\r\n  flush();\r\n\r\n  return hunks;\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Styles\r\n * ------------------------------------------------------------------ */\r\n\r\nconst rootClass = css({\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontSize: theme.fontSize.xs,\r\n  lineHeight: 1.65,\r\n  color: theme.color.foreground,\r\n  border: `1px solid ${theme.color.border}`,\r\n  borderRadius: theme.radius.md,\r\n  overflow: \"hidden\",\r\n  backgroundColor: theme.color.card,\r\n});\r\n\r\nconst headerClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  justifyContent: \"space-between\",\r\n  gap: theme.space[2],\r\n  padding: `${theme.space[2]} ${theme.space[3]}`,\r\n  borderBottom: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.muted,\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.xs,\r\n  color: theme.color.mutedForeground,\r\n});\r\n\r\nconst statClass = css({ display: \"inline-flex\", gap: theme.space[2], fontVariantNumeric: \"tabular-nums\" });\r\nconst addStatClass = css({ color: theme.color.success });\r\nconst delStatClass = css({ color: theme.color.destructive });\r\n\r\nconst hunkHeaderClass = css({\r\n  padding: `2px ${theme.space[3]}`,\r\n  backgroundColor: theme.color.muted,\r\n  color: theme.color.mutedForeground,\r\n  borderTop: `1px solid ${theme.color.border}`,\r\n  borderBottom: `1px solid ${theme.color.border}`,\r\n  fontSize: \"0.6875rem\",\r\n});\r\n\r\nconst scrollClass = css({ overflowX: \"auto\" });\r\n\r\nconst lineClass = css({ display: \"flex\", whiteSpace: \"pre\", minWidth: \"max-content\" });\r\n\r\nconst gutterClass = css({\r\n  flexShrink: 0,\r\n  userSelect: \"none\",\r\n  textAlign: \"right\",\r\n  padding: `0 ${theme.space[2]}`,\r\n  color: theme.color.mutedForeground,\r\n  fontVariantNumeric: \"tabular-nums\",\r\n  opacity: 0.7,\r\n});\r\n\r\nconst signClass = css({\r\n  flexShrink: 0,\r\n  width: \"1.25ch\",\r\n  userSelect: \"none\",\r\n  textAlign: \"center\",\r\n  opacity: 0.8,\r\n});\r\n\r\nconst contentClass = css({ paddingRight: theme.space[3], flex: 1 });\r\n\r\n// Backgrounds are deliberately low-contrast: the *word* highlight is what\r\n// should draw the eye on a modified line, and a saturated full-line wash\r\n// would swamp it.\r\nconst insertLineClass = css({ backgroundColor: \"color-mix(in srgb, currentColor 0%, rgb(34 160 90 / 0.10))\" });\r\nconst deleteLineClass = css({ backgroundColor: \"color-mix(in srgb, currentColor 0%, rgb(220 60 55 / 0.10))\" });\r\n\r\nconst insertWordClass = css({\r\n  backgroundColor: \"rgb(34 160 90 / 0.28)\",\r\n  borderRadius: \"2px\",\r\n});\r\n\r\nconst deleteWordClass = css({\r\n  backgroundColor: \"rgb(220 60 55 / 0.28)\",\r\n  borderRadius: \"2px\",\r\n});\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Rendering\r\n * ------------------------------------------------------------------ */\r\n\r\n/**\r\n * Highlights one line, then splits the highlighted tokens at word-span\r\n * boundaries.\r\n *\r\n * Syntax tokens and word-diff spans are two independent segmentations of the\r\n * same string, so neither can simply nest inside the other — this walks both\r\n * in one pass and emits the intersection, which is what lets a changed word\r\n * keep its syntax colour instead of flattening to plain text.\r\n */\r\nfunction renderLine(text: string, language: SyntaxLanguage, spans: WordSpan[] | undefined, changedClass: string): ReactNode {\r\n  const { tokens } = tokenizeLine(text, language, \"none\" as ScanState);\r\n\r\n  if (!spans || spans.length === 0) return <TokenLine tokens={tokens} />;\r\n\r\n  // Map each character index to whether it sits inside a changed span.\r\n  const changedAt = new Array<boolean>(text.length).fill(false);\r\n  let at = 0;\r\n  for (const span of spans) {\r\n    for (let i = 0; i < span.text.length && at < text.length; i++, at++) changedAt[at] = span.changed;\r\n  }\r\n\r\n  const out: ReactNode[] = [];\r\n  let cursor = 0;\r\n  let key = 0;\r\n\r\n  for (const token of tokens) {\r\n    let start = 0;\r\n    while (start < token.value.length) {\r\n      const isChanged = changedAt[cursor + start] ?? false;\r\n      let end = start + 1;\r\n      while (end < token.value.length && (changedAt[cursor + end] ?? false) === isChanged) end++;\r\n\r\n      const piece: Token = { type: token.type, value: token.value.slice(start, end) };\r\n      out.push(\r\n        isChanged ? (\r\n          <span key={key++} className={changedClass}>\r\n            <TokenLine tokens={[piece]} />\r\n          </span>\r\n        ) : (\r\n          <TokenLine key={key++} tokens={[piece]} />\r\n        ),\r\n      );\r\n      start = end;\r\n    }\r\n    cursor += token.value.length;\r\n  }\r\n\r\n  return <>{out}</>;\r\n}\r\n\r\nexport interface DiffViewProps {\r\n  before: string;\r\n  after: string;\r\n  /** Drives both the header label and the syntax grammar. */\r\n  filename?: string;\r\n  language?: SyntaxLanguage;\r\n  /** Unchanged lines kept either side of a change. */\r\n  context?: number;\r\n  showLineNumbers?: boolean;\r\n  /** Replaces the built-in header (or hides it when null). */\r\n  header?: ReactNode;\r\n  className?: string;\r\n}\r\n\r\n/**\r\n * A unified diff with syntax highlighting and word-level intra-line\r\n * highlighting: on a modified line only the words that actually changed are\r\n * tinted, not the whole line.\r\n */\r\nexport function DiffView({\r\n  before,\r\n  after,\r\n  filename,\r\n  language,\r\n  context = 3,\r\n  showLineNumbers = true,\r\n  header,\r\n  className,\r\n}: DiffViewProps) {\r\n  const resolved = language ?? (filename ? languageFromFilename(filename) : \"ts\");\r\n  const hunks = useMemo(() => computeDiff(before, after, { context }), [before, after, context]);\r\n\r\n  const { additions, deletions } = useMemo(() => {\r\n    let a = 0;\r\n    let d = 0;\r\n    for (const hunk of hunks) {\r\n      for (const line of hunk.lines) {\r\n        if (line.kind === \"insert\") a++;\r\n        else if (line.kind === \"delete\") d++;\r\n      }\r\n    }\r\n    return { additions: a, deletions: d };\r\n  }, [hunks]);\r\n\r\n  const gutterWidth = `${String(\r\n    Math.max(\r\n      ...hunks.flatMap((h) => h.lines.map((l) => Math.max(l.oldNumber ?? 0, l.newNumber ?? 0))),\r\n      1,\r\n    ),\r\n  ).length}ch`;\r\n\r\n  return (\r\n    <div className={className ? `${rootClass} ${className}` : rootClass}>\r\n      {header === null ? null : header !== undefined ? (\r\n        header\r\n      ) : (\r\n        <div className={headerClass}>\r\n          <span>{filename ?? \"Diff\"}</span>\r\n          <span className={statClass}>\r\n            <span className={addStatClass}>+{additions}</span>\r\n            <span className={delStatClass}>−{deletions}</span>\r\n          </span>\r\n        </div>\r\n      )}\r\n\r\n      {hunks.length === 0 ? (\r\n        <div style={{ padding: theme.space[4], color: theme.color.mutedForeground, fontFamily: theme.fontFamily.sans }}>\r\n          No changes\r\n        </div>\r\n      ) : (\r\n        hunks.map((hunk, hunkIndex) => (\r\n          <div key={hunkIndex}>\r\n            <div className={hunkHeaderClass}>\r\n              @@ -{hunk.oldStart} +{hunk.newStart} @@\r\n            </div>\r\n            <div className={scrollClass}>\r\n              {hunk.lines.map((line, lineIndex) => {\r\n                const lineStyle =\r\n                  line.kind === \"insert\" ? insertLineClass : line.kind === \"delete\" ? deleteLineClass : \"\";\r\n                const wordStyle = line.kind === \"insert\" ? insertWordClass : deleteWordClass;\r\n\r\n                return (\r\n                  <div key={lineIndex} className={lineStyle ? `${lineClass} ${lineStyle}` : lineClass}>\r\n                    {showLineNumbers ? (\r\n                      <>\r\n                        <span className={gutterClass} style={{ width: gutterWidth }}>\r\n                          {line.oldNumber ?? \"\"}\r\n                        </span>\r\n                        <span className={gutterClass} style={{ width: gutterWidth }}>\r\n                          {line.newNumber ?? \"\"}\r\n                        </span>\r\n                      </>\r\n                    ) : null}\r\n                    <span className={signClass} aria-hidden=\"true\">\r\n                      {line.kind === \"insert\" ? \"+\" : line.kind === \"delete\" ? \"−\" : \" \"}\r\n                    </span>\r\n                    <span className={contentClass}>\r\n                      {renderLine(line.text, resolved, line.wordSpans, wordStyle)}\r\n                    </span>\r\n                  </div>\r\n                );\r\n              })}\r\n            </div>\r\n          </div>\r\n        ))\r\n      )}\r\n    </div>\r\n  );\r\n}\r\n",
      "type": "registry:component"
    }
  ]
}