import { useState, useEffect, useCallback, useRef } from "react"; import type { Task } from "@kb/core"; import type { ToastType } from "../hooks/useToast"; import type { GitStatus, GitCommit, GitBranch, GitWorktree, GitFetchResult, GitPullResult, GitPushResult, } from "../api"; import { fetchGitStatus, fetchGitCommits, fetchCommitDiff, fetchGitBranches, fetchGitWorktrees, createBranch, checkoutBranch, deleteBranch, fetchRemote, pullBranch, pushBranch, } from "../api"; import { GitBranch as GitBranchIcon, GitCommit as GitCommitIcon, GitPullRequest, GitMerge, RefreshCw, Plus, Trash2, ChevronRight, ChevronDown, Check, X, Loader2, HardDrive, Radio, ArrowUp, ArrowDown, AlertCircle, } from "lucide-react"; type SectionId = "status" | "commits" | "branches" | "worktrees" | "remotes"; const SECTIONS = [ { id: "status" as SectionId, label: "Status", icon: Radio }, { id: "commits" as SectionId, label: "Commits", icon: GitCommitIcon }, { id: "branches" as SectionId, label: "Branches", icon: GitBranchIcon }, { id: "worktrees" as SectionId, label: "Worktrees", icon: HardDrive }, { id: "remotes" as SectionId, label: "Remotes", icon: GitMerge }, ]; interface GitManagerModalProps { isOpen: boolean; onClose: () => void; tasks: Task[]; addToast: (message: string, type?: ToastType) => void; } export function GitManagerModal({ isOpen, onClose, tasks, addToast }: GitManagerModalProps) { const [activeSection, setActiveSection] = useState("status"); const [loading, setLoading] = useState(false); const [status, setStatus] = useState(null); const [commits, setCommits] = useState([]); const [branches, setBranches] = useState([]); const [worktrees, setWorktrees] = useState([]); const [selectedCommit, setSelectedCommit] = useState(null); const [commitDiff, setCommitDiff] = useState<{ stat: string; patch: string } | null>(null); const [newBranchName, setNewBranchName] = useState(""); const [branchBase, setBranchBase] = useState(""); const [loadingDiff, setLoadingDiff] = useState(false); const [remoteLoading, setRemoteLoading] = useState(null); const [lastRemoteResult, setLastRemoteResult] = useState(null); const [commitsLimit, setCommitsLimit] = useState(20); const modalRef = useRef(null); // Fetch data when section changes or modal opens const fetchSectionData = useCallback(async () => { if (!isOpen) return; setLoading(true); try { switch (activeSection) { case "status": const statusData = await fetchGitStatus(); setStatus(statusData); break; case "commits": const commitsData = await fetchGitCommits(commitsLimit); setCommits(commitsData); break; case "branches": const [branchesData, statusForBranch] = await Promise.all([fetchGitBranches(), fetchGitStatus()]); setBranches(branchesData); setStatus(statusForBranch); break; case "worktrees": const worktreesData = await fetchGitWorktrees(); setWorktrees(worktreesData); break; case "remotes": // Just refresh status for remote section const remoteStatus = await fetchGitStatus(); setStatus(remoteStatus); break; } } catch (err: any) { addToast(err.message || "Failed to fetch git data", "error"); } finally { setLoading(false); } }, [activeSection, isOpen, commitsLimit, addToast]); useEffect(() => { if (isOpen) { fetchSectionData(); } }, [fetchSectionData, isOpen]); // Keyboard support useEffect(() => { if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, [isOpen, onClose]); // Handle commit selection and diff loading const handleCommitClick = useCallback(async (hash: string) => { if (selectedCommit === hash) { setSelectedCommit(null); setCommitDiff(null); return; } setSelectedCommit(hash); setLoadingDiff(true); try { const diff = await fetchCommitDiff(hash); setCommitDiff(diff); } catch (err: any) { addToast(err.message || "Failed to load diff", "error"); setCommitDiff(null); } finally { setLoadingDiff(false); } }, [selectedCommit, addToast]); // Handle branch creation const handleCreateBranch = useCallback(async (e: React.FormEvent) => { e.preventDefault(); if (!newBranchName.trim()) return; setLoading(true); try { await createBranch(newBranchName.trim(), branchBase.trim() || undefined); addToast(`Created branch ${newBranchName}`, "success"); setNewBranchName(""); setBranchBase(""); // Refresh branches const branchesData = await fetchGitBranches(); setBranches(branchesData); } catch (err: any) { addToast(err.message || "Failed to create branch", "error"); } finally { setLoading(false); } }, [newBranchName, branchBase, addToast]); // Handle branch checkout const handleCheckoutBranch = useCallback(async (name: string) => { setLoading(true); try { await checkoutBranch(name); addToast(`Switched to ${name}`, "success"); // Refresh status and branches const [statusData, branchesData] = await Promise.all([fetchGitStatus(), fetchGitBranches()]); setStatus(statusData); setBranches(branchesData); } catch (err: any) { addToast(err.message || "Failed to checkout branch", "error"); } finally { setLoading(false); } }, [addToast]); // Handle branch deletion const handleDeleteBranch = useCallback(async (name: string) => { if (!confirm(`Delete branch "${name}"?`)) return; setLoading(true); try { await deleteBranch(name); addToast(`Deleted branch ${name}`, "success"); // Refresh branches const branchesData = await fetchGitBranches(); 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); addToast(`Force deleted branch ${name}`, "success"); const branchesData = await fetchGitBranches(); 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]); // Handle fetch const handleFetch = useCallback(async () => { setRemoteLoading("fetch"); try { const result = await fetchRemote(); setLastRemoteResult(result); addToast(result.message || "Fetch completed", result.fetched ? "success" : "info"); // Refresh status const statusData = await fetchGitStatus(); setStatus(statusData); } catch (err: any) { addToast(err.message || "Fetch failed", "error"); } finally { setRemoteLoading(null); } }, [addToast]); // Handle pull const handlePull = useCallback(async () => { setRemoteLoading("pull"); try { const result = await pullBranch(); setLastRemoteResult(result); if (result.conflict) { addToast("Merge conflict detected. Resolve manually.", "error"); } else { addToast(result.message || "Pull completed", "success"); } // Refresh status const statusData = await fetchGitStatus(); setStatus(statusData); } catch (err: any) { addToast(err.message || "Pull failed", "error"); } finally { setRemoteLoading(null); } }, [addToast]); // Handle push const handlePush = useCallback(async () => { setRemoteLoading("push"); try { const result = await pushBranch(); setLastRemoteResult(result); addToast(result.message || "Push completed", "success"); // Refresh status const statusData = await fetchGitStatus(); setStatus(statusData); } catch (err: any) { addToast(err.message || "Push failed", "error"); } finally { setRemoteLoading(null); } }, [addToast]); // Load more commits const handleLoadMoreCommits = useCallback(() => { setCommitsLimit((prev) => Math.min(prev + 20, 100)); }, []); if (!isOpen) return null; return (
e.target === e.currentTarget && onClose()}>

