Skip to main content

Diff Viewer Widget

The diff viewer widget displays file diffs in unified or side-by-side mode with syntax highlighting, hunk navigation, and intra-line change highlighting.

Basic Usage

import { ui } from "@rezi-ui/core";
import { defineWidget } from "@rezi-ui/core";
import type { DiffData } from "@rezi-ui/core";

const DiffViewer = defineWidget((ctx) => {
  const [scrollTop, setScrollTop] = ctx.useState(0);

  const diff: DiffData = {
    oldPath: "src/utils.ts",
    newPath: "src/utils.ts",
    status: "modified",
    hunks: [
      {
        oldStart: 1,
        oldCount: 3,
        newStart: 1,
        newCount: 4,
        header: "function hello()",
        lines: [
          { type: "context", content: "function hello() {" },
          { type: "delete", content: '  console.log("Hello");' },
          { type: "add", content: '  console.log("Hello, world!");' },
          { type: "add", content: '  console.log("Welcome");' },
          { type: "context", content: "}" },
        ],
      },
    ],
  };

  return ui.diffViewer({
    id: "my-diff",
    diff,
    mode: "unified",
    scrollTop,
    onScroll: setScrollTop,
  });
});

View Modes

Unified Mode

Traditional diff format with added/deleted lines interleaved:
ui.diffViewer({
  id: "unified-diff",
  diff,
  mode: "unified",
  scrollTop,
  onScroll: setScrollTop,
});
Output:
@@ -1,3 +1,4 @@ function hello()
  function hello() {
-   console.log("Hello");
+   console.log("Hello, world!");
+   console.log("Welcome");
  }

Side-by-Side Mode

Two-column layout showing old and new versions:
ui.diffViewer({
  id: "sidebyside-diff",
  diff,
  mode: "sideBySide",
  scrollTop,
  onScroll: setScrollTop,
});

Parsing Unified Diff

Parse standard unified diff output:
import { parseUnifiedDiff } from "@rezi-ui/core";

const diffText = `
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,3 +1,4 @@
 function hello() {
-  console.log("Hello");
+  console.log("Hello, world!");
+  console.log("Welcome");
 }
`;

const diff = parseUnifiedDiff(diffText);
// Returns: DiffData with oldPath, newPath, hunks, status

Intra-Line Highlighting

Highlight specific character ranges that changed within a line:
import { computeIntraLineHighlights } from "@rezi-ui/core";

const deletedLine = '  console.log("Hello");';
const addedLine = '  console.log("Hello, world!");';

const highlights = computeIntraLineHighlights(deletedLine, addedLine);
// Returns:
// {
//   deleted: [[18, 23]],  // "Hello" range
//   added: [[18, 31]],    // "Hello, world!" range
// }

const diff: DiffData = {
  oldPath: "file.ts",
  newPath: "file.ts",
  status: "modified",
  hunks: [
    {
      oldStart: 1,
      oldCount: 1,
      newStart: 1,
      newCount: 1,
      lines: [
        {
          type: "delete",
          content: deletedLine,
          highlights: highlights.deleted,
        },
        {
          type: "add",
          content: addedLine,
          highlights: highlights.added,
        },
      ],
    },
  ],
};

Hunk Navigation

import { defineWidget } from "@rezi-ui/core";
import { navigateHunk, getHunkScrollPosition } from "@rezi-ui/core";

const DiffWithNav = defineWidget((ctx) => {
  const [focusedHunk, setFocusedHunk] = ctx.useState(0);
  const [scrollTop, setScrollTop] = ctx.useState(0);

  const goToNextHunk = () => {
    const nextHunk = navigateHunk(focusedHunk, "next", diff.hunks.length);
    setFocusedHunk(nextHunk);
    setScrollTop(getHunkScrollPosition(nextHunk, diff.hunks));
  };

  const goToPrevHunk = () => {
    const prevHunk = navigateHunk(focusedHunk, "prev", diff.hunks.length);
    setFocusedHunk(prevHunk);
    setScrollTop(getHunkScrollPosition(prevHunk, diff.hunks));
  };

  return ui.column({ gap: 1 }, [
    ui.row({ gap: 1 }, [
      ui.button({
        id: "prev-hunk",
        label: "← Previous Hunk",
        onPress: goToPrevHunk,
      }),
      ui.button({
        id: "next-hunk",
        label: "Next Hunk →",
        onPress: goToNextHunk,
      }),
      ui.text(`Hunk ${focusedHunk + 1} of ${diff.hunks.length}`),
    ]),
    ui.diffViewer({
      id: "nav-diff",
      diff,
      mode: "unified",
      scrollTop,
      focusedHunk,
      onScroll: setScrollTop,
    }),
  ]);
});

