From 25db426e6240b5cccf3ee1aacdf7702b145b791f Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 14 Apr 2026 19:35:52 +0200 Subject: [PATCH] Enhance diff viewer with side-by-side mode and theme support - Add toggle between unified and split (side-by-side) view - Make diff viewer theme-aware for light/dark modes - Add context lines around changes - Make diff summary collapsible for large change sets --- src/components/diff-viewer.tsx | 287 ++++++++++++++++++++++++++------- 1 file changed, 230 insertions(+), 57 deletions(-) diff --git a/src/components/diff-viewer.tsx b/src/components/diff-viewer.tsx index 10ba7fb..1f8de24 100644 --- a/src/components/diff-viewer.tsx +++ b/src/components/diff-viewer.tsx @@ -1,7 +1,9 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { diffPlistKeys, type PlistDiff } from "@/lib/plist"; +import { Columns2, Rows3, ChevronRight } from "lucide-react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; interface DiffViewerProps { oldXml: string; @@ -17,14 +19,19 @@ type DiffLine = { newNum?: number; }; -function computeDiff(oldText: string, newText: string): DiffLine[] { +type SplitRow = { + left?: { num?: number; content: string; type: "context" | "remove" }; + right?: { num?: number; content: string; type: "context" | "add" }; +}; + +function computeDiff(oldText: string, newText: string, contextLines = 3): DiffLine[] { const oldLines = oldText.split("\n"); const newLines = newText.split("\n"); const oldSet = new Set(oldLines); const newSet = new Set(newLines); - const result: DiffLine[] = []; + const rawDiff: DiffLine[] = []; let oi = 0, ni = 0; @@ -33,30 +40,104 @@ function computeDiff(oldText: string, newText: string): DiffLine[] { const newLine = newLines[ni]; if (oi < oldLines.length && ni < newLines.length && oldLine === newLine) { - // Skip context lines - only show changes + rawDiff.push({ type: "context", content: oldLine, oldNum: oi + 1, newNum: ni + 1 }); oi++; ni++; } else if (oi < oldLines.length && !newSet.has(oldLine)) { - result.push({ type: "remove", content: oldLine, oldNum: oi + 1 }); + rawDiff.push({ type: "remove", content: oldLine, oldNum: oi + 1 }); oi++; } else if (ni < newLines.length && !oldSet.has(newLine)) { - result.push({ type: "add", content: newLine, newNum: ni + 1 }); + rawDiff.push({ type: "add", content: newLine, newNum: ni + 1 }); ni++; } else { + rawDiff.push({ type: "context", content: oldLine, oldNum: oi + 1, newNum: ni + 1 }); oi++; ni++; } } + const includeLines = new Set(); + rawDiff.forEach((line, idx) => { + if (line.type !== "context") { + for (let i = Math.max(0, idx - contextLines); i <= Math.min(rawDiff.length - 1, idx + contextLines); i++) { + includeLines.add(i); + } + } + }); + + const result: DiffLine[] = []; + let lastIncluded = -1; + + rawDiff.forEach((line, idx) => { + if (includeLines.has(idx)) { + if (lastIncluded !== -1 && idx - lastIncluded > 1) { + result.push({ type: "context", content: "ยทยทยท", oldNum: undefined, newNum: undefined }); + } + result.push(line); + lastIncluded = idx; + } + }); + return result; } +function computeSplitDiff(diffLines: DiffLine[]): SplitRow[] { + const rows: SplitRow[] = []; + let i = 0; + + while (i < diffLines.length) { + const line = diffLines[i]; + + if (line.type === "context") { + rows.push({ + left: { num: line.oldNum, content: line.content, type: "context" }, + right: { num: line.newNum, content: line.content, type: "context" }, + }); + i++; + } else if (line.type === "remove") { + // Collect consecutive removes + const removes: DiffLine[] = []; + while (i < diffLines.length && diffLines[i].type === "remove") { + removes.push(diffLines[i]); + i++; + } + // Collect consecutive adds + const adds: DiffLine[] = []; + while (i < diffLines.length && diffLines[i].type === "add") { + adds.push(diffLines[i]); + i++; + } + // Pair them up + const maxLen = Math.max(removes.length, adds.length); + for (let j = 0; j < maxLen; j++) { + const row: SplitRow = {}; + if (j < removes.length) { + row.left = { num: removes[j].oldNum, content: removes[j].content, type: "remove" }; + } + if (j < adds.length) { + row.right = { num: adds[j].newNum, content: adds[j].content, type: "add" }; + } + rows.push(row); + } + } else if (line.type === "add") { + rows.push({ + right: { num: line.newNum, content: line.content, type: "add" }, + }); + i++; + } + } + + return rows; +} + export function DiffViewer({ oldXml, newXml, oldLabel, newLabel, }: DiffViewerProps) { + const [viewMode, setViewMode] = useState<"unified" | "split">("split"); + const keysDiff = useMemo( () => diffPlistKeys(oldXml, newXml), [oldXml, newXml], @@ -67,6 +148,11 @@ export function DiffViewer({ [oldXml, newXml], ); + const splitRows = useMemo( + () => computeSplitDiff(diffLines), + [diffLines], + ); + const hasChanges = keysDiff.added.length > 0 || keysDiff.removed.length > 0 || @@ -83,65 +169,152 @@ export function DiffViewer({ return (
-
-
- - {oldLabel} - + {newLabel} +
+
+ {viewMode === "unified" ? ( + <> + - {oldLabel} +
+ +
+ + {newLabel} + + ) : ( + <> + - {oldLabel} + + + {newLabel} + + )}
-
-          {diffLines.map((line, i) => (
-            
- - {line.type === "remove" ? "-" : line.type === "add" ? "+" : " "} - - {line.content} + + {viewMode === "unified" ? ( +
+            {diffLines.map((line, i) => (
+              
+ + {line.type === "remove" ? "-" : line.type === "add" ? "+" : " "} + + {line.content} +
+ ))} +
+ ) : ( +
+
+ {splitRows.map((row, i) => ( +
+
+ {row.left && ( + <> + + {row.left.num ?? ""} + + {row.left.content} + + )} +
+
+ {row.right && ( + <> + + {row.right.num ?? ""} + + {row.right.content} + + )} +
+
+ ))}
- ))} -
+
+ )}
); } function DiffSummary({ diff }: { diff: PlistDiff }) { + const totalChanges = diff.added.length + diff.removed.length + diff.changed.length; + + const summaryParts = []; + if (diff.added.length > 0) summaryParts.push(`${diff.added.length} added`); + if (diff.removed.length > 0) summaryParts.push(`${diff.removed.length} removed`); + if (diff.changed.length > 0) summaryParts.push(`${diff.changed.length} changed`); + return ( -
- {diff.added.length > 0 && ( -
- - - {diff.added.length} added:{" "} - {diff.added.join(", ")} - -
- )} - {diff.removed.length > 0 && ( -
- - - {diff.removed.length} removed:{" "} - {diff.removed.join(", ")} - -
- )} - {diff.changed.length > 0 && ( -
- - - {diff.changed.length} changed:{" "} - {diff.changed.join(", ")} - -
- )} -
+ + + + {totalChanges} changes + ({summaryParts.join(", ")}) + + + {diff.added.length > 0 && ( +
+ + + {diff.added.length} added:{" "} + {diff.added.join(", ")} + +
+ )} + {diff.removed.length > 0 && ( +
+ + + {diff.removed.length} removed:{" "} + {diff.removed.join(", ")} + +
+ )} + {diff.changed.length > 0 && ( +
+ + + {diff.changed.length} changed:{" "} + {diff.changed.join(", ")} + +
+ )} +
+
); }