- 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>
296 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|