import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import type { Task } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import type { GitStatus, GitCommit, GitBranch, GitWorktree, GitFetchResult, GitPullResult, GitPushResult, GitStash, GitFileChange, GitRemoteDetailed, } from "../api"; import { fetchGitStatus, fetchGitCommits, fetchCommitDiff, fetchGitBranches, fetchGitWorktrees, createBranch, checkoutBranch, deleteBranch, fetchRemote, pullBranch, pushBranch, fetchGitStashList, createStash, applyStash, dropStash, fetchFileChanges, stageFiles, unstageFiles, createCommit, discardChanges, fetchUnstagedDiff, 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, } 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, addToast, projectId }: GitManagerModalProps) { const [activeSection, setActiveSection] = useState("status"); const [loading, setLoading] = useState(false); const [sectionError, setSectionError] = useState(null); const modalRef = useRef(null); const copyToClipboard = useCopyToClipboard(addToast); // ── Status state const [status, setStatus] = 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); // ── 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); // ── 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); setStatus(statusData); break; } case "changes": { const [statusData, changes] = await Promise.all([fetchGitStatus(projectId), fetchFileChanges(projectId)]); setStatus(statusData); setFileChanges(changes); setSelectedFiles(new Set()); break; } case "commits": { const commitsData = await fetchGitCommits(commitsLimit, projectId); setCommits(commitsData); break; } case "branches": { const [branchesData, statusForBranch] = await Promise.all([fetchGitBranches(projectId), fetchGitStatus(projectId)]); setBranches(branchesData); setStatus(statusForBranch); break; } case "worktrees": { const worktreesData = await fetchGitWorktrees(projectId); setWorktrees(worktreesData); break; } case "stashes": { const stashesData = await fetchGitStashList(projectId); setStashes(stashesData); break; } case "remotes": { const remoteStatus = await fetchGitStatus(projectId); setStatus(remoteStatus); break; } } } catch (err: any) { setSectionError(err.message || "Failed to fetch git data"); addToast(err.message || "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") { onClose(); 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, onClose, 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()); } catch (err: any) { addToast(err.message || "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()); } catch (err: any) { addToast(err.message || "Failed to unstage files", "error"); } }, [addToast, projectId]); const handleDiscardChanges = useCallback(async (files: string[]) => { if (!confirm(`Discard changes to ${files.length} file(s)? This cannot be undone.`)) 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)]); setFileChanges(changes); setStatus(statusData); setSelectedFiles(new Set()); } catch (err: any) { addToast(err.message || "Failed to discard changes", "error"); } }, [addToast, projectId]); 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)]); setFileChanges(changes); setStatus(statusData); } catch (err: any) { addToast(err.message || "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)]); setFileChanges(changes); setStatus(statusData); } catch (err: any) { addToast(err.message || "Failed to commit", "error"); } finally { setCommitting(false); } }, [commitMessage, fileChanges, addToast, projectId]); const handleViewDiff = useCallback(async () => { setLoadingChangeDiff(true); try { const diff = await fetchUnstagedDiff(projectId); setChangeDiff(diff); } catch (err: any) { addToast(err.message || "Failed to load diff", "error"); } finally { 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: any) { addToast(err.message || "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: any) { addToast(err.message || "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), fetchGitBranches(projectId)]); setStatus(statusData); setBranches(branchesData); } catch (err: any) { addToast(err.message || "Failed to checkout branch", "error"); } finally { setLoading(false); } }, [addToast, projectId]); const handleDeleteBranch = useCallback(async (name: string) => { if (!confirm(`Delete branch "${name}"?`)) return; setLoading(true); try { await deleteBranch(name, undefined, projectId); addToast(`Deleted branch ${name}`, "success"); const branchesData = await fetchGitBranches(projectId); setBranches(branchesData); } catch (err: any) { if (err.message?.includes("not fully merged")) { if (confirm("Branch has unmerged commits. Force delete?")) { try { await deleteBranch(name, true, projectId); addToast(`Force deleted branch ${name}`, "success"); const branchesData = await fetchGitBranches(projectId); setBranches(branchesData); } catch (forceErr: any) { addToast(forceErr.message || "Failed to delete branch", "error"); } } } else { addToast(err.message || "Failed to delete branch", "error"); } } finally { setLoading(false); } }, [addToast, projectId]); 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 handleCreateStash = useCallback(async (e: React.FormEvent) => { e.preventDefault(); setStashLoading("create"); try { await createStash(stashMessage.trim() || undefined, projectId); addToast("Changes stashed", "success"); setStashMessage(""); const stashesData = await fetchGitStashList(projectId); setStashes(stashesData); } catch (err: any) { addToast(err.message || "Failed to stash changes", "error"); } finally { setStashLoading(null); } }, [stashMessage, addToast, projectId]); const handleApplyStash = useCallback(async (index: number, drop: boolean = false) => { setStashLoading(`apply-${index}`); try { await applyStash(index, drop, projectId); addToast(drop ? "Stash popped" : "Stash applied", "success"); const stashesData = await fetchGitStashList(projectId); setStashes(stashesData); } catch (err: any) { addToast(err.message || "Failed to apply stash", "error"); } finally { setStashLoading(null); } }, [addToast, projectId]); const handleDropStash = useCallback(async (index: number) => { if (!confirm(`Drop stash@{${index}}? This cannot be undone.`)) return; setStashLoading(`drop-${index}`); try { await dropStash(index, projectId); addToast("Stash dropped", "success"); const stashesData = await fetchGitStashList(projectId); setStashes(stashesData); } catch (err: any) { addToast(err.message || "Failed to drop stash", "error"); } finally { setStashLoading(null); } }, [addToast, projectId]); // ── 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); setStatus(statusData); } catch (err: any) { addToast(err.message || "Fetch failed", "error"); } finally { setRemoteLoading(null); } }, [addToast, projectId]); const handlePull = useCallback(async () => { setRemoteLoading("pull"); try { const result = await pullBranch(projectId); setLastRemoteResult(result); if (result.conflict) { addToast("Merge conflict detected. Resolve manually.", "error"); } else { addToast(result.message || "Pull completed", "success"); } const statusData = await fetchGitStatus(projectId); setStatus(statusData); } catch (err: any) { addToast(err.message || "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); setStatus(statusData); } catch (err: any) { addToast(err.message || "Push failed", "error"); } finally { setRemoteLoading(null); } }, [addToast, projectId]); // ── 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 (
e.target === e.currentTarget && onClose()}>

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, }: { status: GitStatus; copyToClipboard: (text: string, label?: string) => void; }) { return (

Repository Status

Branch {status.branch}
Commit {status.commit}
Working Tree {status.isDirty ? ( <> Modified ) : ( <> Clean )}
Remote Sync {status.ahead > 0 && ( {status.ahead} )} {status.behind > 0 && ( {status.behind} )} {status.ahead === 0 && status.behind === 0 && ( Up to date )}
); } /** Changes panel with staging, unstaging, committing */ function ChangesPanel({ status, stagedFiles, unstagedFiles, selectedFiles, toggleFileSelection, onStageFiles, onUnstageFiles, onDiscardChanges, onViewDiff, changeDiff, loadingChangeDiff, 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; onViewDiff: () => void; changeDiff: { stat: string; patch: string } | null; loadingChangeDiff: boolean; 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) => (
{f.file}
)) )}
{/* Staged Changes */}
Staged Changes ({stagedFiles.length})
{selectedStaged.length > 0 && ( )} {stagedFiles.length > 0 && ( )}
{stagedFiles.length === 0 ? (
No staged changes
) : ( stagedFiles.map((f) => (
{f.file}
)) )}
{/* Diff Viewer */} {unstagedFiles.length > 0 && (
{changeDiff && (
{changeDiff.stat &&
{changeDiff.stat}
}
{changeDiff.patch}
)}
)} {/* Commit Form */}