Unified diff viewer

An opinionated simple diff viewer for React.

Built for asi.review, but available as a standalone component.

The unified diff viewer parses standard unified diffs and renders them as minimally as possible. It merges nearby insertions and deletions into a single “modified” row and shows small character-level edits inline to make it easier to understand changes.  

Below is documentation for how to use it, and an in-depth explanation of how it works. To the right, you can see the DiffViewer component. During the in-depth explanation you will see how we go from a verbose Github style diff, to a more minimal diff.

apps/web/components/overflow-card.tsx

1import React, { useLayoutEffect, useRef, useState } from "react";
2import { Fade } from "./blur-fade/blur-fade";
3import { cn } from "@workspace/ui/lib/utils";
4import { Check, Copy, ChevronDown } from "lucide-react";
5import { Button } from "@workspace/ui/components/button";
import { useTheme } from "next-themes";
6import * as Collapsible from "@radix-ui/react-collapsible";
7
8const Root = ({
9 className,
10 children,
11 defaultOpen = true,
12 ...props
13}: React.ComponentProps<"div">) => & {
14 defaultOpen?: boolean;
15}) => {
16
17 return (
18 <Collapsible.Root defaultOpen={defaultOpen}>
19 <div
20 {...props}
21 className={cn(
22 "relative text-[13px] rounded-xl overflow-hidden border bg-code min-h-16",
23 className
24 )}
25 / >
26 {children}
27 </div>
28 </Collapsible.Root>
29 );
30};
31
32const Header: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
33 className,
34 children,
35 ...props
36}) => (
37 <Collapsible.Trigger asChild>
38 <div
39 {...props}
40 className={cn(
41 "absolute top-3 inset-x-4 z-20",
42 "flex items-center gap-2 justify-between",
43 className
44 )}
45 >
46 <Button variant="ghost" size="icon" className="h-8 w-8">
47 <ChevronDown className="h-4 w-4 transition-transform duration-200 [[data-state=open]_&]:rotate-180" />
48 </Button>
49 {children}
50 </div>
51 </Collapsible.Trigger>
52);
53
Change ratio0.45
Diff distance30
Char edits2

Installation

pnpm dlx shadcn@latest add https://ui.fredrika.dev/r/diff-viewer.json

View the source code on GitHub

Usage

Use parseDiff to convert a diff string into a list of files. Passing children to the Diff is optional, just passing the hunks is fine too.

import { parseDiff } from "@/ui/diff/utils";
import { Diff, Hunk, Link } from "@/ui/diff";
 
const DiffViewer = ({ diff }: { diff: string }) => {
  const files = parseDiff(diff);
 
  return (
    <div>
      {files.map((file) => (
        <Diff key={file.name} hunks={file.hunks}>
          {file.hunks.map((hunk) => (
            <Hunk key={hunk.id} hunk={hunk} />
          ))}
        </Diff>
      ))}
    </div>
  );
};

Parsing diffs

GitHub shows diffs without merging modified lines. Inserted and deleted blocks are rendered separately, sometimes far apart. Take a look at line 4, where the ChevronDown icon is added to the import. You need to read two lines to understand that only an icon was added and nothing deleted.

- import { Check, Copy } from "lucide-react";
+ import { Check, Copy, ChevronDown } from "lucide-react";

apps/web/components/overflow-card.tsx

1import React, { useLayoutEffect, useRef, useState } from "react";
2import { Fade } from "./blur-fade/blur-fade";
3import { cn } from "@workspace/ui/lib/utils";
4import { Check, Copy, ChevronDown } from "lucide-react";
5import { Button } from "@workspace/ui/components/button";
import { useTheme } from "next-themes";
6import * as Collapsible from "@radix-ui/react-collapsible";
7
8const Root = ({
9 className,
10 children,
11 defaultOpen = true,
12 ...props
13}: React.ComponentProps<"div">) => & {
14 defaultOpen?: boolean;
15}) => {
16
17 return (
18 <Collapsible.Root defaultOpen={defaultOpen}>
19 <div
20 {...props}
21 className={cn(
22 "relative text-[13px] rounded-xl overflow-hidden border bg-code min-h-16",
23 className
24 )}
25 / >
26 {children}
27 </div>
28 </Collapsible.Root>
29 );
30};
31
32const Header: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
33 className,
34 children,
35 ...props
36}) => (
37 <Collapsible.Trigger asChild>
38 <div
39 {...props}
40 className={cn(
41 "absolute top-3 inset-x-4 z-20",
42 "flex items-center gap-2 justify-between",
43 className
44 )}
45 >
46 <Button variant="ghost" size="icon" className="h-8 w-8">
47 <ChevronDown className="h-4 w-4 transition-transform duration-200 [[data-state=open]_&]:rotate-180" />
48 </Button>
49 {children}
50 </div>
51 </Collapsible.Trigger>
52);
53
Change ratio0.45
Diff distance30
Char edits2

