Files
fusion/packages/dashboard/app/components/ChangedFilesModal.tsx
gsxdsm 5e18ca1b0e fix: unify diff endpoints and show changed files on done task cards
- Server /tasks/:id/diff and /tasks/:id/file-diffs now both use
  resolveDiffBase() for consistent file lists across card and modal
- Both endpoints handle done tasks via mergeDetails.commitSha,
  returning structured { files, stats } from the merge commit
- TaskChangesTab uses fetchTaskDiff for all columns (no more
  fetchCommitDiff/parsePatch client-side path for done tasks)
- useChangedFiles hook simplified — server handles done tasks
- Done task cards show "N files changed" button using mergeDetails
- Updated tests for new unified data flow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:55:46 -07:00

296 lines
9.7 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import {
FileEdit,
FileMinus,
FilePlus,
FileSymlink,
FolderGit2,
ArrowLeft,
X,
} from "lucide-react";
import { useChangedFiles } from "../hooks/useChangedFiles";
import { highlightDiff } from "../utils/highlightDiff";
import type { TaskFileDiff } from "../api";
const MOBILE_BREAKPOINT = 768;
interface ChangedFilesModalProps {
taskId: string;
worktree: string | undefined;
column: string;
projectId?: string;
isOpen: boolean;
onClose: () => void;
}
function getStatusLabel(status: TaskFileDiff["status"]): string {
switch (status) {
case "added":
return "A";
case "deleted":
return "D";
case "renamed":
return "R";
default:
return "M";
}
}
function getStatusIcon(status: TaskFileDiff["status"]) {
switch (status) {
case "added":
return <FilePlus size={16} />;
case "deleted":
return <FileMinus size={16} />;
case "renamed":
return <FileSymlink size={16} />;
default:
return <FileEdit size={16} />;
}
}
function getDiffStat(diff: string): string {
const lines = diff.split("\n");
const statLines = lines.filter(
(line) =>
line.startsWith("diff --git") ||
line.startsWith("index ") ||
line.startsWith("--- ") ||
line.startsWith("+++ "),
);
return statLines.join("\n").trim();
}
export function ChangedFilesModal({
taskId,
worktree,
column,
projectId,
isOpen,
onClose,
}: ChangedFilesModalProps) {
const { files, loading, error, selectedFile, setSelectedFile, resetSelection } = useChangedFiles(
taskId,
worktree,
column,
projectId,
);
const [isMobile, setIsMobile] = useState(false);
const [mobileView, setMobileView] = useState<"list" | "diff">("list");
// Track whether the user has manually navigated to the diff view on mobile.
// This prevents the resize-to-mobile effect from stealing navigation intent.
const mobileDiffIntentional = useRef(false);
// Detect mobile viewport
useEffect(() => {
if (!isOpen) return;
const checkMobile = () => {
setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, [isOpen]);
// When resizing from desktop to mobile with a file selected, show diff pane
// only if the user hasn't just opened the modal (which starts at the list).
// When resizing from mobile to desktop, no special action needed (both panes visible)
useEffect(() => {
if (!isOpen || !isMobile) return;
// Only auto-switch to diff on resize if the user is actively viewing a diff
// (not the initial open where resetSelection has just cleared selectedFile)
if (selectedFile && mobileDiffIntentional.current) {
setMobileView("diff");
}
}, [isOpen, isMobile, selectedFile]);
// Auto-select first file on desktop when files load
useEffect(() => {
if (!isOpen || isMobile) return;
if (!loading && files.length > 0 && !selectedFile) {
setSelectedFile(files[0]);
}
}, [isOpen, isMobile, loading, files, selectedFile, setSelectedFile]);
// Reset mobile view and selection when modal opens.
// Always start on the file list so mobile users see changed files first.
useEffect(() => {
if (isOpen) {
setMobileView("list");
mobileDiffIntentional.current = false;
resetSelection();
}
}, [isOpen, resetSelection]);
// Escape key handler
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
// On mobile diff view, Escape goes back to list first
if (isMobile && mobileView === "diff") {
setMobileView("list");
} else {
onClose();
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose, isMobile, mobileView]);
const handleSelectFile = useCallback(
(file: TaskFileDiff) => {
setSelectedFile(file);
if (isMobile) {
mobileDiffIntentional.current = true;
setMobileView("diff");
}
},
[isMobile, setSelectedFile],
);
const handleBackToList = useCallback(() => {
setMobileView("list");
}, []);
const selectedStat = useMemo(
() => (selectedFile ? getDiffStat(selectedFile.diff) : ""),
[selectedFile],
);
if (!isOpen) return null;
const sidebarClasses = [
"file-browser-sidebar",
"changed-files-sidebar",
isMobile ? "mobile" : "",
isMobile && mobileView === "list" ? "active" : "",
]
.filter(Boolean)
.join(" ");
const contentClasses = [
"file-browser-content",
"changed-files-content",
isMobile ? "mobile" : "",
isMobile && mobileView === "diff" ? "active" : "",
]
.filter(Boolean)
.join(" ");
const showBackButton = isMobile && mobileView === "diff";
return (
<div className="modal-overlay open" onClick={onClose}>
<div
className="modal file-browser-modal changed-files-modal"
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header file-browser-modal-header">
<div className="file-browser-header-title">
<FolderGit2 size={18} />
<span>Changed Files {taskId}</span>
{showBackButton && selectedFile ? (
<span className="file-browser-header-path">{selectedFile.path}</span>
) : null}
</div>
<button className="modal-close" onClick={onClose} aria-label="Close changed files viewer">
<X size={20} />
</button>
</div>
<div className="file-browser-body changed-files-layout">
<aside className={sidebarClasses} aria-label="Changed files sidebar">
{loading ? (
<div className="gm-diff-loading changed-files-loading" role="status">
<span className="changed-files-loading-spinner" aria-hidden="true" />
<span>Loading changed files</span>
</div>
) : error ? (
<div className="gm-diff-error changed-files-error" role="alert">
<span className="changed-files-error-icon" aria-hidden="true"></span>
<span>{error}</span>
</div>
) : files.length === 0 ? (
<div className="file-browser-empty changed-files-empty">
<span className="changed-files-empty-icon" aria-hidden="true">📁</span>
<span>No files changed</span>
</div>
) : (
<div className="file-browser-list" role="list" aria-label="Changed files list">
{files.map((file, index) => {
const active =
selectedFile?.path === file.path && selectedFile?.oldPath === file.oldPath;
return (
<button
key={`${file.oldPath ?? ""}:${file.path}`}
type="button"
role="listitem"
aria-label={file.path}
aria-current={active ? "true" : undefined}
className={`file-node file-node--file changed-files-entry ${active ? "active" : ""}`}
onClick={() => handleSelectFile(file)}
>
<span className="file-node-icon">{getStatusIcon(file.status)}</span>
<span className="file-node-name">{file.path}</span>
<span className={`detail-column-badge changed-files-badge changed-files-badge--${file.status}`}>
{getStatusLabel(file.status)}
</span>
</button>
);
})}
</div>
)}
</aside>
<section className={contentClasses}>
{selectedFile ? (
<div
className="gm-diff-section changed-files-diff-section"
aria-label={`Diff for ${selectedFile.path}`}
>
<div className="file-browser-toolbar">
<div className="file-browser-file-info">
{showBackButton && (
<button
className="changed-files-back-button"
onClick={handleBackToList}
aria-label="Back to file list"
>
<ArrowLeft size={16} />
<span>Back</span>
</button>
)}
<strong>{selectedFile.path}</strong>
<span className={`detail-column-badge changed-files-badge changed-files-badge--${selectedFile.status}`}>
{getStatusLabel(selectedFile.status)}
</span>
{selectedFile.oldPath ? (
<span className="changed-files-renamed">Renamed from {selectedFile.oldPath}</span>
) : null}
</div>
</div>
<div className="gm-diff-viewer">
{selectedStat ? <pre className="gm-diff-stat">{selectedStat}</pre> : null}
<pre className="gm-diff-patch">
<code>{highlightDiff(selectedFile.diff || "No diff available")}</code>
</pre>
</div>
</div>
) : !loading && !error && files.length > 0 ? (
<div className="file-browser-empty changed-files-empty">
Select a file to view changes
</div>
) : null}
</section>
</div>
</div>
</div>
);
}