Diff Viewer

A diff you can style, and accept or reject hunk by hunk.

src/lib/workspace.ts+6 3 — 6 lines added, 3 removed
Removed: export async function loadWorkspace(id: string) {
Removed: const response = await fetch(`/api/workspaces/${id}`);
Added: export async function loadWorkspace(id: string, signal?: AbortSignal) {
Added: const response = await fetch(`/api/workspaces/${id}`, { signal });
Added: if (!response.ok) {
Added: throw new WorkspaceError(response.status);
Added: }
Unchanged: const workspace = await response.json();
Unchanged: return workspace;
Unchanged: }
Unchanged:
Unchanged: export function formatSeats(count: number) {
Removed: return count + " seats";
Added: return count === 1 ? "1 seat" : `${count} seats`;
Unchanged: }

Installation

Terminal
pnpm dlx @dowel-ui/cli add diff-viewer

npm packages installed: diff.

Accessibility

A semantic table, not a grid. role="grid" would promise cell-by-cell arrow navigation that does not exist here and makes no sense for reading code. Every row states added, removed or unchanged in text, because a plus sign and a green tint are not information — a diff read aloud without it is just the same file twice. Line numbers are aria-hidden: they orient a sighted reader, and announcing two numbers before every line makes the diff unlistenable. Changed words are marked with mark elements so they survive as structure, and the empty half of a split pair is hidden rather than read as a blank line of code. The optional wipe entrance is visual only — every row is in the DOM and its kind text is never clipped from assistive technology. With collapseRejected, a rejected hunk's lines are inert and hidden, its header says so in words, and Accept stays available so the decision can be reversed.

Props

DiffViewer

PropTypeDefault
hunks (required)DiffHunk[]
label (required)

Names the diff — usually the path of the file being changed.

string
collapseRejected

Collapse a rejected hunk's lines. Its header and controls stay, so the decision can still be reversed, and the header says the lines are hidden.

booleanfalse
decisions

Per-hunk decisions, keyed by hunk id. Controlled.

Record<string, HunkDecision>
entrance

Entrance for added lines. wipe draws each one in along the reading direction, a line after the other; context and removed lines were already there and do not move.

"none" | "wipe""none"
onDecision(hunkId: string, decision: HunkDecision) => void
view"unified" | "split""unified"

Plus every attribute of <div> except children.

DiffViewerToolbar

PropTypeDefault
onViewChange (required)(view: "unified" | "split") => void
view (required)"unified" | "split"

Plus every attribute of <div> except onChange.

Quality

9/10 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedfails
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Source

This is exactly what dowel add diff-viewer writes into your project, with imports rewritten to your own path alias.

ui/diff-model.ts
import { diffLines, diffWordsWithSpace } from "diff";

/**
 * Turning two versions of a file into rows a diff can render.
 *
 * The algorithm is not the interesting part and is not reimplemented here —
 * jsdiff does Myers properly, is BSD-licensed, has no dependencies of its own,
 * and getting an O(ND) diff subtly wrong is a poor use of anybody's afternoon.
 * What every packaged *viewer* welds on is a styling strategy: emotion in
 * react-diff-viewer-continued, HTML strings and a stylesheet in diff2html. That
 * is what cannot be reached by design tokens, and it is why this exists.
 *
 * Everything below is pure, so the grouping, the context collapsing and the
 * word-level pairing can be tested without rendering anything.
 */

export type RowKind = "context" | "added" | "removed";

export interface WordSegment {
  text: string;
  changed: boolean;
}

export interface DiffRow {
  kind: RowKind;
  /** 1-based line number in the original. Absent on an added line. */
  before?: number;
  /** 1-based line number in the result. Absent on a removed line. */
  after?: number;
  content: string;
  /**
   * Word-level split, present only where a removed line pairs with an added
   * one. Without a pair there is nothing to compare against, and highlighting
   * the whole line as "changed" would be noise.
   */
  segments?: WordSegment[];
}

export interface DiffHunk {
  id: string;
  rows: DiffRow[];
  /** Context lines hidden before this hunk, if any. */
  skippedBefore: number;
}

export interface BuildDiffOptions {
  /** Unchanged lines kept either side of a change. */
  context?: number;
  /**
   * Compare word by word inside a changed line. Off for very large diffs,
   * where the extra pass costs more than it explains.
   */
  words?: boolean;
}

function splitLines(text: string): string[] {
  const lines = text.split("\n");
  // A trailing newline produces a final empty element that is not a line.
  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
  return lines;
}

/**
 * Gives both sides a trailing newline before they are compared.
 *
 * jsdiff's line tokens carry their own newline, so a final line written as `a`
 * and one written as `a\n` are different tokens. Appending a line to a file
 * that did not end in a newline would otherwise be reported as the last line
 * being removed and re-added — a change the author did not make, sitting on
 * top of the one they did.
 */
function withTrailingNewline(text: string): string {
  return text.endsWith("\n") ? text : `${text}\n`;
}

/**
 * Pairs removed lines with added ones inside a single change block.
 *
 * jsdiff reports a change as a run of removals followed by a run of additions.
 * Comparing the first removal against the first addition is what turns "this
 * line went away and another arrived" into "this word changed" — but only while
 * the runs line up. Beyond the shorter run there is no counterpart, and
 * inventing one produces confident nonsense.
 */
function pairWords(removed: DiffRow[], added: DiffRow[]): void {
  const pairs = Math.min(removed.length, added.length);

  for (let index = 0; index < pairs; index += 1) {
    const from = removed[index];
    const to = added[index];
    if (!from || !to) continue;

    const parts = diffWordsWithSpace(from.content, to.content);

    from.segments = parts
      .filter((part) => !part.added)
      .map((part) => ({ text: part.value, changed: Boolean(part.removed) }));

    to.segments = parts
      .filter((part) => !part.removed)
      .map((part) => ({ text: part.value, changed: Boolean(part.added) }));
  }
}

export function buildDiff(
  before: string,
  after: string,
  options: BuildDiffOptions = {},
): DiffHunk[] {
  const { context = 3, words = true } = options;

  const changes = diffLines(withTrailingNewline(before), withTrailingNewline(after));
  const rows: DiffRow[] = [];
  let beforeLine = 1;
  let afterLine = 1;

  // Flat rows first. Grouping into hunks is a separate concern and mixing the
  // two is how these implementations become unreadable.
  let pendingRemoved: DiffRow[] = [];
  let pendingAdded: DiffRow[] = [];

  const flushPair = () => {
    if (words && pendingRemoved.length > 0 && pendingAdded.length > 0) {
      pairWords(pendingRemoved, pendingAdded);
    }
    pendingRemoved = [];
    pendingAdded = [];
  };

  for (const change of changes) {
    const lines = splitLines(change.value);

    if (change.added) {
      for (const content of lines) {
        const row: DiffRow = { kind: "added", after: afterLine, content };
        afterLine += 1;
        rows.push(row);
        pendingAdded.push(row);
      }
      continue;
    }

    if (change.removed) {
      // A removal run that follows an addition run starts a new pairing.
      if (pendingAdded.length > 0) flushPair();
      for (const content of lines) {
        const row: DiffRow = { kind: "removed", before: beforeLine, content };
        beforeLine += 1;
        rows.push(row);
        pendingRemoved.push(row);
      }
      continue;
    }

    flushPair();
    for (const content of lines) {
      rows.push({ kind: "context", before: beforeLine, after: afterLine, content });
      beforeLine += 1;
      afterLine += 1;
    }
  }

  flushPair();

  return groupIntoHunks(rows, context);
}

/**
 * Groups rows into hunks, dropping context beyond `context` lines.
 *
 * A file with one changed line is otherwise thousands of rows of identical
 * text, and the reader has to find the change in it.
 */
export function groupIntoHunks(rows: DiffRow[], context: number): DiffHunk[] {
  const changedIndexes = rows
    .map((row, index) => (row.kind === "context" ? -1 : index))
    .filter((index) => index >= 0);

  if (changedIndexes.length === 0) return [];

  // Ranges of rows to keep, then merged where their context overlaps —
  // otherwise two nearby changes produce two hunks separated by nothing.
  const ranges: [number, number][] = [];
  for (const index of changedIndexes) {
    const start = Math.max(0, index - context);
    const end = Math.min(rows.length - 1, index + context);
    const last = ranges[ranges.length - 1];
    if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end);
    else ranges.push([start, end]);
  }

  let previousEnd = -1;
  return ranges.map(([start, end], index) => {
    const skippedBefore = start - previousEnd - 1;
    previousEnd = end;
    return {
      id: `hunk-${String(index)}`,
      rows: rows.slice(start, end + 1),
      skippedBefore: Math.max(0, skippedBefore),
    };
  });
}

