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
| 1 | import React, { useLayoutEffect, useRef, useState } from "react"; | |
| 2 | import { Fade } from "./blur-fade/blur-fade"; | |
| 3 | import { cn } from "@workspace/ui/lib/utils"; | |
| 4 | import { Check, Copy, ChevronDown } from "lucide-react"; | |
| 5 | import { Button } from "@workspace/ui/components/button"; | |
| – | ||
| 6 | import * as Collapsible from "@radix-ui/react-collapsible"; | |
| 7 | ||
| 8 | const 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 | ||
| 32 | const 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 |
View the source code on GitHub
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>
);
};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
| 1 | import React, { useLayoutEffect, useRef, useState } from "react"; | |
| 2 | import { Fade } from "./blur-fade/blur-fade"; | |
| 3 | import { cn } from "@workspace/ui/lib/utils"; | |
| 4 | import { Check, Copy, ChevronDown } from "lucide-react"; | |
| 5 | import { Button } from "@workspace/ui/components/button"; | |
| – | ||
| 6 | import * as Collapsible from "@radix-ui/react-collapsible"; | |
| 7 | ||
| 8 | const 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 | ||
| 32 | const 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 |
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
| 1 | import React, { useLayoutEffect, useRef, useState } from "react"; | |
| 2 | import { Fade } from "./blur-fade/blur-fade"; | |
| 3 | import { cn } from "@workspace/ui/lib/utils"; | |
| 4 | import { Check, Copy, ChevronDown } from "lucide-react"; | |
| 5 | import { Button } from "@workspace/ui/components/button"; | |
| – | ||
| 6 | import * as Collapsible from "@radix-ui/react-collapsible"; | |
| 7 | ||
| 8 | const 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 | ||
| 32 | const 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 |
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
| 1 | import React, { useLayoutEffect, useRef, useState } from "react"; | |
| 2 | import { Fade } from "./blur-fade/blur-fade"; | |
| 3 | import { cn } from "@workspace/ui/lib/utils"; | |
| 4 | import { Check, Copy, ChevronDown } from "lucide-react"; | |
| 5 | import { Button } from "@workspace/ui/components/button"; | |
| – | ||
| 6 | import * as Collapsible from "@radix-ui/react-collapsible"; | |
| 7 | ||
| 8 | const 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 | ||
| 32 | const 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 |
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
| 1 | import React, { useLayoutEffect, useRef, useState } from "react"; | |
| 2 | import { Fade } from "./blur-fade/blur-fade"; | |
| 3 | import { cn } from "@workspace/ui/lib/utils"; | |
| 4 | import { Check, Copy, ChevronDown } from "lucide-react"; | |
| 5 | import { Button } from "@workspace/ui/components/button"; | |
| – | ||
| 6 | import * as Collapsible from "@radix-ui/react-collapsible"; | |
| 7 | ||
| 8 | const 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 | ||
| 32 | const 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 |
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
| 1 | import React, { useLayoutEffect, useRef, useState } from "react"; | |
| 2 | import { Fade } from "./blur-fade/blur-fade"; | |
| 3 | import { cn } from "@workspace/ui/lib/utils"; | |
| 4 | import { Check, Copy, ChevronDown } from "lucide-react"; | |
| 5 | import { Button } from "@workspace/ui/components/button"; | |
| – | ||
| 6 | import * as Collapsible from "@radix-ui/react-collapsible"; | |
| 7 | ||
| 8 | const 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 | ||
| 32 | const 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 |
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
| 1 | import React, { useLayoutEffect, useRef, useState } from "react"; | |
| 2 | import { Fade } from "./blur-fade/blur-fade"; | |
| 3 | import { cn } from "@workspace/ui/lib/utils"; | |
| 4 | import { Check, Copy, ChevronDown } from "lucide-react"; | |
| 5 | import { Button } from "@workspace/ui/components/button"; | |
| – | ||
| 6 | import * as Collapsible from "@radix-ui/react-collapsible"; | |
| 7 | ||
| 8 | const 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 | ||
| 32 | const 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 |
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
| 1 | import React, { useLayoutEffect, useRef, useState } from "react"; | |
| 2 | import { Fade } from "./blur-fade/blur-fade"; | |
| 3 | import { cn } from "@workspace/ui/lib/utils"; | |
| 4 | import { Check, Copy, ChevronDown } from "lucide-react"; | |
| 5 | import { Button } from "@workspace/ui/components/button"; | |
| – | ||
| 6 | import * as Collapsible from "@radix-ui/react-collapsible"; | |
| 7 | ||
| 8 | const 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 | ||
| 32 | const 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 |