Data Display

Diff Viewer

A dependency-light text diff with add/remove row tinting, expandable collapsed context, tabular line numbers, and a per-line copy affordance on hover.

Install

npx shadcn@latest add @paragon/diff-viewer

diff-viewer.tsx

"use client";

import * as React from "react";
import { Check, Copy, UnfoldVertical } from "lucide-react";
import { cn } from "@/lib/utils";

export type DiffLineType = "add" | "remove" | "context";

export interface DiffLine {
  /** Defaults to "context". */
  type?: DiffLineType;
  content: string;
}

export interface DiffHunk {
  /** Renders as a "show N hidden lines" affordance until expanded. */
  collapsed?: boolean;
  lines: DiffLine[];
}

interface NumberedLine {
  type: DiffLineType;
  content: string;
  old: number | null;
  new: number | null;
  key: string;
}

export interface DiffViewerProps extends React.ComponentProps<"div"> {
  hunks: DiffHunk[];
  /** Optional header with the file path and +/− stats. */
  fileName?: string;
  startOldLine?: number;
  startNewLine?: number;
}

const rowTint: Record<DiffLineType, string> = {
  add: "bg-emerald-500/8 hover:bg-emerald-500/15 dark:bg-emerald-500/10",
  remove: "bg-red-500/8 hover:bg-red-500/15 dark:bg-red-500/10",
  context: "hover:bg-muted/40",
};

const markerColor: Record<DiffLineType, string> = {
  add: "text-emerald-600 dark:text-emerald-400",
  remove: "text-red-600 dark:text-red-400",
  context: "text-transparent",
};

const marker: Record<DiffLineType, string> = {
  add: "+",
  remove: "-",
  context: " ",
};

/**
 * A dependency-light text diff. Added and removed lines tint the full
 * row; collapsed context expands in place through the grid-rows
 * transition (the reveal grows on ease-out while the affordance folds
 * away on ease-exit). Hovering a line reveals a copy affordance —
 * opacity only, and always reachable by keyboard regardless of hover.
 * Monospace, no syntax highlighting.
 */
