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'; function GitPanel({ selectedProject, isMobile }) { 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 textareaRef = useRef(null); const dropdownRef = useRef(null); 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); } } } 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 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) }) }); 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 renderDiffLine = (line, index) => { const isAddition = line.startsWith('+') && !line.startsWith('+++'); const isDeletion = line.startsWith('-') && !line.startsWith('---'); const isHeader = line.startsWith('@@'); return (
{line}
); }; 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 (
toggleCommitExpanded(commit.hash)} >
{isExpanded ? : }

{commit.message}

{commit.author} • {commit.date}

{commit.hash.substring(0, 7)}
{isExpanded && diff && (
{commit.stats}
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
)}
); }; const renderFileItem = (filePath, status) => { const isExpanded = expandedFiles.has(filePath); const isSelected = selectedFiles.has(filePath); const diff = gitDiff[filePath]; return (
toggleFileSelected(filePath)} onClick={(e) => e.stopPropagation()} className={`rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600 ${isMobile ? 'mr-1.5' : 'mr-2'}`} />
toggleFileExpanded(filePath)} >
{filePath}
{(status === 'M' || status === 'D') && ( )} {status === 'U' && ( )} {status}
{/* Operation header */}
{status} {getStatusLabel(status)}
{isMobile && ( )}
{diff && diff.split('\n').map((line, index) => renderDiffLine(line, index))}
); }; if (!selectedProject) { return (

Select a project to view source control

); } return (
{/* Header */}
{/* Branch Dropdown */} {showBranchDropdown && (
{branches.map(branch => ( ))}
)}
{/* Remote action buttons - smart logic based on ahead/behind status */} {remoteStatus?.hasRemote && ( <> {/* Publish button - show when branch doesn't exist on remote */} {!remoteStatus?.hasUpstream && ( )} {/* Show normal push/pull buttons only if branch has upstream */} {remoteStatus?.hasUpstream && !remoteStatus?.isUpToDate && ( <> {/* Pull button - show when behind (primary action) */} {remoteStatus.behind > 0 && ( )} {/* Push button - show when ahead (primary action when ahead only) */} {remoteStatus.ahead > 0 && ( )} {/* Fetch button - show when ahead only or when diverged (secondary action) */} {(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && ( )} )} )}
{/* Git Repository Not Found Message */} {gitStatus?.error ? (

{gitStatus.error}

{gitStatus.details && (

{gitStatus.details}

)}

Tip: Run git init in your project directory to initialize git source control.

) : ( <> {/* Tab Navigation - Only show when git is available and no files expanded */}
{/* Changes View */} {activeView === 'changes' && ( <> {/* Mobile Commit Toggle Button / Desktop Always Visible - Hide when files expanded */}
{isMobile && isCommitAreaCollapsed ? (
) : ( <> {/* Commit Message Input */}
{/* Mobile collapse button */} {isMobile && (
Commit Changes
)}