Now look at line 4 again. The separate insert and delete are merged together, making it much easier to understand what changed.

The naive approach is to iterate over all changes and merge adjacent insertions and deletions into one "normal" line where segments are marked as insert or delete.

const mergeAdjacentLines = (changes: _Change[]): Line[] => {
  const out: Line[] = [];
  for (let i = 0; i < changes.length; i++) {
    const current = changes[i];
    const next = changes[i + 1];
    if (current.type === "delete" && next?.type === "insert") {
      out.push({
        ...current,
        type: "normal",
        isNormal: true,
        oldLineNumber: current.lineNumber,
        newLineNumber: next.lineNumber,
        content: buildInlineDiffSegments(current, next),
      });
      i++; // Skip next
    } else {
      out.push(current);
    }
  }
 
  return out;
};

apps/web/components/overflow-card.tsx

1import React, { useLayoutEffect, useRef, useState } from "react";
2import { Fade } from "./blur-fade/blur-fade";
3import { cn } from "@workspace/ui/lib/utils";
4import { Check, Copy, ChevronDown } from "lucide-react";
5import { Button } from "@workspace/ui/components/button";
import { useTheme } from "next-themes";
6import * as Collapsible from "@radix-ui/react-collapsible";
7
8const Root = ({
9 className,
10 children,
11 defaultOpen = true,
12 ...props
13}: React.ComponentProps<"div">) => & {
14 defaultOpen?: boolean;
15}) => {
16
17 return (
18 <Collapsible.Root defaultOpen={defaultOpen}>
19 <div
20 {...props}
21 className={cn(
22 "relative text-[13px] rounded-xl overflow-hidden border bg-code min-h-16",
23 className
24 )}
25 / >
26 {children}
27 </div>
28 </Collapsible.Root>
29 );
30};
31
32const Header: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
33 className,
34 children,
35 ...props
36}) => (
37 <Collapsible.Trigger asChild>
38 <div
39 {...props}
40 className={cn(
41 "absolute top-3 inset-x-4 z-20",
42 "flex items-center gap-2 justify-between",
43 className
44 )}
45 >
46 <Button variant="ghost" size="icon" className="h-8 w-8">
47 <ChevronDown className="h-4 w-4 transition-transform duration-200 [[data-state=open]_&]:rotate-180" />
48 </Button>
49 {children}
50 </div>
51 </Collapsible.Trigger>
52);
53
Change ratio0.45
Diff distance30
Char edits2

The problem with just merging every delete+insert pair is that when the lines are very different, the result is noisy. See line 6 – not very nice.

To solve this, we can add a maximum change ratio. So only lines where the change ratio is less than this limit are merged.

const calculateChangeRatio = (a: string, b: string): number => {
  const totalChars = a.length + b.length;
  if (totalChars === 0) return 1;
  const tokens = diffWords(a, b);
  const changedChars = tokens
    .filter((token) => token.added || token.removed)
    .reduce((sum, token) => sum + token.value.length, 0);
  return changedChars / totalChars;
};
 
// Then we check for it before merging
if (current.type === "delete" && next.type === "insert" 
  && calculateChangeRatio(current.content, next.content) < options.maxChangeRatio) { /* ... */ }

apps/web/components/overflow-card.tsx

1import React, { useLayoutEffect, useRef, useState } from "react";
2import { Fade } from "./blur-fade/blur-fade";
3import { cn } from "@workspace/ui/lib/utils";
4import { Check, Copy, ChevronDown } from "lucide-react";
5import { Button } from "@workspace/ui/components/button";
import { useTheme } from "next-themes";
6import * as Collapsible from "@radix-ui/react-collapsible";
7
8const Root = ({
9 className,
10 children,
11 defaultOpen = true,
12 ...props
13}: React.ComponentProps<"div">) => & {
14 defaultOpen?: boolean;
15}) => {
16
17 return (
18 <Collapsible.Root defaultOpen={defaultOpen}>
19 <div
20 {...props}
21 className={cn(
22 "relative text-[13px] rounded-xl overflow-hidden border bg-code min-h-16",
23 className
24 )}
25 / >
26 {children}
27 </div>
28 </Collapsible.Root>
29 );
30};
31
32const Header: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
33 className,
34 children,
35 ...props
36}) => (
37 <Collapsible.Trigger asChild>
38 <div
39 {...props}
40 className={cn(
41 "absolute top-3 inset-x-4 z-20",
42 "flex items-center gap-2 justify-between",
43 className
44 )}
45 >
46 <Button variant="ghost" size="icon" className="h-8 w-8">
47 <ChevronDown className="h-4 w-4 transition-transform duration-200 [[data-state=open]_&]:rotate-180" />
48 </Button>
49 {children}
50 </div>
51 </Collapsible.Trigger>
52);
53
Change ratio0.45
Diff distance30
Char edits2

