import { useState, useEffect, useRef, useCallback } from 'react'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { BranchAutocomplete } from '@/components/ui/branch-autocomplete'; import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react'; import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; interface WorktreeInfo { path: string; branch: string; isMain: boolean; hasChanges?: boolean; changedFilesCount?: number; } interface CreatePRDialogProps { open: boolean; onOpenChange: (open: boolean) => void; worktree: WorktreeInfo | null; projectPath: string | null; onCreated: (prUrl?: string) => void; /** Default base branch for the PR (defaults to 'main' if not provided) */ defaultBaseBranch?: string; } export function CreatePRDialog({ open, onOpenChange, worktree, projectPath, onCreated, defaultBaseBranch = 'main', }: CreatePRDialogProps) { const [title, setTitle] = useState(''); const [body, setBody] = useState(''); const [baseBranch, setBaseBranch] = useState(defaultBaseBranch); const [commitMessage, setCommitMessage] = useState(''); const [isDraft, setIsDraft] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [prUrl, setPrUrl] = useState(null); const [browserUrl, setBrowserUrl] = useState(null); const [showBrowserFallback, setShowBrowserFallback] = useState(false); // Branch fetching state const [branches, setBranches] = useState([]); const [isLoadingBranches, setIsLoadingBranches] = useState(false); // Track whether an operation completed that warrants a refresh const operationCompletedRef = useRef(false); // Common state reset function to avoid duplication const resetState = useCallback(() => { setTitle(''); setBody(''); setCommitMessage(''); setBaseBranch(defaultBaseBranch); setIsDraft(false); setError(null); setPrUrl(null); setBrowserUrl(null); setShowBrowserFallback(false); operationCompletedRef.current = false; setBranches([]); }, [defaultBaseBranch]); // Fetch branches for autocomplete const fetchBranches = useCallback(async () => { if (!worktree?.path) return; setIsLoadingBranches(true); try { const api = getElectronAPI(); if (!api?.worktree?.listBranches) { return; } // Fetch both local and remote branches for PR base branch selection const result = await api.worktree.listBranches(worktree.path, true); if (result.success && result.result) { // Extract branch names, filtering out the current worktree branch const branchNames = result.result.branches .map((b) => b.name) .filter((name) => name !== worktree.branch); setBranches(branchNames); } } catch { // Silently fail - branches will default to main only } finally { setIsLoadingBranches(false); } }, [worktree?.path, worktree?.branch]); // Reset state when dialog opens or worktree changes useEffect(() => { // Reset all state on both open and close resetState(); if (open) { // Fetch fresh branches when dialog opens fetchBranches(); } }, [open, worktree?.path, resetState, fetchBranches]); const handleCreate = async () => { if (!worktree) return; setIsLoading(true); setError(null); try { const api = getElectronAPI(); if (!api?.worktree?.createPR) { setError('Worktree API not available'); return; } const result = await api.worktree.createPR(worktree.path, { projectPath: projectPath || undefined, commitMessage: commitMessage || undefined, prTitle: title || worktree.branch, prBody: body || `Changes from branch ${worktree.branch}`, baseBranch, draft: isDraft, }); if (result.success && result.result) { if (result.result.prCreated && result.result.prUrl) { setPrUrl(result.result.prUrl); // Mark operation as completed for refresh on close operationCompletedRef.current = true; // Show different message based on whether PR already existed if (result.result.prAlreadyExisted) { toast.success('Pull request found!', { description: `PR already exists for ${result.result.branch}`, action: { label: 'View PR', onClick: () => window.open(result.result!.prUrl!, '_blank', 'noopener,noreferrer'), }, }); } else { toast.success('Pull request created!', { description: `PR created from ${result.result.branch}`, action: { label: 'View PR', onClick: () => window.open(result.result!.prUrl!, '_blank', 'noopener,noreferrer'), }, }); } // Don't call onCreated() here - keep dialog open to show success message // onCreated() will be called when user closes the dialog } else { // Branch was pushed successfully const prError = result.result.prError; const hasBrowserUrl = !!result.result.browserUrl; // Check if we should show browser fallback if (!result.result.prCreated && hasBrowserUrl) { // If gh CLI is not available, show browser fallback UI if (prError === 'gh_cli_not_available' || !result.result.ghCliAvailable) { setBrowserUrl(result.result.browserUrl ?? null); setShowBrowserFallback(true); // Mark operation as completed - branch was pushed successfully operationCompletedRef.current = true; toast.success('Branch pushed', { description: result.result.committed ? `Commit ${result.result.commitHash} pushed to ${result.result.branch}` : `Branch ${result.result.branch} pushed`, }); // Don't call onCreated() here - we want to keep the dialog open to show the browser URL setIsLoading(false); return; // Don't close dialog, show browser fallback UI } // gh CLI is available but failed - show error with browser option if (prError) { // Parse common gh CLI errors for better messages let errorMessage = prError; if (prError.includes('No commits between')) { errorMessage = 'No new commits to create PR. Make sure your branch has changes compared to the base branch.'; } else if (prError.includes('already exists')) { errorMessage = 'A pull request already exists for this branch.'; } else if (prError.includes('not logged in') || prError.includes('auth')) { errorMessage = "GitHub CLI not authenticated. Run 'gh auth login' in terminal."; } // Show error but also provide browser option setBrowserUrl(result.result.browserUrl ?? null); setShowBrowserFallback(true); // Mark operation as completed - branch was pushed even though PR creation failed operationCompletedRef.current = true; toast.error('PR creation failed', { description: errorMessage, duration: 8000, }); // Don't call onCreated() here - we want to keep the dialog open to show the browser URL setIsLoading(false); return; } } // Show success toast for push toast.success('Branch pushed', { description: result.result.committed ? `Commit ${result.result.commitHash} pushed to ${result.result.branch}` : `Branch ${result.result.branch} pushed`, }); // No browser URL available, just close if (!result.result.prCreated) { if (!hasBrowserUrl) { toast.info('PR not created', { description: 'Could not determine repository URL. GitHub CLI (gh) may not be installed or authenticated.', duration: 8000, }); } } onCreated(); onOpenChange(false); } } else { setError(result.error || 'Failed to create pull request'); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create PR'); } finally { setIsLoading(false); } }; const handleClose = () => { // Only call onCreated() if an actual operation completed // This prevents unnecessary refreshes when user cancels if (operationCompletedRef.current) { // Pass the PR URL if one was created onCreated(prUrl || undefined); } onOpenChange(false); // State reset is handled by useEffect when open becomes false }; if (!worktree) return null; const shouldShowBrowserFallback = showBrowserFallback && browserUrl; return ( Create Pull Request Push changes and create a pull request from{' '} {worktree.branch} {prUrl ? (

Pull Request Created!

Your PR is ready for review

) : shouldShowBrowserFallback ? (

Branch Pushed!

Your changes have been pushed to GitHub.
Click below to create a pull request in your browser.

{browserUrl}

Tip: Install the GitHub CLI (gh) to create PRs directly from the app

) : ( <>
{worktree.hasChanges && (
setCommitMessage(e.target.value)} className="font-mono text-sm" />

{worktree.changedFilesCount} uncommitted file(s) will be committed

)}
setTitle(e.target.value)} />