import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { ArrowLeft, FileText, ChevronDown, ChevronUp, RefreshCw, GitBranch } from "lucide-react"; import { makeRequest } from "@/lib/api"; import type { WorktreeDiff, DiffChunkType, DiffChunk, BranchStatus } from "shared/types"; interface ApiResponse { success: boolean; data: T | null; message: string | null; } export function TaskAttemptComparePage() { const { projectId, taskId, attemptId } = useParams<{ projectId: string; taskId: string; attemptId: string; }>(); const navigate = useNavigate(); const [diff, setDiff] = useState(null); const [branchStatus, setBranchStatus] = useState(null); const [loading, setLoading] = useState(true); const [branchStatusLoading, setBranchStatusLoading] = useState(true); const [error, setError] = useState(null); const [merging, setMerging] = useState(false); const [rebasing, setRebasing] = useState(false); const [mergeSuccess, setMergeSuccess] = useState(false); const [rebaseSuccess, setRebaseSuccess] = useState(false); const [expandedSections, setExpandedSections] = useState>(new Set()); useEffect(() => { if (projectId && taskId && attemptId) { fetchDiff(); fetchBranchStatus(); } }, [projectId, taskId, attemptId]); const fetchDiff = async () => { if (!projectId || !taskId || !attemptId) return; try { setLoading(true); const response = await makeRequest( `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/diff` ); if (response.ok) { const result: ApiResponse = await response.json(); if (result.success && result.data) { setDiff(result.data); } else { setError("Failed to load diff"); } } else { setError("Failed to load diff"); } } catch (err) { setError("Failed to load diff"); } finally { setLoading(false); } }; const fetchBranchStatus = async () => { if (!projectId || !taskId || !attemptId) return; try { setBranchStatusLoading(true); const response = await makeRequest( `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/branch-status` ); if (response.ok) { const result: ApiResponse = await response.json(); if (result.success && result.data) { setBranchStatus(result.data); } else { setError("Failed to load branch status"); } } else { setError("Failed to load branch status"); } } catch (err) { setError("Failed to load branch status"); } finally { setBranchStatusLoading(false); } }; const handleBackClick = () => { navigate(`/projects/${projectId}/tasks/${taskId}`); }; const handleMergeClick = async () => { if (!projectId || !taskId || !attemptId) return; try { setMerging(true); const response = await makeRequest( `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/merge`, { method: 'POST', } ); if (response.ok) { const result: ApiResponse = await response.json(); if (result.success) { setMergeSuccess(true); // Optionally refetch the diff to show updated state fetchDiff(); } else { setError("Failed to merge changes"); } } else { setError("Failed to merge changes"); } } catch (err) { setError("Failed to merge changes"); } finally { setMerging(false); } }; const handleRebaseClick = async () => { if (!projectId || !taskId || !attemptId) return; try { setRebasing(true); const response = await makeRequest( `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/rebase`, { method: 'POST', } ); if (response.ok) { const result: ApiResponse = await response.json(); if (result.success) { setRebaseSuccess(true); // Refresh both diff and branch status after rebase fetchDiff(); fetchBranchStatus(); } else { setError(result.message || "Failed to rebase branch"); } } else { setError("Failed to rebase branch"); } } catch (err) { setError("Failed to rebase branch"); } finally { setRebasing(false); } }; const getChunkClassName = (chunkType: DiffChunkType) => { const baseClass = "font-mono text-sm whitespace-pre px-3 py-1"; switch (chunkType) { case 'Insert': return `${baseClass} bg-green-50 text-green-800 border-l-2 border-green-400`; case 'Delete': return `${baseClass} bg-red-50 text-red-800 border-l-2 border-red-400`; case 'Equal': default: return `${baseClass} text-gray-700`; } }; const getChunkPrefix = (chunkType: DiffChunkType) => { switch (chunkType) { case 'Insert': return '+'; case 'Delete': return '-'; case 'Equal': default: return ' '; } }; interface ProcessedLine { content: string; chunkType: DiffChunkType; lineNumber: number; } interface ProcessedSection { type: 'context' | 'change' | 'expanded'; lines: ProcessedLine[]; expandKey?: string; expandedAbove?: boolean; expandedBelow?: boolean; } const processFileChunks = (chunks: DiffChunk[], fileIndex: number) => { const CONTEXT_LINES = 3; const lines: ProcessedLine[] = []; let currentLineNumber = 1; // Convert chunks to lines with line numbers chunks.forEach(chunk => { const chunkLines = chunk.content.split('\n'); chunkLines.forEach((line, index) => { if (index < chunkLines.length - 1 || line !== '') { // Skip empty last line from split lines.push({ content: line, chunkType: chunk.chunk_type, lineNumber: currentLineNumber++ }); } }); }); const sections: ProcessedSection[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (line.chunkType === 'Equal') { // Look for the next change or end of file let nextChangeIndex = i + 1; while (nextChangeIndex < lines.length && lines[nextChangeIndex].chunkType === 'Equal') { nextChangeIndex++; } const contextLength = nextChangeIndex - i; const hasNextChange = nextChangeIndex < lines.length; const hasPrevChange = sections.length > 0 && sections[sections.length - 1].type === 'change'; if (contextLength <= CONTEXT_LINES * 2 || (!hasPrevChange && !hasNextChange)) { // Show all context if it's short or if there are no changes around it sections.push({ type: 'context', lines: lines.slice(i, nextChangeIndex) }); } else { // Split into context sections with expandable middle if (hasPrevChange) { // Add context after previous change sections.push({ type: 'context', lines: lines.slice(i, i + CONTEXT_LINES) }); i += CONTEXT_LINES; } if (hasNextChange) { // Add expandable section const expandStart = hasPrevChange ? i : i + CONTEXT_LINES; const expandEnd = nextChangeIndex - CONTEXT_LINES; if (expandEnd > expandStart) { const expandKey = `${fileIndex}-${expandStart}-${expandEnd}`; const isExpanded = expandedSections.has(expandKey); if (isExpanded) { sections.push({ type: 'expanded', lines: lines.slice(expandStart, expandEnd), expandKey }); } else { sections.push({ type: 'context', lines: [], expandKey }); } } // Add context before next change sections.push({ type: 'context', lines: lines.slice(nextChangeIndex - CONTEXT_LINES, nextChangeIndex) }); } else if (!hasPrevChange) { // No changes around, just show first few lines sections.push({ type: 'context', lines: lines.slice(i, i + CONTEXT_LINES) }); } } i = nextChangeIndex; } else { // Found a change, collect all consecutive changes const changeStart = i; while (i < lines.length && lines[i].chunkType !== 'Equal') { i++; } sections.push({ type: 'change', lines: lines.slice(changeStart, i) }); } } return sections; }; const toggleExpandSection = (expandKey: string) => { setExpandedSections(prev => { const newSet = new Set(prev); if (newSet.has(expandKey)) { newSet.delete(expandKey); } else { newSet.add(expandKey); } return newSet; }); }; if (loading || branchStatusLoading) { return (

Loading diff...

); } if (error) { return (

{error}

); } return (

Compare Changes

{/* Branch Status */} {!branchStatusLoading && branchStatus && (
{branchStatus.up_to_date ? ( Up to date ) : branchStatus.is_behind === true ? ( {branchStatus.commits_behind} commit{branchStatus.commits_behind !== 1 ? 's' : ''} behind main ) : ( {branchStatus.commits_ahead} commit{branchStatus.commits_ahead !== 1 ? 's' : ''} ahead of main )}
)} {/* Success Messages */} {rebaseSuccess && (
Branch rebased successfully!
)} {mergeSuccess && (
Changes merged successfully!
)} {/* Action Buttons */}
{branchStatus && branchStatus.is_behind === true && ( )}
Diff: Base Commit vs. Current Worktree

Shows changes made in the task attempt worktree compared to the base commit

{!diff || diff.files.length === 0 ? (

No changes detected

The worktree is identical to the base commit

) : (
{diff.files.map((file, fileIndex) => (

{file.path}

{processFileChunks(file.chunks, fileIndex).map((section, sectionIndex) => { if (section.type === 'context' && section.lines.length === 0 && section.expandKey) { // Render expand button const lineCount = parseInt(section.expandKey.split('-')[2]) - parseInt(section.expandKey.split('-')[1]); return (
); } // Render lines (context, change, or expanded) return (
{section.type === 'expanded' && section.expandKey && ( )} {section.lines.map((line, lineIndex) => (
{getChunkPrefix(line.chunkType)}{line.content}
))}
); })}
))}
)}
); }