diff --git a/src/main/main.ts b/src/main/main.ts index 52ec5e4..58f8047 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -3465,6 +3465,64 @@ ipcMain.handle('git:checkoutBranch', async (_event, data: { cwd: string; branchN } }); +// Git operations - switch to an existing branch +ipcMain.handle('git:switchBranch', async (_event, data: { cwd: string; branchName: string }) => { + try { + const branch = (data.branchName || '').trim(); + if (!branch) { + return { success: false, error: 'Branch name is required' }; + } + + // Check if branch exists locally + let branchExistsLocally = false; + try { + const { stdout } = await execAsync(`git branch --list "${branch.replace(/"/g, '\\"')}"`, { + cwd: data.cwd, + }); + branchExistsLocally = stdout.trim().length > 0; + } catch { + // Ignore errors checking local branches + } + + if (branchExistsLocally) { + // Branch exists locally, just switch to it + try { + await execAsync(`git switch "${branch.replace(/"/g, '\\"')}"`, { cwd: data.cwd }); + return { success: true }; + } catch (switchError) { + // git switch might not be available, try checkout + try { + await execAsync(`git checkout "${branch.replace(/"/g, '\\"')}"`, { cwd: data.cwd }); + return { success: true }; + } catch (checkoutError) { + // Return the actual error (likely uncommitted changes) + return { success: false, error: String(checkoutError) }; + } + } + } else { + // Branch doesn't exist locally, create from remote tracking + try { + await execAsync( + `git checkout -b "${branch.replace(/"/g, '\\"')}" "origin/${branch.replace(/"/g, '\\"')}"`, + { cwd: data.cwd } + ); + return { success: true }; + } catch (trackingError) { + // Try just checking out - git might auto-track + try { + await execAsync(`git checkout "${branch.replace(/"/g, '\\"')}"`, { cwd: data.cwd }); + return { success: true }; + } catch (checkoutError) { + return { success: false, error: String(checkoutError) }; + } + } + } + } catch (error) { + console.error('Git switch branch failed:', error); + return { success: false, error: String(error) }; + } +}); + // Git operations - merge worktree branch to main/master ipcMain.handle( 'git:mergeToMain', diff --git a/src/preload/preload.ts b/src/preload/preload.ts index eb1c08d..a8ccb1d 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -582,6 +582,12 @@ const electronAPI = { ): Promise<{ success: boolean; error?: string }> => { return ipcRenderer.invoke('git:checkoutBranch', { cwd, branchName }); }, + switchBranch: ( + cwd: string, + branchName: string + ): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('git:switchBranch', { cwd, branchName }); + }, mergeToMain: ( cwd: string, deleteBranch: boolean, diff --git a/src/renderer/components/GitBranchWidget.tsx b/src/renderer/components/GitBranchWidget.tsx index dff359a..7a66b4a 100644 --- a/src/renderer/components/GitBranchWidget.tsx +++ b/src/renderer/components/GitBranchWidget.tsx @@ -1,15 +1,27 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; interface GitBranchWidgetProps { cwd?: string; refreshKey?: number; + onBranchChange?: () => void; } -export const GitBranchWidget: React.FC = ({ cwd, refreshKey }) => { +export const GitBranchWidget: React.FC = ({ + cwd, + refreshKey, + onBranchChange, +}) => { const [branch, setBranch] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [isWorktree, setIsWorktree] = useState(false); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [branches, setBranches] = useState([]); + const [isSwitching, setIsSwitching] = useState(false); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); + const [branchFilter, setBranchFilter] = useState(''); + const dropdownRef = useRef(null); + const filterInputRef = useRef(null); useEffect(() => { if (!cwd) { @@ -55,6 +67,87 @@ export const GitBranchWidget: React.FC = ({ cwd, refreshKe fetchBranch(); }, [cwd, refreshKey]); + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + setBranchFilter(''); + } + }; + + if (isDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + // Focus the filter input when dropdown opens + setTimeout(() => filterInputRef.current?.focus(), 0); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isDropdownOpen]); + + const handleBranchClick = async () => { + if (!cwd || isWorktree) return; + + setIsDropdownOpen(!isDropdownOpen); + setBranchFilter(''); + + if (!isDropdownOpen) { + // Fetch branches when opening dropdown + setIsLoadingBranches(true); + try { + const result = await window.electronAPI.git.listBranches(cwd); + if (result.success && result.branches) { + setBranches(result.branches); + } + } catch (err) { + console.error('Failed to list branches:', err); + } finally { + setIsLoadingBranches(false); + } + } + }; + + const handleSwitchBranch = async (targetBranch: string) => { + if (!cwd || targetBranch === branch) { + setIsDropdownOpen(false); + setBranchFilter(''); + return; + } + + setIsSwitching(true); + try { + const result = await window.electronAPI.git.switchBranch(cwd, targetBranch); + if (result.success) { + setBranch(targetBranch); + onBranchChange?.(); + } else { + console.error('Failed to switch branch:', result.error); + // Show user-friendly error message + let errorMsg = 'Failed to switch branch'; + if (result.error?.includes('uncommitted') || result.error?.includes('overwritten')) { + errorMsg = + 'Cannot switch: you have uncommitted changes. Please commit or stash them first.'; + } else if (result.error?.includes('not found') || result.error?.includes('did not match')) { + errorMsg = `Branch "${targetBranch}" not found`; + } + alert(errorMsg); + } + } catch (err) { + console.error('Failed to switch branch:', err); + alert('Failed to switch branch. See console for details.'); + } finally { + setIsSwitching(false); + setIsDropdownOpen(false); + setBranchFilter(''); + } + }; + + const filteredBranches = branches.filter((b) => + b.toLowerCase().includes(branchFilter.toLowerCase()) + ); + if (isLoading) { return (
@@ -84,31 +177,107 @@ export const GitBranchWidget: React.FC = ({ cwd, refreshKe } return ( -
- +
- - - - - - - {branch} - - {isWorktree && ( - - worktree + + + + + + + {isSwitching ? 'Switching...' : branch} + {isWorktree && ( + + worktree + + )} + {!isWorktree && ( + + + + )} +
+ + {isDropdownOpen && !isWorktree && ( +
+
+ setBranchFilter(e.target.value)} + className="w-full px-2 py-1 text-xs bg-copilot-bg border border-copilot-border rounded text-copilot-text placeholder-copilot-text-muted focus:outline-none focus:border-copilot-accent" + onKeyDown={(e) => { + if (e.key === 'Escape') { + setIsDropdownOpen(false); + setBranchFilter(''); + } else if (e.key === 'Enter' && filteredBranches.length === 1) { + handleSwitchBranch(filteredBranches[0]); + } + }} + /> +
+
+ {isLoadingBranches ? ( +
+ + + + Loading branches... +
+ ) : filteredBranches.length === 0 ? ( +
No branches found
+ ) : ( + filteredBranches.map((b) => ( + + )) + )} +
+
)}
);