From ae11905a0ab695c129e56401bf68be4878cb92ef Mon Sep 17 00:00:00 2001 From: Naowas Morshed Eimon Date: Mon, 30 Mar 2026 16:10:16 +0600 Subject: [PATCH 1/3] wip: JSON Diff Viewer --- src/app/toolRegistry.ts | 3 + src/components/tools/JsonDiffViewer.tsx | 376 ++++++++++++++++++++++++ src/utils/json-diff.ts | 138 +++++++++ tests/tools/json-diff.test.ts | 64 ++++ 4 files changed, 581 insertions(+) create mode 100644 src/components/tools/JsonDiffViewer.tsx create mode 100644 src/utils/json-diff.ts create mode 100644 tests/tools/json-diff.test.ts diff --git a/src/app/toolRegistry.ts b/src/app/toolRegistry.ts index 33076c74..2043698a 100644 --- a/src/app/toolRegistry.ts +++ b/src/app/toolRegistry.ts @@ -10,6 +10,7 @@ import { FileJson, FileText, Globe, + GitCompare, Hash, Image as ImageIcon, Key, @@ -32,6 +33,7 @@ const lazyNamed = >( export const ALL_TOOLS = [ { icon: FileJson, name: "JSON Viewer", id: "json-viewer", category: "Development Tools" }, + { icon: GitCompare, name: "JSON Diff Viewer", id: "json-diff-viewer", category: "Development Tools" }, { icon: Code, name: "Code Playground", id: "code-playground", category: "Development Tools" }, { icon: Terminal, name: "Regex Generator", id: "regex-generator", category: "Development Tools" }, { icon: Type, name: "JSON to TypeScript", id: "json-typescript", category: "Development Tools" }, @@ -70,6 +72,7 @@ export const TOOL_BY_ID = new Map(ALL_TOOLS.map((tool) = export const TOOL_COMPONENTS: Record> = { "json-viewer": lazyNamed(() => import("@/components/tools/JsonViewer"), "JsonViewer"), + "json-diff-viewer": lazyNamed(() => import("@/components/tools/JsonDiffViewer"), "JsonDiffViewer"), "code-playground": lazyNamed(() => import("@/components/tools/CodePlayground"), "CodePlayground"), "regex-generator": lazyNamed(() => import("@/components/tools/RegexGenerator"), "RegexGenerator"), "json-typescript": lazyNamed(() => import("@/components/tools/JsonToTypeScript"), "JsonToTypeScript"), diff --git a/src/components/tools/JsonDiffViewer.tsx b/src/components/tools/JsonDiffViewer.tsx new file mode 100644 index 00000000..952be171 --- /dev/null +++ b/src/components/tools/JsonDiffViewer.tsx @@ -0,0 +1,376 @@ +import { useMemo, useState, useTransition } from "react"; +import { ArrowLeftRight, Check, Copy, Eraser, GitCompare, Sparkles } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { diffJsonValues, formatDiffValue, type JsonDiffEntry } from "@/utils/json-diff"; + +const SAMPLE_ORIGINAL = JSON.stringify( + { + name: "DevTools Desktop", + version: "1.3.0", + features: ["json-viewer", "jwt-decoder", "text-compare"], + settings: { + theme: "dark", + autoUpdate: true, + shortcuts: { + commandPalette: "Ctrl+K", + sidebarToggle: "Ctrl+B", + }, + }, + }, + null, + 2, +); + +const SAMPLE_MODIFIED = JSON.stringify( + { + name: "DevTools Desktop", + version: "1.4.0", + features: ["json-viewer", "jwt-decoder", "text-compare", "json-diff-viewer"], + settings: { + theme: "system", + autoUpdate: false, + shortcuts: { + commandPalette: "Ctrl+Shift+P", + }, + }, + metadata: { + releaseDate: "2026-03-30", + }, + }, + null, + 2, +); + +const ENTRY_CLASSES: Record = { + added: "border-green-500/30 bg-green-500/10", + removed: "border-red-500/30 bg-red-500/10", + changed: "border-yellow-500/30 bg-yellow-500/10", + unchanged: "border-border bg-muted/40", +}; + +const BADGE_CLASSES: Record = { + added: "bg-green-500 text-white", + removed: "bg-red-500 text-white", + changed: "bg-yellow-500 text-black", + unchanged: "border border-border bg-background text-foreground", +}; + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Invalid JSON"; +} + +export function JsonDiffViewer() { + const [originalInput, setOriginalInput] = useState(""); + const [modifiedInput, setModifiedInput] = useState(""); + const [originalError, setOriginalError] = useState(""); + const [modifiedError, setModifiedError] = useState(""); + const [entries, setEntries] = useState([]); + const [truncated, setTruncated] = useState(false); + const [showOnlyDifferences, setShowOnlyDifferences] = useState(true); + const [hasCompared, setHasCompared] = useState(false); + const [copiedField, setCopiedField] = useState<"original" | "modified" | null>(null); + const [isComparing, startTransition] = useTransition(); + + const filteredEntries = useMemo(() => { + if (!showOnlyDifferences) { + return entries; + } + + return entries.filter((entry) => entry.type !== "unchanged"); + }, [entries, showOnlyDifferences]); + + const counts = useMemo(() => { + return entries.reduce( + (accumulator, entry) => { + accumulator[entry.type] += 1; + return accumulator; + }, + { added: 0, removed: 0, changed: 0, unchanged: 0 }, + ); + }, [entries]); + + const compareJson = () => { + setOriginalError(""); + setModifiedError(""); + + if (!originalInput.trim() && !modifiedInput.trim()) { + toast.error("Enter JSON in at least one panel."); + return; + } + + let parsedOriginal: unknown; + let parsedModified: unknown; + let validationFailed = false; + + try { + parsedOriginal = JSON.parse(originalInput); + setOriginalInput(JSON.stringify(parsedOriginal, null, 2)); + } catch (error) { + setOriginalError(`Invalid JSON: ${getErrorMessage(error)}`); + validationFailed = true; + } + + try { + parsedModified = JSON.parse(modifiedInput); + setModifiedInput(JSON.stringify(parsedModified, null, 2)); + } catch (error) { + setModifiedError(`Invalid JSON: ${getErrorMessage(error)}`); + validationFailed = true; + } + + if (validationFailed) { + setEntries([]); + setTruncated(false); + setHasCompared(false); + return; + } + + startTransition(() => { + setTimeout(() => { + const result = diffJsonValues(parsedOriginal, parsedModified, { + includeUnchanged: true, + maxEntries: 10000, + }); + + setEntries(result.entries); + setTruncated(result.truncated); + setHasCompared(true); + toast.success("Comparison complete."); + }, 0); + }); + }; + + const copyText = async (value: string, field: "original" | "modified") => { + if (!value.trim()) { + toast.error("Nothing to copy."); + return; + } + + await navigator.clipboard.writeText(value); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 1200); + toast.success("Copied to clipboard."); + }; + + const loadSample = () => { + setOriginalInput(SAMPLE_ORIGINAL); + setModifiedInput(SAMPLE_MODIFIED); + setOriginalError(""); + setModifiedError(""); + setEntries([]); + setHasCompared(false); + setTruncated(false); + }; + + const swapJson = () => { + setOriginalInput(modifiedInput); + setModifiedInput(originalInput); + setOriginalError(modifiedError); + setModifiedError(originalError); + setEntries([]); + setHasCompared(false); + setTruncated(false); + }; + + const clearAll = () => { + setOriginalInput(""); + setModifiedInput(""); + setOriginalError(""); + setModifiedError(""); + setEntries([]); + setTruncated(false); + setHasCompared(false); + }; + + return ( +
+
+ + + + +
+ +
+ + +
+
+ Original JSON + Source JSON object +
+ +
+
+ +