Hunk Actions

Stage/Unstage Hunks

ui.diffViewer({
  id: "git-diff",
  diff,
  mode: "unified",
  scrollTop,
  onScroll: setScrollTop,
  onStageHunk: (hunkIndex) => {
    console.log(`Stage hunk ${hunkIndex}`);
    // Call git add -p logic
  },
  onUnstageHunk: (hunkIndex) => {
    console.log(`Unstage hunk ${hunkIndex}`);
    // Call git reset -p logic
  },
});

Apply/Revert Hunks

ui.diffViewer({
  id: "patch-diff",
  diff,
  mode: "unified",
  scrollTop,
  onScroll: setScrollTop,
  onApplyHunk: (hunkIndex) => {
    console.log(`Apply hunk ${hunkIndex}`);
    // Apply this hunk to working copy
  },
  onRevertHunk: (hunkIndex) => {
    console.log(`Revert hunk ${hunkIndex}`);
    // Revert this hunk from working copy
  },
});

Expand/Collapse Hunks

import { defineWidget } from "@rezi-ui/core";

const CollapsibleDiff = defineWidget((ctx) => {
  const [expandedHunks, setExpandedHunks] = ctx.useState<readonly number[]>([]);

  return ui.diffViewer({
    id: "collapsible-diff",
    diff,
    mode: "unified",
    scrollTop: 0,
    expandedHunks,
    onScroll: () => {},
    onHunkToggle: (hunkIndex, expanded) => {
      if (expanded) {
        setExpandedHunks([...expandedHunks, hunkIndex]);
      } else {
        setExpandedHunks(expandedHunks.filter((i) => i !== hunkIndex));
      }
    },
  });
});

File Status

The status field indicates the type of change:
const addedFile: DiffData = {
  oldPath: "/dev/null",
  newPath: "src/new-file.ts",
  status: "added",
  hunks: [...],
};

const deletedFile: DiffData = {
  oldPath: "src/old-file.ts",
  newPath: "/dev/null",
  status: "deleted",
  hunks: [...],
};

const renamedFile: DiffData = {
  oldPath: "src/old-name.ts",
  newPath: "src/new-name.ts",
  status: "renamed",
  hunks: [...],
};

const modifiedFile: DiffData = {
  oldPath: "src/file.ts",
  newPath: "src/file.ts",
  status: "modified",
  hunks: [...],
};

Context Lines

Control how many context lines to show around changes:
ui.diffViewer({
  id: "context-diff",
  diff,
  mode: "unified",
  scrollTop,
  onScroll: setScrollTop,
  contextLines: 5, // Default: 3
});

Line Numbers

ui.diffViewer({
  id: "numbered-diff",
  diff,
  mode: "unified",
  scrollTop,
  onScroll: setScrollTop,
  lineNumbers: true, // Default: true
});

Git Workflow Example

import { defineWidget } from "@rezi-ui/core";
import { parseUnifiedDiff } from "@rezi-ui/core";

