{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "diff-viewer",
  "type": "registry:ui",
  "title": "Diff Viewer",
  "description": "Unified or split diffs with word-level marks and folds that open in steps.",
  "categories": [
    "developer"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/code-block.json"
  ],
  "files": [
    {
      "path": "components/ui/diff-viewer.tsx",
      "content": "\"use client\";\nimport { ToggleGroup } from \"@base-ui/react/toggle-group\";\nimport { Toggle } from \"@base-ui/react/toggle\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Fragment, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { codeTheme, indentOf, resolveLanguage, tokenize, type CodeToken } from \"@/components/ui/code-block\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowRight, ChevronsUpDown, File } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Diffing: Myers' O((N+M)D) shortest edit script, for lines and for the words inside a changed line.\n * -----------------------------------------------------------------------------------------------*/\n\ntype Edit = { op: \"=\" | \"-\" | \"+\"; a: number; b: number };\n\nexport function diff<T>(a: readonly T[], b: readonly T[]): Edit[] {\n  const n = a.length;\n  const m = b.length;\n  const max = n + m;\n  const off = max + 1;\n  const v = new Int32Array(2 * max + 3);\n  const trace: Int32Array[] = [];\n  outer: for (let d = 0; d <= max; d++) {\n    trace.push(v.slice());\n    for (let k = -d; k <= d; k += 2) {\n      let x = k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]) ? v[off + k + 1] : v[off + k - 1] + 1;\n      let y = x - k;\n      while (x < n && y < m && a[x] === b[y]) {\n        x++;\n        y++;\n      }\n      v[off + k] = x;\n      if (x >= n && y >= m) break outer;\n    }\n  }\n  const out: Edit[] = [];\n  let x = n;\n  let y = m;\n  for (let d = trace.length - 1; d >= 0; d--) {\n    const t = trace[d];\n    const k = x - y;\n    const pk = k === -d || (k !== d && t[off + k - 1] < t[off + k + 1]) ? k + 1 : k - 1;\n    const px = t[off + pk];\n    const py = px - pk;\n    while (x > px && y > py) out.push({ op: \"=\", a: --x, b: --y });\n    if (d > 0) out.push(x === px ? { op: \"+\", a: x, b: --y } : { op: \"-\", a: --x, b: y });\n    x = px;\n    y = py;\n  }\n  return out.reverse();\n}\n\ntype Range = [number, number];\n\n/** Character ranges that changed between two versions of a line, or null when so much changed that marking it all is noise. */\nfunction wordMarks(before: string, after: string): [Range[], Range[], number] | null {\n  const split = (s: string) => s.match(/\\w+|\\s+|[^\\w\\s]/g) ?? [];\n  const a = split(before);\n  const b = split(after);\n  const edits = diff(a, b);\n  const ra: Range[] = [];\n  const rb: Range[] = [];\n  let ia = 0;\n  let ib = 0;\n  let same = 0;\n  const push = (list: Range[], at: number, len: number) => {\n    const last = list[list.length - 1];\n    if (last && last[1] === at) last[1] += len;\n    else list.push([at, at + len]);\n  };\n  for (const e of edits) {\n    if (e.op === \"=\") {\n      same += a[e.a].length;\n      ia += a[e.a].length;\n      ib += b[e.b].length;\n    } else if (e.op === \"-\") {\n      push(ra, ia, a[e.a].length);\n      ia += a[e.a].length;\n    } else {\n      push(rb, ib, b[e.b].length);\n      ib += b[e.b].length;\n    }\n  }\n  const longest = Math.max(before.trim().length, after.trim().length, 1);\n  const score = same / longest;\n  return score < 0.6 ? null : [ra, rb, score];\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Model: the file as runs of unchanged lines and blocks of changes\n * -----------------------------------------------------------------------------------------------*/\n\ntype Line = { no: number; tokens: CodeToken[]; marks?: Range[] };\ntype Change = { kind: \"change\"; dels: Line[]; adds: Line[]; rows: { del?: Line; add?: Line }[] };\ntype Same = { kind: \"same\"; id: number; rows: { a: Line; b: Line }[]; keepTop: number; keepBottom: number; gap: number; first: boolean; last: boolean };\ntype Segment = Change | Same;\n\nconst text = (t: CodeToken[]) => t.map((x) => x.value).join(\"\");\n\nfunction model(before: string, after: string, lang: string | undefined, filename: string | undefined, context: number) {\n  const language = resolveLanguage(lang, filename);\n  const A = tokenize(before, language);\n  const B = tokenize(after, language);\n  const edits = diff(A.map(text), B.map(text));\n  const segments: Segment[] = [];\n  let adds = 0;\n  let dels = 0;\n  for (const e of edits) {\n    const last = segments[segments.length - 1];\n    if (e.op === \"=\") {\n      const row = { a: { no: e.a + 1, tokens: A[e.a] }, b: { no: e.b + 1, tokens: B[e.b] } };\n      if (last?.kind === \"same\") last.rows.push(row);\n      else segments.push({ kind: \"same\", id: segments.length, rows: [row], keepTop: 0, keepBottom: 0, gap: 0, first: false, last: false });\n    } else {\n      const block = last?.kind === \"change\" ? last : (segments[segments.push({ kind: \"change\", dels: [], adds: [], rows: [] }) - 1] as Change);\n      if (e.op === \"-\") {\n        block.dels.push({ no: e.a + 1, tokens: A[e.a] });\n        dels++;\n      } else {\n        block.adds.push({ no: e.b + 1, tokens: B[e.b] });\n        adds++;\n      }\n    }\n  }\n  segments.forEach((s, i) => {\n    if (s.kind === \"change\") {\n      // Pair each removed line with the added line that most resembles it (in order), and mark only the words that changed.\n      const pairs: [number, number][] = [];\n      let from = 0;\n      s.dels.forEach((d, i) => {\n        let best: { j: number; m: [Range[], Range[]]; score: number } | null = null;\n        for (let j = from; j < s.adds.length; j++) {\n          const m = wordMarks(text(d.tokens), text(s.adds[j].tokens));\n          if (m && (!best || m[2] > best.score)) best = { j, m: [m[0], m[1]], score: m[2] };\n        }\n        if (!best) return;\n        [d.marks, s.adds[best.j].marks] = best.m;\n        pairs.push([i, best.j]);\n        from = best.j + 1;\n      });\n      // Split rows: unpaired lines sit side by side, pairs share a row so the eye can compare them.\n      let di = 0;\n      let aj = 0;\n      const fill = (toD: number, toA: number) => {\n        while (di < toD || aj < toA) s.rows.push({ del: di < toD ? s.dels[di++] : undefined, add: aj < toA ? s.adds[aj++] : undefined });\n      };\n      for (const [pi, pj] of pairs) {\n        fill(pi, pj);\n        s.rows.push({ del: s.dels[di++], add: s.adds[aj++] });\n      }\n      fill(s.dels.length, s.adds.length);\n      return;\n    }\n    s.first = i === 0;\n    s.last = i === segments.length - 1;\n    s.keepTop = s.first ? 0 : context;\n    s.keepBottom = s.last ? 0 : context;\n    const hidden = s.rows.length - s.keepTop - s.keepBottom;\n    // Folding away three lines costs more than it saves.\n    if (hidden >= 4) s.gap = hidden;\n    else s.keepTop = s.rows.length;\n  });\n  return { segments, adds, dels, digits: String(Math.max(A.length, B.length)).length };\n}\n\n/* -------------------------------------------------------------------------------------------------\n * DiffViewer\n * -----------------------------------------------------------------------------------------------*/\n\nconst STEP = 20;\n// NumberFlow animates through the Web Animations API, which needs the curve as a string.\nconst easeOut = `cubic-bezier(${ease.out.join(\", \")})`;\n\nexport type DiffView = \"unified\" | \"split\";\n\nexport type DiffViewerProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  before: string;\n  after: string;\n  /** Language name, alias or extension. Falls back to the filename's extension. */\n  lang?: string;\n  filename?: string;\n  /** Set when the file was renamed; the header shows old → new. */\n  previousFilename?: string;\n  view?: DiffView;\n  defaultView?: DiffView;\n  onViewChange?: (view: DiffView) => void;\n  /** Unchanged lines kept around each change before the rest folds away. */\n  context?: number;\n};\n\nexport function DiffViewer({\n  before,\n  after,\n  lang,\n  filename,\n  previousFilename,\n  view: viewProp,\n  defaultView = \"unified\",\n  onViewChange,\n  context = 3,\n  className,\n  style,\n  ...rest\n}: DiffViewerProps) {\n  const reduce = useReducedMotion();\n  const pillId = useId();\n  const root = useRef<HTMLDivElement>(null);\n  const [view, setView] = useControllableState<DiffView>({ value: viewProp, defaultValue: defaultView, onChange: onViewChange });\n  const [narrow, setNarrow] = useState(false);\n  const [open, setOpen] = useState<Record<number, { top: number[]; bottom: number[] }>>({});\n  const { segments, adds, dels, digits } = useMemo(() => model(before, after, lang, filename, context), [before, after, lang, filename, context]);\n\n  // Split needs room for two columns of code; below that it reads as unified, whatever was chosen.\n  useEffect(() => {\n    const el = root.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setNarrow(entry.contentRect.width < 560));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  const shown: DiffView = narrow ? \"unified\" : view;\n\n  const reveal = (id: number, side: \"top\" | \"bottom\", count: number) =>\n    setOpen((o) => {\n      const cur = o[id] ?? { top: [], bottom: [] };\n      return { ...o, [id]: { ...cur, [side]: [...cur[side], count] } };\n    });\n\n  const unchanged = adds === 0 && dels === 0;\n  const total = adds + dels;\n  const blocks = Array.from({ length: 5 }, (_, i) => (total === 0 ? \"none\" : i < Math.round((adds / total) * 5) ? \"add\" : \"del\"));\n\n  return (\n    <div\n      ref={root}\n      data-diff=\"\"\n      data-view={shown}\n      tabIndex={-1}\n      style={{ ...codeTheme, \"--digits\": digits, ...style } as React.CSSProperties}\n      className={cn(\"@container flex min-w-0 flex-col overflow-hidden rounded-xl border border-line bg-raised shadow-[var(--shadow)] outline-none\", className)}\n      {...rest}\n    >\n      <div className=\"flex h-11 shrink-0 items-center gap-3 border-b border-line pl-3.5 pr-1.5\">\n        <File size={14} className=\"shrink-0 text-fg-3\" />\n        <span className=\"flex min-w-0 flex-1 items-center gap-1.5 font-mono text-[12px]\">\n          {previousFilename && previousFilename !== filename && (\n            <>\n              <span className=\"min-w-0 truncate text-fg-3 line-through decoration-fg-4\">{previousFilename}</span>\n              <ArrowRight size={12} className=\"shrink-0 text-fg-4\" />\n            </>\n          )}\n          <span className=\"min-w-0 truncate text-fg\">{filename}</span>\n        </span>\n        <span className=\"flex shrink-0 items-center gap-2 font-mono text-[11.5px] tabular\" aria-label={`${adds} added, ${dels} removed`}>\n          <span aria-hidden className=\"text-success\">+{adds}</span>\n          <span aria-hidden className=\"text-danger\">−{dels}</span>\n          <span aria-hidden className=\"hidden gap-0.5 @[420px]:flex\">\n            {blocks.map((b, i) => (\n              <span key={i} className={cn(\"size-2 rounded-[2px]\", b === \"add\" ? \"bg-success\" : b === \"del\" ? \"bg-danger\" : \"bg-fg/10\")} />\n            ))}\n          </span>\n        </span>\n        <ToggleGroup\n          value={[shown]}\n          onValueChange={(v) => v[0] && setView(v[0] as DiffView)}\n          aria-label=\"Diff layout\"\n          className=\"relative ml-1 hidden shrink-0 items-center rounded-lg bg-fg/[0.05] p-0.5 @[420px]:flex\"\n        >\n          {([\"unified\", \"split\"] as const).map((v) => (\n            <Toggle\n              key={v}\n              value={v}\n              disabled={v === \"split\" && narrow}\n              title={v === \"split\" && narrow ? \"Needs a wider space\" : undefined}\n              className={cn(\n                \"relative h-6 select-none rounded-md px-2 text-[12px] font-medium text-fg-3 outline-none\",\n                \"transition-[color,scale] duration-150 ease-out hover:text-fg-2 active:scale-[0.96] active:duration-75 data-pressed:text-fg\",\n                \"data-disabled:pointer-events-none data-disabled:text-fg-4\",\n                \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n              )}\n            >\n              {shown === v && (\n                <motion.span\n                  layoutId={pillId}\n                  aria-hidden\n                  className=\"absolute inset-0 rounded-md bg-raised shadow-[0_1px_2px_rgb(0_0_0/0.12)] ring-1 ring-fg/[0.07]\"\n                  transition={reduce ? { duration: 0 } : spring.snappy}\n                />\n              )}\n              <span className=\"relative\">{v === \"unified\" ? \"Unified\" : \"Split\"}</span>\n            </Toggle>\n          ))}\n        </ToggleGroup>\n      </div>\n\n      {unchanged ? (\n        <p className=\"px-4 py-6 text-center text-[12.5px] text-fg-3\">No changes between these versions</p>\n      ) : (\n        <AnimatePresence initial={false} mode=\"wait\">\n          <motion.div\n            key={shown}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.08 } }}\n            transition={{ duration: reduce ? 0.1 : 0.16, ease: ease.out }}\n            className=\"font-mono text-[12.5px] leading-5\"\n          >\n            {segments.map((s, i) =>\n              s.kind === \"change\" ? (\n                <Fragment key={i}>{shown === \"unified\" ? <UnifiedChange block={s} /> : <SplitChange block={s} />}</Fragment>\n              ) : (\n                <Unchanged key={i} seg={s} view={shown} open={open[s.id] ?? { top: [], bottom: [] }} reveal={reveal} reduce={!!reduce} />\n              ),\n            )}\n          </motion.div>\n        </AnimatePresence>\n      )}\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Rows\n * -----------------------------------------------------------------------------------------------*/\n\ntype Tone = \"same\" | \"add\" | \"del\";\n\nconst ROW: Record<Tone, string> = { same: \"\", add: \"bg-success-soft\", del: \"bg-danger-soft\" };\nconst NUM: Record<Tone, string> = { same: \"text-fg-4\", add: \"text-success/60\", del: \"text-danger/60\" };\nconst SIGN: Record<Tone, string> = { same: \"text-fg-4\", add: \"text-success\", del: \"text-danger\" };\n// Square washes: a changed run spans several tokens, and rounded pieces would read as separate boxes.\nconst MARK: Record<Tone, string> = { same: \"\", add: \"bg-success/20\", del: \"bg-danger/20\" };\n\n// On a phone the gutters tighten so the code keeps as much of the width as it can.\nconst numCell = \"w-[calc(var(--digits)*1ch+1.25rem)] shrink-0 select-none pr-2.5 text-right tabular @max-[420px]:w-[calc(var(--digits)*1ch+0.75rem)] @max-[420px]:pr-1.5\";\nconst signCell = \"w-6 shrink-0 select-none text-center @max-[420px]:w-5\";\n\n/** Tokens with the changed words washed, splitting a token wherever a mark starts or ends. */\nfunction Code({ line, tone }: { line?: Line; tone: Tone }) {\n  if (!line) return null;\n  const indent = indentOf(line.tokens) + 2;\n  const marks = line.marks ?? [];\n  const out: React.ReactNode[] = [];\n  let at = 0;\n  line.tokens.forEach((t, ti) => {\n    const end = at + t.value.length;\n    const cuts = new Set([at, end]);\n    for (const [s, e] of marks) {\n      if (s > at && s < end) cuts.add(s);\n      if (e > at && e < end) cuts.add(e);\n    }\n    const points = [...cuts].sort((x, y) => x - y);\n    for (let p = 0; p < points.length - 1; p++) {\n      const [s, e] = [points[p], points[p + 1]];\n      const marked = marks.some(([ms, me]) => s >= ms && e <= me);\n      out.push(\n        <span\n          key={`${ti}-${p}`}\n          style={t.type === \"space\" ? undefined : { color: `var(--sh-${t.type})` }}\n          className={cn(t.type === \"comment\" && \"italic\", marked && MARK[tone])}\n        >\n          {t.value.slice(s - at, e - at)}\n        </span>,\n      );\n    }\n    at = end;\n  });\n  return (\n    <span\n      className=\"min-w-0 flex-1 whitespace-pre-wrap pr-4 [overflow-wrap:anywhere]\"\n      // Wrapped lines hang past their own indent, so a long line still reads as one statement.\n      style={{ paddingInlineStart: `${indent}ch`, textIndent: `-${indent}ch` }}\n    >\n      {out.length ? out : <br />}\n    </span>\n  );\n}\n\nfunction Unified({ line, tone, a, b }: { line: Line; tone: Tone; a?: number; b?: number }) {\n  return (\n    <div data-tone={tone} className={cn(\"flex min-h-5\", ROW[tone])}>\n      <span aria-hidden className={cn(numCell, NUM[tone])}>{a ?? \"\"}</span>\n      <span aria-hidden className={cn(numCell, NUM[tone])}>{b ?? \"\"}</span>\n      <span className={cn(signCell, SIGN[tone])}>\n        <span aria-hidden>{tone === \"add\" ? \"+\" : tone === \"del\" ? \"−\" : \"\"}</span>\n        {tone !== \"same\" && <span className=\"sr-only\">{tone === \"add\" ? \"Added line\" : \"Removed line\"}</span>}\n      </span>\n      <Code line={line} tone={tone} />\n    </div>\n  );\n}\n\nfunction UnifiedChange({ block }: { block: Change }) {\n  return (\n    <>\n      {block.dels.map((l) => (\n        <Unified key={`d${l.no}`} line={l} tone=\"del\" a={l.no} />\n      ))}\n      {block.adds.map((l) => (\n        <Unified key={`a${l.no}`} line={l} tone=\"add\" b={l.no} />\n      ))}\n    </>\n  );\n}\n\nfunction Half({ line, tone }: { line?: Line; tone: Tone }) {\n  return (\n    <div\n      className={cn(\n        \"flex min-h-5 min-w-0\",\n        line ? ROW[tone] : \"bg-[repeating-linear-gradient(135deg,transparent_0_5px,color-mix(in_oklab,var(--fg)_5%,transparent)_5px_6px)]\",\n      )}\n    >\n      <span aria-hidden className={cn(numCell, NUM[tone])}>{line?.no ?? \"\"}</span>\n      <span className={cn(signCell, SIGN[tone])}>\n        <span aria-hidden>{line && tone === \"add\" ? \"+\" : line && tone === \"del\" ? \"−\" : \"\"}</span>\n        {line && tone !== \"same\" && <span className=\"sr-only\">{tone === \"add\" ? \"Added line\" : \"Removed line\"}</span>}\n      </span>\n      <Code line={line} tone={tone} />\n    </div>\n  );\n}\n\nconst splitRow = \"grid grid-cols-2 [&>*:first-child]:border-r [&>*:first-child]:border-line\";\n\nfunction SplitChange({ block }: { block: Change }) {\n  return block.rows.map((r, i) => (\n    <div key={i} className={splitRow}>\n      <Half line={r.del} tone=\"del\" />\n      <Half line={r.add} tone=\"add\" />\n    </div>\n  ));\n}\n\nfunction SameRows({ rows, view }: { rows: Same[\"rows\"]; view: DiffView }) {\n  return rows.map((r) =>\n    view === \"unified\" ? (\n      <Unified key={r.b.no} line={r.b} tone=\"same\" a={r.a.no} b={r.b.no} />\n    ) : (\n      <div key={r.b.no} className={splitRow}>\n        <Half line={r.a} tone=\"same\" />\n        <Half line={r.b} tone=\"same\" />\n      </div>\n    ),\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Unchanged runs, folded, with controls to reveal them in steps from either end\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Unchanged({\n  seg,\n  view,\n  open,\n  reveal,\n  reduce,\n}: {\n  seg: Same;\n  view: DiffView;\n  open: { top: number[]; bottom: number[] };\n  reveal: (id: number, side: \"top\" | \"bottom\", count: number) => void;\n  reduce: boolean;\n}) {\n  const { rows, keepTop, keepBottom, gap } = seg;\n  const top = open.top.reduce((a, b) => a + b, 0);\n  const bottom = open.bottom.reduce((a, b) => a + b, 0);\n  const left = Math.max(0, gap - top - bottom);\n  const start = keepTop;\n  const end = rows.length - keepBottom;\n\n  const chunk = (key: string, from: number, to: number) => (\n    <motion.div\n      key={key}\n      initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n      animate={{ height: \"auto\", opacity: 1 }}\n      transition={reduce ? { duration: 0.12 } : { height: { duration: 0.28, ease: ease.inOut }, opacity: { duration: 0.2, delay: 0.06 } }}\n      className=\"overflow-hidden\"\n    >\n      <SameRows rows={rows.slice(from, to)} view={view} />\n    </motion.div>\n  );\n\n  // Chunks revealed downward from the change above, in the order they were opened.\n  const before = (list: number[], i: number) => list.slice(0, i).reduce((a, b) => a + b, 0);\n  const topChunks = open.top.map((n, i) => chunk(`t${i}`, start + before(open.top, i), start + before(open.top, i) + n));\n  // Chunks revealed upward from the change below: each new one lands above the last.\n  const bottomChunks = open.bottom.map((n, i) => chunk(`b${i}`, end - before(open.bottom, i) - n, end - before(open.bottom, i))).reverse();\n\n  const { first, last } = seg;\n  const hiddenFrom = start + top;\n\n  return (\n    <>\n      <SameRows rows={rows.slice(0, gap ? start : rows.length)} view={view} />\n      {gap > 0 && (\n        <>\n          {topChunks}\n          <AnimatePresence initial={false}>\n            {left > 0 && (\n              <motion.div\n                key=\"gap\"\n                exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0, transition: { duration: 0.2, ease: ease.inOut } }}\n                className=\"overflow-hidden\"\n              >\n                <Expander\n                  left={left}\n                  view={view}\n                  from={rows[hiddenFrom]?.b.no ?? 0}\n                  atTop={first && top === 0}\n                  atBottom={last && bottom === 0}\n                  // At the top of the file there's nothing above to extend down from, and at the end nothing below to extend up from.\n                  canDown={!first}\n                  canUp={!last}\n                  onDown={() => reveal(seg.id, \"top\", Math.min(STEP, left))}\n                  onUp={() => reveal(seg.id, \"bottom\", Math.min(STEP, left))}\n                  onAll={() => reveal(seg.id, \"top\", left)}\n                />\n              </motion.div>\n            )}\n          </AnimatePresence>\n          {bottomChunks}\n          <SameRows rows={rows.slice(end)} view={view} />\n        </>\n      )}\n    </>\n  );\n}\n\nfunction Expander({\n  left,\n  view,\n  from,\n  atTop,\n  atBottom,\n  canUp,\n  canDown,\n  onUp,\n  onDown,\n  onAll,\n}: {\n  left: number;\n  view: DiffView;\n  from: number;\n  atTop: boolean;\n  atBottom: boolean;\n  canUp: boolean;\n  canDown: boolean;\n  onUp: () => void;\n  onDown: () => void;\n  onAll: () => void;\n}) {\n  const reduce = useReducedMotion();\n  const partial = left > STEP;\n  // Showing everything removes this row. If it held keyboard focus, hand focus to the next fold, or to the viewer.\n  const all = (e: React.MouseEvent<HTMLButtonElement>) => {\n    const from = e.currentTarget;\n    const keyboard = from.matches(\":focus-visible\");\n    const viewer = from.closest<HTMLElement>(\"[data-diff]\");\n    const rows = viewer ? [...viewer.querySelectorAll<HTMLElement>(\"[data-expand-all]\")] : [];\n    const mine = from.closest(\"[data-expander]\")?.querySelector<HTMLElement>(\"[data-expand-all]\");\n    const next = rows[rows.indexOf(mine as HTMLElement) + 1] ?? viewer;\n    onAll();\n    if (keyboard) requestAnimationFrame(() => next?.focus({ preventScroll: true }));\n  };\n  const icon = cn(\n    \"relative grid size-6 place-items-center rounded-md text-fg-3 outline-none\",\n    \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75\",\n    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n    \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n  );\n  return (\n    <div data-expander=\"\" className={cn(\"flex h-8 items-center border-y border-line bg-fg/[0.025] font-sans\", atTop && \"border-t-0\", atBottom && \"border-b-0\")}>\n      {/* The controls sit in the gutter and the label starts where the code does. */}\n      <div\n        className={cn(\n          \"flex shrink-0 items-center justify-end gap-0.5 pr-1.5 font-mono\",\n          view === \"unified\" ? \"w-[calc(var(--digits)*2ch+2.5rem)] @max-[420px]:w-[calc(var(--digits)*2ch+1.5rem)]\" : \"w-[calc(var(--digits)*1ch+1.25rem)]\",\n        )}\n      >\n        {partial && canDown && (\n          <button type=\"button\" onClick={onDown} aria-label={`Show ${STEP} lines below the change above`} className={icon}>\n            <FoldIcon dir=\"down\" />\n          </button>\n        )}\n        {partial && canUp && (\n          <button type=\"button\" onClick={onUp} aria-label={`Show ${STEP} lines above the change below`} className={icon}>\n            <FoldIcon dir=\"up\" />\n          </button>\n        )}\n        {!partial && (\n          // The label beside it does the same thing, so this one stays out of the tab order.\n          <button type=\"button\" tabIndex={-1} onClick={all} aria-label={`Show ${left} unchanged lines`} className={icon}>\n            <ChevronsUpDown size={14} />\n          </button>\n        )}\n      </div>\n      <button\n        type=\"button\"\n        data-expand-all=\"\"\n        onClick={all}\n        className={cn(\n          \"group/all flex h-full min-w-0 flex-1 items-center gap-2 pl-6 pr-3 @max-[420px]:pl-5 text-left text-[12px] text-fg-3 outline-none\",\n          \"rounded-md transition-colors duration-150 hover:text-fg-2\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-4 focus-visible:outline-fg-3\",\n        )}\n      >\n        <span className=\"min-w-0 flex-1 truncate\">\n          <NumberFlow\n            value={left}\n            className=\"tabular\"\n            transformTiming={{ duration: reduce ? 0 : 420, easing: easeOut }}\n            spinTiming={{ duration: reduce ? 0 : 420, easing: easeOut }}\n            opacityTiming={{ duration: reduce ? 120 : 240, easing: \"ease-out\" }}\n          />{\" \"}\n          unchanged {left === 1 ? \"line\" : \"lines\"}\n        </span>\n        <span className=\"shrink-0 font-mono text-2xs tabular text-fg-4 transition-colors duration-150 group-hover/all:text-fg-3\">\n          {left === 1 ? `Line ${from}` : `Lines ${from}–${from + left - 1}`}\n        </span>\n      </button>\n    </div>\n  );\n}\n\nfunction FoldIcon({ dir }: { dir: \"up\" | \"down\" }) {\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      {dir === \"up\" ? <path d=\"M4.5 7.5 8 4l3.5 3.5M3 12h10\" /> : <path d=\"M3 4h10M4.5 8.5 8 12l3.5-3.5\" />}\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/diff-viewer"
}
