// @ts-nocheck
/**
* Tree Diff Viewer Component
*
* A React Native diff viewer that displays changes in a hierarchical tree structure
* Shows added (+), removed (−), and changed (≈) items with visual indicators
*
* Usage:
*
*/
import React, { useMemo, useState, useEffect } from "react";
import {
View,
Text,
ScrollView,
StyleSheet,
TouchableOpacity,
} from "react-native";
import { gameUIColors } from "@react-buoy/shared-ui";
// ============================================
// TYPES & INTERFACES
// ============================================
type DiffType = "added" | "removed" | "changed" | "unchanged";
interface DiffNode {
key: string;
path: string[];
type: DiffType;
oldValue?: any;
newValue?: any;
children?: DiffNode[];
expanded?: boolean;
}
interface Theme {
background: string;
text: string;
addedBg: string;
addedText: string;
removedBg: string;
removedText: string;
changedBg: string;
changedText: string;
addedWordBg: string;
removedWordBg: string;
arrowText: string;
keyText: string;
expandIcon: string;
bracketText: string;
nullText: string;
undefinedText: string;
stringText: string;
numberText: string;
booleanText: string;
}
// ============================================
// THEMES
// ============================================
const darkTheme: Theme = {
background: gameUIColors.diff.lineNumberBackground, // Exact from gameUIColors
text: gameUIColors.diff.unchangedText, // Exact from gameUIColors
// Line backgrounds - exact from gameUIColors
addedBg: gameUIColors.diff.addedBackground,
removedBg: gameUIColors.diff.removedBackground,
changedBg: gameUIColors.diff.modifiedBackground,
// Text colors - exact from gameUIColors
addedText: gameUIColors.diff.addedText,
removedText: gameUIColors.diff.removedText,
changedText: gameUIColors.diff.modifiedText,
// Word highlights - darker backgrounds for text
addedWordBg: gameUIColors.diff.addedWordHighlight,
removedWordBg: gameUIColors.diff.removedWordHighlight,
// UI elements
arrowText: gameUIColors.diff.modifiedText,
keyText: gameUIColors.diff.modifiedText,
expandIcon: gameUIColors.diff.lineNumberText,
bracketText: gameUIColors.diff.unchangedText,
nullText: gameUIColors.diff.modifiedText,
undefinedText: gameUIColors.diff.modifiedText,
stringText: gameUIColors.diff.unchangedText,
numberText: gameUIColors.diff.unchangedText,
booleanText: gameUIColors.diff.modifiedText,
};
const lightTheme: Theme = {
background: "#ffffff",
text: "#333333",
addedBg: "rgba(40, 167, 69, 0.1)",
addedText: "#28A745",
removedBg: "rgba(220, 53, 69, 0.1)",
removedText: "#DC3545",
changedBg: "rgba(255, 193, 7, 0.1)",
changedText: "#FFC107",
arrowText: "#007BFF",
keyText: "#0451A5",
expandIcon: "#6A737D",
bracketText: "#6A737D",
nullText: "#0000FF",
undefinedText: "#0000FF",
stringText: "#A31515",
numberText: "#098658",
booleanText: "#0000FF",
};
// ============================================
// DIFF COMPUTATION
// ============================================
// Removed unused getType function
function isObject(obj: any): boolean {
return obj !== null && typeof obj === "object" && !Array.isArray(obj);
}
function isEqual(a: any, b: any): boolean {
if (a === b) return true;
if (a === null || b === null) return false;
if (a === undefined || b === undefined) return false;
if (typeof a !== typeof b) return false;
if (typeof a === "object") {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((val, idx) => isEqual(val, b[idx]));
}
if (isObject(a) && isObject(b)) {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key) => isEqual(a[key], b[key]));
}
}
return false;
}
function computeDiff(
oldValue: any,
newValue: any,
path: string[] = []
): DiffNode[] {
const result: DiffNode[] = [];
// Handle primitives and null/undefined
if (
!isObject(oldValue) &&
!isObject(newValue) &&
!Array.isArray(oldValue) &&
!Array.isArray(newValue)
) {
if (!isEqual(oldValue, newValue)) {
return [
{
key: path.length > 0 ? path[path.length - 1] : "root",
path,
type:
oldValue === undefined
? "added"
: newValue === undefined
? "removed"
: "changed",
oldValue,
newValue,
},
];
}
return [
{
key: path.length > 0 ? path[path.length - 1] : "root",
path,
type: "unchanged",
oldValue,
newValue,
},
];
}
// Handle arrays
if (Array.isArray(oldValue) || Array.isArray(newValue)) {
const oldArray = Array.isArray(oldValue) ? oldValue : [];
const newArray = Array.isArray(newValue) ? newValue : [];
const maxLength = Math.max(oldArray.length, newArray.length);
for (let i = 0; i < maxLength; i++) {
const itemPath = [...path, `[${i}]`];
const oldItem = i < oldArray.length ? oldArray[i] : undefined;
const newItem = i < newArray.length ? newArray[i] : undefined;
if (oldItem === undefined) {
// Added array item: if complex, include children so it can expand
const isComplex = Array.isArray(newItem) || isObject(newItem);
result.push({
key: `[${i}]`,
path: itemPath,
type: "added",
newValue: newItem,
...(isComplex
? {
children: computeDiff(
Array.isArray(newItem) ? [] : {},
newItem,
itemPath
),
expanded: false,
}
: {}),
});
} else if (newItem === undefined) {
// Removed array item: if complex, include children so it can expand
const isComplex = Array.isArray(oldItem) || isObject(oldItem);
result.push({
key: `[${i}]`,
path: itemPath,
type: "removed",
oldValue: oldItem,
...(isComplex
? {
children: computeDiff(
oldItem,
Array.isArray(oldItem) ? [] : {},
itemPath
),
expanded: false,
}
: {}),
});
} else if (!isEqual(oldItem, newItem)) {
if (
isObject(oldItem) ||
isObject(newItem) ||
Array.isArray(oldItem) ||
Array.isArray(newItem)
) {
result.push({
key: `[${i}]`,
path: itemPath,
type: "changed",
oldValue: oldItem,
newValue: newItem,
children: computeDiff(oldItem, newItem, itemPath),
expanded: false,
});
} else {
result.push({
key: `[${i}]`,
path: itemPath,
type: "changed",
oldValue: oldItem,
newValue: newItem,
});
}
} else {
const isComplex = Array.isArray(oldItem) || isObject(oldItem);
result.push({
key: `[${i}]`,
path: itemPath,
type: "unchanged",
oldValue: oldItem,
newValue: newItem,
...(isComplex
? {
children: computeDiff(oldItem, newItem, itemPath),
expanded: false,
}
: {}),
});
}
}
return result;
}
// Handle objects
const oldObj = isObject(oldValue) ? oldValue : {};
const newObj = isObject(newValue) ? newValue : {};
const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
for (const key of allKeys) {
const keyPath = [...path, key];
const oldVal = oldObj[key];
const newVal = newObj[key];
if (!(key in oldObj)) {
// Added key: if complex, include children so it can expand
const isComplex = Array.isArray(newVal) || isObject(newVal);
result.push({
key,
path: keyPath,
type: "added",
newValue: newVal,
...(isComplex
? {
children: computeDiff(
Array.isArray(newVal) ? [] : {},
newVal,
keyPath
),
expanded: false,
}
: {}),
});
} else if (!(key in newObj)) {
// Removed key: if complex, include children so it can expand
const isComplex = Array.isArray(oldVal) || isObject(oldVal);
result.push({
key,
path: keyPath,
type: "removed",
oldValue: oldVal,
...(isComplex
? {
children: computeDiff(
oldVal,
Array.isArray(oldVal) ? [] : {},
keyPath
),
expanded: false,
}
: {}),
});
} else if (!isEqual(oldVal, newVal)) {
if (
isObject(oldVal) ||
isObject(newVal) ||
Array.isArray(oldVal) ||
Array.isArray(newVal)
) {
result.push({
key,
path: keyPath,
type: "changed",
oldValue: oldVal,
newValue: newVal,
children: computeDiff(oldVal, newVal, keyPath),
expanded: false,
});
} else {
result.push({
key,
path: keyPath,
type: "changed",
oldValue: oldVal,
newValue: newVal,
});
}
} else {
const isComplex = Array.isArray(oldVal) || isObject(oldVal);
result.push({
key,
path: keyPath,
type: "unchanged",
oldValue: oldVal,
newValue: newVal,
...(isComplex
? {
children: computeDiff(oldVal, newVal, keyPath),
expanded: false,
}
: {}),
});
}
}
return result;
}
// ============================================
// VALUE RENDERING
// ============================================
function stringifyValue(value: any, compact: boolean = true): string {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "string") {
// Truncate long strings for better readability
if (compact && value.length > 30) {
return `"${value.substring(0, 27)}..."`;
}
return `"${value}"`;
}
if (typeof value === "number") {
// Format large numbers with commas for readability
return value.toLocaleString();
}
if (typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
if (compact) {
const count = value.length;
return count === 0 ? "[ ]" : `[ ${count} item${count !== 1 ? "s" : ""} ]`;
}
return JSON.stringify(value, null, 2);
}
if (isObject(value)) {
if (compact) {
const keys = Object.keys(value).length;
return keys === 0 ? "{ }" : `{ ${keys} key${keys !== 1 ? "s" : ""} }`;
}
return JSON.stringify(value, null, 2);
}
return String(value);
}
// ============================================
// MAIN COMPONENT
// ============================================
interface TreeDiffViewerProps {
oldValue: any;
newValue: any;
theme?: "dark" | "light";
expandAll?: boolean;
showUnchanged?: boolean;
}
export default function TreeDiffViewer({
oldValue,
newValue,
theme: themeName = "dark",
expandAll = false,
showUnchanged = true,
}: TreeDiffViewerProps) {
const theme = themeName === "dark" ? darkTheme : lightTheme;
// Initialize with first-level items expanded
const [expandedPaths, setExpandedPaths] = useState>(() => {
const initialExpanded = new Set();
// Auto-expand first level items
const rootDiff = computeDiff(oldValue, newValue);
rootDiff.forEach((node) => {
if (node.children && node.children.length > 0) {
initialExpanded.add(node.path.join("."));
}
});
return initialExpanded;
});
const diffTree = useMemo(() => {
const rootDiff = computeDiff(oldValue, newValue);
return rootDiff;
}, [oldValue, newValue]);
// Auto-expand all first-level nodes whenever the compared values change
useEffect(() => {
const initial = new Set();
diffTree.forEach((node) => {
if (node.children && node.children.length > 0) {
initial.add(node.path.join("."));
}
});
setExpandedPaths(initial);
}, [diffTree]);
const toggleExpanded = (path: string[]) => {
const pathStr = path.join(".");
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(pathStr)) {
next.delete(pathStr);
} else {
next.add(pathStr);
}
return next;
});
};
// Track line numbers globally for the entire tree
let globalLineNumber = 0;
const renderDiffNode = (
node: DiffNode,
depth: number = 0
): React.ReactNode => {
if (!showUnchanged && node.type === "unchanged") {
return null;
}
globalLineNumber++;
const currentLine = globalLineNumber;
const indent = depth * 20;
const isExpanded = expandAll || expandedPaths.has(node.path.join("."));
const hasChildren = node.children && node.children.length > 0;
// Row background matches DEFAULT viewer behavior
const getNodeStyle = () => {
switch (node.type) {
case "added":
return { backgroundColor: theme.addedBg };
case "removed":
return { backgroundColor: theme.removedBg };
case "changed":
return { backgroundColor: theme.changedBg };
default:
return { backgroundColor: "transparent" };
}
};
// Removed unused getTextColor function
// Get the marker (+, -, ~) for the diff type
const getMarker = () => {
switch (node.type) {
case "added":
return "+";
case "removed":
return "−"; // Use proper minus sign
case "changed":
return "≈"; // Use proper approximation sign
default:
return " ";
}
};
// Get marker colors
const getMarkerStyle = () => {
switch (node.type) {
case "added":
return {
backgroundColor: gameUIColors.diff.markerAddedBackground,
color: gameUIColors.diff.addedText,
};
case "removed":
return {
backgroundColor: gameUIColors.diff.markerRemovedBackground,
color: gameUIColors.diff.removedText,
};
case "changed":
return {
backgroundColor: gameUIColors.diff.markerModifiedBackground,
color: gameUIColors.diff.modifiedText,
};
default:
return {
backgroundColor: "transparent",
color: gameUIColors.diff.markerText,
};
}
};
return (
toggleExpanded(node.path) : undefined}
style={[styles.row, getNodeStyle()]}
>
{String(currentLine).padStart(2, " ")}
{getMarker()}
{hasChildren && (
{isExpanded ? "−" : "+"}
)}
{node.key}
:
{node.type === "changed" && !hasChildren && (
<>
{stringifyValue(node.oldValue)}
{" => "}
{stringifyValue(node.newValue)}
>
)}
{node.type === "added" && !hasChildren && (
{stringifyValue(node.newValue)}
)}
{node.type === "removed" && !hasChildren && (
{stringifyValue(node.oldValue)}
)}
{node.type === "unchanged" && !hasChildren && (
{stringifyValue(node.oldValue)}
)}
{hasChildren && !isExpanded && (
<>
{node.type === "changed" && (
<>
{stringifyValue(node.oldValue, true)}
{" => "}
{stringifyValue(node.newValue, true)}
>
)}
{node.type === "added" && (
{stringifyValue(node.newValue, true)}
)}
{node.type === "removed" && (
{stringifyValue(node.oldValue, true)}
)}
>
)}
{/* Removed badges as they're redundant with background highlighting */}
{hasChildren && isExpanded && (
{node.children.map((child) => renderDiffNode(child, depth + 1))}
)}
);
};
const countChanges = (
nodes: DiffNode[]
): { added: number; removed: number; changed: number } => {
let added = 0,
removed = 0,
changed = 0;
const count = (nodeList: DiffNode[]) => {
for (const node of nodeList) {
if (node.type === "added") added++;
else if (node.type === "removed") removed++;
else if (node.type === "changed") changed++;
if (node.children) {
count(node.children);
}
}
};
count(nodes);
return { added, removed, changed };
};
const stats = useMemo(() => countChanges(diffTree), [diffTree]);
// Show header only if there are changes
const hasChanges = stats.added > 0 || stats.removed > 0 || stats.changed > 0;
return (
{hasChanges && (
{stats.added > 0 && (
+
{stats.added}
new
)}
{stats.removed > 0 && (
−
{stats.removed}
gone
)}
{stats.changed > 0 && (
≈
{stats.changed}
modified
)}
)}
{diffTree.length === 0 ? (
≡
No changes detected
The data is identical
) : (
{(() => {
globalLineNumber = 0; // Reset counter before rendering
return diffTree.map((node) => renderDiffNode(node, 0));
})()}
)}
);
}
// ============================================
// STYLES
// ============================================
const styles = StyleSheet.create({
container: {
flex: 1,
borderRadius: 8,
overflow: "hidden",
},
header: {
paddingHorizontal: 12,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: gameUIColors.diff.lineNumberBorder,
},
summaryContainer: {
flexDirection: "row",
gap: 16,
alignItems: "center",
},
summaryItem: {
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
summaryAdded: {
backgroundColor: gameUIColors.diff.addedBackground,
},
summaryRemoved: {
backgroundColor: gameUIColors.diff.removedBackground,
},
summaryChanged: {
backgroundColor: gameUIColors.diff.modifiedBackground,
},
summaryIcon: {
fontSize: 14,
fontWeight: "700",
fontFamily: "monospace",
},
summaryCount: {
fontSize: 13,
fontWeight: "600",
fontFamily: "monospace",
},
summaryLabel: {
fontSize: 11,
fontFamily: "monospace",
opacity: 0.9,
},
scrollView: {
flex: 1,
},
row: {
minHeight: 26,
justifyContent: "center",
flexDirection: "row",
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "rgba(255, 255, 255, 0.02)",
},
lineNumber: {
width: 32,
paddingHorizontal: 6,
paddingVertical: 4,
backgroundColor: gameUIColors.diff.lineNumberBackground,
justifyContent: "center",
borderRightWidth: 1,
borderRightColor: gameUIColors.diff.lineNumberBorder,
},
lineNumberText: {
fontSize: 11,
fontFamily: "monospace",
textAlign: "right",
},
marker: {
width: 20,
paddingHorizontal: 2,
paddingVertical: 4,
justifyContent: "center",
alignItems: "center",
},
markerText: {
fontSize: 12,
fontFamily: "monospace",
fontWeight: "600",
},
content: {
flex: 1,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 4,
},
expandIconContainer: {
width: 16,
height: 16,
borderRadius: 3,
backgroundColor: gameUIColors.diff.lineNumberBorder,
alignItems: "center",
justifyContent: "center",
marginRight: 6,
},
expandIcon: {
fontSize: 11,
fontFamily: "monospace",
fontWeight: "600",
},
colon: {
fontSize: 12,
fontFamily: "monospace",
marginHorizontal: 4,
},
key: {
fontSize: 11,
fontFamily: "monospace",
fontWeight: "500",
opacity: 0.9,
},
value: {
fontSize: 11,
fontFamily: "monospace",
maxWidth: "80%",
},
arrow: {
fontSize: 12,
fontFamily: "monospace",
fontWeight: "600",
},
badge: {
fontSize: 11,
fontFamily: "monospace",
fontWeight: "600",
marginLeft: 8,
},
emptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: 60,
},
emptyIcon: {
fontSize: 48,
fontFamily: "monospace",
opacity: 0.2,
marginBottom: 12,
},
emptyTitle: {
fontSize: 14,
fontFamily: "monospace",
fontWeight: "600",
marginBottom: 4,
},
emptySubtitle: {
fontSize: 12,
fontFamily: "monospace",
opacity: 0.6,
},
});