const GitDiffViewer = defineWidget((ctx) => {
  const [diffText, setDiffText] = ctx.useState("");
  const [scrollTop, setScrollTop] = ctx.useState(0);

  // Load diff from git
  ctx.useEffect(() => {
    const loadDiff = async () => {
      // Example: git diff HEAD~1 HEAD
      const result = await runGitCommand("git", ["diff", "HEAD~1", "HEAD"]);
      setDiffText(result.stdout);
    };
    loadDiff();
  }, []);

  const diff = parseUnifiedDiff(diffText);

  return ui.column({ gap: 1 }, [
    ui.row({ gap: 1 }, [
      ui.text(`${diff.oldPath}${diff.newPath}`, { variant: "heading" }),
      ui.spacer({ flex: 1 }),
      ui.badge({
        text: diff.status,
        variant:
          diff.status === "added"
            ? "success"
            : diff.status === "deleted"
              ? "error"
              : "default",
      }),
    ]),
    ui.diffViewer({
      id: "git-diff",
      diff,
      mode: "unified",
      scrollTop,
      onScroll: setScrollTop,
      onStageHunk: async (hunkIndex) => {
        // Stage this hunk
        await runGitCommand("git", ["add", "-p", diff.newPath]);
      },
    }),
  ]);
});

Diff Colors

Default color scheme (customizable via theme):
import { DIFF_COLORS } from "@rezi-ui/core";

// Default colors:
// {
//   addBg: { r: 35, g: 65, b: 35 },      // Added line background
//   deleteBg: { r: 65, g: 35, b: 35 },   // Deleted line background
//   addFg: { r: 150, g: 255, b: 150 },   // Added text
//   deleteFg: { r: 255, g: 150, b: 150 }, // Deleted text
//   hunkHeader: { r: 100, g: 149, b: 237 }, // Hunk header
//   lineNumber: { r: 100, g: 100, b: 100 }, // Line numbers
//   border: { r: 80, g: 80, b: 80 },     // Borders
// }

Flatten Hunks for Rendering

import { flattenHunks } from "@rezi-ui/core";
import type { FlattenedDiffLine } from "@rezi-ui/core";

const flattened: readonly FlattenedDiffLine[] = flattenHunks(diff.hunks);
// Returns array of:
// {
//   hunkIndex: number,
//   lineIndex: number,
//   line: DiffLine,
//   oldLineNum: number | null,
//   newLineNum: number | null,
// }

Props Reference

DiffViewerProps

PropTypeDefaultDescription
idstringRequiredWidget identifier
diffDiffDataRequiredDiff data to display
mode"unified" | "sideBySide"RequiredView mode
scrollTopnumberRequiredVertical scroll position
onScroll(scrollTop: number) => voidRequiredScroll callback
expandedHunksreadonly number[]Expanded hunk indices
focusedHunknumberCurrently focused hunk
lineNumbersbooleantrueShow line numbers
contextLinesnumber3Context lines around changes
focusedHunkStyleTextStyleFocused hunk header style
onHunkToggle(hunkIndex: number, expanded: boolean) => voidHunk expand callback
onStageHunk(hunkIndex: number) => voidStage hunk callback
onUnstageHunk(hunkIndex: number) => voidUnstage hunk callback
onApplyHunk(hunkIndex: number) => voidApply hunk callback
onRevertHunk(hunkIndex: number) => voidRevert hunk callback
focusablebooleantrueInclude in tab order
accessibleLabelstringAccessibility label
focusConfigFocusConfigFocus appearance
scrollbarVariant"minimal" | "classic" | "modern" | "dots" | "thin""minimal"Scrollbar style
scrollbarStyleTextStyleScrollbar color

DiffData

FieldTypeDescription
oldPathstringOriginal file path
newPathstringNew file path
hunksreadonly DiffHunk[]Diff hunks
isBinarybooleanBinary file flag
status"added" | "deleted" | "modified" | "renamed" | "copied"File change status

DiffHunk

FieldTypeDescription
oldStartnumberOriginal line range start
oldCountnumberOriginal line count
newStartnumberNew line range start
newCountnumberNew line count
headerstringOptional header text
linesreadonly DiffLine[]Hunk lines

DiffLine

FieldTypeDescription
type"context" | "add" | "delete"Line type
contentstringLine content
oldLineNumbernumberOriginal line number
newLineNumbernumberNew line number
highlightsreadonly [number, number][]Intra-line change ranges

Location in Source

  • Implementation: packages/core/src/widgets/diffViewer.ts
  • Types: packages/core/src/widgets/types.ts:2014-2103
  • Factory: packages/core/src/widgets/ui.ts:diffViewer()

Build docs developers (and LLMs) love