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 ; case "modified": return ; case "deleted": return ; case "renamed": case "copied": return ; default: return ; } } /** 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 {label}; } /** 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("status"); const [loading, setLoading] = useState(false); const [sectionError, setSectionError] = useState(null); const modalRef = useRef(null); useModalResizePersist(modalRef, isOpen, "fusion:git-modal-size"); const overlayDismissProps = useOverlayDismiss(handleClose); const copyToClipboard = useCopyToClipboard(addToast); // ── Status state const [status, setStatus] = useState(null); const [rootDir, setRootDir] = useState(null); // ── Changes state const [fileChanges, setFileChanges] = useState([]); const [selectedFiles, setSelectedFiles] = useState>(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(null); const [selectedDiffTarget, setSelectedDiffTarget] = useState<{ file: string; staged: boolean } | null>(null); const changeDiffRequestIdRef = useRef(0); // ── Commits state const [commits, setCommits] = useState([]); const [selectedCommit, setSelectedCommit] = useState(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([]); const [newBranchName, setNewBranchName] = useState(""); const [branchBase, setBranchBase] = useState(""); const [branchSearch, setBranchSearch] = useState(""); const [selectedBranch, setSelectedBranch] = useState(null); const [branchCommits, setBranchCommits] = useState([]); const [loadingBranchCommits, setLoadingBranchCommits] = useState(false); const [expandedBranchCommit, setExpandedBranchCommit] = useState(null); const [branchCommitDiff, setBranchCommitDiff] = useState<{ stat: string; patch: string } | null>(null); const [loadingBranchCommitDiff, setLoadingBranchCommitDiff] = useState(false); // ── Worktrees state const [worktrees, setWorktrees] = useState([]); // ── Stashes state const [stashes, setStashes] = useState([]); const [stashMessage, setStashMessage] = useState(""); const [stashLoading, setStashLoading] = useState(null); const [expandedStashIndex, setExpandedStashIndex] = useState(null); const [stashDiff, setStashDiff] = useState<{ stat: string; patch: string } | null>(null); const [loadingStashDiff, setLoadingStashDiff] = useState(false); const [stashDiffError, setStashDiffError] = useState(null); const stashDiffRequestIdRef = useRef(0); // ── Remotes state const [remoteLoading, setRemoteLoading] = useState(null); const [lastRemoteResult, setLastRemoteResult] = useState(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]); // 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/ // 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 (

Git Manager

{/* Sidebar Navigation */} {/* Content Area */}
{/* Loading overlay */} {loading && (
Loading...
)} {/* Error state */} {sectionError && !loading && (
{sectionError}
)} {/* ── Status Panel ── */} {activeSection === "status" && !loading && status && ( )} {/* ── Changes Panel ── */} {activeSection === "changes" && !loading && ( )} {/* ── Commits Panel ── */} {activeSection === "commits" && !loading && ( = commitsLimit && commitsLimit < 100} copyToClipboard={copyToClipboard} /> )} {/* ── Branches Panel ── */} {activeSection === "branches" && !loading && ( )} {/* ── Worktrees Panel ── */} {activeSection === "worktrees" && !loading && ( )} {/* ── Stashes Panel ── */} {activeSection === "stashes" && !loading && ( )} {/* ── Remotes Panel ── */} {activeSection === "remotes" && !loading && ( )}
); } // ── 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>(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 (

Repository Status