/** Row pairs for a side-by-side view, aligning removals against additions. */
export function toSplitRows(
  rows: DiffRow[],
): { left: DiffRow | null; right: DiffRow | null }[] {
  const pairs: { left: DiffRow | null; right: DiffRow | null }[] = [];
  let index = 0;

  while (index < rows.length) {
    const row = rows[index];
    if (!row) break;

    if (row.kind === "context") {
      pairs.push({ left: row, right: row });
      index += 1;
      continue;
    }

    // Take the whole removal run and the whole addition run that follows, then
    // lay them alongside each other. Emitting them in document order instead
    // would put every removal above every addition, which is the unified view
    // wearing a two-column costume.
    const removed: DiffRow[] = [];
    while (rows[index]?.kind === "removed") {
      removed.push(rows[index] as DiffRow);
      index += 1;
    }
    const added: DiffRow[] = [];
    while (rows[index]?.kind === "added") {
      added.push(rows[index] as DiffRow);
      index += 1;
    }

    const height = Math.max(removed.length, added.length);
    for (let offset = 0; offset < height; offset += 1) {
      pairs.push({ left: removed[offset] ?? null, right: added[offset] ?? null });
    }
  }

  return pairs;
}

/** Counts, for a summary that says what the diff does before it is read. */
export function countChanges(hunks: DiffHunk[]): { added: number; removed: number } {
  let added = 0;
  let removed = 0;
  for (const hunk of hunks) {
    for (const row of hunk.rows) {
      if (row.kind === "added") added += 1;
      if (row.kind === "removed") removed += 1;
    }
  }
  return { added, removed };
}
ui/diff-viewer.tsx
"use client";

