react-diff-viewer-continued
Version:
Continuation of a simple and beautiful text diff viewer component made with diff and React
560 lines (559 loc) • 24.4 kB
JavaScript
import * as diff from "diff";
import * as yaml from "js-yaml";
const jsDiff = diff;
export var DiffType;
(function (DiffType) {
DiffType[DiffType["DEFAULT"] = 0] = "DEFAULT";
DiffType[DiffType["ADDED"] = 1] = "ADDED";
DiffType[DiffType["REMOVED"] = 2] = "REMOVED";
DiffType[DiffType["CHANGED"] = 3] = "CHANGED";
})(DiffType || (DiffType = {}));
/**
* Stringify a value using the specified format.
*/
function stringify(val, format) {
if (format === 'yaml') {
return yaml.dump(val, { indent: 2, lineWidth: -1, noRefs: true }).trimEnd();
}
return JSON.stringify(val, null, 2);
}
/**
* Performs a fast structural diff on objects.
*
* Strategy: Use structural comparison to identify which subtrees changed,
* then use diffLines only on those changed subtrees. This avoids running
* the expensive O(ND) Myers diff on the entire content, while still producing
* proper line-by-line diffs for the parts that changed.
*/
function structuralDiff(oldObj, newObj, format = 'json') {
const oldStr = stringify(oldObj, format);
const newStr = stringify(newObj, format);
// Fast path: identical objects
if (oldStr === newStr) {
return [{ value: oldStr }];
}
// Use recursive structural diff that applies diffLines to changed subtrees
return diffStructurally(oldObj, newObj, 0, format);
}
/**
* JSON diff that preserves key order from each object.
* Uses structural comparison for performance.
*/
function structuralJsonDiff(oldObj, newObj) {
return structuralDiff(oldObj, newObj, 'json');
}
/**
* Optimized diff for JSON strings that preserves original formatting and key order.
* Uses parsing only to check for structural equality (fast path),
* then falls back to diffLines on original strings to preserve formatting.
*/
function structuralJsonStringDiff(oldJson, newJson) {
try {
// Parse JSON to check for structural equality
const oldObj = JSON.parse(oldJson);
const newObj = JSON.parse(newJson);
// Fast path: check if structurally identical by comparing serialized forms
const oldNormalized = JSON.stringify(oldObj);
const newNormalized = JSON.stringify(newObj);
if (oldNormalized === newNormalized) {
// Structurally identical - return original string to preserve formatting
return [{ value: oldJson }];
}
// Files differ - use diffLines on ORIGINAL strings to preserve key order and formatting
return diff.diffLines(oldJson, newJson, {
newlineIsToken: false,
});
}
catch (e) {
// If JSON parsing fails, fall back to line diff
return diff.diffLines(oldJson, newJson, {
newlineIsToken: false,
});
}
}
/**
* Optimized diff for YAML that preserves original line numbers.
* Uses parsing only to check for structural equality (fast path),
* then falls back to diffLines on original strings to preserve line numbers.
*/
function structuralYamlDiff(oldYaml, newYaml) {
// Parse YAML to check for structural equality
const oldObj = yaml.load(oldYaml);
const newObj = yaml.load(newYaml);
// Fast path: check if structurally identical by comparing serialized forms
const oldNormalized = yaml.dump(oldObj, { indent: 2, lineWidth: -1, noRefs: true });
const newNormalized = yaml.dump(newObj, { indent: 2, lineWidth: -1, noRefs: true });
if (oldNormalized === newNormalized) {
// Structurally identical - return original string to preserve formatting
return [{ value: oldYaml }];
}
// Files differ - use diffLines on ORIGINAL strings to preserve line numbers
return diff.diffLines(oldYaml, newYaml, {
newlineIsToken: false,
});
}
/**
* Recursively diff two values structurally.
* For unchanged parts, output as-is.
* For changed parts, use diffLines to get proper line-by-line diff.
*/
function diffStructurally(oldVal, newVal, indent, format = 'json') {
const oldStr = stringify(oldVal, format);
const newStr = stringify(newVal, format);
// Fast path: identical
if (oldStr === newStr) {
return [{ value: reindent(oldStr, indent, format) }];
}
// Both are objects - compare key by key
if (typeof oldVal === 'object' && oldVal !== null &&
typeof newVal === 'object' && newVal !== null &&
!Array.isArray(oldVal) && !Array.isArray(newVal)) {
return diffObjects(oldVal, newVal, indent, format);
}
// Both are arrays - compare element by element
if (Array.isArray(oldVal) && Array.isArray(newVal)) {
return diffArrays(oldVal, newVal, indent, format);
}
// Different types or primitives - use diffLines for proper diff
return diffWithLines(oldStr, newStr, indent, format);
}
/**
* Diff two objects key by key.
* Iterates in NEW key order to preserve the new file's structure,
* then appends any old-only keys as removed.
*/
function diffObjects(oldObj, newObj, indent, format = 'json') {
const changes = [];
const indentStr = ' '.repeat(indent);
const innerIndent = ' '.repeat(indent + 1);
const oldKeySet = new Set(Object.keys(oldObj));
const newKeys = Object.keys(newObj);
// Build ordered key list: new keys in their order, then old-only keys
const oldOnlyKeys = [...oldKeySet].filter(k => !(k in newObj));
const allKeys = [...newKeys, ...oldOnlyKeys];
changes.push({ value: '{\n' });
for (let i = 0; i < allKeys.length; i++) {
const key = allKeys[i];
const isLast = i === allKeys.length - 1;
const comma = isLast ? '' : ',';
const inOld = key in oldObj;
const inNew = key in newObj;
if (inOld && inNew) {
// Key in both - recursively diff values
const oldValStr = stringify(oldObj[key], format);
const newValStr = stringify(newObj[key], format);
if (oldValStr === newValStr) {
// Values identical
const valueStr = reindent(oldValStr, indent + 1);
changes.push({ value: innerIndent + JSON.stringify(key) + ': ' + valueStr + comma + '\n' });
}
else {
// Values differ - recursively diff them
const keyPrefix = innerIndent + JSON.stringify(key) + ': ';
const valueDiff = diffStructurally(oldObj[key], newObj[key], indent + 1, format);
// Prepend key to the appropriate changes
if (valueDiff.length > 0) {
if (!valueDiff[0].removed && !valueDiff[0].added) {
// First change is neutral (e.g., opening brace of nested object) - prepend key to it only
valueDiff[0].value = keyPrefix + valueDiff[0].value;
}
else {
// First change is removed or added - this is a primitive value change
// Both the removed (old) and added (new) lines need the key
const firstRemoved = valueDiff.find(c => c.removed);
const firstAdded = valueDiff.find(c => c.added);
if (firstRemoved)
firstRemoved.value = keyPrefix + firstRemoved.value;
if (firstAdded)
firstAdded.value = keyPrefix + firstAdded.value;
}
}
// Add comma to last change if needed
if (comma && valueDiff.length > 0) {
const last = valueDiff[valueDiff.length - 1];
last.value = last.value.replace(/\n$/, comma + '\n');
}
changes.push(...valueDiff);
}
}
else if (inOld) {
// Key only in old - removed
const valueStr = reindent(stringify(oldObj[key], format), indent + 1);
changes.push({ removed: true, value: innerIndent + JSON.stringify(key) + ': ' + valueStr + comma + '\n' });
}
else {
// Key only in new - added
const valueStr = reindent(stringify(newObj[key], format), indent + 1);
changes.push({ added: true, value: innerIndent + JSON.stringify(key) + ': ' + valueStr + comma + '\n' });
}
}
changes.push({ value: indentStr + '}\n' });
return changes;
}
/**
* Diff two arrays element by element.
*/
function diffArrays(oldArr, newArr, indent, format = 'json') {
const changes = [];
const indentStr = ' '.repeat(indent);
const innerIndent = ' '.repeat(indent + 1);
changes.push({ value: '[\n' });
const maxLen = Math.max(oldArr.length, newArr.length);
for (let i = 0; i < maxLen; i++) {
const isLast = i === maxLen - 1;
const comma = isLast ? '' : ',';
if (i >= oldArr.length) {
// Element only in new - added
const valueStr = reindent(stringify(newArr[i], format), indent + 1);
changes.push({ added: true, value: innerIndent + valueStr + comma + '\n' });
}
else if (i >= newArr.length) {
// Element only in old - removed
const valueStr = reindent(stringify(oldArr[i], format), indent + 1);
changes.push({ removed: true, value: innerIndent + valueStr + comma + '\n' });
}
else {
// Element in both - recursively diff
const oldElemStr = stringify(oldArr[i], format);
const newElemStr = stringify(newArr[i], format);
if (oldElemStr === newElemStr) {
// Elements identical
const valueStr = reindent(oldElemStr, indent + 1);
changes.push({ value: innerIndent + valueStr + comma + '\n' });
}
else {
// Elements differ - recursively diff them
const elemDiff = diffStructurally(oldArr[i], newArr[i], indent + 1, format);
// Prepend indent to first change so they're on the same line
if (elemDiff.length > 0) {
elemDiff[0].value = innerIndent + elemDiff[0].value;
}
// Add comma to last change if needed
if (comma && elemDiff.length > 0) {
const last = elemDiff[elemDiff.length - 1];
last.value = last.value.replace(/\n$/, comma + '\n');
}
changes.push(...elemDiff);
}
}
}
changes.push({ value: indentStr + ']\n' });
return changes;
}
/**
* Use diffLines for proper line-by-line diff of two strings.
* This is the fallback for when structural comparison finds different values.
*/
function diffWithLines(oldStr, newStr, indent, _format = 'json') {
const oldIndented = reindent(oldStr, indent);
const newIndented = reindent(newStr, indent);
// Use diffLines for proper line-level comparison
const lineDiff = diff.diffLines(oldIndented, newIndented);
return lineDiff.map(change => ({
value: change.value,
added: change.added,
removed: change.removed
}));
}
/**
* Re-indent a string to the specified level.
*/
function reindent(str, indent, _format = 'json') {
if (indent === 0)
return str;
const indentStr = ' '.repeat(indent);
return str.split('\n').map((line, i) => i === 0 ? line : indentStr + line).join('\n');
}
// See https://github.com/kpdecker/jsdiff/tree/v4.0.1#api for more info on the below JsDiff methods
export var DiffMethod;
(function (DiffMethod) {
DiffMethod["CHARS"] = "diffChars";
DiffMethod["WORDS"] = "diffWords";
DiffMethod["WORDS_WITH_SPACE"] = "diffWordsWithSpace";
DiffMethod["LINES"] = "diffLines";
DiffMethod["TRIMMED_LINES"] = "diffTrimmedLines";
DiffMethod["SENTENCES"] = "diffSentences";
DiffMethod["CSS"] = "diffCss";
DiffMethod["JSON"] = "diffJson";
DiffMethod["YAML"] = "diffYaml";
})(DiffMethod || (DiffMethod = {}));
/**
* Splits diff text by new line and computes final list of diff lines based on
* conditions.
*
* @param value Diff text from the js diff module.
*/
const constructLines = (value) => {
if (value === "")
return [];
const lines = value.replace(/\n$/, "").split("\n");
return lines;
};
/**
* Computes word diff information in the line.
* [TODO]: Consider adding options argument for JsDiff text block comparison
*
* @param oldValue Old word in the line.
* @param newValue New word in the line.
* @param compareMethod JsDiff text diff method from https://github.com/kpdecker/jsdiff/tree/v4.0.1#api
*/
const computeDiff = (oldValue, newValue, compareMethod = DiffMethod.CHARS) => {
const compareFunc = typeof compareMethod === "string" ? jsDiff[compareMethod] : compareMethod;
const diffArray = compareFunc(oldValue, newValue);
const computedDiff = {
left: [],
right: [],
};
diffArray.forEach(({ added, removed, value }) => {
const diffInformation = {};
if (added) {
diffInformation.type = DiffType.ADDED;
diffInformation.value = value;
computedDiff.right.push(diffInformation);
}
if (removed) {
diffInformation.type = DiffType.REMOVED;
diffInformation.value = value;
computedDiff.left.push(diffInformation);
}
if (!removed && !added) {
diffInformation.type = DiffType.DEFAULT;
diffInformation.value = value;
computedDiff.right.push(diffInformation);
computedDiff.left.push(diffInformation);
}
return diffInformation;
});
return computedDiff;
};
/**
* [TODO]: Think about moving common left and right value assignment to a
* common place. Better readability?
*
* Computes line wise information based in the js diff information passed. Each
* line contains information about left and right section. Left side denotes
* deletion and right side denotes addition.
*
* @param oldString Old string to compare.
* @param newString New string to compare with old string.
* @param disableWordDiff Flag to enable/disable word diff.
* @param lineCompareMethod JsDiff text diff method from https://github.com/kpdecker/jsdiff/tree/v4.0.1#api
* @param linesOffset line number to start counting from
* @param showLines lines that are always shown, regardless of diff
*/
const computeLineInformation = (oldString, newString, disableWordDiff = false, lineCompareMethod = DiffMethod.CHARS, linesOffset = 0, showLines = [], deferWordDiff = false) => {
let diffArray = [];
// Handle different input types and compare methods
if (typeof oldString === "string" && typeof newString === "string") {
// Check if we should use structural diff for JSON or YAML
if (lineCompareMethod === DiffMethod.JSON) {
// Use JSON structural diff - preserves original formatting and key order
diffArray = structuralJsonStringDiff(oldString, newString);
}
else if (lineCompareMethod === DiffMethod.YAML) {
try {
// Use YAML structural diff - parses, normalizes, and outputs as YAML
diffArray = structuralYamlDiff(oldString, newString);
}
catch (e) {
// If YAML parsing fails, fall back to line diff
diffArray = diff.diffLines(oldString, newString, {
newlineIsToken: false,
});
}
}
else {
diffArray = diff.diffLines(oldString, newString, {
newlineIsToken: false,
});
}
}
else {
// Use our fast structural JSON diff instead of diff.diffJson
// This is O(n) for structure comparison vs O(ND) for Myers on large strings
diffArray = structuralJsonDiff(oldString, newString);
}
let rightLineNumber = linesOffset;
let leftLineNumber = linesOffset;
let lineInformation = [];
let counter = 0;
const diffLines = [];
const ignoreDiffIndexes = [];
const getLineInformation = (value, diffIndex, added, removed, evaluateOnlyFirstLine) => {
const lines = constructLines(value);
return lines
.map((line, lineIndex) => {
const left = {};
const right = {};
if (ignoreDiffIndexes.includes(`${diffIndex}-${lineIndex}`) ||
(evaluateOnlyFirstLine && lineIndex !== 0)) {
return undefined;
}
if (added || removed) {
let countAsChange = true;
if (removed) {
leftLineNumber += 1;
left.lineNumber = leftLineNumber;
left.type = DiffType.REMOVED;
left.value = line || " ";
// When the current line is of type REMOVED, check the next item in
// the diff array whether it is of type ADDED. If true, the current
// diff will be marked as both REMOVED and ADDED. Meaning, the
// current line is a modification.
const nextDiff = diffArray[diffIndex + 1];
if (nextDiff?.added) {
const nextDiffLines = constructLines(nextDiff.value)[lineIndex];
if (nextDiffLines) {
const nextDiffLineInfo = getLineInformation(nextDiffLines, diffIndex, true, false, true);
const { value: rightValue, lineNumber, type, } = nextDiffLineInfo[0].right;
// When identified as modification, push the next diff to ignore
// list as the next value will be added in this line computation as
// right and left values.
ignoreDiffIndexes.push(`${diffIndex + 1}-${lineIndex}`);
right.lineNumber = lineNumber;
if (left.value === rightValue) {
// The new value is exactly the same as the old
countAsChange = false;
right.type = 0;
left.type = 0;
right.value = rightValue;
}
else {
right.type = type;
// Do char level diff and assign the corresponding values to the
// left and right diff information object.
// Skip word diff for very long lines (>500 chars) to avoid performance issues
const MAX_LINE_LENGTH_FOR_WORD_DIFF = 500;
const lineIsTooLong = line.length > MAX_LINE_LENGTH_FOR_WORD_DIFF ||
rightValue.length > MAX_LINE_LENGTH_FOR_WORD_DIFF;
if (disableWordDiff || lineIsTooLong) {
right.value = rightValue;
}
else if (deferWordDiff) {
// Store raw values for deferred word diff computation
left.rawValue = line;
left.value = line;
right.rawValue = rightValue;
right.value = rightValue;
}
else {
const computedDiff = computeDiff(line, rightValue, lineCompareMethod);
right.value = computedDiff.right;
left.value = computedDiff.left;
}
}
}
}
}
else {
rightLineNumber += 1;
right.lineNumber = rightLineNumber;
right.type = DiffType.ADDED;
right.value = line;
}
if (countAsChange && !evaluateOnlyFirstLine) {
if (!diffLines.includes(counter)) {
diffLines.push(counter);
}
}
}
else {
leftLineNumber += 1;
rightLineNumber += 1;
left.lineNumber = leftLineNumber;
left.type = DiffType.DEFAULT;
left.value = line;
right.lineNumber = rightLineNumber;
right.type = DiffType.DEFAULT;
right.value = line;
}
if (showLines?.includes(`L-${left.lineNumber}`) ||
(showLines?.includes(`R-${right.lineNumber}`) &&
!diffLines.includes(counter))) {
diffLines.push(counter);
}
if (!evaluateOnlyFirstLine) {
counter += 1;
}
return { right, left };
})
.filter(Boolean);
};
diffArray.forEach(({ added, removed, value }, index) => {
lineInformation = [
...lineInformation,
...getLineInformation(value, index, added, removed),
];
});
return {
lineInformation,
diffLines,
};
};
/**
* Computes line diff information using a Web Worker to avoid blocking the UI thread.
* This offloads the expensive `computeLineInformation` logic to a separate thread.
*
* @param oldString Old string to compare.
* @param newString New string to compare with old string.
* @param disableWordDiff Flag to enable/disable word diff.
* @param lineCompareMethod JsDiff text diff method from https://github.com/kpdecker/jsdiff/tree/v4.0.1#api
* @param linesOffset line number to start counting from
* @param showLines lines that are always shown, regardless of diff
* @returns Promise<ComputedLineInformation> - Resolves with line-by-line diff data from the worker.
*/
// Import the bundled worker code (generated by scripts/build-worker.js)
import { WORKER_CODE } from './workerBundle.js';
// Cached Blob URL for the worker - created once and reused
let workerBlobUrl = null;
let workerAvailable = null;
// Create a Blob URL from the bundled worker code
const getWorkerBlobUrl = () => {
if (workerBlobUrl !== null)
return workerBlobUrl;
if (typeof Worker === 'undefined' || typeof Blob === 'undefined' || typeof URL === 'undefined') {
workerAvailable = false;
return null;
}
try {
const blob = new Blob([WORKER_CODE], { type: 'application/javascript' });
workerBlobUrl = URL.createObjectURL(blob);
workerAvailable = true;
}
catch {
workerAvailable = false;
workerBlobUrl = null;
}
return workerBlobUrl;
};
const computeLineInformationWorker = (oldString, newString, disableWordDiff = false, lineCompareMethod = DiffMethod.CHARS, linesOffset = 0, showLines = [], deferWordDiff = false) => {
const fallback = () => computeLineInformation(oldString, newString, disableWordDiff, lineCompareMethod, linesOffset, showLines, deferWordDiff);
const blobUrl = getWorkerBlobUrl();
if (!blobUrl) {
return Promise.resolve(fallback());
}
return new Promise((resolve) => {
let worker;
try {
worker = new Worker(blobUrl);
}
catch {
// Worker instantiation failed - fall back to synchronous computation
workerAvailable = false;
resolve(fallback());
return;
}
worker.onmessage = (e) => {
resolve(e.data);
worker.terminate();
};
worker.onerror = () => {
// Worker error - fall back and mark as unavailable for future calls
workerAvailable = false;
worker.terminate();
resolve(fallback());
};
worker.postMessage({ oldString, newString, disableWordDiff, lineCompareMethod, linesOffset, showLines, deferWordDiff });
});
};
export { computeLineInformation, computeLineInformationWorker, computeDiff };