Branch {status.branch} {/* Only flag "not on " 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 && ( {" "}(not on {status.integrationBranch}) )}
Commit {status.commit} {status.headSha && ( )}
Working Tree {status.isDirty ? ( <> Modified ) : ( <> Clean )} {status.dirtyDetails && (status.dirtyDetails.staged + status.dirtyDetails.modified + status.dirtyDetails.untracked + status.dirtyDetails.conflicted) > 0 && ( {status.dirtyDetails.staged > 0 && {status.dirtyDetails.staged} staged} {status.dirtyDetails.staged > 0 && (status.dirtyDetails.modified + status.dirtyDetails.untracked + status.dirtyDetails.conflicted) > 0 && " · "} {status.dirtyDetails.modified > 0 && {status.dirtyDetails.modified} modified} {status.dirtyDetails.modified > 0 && (status.dirtyDetails.untracked + status.dirtyDetails.conflicted) > 0 && " · "} {status.dirtyDetails.untracked > 0 && {status.dirtyDetails.untracked} untracked} {status.dirtyDetails.untracked > 0 && status.dirtyDetails.conflicted > 0 && " · "} {status.dirtyDetails.conflicted > 0 && ( {status.dirtyDetails.conflicted} conflicted )} )}
vs origin {status.ahead > 0 && ( {status.ahead} )} {status.behind > 0 && ( {status.behind} )} {status.ahead === 0 && status.behind === 0 && ( Up to date )}
{status.integrationBranch && (
Integration branch {status.integrationBranch} {status.integrationBranchSource && ( {" "}({status.integrationBranchSource}) )} {status.integrationTipSha && ( tip {status.integrationTipSha.slice(0, 8)} {status.integrationTipSource === "remote-only" && ( <> {" "}(remote-only — run git switch {status.integrationBranch} to track locally) )} )} {status.integrationTipSource === "missing" && ( no ref found for {status.integrationBranch} )}
{status.integrationTipSha !== undefined && (status.aheadOfIntegration !== undefined || status.behindIntegration !== undefined) && (
HEAD vs {status.integrationBranch} {(status.aheadOfIntegration ?? 0) === 0 && (status.behindIntegration ?? 0) === 0 ? ( Aligned ) : ( <> {(status.aheadOfIntegration ?? 0) > 0 && ( {status.aheadOfIntegration} )} {(status.behindIntegration ?? 0) > 0 && ( {status.behindIntegration} )} )}
)} {status.originIntegrationTipSha !== undefined && (
Local {status.integrationBranch} vs origin {status.originIntegrationTipSha === null ? ( no origin tracking ) : 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. no local tracking ) : (status.aheadOfOriginIntegration ?? 0) === 0 && (status.behindOriginIntegration ?? 0) === 0 ? ( Synced ) : ( <> {(status.aheadOfOriginIntegration ?? 0) > 0 && ( {status.aheadOfOriginIntegration} )} {(status.behindOriginIntegration ?? 0) > 0 && ( {status.behindOriginIntegration} )} )}
)} {status.integrationTipSource === "remote-only" && status.aheadOfIntegrationRemote !== undefined && ( // In remote-only mode the `HEAD vs ` card is suppressed // (no local tip to compare against). Surface a dedicated HEAD vs // origin/ card so the operator still sees a meaningful // distance.
HEAD vs origin/{status.integrationBranch} {(status.aheadOfIntegrationRemote ?? 0) === 0 && (status.behindIntegrationRemote ?? 0) === 0 ? ( Aligned ) : ( <> {(status.aheadOfIntegrationRemote ?? 0) > 0 && ( {status.aheadOfIntegrationRemote} )} {(status.behindIntegrationRemote ?? 0) > 0 && ( {status.behindIntegrationRemote} )} )}
)} {(status.stashCount ?? 0) > 0 && (
Stashes {status.stashCount}
)}
)} {status.indexStaleVsHead === true && (
Stale index detected.{" "} 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 "staged changes." Enable mergeAdvanceAutoSync in Settings to have the merger reconcile automatically, or run git reset --hard HEAD to snap forward manually.
)} {visibleAdvances.length > 0 && (
Recent integration-branch advances {" "}({actionableAdvances.length} need action) {showSyncWorkingTree && ( )}
{advancesHelpOpen && (

Each entry is a Fusion task whose squash commit advanced the integration branch ref ({status.integrationBranch ?? "main"}). The auto-sync outcome says whether your working tree was also fast-forwarded to that new tip.

  • clean-sync / synced-with-edits-restored — working tree is in sync; nothing to do.
  • reachable / subsumed / orphaned / superseded — already handled (including history rewrites where equivalent content already landed, original SHAs disappeared, or HEAD is already aligned to the rewritten integration tip).
  • pending + off / not run — auto-sync is disabled in Settings; the branch ref moved but your worktree didn't follow.
  • pending + stash-failed / would-conflict / similar — auto-sync tried but couldn't reconcile (usually local edits collide with the new commit).

Fix: Fusion only shows Sync working tree when at least one advance is genuinely pending and HEAD is not aligned with the integration tip. If entries are already handled (subsumed/orphaned/reachable/superseded), no sync action is offered.

)}
    {visibleAdvances.map((advance) => (
  • {advance.toSha.slice(0, 8)} {" "} {advance.taskId} {advance.autoSyncOutcome ? ( {" "}auto-sync: {advance.autoSyncOutcome} ) : ( {" "}auto-sync: off / not run )} {" "}· {new Date(advance.advancedAt).toLocaleTimeString()} · {advance.resolution} {(advance.resolution === "orphaned" || advance.resolution === "subsumed" || advance.resolution === "superseded") && ( )}
  • ))}
)}
); } /** 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; 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 (
{/* Current branch indicator */} {status && (
{status.branch} {status.isDirty && ( Modified )}
)}
{/* Unstaged Changes */}
Unstaged Changes ({unstagedFiles.length})
{selectedUnstaged.length > 0 && ( <> )} {unstagedFiles.length > 0 && ( )}
{unstagedFiles.length === 0 ? (
No unstaged changes
) : ( unstagedFiles.map((f) => { const isActive = selectedDiffTarget?.file === f.file && selectedDiffTarget.staged === false; return (
onSelectDiffFile(f.file, false)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelectDiffFile(f.file, false); } }} > {f.file}
); }) )}
{/* Staged Changes */}
Staged Changes ({stagedFiles.length})
{selectedStaged.length > 0 && ( )} {stagedFiles.length > 0 && ( )}
{stagedFiles.length === 0 ? (
No staged changes
) : ( stagedFiles.map((f) => { const isActive = selectedDiffTarget?.file === f.file && selectedDiffTarget.staged === true; return (
onSelectDiffFile(f.file, true)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelectDiffFile(f.file, true); } }} > {f.file}
); }) )}
{/* Diff Viewer (right pane on desktop, stacked below on mobile) */}
{(selectedDiffTarget || loadingChangeDiff || changeDiff || changeDiffError) ? (
{selectedDiffTarget && (
{selectedDiffTarget.staged ? "Staged" : "Unstaged"} diff: {selectedDiffTarget.file}
)} {loadingChangeDiff && (
Loading diff...
)} {changeDiffError && !loadingChangeDiff && (
{changeDiffError}
)} {changeDiff && !loadingChangeDiff && (
{changeDiff.stat &&
{changeDiff.stat}
}
{changeDiff.patch}
)}
) : (
Select a file to view its diff
)}
{/* /gm-changes-split */} {/* Commit Form */}