// Motion from SmoothUI AI Diff (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import {
  useEffect,
  useMemo,
  useRef,
  useState,
  type ComponentPropsWithRef,
  type CSSProperties,
  type ReactNode,
} from "react";

import { disabledStyles, focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

import { countChanges, toSplitRows, type DiffHunk, type DiffRow } from "./diff-model";

/**
 * A diff, and a decision about it.
 *
 * The rendering half exists because every packaged viewer brings its own
 * styling system — emotion, or HTML strings plus a stylesheet — which design
 * tokens cannot reach and which is awkward under RSC. The deciding half exists
 * because Dowel is for AI products, and an agent proposing a change to a file is
 * the case that needs it: what every coding agent ships, and no component
 * library does.
 *
 * Decisions are controlled. The component never mutates the diff or applies
 * anything; it reports which hunks were accepted and rejected and leaves the
 * consequences to the application, the same way ai-action-ledger does.
 *
 * Not a grid. A code listing is a table of text, and role="grid" would promise
 * cell-by-cell arrow navigation that neither exists here nor makes sense for
 * reading code. Line kind is carried in text, because a plus sign and a green
 * background are not information.
 */

export type HunkDecision = "accepted" | "rejected";

const KIND_LABEL: Record<DiffRow["kind"], string> = {
  added: "Added",
  removed: "Removed",
  context: "Unchanged",
};

export interface DiffViewerProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
  hunks: DiffHunk[];
  /** Names the diff — usually the path of the file being changed. */
  label: string;
  view?: "unified" | "split";
  /** Per-hunk decisions, keyed by hunk id. Controlled. */
  decisions?: Record<string, HunkDecision>;
  onDecision?: (hunkId: string, decision: HunkDecision) => void;
  children?: ReactNode;
  /**
   * Entrance for added lines. `wipe` draws each one in along the reading
   * direction, a line after the other; context and removed lines were already
   * there and do not move.
   */
  entrance?: "none" | "wipe";
  /**
   * Collapse a rejected hunk's lines. Its header and controls stay, so the
   * decision can still be reversed, and the header says the lines are hidden.
   */
  collapseRejected?: boolean;
}

const PREFIX = "dowel-diff-viewer";

/* The wipe reveals an added line's code (never its line numbers) in reading
 * order; its stagger is capped so a long diff does not take seconds to appear.
 * The flash is a one-shot tint when a hunk's decision changes. */
