/** * @fileoverview Diff Viewer component * * Side-by-side or inline diff display for comparing version content. * Highlights additions and deletions with color coding. * Includes focus trap and ARIA attributes for accessibility compliance. * * @module @writenex/astro/client/components/VersionHistory/DiffViewer */ import { AlignLeft, Columns, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useFocusTrap } from "../../hooks/useFocusTrap"; import "./DiffViewer.css"; /** * Props for the DiffViewer component */ interface DiffViewerProps { /** Old content (version) */ oldContent: string; /** New content (current) */ newContent: string; /** Label for old content */ oldLabel: string; /** Label for new content */ newLabel: string; /** Callback to close the viewer */ onClose: () => void; } /** * Diff line type */ type DiffLineType = "unchanged" | "added" | "removed"; /** * Diff line data */ interface DiffLine { type: DiffLineType; content: string; oldLineNum?: number; newLineNum?: number; } /** * Simple line-by-line diff algorithm * Uses longest common subsequence approach */ function computeDiff(oldText: string, newText: string): DiffLine[] { const oldLines = oldText.split("\n"); const newLines = newText.split("\n"); const result: DiffLine[] = []; // Build LCS table const m = oldLines.length; const n = newLines.length; const dp: number[][] = Array.from( { length: m + 1 }, () => Array(n + 1).fill(0) as number[] ) as number[][]; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { const prevDiag = (dp[i - 1]?.[j - 1] ?? 0) as number; const prevUp = (dp[i - 1]?.[j] ?? 0) as number; const prevLeft = (dp[i]?.[j - 1] ?? 0) as number; if (oldLines[i - 1] === newLines[j - 1]) { dp[i]![j] = prevDiag + 1; } else { dp[i]![j] = Math.max(prevUp, prevLeft); } } } // Backtrack to find diff let i = m; let j = n; const stack: DiffLine[] = []; while (i > 0 || j > 0) { if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { stack.push({ type: "unchanged", content: oldLines[i - 1] ?? "", oldLineNum: i, newLineNum: j, }); i--; j--; } else if ( j > 0 && (i === 0 || (dp[i]?.[j - 1] ?? 0) >= (dp[i - 1]?.[j] ?? 0)) ) { stack.push({ type: "added", content: newLines[j - 1] ?? "", newLineNum: j, }); j--; } else if (i > 0) { stack.push({ type: "removed", content: oldLines[i - 1] ?? "", oldLineNum: i, }); i--; } } // Reverse to get correct order while (stack.length > 0) { result.push(stack.pop()!); } return result; } /** * Diff Viewer component * * @component * @example * ```tsx * setShowDiff(false)} * /> * ``` */ export function DiffViewer({ oldContent, newContent, oldLabel, newLabel, onClose, }: DiffViewerProps): React.ReactElement { const [viewMode, setViewMode] = useState<"split" | "unified">("split"); const triggerRef = useRef(null); // Store the trigger element when modal mounts useEffect(() => { triggerRef.current = document.activeElement as HTMLElement; }, []); // Focus trap for accessibility const { containerRef } = useFocusTrap({ enabled: true, onEscape: onClose, returnFocusTo: triggerRef.current, }); const diffLines = useMemo( () => computeDiff(oldContent, newContent), [oldContent, newContent] ); const stats = useMemo(() => { let additions = 0; let deletions = 0; for (const line of diffLines) { if (line.type === "added") additions++; if (line.type === "removed") deletions++; } return { additions, deletions }; }, [diffLines]); const handleOverlayClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) onClose(); }; return (
{/* Header */}

Compare Versions

+{stats.additions} -{stats.deletions}
{/* Content */}
{viewMode === "split" ? ( ) : ( )}
); } /** * Split view component */ function SplitView({ diffLines, oldLabel, newLabel, }: { diffLines: DiffLine[]; oldLabel: string; newLabel: string; }): React.ReactElement { return (
{/* Old content column */}
{oldLabel}
{diffLines.map((line, idx) => { if (line.type === "added") { return (
); } return (
{line.oldLineNum} {line.type === "removed" && ( - )} {line.content}
); })}
{/* New content column */}
{newLabel}
{diffLines.map((line, idx) => { if (line.type === "removed") { return (
); } return (
{line.newLineNum} {line.type === "added" && ( + )} {line.content}
); })}
); } /** * Unified view component */ function UnifiedView({ diffLines, oldLabel, newLabel, }: { diffLines: DiffLine[]; oldLabel: string; newLabel: string; }): React.ReactElement { return (
{oldLabel} {newLabel}
{diffLines.map((line, idx) => (
{line.oldLineNum ?? ""} {line.newLineNum ?? ""} {line.type !== "unchanged" && ( {line.type === "added" ? "+" : "-"} )} {line.content}
))}
); }