import React, { useState } from "react"; interface DiffLine { type: "add" | "del" | "context" | "hunk-header"; content: string; oldLine?: number; newLine?: number; } interface FileDiff { oldPath: string; newPath: string; additions: number; deletions: number; lines: DiffLine[]; isBinary: boolean; } /** * Parse a unified diff string into structured FileDiff objects */ const MAX_FILES = 300; const MAX_LINES = 10000; export function parseUnifiedDiff(rawDiff: string): { files: FileDiff[], isTruncated: boolean } { const files: FileDiff[] = []; if (!rawDiff.trim()) return { files, isTruncated: false }; const diffSections = rawDiff.split(/^diff --git /m).filter(Boolean); let totalLinesParsed = 0; let isTruncated = false; for (const section of diffSections) { if (files.length >= MAX_FILES || totalLinesParsed >= MAX_LINES) { isTruncated = true; break; } const lines = section.split("\n"); const headerLine = lines[0] || ""; // Parse file paths from "a/path b/path" const pathMatch = headerLine.match(/a\/(.+?) b\/(.+)/); const oldPath = pathMatch?.[1] || ""; const newPath = pathMatch?.[2] || ""; const isBinary = section.includes("Binary files"); let additions = 0; let deletions = 0; const diffLines: DiffLine[] = []; let oldLine = 0; let newLine = 0; for (let i = 1; i < lines.length; i++) { const line = lines[i]; // Skip git metadata lines if ( line.startsWith("index ") || line.startsWith("---") || line.startsWith("+++") || line.startsWith("old mode") || line.startsWith("new mode") || line.startsWith("new file mode") || line.startsWith("deleted file mode") || line.startsWith("similarity index") || line.startsWith("rename from") || line.startsWith("rename to") || line.startsWith("copy from") || line.startsWith("copy to") ) { continue; } // Hunk header const hunkMatch = line.match( /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)/, ); if (hunkMatch) { oldLine = parseInt(hunkMatch[1], 10); newLine = parseInt(hunkMatch[2], 10); diffLines.push({ type: "hunk-header", content: line, }); continue; } if (line.startsWith("+")) { additions++; diffLines.push({ type: "add", content: line.slice(1), newLine: newLine++, }); } else if (line.startsWith("-")) { deletions++; diffLines.push({ type: "del", content: line.slice(1), oldLine: oldLine++, }); } else if (line.startsWith(" ") || line === "") { diffLines.push({ type: "context", content: line.startsWith(" ") ? line.slice(1) : line, oldLine: oldLine++, newLine: newLine++, }); } totalLinesParsed++; } files.push({ oldPath, newPath, additions, deletions, lines: diffLines, isBinary, }); } return { files, isTruncated }; } interface DiffViewProps { rawDiff: string; /** Base ref name for linking */ baseRef?: string; /** Head ref name for linking */ headRef?: string; /** Repo base URL, e.g. /owner/repo */ repoUrl?: string; /** Show inline comment button on hover (for PR review) */ enableComments?: boolean; /** Callback when user clicks the add comment button on a line */ onAddComment?: ( filePath: string, line: number, side: "LEFT" | "RIGHT", ) => void; /** Map of file paths to approval status */ fileApprovals?: Record; /** Callback when user clicks the approve file checkbox */ onApproveFile?: (filePath: string, approved: boolean) => void; /** Code quality issues to show inline */ codeQualityIssues?: Array<{ filePath: string; line: number; message: string; severity: string; provider: string; }>; } export function DiffView({ rawDiff, repoUrl, enableComments = false, onAddComment, fileApprovals = {}, onApproveFile, codeQualityIssues, }: DiffViewProps) { const { files, isTruncated } = parseUnifiedDiff(rawDiff); const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); const toggleFile = (idx: number) => { setCollapsedFiles((prev) => { const next = new Set(prev); if (next.has(idx)) next.delete(idx); else next.add(idx); return next; }); }; if (files.length === 0) { return (
No differences found.
); } const totalAdditions = files.reduce((s, f) => s + f.additions, 0); const totalDeletions = files.reduce((s, f) => s + f.deletions, 0); return (
{/* File summary */}
Showing {files.length} changed file{files.length !== 1 ? "s" : ""} +{totalAdditions} -{totalDeletions}
{isTruncated && (
This diff is too large and has been truncated. Displaying first {files.length} files.
)}
{files.map((file, idx) => ( {file.newPath || file.oldPath} ))}
{/* File diffs */} {files.map((file, fileIdx) => (
{/* File header */}
toggleFile(fileIdx)} >
{collapsedFiles.has(fileIdx) ? "▶" : "▼"} {file.oldPath !== file.newPath ? `${file.oldPath} → ${file.newPath}` : file.newPath}
+{file.additions} -{file.deletions} {repoUrl && ( e.stopPropagation()} > View file )} {enableComments && ( )}
{/* Diff lines */} {!collapsedFiles.has(fileIdx) && (
{file.isBinary ? (
Binary file not shown.
) : ( {file.lines.map((line, lineIdx) => { if (line.type === "hunk-header") { return ( ); } const bgClass = line.type === "add" ? "bg-[#0d2818]" : line.type === "del" ? "bg-[#2d0f15]" : ""; const lineNumClass = line.type === "add" ? "bg-[#0a3519] text-green-600" : line.type === "del" ? "bg-[#3d0f18] text-red-600" : "text-muted-foreground"; const issueForLine = line.newLine && codeQualityIssues ? codeQualityIssues.find( (i) => i.filePath === (file.newPath || file.oldPath) && i.line === line.newLine ) : undefined; return ( {/* Old line number */} {/* New line number */} {/* Code */} {issueForLine && ( )} ); })}
{line.content}
{line.type !== "add" ? line.oldLine : ""} {enableComments && line.type === "del" && line.oldLine && ( )}
{line.type !== "del" ? line.newLine : ""} {enableComments && line.type === "add" && line.newLine && ( )}
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "} {line.content}
{issueForLine.provider} {issueForLine.severity}
{issueForLine.message}
)}
)}
))}
); } function DiffBar({ additions, deletions, }: { additions: number; deletions: number; }) { const total = additions + deletions; if (total === 0) return null; const maxBlocks = 5; const addBlocks = Math.round((additions / total) * maxBlocks); const delBlocks = maxBlocks - addBlocks; return ( {Array.from({ length: addBlocks }).map((_, i) => ( ))} {Array.from({ length: delBlocks }).map((_, i) => ( ))} ); } export default DiffView;