From 3a4509450710853619daaf38850f27b7eaa5a5cb Mon Sep 17 00:00:00 2001 From: utkuakyuz Date: Tue, 21 Oct 2025 00:37:14 +0300 Subject: [PATCH 1/4] feat: add line count display feature to diff viewer - Introduced a new configuration option to show line counts in the diff viewer. - Added a LineCountDisplay component to visualize added, removed, and modified lines. - Updated the VirtualizedDiffViewer to calculate and display line count statistics based on the diff data. - Enhanced the Sidebar component to include a checkbox for toggling the line count display. - Updated styles for the new line count display feature. --- demo/src/App.tsx | 2 + demo/src/components/Sidebar.tsx | 13 +++++ demo/src/types/index.ts | 1 + .../components/LineCountDisplay.tsx | 53 +++++++++++++++++++ .../components/VirtualizedDiffViewer.tsx | 16 +++++- .../DiffViewer/styles/JsonDiffCustomTheme.css | 47 ++++++++++++++++ src/components/DiffViewer/types/index.ts | 8 +++ .../DiffViewer/utils/lineCountUtils.ts | 37 +++++++++++++ 8 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/components/DiffViewer/components/LineCountDisplay.tsx create mode 100644 src/components/DiffViewer/utils/lineCountUtils.ts diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 8ce57d7..1b4c616 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -25,6 +25,7 @@ export default function App() { miniMapWidth: 20, hideSearch: false, height: 380, + showLineCount: true, // Differ Configuration detectCircular: true, @@ -219,6 +220,7 @@ export default function App() { height={config.height} miniMapWidth={config.miniMapWidth} hideSearch={config.hideSearch} + showLineCount={config.showLineCount} inlineDiffOptions={{ mode: config.inlineDiffMode }} oldValue={parsedOldValue} newValue={parsedNewValue} diff --git a/demo/src/components/Sidebar.tsx b/demo/src/components/Sidebar.tsx index b5368e1..d857c17 100644 --- a/demo/src/components/Sidebar.tsx +++ b/demo/src/components/Sidebar.tsx @@ -94,6 +94,19 @@ function Sidebar(props: Props) { +
+ +

Display statistics for added, removed, and modified lines

+
+
+ + {showLineCount && ( + + )} {/* List & Minimap */} diff --git a/src/components/DiffViewer/styles/JsonDiffCustomTheme.css b/src/components/DiffViewer/styles/JsonDiffCustomTheme.css index 0399d7f..d7a682a 100644 --- a/src/components/DiffViewer/styles/JsonDiffCustomTheme.css +++ b/src/components/DiffViewer/styles/JsonDiffCustomTheme.css @@ -42,6 +42,53 @@ background: rgba(182, 180, 67, 0.08); } +/* LINE COUNT DISPLAY */ +.line-count-display { + display: flex; + gap: 12px; + align-items: center; + font-size: 11px; + color: #f8f8f2; + margin-left: auto; +} + +.line-count-item { + padding: 2px 6px; + border-radius: 3px; + font-weight: 500; + white-space: nowrap; +} + +.line-count-item.added { + background: rgba(100, 182, 67, 0.2); + color: #a5ff99; + border: 1px solid rgba(100, 182, 67, 0.3); +} + +.line-count-item.removed { + background: rgba(160, 128, 100, 0.2); + color: #ffaa99; + border: 1px solid rgba(160, 128, 100, 0.3); +} + +.line-count-item.modified { + background: rgba(182, 180, 67, 0.2); + color: #ecff99; + border: 1px solid rgba(182, 180, 67, 0.3); +} + +.line-count-item.total { + background: rgba(69, 96, 248, 0.2); + color: #4560f8; + border: 1px solid rgba(69, 96, 248, 0.3); +} + +.line-count-item.no-changes { + background: rgba(248, 248, 242, 0.1); + color: #f8f8f2; + border: 1px solid rgba(248, 248, 242, 0.2); +} + .json-diff-viewer.json-diff-viewer-theme-custom .empty-equal-cell { opacity: 0.4; background: repeating-linear-gradient(-53deg, rgb(69, 69, 70), rgb(69, 69, 70) 1.5px, #282a36 1.5px, #282a36 4px); diff --git a/src/components/DiffViewer/types/index.ts b/src/components/DiffViewer/types/index.ts index c01f5a7..2415b0e 100644 --- a/src/components/DiffViewer/types/index.ts +++ b/src/components/DiffViewer/types/index.ts @@ -32,6 +32,13 @@ export type SearchState = { currentIndex: number; }; +export type LineCountStats = { + added: number; + removed: number; + modified: number; + total: number; +}; + export type VirtualizedDiffViewerProps = { oldValue: object; newValue: object; @@ -49,6 +56,7 @@ export type VirtualizedDiffViewerProps = { miniMapWidth?: number; inlineDiffOptions?: InlineDiffOptions; overScanCount?: number; + showLineCount?: boolean; }; export type DiffMinimapProps = { diff --git a/src/components/DiffViewer/utils/lineCountUtils.ts b/src/components/DiffViewer/utils/lineCountUtils.ts new file mode 100644 index 0000000..f9e51db --- /dev/null +++ b/src/components/DiffViewer/utils/lineCountUtils.ts @@ -0,0 +1,37 @@ +import type { DiffResult } from "json-diff-kit"; + +import type { LineCountStats } from "../types"; + +export function calculateLineCountStats(diffData: [DiffResult[], DiffResult[]]): LineCountStats { + const [leftDiff, rightDiff] = diffData; + + let added = 0; + let removed = 0; + let modified = 0; + + // Count changes from the left diff (removed lines) + for (const line of leftDiff) { + if (line.type === "remove") { + removed++; + } + else if (line.type === "modify") { + modified++; + } + } + + // Count changes from the right diff (added lines) + for (const line of rightDiff) { + if (line.type === "add") { + added++; + } + } + + const total = added + removed + modified; + + return { + added, + removed, + modified, + total, + }; +} From 440757a59e04d75c0ebc27bbe5e6181c6ba69f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20Aky=C3=BCz?= Date: Sat, 25 Oct 2025 16:49:08 +0300 Subject: [PATCH 2/4] style: line count display improvement --- .../components/LineCountDisplay.tsx | 59 ++++++++++--------- .../components/VirtualizedDiffViewer.tsx | 1 - .../DiffViewer/styles/JsonDiffCustomTheme.css | 8 +++ 3 files changed, 40 insertions(+), 28 deletions(-) diff --git a/src/components/DiffViewer/components/LineCountDisplay.tsx b/src/components/DiffViewer/components/LineCountDisplay.tsx index da7a09f..ec8f02f 100644 --- a/src/components/DiffViewer/components/LineCountDisplay.tsx +++ b/src/components/DiffViewer/components/LineCountDisplay.tsx @@ -17,35 +17,40 @@ export const LineCountDisplay: React.FC = ({ stats }) => return (
- {stats.added > 0 && ( - - + - {stats.added} - {" "} - added - - )} - {stats.removed > 0 && ( - - - - {stats.removed} - {" "} - removed - - )} - {stats.modified > 0 && ( - - ~ - {stats.modified} +
+ {stats.added > 0 && ( + + + + {stats.added} + {" "} + added + + )} + + {stats.removed > 0 && ( + + - + {stats.removed} + {" "} + removed + + )} +
+
+ {stats.modified > 0 && ( + + ~ + {stats.modified} + {" "} + modified + + )} + + {stats.total} {" "} - modified + total changes - )} - - {stats.total} - {" "} - total changes - +
); }; diff --git a/src/components/DiffViewer/components/VirtualizedDiffViewer.tsx b/src/components/DiffViewer/components/VirtualizedDiffViewer.tsx index 2f85f3d..8dd3097 100644 --- a/src/components/DiffViewer/components/VirtualizedDiffViewer.tsx +++ b/src/components/DiffViewer/components/VirtualizedDiffViewer.tsx @@ -148,7 +148,6 @@ export const VirtualizedDiffViewer: React.FC = ({
{leftTitle}
{rightTitle}
- {showLineCount && ( )} diff --git a/src/components/DiffViewer/styles/JsonDiffCustomTheme.css b/src/components/DiffViewer/styles/JsonDiffCustomTheme.css index d7a682a..f1629f7 100644 --- a/src/components/DiffViewer/styles/JsonDiffCustomTheme.css +++ b/src/components/DiffViewer/styles/JsonDiffCustomTheme.css @@ -46,10 +46,18 @@ .line-count-display { display: flex; gap: 12px; + margin-top: 5px; align-items: center; font-size: 11px; color: #f8f8f2; margin-left: auto; + justify-content: space-between; +} + +.line-count-item-sub-holder { + display: flex; + align-items: center; + gap: 4px; } .line-count-item { From cab9475941059bc1ae1b5c31f570dc368f674e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20Aky=C3=BCz?= Date: Sat, 25 Oct 2025 17:06:58 +0300 Subject: [PATCH 3/4] feat: difference in objects counts for compare key method --- demo/src/App.tsx | 2 + demo/src/components/Sidebar.tsx | 13 ++ demo/src/types/index.ts | 1 + .../components/ObjectCountDisplay.tsx | 64 ++++++ .../components/VirtualizedDiffViewer.tsx | 23 +- .../DiffViewer/styles/JsonDiffCustomTheme.css | 55 +++++ src/components/DiffViewer/types/index.ts | 8 + .../DiffViewer/utils/objectCountUtils.ts | 211 ++++++++++++++++++ src/index.ts | 1 + 9 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 src/components/DiffViewer/components/ObjectCountDisplay.tsx create mode 100644 src/components/DiffViewer/utils/objectCountUtils.ts diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 1b4c616..ff02cc1 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -26,6 +26,7 @@ export default function App() { hideSearch: false, height: 380, showLineCount: true, + showObjectCountStats: false, // Differ Configuration detectCircular: true, @@ -221,6 +222,7 @@ export default function App() { miniMapWidth={config.miniMapWidth} hideSearch={config.hideSearch} showLineCount={config.showLineCount} + showObjectCountStats={config.showObjectCountStats} inlineDiffOptions={{ mode: config.inlineDiffMode }} oldValue={parsedOldValue} newValue={parsedNewValue} diff --git a/demo/src/components/Sidebar.tsx b/demo/src/components/Sidebar.tsx index d857c17..c6dab21 100644 --- a/demo/src/components/Sidebar.tsx +++ b/demo/src/components/Sidebar.tsx @@ -107,6 +107,19 @@ function Sidebar(props: Props) {

Display statistics for added, removed, and modified lines

+
+ +

Display object statistics when using compare-key method

+
+
{/* List & Minimap */} diff --git a/src/components/DiffViewer/styles/JsonDiffCustomTheme.css b/src/components/DiffViewer/styles/JsonDiffCustomTheme.css index f1629f7..0448747 100644 --- a/src/components/DiffViewer/styles/JsonDiffCustomTheme.css +++ b/src/components/DiffViewer/styles/JsonDiffCustomTheme.css @@ -97,6 +97,61 @@ border: 1px solid rgba(248, 248, 242, 0.2); } +/* OBJECT COUNT DISPLAY */ +.object-count-display { + display: flex; + gap: 12px; + margin-top: 5px; + align-items: center; + font-size: 11px; + color: #f8f8f2; + margin-left: auto; + justify-content: space-between; +} + +.object-count-item-sub-holder { + display: flex; + align-items: center; + gap: 4px; +} + +.object-count-item { + padding: 2px 6px; + border-radius: 3px; + font-weight: 500; + white-space: nowrap; +} + +.object-count-item.added { + background: rgba(100, 182, 67, 0.2); + color: #a5ff99; + border: 1px solid rgba(100, 182, 67, 0.3); +} + +.object-count-item.removed { + background: rgba(160, 128, 100, 0.2); + color: #ffaa99; + border: 1px solid rgba(160, 128, 100, 0.3); +} + +.object-count-item.modified { + background: rgba(182, 180, 67, 0.2); + color: #ecff99; + border: 1px solid rgba(182, 180, 67, 0.3); +} + +.object-count-item.total { + background: rgba(69, 96, 248, 0.2); + color: #4560f8; + border: 1px solid rgba(69, 96, 248, 0.3); +} + +.object-count-item.no-changes { + background: rgba(248, 248, 242, 0.1); + color: #f8f8f2; + border: 1px solid rgba(248, 248, 242, 0.2); +} + .json-diff-viewer.json-diff-viewer-theme-custom .empty-equal-cell { opacity: 0.4; background: repeating-linear-gradient(-53deg, rgb(69, 69, 70), rgb(69, 69, 70) 1.5px, #282a36 1.5px, #282a36 4px); diff --git a/src/components/DiffViewer/types/index.ts b/src/components/DiffViewer/types/index.ts index 2415b0e..e648d0e 100644 --- a/src/components/DiffViewer/types/index.ts +++ b/src/components/DiffViewer/types/index.ts @@ -39,6 +39,13 @@ export type LineCountStats = { total: number; }; +export type ObjectCountStats = { + added: number; + removed: number; + modified: number; + total: number; +}; + export type VirtualizedDiffViewerProps = { oldValue: object; newValue: object; @@ -57,6 +64,7 @@ export type VirtualizedDiffViewerProps = { inlineDiffOptions?: InlineDiffOptions; overScanCount?: number; showLineCount?: boolean; + showObjectCountStats?: boolean; }; export type DiffMinimapProps = { diff --git a/src/components/DiffViewer/utils/objectCountUtils.ts b/src/components/DiffViewer/utils/objectCountUtils.ts new file mode 100644 index 0000000..f945d8d --- /dev/null +++ b/src/components/DiffViewer/utils/objectCountUtils.ts @@ -0,0 +1,211 @@ +import type { ObjectCountStats } from "../types"; + +/** + * Recursively extracts all arrays from a JSON object + */ +function extractArrays(obj: any, arrays: any[] = []): any[] { + if (Array.isArray(obj)) { + arrays.push(obj); + // Also check nested objects within the array + obj.forEach((item) => { + if (typeof item === "object" && item !== null) { + extractArrays(item, arrays); + } + }); + } + else if (typeof obj === "object" && obj !== null) { + Object.values(obj).forEach((value) => { + extractArrays(value, arrays); + }); + } + return arrays; +} + +/** + * Checks if an array contains objects with the specified compare key + */ +function hasObjectsWithCompareKey(arr: any[], compareKey: string): boolean { + if (!Array.isArray(arr) || arr.length === 0 || !compareKey) { + return false; + } + + return arr.every(item => + typeof item === "object" + && item !== null + && item !== undefined + && compareKey in item + && item[compareKey] !== undefined + && item[compareKey] !== null, + ); +} + +/** + * Creates a map of objects keyed by the compare key value + */ +function createObjectMap(arr: any[], compareKey: string): Map { + const map = new Map(); + if (!Array.isArray(arr) || !compareKey) { + return map; + } + + arr.forEach((obj) => { + if (typeof obj === "object" && obj !== null && obj !== undefined && compareKey in obj) { + const keyValue = obj[compareKey]; + if (keyValue !== undefined && keyValue !== null) { + map.set(keyValue, obj); + } + } + }); + return map; +} + +/** + * Deep compares two objects to check if they are different + */ +function objectsAreDifferent(obj1: any, obj2: any): boolean { + if (obj1 === obj2) + return false; + if (typeof obj1 !== typeof obj2) + return true; + if (typeof obj1 !== "object" || obj1 === null || obj2 === null) + return obj1 !== obj2; + + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) + return true; + + for (const key of keys1) { + if (!keys2.includes(key)) + return true; + if (objectsAreDifferent(obj1[key], obj2[key])) + return true; + } + + return false; +} + +/** + * Calculates object count statistics for compare-key method + */ +export function calculateObjectCountStats( + oldValue: any, + newValue: any, + compareKey: string, +): ObjectCountStats { + // Early return for invalid inputs + if (!oldValue || !newValue || !compareKey || typeof compareKey !== "string") { + return { added: 0, removed: 0, modified: 0, total: 0 }; + } + + try { + const oldArrays = extractArrays(oldValue); + const newArrays = extractArrays(newValue); + + const oldArraysWithKey = oldArrays.filter(arr => hasObjectsWithCompareKey(arr, compareKey)); + const newArraysWithKey = newArrays.filter(arr => hasObjectsWithCompareKey(arr, compareKey)); + + if (oldArraysWithKey.length === 0 && newArraysWithKey.length === 0) { + return { added: 0, removed: 0, modified: 0, total: 0 }; + } + + let totalAdded = 0; + let totalRemoved = 0; + let totalModified = 0; + + const processedOldArrays = new Set(); + const processedNewArrays = new Set(); + + oldArraysWithKey.forEach((oldArr, oldIndex) => { + try { + const arraySignature = `${oldIndex}-${oldArr.length}-${JSON.stringify(oldArr.map((item: any) => item[compareKey]).sort())}`; + if (processedOldArrays.has(arraySignature)) + return; + processedOldArrays.add(arraySignature); + + const newArr = newArraysWithKey.find((newArr, newIndex) => { + try { + const newSignature = `${newIndex}-${newArr.length}-${JSON.stringify(newArr.map((item: any) => item[compareKey]).sort())}`; + if (processedNewArrays.has(newSignature)) + return false; + + const oldKeys = oldArr.map((item: any) => item[compareKey]).sort(); + const newKeys = newArr.map((item: any) => item[compareKey]).sort(); + const commonKeys = oldKeys.filter((key: any) => newKeys.includes(key)); + + return commonKeys.length > 0; // Arrays share at least one key + } + catch (error) { + console.warn("Error processing new array:", error); + return false; + } + }); + + if (newArr) { + const newSignature = `${newArraysWithKey.indexOf(newArr)}-${newArr.length}-${JSON.stringify(newArr.map((item: any) => item[compareKey]).sort())}`; + processedNewArrays.add(newSignature); + + const oldMap = createObjectMap(oldArr, compareKey); + const newMap = createObjectMap(newArr, compareKey); + + // Count added objects + for (const [key, newObj] of newMap) { + if (!oldMap.has(key)) { + totalAdded++; + } + else { + // Check if object was modified + const oldObj = oldMap.get(key); + if (objectsAreDifferent(oldObj, newObj)) { + totalModified++; + } + } + } + + // Count removed objects + for (const [key] of oldMap) { + if (!newMap.has(key)) { + totalRemoved++; + } + } + } + else { + // If no corresponding array found, all objects are considered removed + totalRemoved += oldArr.length; + } + } + catch (error) { + console.warn("Error processing old array:", error); + } + }); + + newArraysWithKey.forEach((newArr, newIndex) => { + try { + const arraySignature = `${newIndex}-${newArr.length}-${JSON.stringify(newArr.map((item: any) => item[compareKey]).sort())}`; + if (processedNewArrays.has(arraySignature)) + return; + processedNewArrays.add(arraySignature); + + // If this array not matched with an old array, all objects are considered added + totalAdded += newArr.length; + } + catch (error) { + console.warn("Error processing new array:", error); + } + }); + + const total = totalAdded + totalRemoved + totalModified; + + return { + added: totalAdded, + removed: totalRemoved, + modified: totalModified, + total, + }; + } + catch (error) { + console.warn("Error calculating object count stats:", error); + return { added: 0, removed: 0, modified: 0, total: 0 }; + } +} diff --git a/src/index.ts b/src/index.ts index 7e0fd61..b3b82de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export { default as VirtualDiffViewer } from "./components/DiffViewer"; +export { calculateObjectCountStats } from "./components/DiffViewer/utils/objectCountUtils"; export { Differ, type DiffResult } from "json-diff-kit"; From 81bcf78a5fac5d624b7a9435df0a842bcc6b0064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20Aky=C3=BCz?= Date: Sat, 25 Oct 2025 17:37:24 +0300 Subject: [PATCH 4/4] fix: type and undefined guard for highlight and rendered row --- .../json-diff/get-inline-syntax-highlight.ts | 4 ++ .../utils/json-diff/row-renderer-grid.tsx | 55 +++++++++++-------- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/components/DiffViewer/utils/json-diff/get-inline-syntax-highlight.ts b/src/components/DiffViewer/utils/json-diff/get-inline-syntax-highlight.ts index 4ece157..cfc13c0 100644 --- a/src/components/DiffViewer/utils/json-diff/get-inline-syntax-highlight.ts +++ b/src/components/DiffViewer/utils/json-diff/get-inline-syntax-highlight.ts @@ -9,6 +9,10 @@ export type InlineHighlightResult = { }; function syntaxHighlightLine(enabled: boolean, text: string, offset: number): InlineHighlightResult[] { + if (!text || typeof text !== "string") { + return [{ token: "plain", start: offset, end: offset }]; + } + if (!enabled) { return [{ token: "plain", start: offset, end: text.length + offset }]; } diff --git a/src/components/DiffViewer/utils/json-diff/row-renderer-grid.tsx b/src/components/DiffViewer/utils/json-diff/row-renderer-grid.tsx index f726e96..f6d06c9 100644 --- a/src/components/DiffViewer/utils/json-diff/row-renderer-grid.tsx +++ b/src/components/DiffViewer/utils/json-diff/row-renderer-grid.tsx @@ -68,34 +68,41 @@ ListChildComponentProps<{ const [lDiff, rDiff] = leftPart.type === "modify" && rightPart.type === "modify" - ? getInlineDiff(leftPart.text, rightPart.text, inlineDiffOptions ?? { mode: "char" }) + ? getInlineDiff(leftPart.text || "", rightPart.text || "", inlineDiffOptions ?? { mode: "char" }) : [[], []]; - const lTokens = syntaxHighlightLine(true, leftPart.text, 0); - const rTokens = syntaxHighlightLine(true, rightPart.text, 0); + const lTokens = syntaxHighlightLine(true, leftPart.text || "", 0); + const rTokens = syntaxHighlightLine(true, rightPart.text || "", 0); const lResult = mergeSegments(lTokens, lDiff); const rResult = mergeSegments(rTokens, rDiff); - const renderInlineResult = (text: string, result: typeof lResult, comma?: boolean) => ( - <> - {result.map((item, idx) => { - const frag = text.slice(item.start, item.end); - const className = [ - item.type ? `inline-diff-${item.type}` : "", - item.token ? `token ${item.token}` : "", - ] - .filter(Boolean) - .join(" "); - return ( - - {frag} - - ); - })} - {comma && ,} - - ); + const renderInlineResult = (text: string, result: typeof lResult, comma?: boolean) => { + // Guard against undefined or null text + if (!text || typeof text !== "string") { + return ; + } + + return ( + <> + {result.map((item, idx) => { + const frag = text.slice(item.start, item.end); + const className = [ + item.type ? `inline-diff-${item.type}` : "", + item.token ? `token ${item.token}` : "", + ] + .filter(Boolean) + .join(" "); + return ( + + {frag} + + ); + })} + {comma && ,} + + ); + }; return (
           {leftPart.text && indentChar.repeat(leftPart.level * indentSize)}
-          {renderInlineResult(leftPart.text, lResult, leftPart.comma)}
+          {renderInlineResult(leftPart.text || "", lResult, leftPart.comma)}
         
@@ -126,7 +133,7 @@ ListChildComponentProps<{
           {rightPart.text && indentChar.repeat(rightPart.level * indentSize)}
-          {renderInlineResult(rightPart.text, rResult, rightPart.comma)}
+          {renderInlineResult(rightPart.text || "", rResult, rightPart.comma)}