Setting the max change ratio to 0.45 prevents the parser from merging line 6. Try playing with it to see what happens.

But deletions and insertions of modified lines are not always next to each other. Look at line 20. The deleted version of that line is displayed below line 38, making it very difficult to see that the only change is adding min-h-16.

What if we find the best possible match for each deletion while maintaining the order?

apps/web/components/overflow-card.tsx

1import React, { useLayoutEffect, useRef, useState } from "react";
2import { Fade } from "./blur-fade/blur-fade";
3import { cn } from "@workspace/ui/lib/utils";
4import { Check, Copy, ChevronDown } from "lucide-react";
5import { Button } from "@workspace/ui/components/button";
import { useTheme } from "next-themes";
6import * as Collapsible from "@radix-ui/react-collapsible";
7
8const Root = ({
9 className,
10 children,
11 defaultOpen = true,
12 ...props
13}: React.ComponentProps<"div">) => & {
14 defaultOpen?: boolean;
15}) => {
16
17 return (
18 <Collapsible.Root defaultOpen={defaultOpen}>
19 <div
20 {...props}
21 className={cn(
22 "relative text-[13px] rounded-xl overflow-hidden border bg-code min-h-16",
23 className
24 )}
25 / >
26 {children}
27 </div>
28 </Collapsible.Root>
29 );
30};
31
32const Header: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
33 className,
34 children,
35 ...props
36}) => (
37 <Collapsible.Trigger asChild>
38 <div
39 {...props}
40 className={cn(
41 "absolute top-3 inset-x-4 z-20",
42 "flex items-center gap-2 justify-between",
43 className
44 )}
45 >
46 <Button variant="ghost" size="icon" className="h-8 w-8">
47 <ChevronDown className="h-4 w-4 transition-transform duration-200 [[data-state=open]_&]:rotate-180" />
48 </Button>
49 {children}
50 </div>
51 </Collapsible.Trigger>
52);
53
Change ratio0.45
Diff distance30
Char edits2

Now look at line 20 again. The separate insert and delete are merged together, making it much easier to understand what changed.

But why isn't the small typo that was fixed by adding x merged together? Try increasing the inline max char edits to see how it works.

const charDiff = diffCharsIfWithinEditLimit(
  current.value,
  next.value,
  options.inlineMaxCharEdits
);
 
if (!charDiff.exceededLimit) {
  charDiff.diffs.forEach(mergeIntoResult);
  i++;
} else {
  result.push(current);
}

apps/web/components/overflow-card.tsx

1import React, { useLayoutEffect, useRef, useState } from "react";
2import { Fade } from "./blur-fade/blur-fade";
3import { cn } from "@workspace/ui/lib/utils";
4import { Check, Copy, ChevronDown } from "lucide-react";
5import { Button } from "@workspace/ui/components/button";
import { useTheme } from "next-themes";
6import * as Collapsible from "@radix-ui/react-collapsible";
7
8const Root = ({
9 className,
10 children,
11 defaultOpen = true,
12 ...props
13}: React.ComponentProps<"div">) => & {
14 defaultOpen?: boolean;
15}) => {
16
17 return (
18 <Collapsible.Root defaultOpen={defaultOpen}>
19 <div
20 {...props}
21 className={cn(
22 "relative text-[13px] rounded-xl overflow-hidden border bg-code min-h-16",
23 className
24 )}
25 / >
26 {children}
27 </div>
28 </Collapsible.Root>
29 );
30};
31
32const Header: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
33 className,
34 children,
35 ...props
36}) => (
37 <Collapsible.Trigger asChild>
38 <div
39 {...props}
40 className={cn(
41 "absolute top-3 inset-x-4 z-20",
42 "flex items-center gap-2 justify-between",
43 className
44 )}
45 >
46 <Button variant="ghost" size="icon" className="h-8 w-8">
47 <ChevronDown className="h-4 w-4 transition-transform duration-200 [[data-state=open]_&]:rotate-180" />
48 </Button>
49 {children}
50 </div>
51 </Collapsible.Trigger>
52);
53
Change ratio0.45
Diff distance30
Char edits2

