import { useState, useEffect, useCallback } from "react"; import { FileCode, ChevronDown, ChevronRight, ChevronLeft, AlertCircle, GitCommit, WrapText, Maximize2 } from "lucide-react"; import type { MergeDetails, Column } from "@fusion/core"; import { fetchTaskDiff, type TaskDiff } from "../api"; import { highlightDiff } from "../utils/highlightDiff"; import { truncateMiddle } from "../utils/truncatePath"; import { ChangesDiffModal } from "./ChangesDiffModal"; interface TaskChangesTabProps { taskId: string; worktree?: string; projectId?: string; column?: Column; mergeDetails?: MergeDetails; } function getStatusLabel(status: "added" | "modified" | "deleted" | "unknown"): string { switch (status) { case "added": return "A"; case "deleted": return "D"; case "modified": return "M"; default: return "?"; } } /** Normalized file entry used by both worktree-backed and commit-backed paths */ interface NormalizedFile { path: string; status: "added" | "modified" | "deleted" | "unknown"; additions: number; deletions: number; patch: string; } /** * TaskChangesTab displays file-level diffs for a task. * * For in-progress/in-review tasks it loads the diff from the live worktree. * For done tasks with a recorded merge commit (mergeDetails.commitSha) it loads * the diff from git history instead, so changes remain visible even after the * worktree is cleaned up. * * For done tasks WITHOUT a recorded commit SHA, the tab shows a safe summary * fallback using the merge details numbers (filesChanged/insertions/deletions) * rather than fetching a detailed diff that could include unrelated repository * changes. This prevents inflated file counts that don't match the card-level * display. */ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetails }: TaskChangesTabProps) { const [files, setFiles] = useState([]); const [stats, setStats] = useState<{ filesChanged: number; additions: number; deletions: number }>({ filesChanged: 0, additions: 0, deletions: 0 }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expandedFiles, setExpandedFiles] = useState>(new Set()); const [currentFileIndex, setCurrentFileIndex] = useState(null); const [wordWrap, setWordWrap] = useState(true); const [expandedViewOpen, setExpandedViewOpen] = useState(false); const isDone = column === "done"; const isDoneWithCommit = isDone && Boolean(mergeDetails?.commitSha); // Done tasks without commit SHA must not fetch detailed diffs — the server // would fall back to a repository-wide scan that inflates the file list. const canLoad = (column === "in-progress" || column === "in-review") || isDoneWithCommit; const loadDiff = useCallback(async () => { if (!canLoad) { setLoading(false); return; } try { setLoading(true); setError(null); const data: TaskDiff = await fetchTaskDiff(taskId, undefined, projectId); const normalized: NormalizedFile[] = data.files.map((f) => ({ path: f.path, status: f.status, additions: f.additions, deletions: f.deletions, patch: f.patch, })); setFiles(normalized); setStats(data.stats); if (normalized.length > 0) { setExpandedFiles(new Set([normalized[0].path])); setCurrentFileIndex(0); } } catch (err: any) { setError(err.message || "Failed to load diff"); } finally { setLoading(false); } }, [taskId, projectId, canLoad]); useEffect(() => { loadDiff(); }, [loadDiff]); const toggleFile = (filePath: string) => { setExpandedFiles((prev) => { const next = new Set(prev); if (next.has(filePath)) { next.delete(filePath); } else { next.add(filePath); // Update currentFileIndex to the newly expanded file const idx = files.findIndex((f) => f.path === filePath); if (idx !== -1) { setCurrentFileIndex(idx); } } return next; }); }; const navigateToFile = (index: number) => { if (index < 0 || index >= files.length) return; const targetPath = files[index].path; // Collapse all files and expand only the target setExpandedFiles(new Set([targetPath])); setCurrentFileIndex(index); }; const canGoPrev = currentFileIndex !== null && currentFileIndex > 0; const canGoNext = currentFileIndex !== null && currentFileIndex < files.length - 1; if (loading) { return (
Loading changes...
); } if (error) { return (
Error loading changes: {error}
); } // Non-done task without a worktree → show worktree empty state if (!isDone && !worktree) { return (

No worktree available for this task.

Changes will be shown once the task is in progress.
); } // Done task without commit SHA → show safe summary fallback. // We must NOT fetch detailed diffs here because the server would fall back // to a repository-wide scan, producing an inflated/unrelated file list. if (isDone && !isDoneWithCommit) { const summaryFiles = mergeDetails?.filesChanged; const summaryAdditions = mergeDetails?.insertions; const summaryDeletions = mergeDetails?.deletions; const hasSummary = summaryFiles != null || summaryAdditions != null || summaryDeletions != null; return (

Detailed file changes unavailable.

{hasSummary ? `Merge summary: ${summaryFiles ?? 0} file${(summaryFiles ?? 0) === 1 ? "" : "s"} changed, +${summaryAdditions ?? 0} additions, -${summaryDeletions ?? 0} deletions.` : "No merge commit was recorded for this task."}
); } if (files.length === 0) { return (

No files modified.

{isDone ? "No file changes were recorded in the merge commit." : "The agent did not modify any files during execution."}
); } return (
{/* Commit metadata for done tasks */} {isDone && mergeDetails && (
{mergeDetails.commitSha && (
{mergeDetails.commitSha.slice(0, 7)}
)} {mergeDetails.mergeCommitMessage && (
{mergeDetails.mergeCommitMessage}
)} {mergeDetails.mergedAt && (
Merged {new Date(mergeDetails.mergedAt).toLocaleString()}
)}
)}

Files Changed ({stats.filesChanged})

+{stats.additions}{" "} -{stats.deletions}
{files.length > 0 && (
{currentFileIndex !== null ? `${currentFileIndex + 1}/${files.length}` : `—/${files.length}`}
)}
{files.map((file) => { const isExpanded = expandedFiles.has(file.path); return (
{isExpanded && file.patch && (
                    {highlightDiff(file.patch)}
                  
)}
); })}
setExpandedViewOpen(false)} onRefresh={loadDiff} />
); }