import { ArrowRight, GitBranch as GitBranchIcon, GitPullRequest, RefreshCw, Settings, AlertTriangle, CheckCircle, ExternalLink, } from 'lucide-react'; import { Button } from '@/components/ui/button.tsx'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip.tsx'; import { useMemo, useState } from 'react'; import type { BranchStatus, Merge, GitBranch, TaskAttempt, TaskWithAttemptStatus, } from 'shared/types'; import { ChangeTargetBranchDialog } from '@/components/dialogs/tasks/ChangeTargetBranchDialog'; import { RebaseDialog } from '@/components/dialogs/tasks/RebaseDialog'; import { CreatePRDialog } from '@/components/dialogs/tasks/CreatePRDialog'; import { useTranslation } from 'react-i18next'; import { useGitOperations } from '@/hooks/useGitOperations'; interface GitOperationsProps { selectedAttempt: TaskAttempt; task: TaskWithAttemptStatus; projectId: string; branchStatus: BranchStatus | null; branches: GitBranch[]; isAttemptRunning: boolean; selectedBranch: string | null; layout?: 'horizontal' | 'vertical'; } export type GitOperationsInputs = Omit; function GitOperations({ selectedAttempt, task, projectId, branchStatus, branches, isAttemptRunning, selectedBranch, layout = 'horizontal', }: GitOperationsProps) { const { t } = useTranslation('tasks'); const git = useGitOperations(selectedAttempt.id, projectId); const isChangingTargetBranch = git.states.changeTargetBranchPending; // Git status calculations const hasConflictsCalculated = useMemo( () => Boolean((branchStatus?.conflicted_files?.length ?? 0) > 0), [branchStatus?.conflicted_files] ); // Local state for git operations const [merging, setMerging] = useState(false); const [pushing, setPushing] = useState(false); const [rebasing, setRebasing] = useState(false); const [mergeSuccess, setMergeSuccess] = useState(false); const [pushSuccess, setPushSuccess] = useState(false); // Target branch change handlers const handleChangeTargetBranchClick = async (newBranch: string) => { await git.actions.changeTargetBranch(newBranch); }; const handleChangeTargetBranchDialogOpen = async () => { try { const result = await ChangeTargetBranchDialog.show({ branches, isChangingTargetBranch: isChangingTargetBranch, }); if (result.action === 'confirmed' && result.branchName) { await handleChangeTargetBranchClick(result.branchName); } } catch (error) { // User cancelled - do nothing } }; // Memoize merge status information to avoid repeated calculations const mergeInfo = useMemo(() => { if (!branchStatus?.merges) return { hasOpenPR: false, openPR: null, hasMergedPR: false, mergedPR: null, hasMerged: false, latestMerge: null, }; const openPR = branchStatus.merges.find( (m) => m.type === 'pr' && m.pr_info.status === 'open' ); const mergedPR = branchStatus.merges.find( (m) => m.type === 'pr' && m.pr_info.status === 'merged' ); const merges = branchStatus.merges.filter( (m: Merge) => m.type === 'direct' || (m.type === 'pr' && m.pr_info.status === 'merged') ); return { hasOpenPR: !!openPR, openPR, hasMergedPR: !!mergedPR, mergedPR, hasMerged: merges.length > 0, latestMerge: branchStatus.merges[0] || null, // Most recent merge }; }, [branchStatus?.merges]); const mergeButtonLabel = useMemo(() => { if (mergeSuccess) return t('git.states.merged'); if (merging) return t('git.states.merging'); return t('git.states.merge'); }, [mergeSuccess, merging, t]); const rebaseButtonLabel = useMemo(() => { if (rebasing) return t('git.states.rebasing'); return t('git.states.rebase'); }, [rebasing, t]); const prButtonLabel = useMemo(() => { if (mergeInfo.hasOpenPR) { return pushSuccess ? t('git.states.pushed') : pushing ? t('git.states.pushing') : t('git.states.push'); } return t('git.states.createPr'); }, [mergeInfo.hasOpenPR, pushSuccess, pushing, t]); const handleMergeClick = async () => { // Directly perform merge without checking branch status await performMerge(); }; const handlePushClick = async () => { try { setPushing(true); await git.actions.push(); setPushSuccess(true); setTimeout(() => setPushSuccess(false), 2000); } finally { setPushing(false); } }; const performMerge = async () => { try { setMerging(true); await git.actions.merge(); setMergeSuccess(true); setTimeout(() => setMergeSuccess(false), 2000); } finally { setMerging(false); } }; const handleRebaseWithNewBranchAndUpstream = async ( newBaseBranch: string, selectedUpstream: string ) => { setRebasing(true); try { await git.actions.rebase({ newBaseBranch: newBaseBranch, oldBaseBranch: selectedUpstream, }); } finally { setRebasing(false); } }; const handleRebaseDialogOpen = async () => { try { const defaultTargetBranch = selectedAttempt.target_branch; const result = await RebaseDialog.show({ branches, isRebasing: rebasing, initialTargetBranch: defaultTargetBranch, initialUpstreamBranch: defaultTargetBranch, }); if ( result.action === 'confirmed' && result.branchName && result.upstreamBranch ) { await handleRebaseWithNewBranchAndUpstream( result.branchName, result.upstreamBranch ); } } catch (error) { // User cancelled - do nothing } }; const handlePRButtonClick = async () => { // If PR already exists, push to it if (mergeInfo.hasOpenPR) { await handlePushClick(); return; } CreatePRDialog.show({ attempt: selectedAttempt, task, projectId, }); }; const isVertical = layout === 'vertical'; const containerClasses = isVertical ? 'grid grid-cols-1 items-start gap-3 overflow-hidden' : 'grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 overflow-hidden'; const settingsBtnClasses = isVertical ? 'inline-flex h-5 w-5 p-0 hover:bg-muted' : 'hidden md:inline-flex h-5 w-5 p-0 hover:bg-muted'; const actionsClasses = isVertical ? 'flex flex-wrap items-center gap-2' : 'shrink-0 flex flex-wrap items-center gap-2 overflow-y-hidden overflow-x-visible max-h-8'; return (
{/* Left: Branch flow */}
{/* Task branch chip */} {selectedAttempt.branch} {t('git.labels.taskBranch')} {/* Target branch chip + change button */}
{branchStatus?.target_branch_name || selectedAttempt.target_branch || selectedBranch || t('git.branch.current')} {t('rebase.dialog.targetLabel')} {t('branches.changeTarget.dialog.title')}
{/* Center: Status chips */}
{(() => { const commitsAhead = branchStatus?.commits_ahead ?? 0; const commitsBehind = branchStatus?.commits_behind ?? 0; if (hasConflictsCalculated) { return ( {t('git.status.conflicts')} ); } if (branchStatus?.is_rebase_in_progress) { return ( {t('git.states.rebasing')} ); } if (mergeInfo.hasMergedPR) { return ( {t('git.states.merged')} ); } if (mergeInfo.hasOpenPR && mergeInfo.openPR?.type === 'pr') { const prMerge = mergeInfo.openPR; return ( ); } const chips: React.ReactNode[] = []; if (commitsAhead > 0) { chips.push( +{commitsAhead}{' '} {t('git.status.commits', { count: commitsAhead })}{' '} {t('git.status.ahead')} ); } if (commitsBehind > 0) { chips.push( {commitsBehind}{' '} {t('git.status.commits', { count: commitsBehind })}{' '} {t('git.status.behind')} ); } if (chips.length > 0) return
{chips}
; return ( {t('git.status.upToDate')} ); })()}
{/* Right: Actions */} {branchStatus && (
)}
); } export default GitOperations;