import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useAppStore } from '../../store'; import type { GitInitProgress, GitHistoryEntry, GitRemoteStateDto } from '../../../main/shared/electronApi'; import './GitSidebar.css'; import '../Sidebar/Sidebar.css'; type GitSidebarStatusFile = { path: string; status: string }; const mergeStatusFilesIncremental = ( previous: GitSidebarStatusFile[], next: GitSidebarStatusFile[], ): GitSidebarStatusFile[] => { const previousByPath = new Map(previous.map((entry) => [entry.path, entry])); return next.map((entry) => { const existing = previousByPath.get(entry.path); if (!existing) { return entry; } if (existing.status === entry.status) { return existing; } return entry; }); }; export const GitSidebar: React.FC = () => { const { activeProject, openTab, tabs, closeTab } = useAppStore(); const [projectPath, setProjectPath] = useState(null); const [loading, setLoading] = useState(true); const [initializing, setInitializing] = useState(false); const [statusLoading, setStatusLoading] = useState(false); const [actionLoading, setActionLoading] = useState<'fetch' | 'pull' | 'push' | 'prune-lfs' | 'commit' | null>(null); const [error, setError] = useState(null); const [errorGuidance, setErrorGuidance] = useState([]); const [isRepo, setIsRepo] = useState(false); const [hasRemote, setHasRemote] = useState(false); const [currentBranch, setCurrentBranch] = useState(null); const [statusFiles, setStatusFiles] = useState>([]); const [commitMessage, setCommitMessage] = useState(''); const [historyLoading, setHistoryLoading] = useState(false); const [historyEntries, setHistoryEntries] = useState([]); const [remoteState, setRemoteState] = useState(null); const [remoteStateError, setRemoteStateError] = useState(null); const [initProgress, setInitProgress] = useState(null); const [initTranscript, setInitTranscript] = useState([]); const [isTranscriptExpanded, setIsTranscriptExpanded] = useState(false); const remoteUrlInputRef = useRef(null); const commitMessageInputRef = useRef(null); const statusRefreshInFlightRef = useRef(false); const remoteRefreshInFlightRef = useRef(false); const refreshRepoDetails = useCallback( async (targetProjectPath: string, options?: { background?: boolean }) => { if (statusRefreshInFlightRef.current) { return; } const background = options?.background ?? false; statusRefreshInFlightRef.current = true; if (!background) { setStatusLoading(true); setHistoryLoading(true); } try { const [status, history] = await Promise.all([ window.electronAPI.git.getStatus(targetProjectPath), window.electronAPI.git.getHistory(targetProjectPath, 20), ]); setStatusFiles((previous) => mergeStatusFilesIncremental(previous, status.files)); setHistoryEntries(history); } finally { statusRefreshInFlightRef.current = false; if (!background) { setStatusLoading(false); setHistoryLoading(false); } } }, [], ); const refreshRemoteState = useCallback( async (targetProjectPath: string, options?: { background?: boolean; fetchFirst?: boolean }) => { if (remoteRefreshInFlightRef.current) { return; } const background = options?.background ?? false; const fetchFirst = options?.fetchFirst ?? false; remoteRefreshInFlightRef.current = true; try { if (fetchFirst) { const fetchResult = await window.electronAPI.git.fetch(targetProjectPath); if (!fetchResult.success) { const message = fetchResult.error || 'Failed to fetch remote updates.'; setRemoteStateError(message); if (!background) { setError(message); } return; } } const nextRemoteState = await window.electronAPI.git.getRemoteState(targetProjectPath); setRemoteState(nextRemoteState); setRemoteStateError(null); } catch { const message = 'Unable to refresh remote tracking state.'; setRemoteStateError(message); if (!background) { setError(message); } } finally { remoteRefreshInFlightRef.current = false; } }, [], ); const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`; const getCommitDiffTabId = (commitHash: string): string => `git-diff:commit:${commitHash}`; const getActionProgressMessage = (action: 'fetch' | 'pull' | 'push' | 'prune-lfs' | 'commit'): string => { if (action === 'push') { return 'Pushing commits to remote... this can take a while for large uploads.'; } if (action === 'fetch') { return 'Fetching remote updates...'; } if (action === 'pull') { return 'Pulling latest changes...'; } if (action === 'prune-lfs') { return 'Pruning local Git LFS cache...'; } return 'Creating commit...'; }; const getHistoryStatusLabel = (status: GitHistoryEntry['syncStatus']): string => { if (status === 'local-only') { return 'Local only'; } if (status === 'remote-only') { return 'Remote only'; } return 'Synced'; }; const openDiffTab = useCallback( (filePath: string, isTransient: boolean) => { openTab({ type: 'git-diff', id: getDiffTabId(filePath), isTransient, }); }, [openTab], ); const openCommitDiffTab = useCallback( (commitHash: string, isTransient: boolean) => { openTab({ type: 'git-diff', id: getCommitDiffTabId(commitHash), isTransient, }); }, [openTab], ); const resolveProjectPath = useCallback(async (): Promise => { if (!activeProject) { return null; } if (activeProject.dataPath) { return activeProject.dataPath; } return window.electronAPI.app.getDefaultProjectPath(activeProject.id); }, [activeProject]); const loadRepoState = useCallback(async () => { setLoading(true); setError(null); setErrorGuidance([]); try { const availability = await window.electronAPI.git.checkAvailability(); if (!availability.gitFound) { setError('Git executable not found. Please install Git and restart the app.'); setIsRepo(false); return; } const resolvedProjectPath = await resolveProjectPath(); setProjectPath(resolvedProjectPath); if (!resolvedProjectPath) { setError('No active project selected.'); setIsRepo(false); return; } const repoState = await window.electronAPI.git.getRepoState(resolvedProjectPath); setIsRepo(repoState.isRepo); setHasRemote(repoState.hasRemote); setCurrentBranch(repoState.currentBranch || null); if (repoState.isRepo) { await refreshRepoDetails(resolvedProjectPath); if (repoState.hasRemote) { await refreshRemoteState(resolvedProjectPath); } else { setRemoteState(null); setRemoteStateError(null); } } else { setStatusFiles([]); setHistoryEntries([]); setRemoteState(null); setRemoteStateError(null); } } catch { setError('Unable to load repository status.'); setIsRepo(false); setHasRemote(false); setStatusFiles([]); setHistoryEntries([]); setRemoteState(null); setRemoteStateError(null); } finally { setLoading(false); } }, [refreshRemoteState, refreshRepoDetails, resolveProjectPath]); useEffect(() => { void loadRepoState(); }, [loadRepoState]); useEffect(() => { const unsubscribe = window.electronAPI.git.onInitProgress((progress) => { setInitProgress(progress); setInitTranscript((previous) => [...previous, progress].slice(-12)); if (progress.phase === 'failed') { setIsTranscriptExpanded(true); } }); return () => { unsubscribe(); }; }, []); useEffect(() => { if (!isRepo || !projectPath) { return; } const intervalId = globalThis.setInterval(() => { void refreshRepoDetails(projectPath, { background: true }); }, 2000); return () => { globalThis.clearInterval(intervalId); }; }, [isRepo, projectPath, refreshRepoDetails]); useEffect(() => { if (!isRepo || !hasRemote || !projectPath) { return; } const intervalId = globalThis.setInterval(() => { void refreshRemoteState(projectPath, { background: true, fetchFirst: true }); }, 30000); return () => { globalThis.clearInterval(intervalId); }; }, [hasRemote, isRepo, projectPath, refreshRemoteState]); const handleInitialize = async () => { if (!projectPath) { return; } setInitializing(true); setError(null); setInitTranscript([]); setInitProgress({ phase: 'initializing-repo', progress: 0, message: 'Preparing repository initialization...', }); try { const normalizedRemoteUrl = remoteUrlInputRef.current?.value.trim() || ''; const result = normalizedRemoteUrl ? await window.electronAPI.git.init(projectPath, normalizedRemoteUrl) : await window.electronAPI.git.init(projectPath); if (!result.success) { setError(result.error || 'Failed to initialize git repository.'); return; } await loadRepoState(); } catch { setError('Failed to initialize git repository.'); } finally { setInitializing(false); } }; const handleRepoAction = async (action: 'fetch' | 'pull' | 'push' | 'prune-lfs') => { if (actionLoading) { return; } const effectiveProjectPath = projectPath ?? (await resolveProjectPath()); if (!effectiveProjectPath) { setError('No active project selected.'); return; } if (!projectPath) { setProjectPath(effectiveProjectPath); } setActionLoading(action); setError(null); setErrorGuidance([]); try { const result = action === 'fetch' ? await window.electronAPI.git.fetch(effectiveProjectPath) : action === 'pull' ? await window.electronAPI.git.pull(effectiveProjectPath) : action === 'push' ? await window.electronAPI.git.push(effectiveProjectPath) : await window.electronAPI.git.pruneLfs(effectiveProjectPath, { dryRun: false, verifyRemote: true, recentCommitsToKeep: 2, }); if (!result.success) { setError(result.error || `Failed to ${action}.`); setErrorGuidance('guidance' in result ? result.guidance || [] : []); return; } await loadRepoState(); } catch { setError(`Failed to ${action}.`); } finally { setActionLoading(null); } }; const handleCommit = async () => { if (actionLoading) { return; } const effectiveProjectPath = projectPath ?? (await resolveProjectPath()); if (!effectiveProjectPath) { setError('No active project selected.'); return; } if (!projectPath) { setProjectPath(effectiveProjectPath); } setActionLoading('commit'); setError(null); setErrorGuidance([]); try { const messageToCommit = commitMessageInputRef.current?.value ?? commitMessage; const result = await window.electronAPI.git.commitAll(effectiveProjectPath, messageToCommit); if (!result.success) { setError(result.error || 'Failed to commit changes.'); setErrorGuidance(result.guidance || []); return; } tabs .filter((tab) => tab.type === 'git-diff') .forEach((tab) => closeTab(tab.id)); setCommitMessage(''); await loadRepoState(); } catch { setError('Failed to commit changes.'); } finally { setActionLoading(null); } }; if (loading) { return (
SOURCE CONTROL
Loading...
); } const transcriptSection = initTranscript.length > 0 ? (
{isTranscriptExpanded && (
    {initTranscript.map((entry, index) => (
  • {entry.progress}% — {entry.message.toLowerCase().replace(/\.+$/, '')} {entry.detail ? ` (${entry.detail})` : ''}
  • ))}
)}
) : null; if (isRepo) { return (
SOURCE CONTROL
{actionLoading && (
{getActionProgressMessage(actionLoading)}
)}
Open Changes ({statusFiles.length})
setCommitMessage(event.target.value)} disabled={actionLoading !== null} />
{statusLoading ? (
Loading changes...
) : statusFiles.length === 0 ? (
No changes
) : (
{statusFiles.map((file) => ( ))}
)}
Version History ({historyEntries.length})
Synced Local only Remote only
{historyLoading ? (
Loading history...
) : historyEntries.length === 0 ? (
No commits yet
) : (
{historyEntries.map((entry) => ( ))}
)} {currentBranch &&
Branch: {currentBranch}
} {remoteState?.hasUpstream && remoteState.localBranch && remoteState.upstreamBranch && (
{remoteState.localBranch} → {remoteState.upstreamBranch}
)} {remoteState?.hasUpstream && (
ahead {remoteState.ahead} / behind {remoteState.behind}
)} {remoteStateError &&
{remoteStateError}
}
{error && (
{error}
{errorGuidance.length > 0 && (
    {errorGuidance.map((step) => (
  • {step}
  • ))}
)}
)} {transcriptSection}
); } return (
SOURCE CONTROL

This project is not a git repository.

{initializing && (

{initProgress?.message || 'Initializing repository...'} {typeof initProgress?.progress === 'number' ? ` (${initProgress.progress}%)` : ''} {initProgress?.detail ? ` — ${initProgress.detail}` : ''}

)} {error &&

{error}

}
{transcriptSection}
); };