const STYLES = `
@keyframes ${PREFIX}-wipe{from{clip-path:inset(0 100% 0 0)}}
@keyframes ${PREFIX}-wipe-rtl{from{clip-path:inset(0 0 0 100%)}}
@keyframes ${PREFIX}-flash-accepted{from{background-color:color-mix(in oklab,var(--color-success) 14%,transparent)}}
@keyframes ${PREFIX}-flash-rejected{from{background-color:color-mix(in oklab,var(--color-destructive) 12%,transparent)}}
.${PREFIX}-wipe{animation:${PREFIX}-wipe calc(280ms * var(--motion-scale,1)) var(--ease-out-quint) both;animation-delay:calc(var(--dowel-i,0) * 20ms * var(--motion-scale,1))}
.${PREFIX}-wipe:dir(rtl){animation-name:${PREFIX}-wipe-rtl}
[data-slot=diff-hunk][data-decision-changed=accepted]{animation:${PREFIX}-flash-accepted calc(350ms * var(--motion-scale,1)) var(--ease-out-quint)}
[data-slot=diff-hunk][data-decision-changed=rejected]{animation:${PREFIX}-flash-rejected calc(350ms * var(--motion-scale,1)) var(--ease-out-quint)}
`;

/** Lines after this many in a hunk share the last one's delay. */
const WIPE_STAGGER_CAP = 20;

/** Props for the content cell of an added line under `entrance="wipe"`. */
function wipe(index: number): { className: string; style: CSSProperties } {
  return {
    className: `${PREFIX}-wipe`,
    style: { "--dowel-i": Math.min(index, WIPE_STAGGER_CAP) } as CSSProperties,
  };
}

export function DiffViewer({
  className,
  hunks,
  label,
  view = "unified",
  decisions,
  onDecision,
  children,
  entrance = "none",
  collapseRejected = false,
  ...props
}: DiffViewerProps) {
  const counts = useMemo(() => countChanges(hunks), [hunks]);

  return (
    <div
      data-slot="diff-viewer"
      data-view={view}
      className={cn("flex flex-col gap-2", className)}
      {...props}
    >
      <style href={PREFIX} precedence="dowel">
        {STYLES}
      </style>
      <div className="flex flex-wrap items-baseline justify-between gap-2">
        <span className="font-mono text-sm font-medium">{label}</span>
        {/* Said in words as well as coloured, and before the diff rather than
            after it: a reader deserves to know the size of the change before
            they start reading it. */}
        <span data-slot="diff-viewer-summary" className="text-xs text-muted-foreground">
          <span className="text-success">+{counts.added}</span>{" "}
          <span className="text-destructive">−{counts.removed}</span>
          <span className="sr-only">
            {` — ${String(counts.added)} lines added, ${String(counts.removed)} removed`}
          </span>
        </span>
      </div>

      {children}

      {hunks.length === 0 ? (
        <p className="rounded-lg border border-border bg-muted/30 p-3 text-sm text-muted-foreground">
          No changes.
        </p>
      ) : (
        <div className="overflow-hidden rounded-lg border border-border">
          {hunks.map((hunk) => (
            <DiffHunkView
              key={hunk.id}
              hunk={hunk}
              label={label}
              view={view}
              decision={decisions?.[hunk.id]}
              onDecision={onDecision}
              entrance={entrance}
              collapseRejected={collapseRejected}
            />
          ))}
        </div>
      )}
    </div>
  );
}

