/**
* SplitDiffViewer
*
* A line-by-line split diff viewer for comparing two values.
* Shows PREV on the left and CUR on the right with word-level highlighting.
* This is a dumb component - all state is controlled externally.
*/
import React, { Fragment, useMemo, memo } from "react";
import { View, Text, ScrollView, StyleSheet } from "react-native";
import {
computeLineDiff,
DiffType,
LineDiffInfo,
WordDiff,
DiffComputeOptions,
} from "./lineDiff";
import { DiffSummary } from "./DiffSummary";
import { DiffTheme, devToolsDefaultTheme } from "./diffThemes";
export interface SplitDiffViewerOptions {
/** Hide line numbers */
hideLineNumbers?: boolean;
/** Disable word-level diff highlighting */
disableWordDiff?: boolean;
/** Only show changed lines with context */
showDiffOnly?: boolean;
/** Comparison method */
compareMethod?: "chars" | "words" | "lines" | "trimmedLines";
/** Number of context lines when showDiffOnly is true */
contextLines?: number;
}
export interface SplitDiffViewerProps {
/** Old (previous) value to compare */
oldValue: unknown;
/** New (current) value to compare */
newValue: unknown;
/** Theme for styling (defaults to devToolsDefault) */
theme?: DiffTheme;
/** Diff options */
options?: SplitDiffViewerOptions;
/** Show theme name badge */
showThemeName?: boolean;
/** Fixed height (defaults to 400) */
height?: number;
}
/**
* SplitDiffViewer displays a side-by-side diff comparison.
*/
export const SplitDiffViewer = memo(function SplitDiffViewer({
oldValue,
newValue,
theme = devToolsDefaultTheme,
options = {},
showThemeName = false,
height = 400,
}: SplitDiffViewerProps) {
const {
hideLineNumbers = false,
disableWordDiff = false,
showDiffOnly = false,
compareMethod = "words",
contextLines = 3,
} = options;
// Compute line-by-line diff with options
const diffComputeOptions: DiffComputeOptions = {
compareMethod,
disableWordDiff,
showDiffOnly,
contextLines,
};
const lineDiffs = useMemo(
() => computeLineDiff(oldValue, newValue, diffComputeOptions),
[oldValue, newValue, compareMethod, disableWordDiff, showDiffOnly, contextLines]
);
// Create dynamic styles based on theme
const dynamicStyles = useMemo(() => createDynamicStyles(theme, height), [theme, height]);
// Render word diff content
const renderWordDiff = (wordDiffs: WordDiff[]) => {
return wordDiffs.map((word, idx) => {
let backgroundColor = "transparent";
if (word.type === DiffType.ADDED) {
backgroundColor = theme.addedWordHighlight;
} else if (word.type === DiffType.REMOVED) {
backgroundColor = theme.removedWordHighlight;
}
return (
{word.value}
);
});
};
// Get colors for diff type
const getDiffColors = (type: DiffType, isModified: boolean = false) => {
if (isModified) {
return {
background: theme.modifiedBackground,
text: theme.modifiedText,
markerBg: theme.markerModifiedBackground,
};
}
switch (type) {
case DiffType.ADDED:
return {
background: theme.addedBackground,
text: theme.addedText,
markerBg: theme.markerAddedBackground,
};
case DiffType.REMOVED:
return {
background: theme.removedBackground,
text: theme.removedText,
markerBg: theme.markerRemovedBackground,
};
case DiffType.DEFAULT:
return {
background: theme.unchangedBackground,
text: theme.unchangedText,
markerBg: "transparent",
};
default:
return {
background: theme.unchangedBackground,
text: theme.unchangedText,
markerBg: "transparent",
};
}
};
// Render a single line side (left or right)
const renderLineSide = (
lineNumber: number | undefined,
content: string | WordDiff[] | undefined,
type: DiffType,
marker: string,
isEmpty: boolean = false
) => {
if (isEmpty) {
return (
<>
{!hideLineNumbers && (
)}
>
);
}
const colors = getDiffColors(type);
return (
<>
{/* Line number gutter */}
{!hideLineNumbers && (
{lineNumber || " "}
)}
{/* Change marker */}
{marker}
{/* Content */}
{Array.isArray(content) ? renderWordDiff(content) : content || " "}
>
);
};
// Check if we should show a separator (gap in line numbers)
const shouldShowSeparator = (idx: number, diffs: LineDiffInfo[]) => {
if (!showDiffOnly || idx === 0) return false;
const prevDiff = diffs[idx - 1];
const currDiff = diffs[idx];
// Check for gap in line numbers
const leftGap =
currDiff.leftLineNumber &&
prevDiff.leftLineNumber &&
currDiff.leftLineNumber - prevDiff.leftLineNumber > 1;
const rightGap =
currDiff.rightLineNumber &&
prevDiff.rightLineNumber &&
currDiff.rightLineNumber - prevDiff.rightLineNumber > 1;
return leftGap || rightGap;
};
// Render a complete row with both left and right sides
const renderDiffRow = (
diff: LineDiffInfo,
idx: number,
diffs: LineDiffInfo[]
) => {
const isRemoved = diff.type === DiffType.REMOVED;
const isAdded = diff.type === DiffType.ADDED;
const isModified = diff.type === DiffType.MODIFIED;
const isDefault = diff.type === DiffType.DEFAULT;
return (
{shouldShowSeparator(idx, diffs) && (
• • •
)}
{/* Left side (PREV) */}
{isRemoved || isModified || isDefault
? renderLineSide(
diff.leftLineNumber,
diff.leftContent,
isModified ? DiffType.REMOVED : diff.type,
isRemoved || isModified ? "-" : " "
)
: renderLineSide(
undefined,
undefined,
DiffType.DEFAULT,
" ",
true
)}
{/* Center divider */}
{/* Right side (CUR) */}
{isAdded || isModified || isDefault
? renderLineSide(
diff.rightLineNumber,
diff.rightContent,
isModified ? DiffType.ADDED : diff.type,
isAdded || isModified ? "+" : " "
)
: renderLineSide(
undefined,
undefined,
DiffType.DEFAULT,
" ",
true
)}
);
};
// Count diff statistics
const addedCount = lineDiffs.filter((d) => d.type === DiffType.ADDED).length;
const removedCount = lineDiffs.filter((d) => d.type === DiffType.REMOVED).length;
const modifiedCount = lineDiffs.filter((d) => d.type === DiffType.MODIFIED).length;
return (
0.7
? 0.5
: theme.neonIntensity && theme.neonIntensity > 0.3
? 0.3
: 0.1,
shadowRadius:
theme.neonIntensity && theme.neonIntensity > 0.7
? 10
: theme.neonIntensity && theme.neonIntensity > 0.3
? 5
: 2,
},
]}
>
{showThemeName && (
{theme.name}
{theme.description}
)}
{/* Header */}
PREV
CUR
{/* Summary bar (top) */}
{/* Single ScrollView for both sides */}
{lineDiffs.length === 0 ? (
{showDiffOnly ? "No differences found" : "No content to display"}
) : (
lineDiffs.map((diff, idx) => renderDiffRow(diff, idx, lineDiffs))
)}
);
});
// Create dynamic styles based on theme
function createDynamicStyles(theme: DiffTheme, height: number) {
return StyleSheet.create({
container: {
height,
backgroundColor: theme.background,
borderRadius: 8,
overflow: "hidden",
borderWidth: 1,
borderColor: theme.borderColor,
},
themeBadge: {
backgroundColor: theme.panelBackground,
paddingHorizontal: 12,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: theme.borderColor,
},
themeName: {
fontSize: 12,
fontWeight: "700",
color: theme.accentColor || theme.unchangedText,
fontFamily: "monospace",
letterSpacing: 0.5,
},
themeDescription: {
fontSize: 10,
color: theme.unchangedText,
fontFamily: "monospace",
marginTop: 2,
opacity: 0.8,
},
header: {
flexDirection: "row",
backgroundColor: theme.headerBackground,
borderBottomWidth: 1,
borderBottomColor: theme.borderColor,
},
headerLeft: {
flex: 1,
paddingVertical: 8,
paddingHorizontal: 12,
},
headerRight: {
flex: 1,
paddingVertical: 8,
paddingHorizontal: 12,
},
headerTitle: {
fontSize: 10,
fontWeight: "700",
color: theme.accentColor || theme.unchangedText,
fontFamily: "monospace",
letterSpacing: 0.5,
textTransform: "uppercase",
},
divider: {
width: 1,
backgroundColor: theme.dividerColor,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 10,
},
row: {
flexDirection: "row",
minHeight: 20,
},
leftSide: {
flex: 1,
flexDirection: "row",
},
rightSide: {
flex: 1,
flexDirection: "row",
},
centerDivider: {
width: 1,
backgroundColor: theme.dividerColor,
},
gutter: {
width: 35,
paddingHorizontal: 4,
justifyContent: "center",
alignItems: "flex-end",
backgroundColor: theme.lineNumberBackground,
borderRightWidth: 1,
borderRightColor: theme.lineNumberBorder,
},
emptyGutter: {
backgroundColor: theme.contextBackground,
},
lineNumber: {
fontSize: 9,
fontFamily: "monospace",
color: theme.lineNumberText,
},
marker: {
width: 20,
justifyContent: "center",
alignItems: "center",
},
emptyMarker: {
backgroundColor: theme.contextBackground,
},
markerText: {
fontSize: 10,
fontFamily: "monospace",
fontWeight: "600",
},
contentCell: {
flex: 1,
paddingHorizontal: 8,
paddingVertical: 2,
},
emptyContent: {
backgroundColor: theme.contextBackground,
},
content: {
fontSize: 10,
fontFamily: "monospace",
lineHeight: 16,
},
wordDiff: {
fontSize: 10,
fontFamily: "monospace",
},
separator: {
height: 20,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.separatorBackground,
},
separatorText: {
fontSize: 8,
color: theme.separatorText,
fontFamily: "monospace",
letterSpacing: 2,
},
emptyState: {
padding: 40,
alignItems: "center",
justifyContent: "center",
},
emptyText: {
fontSize: 11,
color: theme.emptyStateText,
fontStyle: "italic",
fontFamily: "monospace",
},
});
}