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 { getErrorMessage } from "@fusion/core";
import { fetchTaskDiff, type TaskDiff } from "../api";
import { highlightDiff } from "../utils/highlightDiff";
import { ChangesDiffModal } from "./ChangesDiffModal";
import "./TaskDiffShared.css";
import "./TaskChangesTab.css";
interface TaskChangesTabProps {
taskId: string;
worktree?: string;
projectId?: string;
column?: Column;
mergeDetails?: MergeDetails;
/**
* Files modified by the task during execution, captured from the worktree.
* Used as a last-resort fallback when the live worktree diff is empty or the
* recorded `mergeDetails.commitSha` resolves to an empty git commit (which
* can happen when the merger stores a per-branch SHA that gets collapsed
* into a different squash on main). Without this, the tab would show "no
* changes" while the card shows N.
*/
modifiedFiles?: string[];
}
function getStatusLabel(status: "added" | "modified" | "deleted" | "unknown"): string {
switch (status) {
case "added":
return "A";
case "deleted":
return "D";
case "modified":
return "M";
default:
return "?";
}
}
function renderModifiedFilesFallback(
modifiedFiles: string[],
isDone: boolean,
mergeDetails?: MergeDetails,
) {
return (
{isDone
? "The recorded merge commit has no diff (likely collapsed into a squash on main). Showing file paths only — patches unavailable."
: "The live worktree diff is empty. Showing the last file paths captured during execution — patches unavailable."}
{modifiedFiles.map((path) => (
{getStatusLabel("unknown")}
{path}
))}
);
}
/** 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, modifiedFiles }: 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) {
setError(getErrorMessage(err) || "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) {
if (modifiedFiles && modifiedFiles.length > 0) {
return renderModifiedFilesFallback(modifiedFiles, false);
}
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) {
if (modifiedFiles && modifiedFiles.length > 0) {
return renderModifiedFilesFallback(modifiedFiles, true, mergeDetails);
}
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."}