Using word-diff

Git has another mode for generating diffs called word-diff. Github doesn't support this, but you can run it in your terminal like this:

git diff --word-diff --no-index <old-file> <new-file>

Unlike the default Myer's algorithm, word-diff outputs the diff on a word-by-word basis. Each inserted word is wrapped in {+ and +} and each deleted word is wrapped in [- and -]. So we no longer need to guess which lines are modified.

Because it's parsing on word-level, we still need to merge adjacent insertions and deletions on each line using the same heuristics as before.

import { Check, [-Copy-]{+Copy, ChevronDown+} } from "lucide-react";

It does not have any options for when lines are similar enough. Our anti-example will be merged as well:

import [-{ useTheme }-]{+* as Collapsible+} from [-"next-themes";-]{+"@radix-ui/react-collapsible";+}

apps/web/components/overflow-card.tsx

1import React, { useLayoutEffect, useRef, useState } from "react";
2import { Fade } from "./blur-fade/blur-fade";
3import { cn } from "@workspace/ui/lib/utils";
4import { Check, Copy, ChevronDown } from "lucide-react";
5import { Button } from "@workspace/ui/components/button";
import { useTheme } from "next-themes";
6import * as Collapsible from "@radix-ui/react-collapsible";
7
8const Root = ({
9 className,
10 children,
11 defaultOpen = true,
12 ...props
13}: React.ComponentProps<"div">) => & {
14 defaultOpen?: boolean;
15}) => {
16
17 return (
18 <Collapsible.Root defaultOpen={defaultOpen}>
19 <div
20 {...props}
21 className={cn(
22 "relative text-[13px] rounded-xl overflow-hidden border bg-code min-h-16",
23 className
24 )}
25 / >
26 {children}
27 </div>
28 </Collapsible.Root>
29 );
30};
31
32const Header: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
33 className,
34 children,
35 ...props
36}) => (
37 <Collapsible.Trigger asChild>
38 <div
39 {...props}
40 className={cn(
41 "absolute top-3 inset-x-4 z-20",
42 "flex items-center gap-2 justify-between",
43 className
44 )}
45 >
46 <Button variant="ghost" size="icon" className="h-8 w-8">
47 <ChevronDown className="h-4 w-4 transition-transform duration-200 [[data-state=open]_&]:rotate-180" />
48 </Button>
49 {children}
50 </div>
51 </Collapsible.Trigger>
52);
53
Change ratio0.45
Diff distance30
Char edits2

So we can use the same change ratio to split merged lines into separate insertions and deletions.

// before
import [-{ useTheme }-]{+* as Collapsible+} from [-"next-themes";-]{+"@radix-ui/react-collapsible";+}
 
// after
[-import { useTheme } from "next-themes";-]
{+import * as Collapsible from "@radix-ui/react-collapsible";+}

And that's probably the best results we can get.

apps/web/components/overflow-card.tsx

1import React, { useLayoutEffect, useRef, useState } from "react";
2import { Fade } from "./blur-fade/blur-fade";
3import { cn } from "@workspace/ui/lib/utils";
4import { Check, Copy, ChevronDown } from "lucide-react";
5import { Button } from "@workspace/ui/components/button";
import { useTheme } from "next-themes";
6import * as Collapsible from "@radix-ui/react-collapsible";
7
8const Root = ({
9 className,
10 children,
11 defaultOpen = true,
12 ...props
13}: React.ComponentProps<"div">) => & {
14 defaultOpen?: boolean;
15}) => {
16
17 return (
18 <Collapsible.Root defaultOpen={defaultOpen}>
19 <div
20 {...props}
21 className={cn(
22 "relative text-[13px] rounded-xl overflow-hidden border bg-code min-h-16",
23 className
24 )}
25 / >
26 {children}
27 </div>
28 </Collapsible.Root>
29 );
30};
31
32const Header: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
33 className,
34 children,
35 ...props
36}) => (
37 <Collapsible.Trigger asChild>
38 <div
39 {...props}
40 className={cn(
41 "absolute top-3 inset-x-4 z-20",
42 "flex items-center gap-2 justify-between",
43 className
44 )}
45 >
46 <Button variant="ghost" size="icon" className="h-8 w-8">
47 <ChevronDown className="h-4 w-4 transition-transform duration-200 [[data-state=open]_&]:rotate-180" />
48 </Button>
49 {children}
50 </div>
51 </Collapsible.Trigger>
52);
53
Change ratio0.45
Diff distance30
Char edits2