Files
fusion/packages/dashboard/app/components/GitManagerModal.tsx
gsxdsm 419d6cda86 FN-5877: add sync-with-origin remotes action
Add a one-click Remotes sync flow that rebases on pull before pushing.

- add a Sync button in Git Manager Remotes that runs pull --rebase and then push
- stop before pushing when the rebase pull reports a conflict or fails, and surface toasts/status updates
- cover successful, conflicting, failing, and loading sync behavior in GitManagerModal tests
- document the new Sync action in the dashboard guide

Files changed:
 docs/dashboard-guide.md                            |   1 +
 .../dashboard/app/components/GitManagerModal.tsx   |  39 ++++++++
 .../components/__tests__/GitManagerModal.test.tsx  | 100 +++++++++++++++++++++
 3 files changed, 140 insertions(+)

Fusion-Task-Id: FN-5877

Fusion-Task-Lineage: 2e28c00d-b965-4f44-a6fa-26536d91d16d
2026-06-02 08:25:46 -07:00

3126 lines
120 KiB
TypeScript

import "./ScriptsModal.css";
import { useState, useEffect, useCallback, useRef, useMemo, type CSSProperties } from "react";
import type { Task } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
import { getPathBasename } from "../utils/pathDisplay";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useViewportMode } from "../hooks/useViewportMode";
import type {
GitStatus,
GitCommit,
GitBranch,
GitWorktree,
GitFetchResult,
GitPullResult,
GitPushResult,
GitStash,
GitFileChange,
GitRemoteDetailed,
} from "../api";
import {
api,
fetchConfig,
fetchGitStatus,
fetchGitCommits,
fetchCommitDiff,
fetchGitBranches,
fetchGitWorktrees,
createBranch,
checkoutBranch,
deleteBranch,
fetchRemote,
pullBranch,
pushBranch,
fetchGitStashList,
createStash,
applyStash,
dropStash,
fetchStashDiff,
fetchFileChanges,
stageFiles,
unstageFiles,
createCommit,
discardChanges,
fetchGitFileDiff,
fetchGitRemotesDetailed,
addGitRemote,
removeGitRemote,
renameGitRemote,
updateGitRemoteUrl,
fetchAheadCommits,
fetchRemoteCommits,
fetchBranchCommits,
} from "../api";
import {
GitBranch as GitBranchIcon,
GitCommit as GitCommitIcon,
GitPullRequest,
GitMerge,
RefreshCw,
Plus,
Trash2,
ChevronRight,
ChevronDown,
Check,
X,
Loader2,
HardDrive,
Radio,
ArrowUp,
ArrowDown,
AlertCircle,
Copy,
Search,
FileText,
FolderGit2,
Archive,
FilePlus,
FileMinus,
FileEdit,
FileQuestion,
FileDiff,
CheckCircle,
XCircle,
Send,
Pencil,
Info,
} from "lucide-react";
// ── Types & Constants ─────────────────────────────────────────────
type SectionId = "status" | "changes" | "commits" | "branches" | "worktrees" | "stashes" | "remotes";
const SECTIONS: { id: SectionId; label: string; icon: React.ComponentType<{ size?: number }> }[] = [
{ id: "status", label: "Status", icon: Radio },
{ id: "changes", label: "Changes", icon: FileDiff },
{ id: "commits", label: "Commits", icon: GitCommitIcon },
{ id: "branches", label: "Branches", icon: GitBranchIcon },
{ id: "worktrees", label: "Worktrees", icon: HardDrive },
{ id: "stashes", label: "Stashes", icon: Archive },
{ id: "remotes", label: "Remotes", icon: GitMerge },
];
// ── Helper Utilities ──────────────────────────────────────────────
/** Icon for a file change status */
function FileStatusIcon({ status }: { status: GitFileChange["status"] }) {
switch (status) {
case "added":
case "untracked":
return <FilePlus size={14} className="gm-file-icon gm-file-added" />;
case "modified":
return <FileEdit size={14} className="gm-file-icon gm-file-modified" />;
case "deleted":
return <FileMinus size={14} className="gm-file-icon gm-file-deleted" />;
case "renamed":
case "copied":
return <FileText size={14} className="gm-file-icon gm-file-renamed" />;
default:
return <FileQuestion size={14} className="gm-file-icon" />;
}
}
/** Label badge for file status */
function FileStatusBadge({ status }: { status: GitFileChange["status"] }) {
const label =
status === "untracked" ? "U" :
status === "added" ? "A" :
status === "modified" ? "M" :
status === "deleted" ? "D" :
status === "renamed" ? "R" :
status === "copied" ? "C" : "?";
return <span className={`gm-file-badge gm-file-badge-${status}`}>{label}</span>;
}
/** Copy text to clipboard with toast feedback */
function useCopyToClipboard(addToast: (msg: string, type?: ToastType) => void) {
return useCallback(
async (text: string, label?: string) => {
try {
await navigator.clipboard.writeText(text);
addToast(`Copied ${label || "to clipboard"}`, "success");
} catch {
addToast("Failed to copy", "error");
}
},
[addToast]
);
}
/** Format relative date. Returns "—" for invalid/empty dates. */
function relativeDate(dateStr: string | undefined | null): string {
if (!dateStr) return "—";
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "—";
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
// ── Props ─────────────────────────────────────────────────────────
interface GitManagerModalProps {
isOpen: boolean;
onClose: () => void;
tasks: Task[];
addToast: (message: string, type?: ToastType) => void;
projectId?: string;
}
// ── Main Component ────────────────────────────────────────────────
export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, projectId }: GitManagerModalProps) {
const confirmContext = useConfirm();
const viewportMode = useViewportMode();
useMobileScrollLock(isOpen);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: viewportMode === "mobile",
});
const keyboardStyle: CSSProperties = keyboardOpen
? ({
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as CSSProperties)
: {};
const handleClose = useCallback(() => {
if (viewportMode === "mobile") {
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement) {
activeElement.blur();
}
window.scrollTo(0, 0);
requestAnimationFrame(() => {
window.scrollTo(0, 0);
});
}
onClose();
}, [onClose, viewportMode]);
const [activeSection, setActiveSection] = useState<SectionId>("status");
const [loading, setLoading] = useState(false);
const [sectionError, setSectionError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, isOpen, "fusion:git-modal-size");
const overlayDismissProps = useOverlayDismiss(handleClose);
const copyToClipboard = useCopyToClipboard(addToast);
// ── Status state
const [status, setStatus] = useState<GitStatus | null>(null);
const [rootDir, setRootDir] = useState<string | null>(null);
// ── Changes state
const [fileChanges, setFileChanges] = useState<GitFileChange[]>([]);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [commitMessage, setCommitMessage] = useState("");
const [committing, setCommitting] = useState(false);
const [changeDiff, setChangeDiff] = useState<{ stat: string; patch: string } | null>(null);
const [loadingChangeDiff, setLoadingChangeDiff] = useState(false);
const [changeDiffError, setChangeDiffError] = useState<string | null>(null);
const [selectedDiffTarget, setSelectedDiffTarget] = useState<{ file: string; staged: boolean } | null>(null);
const changeDiffRequestIdRef = useRef(0);
// ── Commits state
const [commits, setCommits] = useState<GitCommit[]>([]);
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
const [commitDiff, setCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
const [loadingDiff, setLoadingDiff] = useState(false);
const [commitsLimit, setCommitsLimit] = useState(20);
const [commitSearch, setCommitSearch] = useState("");
// ── Branches state
const [branches, setBranches] = useState<GitBranch[]>([]);
const [newBranchName, setNewBranchName] = useState("");
const [branchBase, setBranchBase] = useState("");
const [branchSearch, setBranchSearch] = useState("");
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
const [branchCommits, setBranchCommits] = useState<GitCommit[]>([]);
const [loadingBranchCommits, setLoadingBranchCommits] = useState(false);
const [expandedBranchCommit, setExpandedBranchCommit] = useState<string | null>(null);
const [branchCommitDiff, setBranchCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
const [loadingBranchCommitDiff, setLoadingBranchCommitDiff] = useState(false);
// ── Worktrees state
const [worktrees, setWorktrees] = useState<GitWorktree[]>([]);
// ── Stashes state
const [stashes, setStashes] = useState<GitStash[]>([]);
const [stashMessage, setStashMessage] = useState("");
const [stashLoading, setStashLoading] = useState<string | null>(null);
const [expandedStashIndex, setExpandedStashIndex] = useState<number | null>(null);
const [stashDiff, setStashDiff] = useState<{ stat: string; patch: string } | null>(null);
const [loadingStashDiff, setLoadingStashDiff] = useState(false);
const [stashDiffError, setStashDiffError] = useState<string | null>(null);
const stashDiffRequestIdRef = useRef(0);
// ── Remotes state
const [remoteLoading, setRemoteLoading] = useState<string | null>(null);
const [lastRemoteResult, setLastRemoteResult] = useState<GitFetchResult | GitPullResult | GitPushResult | null>(null);
// ── Data Fetching ───────────────────────────────────────────────
const fetchSectionData = useCallback(async () => {
if (!isOpen) return;
setLoading(true);
setSectionError(null);
try {
switch (activeSection) {
case "status": {
const statusData = await fetchGitStatus(projectId, { extended: true });
setStatus(statusData);
break;
}
case "changes": {
const [statusData, changes] = await Promise.all([fetchGitStatus(projectId, { extended: true }), fetchFileChanges(projectId)]);
setStatus(statusData);
setFileChanges(changes);
setSelectedFiles(new Set());
setSelectedDiffTarget(null);
setChangeDiff(null);
setChangeDiffError(null);
break;
}
case "commits": {
const commitsData = await fetchGitCommits(commitsLimit, projectId);
setCommits(commitsData);
break;
}
case "branches": {
const [branchesData, statusForBranch] = await Promise.all([fetchGitBranches(projectId), fetchGitStatus(projectId, { extended: true })]);
setBranches(branchesData);
setStatus(statusForBranch);
break;
}
case "worktrees": {
const worktreesData = await fetchGitWorktrees(projectId);
setWorktrees(worktreesData);
break;
}
case "stashes": {
const stashesData = await fetchGitStashList(projectId);
setStashes(stashesData);
setExpandedStashIndex(null);
setStashDiff(null);
setStashDiffError(null);
stashDiffRequestIdRef.current += 1;
break;
}
case "remotes": {
const remoteStatus = await fetchGitStatus(projectId, { extended: true });
setStatus(remoteStatus);
break;
}
}
} catch (err) {
setSectionError(getErrorMessage(err) || "Failed to fetch git data");
addToast(getErrorMessage(err) || "Failed to fetch git data", "error");
} finally {
setLoading(false);
}
}, [activeSection, isOpen, commitsLimit, addToast, projectId]);
useEffect(() => {
if (isOpen) {
fetchSectionData();
}
}, [fetchSectionData, isOpen]);
// ── Keyboard Navigation ─────────────────────────────────────────
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
return;
}
// Arrow key navigation between sections
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && e.altKey) {
e.preventDefault();
const currentIndex = SECTIONS.findIndex((s) => s.id === activeSection);
if (e.key === "ArrowUp" && currentIndex > 0) {
setActiveSection(SECTIONS[currentIndex - 1].id);
} else if (e.key === "ArrowDown" && currentIndex < SECTIONS.length - 1) {
setActiveSection(SECTIONS[currentIndex + 1].id);
}
}
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [isOpen, handleClose, activeSection]);
// ── Changes Handlers ────────────────────────────────────────────
const handleStageFiles = useCallback(async (files: string[]) => {
try {
await stageFiles(files, projectId);
addToast(`Staged ${files.length} file(s)`, "success");
const changes = await fetchFileChanges(projectId);
setFileChanges(changes);
setSelectedFiles(new Set());
setSelectedDiffTarget(null);
setChangeDiff(null);
setChangeDiffError(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to stage files", "error");
}
}, [addToast, projectId]);
const handleUnstageFiles = useCallback(async (files: string[]) => {
try {
await unstageFiles(files, projectId);
addToast(`Unstaged ${files.length} file(s)`, "success");
const changes = await fetchFileChanges(projectId);
setFileChanges(changes);
setSelectedFiles(new Set());
setSelectedDiffTarget(null);
setChangeDiff(null);
setChangeDiffError(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to unstage files", "error");
}
}, [addToast, projectId]);
const handleDiscardChanges = useCallback(async (files: string[]) => {
const shouldDiscard = await confirmContext.confirm({
title: "Discard Changes",
message: `Discard changes to ${files.length} file(s)? This cannot be undone.`,
danger: true,
});
if (!shouldDiscard) return;
try {
await discardChanges(files, projectId);
addToast(`Discarded changes to ${files.length} file(s)`, "success");
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]);
setFileChanges(changes);
setStatus(statusData);
setSelectedFiles(new Set());
setSelectedDiffTarget(null);
setChangeDiff(null);
setChangeDiffError(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to discard changes", "error");
}
}, [addToast, projectId, confirmContext]);
const handleCommit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!commitMessage.trim()) return;
setCommitting(true);
try {
const result = await createCommit(commitMessage.trim(), projectId);
addToast(`Committed: ${result.hash}`, "success");
setCommitMessage("");
// Refresh changes and status
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]);
setFileChanges(changes);
setStatus(statusData);
setSelectedDiffTarget(null);
setChangeDiff(null);
setChangeDiffError(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to commit", "error");
} finally {
setCommitting(false);
}
}, [commitMessage, addToast, projectId]);
const handleStageAllAndCommit = useCallback(async () => {
if (!commitMessage.trim()) return;
setCommitting(true);
try {
const unstaged = fileChanges.filter((f) => !f.staged).map((f) => f.file);
if (unstaged.length > 0) {
await stageFiles(unstaged, projectId);
}
const result = await createCommit(commitMessage.trim(), projectId);
addToast(`Committed: ${result.hash}`, "success");
setCommitMessage("");
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]);
setFileChanges(changes);
setStatus(statusData);
setSelectedDiffTarget(null);
setChangeDiff(null);
setChangeDiffError(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to commit", "error");
} finally {
setCommitting(false);
}
}, [commitMessage, fileChanges, addToast, projectId]);
const handleSelectDiffFile = useCallback(async (file: string, staged: boolean) => {
setSelectedDiffTarget({ file, staged });
setLoadingChangeDiff(true);
setChangeDiffError(null);
const requestId = changeDiffRequestIdRef.current + 1;
changeDiffRequestIdRef.current = requestId;
try {
const diff = await fetchGitFileDiff(file, staged, projectId);
if (changeDiffRequestIdRef.current !== requestId) {
return;
}
setChangeDiff(diff);
} catch (err) {
if (changeDiffRequestIdRef.current !== requestId) {
return;
}
const errorMessage = getErrorMessage(err) || "Failed to load file diff";
setChangeDiff(null);
setChangeDiffError(errorMessage);
addToast(errorMessage, "error");
} finally {
if (changeDiffRequestIdRef.current === requestId) {
setLoadingChangeDiff(false);
}
}
}, [addToast, projectId]);
const toggleFileSelection = useCallback((file: string) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(file)) {
next.delete(file);
} else {
next.add(file);
}
return next;
});
}, []);
// ── Commit Handlers ─────────────────────────────────────────────
const handleCommitClick = useCallback(async (hash: string) => {
if (selectedCommit === hash) {
setSelectedCommit(null);
setCommitDiff(null);
return;
}
setSelectedCommit(hash);
setLoadingDiff(true);
try {
const diff = await fetchCommitDiff(hash, projectId);
setCommitDiff(diff);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to load diff", "error");
setCommitDiff(null);
} finally {
setLoadingDiff(false);
}
}, [selectedCommit, addToast, projectId]);
const handleLoadMoreCommits = useCallback(() => {
setCommitsLimit((prev) => Math.min(prev + 20, 100));
}, []);
const filteredCommits = useMemo(() => {
if (!commitSearch.trim()) return commits;
const q = commitSearch.toLowerCase();
return commits.filter(
(c) =>
c.message.toLowerCase().includes(q) ||
c.author.toLowerCase().includes(q) ||
c.shortHash.toLowerCase().includes(q)
);
}, [commits, commitSearch]);
// ── Branch Handlers ─────────────────────────────────────────────
const handleCreateBranch = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!newBranchName.trim()) return;
setLoading(true);
try {
await createBranch(newBranchName.trim(), branchBase.trim() || undefined, projectId);
addToast(`Created branch ${newBranchName}`, "success");
setNewBranchName("");
setBranchBase("");
const branchesData = await fetchGitBranches(projectId);
setBranches(branchesData);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to create branch", "error");
} finally {
setLoading(false);
}
}, [newBranchName, branchBase, addToast, projectId]);
const handleCheckoutBranch = useCallback(async (name: string) => {
setLoading(true);
try {
await checkoutBranch(name, projectId);
addToast(`Switched to ${name}`, "success");
const [statusData, branchesData] = await Promise.all([fetchGitStatus(projectId, { extended: true }), fetchGitBranches(projectId)]);
setStatus(statusData);
setBranches(branchesData);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to checkout branch", "error");
} finally {
setLoading(false);
}
}, [addToast, projectId]);
const handleDeleteBranch = useCallback(async (name: string) => {
const shouldDelete = await confirmContext.confirm({
title: "Delete Branch",
message: `Delete branch "${name}"?`,
danger: true,
});
if (!shouldDelete) return;
setLoading(true);
try {
await deleteBranch(name, undefined, projectId);
addToast(`Deleted branch ${name}`, "success");
const branchesData = await fetchGitBranches(projectId);
setBranches(branchesData);
} catch (err) {
if (getErrorMessage(err).includes("not fully merged")) {
const shouldForceDelete = await confirmContext.confirm({
title: "Force Delete Branch",
message: "Branch has unmerged commits. Force delete?",
danger: true,
});
if (shouldForceDelete) {
try {
await deleteBranch(name, true, projectId);
addToast(`Force deleted branch ${name}`, "success");
const branchesData = await fetchGitBranches(projectId);
setBranches(branchesData);
} catch (forceErr) {
addToast(getErrorMessage(forceErr) || "Failed to delete branch", "error");
}
}
} else {
addToast(getErrorMessage(err) || "Failed to delete branch", "error");
}
} finally {
setLoading(false);
}
}, [addToast, projectId, confirmContext]);
const filteredBranches = useMemo(() => {
if (!branchSearch.trim()) return branches;
const q = branchSearch.toLowerCase();
return branches.filter((b) => b.name.toLowerCase().includes(q));
}, [branches, branchSearch]);
// ── Branch Selection Handlers ───────────────────────────────────
/** Toggle branch selection to show commits for that branch */
const handleSelectBranch = useCallback(async (name: string) => {
if (selectedBranch === name) {
// Deselect
setSelectedBranch(null);
setBranchCommits([]);
setExpandedBranchCommit(null);
setBranchCommitDiff(null);
return;
}
setSelectedBranch(name);
setBranchCommits([]);
setExpandedBranchCommit(null);
setBranchCommitDiff(null);
setLoadingBranchCommits(true);
try {
const data = await fetchBranchCommits(name, 10, projectId);
setBranchCommits(data);
} catch {
setBranchCommits([]);
} finally {
setLoadingBranchCommits(false);
}
}, [selectedBranch, projectId]);
/** Click a commit in the branch view to expand/collapse its diff */
const handleBranchCommitClick = useCallback(async (hash: string) => {
if (expandedBranchCommit === hash) {
setExpandedBranchCommit(null);
setBranchCommitDiff(null);
return;
}
setExpandedBranchCommit(hash);
setBranchCommitDiff(null);
setLoadingBranchCommitDiff(true);
try {
const diff = await fetchCommitDiff(hash, projectId);
setBranchCommitDiff(diff);
} catch {
setBranchCommitDiff(null);
} finally {
setLoadingBranchCommitDiff(false);
}
}, [expandedBranchCommit, projectId]);
/** Close branch details panel */
const handleCloseBranchDetails = useCallback(() => {
setSelectedBranch(null);
setBranchCommits([]);
setExpandedBranchCommit(null);
setBranchCommitDiff(null);
}, []);
// ── Stash Handlers ──────────────────────────────────────────────
const resetStashDiffState = useCallback(() => {
stashDiffRequestIdRef.current += 1;
setExpandedStashIndex(null);
setStashDiff(null);
setStashDiffError(null);
setLoadingStashDiff(false);
}, []);
const handleCreateStash = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setStashLoading("create");
resetStashDiffState();
try {
await createStash(stashMessage.trim() || undefined, projectId);
addToast("Changes stashed", "success");
setStashMessage("");
const stashesData = await fetchGitStashList(projectId);
setStashes(stashesData);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to stash changes", "error");
} finally {
setStashLoading(null);
}
}, [stashMessage, addToast, projectId, resetStashDiffState]);
const handleApplyStash = useCallback(async (index: number, drop: boolean = false) => {
setStashLoading(`apply-${index}`);
resetStashDiffState();
try {
await applyStash(index, drop, projectId);
addToast(drop ? "Stash popped" : "Stash applied", "success");
const stashesData = await fetchGitStashList(projectId);
setStashes(stashesData);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to apply stash", "error");
} finally {
setStashLoading(null);
}
}, [addToast, projectId, resetStashDiffState]);
const handleDropStash = useCallback(async (index: number) => {
const shouldDrop = await confirmContext.confirm({
title: "Drop Stash",
message: `Drop stash@{${index}}? This cannot be undone.`,
danger: true,
});
if (!shouldDrop) return;
setStashLoading(`drop-${index}`);
resetStashDiffState();
try {
await dropStash(index, projectId);
addToast("Stash dropped", "success");
const stashesData = await fetchGitStashList(projectId);
setStashes(stashesData);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to drop stash", "error");
} finally {
setStashLoading(null);
}
}, [addToast, projectId, confirmContext, resetStashDiffState]);
const handleToggleStashDiff = useCallback(async (index: number) => {
if (expandedStashIndex === index) {
resetStashDiffState();
return;
}
const requestId = stashDiffRequestIdRef.current + 1;
stashDiffRequestIdRef.current = requestId;
setExpandedStashIndex(index);
setStashDiff(null);
setStashDiffError(null);
setLoadingStashDiff(true);
try {
const diff = await fetchStashDiff(index, projectId);
if (stashDiffRequestIdRef.current !== requestId) {
return;
}
setStashDiff(diff);
} catch (err) {
if (stashDiffRequestIdRef.current !== requestId) {
return;
}
setStashDiff(null);
setStashDiffError(getErrorMessage(err) || "Failed to load stash diff");
} finally {
if (stashDiffRequestIdRef.current === requestId) {
setLoadingStashDiff(false);
}
}
}, [expandedStashIndex, projectId, resetStashDiffState]);
// ── Remote Handlers ─────────────────────────────────────────────
const handleFetch = useCallback(async () => {
setRemoteLoading("fetch");
try {
const result = await fetchRemote(undefined, projectId);
setLastRemoteResult(result);
addToast(result.message || "Fetch completed", result.fetched ? "success" : "info");
const statusData = await fetchGitStatus(projectId, { extended: true });
setStatus(statusData);
} catch (err) {
addToast(getErrorMessage(err) || "Fetch failed", "error");
} finally {
setRemoteLoading(null);
}
}, [addToast, projectId]);
const handlePull = useCallback(async (options?: { rebase?: boolean }) => {
setRemoteLoading("pull");
try {
const result = await pullBranch(options, projectId);
setLastRemoteResult(result);
if (result.conflict) {
addToast("Merge conflict detected. Resolve manually.", "error");
} else {
const fallbackMessage = options?.rebase ? "Pull --rebase completed" : "Pull completed";
addToast(result.message || fallbackMessage, "success");
}
const statusData = await fetchGitStatus(projectId, { extended: true });
setStatus(statusData);
} catch (err) {
addToast(getErrorMessage(err) || "Pull failed", "error");
} finally {
setRemoteLoading(null);
}
}, [addToast, projectId]);
const handlePush = useCallback(async () => {
setRemoteLoading("push");
try {
const result = await pushBranch(projectId);
setLastRemoteResult(result);
addToast(result.message || "Push completed", "success");
const statusData = await fetchGitStatus(projectId, { extended: true });
setStatus(statusData);
} catch (err) {
addToast(getErrorMessage(err) || "Push failed", "error");
} finally {
setRemoteLoading(null);
}
}, [addToast, projectId]);
const handleSyncWithOrigin = useCallback(async () => {
setRemoteLoading("sync");
try {
const pullResult = await pullBranch({ rebase: true }, projectId);
setLastRemoteResult(pullResult);
if (pullResult.conflict) {
addToast("Merge conflict detected. Resolve manually.", "error");
return;
}
const pushResult = await pushBranch(projectId);
setLastRemoteResult(pushResult);
addToast("Synced with origin (pull --rebase + push)", "success");
const statusData = await fetchGitStatus(projectId, { extended: true });
setStatus(statusData);
} catch (err) {
addToast(getErrorMessage(err) || "Sync with origin failed", "error");
} finally {
setRemoteLoading(null);
}
}, [addToast, projectId]);
// Fetch rootDir from config (used as worktreePath for the per-task sync
// button surfaced from RemotesPanel below).
useEffect(() => {
fetchConfig(projectId).then((cfg) => setRootDir(cfg.rootDir)).catch(() => setRootDir(null));
}, [projectId]);
const handleSyncIntegrationTip = useCallback(async () => {
if (!status?.integrationBranch || status.isOnIntegrationBranch === false) return;
const worktreePath = rootDir;
if (!worktreePath) {
addToast("Project root path not available", "error");
return;
}
setRemoteLoading("sync-integration");
try {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
await api(`/git/pull${query}`, {
method: "POST",
body: JSON.stringify({
worktreePath,
integrationBranch: status.integrationBranch,
taskId: undefined,
// Pure-local catch-up: the merger advanced refs/heads/<integration>
// locally; the worktree just needs to hard-reset to that ref.
// No reason to fetch/merge from origin here — that would silently
// pull in unrelated remote work the operator didn't ask for.
skipOriginFetch: true,
}),
});
addToast("Synced worktree to local integration tip", "success");
const statusData = await fetchGitStatus(projectId, { extended: true });
setStatus(statusData);
} catch (err) {
addToast(getErrorMessage(err) || "Sync failed", "error");
} finally {
setRemoteLoading(null);
}
}, [addToast, projectId, rootDir, status?.integrationBranch, status?.isOnIntegrationBranch]);
// ── Derived state ───────────────────────────────────────────────
const stagedFiles = useMemo(() => fileChanges.filter((f) => f.staged), [fileChanges]);
const unstagedFiles = useMemo(() => fileChanges.filter((f) => !f.staged), [fileChanges]);
// ── Render ──────────────────────────────────────────────────────
if (!isOpen) return null;
return (
<div className="modal-overlay open git-manager-modal-overlay" {...overlayDismissProps} role="dialog" aria-modal="true">
<div className="modal gm-modal" ref={modalRef} style={keyboardStyle}>
<div className="modal-header">
<h3>
<FolderGit2 size={18} style={{ marginRight: 8, verticalAlign: "middle" }} />
Git Manager
</h3>
<div className="gm-header-actions">
<button
className="btn btn-sm"
onClick={fetchSectionData}
disabled={loading}
title="Refresh"
>
<RefreshCw size={14} className={loading ? "spin" : ""} />
</button>
<button className="modal-close" onClick={handleClose} aria-label="Close">
<X size={18} />
</button>
</div>
</div>
<div className="gm-layout">
{/* Sidebar Navigation */}
<nav className="gm-sidebar" role="tablist" aria-label="Git Manager Sections">
{SECTIONS.map((section) => {
const Icon = section.icon;
return (
<button
key={section.id}
role="tab"
aria-selected={activeSection === section.id}
className={`gm-nav-item${activeSection === section.id ? " active" : ""}`}
onClick={() => setActiveSection(section.id)}
>
<Icon size={16} />
<span className="gm-nav-label">{section.label}</span>
</button>
);
})}
</nav>
{/* Content Area */}
<div className="gm-content" role="tabpanel">
{/* Loading overlay */}
{loading && (
<div className="gm-loading">
<Loader2 size={24} className="spin" />
<span>Loading...</span>
</div>
)}
{/* Error state */}
{sectionError && !loading && (
<div className="gm-error">
<AlertCircle size={18} />
<span>{sectionError}</span>
<button className="btn btn-sm" onClick={fetchSectionData}>
Retry
</button>
</div>
)}
{/* ── Status Panel ── */}
{activeSection === "status" && !loading && status && (
<StatusPanel
status={status}
copyToClipboard={copyToClipboard}
onSyncWorkingTree={handleSyncIntegrationTip}
syncing={remoteLoading === "sync-integration"}
/>
)}
{/* ── Changes Panel ── */}
{activeSection === "changes" && !loading && (
<ChangesPanel
status={status}
stagedFiles={stagedFiles}
unstagedFiles={unstagedFiles}
selectedFiles={selectedFiles}
toggleFileSelection={toggleFileSelection}
onStageFiles={handleStageFiles}
onUnstageFiles={handleUnstageFiles}
onDiscardChanges={handleDiscardChanges}
onSelectDiffFile={handleSelectDiffFile}
selectedDiffTarget={selectedDiffTarget}
changeDiff={changeDiff}
loadingChangeDiff={loadingChangeDiff}
changeDiffError={changeDiffError}
commitMessage={commitMessage}
setCommitMessage={setCommitMessage}
onCommit={handleCommit}
onStageAllAndCommit={handleStageAllAndCommit}
committing={committing}
/>
)}
{/* ── Commits Panel ── */}
{activeSection === "commits" && !loading && (
<CommitsPanel
commits={filteredCommits}
commitSearch={commitSearch}
setCommitSearch={setCommitSearch}
selectedCommit={selectedCommit}
commitDiff={commitDiff}
loadingDiff={loadingDiff}
onCommitClick={handleCommitClick}
onLoadMore={handleLoadMoreCommits}
canLoadMore={commits.length >= commitsLimit && commitsLimit < 100}
copyToClipboard={copyToClipboard}
/>
)}
{/* ── Branches Panel ── */}
{activeSection === "branches" && !loading && (
<BranchesPanel
branches={filteredBranches}
branchSearch={branchSearch}
setBranchSearch={setBranchSearch}
newBranchName={newBranchName}
setNewBranchName={setNewBranchName}
branchBase={branchBase}
setBranchBase={setBranchBase}
onCreateBranch={handleCreateBranch}
onCheckoutBranch={handleCheckoutBranch}
onDeleteBranch={handleDeleteBranch}
loading={loading}
allBranches={branches}
selectedBranch={selectedBranch}
branchCommits={branchCommits}
loadingBranchCommits={loadingBranchCommits}
expandedBranchCommit={expandedBranchCommit}
branchCommitDiff={branchCommitDiff}
loadingBranchCommitDiff={loadingBranchCommitDiff}
onSelectBranch={handleSelectBranch}
onBranchCommitClick={handleBranchCommitClick}
onCloseBranchDetails={handleCloseBranchDetails}
/>
)}
{/* ── Worktrees Panel ── */}
{activeSection === "worktrees" && !loading && (
<WorktreesPanel worktrees={worktrees} />
)}
{/* ── Stashes Panel ── */}
{activeSection === "stashes" && !loading && (
<StashesPanel
stashes={stashes}
stashMessage={stashMessage}
setStashMessage={setStashMessage}
onCreateStash={handleCreateStash}
onApplyStash={handleApplyStash}
onDropStash={handleDropStash}
onToggleStashDiff={handleToggleStashDiff}
stashLoading={stashLoading}
expandedStashIndex={expandedStashIndex}
stashDiff={stashDiff}
loadingStashDiff={loadingStashDiff}
stashDiffError={stashDiffError}
/>
)}
{/* ── Remotes Panel ── */}
{activeSection === "remotes" && !loading && (
<RemotesPanel
status={status}
remoteLoading={remoteLoading}
lastRemoteResult={lastRemoteResult}
onFetch={handleFetch}
onPull={handlePull}
onPush={handlePush}
onSync={handleSyncWithOrigin}
onSyncIntegrationTip={handleSyncIntegrationTip}
syncIntegrationDisabled={
!status?.integrationBranch ||
status?.isOnIntegrationBranch === false ||
remoteLoading !== null
}
addToast={addToast}
projectId={projectId}
copyToClipboard={copyToClipboard}
/>
)}
</div>
</div>
</div>
</div>
);
}
// ── Sub-Components ────────────────────────────────────────────────
/** Status overview panel */
function StatusPanel({
status,
copyToClipboard,
onSyncWorkingTree,
syncing,
}: {
status: GitStatus;
copyToClipboard: (text: string, label?: string) => void;
onSyncWorkingTree: () => void;
syncing: boolean;
}) {
const [advancesHelpOpen, setAdvancesHelpOpen] = useState(false);
const [dismissedAdvanceShas, setDismissedAdvanceShas] = useState<Set<string>>(new Set());
const visibleAdvances = (status.recentMergeAdvances ?? []).filter((advance) => !dismissedAdvanceShas.has(advance.toSha));
const actionableAdvances = visibleAdvances.filter((advance) => advance.resolution === "pending");
const hasActionableAdvances = actionableAdvances.length > 0;
const isHeadAlignedWithIntegration = status.aheadOfIntegration === 0 && status.behindIntegration === 0;
const showSyncWorkingTree = hasActionableAdvances && !isHeadAlignedWithIntegration;
return (
<div className="gm-panel" data-testid="status-panel">
<div className="gm-panel-header">
<h4>Repository Status</h4>
</div>
<div className="gm-status-grid">
<div className="gm-status-card">
<span className="gm-status-label">Branch</span>
<span className="gm-status-value">
<GitBranchIcon size={14} />
<span>{status.branch}</span>
{/* Only flag "not on <integration>" when we know the worktree IS
on a branch — detached HEAD (isOnIntegrationBranch undefined)
is a non-branch state, not "on the wrong branch." */}
{status.integrationBranch && status.isOnIntegrationBranch === false && (
<span className="gm-status-sub" title="Currently on a non-integration branch">
{" "}(not on {status.integrationBranch})
</span>
)}
</span>
</div>
<div className="gm-status-card">
<span className="gm-status-label">Commit</span>
<span className="gm-status-value">
<code className="gm-hash">{status.commit}</code>
<button
className="gm-icon-btn"
onClick={() => copyToClipboard(status.commit, "commit hash")}
title={`Copy short commit hash${status.headSha ? " (use the full SHA below for git operations)" : ""}`}
>
<Copy size={12} />
</button>
{status.headSha && (
<button
className="gm-icon-btn"
onClick={() => copyToClipboard(status.headSha!, "full commit hash")}
title="Copy full 40-char SHA"
>
<Copy size={12} />
<span style={{ fontSize: 10, marginLeft: 2 }}>full</span>
</button>
)}
</span>
</div>
<div className="gm-status-card">
<span className="gm-status-label">Working Tree</span>
<span className={`gm-status-badge ${status.isDirty ? "dirty" : "clean"}`}>
{status.isDirty ? (
<>
<AlertCircle size={12} />
Modified
</>
) : (
<>
<CheckCircle size={12} />
Clean
</>
)}
</span>
{status.dirtyDetails && (status.dirtyDetails.staged + status.dirtyDetails.modified + status.dirtyDetails.untracked + status.dirtyDetails.conflicted) > 0 && (
<span className="gm-status-sub">
{status.dirtyDetails.staged > 0 && <span title="Staged">{status.dirtyDetails.staged} staged</span>}
{status.dirtyDetails.staged > 0 && (status.dirtyDetails.modified + status.dirtyDetails.untracked + status.dirtyDetails.conflicted) > 0 && " · "}
{status.dirtyDetails.modified > 0 && <span title="Modified">{status.dirtyDetails.modified} modified</span>}
{status.dirtyDetails.modified > 0 && (status.dirtyDetails.untracked + status.dirtyDetails.conflicted) > 0 && " · "}
{status.dirtyDetails.untracked > 0 && <span title="Untracked">{status.dirtyDetails.untracked} untracked</span>}
{status.dirtyDetails.untracked > 0 && status.dirtyDetails.conflicted > 0 && " · "}
{status.dirtyDetails.conflicted > 0 && (
<span title="Unresolved merge conflicts" className="gm-status-conflict">
{status.dirtyDetails.conflicted} conflicted
</span>
)}
</span>
)}
</div>
<div className="gm-status-card">
<span className="gm-status-label">vs origin</span>
<span className="gm-status-value">
{status.ahead > 0 && (
<span className="gm-ahead" title={`${status.ahead} commit(s) ahead of upstream`}>
<ArrowUp size={12} />
{status.ahead}
</span>
)}
{status.behind > 0 && (
<span className="gm-behind" title={`${status.behind} commit(s) behind upstream`}>
<ArrowDown size={12} />
{status.behind}
</span>
)}
{status.ahead === 0 && status.behind === 0 && (
<span className="gm-in-sync">
<CheckCircle size={12} />
Up to date
</span>
)}
</span>
</div>
</div>
{status.integrationBranch && (
<div className="gm-status-grid">
<div className="gm-status-card" data-testid="integration-branch-card">
<span className="gm-status-label">Integration branch</span>
<span className="gm-status-value">
<GitBranchIcon size={14} />
<span>{status.integrationBranch}</span>
{status.integrationBranchSource && (
<span className="gm-status-sub" title={`Resolved from ${status.integrationBranchSource}`}>
{" "}({status.integrationBranchSource})
</span>
)}
</span>
{status.integrationTipSha && (
<span className="gm-status-sub">
tip <code className="gm-hash">{status.integrationTipSha.slice(0, 8)}</code>
{status.integrationTipSource === "remote-only" && (
<>
{" "}<span title="No local refs/heads/<branch>; using refs/remotes/origin/<branch> as the integration tip.">(remote-only run <code>git switch {status.integrationBranch}</code> to track locally)</span>
</>
)}
</span>
)}
{status.integrationTipSource === "missing" && (
<span className="gm-status-sub gm-status-conflict" title="Neither refs/heads nor refs/remotes/origin has this branch">
no ref found for {status.integrationBranch}
</span>
)}
</div>
{status.integrationTipSha !== undefined && (status.aheadOfIntegration !== undefined || status.behindIntegration !== undefined) && (
<div className="gm-status-card">
<span className="gm-status-label">HEAD vs {status.integrationBranch}</span>
<span className="gm-status-value">
{(status.aheadOfIntegration ?? 0) === 0 && (status.behindIntegration ?? 0) === 0 ? (
<span className="gm-in-sync">
<CheckCircle size={12} />
Aligned
</span>
) : (
<>
{(status.aheadOfIntegration ?? 0) > 0 && (
<span className="gm-ahead" title={`HEAD has ${status.aheadOfIntegration} commit(s) not on ${status.integrationBranch}`}>
<ArrowUp size={12} />
{status.aheadOfIntegration}
</span>
)}
{(status.behindIntegration ?? 0) > 0 && (
<span className="gm-behind" title={`${status.integrationBranch} has ${status.behindIntegration} commit(s) HEAD doesn't`}>
<ArrowDown size={12} />
{status.behindIntegration}
</span>
)}
</>
)}
</span>
</div>
)}
{status.originIntegrationTipSha !== undefined && (
<div className="gm-status-card">
<span className="gm-status-label">Local {status.integrationBranch} vs origin</span>
<span className="gm-status-value">
{status.originIntegrationTipSha === null ? (
<span className="gm-status-sub">no origin tracking</span>
) : status.integrationTipSource === "remote-only" ? (
// Local branch doesn't exist — comparing "local vs origin"
// is undefined. Show an honest state instead of a green
// "Synced" badge that would imply the local ref is in
// sync with origin when there's no local ref at all.
<span className="gm-status-sub" title="No local refs/heads/<branch> exists; nothing to compare against origin.">
no local tracking
</span>
) : (status.aheadOfOriginIntegration ?? 0) === 0 && (status.behindOriginIntegration ?? 0) === 0 ? (
<span className="gm-in-sync">
<CheckCircle size={12} />
Synced
</span>
) : (
<>
{(status.aheadOfOriginIntegration ?? 0) > 0 && (
<span className="gm-ahead" title={`Local ${status.integrationBranch} is ${status.aheadOfOriginIntegration} commit(s) ahead of origin/${status.integrationBranch}`}>
<ArrowUp size={12} />
{status.aheadOfOriginIntegration}
</span>
)}
{(status.behindOriginIntegration ?? 0) > 0 && (
<span className="gm-behind" title={`Local ${status.integrationBranch} is ${status.behindOriginIntegration} commit(s) behind origin/${status.integrationBranch}`}>
<ArrowDown size={12} />
{status.behindOriginIntegration}
</span>
)}
</>
)}
</span>
</div>
)}
{status.integrationTipSource === "remote-only" && status.aheadOfIntegrationRemote !== undefined && (
// In remote-only mode the `HEAD vs <branch>` card is suppressed
// (no local tip to compare against). Surface a dedicated HEAD vs
// origin/<branch> card so the operator still sees a meaningful
// distance.
<div className="gm-status-card">
<span className="gm-status-label">HEAD vs origin/{status.integrationBranch}</span>
<span className="gm-status-value">
{(status.aheadOfIntegrationRemote ?? 0) === 0 && (status.behindIntegrationRemote ?? 0) === 0 ? (
<span className="gm-in-sync">
<CheckCircle size={12} />
Aligned
</span>
) : (
<>
{(status.aheadOfIntegrationRemote ?? 0) > 0 && (
<span className="gm-ahead" title={`HEAD has ${status.aheadOfIntegrationRemote} commit(s) not on origin/${status.integrationBranch}`}>
<ArrowUp size={12} />
{status.aheadOfIntegrationRemote}
</span>
)}
{(status.behindIntegrationRemote ?? 0) > 0 && (
<span className="gm-behind" title={`origin/${status.integrationBranch} has ${status.behindIntegrationRemote} commit(s) HEAD doesn't`}>
<ArrowDown size={12} />
{status.behindIntegrationRemote}
</span>
)}
</>
)}
</span>
</div>
)}
{(status.stashCount ?? 0) > 0 && (
<div className="gm-status-card">
<span className="gm-status-label">Stashes</span>
<span className="gm-status-value">
<Archive size={14} />
<span>{status.stashCount}</span>
</span>
</div>
)}
</div>
)}
{status.indexStaleVsHead === true && (
<div className="gm-status-warning" data-testid="index-stale-warning" role="alert">
<AlertCircle size={14} />
<div>
<strong>Stale index detected.</strong>{" "}
HEAD has advanced (typically because Fusion's merger updated the integration-branch ref)
but the index still reflects the previous tip — `git status` will report the new commits
inverted as &quot;staged changes.&quot; Enable <code>mergeAdvanceAutoSync</code> in Settings to
have the merger reconcile automatically, or run <code>git reset --hard HEAD</code> to
snap forward manually.
</div>
</div>
)}
{visibleAdvances.length > 0 && (
<div className="gm-status-advances" data-testid="recent-merge-advances">
<div className="gm-status-advances-header">
<span>
Recent integration-branch advances
<span className="gm-status-sub">
{" "}({actionableAdvances.length} need action)
</span>
<button
type="button"
className="gm-icon-btn"
style={{ marginLeft: 6 }}
aria-expanded={advancesHelpOpen}
aria-label={advancesHelpOpen ? "Hide explanation" : "What does this mean?"}
onClick={() => setAdvancesHelpOpen((open) => !open)}
title="What does this mean?"
>
<Info size={13} />
</button>
</span>
{showSyncWorkingTree && (
<button
type="button"
className="btn btn-sm"
onClick={onSyncWorkingTree}
disabled={syncing}
data-testid="sync-working-tree-btn"
title="Pull the integration branch into your working tree (auto-stashes uncommitted edits and restores them)"
>
{syncing ? "Syncing…" : "Sync working tree"}
</button>
)}
</div>
{advancesHelpOpen && (
<div className="gm-status-advances-help" data-testid="recent-merge-advances-help">
<p>
Each entry is a Fusion task whose squash commit advanced the
integration branch ref (<code>{status.integrationBranch ?? "main"}</code>).
The <em>auto-sync outcome</em> says whether your working tree
was also fast-forwarded to that new tip.
</p>
<ul className="gm-status-advances-help-list">
<li><code>clean-sync</code> / <code>synced-with-edits-restored</code> — working tree is in sync; nothing to do.</li>
<li><code>reachable</code> / <code>subsumed</code> / <code>orphaned</code> / <code>superseded</code> — already handled (including history rewrites where equivalent content already landed, original SHAs disappeared, or HEAD is already aligned to the rewritten integration tip).</li>
<li><code>pending</code> + <code>off / not run</code> — auto-sync is disabled in Settings; the branch ref moved but your worktree didn&apos;t follow.</li>
<li><code>pending</code> + <code>stash-failed</code> / <code>would-conflict</code> / similar — auto-sync tried but couldn&apos;t reconcile (usually local edits collide with the new commit).</li>
</ul>
<p>
<strong>Fix:</strong> Fusion only shows <em>Sync working tree</em> when at least one advance is genuinely <code>pending</code> and HEAD is not aligned with the integration tip. If entries are already handled (subsumed/orphaned/reachable/superseded), no sync action is offered.
</p>
</div>
)}
<ul>
{visibleAdvances.map((advance) => (
<li key={`${advance.taskId}-${advance.toSha}`} className={advance.needsAction ? "gm-advance-needs-action" : "gm-advance-handled"}>
<code className="gm-hash">{advance.toSha.slice(0, 8)}</code>
{" "}
<strong>{advance.taskId}</strong>
{advance.autoSyncOutcome ? (
<span className="gm-status-sub">
{" "}auto-sync: <code>{advance.autoSyncOutcome}</code>
</span>
) : (
<span className="gm-status-sub">
{" "}auto-sync: <em>off / not run</em>
</span>
)}
<span className="gm-status-sub">
{" "}· {new Date(advance.advancedAt).toLocaleTimeString()} · {advance.resolution}
</span>
{(advance.resolution === "orphaned" || advance.resolution === "subsumed" || advance.resolution === "superseded") && (
<button
type="button"
className="btn btn-xs"
onClick={() => {
setDismissedAdvanceShas((prev) => {
const next = new Set(prev);
next.add(advance.toSha);
return next;
});
}}
data-testid={`dismiss-advance-${advance.toSha}`}
>
Dismiss
</button>
)}
</li>
))}
</ul>
</div>
)}
</div>
);
}
/** Changes panel with staging, unstaging, committing */
function ChangesPanel({
status,
stagedFiles,
unstagedFiles,
selectedFiles,
toggleFileSelection,
onStageFiles,
onUnstageFiles,
onDiscardChanges,
onSelectDiffFile,
selectedDiffTarget,
changeDiff,
loadingChangeDiff,
changeDiffError,
commitMessage,
setCommitMessage,
onCommit,
onStageAllAndCommit,
committing,
}: {
status: GitStatus | null;
stagedFiles: GitFileChange[];
unstagedFiles: GitFileChange[];
selectedFiles: Set<string>;
toggleFileSelection: (file: string) => void;
onStageFiles: (files: string[]) => void;
onUnstageFiles: (files: string[]) => void;
onDiscardChanges: (files: string[]) => void;
onSelectDiffFile: (file: string, staged: boolean) => void;
selectedDiffTarget: { file: string; staged: boolean } | null;
changeDiff: { stat: string; patch: string } | null;
loadingChangeDiff: boolean;
changeDiffError: string | null;
commitMessage: string;
setCommitMessage: (msg: string) => void;
onCommit: (e: React.FormEvent) => void;
onStageAllAndCommit: () => void;
committing: boolean;
}) {
const selectedUnstaged = unstagedFiles.filter((f) => selectedFiles.has(`unstaged:${f.file}`));
const selectedStaged = stagedFiles.filter((f) => selectedFiles.has(`staged:${f.file}`));
return (
<div className="gm-panel" data-testid="changes-panel">
{/* Current branch indicator */}
{status && (
<div className="gm-changes-header">
<span className="gm-branch-indicator">
<GitBranchIcon size={14} />
{status.branch}
</span>
{status.isDirty && (
<span className="gm-dirty-badge">Modified</span>
)}
</div>
)}
<div className="gm-changes-split">
<div className="gm-changes-lists">
{/* Unstaged Changes */}
<div className="gm-file-section">
<div className="gm-file-section-header">
<h5>Unstaged Changes ({unstagedFiles.length})</h5>
<div className="gm-file-section-actions">
{selectedUnstaged.length > 0 && (
<>
<button
className="btn btn-sm btn-primary"
onClick={() => onStageFiles(selectedUnstaged.map((f) => f.file))}
title="Stage selected"
>
<Plus size={12} /> Stage ({selectedUnstaged.length})
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => onDiscardChanges(selectedUnstaged.map((f) => f.file))}
title="Discard selected"
>
<XCircle size={12} />
</button>
</>
)}
{unstagedFiles.length > 0 && (
<button
className="btn btn-sm"
onClick={() => onStageFiles(unstagedFiles.map((f) => f.file))}
title="Stage all"
>
Stage All
</button>
)}
</div>
</div>
<div className="gm-file-list gm-file-list-unstaged" data-testid="gm-file-list-unstaged">
{unstagedFiles.length === 0 ? (
<div className="gm-empty">No unstaged changes</div>
) : (
unstagedFiles.map((f) => {
const isActive = selectedDiffTarget?.file === f.file && selectedDiffTarget.staged === false;
return (
<div
key={`unstaged:${f.file}`}
className={`gm-file-item${isActive ? " active" : ""}`}
onClick={() => onSelectDiffFile(f.file, false)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectDiffFile(f.file, false);
}
}}
>
<label className="gm-file-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedFiles.has(`unstaged:${f.file}`)}
onChange={() => toggleFileSelection(`unstaged:${f.file}`)}
/>
</label>
<FileStatusIcon status={f.status} />
<span className="gm-file-name" title={f.file}><bdo dir="ltr">{f.file}</bdo></span>
<FileStatusBadge status={f.status} />
<button
className="gm-icon-btn"
onClick={(e) => {
e.stopPropagation();
onStageFiles([f.file]);
}}
title="Stage file"
>
<Plus size={12} />
</button>
</div>
);
})
)}
</div>
</div>
{/* Staged Changes */}
<div className="gm-file-section">
<div className="gm-file-section-header">
<h5>Staged Changes ({stagedFiles.length})</h5>
<div className="gm-file-section-actions">
{selectedStaged.length > 0 && (
<button
className="btn btn-sm"
onClick={() => onUnstageFiles(selectedStaged.map((f) => f.file))}
title="Unstage selected"
>
Unstage ({selectedStaged.length})
</button>
)}
{stagedFiles.length > 0 && (
<button
className="btn btn-sm"
onClick={() => onUnstageFiles(stagedFiles.map((f) => f.file))}
title="Unstage all"
>
Unstage All
</button>
)}
</div>
</div>
<div className="gm-file-list gm-file-list-staged" data-testid="gm-file-list-staged">
{stagedFiles.length === 0 ? (
<div className="gm-empty">No staged changes</div>
) : (
stagedFiles.map((f) => {
const isActive = selectedDiffTarget?.file === f.file && selectedDiffTarget.staged === true;
return (
<div
key={`staged:${f.file}`}
className={`gm-file-item staged${isActive ? " active" : ""}`}
onClick={() => onSelectDiffFile(f.file, true)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectDiffFile(f.file, true);
}
}}
>
<label className="gm-file-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedFiles.has(`staged:${f.file}`)}
onChange={() => toggleFileSelection(`staged:${f.file}`)}
/>
</label>
<FileStatusIcon status={f.status} />
<span className="gm-file-name" title={f.file}><bdo dir="ltr">{f.file}</bdo></span>
<FileStatusBadge status={f.status} />
<button
className="gm-icon-btn"
onClick={(e) => {
e.stopPropagation();
onUnstageFiles([f.file]);
}}
title="Unstage file"
>
<X size={12} />
</button>
</div>
);
})
)}
</div>
</div>
</div>
{/* Diff Viewer (right pane on desktop, stacked below on mobile) */}
<div className="gm-changes-diff">
{(selectedDiffTarget || loadingChangeDiff || changeDiff || changeDiffError) ? (
<div className="gm-diff-section">
{selectedDiffTarget && (
<div className="gm-diff-target">
<FileDiff size={14} />
<span>{selectedDiffTarget.staged ? "Staged" : "Unstaged"} diff: </span>
<code>{selectedDiffTarget.file}</code>
</div>
)}
{loadingChangeDiff && (
<div className="gm-diff-loading">
<Loader2 size={16} className="spin" />
Loading diff...
</div>
)}
{changeDiffError && !loadingChangeDiff && (
<div className="gm-diff-error">{changeDiffError}</div>
)}
{changeDiff && !loadingChangeDiff && (
<div className="gm-diff-viewer">
{changeDiff.stat && <pre className="gm-diff-stat">{changeDiff.stat}</pre>}
<pre className="gm-diff-patch">{changeDiff.patch}</pre>
</div>
)}
</div>
) : (
<div className="gm-diff-empty">
<FileDiff size={20} />
<span>Select a file to view its diff</span>
</div>
)}
</div>
</div>
{/* /gm-changes-split */}
{/* Commit Form */}
<form className="gm-commit-form" onSubmit={onCommit}>
<textarea
className="gm-commit-input"
placeholder="Commit message..."
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
rows={3}
disabled={committing}
/>
<div className="gm-commit-actions">
<button
type="submit"
className="btn btn-sm btn-primary"
disabled={committing || !commitMessage.trim() || stagedFiles.length === 0}
title={stagedFiles.length === 0 ? "No staged changes to commit" : "Commit staged changes"}
>
{committing ? <Loader2 size={14} className="spin" /> : <Send size={14} />}
Commit
</button>
{unstagedFiles.length > 0 && (
<button
type="button"
className="btn btn-sm"
onClick={onStageAllAndCommit}
disabled={committing || !commitMessage.trim()}
title="Stage all and commit"
>
Stage All & Commit
</button>
)}
</div>
</form>
</div>
);
}
/** Commits panel with search, diff viewer */
function CommitsPanel({
commits,
commitSearch,
setCommitSearch,
selectedCommit,
commitDiff,
loadingDiff,
onCommitClick,
onLoadMore,
canLoadMore,
copyToClipboard,
}: {
commits: GitCommit[];
commitSearch: string;
setCommitSearch: (q: string) => void;
selectedCommit: string | null;
commitDiff: { stat: string; patch: string } | null;
loadingDiff: boolean;
onCommitClick: (hash: string) => void;
onLoadMore: () => void;
canLoadMore: boolean;
copyToClipboard: (text: string, label?: string) => void;
}) {
return (
<div className="gm-panel" data-testid="commits-panel">
<div className="gm-panel-header">
<h4>Commits</h4>
<div className="gm-search-box">
<Search size={14} />
<input
type="text"
placeholder="Search commits..."
value={commitSearch}
onChange={(e) => setCommitSearch(e.target.value)}
/>
</div>
</div>
<div className="gm-commits-list">
{commits.length === 0 ? (
<div className="gm-empty">
{commitSearch ? "No matching commits" : "No commits found"}
</div>
) : (
commits.map((commit, idx) => (
<div key={commit.hash} className="gm-commit-item">
{/* Simple commit graph line */}
<div className="gm-commit-graph">
<div className="gm-commit-dot" />
{idx < commits.length - 1 && <div className="gm-commit-line" />}
</div>
<div className="gm-commit-body">
<button
className="gm-commit-header"
onClick={() => onCommitClick(commit.hash)}
>
<div className="gm-commit-top-row">
<code className="gm-hash">{commit.shortHash}</code>
<span className="gm-commit-message" title={commit.message}>
{commit.message}
</span>
</div>
<div className="gm-commit-meta">
<span>{commit.author}</span>
<span>•</span>
<span>{relativeDate(commit.date)}</span>
{commit.parents.length > 1 && (
<span className="gm-merge-badge">merge</span>
)}
</div>
</button>
<div className="gm-commit-actions-row">
<button
className="gm-icon-btn"
onClick={() => copyToClipboard(commit.hash, "commit hash")}
title="Copy full hash"
>
<Copy size={12} />
</button>
</div>
{selectedCommit === commit.hash && (
<div className="gm-commit-diff">
{loadingDiff ? (
<div className="gm-diff-loading">
<Loader2 size={16} className="spin" />
Loading diff...
</div>
) : commitDiff ? (
<>
{commit.body && <div className="gm-commit-message-full">{commit.body}</div>}
{commitDiff.stat && <pre className="gm-diff-stat">{commitDiff.stat}</pre>}
<pre className="gm-diff-patch">{commitDiff.patch}</pre>
</>
) : (
<div className="gm-diff-error">Failed to load diff</div>
)}
</div>
)}
</div>
</div>
))
)}
</div>
{canLoadMore && (
<button className="gm-load-more" onClick={onLoadMore}>
Load more commits
</button>
)}
</div>
);
}
/** Branches panel with creation, search, checkout, delete, and branch commit viewing */
function BranchesPanel({
branches,
branchSearch,
setBranchSearch,
newBranchName,
setNewBranchName,
branchBase,
setBranchBase,
onCreateBranch,
onCheckoutBranch,
onDeleteBranch,
loading,
allBranches,
selectedBranch,
branchCommits,
loadingBranchCommits,
expandedBranchCommit,
branchCommitDiff,
loadingBranchCommitDiff,
onSelectBranch,
onBranchCommitClick,
onCloseBranchDetails,
}: {
branches: GitBranch[];
branchSearch: string;
setBranchSearch: (q: string) => void;
newBranchName: string;
setNewBranchName: (name: string) => void;
branchBase: string;
setBranchBase: (base: string) => void;
onCreateBranch: (e: React.FormEvent) => void;
onCheckoutBranch: (name: string) => void;
onDeleteBranch: (name: string) => void;
loading: boolean;
allBranches: GitBranch[];
selectedBranch: string | null;
branchCommits: GitCommit[];
loadingBranchCommits: boolean;
expandedBranchCommit: string | null;
branchCommitDiff: { stat: string; patch: string } | null;
loadingBranchCommitDiff: boolean;
onSelectBranch: (name: string) => void;
onBranchCommitClick: (hash: string) => void;
onCloseBranchDetails: () => void;
}) {
return (
<div className="gm-panel" data-testid="branches-panel">
<div className="gm-panel-header">
<h4>Branches</h4>
<div className="gm-search-box">
<Search size={14} />
<input
type="text"
placeholder="Filter branches..."
value={branchSearch}
onChange={(e) => setBranchSearch(e.target.value)}
/>
</div>
</div>
{/* Create branch form */}
<form className="gm-create-form" onSubmit={onCreateBranch}>
<input
type="text"
placeholder="New branch name"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
disabled={loading}
/>
<select
value={branchBase}
onChange={(e) => setBranchBase(e.target.value)}
disabled={loading}
className="gm-branch-select"
>
<option value="">Base: HEAD</option>
{allBranches.map((b) => (
<option key={b.name} value={b.name}>
{b.name}
</option>
))}
</select>
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={loading || !newBranchName.trim()}
>
<Plus size={14} />
Create
</button>
</form>
{/* Branches list */}
<div className="gm-branches-list">
{branches.length === 0 ? (
<div className="gm-empty">
{branchSearch ? "No matching branches" : "No branches found"}
</div>
) : (
branches.map((branch) => (
<div key={branch.name}>
<div
className={`gm-branch-item${branch.isCurrent ? " current" : ""}${selectedBranch === branch.name ? " selected" : ""}`}
onClick={() => onSelectBranch(branch.name)}
>
<div className="gm-branch-info">
<span className="gm-branch-name">
{branch.isCurrent && <Check size={14} className="gm-current-icon" />}
{branch.name}
</span>
{branch.remote && (
<span className="gm-branch-remote">→ {branch.remote}</span>
)}
{branch.lastCommitDate && (
<span className="gm-branch-date">{relativeDate(branch.lastCommitDate)}</span>
)}
</div>
<div className="gm-branch-actions">
{!branch.isCurrent && (
<>
<button
className="btn btn-sm"
onClick={(e) => { e.stopPropagation(); onCheckoutBranch(branch.name); }}
disabled={loading}
title="Checkout"
>
<GitBranchIcon size={14} />
</button>
<button
className="btn btn-sm btn-danger"
onClick={(e) => { e.stopPropagation(); onDeleteBranch(branch.name); }}
disabled={loading}
title="Delete"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
</div>
{/* Branch commit details — shown when this branch is selected */}
{selectedBranch === branch.name && (
<div className="gm-branch-details">
<div className="gm-branch-details-header">
<span className="gm-branch-details-title">
<GitCommitIcon size={14} />
Commits on {branch.name}
</span>
<button
className="gm-icon-btn"
onClick={onCloseBranchDetails}
title="Close"
data-testid="close-branch-details"
>
<X size={14} />
</button>
</div>
{loadingBranchCommits ? (
<div className="gm-branch-details-loading">
<Loader2 size={16} className="spin" />
Loading commits...
</div>
) : branchCommits.length === 0 ? (
<div className="gm-empty">No commits found</div>
) : (
<div className="gm-branch-commits-list">
{branchCommits.map((commit) => (
<div key={commit.hash} className="gm-branch-commit">
<button
className="gm-branch-commit-row"
onClick={() => onBranchCommitClick(commit.hash)}
data-testid={`branch-commit-${commit.shortHash}`}
>
<span className="gm-commit-hash">{commit.shortHash}</span>
<span className="gm-commit-message" title={commit.message}>
{commit.message}
</span>
<div className="gm-commit-meta">
<span>{commit.author}</span>
<span>•</span>
<span>{relativeDate(commit.date)}</span>
{commit.parents.length > 1 && (
<span className="gm-merge-badge">merge</span>
)}
</div>
</button>
{expandedBranchCommit === commit.hash && (
<div className="gm-commit-diff">
{loadingBranchCommitDiff ? (
<div className="gm-diff-loading">
<Loader2 size={16} className="spin" />
Loading diff...
</div>
) : branchCommitDiff ? (
<>
{(commit.body || commit.message) && (
<div className="gm-commit-message-full">{commit.body || commit.message}</div>
)}
{branchCommitDiff.stat && <pre className="gm-diff-stat">{branchCommitDiff.stat}</pre>}
<pre className="gm-diff-patch">{branchCommitDiff.patch}</pre>
</>
) : (
<div className="gm-diff-error">Failed to load diff</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
))
)}
</div>
</div>
);
}
/** Worktrees panel */
function WorktreesPanel({ worktrees }: { worktrees: GitWorktree[] }) {
return (
<div className="gm-panel" data-testid="worktrees-panel">
<div className="gm-panel-header">
<h4>Worktrees</h4>
<div className="gm-worktree-stats">
<span>{worktrees.length} total</span>
<span className="gm-stat-separator">•</span>
<span>{worktrees.filter((w) => w.taskId).length} in use</span>
</div>
</div>
<div className="gm-worktrees-list">
{worktrees.map((worktree) => (
<div
key={worktree.path}
className={`gm-worktree-item${worktree.isMain ? " main" : ""}`}
>
<div className="gm-worktree-info">
<div className="gm-worktree-path-row">
{worktree.isMain && <span className="gm-badge main">main</span>}
{worktree.isBare && <span className="gm-badge bare">bare</span>}
<span className="gm-worktree-path" title={worktree.path}>
{getPathBasename(worktree.path) || worktree.path}
</span>
</div>
<div className="gm-worktree-detail">
{worktree.branch && (
<span className="gm-worktree-branch">
<GitBranchIcon size={12} />
{worktree.branch}
</span>
)}
{worktree.taskId && (
<span className="gm-worktree-task">{worktree.taskId}</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
}
/** Stashes panel */
function StashesPanel({
stashes,
stashMessage,
setStashMessage,
onCreateStash,
onApplyStash,
onDropStash,
onToggleStashDiff,
stashLoading,
expandedStashIndex,
stashDiff,
loadingStashDiff,
stashDiffError,
}: {
stashes: GitStash[];
stashMessage: string;
setStashMessage: (msg: string) => void;
onCreateStash: (e: React.FormEvent) => void;
onApplyStash: (index: number, drop?: boolean) => void;
onDropStash: (index: number) => void;
onToggleStashDiff: (index: number) => void;
stashLoading: string | null;
expandedStashIndex: number | null;
stashDiff: { stat: string; patch: string } | null;
loadingStashDiff: boolean;
stashDiffError: string | null;
}) {
return (
<div className="gm-panel" data-testid="stashes-panel">
<div className="gm-panel-header">
<h4>Stashes</h4>
</div>
{/* Create stash form */}
<form className="gm-create-form" onSubmit={onCreateStash}>
<input
type="text"
placeholder="Stash message (optional)"
value={stashMessage}
onChange={(e) => setStashMessage(e.target.value)}
disabled={stashLoading !== null}
/>
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={stashLoading !== null}
>
{stashLoading === "create" ? (
<Loader2 size={14} className="spin" />
) : (
<Archive size={14} />
)}
Stash
</button>
</form>
{/* Stash list */}
<div className="gm-stash-list">
{stashes.length === 0 ? (
<div className="gm-empty">No stashes</div>
) : (
stashes.map((stash) => (
<div key={stash.index} className="gm-stash-item">
<div className="gm-stash-header">
<div className="gm-stash-info">
<span className="gm-stash-ref">stash@{`{${stash.index}}`}</span>
<span className="gm-stash-message">{stash.message}</span>
<div className="gm-stash-meta">
{stash.branch && (
<span className="gm-stash-branch">
<GitBranchIcon size={12} />
{stash.branch}
</span>
)}
<span>{relativeDate(stash.date)}</span>
</div>
</div>
<div className="gm-stash-actions">
<button
className="btn btn-sm"
onClick={() => onToggleStashDiff(stash.index)}
disabled={stashLoading !== null}
>
{expandedStashIndex === stash.index ? "Hide" : "View"}
</button>
<button
className="btn btn-sm btn-primary"
onClick={() => onApplyStash(stash.index, false)}
disabled={stashLoading !== null}
title="Apply stash (keep)"
>
{stashLoading === `apply-${stash.index}` ? (
<Loader2 size={14} className="spin" />
) : (
"Apply"
)}
</button>
<button
className="btn btn-sm"
onClick={() => onApplyStash(stash.index, true)}
disabled={stashLoading !== null}
title="Pop stash (apply and drop)"
>
Pop
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => onDropStash(stash.index)}
disabled={stashLoading !== null}
title="Drop stash"
>
{stashLoading === `drop-${stash.index}` ? (
<Loader2 size={14} className="spin" />
) : (
<Trash2 size={14} />
)}
</button>
</div>
</div>
{expandedStashIndex === stash.index && (
<div className="gm-stash-diff">
{loadingStashDiff ? (
<div className="gm-diff-loading">
<Loader2 size={14} className="spin" />
Loading stash diff…
</div>
) : stashDiffError ? (
<div className="gm-diff-error">{stashDiffError}</div>
) : stashDiff ? (
<div className="gm-diff-viewer">
{stashDiff.stat && <pre className="gm-diff-stat">{stashDiff.stat}</pre>}
<pre className="gm-diff-patch">{stashDiff.patch}</pre>
</div>
) : null}
</div>
)}
</div>
))
)}
</div>
</div>
);
}
/** Enhanced Remotes panel with two-column layout */
function RemotesPanel({
status,
remoteLoading,
lastRemoteResult,
onFetch,
onPull,
onPush,
onSync,
onSyncIntegrationTip,
syncIntegrationDisabled,
addToast,
projectId,
copyToClipboard,
}: {
status: GitStatus | null;
remoteLoading: string | null;
lastRemoteResult: GitFetchResult | GitPullResult | GitPushResult | null;
onFetch: () => void;
onPull: (options?: { rebase?: boolean }) => void;
onPush: () => void;
onSync: () => void;
onSyncIntegrationTip: () => void;
syncIntegrationDisabled: boolean;
addToast: (message: string, type?: ToastType) => void;
projectId?: string;
copyToClipboard: (text: string, label?: string) => void;
}) {
/** Extract hostname from remote URL */
const getHostFromUrl = (url: string): string => {
try {
return new URL(url).hostname;
} catch {
return url.replace(/^git@/, "").split(":")[0] || url;
}
};
const [remotes, setRemotes] = useState<GitRemoteDetailed[]>([]);
const [loading, setLoading] = useState(false);
const [remoteActionLoading, setRemoteActionLoading] = useState<string | null>(null);
const [newRemoteName, setNewRemoteName] = useState("");
const [newRemoteUrl, setNewRemoteUrl] = useState("");
const [editingRemote, setEditingRemote] = useState<string | null>(null);
const [editUrlValue, setEditUrlValue] = useState("");
const [editNameValue, setEditNameValue] = useState("");
const [showAddForm, setShowAddForm] = useState(false);
const [pullMenuOpen, setPullMenuOpen] = useState(false);
const pullSplitRef = useRef<HTMLDivElement | null>(null);
// Ahead commits (local commits to push)
const [aheadCommits, setAheadCommits] = useState<GitCommit[]>([]);
const [loadingAhead, setLoadingAhead] = useState(false);
// Selected remote and its recent commits
const [selectedRemote, setSelectedRemote] = useState<string | null>(null);
const [remoteCommits, setRemoteCommits] = useState<GitCommit[]>([]);
const [loadingRemoteCommits, setLoadingRemoteCommits] = useState(false);
const [remoteCommitsError, setRemoteCommitsError] = useState<string | null>(null);
const remoteCommitsRequestIdRef = useRef(0);
// Derived state for selected remote
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
useEffect(() => {
if (remoteLoading !== null || loading) {
setPullMenuOpen(false);
}
}, [remoteLoading, loading]);
useEffect(() => {
if (!pullMenuOpen) {
return;
}
const handleDocumentClick = (event: MouseEvent) => {
if (!pullSplitRef.current?.contains(event.target as Node)) {
setPullMenuOpen(false);
}
};
const handleDocumentKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setPullMenuOpen(false);
}
};
document.addEventListener("mousedown", handleDocumentClick);
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("mousedown", handleDocumentClick);
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, [pullMenuOpen]);
// Inline commit diff expansion (one per list context)
const [expandedAheadCommit, setExpandedAheadCommit] = useState<string | null>(null);
const [aheadCommitDiff, setAheadCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
const [loadingAheadCommitDiff, setLoadingAheadCommitDiff] = useState(false);
const [expandedRemoteCommit, setExpandedRemoteCommit] = useState<string | null>(null);
const [remoteCommitDiff, setRemoteCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
const [loadingRemoteCommitDiff, setLoadingRemoteCommitDiff] = useState(false);
// Fetch remotes when panel mounts
useEffect(() => {
loadRemotes();
}, []);
// Load ahead commits whenever the ahead count indicates commits to push.
// This covers: initial mount (when status arrives), status refresh after
// remote actions (fetch/pull/push), and any other status updates.
useEffect(() => {
if (status && status.ahead > 0) {
loadAheadCommits();
} else if (status && status.ahead === 0) {
// Clear stale ahead commits when push succeeds or upstream catches up
setAheadCommits([]);
}
}, [status?.ahead]);
// Auto-select first remote when remotes load
useEffect(() => {
if (remotes.length > 0 && !selectedRemote) {
setSelectedRemote(remotes[0].name);
}
}, [remotes]);
// Load commits for selected remote
useEffect(() => {
if (selectedRemote) {
loadRemoteCommits(selectedRemote);
} else {
setRemoteCommits([]);
setRemoteCommitsError(null);
}
}, [selectedRemote]);
// Refresh selected remote commits after successful sync operations.
useEffect(() => {
if (!selectedRemote || !lastRemoteResult) return;
loadRemoteCommits(selectedRemote);
}, [selectedRemote, lastRemoteResult]);
// Clear selected remote if it was removed from the list
useEffect(() => {
if (selectedRemote && !remotes.find((r) => r.name === selectedRemote)) {
setSelectedRemote(remotes.length > 0 ? remotes[0].name : null);
}
}, [remotes, selectedRemote]);
const loadRemotes = async () => {
setLoading(true);
try {
const data = await fetchGitRemotesDetailed(projectId);
setRemotes(data);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to load remotes", "error");
} finally {
setLoading(false);
}
};
const loadAheadCommits = async () => {
setLoadingAhead(true);
try {
const commits = await fetchAheadCommits(projectId);
setAheadCommits(commits);
} catch {
// Silently ignore — ahead commits are a nice-to-have
setAheadCommits([]);
} finally {
setLoadingAhead(false);
}
};
const loadRemoteCommits = async (remoteName: string) => {
const requestId = remoteCommitsRequestIdRef.current + 1;
remoteCommitsRequestIdRef.current = requestId;
setLoadingRemoteCommits(true);
setRemoteCommitsError(null);
try {
const commits = await fetchRemoteCommits(remoteName, undefined, 10, projectId);
if (remoteCommitsRequestIdRef.current !== requestId) {
return;
}
setRemoteCommits(commits);
} catch (err) {
if (remoteCommitsRequestIdRef.current !== requestId) {
return;
}
setRemoteCommitsError(getErrorMessage(err) || "Failed to load remote commits");
setRemoteCommits([]);
} finally {
if (remoteCommitsRequestIdRef.current === requestId) {
setLoadingRemoteCommits(false);
}
}
};
const confirmContextRemote = useConfirm();
const handleAddRemote = async (e: React.FormEvent) => {
e.preventDefault();
if (!newRemoteName.trim() || !newRemoteUrl.trim()) return;
setRemoteActionLoading("add");
try {
await addGitRemote(newRemoteName.trim(), newRemoteUrl.trim(), projectId);
addToast(`Remote '${newRemoteName}' added successfully`, "success");
setNewRemoteName("");
setNewRemoteUrl("");
setShowAddForm(false);
await loadRemotes();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to add remote", "error");
} finally {
setRemoteActionLoading(null);
}
};
const handleRemoveRemote = async (name: string) => {
const shouldRemove = await confirmContextRemote.confirm({
title: "Remove Remote",
message: `Are you sure you want to remove remote '${name}'?`,
danger: true,
});
if (!shouldRemove) return;
setRemoteActionLoading(`remove-${name}`);
try {
await removeGitRemote(name, projectId);
addToast(`Remote '${name}' removed`, "success");
await loadRemotes();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to remove remote", "error");
} finally {
setRemoteActionLoading(null);
}
};
const handleRenameRemote = async (oldName: string) => {
if (!editNameValue.trim()) return;
setRemoteActionLoading(`rename-${oldName}`);
try {
await renameGitRemote(oldName, editNameValue.trim(), projectId);
addToast(`Remote renamed to '${editNameValue.trim()}'`, "success");
setEditingRemote(null);
setEditNameValue("");
await loadRemotes();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to rename remote", "error");
} finally {
setRemoteActionLoading(null);
}
};
const handleUpdateUrl = async (name: string) => {
if (!editUrlValue.trim()) return;
setRemoteActionLoading(`url-${name}`);
try {
await updateGitRemoteUrl(name, editUrlValue.trim(), projectId);
addToast(`Remote URL updated`, "success");
setEditingRemote(null);
setEditUrlValue("");
await loadRemotes();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to update remote URL", "error");
} finally {
setRemoteActionLoading(null);
}
};
const handleCompactCommitClick = useCallback(async (
hash: string,
listType: "ahead" | "remote",
) => {
if (listType === "ahead") {
if (expandedAheadCommit === hash) {
setExpandedAheadCommit(null);
setAheadCommitDiff(null);
return;
}
setExpandedAheadCommit(hash);
setAheadCommitDiff(null);
setLoadingAheadCommitDiff(true);
try {
const diff = await fetchCommitDiff(hash);
setAheadCommitDiff(diff);
} catch {
setAheadCommitDiff(null);
} finally {
setLoadingAheadCommitDiff(false);
}
} else {
if (expandedRemoteCommit === hash) {
setExpandedRemoteCommit(null);
setRemoteCommitDiff(null);
return;
}
setExpandedRemoteCommit(hash);
setRemoteCommitDiff(null);
setLoadingRemoteCommitDiff(true);
try {
const diff = await fetchCommitDiff(hash);
setRemoteCommitDiff(diff);
} catch {
setRemoteCommitDiff(null);
} finally {
setLoadingRemoteCommitDiff(false);
}
}
}, [expandedAheadCommit, expandedRemoteCommit]);
const startEditingUrl = (remote: GitRemoteDetailed) => {
setEditingRemote(`url-${remote.name}`);
setEditUrlValue(remote.pushUrl || remote.fetchUrl);
};
const startEditingName = (remote: GitRemoteDetailed) => {
setEditingRemote(`name-${remote.name}`);
setEditNameValue(remote.name);
};
return (
<div className="gm-panel gm-remotes-panel" data-testid="remotes-panel">
{/* Two-column layout */}
<div className="gm-remotes-layout">
{/* ── Left Column: Remote Selector ── */}
<div className="gm-remote-selector" data-testid="remote-selector">
<div className="gm-remote-selector-header">
<span className="gm-remote-selector-title">Remotes</span>
<button
className="btn btn-sm"
onClick={() => setShowAddForm(!showAddForm)}
disabled={remoteActionLoading !== null}
title={showAddForm ? "Cancel" : "Add Remote"}
>
{showAddForm ? <X size={14} /> : <Plus size={14} />}
</button>
</div>
{/* Add Remote Form - collapsible */}
{showAddForm && (
<form className="gm-remote-form" onSubmit={handleAddRemote}>
<input
type="text"
placeholder="Remote name"
value={newRemoteName}
onChange={(e) => setNewRemoteName(e.target.value)}
disabled={remoteActionLoading === "add"}
className="input gm-input"
/>
<input
type="text"
placeholder="Repository URL"
value={newRemoteUrl}
onChange={(e) => setNewRemoteUrl(e.target.value)}
disabled={remoteActionLoading === "add"}
className="input gm-input gm-input-url"
/>
<button
type="submit"
className="btn btn-sm btn-primary"
disabled={!newRemoteName.trim() || !newRemoteUrl.trim() || remoteActionLoading === "add"}
>
{remoteActionLoading === "add" ? (
<Loader2 size={12} className="spin" />
) : (
<Plus size={12} />
)}
Add
</button>
</form>
)}
{/* Remote list */}
{loading ? (
<div className="gm-loading">
<Loader2 size={16} className="spin" />
Loading...
</div>
) : remotes.length === 0 ? (
<div className="gm-empty">No remotes</div>
) : (
remotes.map((remote) => (
<div
key={remote.name}
className={`gm-remote-selector-item${selectedRemote === remote.name ? " selected" : ""}`}
onClick={() => setSelectedRemote(remote.name)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedRemote(remote.name);
}
}}
>
<div className="gm-remote-selector-info">
<span className="gm-remote-selector-name">
<span className="gm-remote-selector-name-text">{remote.name}</span>
{remote.name === "origin" && (
<span className="gm-remote-default-badge">default</span>
)}
</span>
<span className="gm-remote-selector-host" title={remote.fetchUrl}>
{getHostFromUrl(remote.fetchUrl)}
</span>
</div>
<button
className="btn btn-icon btn-sm gm-remote-remove-btn"
onClick={(e) => { e.stopPropagation(); handleRemoveRemote(remote.name); }}
disabled={remoteActionLoading !== null}
title="Remove remote"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
{/* ── Right Column: Detail Panel ── */}
<div className="gm-remote-detail" data-testid="remote-detail-panel">
{selectedRemote && selectedRemoteData ? (
<>
{/* Sync Status Card */}
<div className="gm-remote-sync-card" data-testid="remote-sync-card">
<div className="gm-remote-sync-card-header">
<span className="gm-remote-sync-card-title">{selectedRemote}</span>
{status && (status.ahead > 0 || status.behind > 0) && (
<div className="gm-remote-status">
{status.ahead > 0 && (
<div className="gm-remote-indicator ahead">
<span className="status-dot status-dot--online" aria-hidden="true" />
<ArrowUp size={14} />
{status.ahead} to push
</div>
)}
{status.behind > 0 && (
<div className="gm-remote-indicator behind">
<span className="status-dot status-dot--pending" aria-hidden="true" />
<ArrowDown size={14} />
{status.behind} to pull
</div>
)}
</div>
)}
</div>
<div className="gm-remote-actions">
<button
className="btn btn-primary"
onClick={onFetch}
disabled={remoteLoading !== null || loading}
>
{remoteLoading === "fetch" ? (
<Loader2 size={14} className="spin" />
) : (
<RefreshCw size={14} />
)}
Fetch
</button>
<div className="gm-pull-split" ref={pullSplitRef}>
<button
className="btn btn-primary gm-pull-split-main"
onClick={() => {
setPullMenuOpen(false);
onPull({ rebase: false });
}}
disabled={remoteLoading !== null || loading}
>
{remoteLoading === "pull" ? (
<Loader2 size={14} className="spin" />
) : (
<GitPullRequest size={14} />
)}
Pull
</button>
<button
className="btn btn-primary btn-icon gm-pull-split-toggle"
onClick={() => setPullMenuOpen((open) => !open)}
disabled={remoteLoading !== null || loading}
aria-label="Pull options"
aria-haspopup="menu"
aria-expanded={pullMenuOpen}
>
<ChevronDown size={14} />
</button>
{pullMenuOpen ? (
<div className="gm-pull-menu" role="menu" aria-label="Pull options menu">
<button
className="gm-pull-menu-item"
role="menuitem"
onClick={() => {
setPullMenuOpen(false);
onPull({ rebase: false });
}}
>
Pull
</button>
<button
className="gm-pull-menu-item"
role="menuitem"
onClick={() => {
setPullMenuOpen(false);
onPull({ rebase: true });
}}
>
Pull --rebase
</button>
</div>
) : null}
</div>
<button
className="btn btn-primary"
onClick={onPush}
disabled={remoteLoading !== null || loading}
>
{remoteLoading === "push" ? (
<Loader2 size={14} className="spin" />
) : (
<ArrowUp size={14} />
)}
Push
</button>
<button
className="btn btn-primary"
onClick={onSync}
disabled={remoteLoading !== null || loading}
title="Pull --rebase from origin, then push current branch"
data-testid="remotes-sync-origin-btn"
>
{remoteLoading === "sync" ? (
<Loader2 size={14} className="spin" />
) : (
<GitMerge size={14} />
)}
Sync
</button>
{status?.integrationBranch && (
<button
className="btn gm-sync-integration-btn"
onClick={onSyncIntegrationTip}
disabled={syncIntegrationDisabled}
title={
status.isOnIntegrationBranch === false
? `Not on integration branch (${status.integrationBranch})`
: "Sync working tree to local integration tip (same as banner Pull)"
}
data-testid="remotes-sync-integration-tip-btn"
>
{remoteLoading === "sync-integration" ? (
<Loader2 size={14} className="spin" />
) : (
<GitMerge size={14} />
)}
Sync local tip
</button>
)}
</div>
</div>
{/* Remote Detail Card */}
<div className="gm-remote-detail-card" data-testid="remote-detail-card">
<div className="gm-remote-detail-urls">
{/* Fetch URL */}
<div className="gm-remote-detail-url-row">
<span className="gm-url-label">Fetch:</span>
{editingRemote === `url-${selectedRemote}` ? (
<div className="gm-remote-edit">
<input
type="text"
value={editUrlValue}
onChange={(e) => setEditUrlValue(e.target.value)}
className="input gm-input"
autoFocus
/>
<button
className="btn btn-sm btn-primary"
onClick={() => handleUpdateUrl(selectedRemote)}
disabled={remoteActionLoading === `url-${selectedRemote}`}
>
{remoteActionLoading === `url-${selectedRemote}` ? (
<Loader2 size={12} className="spin" />
) : (
<Check size={12} />
)}
</button>
<button
className="btn btn-sm"
onClick={() => { setEditingRemote(null); setEditUrlValue(""); }}
title="Cancel"
>
<X size={12} />
</button>
</div>
) : (
<>
<span className="gm-url-value" title={selectedRemoteData.fetchUrl}>
<bdo dir="ltr">{selectedRemoteData.fetchUrl}</bdo>
</span>
<div className="gm-remote-inline-actions">
<button
className="btn btn-icon btn-sm"
onClick={() => copyToClipboard(selectedRemoteData.fetchUrl, "fetch URL")}
title="Copy fetch URL"
>
<Copy size={14} />
</button>
<button
className="btn btn-icon btn-sm"
onClick={() => startEditingUrl(selectedRemoteData)}
disabled={remoteActionLoading !== null}
title="Edit remote URL"
>
<Pencil size={14} />
</button>
</div>
</>
)}
</div>
{/* Push URL */}
{selectedRemoteData.pushUrl && selectedRemoteData.pushUrl !== selectedRemoteData.fetchUrl && (
<div className="gm-remote-detail-url-row">
<span className="gm-url-label">Push:</span>
<span className="gm-url-value" title={selectedRemoteData.pushUrl}>
<bdo dir="ltr">{selectedRemoteData.pushUrl}</bdo>
</span>
<div className="gm-remote-inline-actions">
<button
className="btn btn-icon btn-sm"
onClick={() => copyToClipboard(selectedRemoteData.pushUrl!, "push URL")}
title="Copy push URL"
>
<Copy size={14} />
</button>
</div>
</div>
)}
</div>
{/* Name editing */}
<div className="gm-remote-detail-name-row">
{editingRemote === `name-${selectedRemote}` ? (
<div className="gm-remote-edit">
<input
type="text"
value={editNameValue}
onChange={(e) => setEditNameValue(e.target.value)}
className="input gm-input"
autoFocus
/>
<button
className="btn btn-sm btn-primary"
onClick={() => handleRenameRemote(selectedRemote)}
disabled={remoteActionLoading === `rename-${selectedRemote}`}
>
{remoteActionLoading === `rename-${selectedRemote}` ? (
<Loader2 size={12} className="spin" />
) : (
<Check size={12} />
)}
</button>
<button
className="btn btn-sm"
onClick={() => { setEditingRemote(null); setEditNameValue(""); }}
title="Cancel"
>
<X size={12} />
</button>
</div>
) : (
<button
className="btn btn-icon btn-sm"
onClick={() => startEditingName(selectedRemoteData)}
disabled={remoteActionLoading !== null}
title="Edit remote name"
>
<Pencil size={14} />
</button>
)}
</div>
</div>
{/* Commits to Push Section */}
{status && status.ahead > 0 && (
<div className="gm-remote-section" data-testid="commits-to-push">
<div className="gm-section-subheader">
<h5>
<ArrowUp size={14} />
Commits to Push ({status.ahead})
</h5>
</div>
{loadingAhead ? (
<div className="gm-loading">
<Loader2 size={14} className="spin" />
Loading...
</div>
) : aheadCommits.length > 0 ? (
<div className="gm-ahead-commits-list" data-testid="ahead-commits-list">
{aheadCommits.map((commit) => (
<div key={commit.hash} className="gm-commit-item-compact-wrapper">
<div
className="gm-commit-item-compact gm-commit-clickable"
onClick={() => handleCompactCommitClick(commit.hash, "ahead")}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleCompactCommitClick(commit.hash, "ahead");
}
}}
title="Click to view diff"
>
<div className="gm-commit-compact-hash">
<code className="gm-hash">{commit.shortHash}</code>
</div>
<div className="gm-commit-compact-info">
<span className="gm-commit-message" title={commit.message}>
{commit.message}
</span>
<span className="gm-commit-meta">
<span>{commit.author}</span>
<span>•</span>
<span>{relativeDate(commit.date)}</span>
</span>
</div>
<span className="gm-commit-expand-icon">
{expandedAheadCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
</div>
{expandedAheadCommit === commit.hash && (
<div className="gm-commit-diff gm-commit-diff-compact">
{loadingAheadCommitDiff ? (
<div className="gm-diff-loading">
<Loader2 size={16} className="spin" />
Loading diff...
</div>
) : aheadCommitDiff ? (
<>
{(commit.body || commit.message) && (
<div className="gm-commit-message-full">{commit.body || commit.message}</div>
)}
{aheadCommitDiff.stat && <pre className="gm-diff-stat">{aheadCommitDiff.stat}</pre>}
<pre className="gm-diff-patch">{aheadCommitDiff.patch}</pre>
</>
) : (
<div className="gm-diff-error">Failed to load diff</div>
)}
</div>
)}
</div>
))}
</div>
) : (
<div className="gm-empty">
No ahead commits found (may need to fetch first)
</div>
)}
</div>
)}
{/* Recent Remote Commits Section */}
<div className="gm-remote-section" data-testid="remote-commits-section">
<div className="gm-section-subheader">
<h5>
<Radio size={14} />
Recent commits on {selectedRemote}
</h5>
</div>
{loadingRemoteCommits ? (
<div className="gm-loading">
<Loader2 size={14} className="spin" />
Loading commits...
</div>
) : remoteCommitsError ? (
<div className="gm-error">
<AlertCircle size={14} />
{remoteCommitsError}
</div>
) : remoteCommits.length === 0 ? (
<div className="gm-empty">
No commits found on {selectedRemote}. Try fetching first.
</div>
) : (
<div className="gm-remote-commits-list" data-testid="remote-commits-list">
{remoteCommits.map((commit) => (
<div key={commit.hash} className="gm-commit-item-compact-wrapper">
<div
className="gm-commit-item-compact gm-commit-clickable"
onClick={() => handleCompactCommitClick(commit.hash, "remote")}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleCompactCommitClick(commit.hash, "remote");
}
}}
title="Click to view diff"
>
<div className="gm-commit-compact-hash">
<code className="gm-hash">{commit.shortHash}</code>
</div>
<div className="gm-commit-compact-info">
<span className="gm-commit-message" title={commit.message}>
{commit.message}
</span>
<span className="gm-commit-meta">
<span>{commit.author}</span>
<span>•</span>
<span>{relativeDate(commit.date)}</span>
</span>
</div>
<span className="gm-commit-expand-icon">
{expandedRemoteCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
</div>
{expandedRemoteCommit === commit.hash && (
<div className="gm-commit-diff gm-commit-diff-compact">
{loadingRemoteCommitDiff ? (
<div className="gm-diff-loading">
<Loader2 size={16} className="spin" />
Loading diff...
</div>
) : remoteCommitDiff ? (
<>
{(commit.body || commit.message) && (
<div className="gm-commit-message-full">{commit.body || commit.message}</div>
)}
{remoteCommitDiff.stat && <pre className="gm-diff-stat">{remoteCommitDiff.stat}</pre>}
<pre className="gm-diff-patch">{remoteCommitDiff.patch}</pre>
</>
) : (
<div className="gm-diff-error">Failed to load diff</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</>
) : (
<div className="gm-empty">
Select a remote to view details
</div>
)}
{lastRemoteResult && (
<div className="gm-remote-result">
{lastRemoteResult.message}
</div>
)}
</div>
</div>
</div>
);
}