'use client'; import { memo, useState, useCallback } from 'react'; import { IconGitCommit, IconGitPullRequest, IconGitMerge, IconGitCherryPick, IconLoader2, IconCheck, IconChevronDown, IconCloudDownload, IconCloudUpload, IconAlertTriangle, } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose, } from '@kandev/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, } from '@kandev/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { Checkbox } from '@kandev/ui/checkbox'; import { Label } from '@kandev/ui/label'; import { Textarea } from '@kandev/ui/textarea'; import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; import { useGitOperations } from '@/hooks/use-git-operations'; import { useToast } from '@/components/toast-provider'; import type { FileInfo } from '@/lib/state/slices'; type VcsSplitButtonProps = { sessionId: string | null; baseBranch?: string; taskTitle?: string; /** Branch name shown in the PR dialog (e.g. "feature/foo → main") */ displayBranch?: string | null; }; const VcsSplitButton = memo(function VcsSplitButton({ sessionId, baseBranch, taskTitle, displayBranch, }: VcsSplitButtonProps) { const [commitDialogOpen, setCommitDialogOpen] = useState(false); const [commitMessage, setCommitMessage] = useState(''); const [stageAll, setStageAll] = useState(true); const [prDialogOpen, setPrDialogOpen] = useState(false); const [prTitle, setPrTitle] = useState(''); const [prBody, setPrBody] = useState(''); const [prDraft, setPrDraft] = useState(true); const { toast } = useToast(); const gitStatus = useSessionGitStatus(sessionId); const { pull, push, rebase, merge, commit, createPR, isLoading: isGitLoading } = useGitOperations(sessionId); // Check if remote branch tracks current branch const currentBranch = gitStatus?.branch; const remoteBranch = gitStatus?.remote_branch; const hasMatchingUpstream = remoteBranch && currentBranch && remoteBranch === `origin/${currentBranch}`; // Uncommitted file stats const uncommittedFileCount = gitStatus?.files ? Object.keys(gitStatus.files).length : 0; let uncommittedAdditions = 0; let uncommittedDeletions = 0; if (gitStatus?.files && uncommittedFileCount > 0) { for (const file of Object.values(gitStatus.files) as FileInfo[]) { uncommittedAdditions += file.additions || 0; uncommittedDeletions += file.deletions || 0; } } const aheadCount = gitStatus?.ahead ?? 0; const behindCount = gitStatus?.behind ?? 0; // --- handlers --- const handleGitOperation = useCallback( async ( operation: () => Promise<{ success: boolean; output: string; error?: string; }>, operationName: string ) => { try { const result = await operation(); if (result.success) { toast({ title: `${operationName} successful`, description: result.output.slice(0, 200) || `${operationName} completed`, variant: 'success', }); } else { toast({ title: `${operationName} failed`, description: result.error || 'An error occurred', variant: 'error', }); } } catch (e) { toast({ title: `${operationName} failed`, description: e instanceof Error ? e.message : 'An unexpected error occurred', variant: 'error', }); } }, [toast] ); const handlePull = useCallback(() => { handleGitOperation(() => pull(), 'Pull'); }, [handleGitOperation, pull]); const handlePush = useCallback( (force = false) => { handleGitOperation( () => push({ force }), force ? 'Force Push' : 'Push' ); }, [handleGitOperation, push] ); const handleRebase = useCallback(() => { const targetBranch = baseBranch?.replace(/^origin\//, '') || 'main'; handleGitOperation(() => rebase(targetBranch), 'Rebase'); }, [handleGitOperation, rebase, baseBranch]); const handleMerge = useCallback(() => { const targetBranch = baseBranch?.replace(/^origin\//, '') || 'main'; handleGitOperation(() => merge(targetBranch), 'Merge'); }, [handleGitOperation, merge, baseBranch]); const handleOpenCommitDialog = useCallback(() => { setCommitMessage(''); setStageAll(true); setCommitDialogOpen(true); }, []); const handleCommit = useCallback(async () => { if (!commitMessage.trim()) return; setCommitDialogOpen(false); await handleGitOperation( () => commit(commitMessage.trim(), stageAll), 'Commit' ); setCommitMessage(''); }, [commitMessage, stageAll, handleGitOperation, commit]); const handleOpenPRDialog = useCallback(() => { setPrTitle(taskTitle || ''); setPrBody(''); setPrDialogOpen(true); }, [taskTitle]); const handleCreatePR = useCallback(async () => { if (!prTitle.trim()) return; setPrDialogOpen(false); try { const result = await createPR( prTitle.trim(), prBody.trim(), baseBranch, prDraft ); if (result.success) { toast({ title: prDraft ? 'Draft PR created' : 'PR created', description: result.pr_url || 'PR created successfully', variant: 'success', }); if (result.pr_url) window.open(result.pr_url, '_blank'); } else { toast({ title: 'Create PR failed', description: result.error || 'An error occurred', variant: 'error', }); } } catch (e) { toast({ title: 'Create PR failed', description: e instanceof Error ? e.message : 'An error occurred', variant: 'error', }); } setPrTitle(''); setPrBody(''); }, [prTitle, prBody, baseBranch, prDraft, createPR, toast]); // --- dynamic primary action --- const primaryAction = uncommittedFileCount > 0 ? ('commit' as const) : aheadCount > 0 ? ('pr' as const) : behindCount > 0 ? ('rebase' as const) : ('commit' as const); const primaryButtonConfig = { commit: { icon: , label: 'Commit', badge: uncommittedFileCount > 0 ? uncommittedFileCount : null, tooltip: uncommittedFileCount > 0 ? `Commit ${uncommittedFileCount} changed file${uncommittedFileCount !== 1 ? 's' : ''}` : 'No changes to commit', onClick: handleOpenCommitDialog, }, pr: { icon: , label: 'Create PR', badge: aheadCount > 0 ? aheadCount : null, tooltip: `Create PR (${aheadCount} commit${aheadCount !== 1 ? 's' : ''} ahead)`, onClick: handleOpenPRDialog, }, rebase: { icon: , label: 'Rebase', badge: behindCount > 0 ? behindCount : null, tooltip: `Rebase onto ${baseBranch || 'origin/main'} (${behindCount} behind)`, onClick: handleRebase, }, }[primaryAction]; return ( <> {/* Split button */}
{primaryButtonConfig.tooltip} Create PR Pull {hasMatchingUpstream && (gitStatus?.behind ?? 0) > 0 && ( ↓{gitStatus?.behind} )} Push {hasMatchingUpstream && (gitStatus?.ahead ?? 0) > 0 && ( ↑{gitStatus?.ahead} )} handlePush(false)} disabled={isGitLoading || !sessionId} > Push handlePush(true)} disabled={isGitLoading || !sessionId} > Force Push Rebase onto {baseBranch || 'origin/main'} Merge from {baseBranch || 'origin/main'}
{/* Commit Dialog */} Commit Changes
{uncommittedFileCount > 0 ? ( {uncommittedFileCount} {' '} file{uncommittedFileCount !== 1 ? 's' : ''} changed {(uncommittedAdditions > 0 || uncommittedDeletions > 0) && ( ( +{uncommittedAdditions} {' / '} -{uncommittedDeletions} ) )} ) : ( No changes to commit )}