otakustay / react-diff-view

A git diff component
MIT License
829 stars 78 forks source link

react-diff-view

A git diff component to consume the git unified diff output.

Overview

Split view

split view

Unified view

unified view

Optimized selection

select only one side

Full features

Run npm start to enjoy a full featured demo with diff display, collapsed code expansion, code comment and large diff lazy load.

I test the performance with a 2.2MB diff file with 375 files changed, 18721 insertions(+), 35671 deletions(-).

In my laptop (MacBook Pro 15-inch 2016, 2.6 GHz Intel Core i7, 16 GB 2133 MHz LPDDR3) it performs quite slow but tolerable without lazy rendering:

parse: 88.73291015625ms
render: 26072.791015625ms
paint: 6199.848876953125ms

Install

npm install --save react-diff-view

Starting from 3.1.0, you can import {Diff} from 'react-diff-view/esm to reference an unminified ESM module.

Basic usage

Parse diff text

For best display effect, you should generate your diff text with git diff -U1 command.

The {File[] parseDiff({string} text, {Object} [options]) named export is a wrap of gitdiff-parser package with some extra options:

The nearbySequences can have a value of "zip" to "zip" a sequences of deletion and additions, as an example, here is a diff generated from react:

-    // if someone has already defined a value bail and don't track value
-    // will cause over reporting of changes, but it's better then a hard failure
-    // (needed for certain tests that spyOn input values)
-    if (node.hasOwnProperty(valueField)) {
+    // if someone has already defined a value or Safari, then bail
+    // and don't track value will cause over reporting of changes,
+    // but it's better then a hard failure
+    // (needed for certain tests that spyOn input values and Safari)

This is the normal behavior, which will displayed as 3 lines of deletion, 1 line of modification and 3 lines of addition:

Normal sequence behavior

When the value "zip" is passed, the diff will be modified to:

-    // if someone has already defined a value bail and don't track value
+    // if someone has already defined a value or Safari, then bail
-    // will cause over reporting of changes, but it's better then a hard failure
+    // and don't track value will cause over reporting of changes,
-    // (needed for certain tests that spyOn input values)
+    // but it's better then a hard failure
-    if (node.hasOwnProperty(valueField)) {
+    // (needed for certain tests that spyOn input values and Safari)

and as a result rendered as:

Normal sequence behavior

In most cases it can provide a better look in split view.

Render diff hunks

The Diff named export is a component to render a diff, a simplest case to render a diff could be:

import {parseDiff, Diff, Hunk} from 'react-diff-view';

function renderFile({oldRevision, newRevision, type, hunks}) {
    return (
        <Diff key={oldRevision + '-' + newRevision} viewType="split" diffType={type} hunks={hunks}>
            {hunks => hunks.map(hunk => <Hunk key={hunk.content} hunk={hunk} />)}
        </Diff>
    );
}

function App({diffText}) {
    const files = parseDiff(diffText);

    return (
        <div>
            {files.map(renderFile)}
        </div>
    );
}

The children is optional if you only need all hunks to be displayed, however you can use this function children to add custom events or classes to hunks.

As you can see, Diff component requires a hunks prop as well as a function children prop which receive the hunks prop as its argument, this may looks redundant but actually very useful when work with HOCs modifying hunks. For example, we have a HOC to remove all normal changes:

const filterOutNormalChanges = hunk => {
    return {
        ...hunk,
        changes: hunk.changes.filter(change => !change.isNormal);
    };
};

const removeNormalChanges = ComponentIn => {
    const ComponentOut = ({hunks, ...props}) => {
        const purgedHunks = hunks.map(filterOutNormalChanges);

        return <ComponentIn {...props} hunks={hunks} />;
    };

    ComponentOut.displayName = `removeNormalChanges(${ComponentIn.displayName})`;

    return ComponentOut;
};

const MyDiff = removeNormalChanges(Diff);

We can still pass original hunks to MyDiff, however all normal changes are removed from the hunks argument in children prop.

Here is the full list of its props:

Key of change

In selectedChanges and widgets props the key of change is used to match a specific change, a change's key is simply a string computed by the following rules:

if (change.type === 'insert') {
    return 'I' + change.lineNumber;
}
else if (change.type === 'delete') {
    return 'D' + change.lineNumber;
}
else {
    return 'N' + change.oldLineNumber;
}

You are not required to compute this key yourself, the getChangeKey(change) exported function will do it.

Add decoration around hunks

A decoration is customized content rendered around Hunk component, pass a Decoration element in Diff's children is the only required action.

Decoration component basically receives a children prop which can either have one or two elements:

A very simple use case of Decoration is to provide a summary information of hunk:

import {flatMap} from 'lodash';
import {Diff, Hunk, Decoration} from 'react-diff-view';

const renderHunk = hunk => [
    <Decoration key={'decoration-' + hunk.content}>
        {hunk.content}
    </Decoration>,
    <Hunk key={'hunk-' + hunk.content}> hunk={hunk} />
];

const DiffFile = ({diffType, hunks}) => (
    <Diff viewType="split" diffType={diffType}>
        {flatMap(hunks, renderHunk)}
    </Diff>
);

We can also render more content by providing two elements to Decoration:

const renderHunk = hunk => [
    <Decoration key={'decoration-' + hunk.content}>
        <SmileFace />,
        <span>{hunk.content}</span>
    </Decoration>,
    <Hunk key={'hunk-' + hunk.content}> hunk={hunk} />
]

Add widgets

In some cases we need functions like commenting on change, react-diff-view provides an extensible solution called widget to archive such scenarios.

A widget is any react element bound to a change object, a widget is configured in an object with change and element property, when rendering diff changes, if there is a widget object with the same change object, the element will be rendered below the line of code.

In split view a widget will be rendered to its corresponding side if change object is of type addition or deletion, otherwise the widget will be rendered across the entire row.

Note although the widgets prop is of type array, each change can only render one widget, so if there are entries with the same change property, only the first one will be rendered.

Here is a very basic example which adds a warning text on long lines:

import {parseDiff, getChangeKey, Diff} from 'react-diff-view';

const getWidgets = hunks => {
    const changes = hunks.reduce((result, {changes}) => [...result, ...changes], []);
    const longLines = changes.filter(({content}) => content.length > 120);
    return longLines.reduce(
        (widgets, change) => {
            const changeKey = getChangeKey(change);

            return {
                ...widgets,
                [changeKey]: <span className="error">Line too long</span>,
            };
        },
        {}
    );
};

const App = ({diffText}) => {
    const files = parseDiff(diffText);

    return (
        <div>
            {files.map(({hunks}, i) => <Diff key={i} hunks={hunks} widgets={getWidgets(hunks)} viewType="split" />)}
        </div>
    );
};

For a more complex case, you can reference the example in demo/File.js about implementing code comments with the widgets prop.

Customize styles

The basic theme of react-diff-view is simply "picked" from github, with some additional colors for column diffs, the style is located at react-diff-view/style/index.css, you can simply import this file in your project.

CSS variables

react-diff-view includes a bunch of css variables (custom properties) to customize the presentation of diff in supported browsers, these includes:

:root {
    --diff-background-color: initial;
    --diff-text-color: initial;
    --diff-font-family: Consolas, Courier, monospace;
    --diff-selection-background-color: #b3d7ff;
    --diff-selection-text-color: var(--diff-text-color);;
    --diff-gutter-insert-background-color: #d6fedb;
    --diff-gutter-insert-text-color: var(--diff-text-color);
    --diff-gutter-delete-background-color: #fadde0;
    --diff-gutter-delete-text-color: var(--diff-text-color);
    --diff-gutter-selected-background-color: #fffce0;
    --diff-gutter-selected-text-color: var(--diff-text-color);
    --diff-code-insert-background-color: #eaffee;
    --diff-code-insert-text-color: var(--diff-text-color);
    --diff-code-delete-background-color: #fdeff0;
    --diff-code-delete-text-color: var(--diff-text-color);
    --diff-code-insert-edit-background-color: #c0dc91;
    --diff-code-insert-edit-text-color: var(--diff-text-color);
    --diff-code-delete-edit-background-color: #f39ea2;
    --diff-code-delete-edit-text-color: var(--diff-text-color);
    --diff-code-selected-background-color: #fffce0;
    --diff-code-selected-text-color: var(--diff-text-color);
    --diff-omit-gutter-line-color: #cb2a1d;
}

For code highlight colors, we recommend prism-color-variables package which includes CSS variables of every refractor generated token type.

Class names

You can override styles on certain css classes to customize the appearance of react-diff-view, here is a list of css classes generated by component:

The diff-line-hover-(old|new) class is especially designed to tell the precise hover element in split mode, so it only applies when viewType is set to "split", if you want a style on a hovering change, the selector could be:

// Less selector to disable the line number and add an icon in the gutter element when a change is hovered
.diff-line-hover-old.diff-gutter,
.diff-line-hover-new.diff-gutter,
.diff-unified .diff-line:hover .diff-gutter {
    &::before {
        font-family: "FontAwesome";
        content: "\f4b2"; // comment-plus
    }
}

You can pass the className prop to a Diff component to add a custom class to the <table> element.

The Hunk component receives class name props as:

Similarly Decoration component also receives some props to customize class names:

Customize events

The Hunk component receives gutterEvents and codeEvents props to customize events on either gutter or code <td> element.

Both of the above prop is an object containing DOM events key/value pair.

Each event callback receive an object with key change and side, the side property is undefined in unified mode, in split mode it could be either "old" and "new" responding to the triggering element.

One of the common cases is to add code selecting functionality. This can be archived simply by passing an onClick event to gutter and coe and manipulating the selectedChanges prop:

import {useState, useCallback, useMemo} from 'react';

function File({hunks, diffType}) {
    const [selectedChanges, setSelectedChanges] = useState([]);
    const selectChange = useCallback(
        ({change}) => {
            const toggle = selectedChanges => {
                const index = selectedChanges.indexOf(change);
                if (index >= 0) {
                    return [
                        ...selectedChanges.slice(0, index),
                        ...selectedChanges.slice(index + 1),
                    ];
                }
                return [...selectedChanges, change];
            };
            setSelectedChanges(toggle);
        },
        []
    );
    const diffProps = useMemo(
        () => {
            return {
                gutterEvents: {onClick: selectChange},
                codeEvents: {onClick: selectChange},
            };
        },
        [selectChange]
    );

    return (
        <Diff viewType="split" diffType={diffType} hunks={hunks} {...diffProps}>
            {hunks => hunks.map(hunk => <Hunk key={hunk.content} hunk={hunk} />)}
        </Diff>
    );
}

Token system

Since the version 2.0.0 we introduce a powerful token system to provide enhancements such as code highlighting, special word marking, inline diff edits and more.

To find a very simple tokenization example including inline edits highlight please check out this sandbox. This example works in an synchronous way, however we'd like to recommend you utilize web workers to put tokenization process in background.

The token system is quite complicated internally, so we recommend to use only the exported tokenize function to parse and tokenize diffs.

The tokenize function accepts 2 arguments, the first is the hunks array, the second one is an options object containing many optional configurations:

The react-diff-view also ships with several common enhancers:

Edits

The markEdits(hunks, options) exported function will further diff the old and new text within a hunk, the differences between two sides will be marked a class name diff-code-edit.

The options object accepts properties:

It uses diff-match-patch as its internal implement.

Special words

The markWord(word, name, replacement) exported function enables you to mark some special word with 2 arguments:

Marked word will have a class name of diff-code-${name}.

It is better markEdits before markWord since it will not break inline highlight.

Pick ranges

The pickRanges(oldRanges, newRanges) exported function is a more low level function which helps you to pick some ranges of text from each line.

Each range is an object with properties:

By giving an array of ranges on both old and new side, the token system will pick them out of each line, you should pass a renderToken function prop to Diff component to customize your render implement of customized token. The renderToken is a simple function receiving (token, defaultRender) as arguments:

// Suppose we pick ranges of type `searchResult`
const renderToken = (token, defaultRender, i) => {
    if (token.type === 'searchResult') {
        return (
            <span key={i} className="search-result">
                {token.children && token.children.map((token, i) => renderToken(token, defaultRender, i))}
            </span>
        );
    }

    // For other types, use the default render function
    return defaultRender(token, i);
};

We can utilize enhancers to create effects above diffs, for example we want to enable inline diff edits, and highlight tab and carriage return characters, this can be done by:

import refractor from 'refractor';

const options = {
    highlight: true,
    refractor: refractor,
    oldSource: oldSource,
    language: 'jsx',
    enhancers: [
        markWord('\r', 'carriage-return'),
        markWord('\t', 'tab'),
        markEdits(hunks),
    ],
};

const tokens = tokenize(hunks, options);

The tokenize function can work inside a web worker so it does not block the user interface.

Customize gutter

By default, the gutter cell contains the line number of current change, however in many cases developers want a more complicated content in a gutter cell, we provided a renderGutter prop on Diff component to provide flexibility.

The renderGutter function prop will receive a single object argument with following properties:

const renderGutter = ({change, side, renderDefault, wrapInAnchor, inHoverState}) => {
    if (inHoverState) {
        return (
            <>
                {side === 'new' && <UnitTestCoverageBar change={change} />}
                <CommentButton change={change} />
            </>
        );
    }

    return (
        <>
            <UnitTestCoverageBar change={change} />
            {wrapInAnchor(renderDefault())}
        </>
    );
};

Utilities

react-diff-view comes with some utility functions to help simplify common issues:

Enjoy more with raw text provided

Once you can provide a rawCodeOrLines object (which can be a string, or an array of lines of code), there are many more utility function you can use to help organize hunks:

Unsupported

Wordwrap

No, there isn't a wordwrap configuration. Lines are automatically wrapped by default, and it is almost impossible to implement other wrap styles, unless we choose table-layout: auto which critically hinders the performance.

Any solutions for this issue are welcome.

Test

I don't really know how to test such a complicated and UI centric component, any help is welcome.

Related Libraries

Breaking Changes

2.x

Change Log

1.0.0

1.1.0

1.1.1

1.2.0

1.3.0

1.3.1

1.3.2

1.4.0

1.4.1

1.4.2

1.4.3

1.4.4

1.4.5

1.4.6

1.4.7

1.4.8

1.4.9

1.4.10

1.4.11

1.5.0

1.6.0

1.6.1

1.6.2

2.0.0

2.0.1

2.1.0

2.1.1

2.1.2

2.1.4

2.2.0

2.3.0

2.4.0

2.6.0