diff --git a/src/components/diff-viewer.tsx b/src/components/diff-viewer.tsx index e49dc2f..7e5a160 100644 --- a/src/components/diff-viewer.tsx +++ b/src/components/diff-viewer.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { diffPlistKeys, type PlistDiff } from "@/lib/plist"; +import { diffPlistKeys, computeKeyLevelDiff, type PlistDiff, type KeyDiffEntry } from "@/lib/plist"; import { Columns2, Rows3, ChevronRight } from "lucide-react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; @@ -12,124 +12,6 @@ interface DiffViewerProps { newLabel: string; } -type DiffLine = { - type: "context" | "add" | "remove"; - content: string; - oldNum?: number; - newNum?: number; -}; - -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 rawDiff: DiffLine[] = []; - let oi = 0, - ni = 0; - - while (oi < oldLines.length || ni < newLines.length) { - const oldLine = oldLines[oi]; - const newLine = newLines[ni]; - - if (oi < oldLines.length && ni < newLines.length && oldLine === newLine) { - rawDiff.push({ type: "context", content: oldLine, oldNum: oi + 1, newNum: ni + 1 }); - oi++; - ni++; - } else if (oi < oldLines.length && !newSet.has(oldLine)) { - rawDiff.push({ type: "remove", content: oldLine, oldNum: oi + 1 }); - oi++; - } else if (ni < newLines.length && !oldSet.has(newLine)) { - 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, @@ -145,16 +27,11 @@ export function DiffViewer({ [oldXml, newXml], ); - const diffLines = useMemo( - () => computeDiff(oldXml, newXml), + const keyLevelDiff = useMemo( + () => computeKeyLevelDiff(oldXml, newXml), [oldXml, newXml], ); - const splitRows = useMemo( - () => computeSplitDiff(diffLines), - [diffLines], - ); - const hasChanges = keysDiff.added.length > 0 || keysDiff.removed.length > 0 || @@ -204,66 +81,15 @@ export function DiffViewer({ {viewMode === "unified" ? (
-            {diffLines.map((line, i) => (
-              
- - {line.type === "remove" ? "-" : line.type === "add" ? "+" : " "} - - {line.content} -
+ {keyLevelDiff.map((entry, i) => ( + ))}
) : (
- {splitRows.map((row, i) => ( -
-
- {row.left && ( - <> - - {row.left.num ?? ""} - - {row.left.content} - - )} -
-
- {row.right && ( - <> - - {row.right.num ?? ""} - - {row.right.content} - - )} -
-
+ {keyLevelDiff.map((entry, i) => ( + ))}
@@ -273,6 +99,181 @@ export function DiffViewer({ ); } +function KeyDiffBlock({ + entry, + mode, +}: { + entry: KeyDiffEntry; + mode: "unified" | "split"; +}) { + // Ellipsis marker + if (entry.key === "···") { + if (mode === "unified") { + return ( +
+ + ··· +
+ ); + } + return ( +
+
···
+
···
+
+ ); + } + + const oldLines = entry.oldXml?.split("\n") ?? []; + const newLines = entry.newXml?.split("\n") ?? []; + + if (mode === "unified") { + if (entry.type === "context") { + return ( + <> + {oldLines.map((line, i) => ( +
+ + {line} +
+ ))} + + ); + } + if (entry.type === "removed") { + return ( + <> + {oldLines.map((line, i) => ( +
+ - + {line} +
+ ))} + + ); + } + if (entry.type === "added") { + return ( + <> + {newLines.map((line, i) => ( +
+ + + {line} +
+ ))} + + ); + } + // changed: show old then new + return ( + <> + {oldLines.map((line, i) => ( +
+ - + {line} +
+ ))} + {newLines.map((line, i) => ( +
+ + + {line} +
+ ))} + + ); + } + + // Split mode + if (entry.type === "context") { + const maxLines = Math.max(oldLines.length, newLines.length); + return ( + <> + {Array.from({ length: maxLines }).map((_, i) => ( +
+
+ {oldLines[i] ?? ""} +
+
+ {newLines[i] ?? ""} +
+
+ ))} + + ); + } + + if (entry.type === "removed") { + return ( + <> + {oldLines.map((line, i) => ( +
+
+ {line} +
+
+
+ ))} + + ); + } + + if (entry.type === "added") { + return ( + <> + {newLines.map((line, i) => ( +
+
+
+ {line} +
+
+ ))} + + ); + } + + // changed: pair up lines side by side + const maxLines = Math.max(oldLines.length, newLines.length); + return ( + <> + {Array.from({ length: maxLines }).map((_, i) => ( +
+
+ {oldLines[i] ?? ""} +
+
+ {newLines[i] ?? ""} +
+
+ ))} + + ); +} + function DiffSummary({ diff }: { diff: PlistDiff }) { const totalChanges = diff.added.length + diff.removed.length + diff.changed.length; diff --git a/src/lib/plist.ts b/src/lib/plist.ts index 8ff07fd..1f23126 100644 --- a/src/lib/plist.ts +++ b/src/lib/plist.ts @@ -149,3 +149,110 @@ export function diffPlistKeys(oldXml: string, newXml: string): PlistDiff { return { added, removed, changed, unchanged }; } + +export interface KeyDiffEntry { + key: string; + type: "added" | "removed" | "changed" | "context"; + oldXml?: string; + newXml?: string; +} + +function serializeKeyValue(key: string, value: PlistValue): string { + const keyLine = `${escapeXml(key)}`; + const valueLine = valueToXml(value, 0); + return `${keyLine}\n${valueLine}`; +} + +export function computeKeyLevelDiff( + oldXml: string, + newXml: string, + contextCount = 1 +): KeyDiffEntry[] { + const oldJson = plistToJson(oldXml); + const newJson = plistToJson(newXml); + + const oldKeys = new Set(Object.keys(oldJson)); + const newKeys = new Set(Object.keys(newJson)); + + // Sorted keys list (union of both) + const sortedKeys = [...new Set([...oldKeys, ...newKeys])].sort(); + + // Categorize each key + const added = new Set(); + const removed = new Set(); + const changed = new Set(); + + for (const key of sortedKeys) { + const inOld = oldKeys.has(key); + const inNew = newKeys.has(key); + + if (!inOld && inNew) { + added.add(key); + } else if (inOld && !inNew) { + removed.add(key); + } else if (JSON.stringify(oldJson[key]) !== JSON.stringify(newJson[key])) { + changed.add(key); + } + } + + const changedSet = new Set([...added, ...removed, ...changed]); + + // Determine which keys need to be shown as context + const contextKeys = new Set(); + for (let i = 0; i < sortedKeys.length; i++) { + if (changedSet.has(sortedKeys[i])) { + for (let j = Math.max(0, i - contextCount); j <= Math.min(sortedKeys.length - 1, i + contextCount); j++) { + if (!changedSet.has(sortedKeys[j])) { + contextKeys.add(sortedKeys[j]); + } + } + } + } + + // Build result entries + const result: KeyDiffEntry[] = []; + const includeKeys = new Set([...changedSet, ...contextKeys]); + let lastIncludedIdx = -1; + + for (let i = 0; i < sortedKeys.length; i++) { + const key = sortedKeys[i]; + if (!includeKeys.has(key)) continue; + + // Add ellipsis marker if there's a gap + if (lastIncludedIdx !== -1 && i - lastIncludedIdx > 1) { + result.push({ key: "···", type: "context" }); + } + lastIncludedIdx = i; + + if (added.has(key)) { + result.push({ + key, + type: "added", + newXml: serializeKeyValue(key, newJson[key]), + }); + } else if (removed.has(key)) { + result.push({ + key, + type: "removed", + oldXml: serializeKeyValue(key, oldJson[key]), + }); + } else if (changed.has(key)) { + result.push({ + key, + type: "changed", + oldXml: serializeKeyValue(key, oldJson[key]), + newXml: serializeKeyValue(key, newJson[key]), + }); + } else { + // Context key - present in both, show old (they're equal) + result.push({ + key, + type: "context", + oldXml: serializeKeyValue(key, oldJson[key]), + newXml: serializeKeyValue(key, newJson[key]), + }); + } + } + + return result; +}