function DiffHunkView({
  hunk,
  label,
  view,
  decision,
  onDecision,
  entrance,
  collapseRejected,
}: {
  hunk: DiffHunk;
  label: string;
  view: "unified" | "split";
  decision?: HunkDecision;
  onDecision?: (hunkId: string, decision: HunkDecision) => void;
  entrance: "none" | "wipe";
  collapseRejected: boolean;
}) {
  const splitRows = useMemo(
    () => (view === "split" ? toSplitRows(hunk.rows) : []),
    [view, hunk.rows],
  );

  // A decision that changes after mount flashes once. Tracked during render,
  // so a decision present on first render (a replay) does not flash.
  const [previous, setPrevious] = useState(decision);
  const [flash, setFlash] = useState<HunkDecision | undefined>(undefined);
  if (decision !== previous) {
    setPrevious(decision);
    setFlash(decision);
  }

  // A native listener rather than onAnimationEnd: React picks a vendor-prefixed
  // event name wherever AnimationEvent is missing, and would never hear it.
  const sectionRef = useRef<HTMLElement | null>(null);
  useEffect(() => {
    const section = sectionRef.current;
    if (!section || !flash) return;
    const clear = (event: Event) => {
      // Wiped lines end their own animations inside the hunk; only the
      // hunk's flash clears the flag.
      if (event.target === section) setFlash(undefined);
    };
    section.addEventListener("animationend", clear);
    return () => {
      section.removeEventListener("animationend", clear);
    };
  }, [flash]);

  const wiping = entrance === "wipe";
  const collapsed = collapseRejected && decision === "rejected";
  const hiddenNote = collapsed ? <span className="sr-only"> — lines hidden</span> : null;

  const table = (
    <div className="overflow-x-auto">
      <table data-slot="diff-table" className="w-full border-collapse font-mono text-xs">
        <tbody>
          {view === "split"
            ? splitRows.map((pair, index) => (
                <tr key={index} data-slot="diff-row">
                  <SplitCell row={pair.left} side="before" />
                  <SplitCell
                    row={pair.right}
                    side="after"
                    wipeIndex={wiping ? index : undefined}
                  />
                </tr>
              ))
            : hunk.rows.map((row, index) => (
                <UnifiedRow key={index} row={row} wipeIndex={wiping ? index : undefined} />
              ))}
        </tbody>
      </table>
    </div>
  );

  return (
    <section
      data-slot="diff-hunk"
      data-decision={decision}
      ref={sectionRef}
      data-decision-changed={flash}
      aria-label={`${label}, hunk ${hunk.id}`}
      className={cn(
        "border-b border-border last:border-b-0",
        decision === "rejected" && "opacity-55",
      )}
    >
      {hunk.skippedBefore > 0 ? (
        <p
          data-slot="diff-hunk-skipped"
          className="border-b border-border bg-muted/40 px-3 py-1 font-mono text-2xs text-muted-foreground"
        >
          {hunk.skippedBefore} unchanged {hunk.skippedBefore === 1 ? "line" : "lines"} hidden
        </p>
      ) : null}

      {onDecision ? (
        <div className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5">
          <span className="flex-1 text-xs text-muted-foreground">
            {decision === "accepted"
              ? "Accepted"
              : decision === "rejected"
                ? "Rejected"
                : "Not decided"}
            {hiddenNote}
          </span>
          <HunkButton
            pressed={decision === "accepted"}
            onClick={() => {
              onDecision(hunk.id, "accepted");
            }}
          >
            Accept
          </HunkButton>
          <HunkButton
            pressed={decision === "rejected"}
            onClick={() => {
              onDecision(hunk.id, "rejected");
            }}
          >
            Reject
          </HunkButton>
        </div>
      ) : null}

      {!onDecision ? hiddenNote : null}

      {collapseRejected ? (
        // Grid rows animate between 1fr and 0fr, which is a height transition
        // CSS can do. Collapsed lines are inert: hidden, and out of reach.
        <div
          data-slot="diff-hunk-lines"
          data-collapsed={collapsed || undefined}
          className={cn(
            "grid transition-[grid-template-rows,opacity] duration-[calc(250ms*var(--motion-scale))] ease-[var(--ease-out-quint)]",
            collapsed ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr]",
          )}
        >
          <div className="min-h-0 overflow-hidden" inert={collapsed || undefined}>
            {table}
          </div>
        </div>
      ) : (
        table
      )}
    </section>
  );
}

const ROW_STYLES: Record<DiffRow["kind"], string> = {
  added: "bg-success/10",
  removed: "bg-destructive/10",
  context: "",
};