export function DiffViewer({
  hunks,
  fileName,
  startOldLine = 1,
  startNewLine = 1,
  className,
  ...props
}: DiffViewerProps) {
  const [expandedHunks, setExpandedHunks] = React.useState<Set<number>>(
    () => new Set(),
  );
  const [copiedKey, setCopiedKey] = React.useState<string | null>(null);
  const copyTimeout = React.useRef<ReturnType<typeof setTimeout>>(null);

  React.useEffect(() => {
    return () => {
      if (copyTimeout.current) clearTimeout(copyTimeout.current);
    };
  }, []);

  const copyLine = (key: string, content: string) => {
    navigator.clipboard.writeText(content);
    setCopiedKey(key);
    if (copyTimeout.current) clearTimeout(copyTimeout.current);
    copyTimeout.current = setTimeout(() => setCopiedKey(null), 1500);
  };

  // Number every line up front so hidden hunks keep the count honest.
  const numbered = React.useMemo(() => {
    let oldLine = startOldLine;
    let newLine = startNewLine;
    let adds = 0;
    let removes = 0;
    const result: NumberedLine[][] = hunks.map((hunk, hi) =>
      hunk.lines.map((line, li) => {
        const type = line.type ?? "context";
        if (type === "add") adds++;
        if (type === "remove") removes++;
        return {
          type,
          content: line.content,
          old: type === "add" ? null : oldLine++,
          new: type === "remove" ? null : newLine++,
          key: `${hi}-${li}`,
        };
      }),
    );
    return { result, adds, removes };
  }, [hunks, startOldLine, startNewLine]);

  const renderLine = (line: NumberedLine) => {
    const isCopied = copiedKey === line.key;
    return (
      <div
        key={line.key}
        className={cn(
          "group/line relative flex w-max min-w-full items-stretch transition-colors duration-(--duration-fast)",
          rowTint[line.type],
        )}
      >
        <span
          aria-hidden
          className="w-10 shrink-0 py-0.5 pr-2 text-right text-muted-foreground/50 tabular-nums select-none"
        >
          {line.old ?? ""}
        </span>
        <span
          aria-hidden
          className="w-10 shrink-0 py-0.5 pr-2 text-right text-muted-foreground/50 tabular-nums select-none"
        >
          {line.new ?? ""}
        </span>
        <span
          className={cn(
            "w-5 shrink-0 py-0.5 text-center select-none",
            markerColor[line.type],
          )}
        >
          {marker[line.type]}
        </span>
        <span className="flex-1 py-0.5 pr-9 whitespace-pre">{line.content}</span>
        <button
          type="button"
          aria-label={`Copy line ${line.new ?? line.old ?? ""}`.trim()}
          onClick={() => copyLine(line.key, line.content)}
          className={cn(
            "absolute top-1/2 right-1.5 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground",
            "opacity-0 transition-opacity duration-(--duration-fast) group-hover/line:opacity-100 focus-visible:opacity-100 hover:text-foreground",
            "after:absolute after:top-1/2 after:left-1/2 after:size-7 after:-translate-1/2",
          )}
        >
          <span className="relative flex size-3 items-center justify-center">
            <Copy
              className={cn(
                "absolute size-3 transition-[opacity,scale] duration-(--duration-quick) ease-(--ease-out)",
                isCopied ? "scale-50 opacity-0" : "scale-100 opacity-100",
              )}
            />
            <Check
              className={cn(
                "absolute size-3 text-emerald-600 transition-[opacity,scale] duration-(--duration-quick) ease-(--ease-out) dark:text-emerald-400",
                isCopied ? "scale-100 opacity-100" : "scale-50 opacity-0",
              )}
            />
          </span>
        </button>
      </div>
    );
  };

  return (
    <div
      data-slot="diff-viewer"
      className={cn(
        "w-full overflow-hidden rounded-xl bg-card font-mono text-xs shadow-border",
        className,
      )}
      {...props}
    >
      {fileName && (
        <div className="flex items-center justify-between gap-4 border-b px-4 py-2.5">
          <span className="truncate font-medium text-foreground">{fileName}</span>
          <span className="shrink-0 tabular-nums">
            <span className="text-emerald-600 dark:text-emerald-400">
              +{numbered.adds}
            </span>{" "}
            <span className="text-red-600 dark:text-red-400">
{numbered.removes}
            </span>
          </span>
        </div>
      )}
      <div className="overflow-x-auto py-1">
        {hunks.map((hunk, hi) => {
          const lines = numbered.result[hi];
          if (!hunk.collapsed) {
            return <div key={hi}>{lines.map(renderLine)}</div>;
          }
          const isExpanded = expandedHunks.has(hi);
          return (
            <div key={hi}>
              <div
                className={cn(
                  "grid transition-[grid-template-rows] duration-(--duration-quick) ease-(--ease-exit) motion-reduce:transition-none",
                  isExpanded ? "grid-rows-[0fr]" : "grid-rows-[1fr]",
                )}
              >
                <div inert={isExpanded} className="min-h-0 overflow-hidden">
                  <button
                    type="button"
                    onClick={() =>
                      setExpandedHunks((prev) => new Set(prev).add(hi))
                    }
                    className="flex h-8 w-full items-center justify-center gap-2 bg-muted/40 text-[11px] text-muted-foreground transition-colors duration-(--duration-fast) hover:bg-muted/70 hover:text-foreground"
                  >
                    <UnfoldVertical aria-hidden className="size-3.5" />
                    Show {hunk.lines.length} hidden lines
                  </button>
                </div>
              </div>
              <div
                className={cn(
                  "grid transition-[grid-template-rows] duration-(--duration-base) ease-(--ease-out) motion-reduce:transition-none",
                  isExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
                )}
              >
                <div inert={!isExpanded} className="min-h-0 overflow-hidden">
                  {lines.map(renderLine)}
                </div>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}