diff --git a/components/modals/RepoFormModal.tsx b/components/modals/RepoFormModal.tsx index 415652f..7f2ae82 100644 --- a/components/modals/RepoFormModal.tsx +++ b/components/modals/RepoFormModal.tsx @@ -1870,6 +1870,7 @@ const CommitListItem: React.FC = ({ commit, highlight }) => }; const RepoEditView: React.FC = ({ onSave, onCancel, repository, onRefreshState, setToast, confirmAction, defaultCategoryId, onOpenWeblink, detectedExecutables }) => { + const logger = useLogger(); const [formData, setFormData] = useState>(() => repository || NEW_REPO_TEMPLATE); const repoIdForSuggestions = useMemo(() => { @@ -2386,25 +2387,77 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor }; const handleCreateBranch = async () => { - if (!repository || !newBranchName.trim()) return; + const branchName = newBranchName.trim(); + const repoId = repository?.id ?? null; + + logger.info('Create branch requested', { + repoId, + repoPath: repository?.localPath, + branchName, + }); + + if (!repository || !branchName) { + logger.warn('Create branch aborted: missing repository context or branch name', { + repoId, + hasRepository: Boolean(repository), + branchName, + }); + return; + } if (!isGitRepo) { + logger.warn('Create branch blocked: repository is not Git', { + repoId: repository.id, + repoPath: repository.localPath, + branchName, + vcs: repository.vcs, + }); setToast({ message: 'Branch creation is only supported for Git repositories.', type: 'info' }); return; } - const result = await window.electronAPI?.createBranch(repository.localPath, newBranchName.trim()); + const result = await window.electronAPI?.createBranch(repository.localPath, branchName); if (result?.success) { - setToast({ message: `Branch '${newBranchName.trim()}' created`, type: 'success' }); + logger.success('Branch created', { + repoId: repository.id, + repoPath: repository.localPath, + branchName, + }); + setToast({ message: `Branch '${branchName}' created`, type: 'success' }); setNewBranchName(''); fetchBranches(); onRefreshState(repository.id); } else { - setToast({ message: `Error: ${result?.error || 'Electron API not available.'}`, type: 'error' }); + const errorMessage = result?.error || 'Electron API not available.'; + logger.error('Branch creation failed', { + repoId: repository.id, + repoPath: repository.localPath, + branchName, + error: errorMessage, + }); + setToast({ message: `Error: ${errorMessage}`, type: 'error' }); } }; const handleDeleteBranch = async (branchIdentifier: string, scope: 'local' | 'remote') => { - if (!repository) return; + const baseMetadata = { + repoId: repository?.id ?? null, + repoPath: repository?.localPath, + branchIdentifier, + scope, + }; + + logger.info('Delete branch requested', baseMetadata); + + if (!repository) { + logger.warn('Delete branch aborted: missing repository context', baseMetadata); + return; + } if (!isGitRepo) { + logger.warn('Delete branch blocked: repository is not Git', { + ...baseMetadata, + repoId: repository.id, + repoPath: repository.localPath, + vcs: repository.vcs, + }); setToast({ message: 'Branch deletion is only supported for Git repositories.', type: 'info' }); return; } @@ -2413,11 +2466,26 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor const branchLabel = formatBranchSelectionLabel(branchIdentifier, scope); if (scope === 'local' && branchInfo?.current && branchIdentifier === branchInfo.current) { + logger.warn('Delete branch aborted: attempted to delete current branch', { + repoId: repository.id, + repoPath: repository.localPath, + branchIdentifier, + branchLabel, + scope, + currentBranch: branchInfo.current, + }); setToast({ message: 'Cannot delete the currently checked out branch.', type: 'info' }); return; } if (isProtectedBranch(branchLabel, scope)) { + logger.warn('Delete branch aborted: branch is protected', { + repoId: repository.id, + repoPath: repository.localPath, + branchIdentifier, + branchLabel, + scope, + }); setToast({ message: `Branch '${branchLabel}' is protected and cannot be deleted.`, type: 'info' }); return; } @@ -2430,6 +2498,18 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor onConfirm: async () => { const branchArgument = isRemote ? (remoteDetails?.branchName ?? branchIdentifier) : branchIdentifier; const remoteName = remoteDetails?.remoteName; + const actionMetadata = { + repoId: repository.id, + repoPath: repository.localPath, + branchIdentifier, + branchLabel, + scope, + branchArgument, + remoteName, + }; + + logger.info('Deleting branch (confirmed)', actionMetadata); + const result = await window.electronAPI?.deleteBranch( repository.localPath, branchArgument, @@ -2437,22 +2517,50 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor remoteName ); if (result?.success) { + logger.success('Branch deleted', actionMetadata); setToast({ message: `Branch '${branchLabel}' deleted`, type: 'success' }); fetchBranches(); onRefreshState(repository.id); } else { - setToast({ message: `Error: ${result?.error || 'Electron API not available.'}`, type: 'error' }); + const errorMessage = result?.error || 'Electron API not available.'; + logger.error('Branch deletion failed', { + ...actionMetadata, + error: errorMessage, + }); + setToast({ message: `Error: ${errorMessage}`, type: 'error' }); } } }); }; const handleBulkDeleteSelectedBranches = useCallback(() => { + const repoId = repository?.id ?? null; + logger.info('Bulk branch delete requested', { + repoId, + repoPath: repository?.localPath, + selectionCount: selectedBranches.length, + selections: selectedBranches.map(selection => ({ + name: selection.name, + scope: selection.scope, + })), + }); + if (!repository || selectedBranches.length === 0) { + logger.warn('Bulk branch delete aborted: missing repository context or selections', { + repoId, + hasRepository: Boolean(repository), + selectionCount: selectedBranches.length, + }); return; } if (!isGitRepo) { + logger.warn('Bulk branch delete blocked: repository is not Git', { + repoId: repository.id, + repoPath: repository.localPath, + vcs: repository.vcs, + selectionCount: selectedBranches.length, + }); setToast({ message: 'Bulk branch deletion is only supported for Git repositories.', type: 'info' }); return; } @@ -2474,6 +2582,12 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor }); if (deletable.length === 0) { + logger.warn('Bulk branch delete aborted: no deletable branches', { + repoId: repository.id, + repoPath: repository.localPath, + skippedProtectedCount, + skippedCurrentCount, + }); const messages: string[] = []; if (skippedProtectedCount) { messages.push(`Cannot delete protected branch${skippedProtectedCount > 1 ? 'es' : ''}.`); @@ -2496,6 +2610,13 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor icon: , onConfirm: async () => { setIsDeletingBranches(true); + logger.info('Bulk branch delete confirmed', { + repoId: repository.id, + repoPath: repository.localPath, + deletableCount: deletable.length, + skippedProtectedCount, + skippedCurrentCount, + }); try { const results: Array<{ selection: { name: string; scope: 'local' | 'remote' }; success: boolean; error?: string }> = []; for (const selection of deletable) { @@ -2505,6 +2626,15 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor ? (remoteDetails?.branchName ?? selection.name) : selection.name; const remoteName = remoteDetails?.remoteName; + const perBranchMetadata = { + repoId: repository.id, + repoPath: repository.localPath, + branchName: selection.name, + scope: selection.scope, + branchArgument, + remoteName, + }; + logger.info('Deleting branch (bulk)', perBranchMetadata); try { const result = await window.electronAPI?.deleteBranch( repository.localPath, @@ -2513,12 +2643,23 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor remoteName ); if (result?.success) { + logger.success('Branch deleted (bulk)', perBranchMetadata); results.push({ selection, success: true }); } else { - results.push({ selection, success: false, error: result?.error || 'Electron API not available.' }); + const errorMessage = result?.error || 'Electron API not available.'; + logger.error('Branch deletion failed (bulk)', { + ...perBranchMetadata, + error: errorMessage, + }); + results.push({ selection, success: false, error: errorMessage }); } } catch (error: any) { - results.push({ selection, success: false, error: error.message }); + const errorMessage = error?.message || String(error); + logger.error('Branch deletion encountered exception (bulk)', { + ...perBranchMetadata, + error: errorMessage, + }); + results.push({ selection, success: false, error: errorMessage }); } } @@ -2553,11 +2694,33 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor messages.push(`Failed to delete ${failed.length} branch${failed.length > 1 ? 'es' : ''}: ${failureList}.`); } + const summaryMetadata = { + repoId: repository.id, + repoPath: repository.localPath, + succeededCount: succeeded.length, + failedCount: failed.length, + skippedProtectedCount, + skippedCurrentCount, + }; + if (messages.length) { const toastType: 'success' | 'info' | 'error' = failed.length ? (succeeded.length ? 'info' : 'error') : 'success'; + if (toastType === 'success') { + logger.success('Bulk branch delete completed', summaryMetadata); + } else if (toastType === 'info') { + logger.warn('Bulk branch delete completed with partial failures', summaryMetadata); + } else { + logger.error('Bulk branch delete failed', summaryMetadata); + } setToast({ message: messages.join(' '), type: toastType }); + } else { + if (failed.length) { + logger.error('Bulk branch delete finished with failures', summaryMetadata); + } else { + logger.success('Bulk branch delete finished without toast', summaryMetadata); + } } const successfulKeys = new Set( @@ -2574,86 +2737,198 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor } } }); - }, [repository, selectedBranches, branchInfo?.current, confirmAction, setToast, fetchBranches, onRefreshState, isGitRepo]); + }, [repository, selectedBranches, branchInfo?.current, confirmAction, setToast, fetchBranches, onRefreshState, isGitRepo, logger]); const handlePruneRemoteBranches = useCallback(async () => { + const repoId = repository?.id ?? null; + logger.info('Prune remote branches requested', { + repoId, + repoPath: repository?.localPath, + }); if (!repository) { + logger.warn('Remote branch pruning aborted: missing repository context', { + repoId, + }); return; } if (!isGitRepo) { + logger.warn('Remote branch pruning blocked: repository is not Git', { + repoId: repository.id, + repoPath: repository.localPath, + vcs: repository.vcs, + }); setToast({ message: 'Remote pruning is only supported for Git repositories.', type: 'info' }); return; } setIsPruningRemoteBranches(true); try { + logger.info('Pruning remote branches', { + repoId: repository.id, + repoPath: repository.localPath, + }); const result = await window.electronAPI?.pruneRemoteBranches(repository.localPath); if (result?.success) { + logger.success('Remote branches pruned', { + repoId: repository.id, + repoPath: repository.localPath, + message: result?.message, + }); setToast({ message: result?.message ?? 'Pruned stale remote branches.', type: 'success' }); await fetchBranches(); await onRefreshState(repository.id); } else { - setToast({ message: `Error: ${result?.error || 'Electron API not available.'}`, type: 'error' }); + const errorMessage = result?.error || 'Electron API not available.'; + logger.error('Remote branch pruning failed', { + repoId: repository.id, + repoPath: repository.localPath, + error: errorMessage, + }); + setToast({ message: `Error: ${errorMessage}`, type: 'error' }); } } catch (error: any) { - setToast({ message: `Error: ${error?.message || 'Failed to prune remote branches.'}`, type: 'error' }); + const errorMessage = error?.message || 'Failed to prune remote branches.'; + logger.error('Remote branch pruning threw exception', { + repoId: repository.id, + repoPath: repository.localPath, + error: errorMessage, + }); + setToast({ message: `Error: ${errorMessage}`, type: 'error' }); } finally { setIsPruningRemoteBranches(false); } - }, [repository, isGitRepo, setToast, fetchBranches, onRefreshState]); + }, [repository, isGitRepo, setToast, fetchBranches, onRefreshState, logger]); const handleCleanupLocalBranches = useCallback(async () => { + const repoId = repository?.id ?? null; + logger.info('Cleanup local branches requested', { + repoId, + repoPath: repository?.localPath, + }); if (!repository) { + logger.warn('Local branch cleanup aborted: missing repository context', { + repoId, + }); return; } if (!isGitRepo) { + logger.warn('Local branch cleanup blocked: repository is not Git', { + repoId: repository.id, + repoPath: repository.localPath, + vcs: repository.vcs, + }); setToast({ message: 'Local branch cleanup is only supported for Git repositories.', type: 'info' }); return; } setIsCleaningLocalBranches(true); try { + logger.info('Cleaning up local branches', { + repoId: repository.id, + repoPath: repository.localPath, + }); const result = await window.electronAPI?.cleanupLocalBranches(repository.localPath); if (result?.success) { + logger.success('Local branches cleaned up', { + repoId: repository.id, + repoPath: repository.localPath, + message: result?.message, + }); setToast({ message: result?.message ?? 'Removed merged or stale local branches.', type: 'success' }); await fetchBranches(); await onRefreshState(repository.id); } else { - setToast({ message: `Error: ${result?.error || 'Electron API not available.'}`, type: 'error' }); + const errorMessage = result?.error || 'Electron API not available.'; + logger.error('Local branch cleanup failed', { + repoId: repository.id, + repoPath: repository.localPath, + error: errorMessage, + }); + setToast({ message: `Error: ${errorMessage}`, type: 'error' }); } } catch (error: any) { - setToast({ message: `Error: ${error?.message || 'Failed to clean up local branches.'}`, type: 'error' }); + const errorMessage = error?.message || 'Failed to clean up local branches.'; + logger.error('Local branch cleanup threw exception', { + repoId: repository.id, + repoPath: repository.localPath, + error: errorMessage, + }); + setToast({ message: `Error: ${errorMessage}`, type: 'error' }); } finally { setIsCleaningLocalBranches(false); } - }, [repository, isGitRepo, setToast, fetchBranches, onRefreshState]); + }, [repository, isGitRepo, setToast, fetchBranches, onRefreshState, logger]); const handleMergeBranch = async () => { - if (!repository || !branchToMerge) return; + const repoId = repository?.id ?? null; + logger.info('Merge branch requested', { + repoId, + repoPath: repository?.localPath, + sourceBranch: branchToMerge, + targetBranch: branchInfo?.current, + }); + if (!repository || !branchToMerge) { + logger.warn('Branch merge aborted: missing repository context or merge target', { + repoId, + hasRepository: Boolean(repository), + branchToMerge, + currentBranch: branchInfo?.current, + }); + return; + } if (!isGitRepo) { + logger.warn('Branch merge blocked: repository is not Git', { + repoId: repository.id, + repoPath: repository.localPath, + branchToMerge, + currentBranch: branchInfo?.current, + vcs: repository.vcs, + }); setToast({ message: 'Branch merging is only supported for Git repositories.', type: 'info' }); return; } const currentBranch = branchInfo?.current; if (!currentBranch || branchToMerge === currentBranch) { + logger.warn('Branch merge aborted: invalid target branch', { + repoId: repository.id, + repoPath: repository.localPath, + branchToMerge, + currentBranch, + }); setToast({ message: 'Cannot merge a branch into itself.', type: 'info' }); return; } + const mergeSource = branchToMerge; + const mergeTarget = currentBranch; + confirmAction({ title: "Merge Branch", - message: `Are you sure you want to merge '${branchToMerge}' into '${currentBranch}'?`, + message: `Are you sure you want to merge '${mergeSource}' into '${mergeTarget}'?`, confirmText: "Merge", icon: , confirmButtonClass: 'bg-green-600 hover:bg-green-700 focus:ring-green-500', onConfirm: async () => { - const result = await window.electronAPI?.mergeBranch(repository.localPath, branchToMerge); + const actionMetadata = { + repoId: repository.id, + repoPath: repository.localPath, + sourceBranch: mergeSource, + targetBranch: mergeTarget, + }; + logger.info('Merging branch (confirmed)', actionMetadata); + const result = await window.electronAPI?.mergeBranch(repository.localPath, mergeSource); if (result?.success) { - setToast({ message: `Successfully merged '${branchToMerge}' into '${currentBranch}'`, type: 'success' }); + logger.success('Branch merged', actionMetadata); + setToast({ message: `Successfully merged '${mergeSource}' into '${mergeTarget}'`, type: 'success' }); fetchBranches(); onRefreshState(repository.id); } else { - setToast({ message: `Merge failed: ${result?.error || 'Electron API not available.'}`, type: 'error' }); + const errorMessage = result?.error || 'Electron API not available.'; + logger.error('Branch merge failed', { + ...actionMetadata, + error: errorMessage, + }); + setToast({ message: `Merge failed: ${errorMessage}`, type: 'error' }); } } }); @@ -2707,7 +2982,26 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor }, []); const handleCheckoutBranch = useCallback(async () => { + const repoId = repository?.id ?? null; + logger.info('Checkout branch requested', { + repoId, + repoPath: repository?.localPath, + selectionCount: selectedBranches.length, + selections: selectedBranches.map(selection => ({ + name: selection.name, + scope: selection.scope, + })), + primarySelection: primarySelectedBranch + ? { name: primarySelectedBranch.name, scope: primarySelectedBranch.scope } + : null, + }); if (!repository || selectedBranches.length !== 1 || !primarySelectedBranch) { + logger.warn('Branch checkout aborted: invalid selection or missing repository', { + repoId, + hasRepository: Boolean(repository), + selectionCount: selectedBranches.length, + hasPrimary: Boolean(primarySelectedBranch), + }); return; } @@ -2716,6 +3010,12 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor const checkoutLabel = primarySelectedBranch.name; if (currentBranch && normalizedSelectedBranchName && normalizedSelectedBranchName === currentBranch) { + logger.warn('Branch checkout aborted: branch already checked out', { + repoId: repository.id, + repoPath: repository.localPath, + currentBranch, + requestedBranch: normalizedSelectedBranchName, + }); setToast({ message: `Already on '${currentBranch}'.`, type: 'info' }); return; } @@ -2724,8 +3024,21 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor const checkoutTarget = primarySelectedBranch.name; try { + logger.info('Checking out branch', { + repoId: repository.id, + repoPath: repository.localPath, + branch: checkoutTarget, + scope: primarySelectedBranch.scope, + currentBranch, + }); const result = await window.electronAPI?.checkoutBranch({ repoPath: repository.localPath, branch: checkoutTarget, vcs: repository.vcs }); if (result?.success) { + logger.success('Branch checked out', { + repoId: repository.id, + repoPath: repository.localPath, + branch: checkoutTarget, + scope: primarySelectedBranch.scope, + }); setToast({ message: `Checked out '${checkoutLabel}'.`, type: 'success' }); setBranchToMerge(''); setSelectedBranches([]); @@ -2735,14 +3048,30 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor requestAnimationFrame(() => focusBranchItem('local', focusTarget)); } } else { - setToast({ message: `Checkout failed: ${result?.error || 'Electron API not available.'}`, type: 'error' }); + const errorMessage = result?.error || 'Electron API not available.'; + logger.error('Branch checkout failed', { + repoId: repository.id, + repoPath: repository.localPath, + branch: checkoutTarget, + scope: primarySelectedBranch.scope, + error: errorMessage, + }); + setToast({ message: `Checkout failed: ${errorMessage}`, type: 'error' }); } } catch (error: any) { - setToast({ message: `Checkout failed: ${error.message}`, type: 'error' }); + const errorMessage = error?.message || String(error); + logger.error('Branch checkout threw exception', { + repoId: repository.id, + repoPath: repository.localPath, + branch: checkoutTarget, + scope: primarySelectedBranch.scope, + error: errorMessage, + }); + setToast({ message: `Checkout failed: ${errorMessage}`, type: 'error' }); } finally { setIsCheckoutLoading(false); } - }, [repository, selectedBranches.length, primarySelectedBranch, branchInfo?.current, normalizedSelectedBranchName, setToast, fetchBranches, onRefreshState, focusBranchItem]); + }, [repository, selectedBranches, primarySelectedBranch, branchInfo?.current, normalizedSelectedBranchName, setToast, fetchBranches, onRefreshState, focusBranchItem, logger]); const handleBranchKeyDown = useCallback((event: React.KeyboardEvent, branchName: string, scope: 'local' | 'remote') => { if (event.key === 'Enter' || event.key === ' ') { diff --git a/hooks/useLogger.ts b/hooks/useLogger.ts index 11b37d6..e7fbe21 100644 --- a/hooks/useLogger.ts +++ b/hooks/useLogger.ts @@ -10,6 +10,12 @@ export const useLogger = () => { return useMemo(() => ({ debug: (message: string, data?: any) => addLog(DebugLogLevel.DEBUG, message, data), info: (message: string, data?: any) => addLog(DebugLogLevel.INFO, message, data), + success: (message: string, data?: any) => + addLog( + DebugLogLevel.INFO, + message, + data ? { status: 'success', ...data } : { status: 'success' } + ), warn: (message: string, data?: any) => addLog(DebugLogLevel.WARN, message, data), error: (message: string, data?: any) => addLog(DebugLogLevel.ERROR, message, data), }), [addLog]);