import type { GitBranch } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { useStore } from "../store"; import { useNativeApi } from "../hooks/useNativeApi"; interface BranchToolbarProps { envMode: "local" | "worktree"; onEnvModeChange: (mode: "local" | "worktree") => void; envLocked: boolean; } export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) { const { state, dispatch } = useStore(); const api = useNativeApi(); const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [isCreatingBranch, setIsCreatingBranch] = useState(false); const [newBranchName, setNewBranchName] = useState(""); const branchMenuRef = useRef(null); const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId); const activeProject = state.projects.find((project) => project.id === activeThread?.projectId); const activeThreadId = activeThread?.id; const activeThreadBranch = activeThread?.branch ?? null; const activeWorktreePath = activeThread?.worktreePath ?? null; const branchCwd = activeWorktreePath ?? activeProject?.cwd; // ── Queries ─────────────────────────────────────────────────────────── const branchesQuery = useQuery({ queryKey: ["git", "branches", branchCwd], queryFn: () => api!.git.listBranches({ cwd: branchCwd! }), enabled: !!api && !!branchCwd, }); const branches = branchesQuery.data?.branches ?? []; // Default to true while loading — showing "Initialize git" during a fetch is wrong, // and worktrees are inherently git repos. const isRepo = branchesQuery.data?.isRepo ?? !branchesQuery.isLoading; // ── Mutations ───────────────────────────────────────────────────────── const checkoutMutation = useMutation({ mutationFn: (branch: string) => api!.git.checkout({ cwd: branchCwd!, branch }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }), onError: (error) => setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."), }); const createBranchMutation = useMutation({ mutationFn: (branch: string) => api!.git .createBranch({ cwd: branchCwd!, branch }) .then(() => api!.git.checkout({ cwd: branchCwd!, branch })), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }), onError: (error) => setThreadError(error instanceof Error ? error.message : "Failed to create branch."), }); const initMutation = useMutation({ mutationFn: () => api!.git.init({ cwd: branchCwd! }), onSuccess: () => { setThreadError(null); queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }); }, onError: (error) => setThreadError(error instanceof Error ? error.message : "Failed to initialize git repo."), }); // ── Effects ─────────────────────────────────────────────────────────── // Keep thread branch synced to git current branch for local threads. const queryBranches = branchesQuery.data?.branches; useEffect(() => { if (!activeThreadId || activeWorktreePath) return; const current = queryBranches?.find((branch) => branch.current); if (!current) return; if (current.name === activeThreadBranch) return; dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch: current.name, worktreePath: null, }); }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, dispatch]); useEffect(() => { if (!isBranchMenuOpen) return; const handleClickOutside = (event: MouseEvent) => { if (!branchMenuRef.current) return; if (event.target instanceof Node && !branchMenuRef.current.contains(event.target)) { setIsBranchMenuOpen(false); } }; window.addEventListener("mousedown", handleClickOutside); return () => { window.removeEventListener("mousedown", handleClickOutside); }; }, [isBranchMenuOpen]); // ── Helpers ─────────────────────────────────────────────────────────── const setThreadError = (error: string | null) => { if (!activeThreadId) return; dispatch({ type: "SET_ERROR", threadId: activeThreadId, error }); }; const setThreadBranch = (branch: string | null, worktreePath: string | null) => { if (!activeThreadId) return; // If the effective cwd is about to change, stop the running session so the // next message creates a new one with the correct cwd. const sessionId = activeThread?.session?.sessionId; if (sessionId && worktreePath !== activeWorktreePath) { void api?.providers.stopSession({ sessionId }).catch(() => undefined); } dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath }); }; const selectBranch = (branch: GitBranch) => { if (!api || !activeThreadId || !branchCwd) return; // For new worktree mode, selecting a branch picks the base branch. if (envMode === "worktree" && !envLocked && !activeWorktreePath) { setThreadError(null); setThreadBranch(branch.name, null); setIsBranchMenuOpen(false); return; } // If the branch already lives in a worktree, redirect there instead of // trying to checkout (which git would reject with "already used by worktree"). if (branch.worktreePath) { const isMainWorktree = branch.worktreePath === activeProject?.cwd; setThreadError(null); // Main worktree → switch back to local (project cwd, worktreePath=null). // Secondary worktree → point the thread at that worktree path. setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath); setIsBranchMenuOpen(false); return; } checkoutMutation.mutate(branch.name, { onSuccess: () => { setThreadError(null); setThreadBranch(branch.name, activeWorktreePath); setIsBranchMenuOpen(false); }, }); }; const createBranch = () => { const name = newBranchName.trim(); if (!api || !activeThreadId || !branchCwd || !name) return; createBranchMutation.mutate(name, { onSuccess: () => { setThreadError(null); setThreadBranch(name, activeWorktreePath); setNewBranchName(""); setIsCreatingBranch(false); setIsBranchMenuOpen(false); }, }); }; if (!activeThread || !activeProject) return null; return (
{envLocked || activeWorktreePath ? ( {activeWorktreePath ? "Worktree" : "Local"} ) : ( )}
{!isRepo ? ( ) : (
{isBranchMenuOpen && (

Branch

{branches.map((branch) => { const isSelected = branch.name === activeThread.branch; const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProject.cwd; return ( ); })}
{envMode === "local" && ( <>
{isCreatingBranch ? (
{ event.preventDefault(); createBranch(); }} > setNewBranchName(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape") { setIsCreatingBranch(false); setNewBranchName(""); } }} // biome-ignore lint/a11y/noAutofocus: branch name input should focus when shown autoFocus />
) : ( )} )}
)}
)}
); }