From a6405632b885f58c97efb637a609aeced0f6cf76 Mon Sep 17 00:00:00 2001 From: aviram fireberger Date: Sat, 7 Feb 2026 18:33:35 +1100 Subject: [PATCH 1/2] feat(git): add switch branch functionality with error handling --- src/main/main.ts | 39 ++++ src/preload/preload.ts | 110 ++++++++--- src/renderer/components/GitBranchWidget.tsx | 207 +++++++++++++++++--- 3 files changed, 301 insertions(+), 55 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 03154b5..77c7de5 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -3309,6 +3309,45 @@ 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' }; + } + + // Try different methods to switch to the branch + // Method 1: Try git switch (for local branches) + try { + await execAsync(`git switch "${branch.replace(/"/g, '\\"')}"`, { cwd: data.cwd }); + return { success: true }; + } catch (switchError) { + // Method 2: Try git checkout (handles more cases) + try { + await execAsync(`git checkout "${branch.replace(/"/g, '\\"')}"`, { cwd: data.cwd }); + return { success: true }; + } catch (checkoutError) { + // Method 3: Branch might not exist locally, try to 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) { + // All methods failed, return the last error + console.error('Git switch branch - all methods failed:', trackingError); + return { success: false, error: String(trackingError) }; + } + } + } + } 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 3cc9fc1..77bccd5 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -551,6 +551,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, @@ -601,71 +607,113 @@ const electronAPI = { return ipcRenderer.invoke('git:getWorkingStatus', cwd); }, }, - + // Whisper.cpp speech recognition (native) voice: { loadModel: (): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('voice:loadModel') + return ipcRenderer.invoke('voice:loadModel'); }, loadTinyModel: (): Promise<{ success: boolean; path?: string; error?: string }> => { - return ipcRenderer.invoke('voice:loadTinyModel') + return ipcRenderer.invoke('voice:loadTinyModel'); }, - getState: (): Promise<{ isModelLoaded: boolean; isRecording: boolean; error: string | null }> => { - return ipcRenderer.invoke('voice:getState') + getState: (): Promise<{ + isModelLoaded: boolean; + isRecording: boolean; + error: string | null; + }> => { + return ipcRenderer.invoke('voice:getState'); }, startRecording: (): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('voice:startRecording') + return ipcRenderer.invoke('voice:startRecording'); }, - processAudio: (audioData: Uint8Array): Promise<{ success: boolean; isSilence?: boolean; text?: string; partial?: string; error?: string }> => { - return ipcRenderer.invoke('voice:processAudio', audioData) + processAudio: ( + audioData: Uint8Array + ): Promise<{ + success: boolean; + isSilence?: boolean; + text?: string; + partial?: string; + error?: string; + }> => { + return ipcRenderer.invoke('voice:processAudio', audioData); }, - processAndTranscribe: (audioData: Uint8Array, mimeType: string): Promise<{ success: boolean; text?: string; error?: string }> => { - return ipcRenderer.invoke('voice:processAndTranscribe', audioData, mimeType) + processAndTranscribe: ( + audioData: Uint8Array, + mimeType: string + ): Promise<{ success: boolean; text?: string; error?: string }> => { + return ipcRenderer.invoke('voice:processAndTranscribe', audioData, mimeType); }, - detectWakeWord: (audioData: Uint8Array, mimeType: string): Promise<{ success: boolean; text?: string; wakeWordDetected?: boolean; stopWordDetected?: boolean; abortWordDetected?: boolean; error?: string }> => { - return ipcRenderer.invoke('voice:detectWakeWord', audioData, mimeType) + detectWakeWord: ( + audioData: Uint8Array, + mimeType: string + ): Promise<{ + success: boolean; + text?: string; + wakeWordDetected?: boolean; + stopWordDetected?: boolean; + abortWordDetected?: boolean; + error?: string; + }> => { + return ipcRenderer.invoke('voice:detectWakeWord', audioData, mimeType); }, stopRecording: (): Promise<{ success: boolean; text?: string; error?: string }> => { - return ipcRenderer.invoke('voice:stopRecording') + return ipcRenderer.invoke('voice:stopRecording'); }, dispose: (): Promise<{ success: boolean }> => { - return ipcRenderer.invoke('voice:dispose') + return ipcRenderer.invoke('voice:dispose'); }, onResult: (callback: (data: { text: string }) => void): (() => void) => { - const handler = (_event: Electron.IpcRendererEvent, data: { text: string }) => callback(data) - ipcRenderer.on('voice:result', handler) - return () => ipcRenderer.removeListener('voice:result', handler) + const handler = (_event: Electron.IpcRendererEvent, data: { text: string }) => callback(data); + ipcRenderer.on('voice:result', handler); + return () => ipcRenderer.removeListener('voice:result', handler); }, onPartialResult: (callback: (data: { partial: string }) => void): (() => void) => { - const handler = (_event: Electron.IpcRendererEvent, data: { partial: string }) => callback(data) - ipcRenderer.on('voice:partialResult', handler) - return () => ipcRenderer.removeListener('voice:partialResult', handler) + const handler = (_event: Electron.IpcRendererEvent, data: { partial: string }) => + callback(data); + ipcRenderer.on('voice:partialResult', handler); + return () => ipcRenderer.removeListener('voice:partialResult', handler); }, }, - + // Whisper model management (for native whisper.cpp STT) voiceServer: { // Whisper model management - checkModel: (): Promise<{ exists: boolean; path?: string; size?: number; binaryExists?: boolean; binaryPath?: string }> => { - return ipcRenderer.invoke('voiceServer:checkModel') + checkModel: (): Promise<{ + exists: boolean; + path?: string; + size?: number; + binaryExists?: boolean; + binaryPath?: string; + }> => { + return ipcRenderer.invoke('voiceServer:checkModel'); }, downloadModel: (): Promise<{ success: boolean; path?: string; error?: string }> => { - return ipcRenderer.invoke('voiceServer:downloadModel') + return ipcRenderer.invoke('voiceServer:downloadModel'); }, - onDownloadProgress: (callback: (data: { progress: number; downloaded: number; total: number; status: string }) => void): (() => void) => { - const handler = (_event: Electron.IpcRendererEvent, data: { progress: number; downloaded: number; total: number; status: string }) => callback(data) - ipcRenderer.on('voiceServer:downloadProgress', handler) - return () => ipcRenderer.removeListener('voiceServer:downloadProgress', handler) + onDownloadProgress: ( + callback: (data: { + progress: number; + downloaded: number; + total: number; + status: string; + }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + data: { progress: number; downloaded: number; total: number; status: string } + ) => callback(data); + ipcRenderer.on('voiceServer:downloadProgress', handler); + return () => ipcRenderer.removeListener('voiceServer:downloadProgress', handler); }, // Tiny model for wake word detection checkTinyModel: (): Promise<{ exists: boolean; path?: string }> => { - return ipcRenderer.invoke('voiceServer:checkTinyModel') + return ipcRenderer.invoke('voiceServer:checkTinyModel'); }, downloadTinyModel: (): Promise<{ success: boolean; path?: string; error?: string }> => { - return ipcRenderer.invoke('voiceServer:downloadTinyModel') + return ipcRenderer.invoke('voiceServer:downloadTinyModel'); }, }, - + // Settings for target branch persistence settings: { getTargetBranch: ( diff --git a/src/renderer/components/GitBranchWidget.tsx b/src/renderer/components/GitBranchWidget.tsx index dff359a..c6b69e0 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,77 @@ 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); + } + } catch (err) { + console.error('Failed to switch branch:', err); + } finally { + setIsSwitching(false); + setIsDropdownOpen(false); + setBranchFilter(''); + } + }; + + const filteredBranches = branches.filter((b) => + b.toLowerCase().includes(branchFilter.toLowerCase()) + ); + if (isLoading) { return (
@@ -84,31 +167,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) => ( + + )) + )} +
+
)}
); From f52993cc46e48c361210b4aad0740865d64a2af0 Mon Sep 17 00:00:00 2001 From: aviram fireberger Date: Sat, 7 Feb 2026 18:49:41 +1100 Subject: [PATCH 2/2] feat(git): enhance branch switching with error handling and user feedback --- src/main/main.ts | 53 ++++++++++++++------- src/renderer/components/GitBranchWidget.tsx | 10 ++++ 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 77c7de5..3303bcf 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -3317,28 +3317,47 @@ ipcMain.handle('git:switchBranch', async (_event, data: { cwd: string; branchNam return { success: false, error: 'Branch name is required' }; } - // Try different methods to switch to the branch - // Method 1: Try git switch (for local branches) + // Check if branch exists locally + let branchExistsLocally = false; try { - await execAsync(`git switch "${branch.replace(/"/g, '\\"')}"`, { cwd: data.cwd }); - return { success: true }; - } catch (switchError) { - // Method 2: Try git checkout (handles more cases) + 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 checkout "${branch.replace(/"/g, '\\"')}"`, { cwd: data.cwd }); + await execAsync(`git switch "${branch.replace(/"/g, '\\"')}"`, { cwd: data.cwd }); return { success: true }; - } catch (checkoutError) { - // Method 3: Branch might not exist locally, try to create from remote tracking + } catch (switchError) { + // git switch might not be available, try checkout try { - await execAsync( - `git checkout -b "${branch.replace(/"/g, '\\"')}" "origin/${branch.replace(/"/g, '\\"')}"`, - { cwd: data.cwd } - ); + await execAsync(`git checkout "${branch.replace(/"/g, '\\"')}"`, { cwd: data.cwd }); return { success: true }; - } catch (trackingError) { - // All methods failed, return the last error - console.error('Git switch branch - all methods failed:', trackingError); - return { success: false, error: String(trackingError) }; + } 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) }; } } } diff --git a/src/renderer/components/GitBranchWidget.tsx b/src/renderer/components/GitBranchWidget.tsx index c6b69e0..7a66b4a 100644 --- a/src/renderer/components/GitBranchWidget.tsx +++ b/src/renderer/components/GitBranchWidget.tsx @@ -124,9 +124,19 @@ export const GitBranchWidget: React.FC = ({ 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);