Git Manager

{/* Sidebar */} {/* Content */}
{loading && (
Loading...
)} {/* Status Tab */} {activeSection === "status" && status && (

Repository Status

Branch {status.branch}
Commit {status.commit}
Status {status.isDirty ? "Modified" : "Clean"}
Remote {status.ahead > 0 && ( {status.ahead} )} {status.behind > 0 && ( {status.behind} )} {status.ahead === 0 && status.behind === 0 && ( Up to date )}
)} {/* Commits Tab */} {activeSection === "commits" && (

Recent Commits

{commits.map((commit) => (
{selectedCommit === commit.hash && (
{loadingDiff ? (
Loading diff...
) : commitDiff ? ( <>
{commitDiff.stat}
{commitDiff.patch}
) : (
Failed to load diff
)}
)}
))}
{commits.length >= commitsLimit && commitsLimit < 100 && ( )}
)} {/* Branches Tab */} {activeSection === "branches" && (

Branches

{/* Create branch form */}
setNewBranchName(e.target.value)} disabled={loading} /> setBranchBase(e.target.value)} disabled={loading} />
{/* Branches list */}
{branches.map((branch) => (
{branch.isCurrent && } {branch.name} {branch.remote && ( → {branch.remote} )}
{!branch.isCurrent && ( <> )}
))}
)} {/* Worktrees Tab */} {activeSection === "worktrees" && (

Worktrees

{worktrees.length} total {worktrees.filter((w) => w.taskId).length} in use by tasks
{worktrees.map((worktree) => (
{worktree.isMain && main} {worktree.isBare && bare} {worktree.path} {worktree.branch && ( {worktree.branch} )}
{worktree.taskId && ( {worktree.taskId} )}
))}
)} {/* Remotes Tab */} {activeSection === "remotes" && (

Remote Operations

{status && (status.ahead > 0 || status.behind > 0) && (
{status.ahead > 0 && (
{status.ahead} commit(s) to push
)} {status.behind > 0 && (
{status.behind} commit(s) to pull
)}
)}
{lastRemoteResult && (
{lastRemoteResult.message}
)}
)}
); }