import React, { useState, useEffect, useRef } from 'react'; import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles, Download, RotateCcw, Trash2, AlertTriangle, Upload } from 'lucide-react'; import { MicButton } from './MicButton.jsx'; import { authenticatedFetch } from '../utils/api'; import DiffViewer from './DiffViewer.jsx'; function GitPanel({ selectedProject, isMobile, onFileOpen }) { const [gitStatus, setGitStatus] = useState(null); const [gitDiff, setGitDiff] = useState({}); const [isLoading, setIsLoading] = useState(false); const [commitMessage, setCommitMessage] = useState(''); const [expandedFiles, setExpandedFiles] = useState(new Set()); const [selectedFiles, setSelectedFiles] = useState(new Set()); const [isCommitting, setIsCommitting] = useState(false); const [currentBranch, setCurrentBranch] = useState(''); const [branches, setBranches] = useState([]); const [wrapText, setWrapText] = useState(true); const [showLegend, setShowLegend] = useState(false); const [showBranchDropdown, setShowBranchDropdown] = useState(false); const [showNewBranchModal, setShowNewBranchModal] = useState(false); const [newBranchName, setNewBranchName] = useState(''); const [isCreatingBranch, setIsCreatingBranch] = useState(false); const [activeView, setActiveView] = useState('changes'); // 'changes' or 'history' const [recentCommits, setRecentCommits] = useState([]); const [expandedCommits, setExpandedCommits] = useState(new Set()); const [commitDiffs, setCommitDiffs] = useState({}); const [isGeneratingMessage, setIsGeneratingMessage] = useState(false); const [remoteStatus, setRemoteStatus] = useState(null); const [isFetching, setIsFetching] = useState(false); const [isPulling, setIsPulling] = useState(false); const [isPushing, setIsPushing] = useState(false); const [isPublishing, setIsPublishing] = useState(false); const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile const [confirmAction, setConfirmAction] = useState(null); // { type: 'discard|commit|pull|push', file?: string, message?: string } const [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false); const textareaRef = useRef(null); const dropdownRef = useRef(null); // Get current provider from localStorage (same as ChatInterface does) const [provider, setProvider] = useState(() => { return localStorage.getItem('selected-provider') || 'claude'; }); // Listen for provider changes in localStorage useEffect(() => { const handleStorageChange = () => { const newProvider = localStorage.getItem('selected-provider') || 'claude'; setProvider(newProvider); }; window.addEventListener('storage', handleStorageChange); return () => window.removeEventListener('storage', handleStorageChange); }, []); useEffect(() => { if (selectedProject) { fetchGitStatus(); fetchBranches(); fetchRemoteStatus(); if (activeView === 'history') { fetchRecentCommits(); } } }, [selectedProject, activeView]); // Handle click outside dropdown useEffect(() => { const handleClickOutside = (event) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { setShowBranchDropdown(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const fetchGitStatus = async () => { if (!selectedProject) return; setIsLoading(true); try { const response = await authenticatedFetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`); const data = await response.json(); if (data.error) { console.error('Git status error:', data.error); setGitStatus({ error: data.error, details: data.details }); } else { setGitStatus(data); setCurrentBranch(data.branch || 'main'); // Auto-select all changed files const allFiles = new Set([ ...(data.modified || []), ...(data.added || []), ...(data.deleted || []), ...(data.untracked || []) ]); setSelectedFiles(allFiles); // Fetch diffs for changed files for (const file of data.modified || []) { fetchFileDiff(file); } for (const file of data.added || []) { fetchFileDiff(file); } for (const file of data.deleted || []) { fetchFileDiff(file); } for (const file of data.untracked || []) { fetchFileDiff(file); } } } catch (error) { console.error('Error fetching git status:', error); } finally { setIsLoading(false); } }; const fetchBranches = async () => { try { const response = await authenticatedFetch(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`); const data = await response.json(); if (!data.error && data.branches) { setBranches(data.branches); } } catch (error) { console.error('Error fetching branches:', error); } }; const fetchRemoteStatus = async () => { if (!selectedProject) return; try { const response = await authenticatedFetch(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.name)}`); const data = await response.json(); if (!data.error) { setRemoteStatus(data); } else { setRemoteStatus(null); } } catch (error) { console.error('Error fetching remote status:', error); setRemoteStatus(null); } }; const switchBranch = async (branchName) => { try { const response = await authenticatedFetch('/api/git/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: selectedProject.name, branch: branchName }) }); const data = await response.json(); if (data.success) { setCurrentBranch(branchName); setShowBranchDropdown(false); fetchGitStatus(); // Refresh status after branch switch } else { console.error('Failed to switch branch:', data.error); } } catch (error) { console.error('Error switching branch:', error); } }; const createBranch = async () => { if (!newBranchName.trim()) return; setIsCreatingBranch(true); try { const response = await authenticatedFetch('/api/git/create-branch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: selectedProject.name, branch: newBranchName.trim() }) }); const data = await response.json(); if (data.success) { setCurrentBranch(newBranchName.trim()); setShowNewBranchModal(false); setShowBranchDropdown(false); setNewBranchName(''); fetchBranches(); // Refresh branch list fetchGitStatus(); // Refresh status } else { console.error('Failed to create branch:', data.error); } } catch (error) { console.error('Error creating branch:', error); } finally { setIsCreatingBranch(false); } }; const handleFetch = async () => { setIsFetching(true); try { const response = await authenticatedFetch('/api/git/fetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: selectedProject.name }) }); const data = await response.json(); if (data.success) { // Refresh status after successful fetch fetchGitStatus(); fetchRemoteStatus(); } else { console.error('Fetch failed:', data.error); } } catch (error) { console.error('Error fetching from remote:', error); } finally { setIsFetching(false); } }; const handlePull = async () => { setIsPulling(true); try { const response = await authenticatedFetch('/api/git/pull', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: selectedProject.name }) }); const data = await response.json(); if (data.success) { // Refresh status after successful pull fetchGitStatus(); fetchRemoteStatus(); } else { console.error('Pull failed:', data.error); // TODO: Show user-friendly error message } } catch (error) { console.error('Error pulling from remote:', error); } finally { setIsPulling(false); } }; const handlePush = async () => { setIsPushing(true); try { const response = await authenticatedFetch('/api/git/push', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: selectedProject.name }) }); const data = await response.json(); if (data.success) { // Refresh status after successful push fetchGitStatus(); fetchRemoteStatus(); } else { console.error('Push failed:', data.error); // TODO: Show user-friendly error message } } catch (error) { console.error('Error pushing to remote:', error); } finally { setIsPushing(false); } }; const handlePublish = async () => { setIsPublishing(true); try { const response = await authenticatedFetch('/api/git/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: selectedProject.name, branch: currentBranch }) }); const data = await response.json(); if (data.success) { // Refresh status after successful publish fetchGitStatus(); fetchRemoteStatus(); } else { console.error('Publish failed:', data.error); // TODO: Show user-friendly error message } } catch (error) { console.error('Error publishing branch:', error); } finally { setIsPublishing(false); } }; const discardChanges = async (filePath) => { try { const response = await authenticatedFetch('/api/git/discard', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: selectedProject.name, file: filePath }) }); const data = await response.json(); if (data.success) { // Remove from selected files and refresh status setSelectedFiles(prev => { const newSet = new Set(prev); newSet.delete(filePath); return newSet; }); fetchGitStatus(); } else { console.error('Discard failed:', data.error); } } catch (error) { console.error('Error discarding changes:', error); } }; const deleteUntrackedFile = async (filePath) => { try { const response = await authenticatedFetch('/api/git/delete-untracked', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: selectedProject.name, file: filePath }) }); const data = await response.json(); if (data.success) { // Remove from selected files and refresh status setSelectedFiles(prev => { const newSet = new Set(prev); newSet.delete(filePath); return newSet; }); fetchGitStatus(); } else { console.error('Delete failed:', data.error); } } catch (error) { console.error('Error deleting untracked file:', error); } }; const confirmAndExecute = async () => { if (!confirmAction) return; const { type, file, message } = confirmAction; setConfirmAction(null); try { switch (type) { case 'discard': await discardChanges(file); break; case 'delete': await deleteUntrackedFile(file); break; case 'commit': await handleCommit(); break; case 'pull': await handlePull(); break; case 'push': await handlePush(); break; case 'publish': await handlePublish(); break; } } catch (error) { console.error(`Error executing ${type}:`, error); } }; const fetchFileDiff = async (filePath) => { try { const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`); const data = await response.json(); if (!data.error && data.diff) { setGitDiff(prev => ({ ...prev, [filePath]: data.diff })); } } catch (error) { console.error('Error fetching file diff:', error); } }; const handleFileOpen = async (filePath) => { if (!onFileOpen) return; try { // Fetch file content with diff information const response = await authenticatedFetch(`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`); const data = await response.json(); if (data.error) { console.error('Error fetching file with diff:', data.error); // Fallback: open without diff info onFileOpen(filePath); return; } // Create diffInfo object for CodeEditor const diffInfo = { old_string: data.oldContent || '', new_string: data.currentContent || '' }; // Open file with diff information onFileOpen(filePath, diffInfo); } catch (error) { console.error('Error opening file:', error); // Fallback: open without diff info onFileOpen(filePath); } }; const fetchRecentCommits = async () => { try { const response = await authenticatedFetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`); const data = await response.json(); if (!data.error && data.commits) { setRecentCommits(data.commits); } } catch (error) { console.error('Error fetching commits:', error); } }; const fetchCommitDiff = async (commitHash) => { try { const response = await authenticatedFetch(`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`); const data = await response.json(); if (!data.error && data.diff) { setCommitDiffs(prev => ({ ...prev, [commitHash]: data.diff })); } } catch (error) { console.error('Error fetching commit diff:', error); } }; const generateCommitMessage = async () => { setIsGeneratingMessage(true); try { const response = await authenticatedFetch('/api/git/generate-commit-message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: selectedProject.name, files: Array.from(selectedFiles), provider: provider // Pass the current provider (claude or cursor) }) }); const data = await response.json(); if (data.message) { setCommitMessage(data.message); } else { console.error('Failed to generate commit message:', data.error); } } catch (error) { console.error('Error generating commit message:', error); } finally { setIsGeneratingMessage(false); } }; const toggleFileExpanded = (filePath) => { setExpandedFiles(prev => { const newSet = new Set(prev); if (newSet.has(filePath)) { newSet.delete(filePath); } else { newSet.add(filePath); } return newSet; }); }; const toggleCommitExpanded = (commitHash) => { setExpandedCommits(prev => { const newSet = new Set(prev); if (newSet.has(commitHash)) { newSet.delete(commitHash); } else { newSet.add(commitHash); // Fetch diff for this commit if not already fetched if (!commitDiffs[commitHash]) { fetchCommitDiff(commitHash); } } return newSet; }); }; const toggleFileSelected = (filePath) => { setSelectedFiles(prev => { const newSet = new Set(prev); if (newSet.has(filePath)) { newSet.delete(filePath); } else { newSet.add(filePath); } return newSet; }); }; const handleCommit = async () => { if (!commitMessage.trim() || selectedFiles.size === 0) return; setIsCommitting(true); try { const response = await authenticatedFetch('/api/git/commit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: selectedProject.name, message: commitMessage, files: Array.from(selectedFiles) }) }); const data = await response.json(); if (data.success) { // Reset state after successful commit setCommitMessage(''); setSelectedFiles(new Set()); fetchGitStatus(); fetchRemoteStatus(); } else { console.error('Commit failed:', data.error); } } catch (error) { console.error('Error committing changes:', error); } finally { setIsCommitting(false); } }; const createInitialCommit = async () => { setIsCreatingInitialCommit(true); try { const response = await authenticatedFetch('/api/git/initial-commit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: selectedProject.name }) }); const data = await response.json(); if (data.success) { fetchGitStatus(); fetchRemoteStatus(); } else { console.error('Initial commit failed:', data.error); alert(data.error || 'Failed to create initial commit'); } } catch (error) { console.error('Error creating initial commit:', error); alert('Failed to create initial commit'); } finally { setIsCreatingInitialCommit(false); } }; const getStatusLabel = (status) => { switch (status) { case 'M': return 'Modified'; case 'A': return 'Added'; case 'D': return 'Deleted'; case 'U': return 'Untracked'; default: return status; } }; const renderCommitItem = (commit) => { const isExpanded = expandedCommits.has(commit.hash); const diff = commitDiffs[commit.hash]; return (
{commit.message}
{commit.author} • {commit.date}
Select a project to view source control
{gitStatus.details}
)} {/* // ! This can be a custom component that can be reused for " Tip: Create a new project..." as well */}
Tip: Run git init in your project directory to initialize git source control.
This repository doesn't have any commits yet. Create your first commit to start tracking changes.
No changes detected
No commits found
{confirmAction.message}