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,
fetchTaskCommitAssociations,
type TaskDiff,
type TaskCommitAssociationRow,
} 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).
*
* Done-task sources of truth:
* - Authoritative landed diff: `/api/tasks/:id/diff` lineage union.
* - Fallback labels: `Final commit summary` (`mergeDetails`) or
* `files touched during execution` (`modifiedFiles`).
* - Never present fallback values as bare landed `files changed`.
*/
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(
fileList: string[],
isDone: boolean,
mergeDetails?: MergeDetails,
source: "landed" | "execution" = "execution",
) {
return (
{fileList.length} file{fileList.length === 1 ? "" : "s"} {source === "landed" ? "in the merged commit" : "modified during execution"}.
{isDone
// FN-4647: done-task fallback must explicitly describe executor-captured scope.
? source === "landed"
? "These are files captured from the merged commit metadata. The lineage-backed diff is unavailable for this task."
: "These are files captured from the worktree during execution. They may differ from the files that actually landed on main. The lineage-backed diff is unavailable for this task."
: "The live worktree diff is empty. Showing the last file paths captured during execution — patches unavailable."}
{fileList.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 it always attempts `/api/tasks/:id/diff` (lineage-backed when
* needed) so the detailed view stays aligned with TaskCard/useTaskDiffStats.
*
* When a done task has no recorded merge commit SHA and the server returns no
* diff rows (or diff loading fails), the tab falls back to the safe summary/
* modifiedFiles view instead of showing a hard error. This preserves the prior
* graceful behavior while allowing FN-4563/FN-4576 lineage-backed parity.
*/
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 [commitAssociations, setCommitAssociations] = useState([]);
const [lineageId, setLineageId] = 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);
const canLoad = column === "in-progress" || column === "in-review" || isDone;
const loadDiff = useCallback(async () => {
if (!canLoad && !isDone) {
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const associationsData = await fetchTaskCommitAssociations(taskId, projectId);
setLineageId(associationsData.lineageId);
setCommitAssociations(associationsData.associations);
if (!canLoad) {
setFiles([]);
setStats({ filesChanged: 0, additions: 0, deletions: 0 });
return;
}
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) {
if (isDone && !mergeDetails?.commitSha) {
setFiles([]);
setStats({ filesChanged: 0, additions: 0, deletions: 0 });
setError(null);
} else {
setError(getErrorMessage(err) || "Failed to load task changes");
}
} finally {
setLoading(false);
}
}, [taskId, projectId, canLoad, isDone, mergeDetails?.commitSha]);
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 → only show fallback state when branch-fallback diff is empty.
if (!isDone && !worktree && files.length === 0) {
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.
{hasSummary
? `Final commit summary: ${summaryFiles ?? 0} file${(summaryFiles ?? 0) === 1 ? "" : "s"} changed, +${summaryAdditions ?? 0} additions, -${summaryDeletions ?? 0} deletions. Counts only the recorded merge/squash commit, not the full task lineage.`
: "No merge commit was recorded for this task."}