function UnifiedRow({ row, wipeIndex }: { row: DiffRow; wipeIndex?: number }) {
  const motion = wipeIndex !== undefined && row.kind === "added" ? wipe(wipeIndex) : undefined;
  return (
    <tr data-slot="diff-row" data-kind={row.kind} className={ROW_STYLES[row.kind]}>
      <LineNumber value={row.before} />
      <LineNumber value={row.after} />
      <td className="w-4 pe-1 text-center text-muted-foreground select-none" aria-hidden="true">
        {row.kind === "added" ? "+" : row.kind === "removed" ? "−" : ""}
      </td>
      <td
        className={cn("w-full py-0.5 pe-3 break-all whitespace-pre-wrap", motion?.className)}
        style={motion?.style}
      >
        {/* The kind, for anyone who cannot see the sign or the tint. Reading a
            diff aloud without it is reading the same file twice. */}
        <span className="sr-only">{KIND_LABEL[row.kind]}: </span>
        <RowContent row={row} />
      </td>
    </tr>
  );
}

function SplitCell({
  row,
  side,
  wipeIndex,
}: {
  row: DiffRow | null;
  side: "before" | "after";
  wipeIndex?: number;
}) {
  if (!row) {
    // An empty half of a pair, not a blank line of code. Hidden from assistive
    // technology so a reader is not read padding.
    return (
      <>
        <td aria-hidden="true" className="w-10 bg-muted/40" />
        <td aria-hidden="true" className="w-1/2 bg-muted/20" />
      </>
    );
  }

  const motion = wipeIndex !== undefined && row.kind === "added" ? wipe(wipeIndex) : undefined;

  return (
    <>
      <LineNumber value={side === "before" ? row.before : row.after} />
      <td
        className={cn(
          "w-1/2 py-0.5 pe-3 break-all whitespace-pre-wrap",
          ROW_STYLES[row.kind],
          motion?.className,
        )}
        style={motion?.style}
      >
        <span className="sr-only">{KIND_LABEL[row.kind]}: </span>
        <RowContent row={row} />
      </td>
    </>
  );
}

function RowContent({ row }: { row: DiffRow }) {
  if (!row.segments) return <>{row.content}</>;

  return (
    <>
      {row.segments.map((segment, index) =>
        segment.changed ? (
          // A mark element, so the changed words survive as structure rather
          // than existing only as a slightly stronger background.
          <mark
            key={index}
            className={cn(
              "rounded-[2px] text-inherit",
              row.kind === "added" ? "bg-success/30" : "bg-destructive/30",
            )}
          >
            {segment.text}
          </mark>
        ) : (
          <span key={index}>{segment.text}</span>
        ),
      )}
    </>
  );
}

function LineNumber({ value }: { value?: number }) {
  return (
    <td
      // Decorative: the line number is orientation for a sighted reader, and
      // announcing two numbers before every line makes the diff unlistenable.
      aria-hidden="true"
      className="w-10 border-e border-border bg-muted/40 px-2 py-0.5 text-end text-muted-foreground tabular-nums select-none"
    >
      {value ?? ""}
    </td>
  );
}

function HunkButton({
  className,
  pressed,
  ...props
}: ComponentPropsWithRef<"button"> & { pressed: boolean }) {
  return (
    <button
      type="button"
      aria-pressed={pressed}
      className={cn(
        "rounded-md border px-2 py-0.5 text-xs font-medium transition-colors",
        pressed
          ? "border-primary bg-primary text-primary-foreground"
          : "border-input bg-background hover:bg-accent hover:text-accent-foreground",
        focusRing,
        disabledStyles,
        className,
      )}
      {...props}
    />
  );
}

/** Switches between unified and side-by-side. */
export function DiffViewerToolbar({
  className,
  view,
  onViewChange,
  ...props
}: Omit<ComponentPropsWithRef<"div">, "onChange"> & {
  view: "unified" | "split";
  onViewChange: (view: "unified" | "split") => void;
}) {
  return (
    <div
      data-slot="diff-viewer-toolbar"
      role="group"
      aria-label="Diff layout"
      className={cn("flex items-center gap-1", className)}
      {...props}
    >
      {(["unified", "split"] as const).map((option) => (
        <button
          key={option}
          type="button"
          aria-pressed={view === option}
          onClick={() => {
            onViewChange(option);
          }}
          className={cn(
            "rounded-md border px-2 py-0.5 text-xs font-medium capitalize transition-colors",
            view === option
              ? "border-transparent bg-secondary text-secondary-foreground"
              : "border-input bg-background text-muted-foreground hover:text-foreground",
            focusRing,
          )}
        >
          {option}
        </button>
      ))}
    </div>
  );
}