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). 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 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.