diff --git a/src/components/GitPanel.jsx b/src/components/GitPanel.jsx
deleted file mode 100644
index 76690fb..0000000
--- a/src/components/GitPanel.jsx
+++ /dev/null
@@ -1,1426 +0,0 @@
-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(() => {
- // Clear stale repo-scoped state when project changes.
- setCurrentBranch('');
- setBranches([]);
- setGitStatus(null);
- setRemoteStatus(null);
- setSelectedFiles(new Set());
-
- if (!selectedProject) {
- return;
- }
-
- fetchGitStatus();
- fetchBranches();
- fetchRemoteStatus();
- }, [selectedProject]);
-
- useEffect(() => {
- if (!selectedProject || activeView !== 'history') {
- return;
- }
-
- 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 });
- setCurrentBranch('');
- setSelectedFiles(new Set());
- } 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);
- setGitStatus({ error: 'Git operation failed', details: String(error) });
- setCurrentBranch('');
- setSelectedFiles(new Set());
- } 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);
- } else {
- setBranches([]);
- }
- } catch (error) {
- console.error('Error fetching branches:', error);
- setBranches([]);
- }
- };
-
- 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 (
-
-
toggleCommitExpanded(commit.hash)}
- >
-
- {isExpanded ? : }
-
-
-
-
-
- {commit.message}
-
-
- {commit.author} • {commit.date}
-
-
-
- {commit.hash.substring(0, 7)}
-
-
-
-
- {isExpanded && diff && (
-
- )}
-
- );
- };
-
- 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-border text-primary focus:ring-primary/40 bg-background checked:bg-primary ${isMobile ? 'mr-1.5' : 'mr-2'}`}
- />
-
-
{
- e.stopPropagation();
- toggleFileExpanded(filePath);
- }}
- >
-
-
-
{
- e.stopPropagation();
- handleFileOpen(filePath);
- }}
- title="Click to open file"
- >
- {filePath}
-
-
- {(status === 'M' || status === 'D') && (
- {
- e.stopPropagation();
- setConfirmAction({
- type: 'discard',
- file: filePath,
- message: `Discard all changes to "${filePath}"? This action cannot be undone.`
- });
- }}
- className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-destructive/10 rounded text-destructive font-medium flex items-center gap-1`}
- title="Discard changes"
- >
-
- {isMobile && Discard }
-
- )}
- {status === 'U' && (
- {
- e.stopPropagation();
- setConfirmAction({
- type: 'delete',
- file: filePath,
- message: `Delete untracked file "${filePath}"? This action cannot be undone.`
- });
- }}
- className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-destructive/10 rounded text-destructive font-medium flex items-center gap-1`}
- title="Delete untracked file"
- >
-
- {isMobile && Delete }
-
- )}
-
- {status}
-
-
-
-
-
- {/* Operation header */}
-
-
-
- {status}
-
-
- {getStatusLabel(status)}
-
-
- {isMobile && (
-
{
- e.stopPropagation();
- setWrapText(!wrapText);
- }}
- className="text-sm text-muted-foreground hover:text-foreground transition-colors"
- title={wrapText ? "Switch to horizontal scroll" : "Switch to text wrap"}
- >
- {wrapText ? '↔️ Scroll' : '↩️ Wrap'}
-
- )}
-
-
- {diff && }
-
-
-
- );
- };
-
- if (!selectedProject) {
- return (
-
-
Select a project to view source control
-
- );
- }
-
- return (
-
- {/* Header */}
-
-
-
setShowBranchDropdown(!showBranchDropdown)}
- className={`flex items-center hover:bg-accent rounded-lg transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
- >
-
-
-
{currentBranch}
- {/* Remote status indicators */}
- {remoteStatus?.hasRemote && (
-
- {remoteStatus.ahead > 0 && (
-
- ↑{remoteStatus.ahead}
-
- )}
- {remoteStatus.behind > 0 && (
-
- ↓{remoteStatus.behind}
-
- )}
- {remoteStatus.isUpToDate && (
-
- ✓
-
- )}
-
- )}
-
-
-
-
- {/* Branch Dropdown */}
- {showBranchDropdown && (
-
-
- {branches.map(branch => (
-
switchBranch(branch)}
- className={`w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors ${
- branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground'
- }`}
- >
-
- {branch === currentBranch && }
- {branch}
-
-
- ))}
-
-
-
{
- setShowNewBranchModal(true);
- setShowBranchDropdown(false);
- }}
- className="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors flex items-center space-x-2"
- >
-
- Create new 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 && (
- setConfirmAction({
- type: 'publish',
- message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?`
- })}
- disabled={isPublishing}
- className="px-2.5 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
- title={`Publish branch "${currentBranch}" to ${remoteStatus.remoteName}`}
- >
-
- {isPublishing ? 'Publishing...' : 'Publish'}
-
- )}
-
- {/* Show normal push/pull buttons only if branch has upstream */}
- {remoteStatus?.hasUpstream && !remoteStatus?.isUpToDate && (
- <>
- {/* Pull button - show when behind (primary action) */}
- {remoteStatus.behind > 0 && (
- setConfirmAction({
- type: 'pull',
- message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
- })}
- disabled={isPulling}
- className="px-2.5 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
- title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
- >
-
- {isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}
-
- )}
-
- {/* Push button - show when ahead (primary action when ahead only) */}
- {remoteStatus.ahead > 0 && (
- setConfirmAction({
- type: 'push',
- message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
- })}
- disabled={isPushing}
- className="px-2.5 py-1 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
- title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
- >
-
- {isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}
-
- )}
-
- {/* Fetch button - show when ahead only or when diverged (secondary action) */}
- {(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
-
-
- {isFetching ? 'Fetching...' : 'Fetch'}
-
- )}
- >
- )}
- >
- )}
-
- {
- fetchGitStatus();
- fetchBranches();
- fetchRemoteStatus();
- }}
- disabled={isLoading}
- className={`hover:bg-accent rounded-lg transition-colors ${isMobile ? 'p-1' : 'p-1.5'}`}
- >
-
-
-
-
-
- {/* 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 */}
-
-
setActiveView('changes')}
- className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
- activeView === 'changes'
- ? 'text-primary border-b-2 border-primary'
- : 'text-muted-foreground hover:text-foreground'
- }`}
- >
-
-
- Changes
-
-
-
setActiveView('history')}
- className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
- activeView === 'history'
- ? 'text-primary border-b-2 border-primary'
- : 'text-muted-foreground hover:text-foreground'
- }`}
- >
-
-
- History
-
-
-
-
- {/* Changes View */}
- {activeView === 'changes' && (
- <>
- {/* Mobile Commit Toggle Button / Desktop Always Visible - Hide when files expanded */}
-
- {isMobile && isCommitAreaCollapsed ? (
-
- setIsCommitAreaCollapsed(false)}
- className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
- >
-
- Commit {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''}
-
-
-
- ) : (
- <>
- {/* Commit Message Input */}
-
- {/* Mobile collapse button */}
- {isMobile && (
-
- Commit Changes
- setIsCommitAreaCollapsed(true)}
- className="p-1 hover:bg-accent rounded-lg transition-colors"
- >
-
-
-
- )}
-
-
-
-
- {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
-
- setConfirmAction({
- type: 'commit',
- message: `Commit ${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} with message: "${commitMessage.trim()}"?`
- })}
- disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
- className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1 transition-colors"
- >
-
- {isCommitting ? 'Committing...' : 'Commit'}
-
-
-
- >
- )}
-
- >
- )}
-
- {/* File Selection Controls - Only show in changes view and when git is working and no files expanded */}
- {activeView === 'changes' && gitStatus && !gitStatus.error && (
-
-
- {selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} {isMobile ? '' : 'files'} selected
-
-
- {
- const allFiles = new Set([
- ...(gitStatus?.modified || []),
- ...(gitStatus?.added || []),
- ...(gitStatus?.deleted || []),
- ...(gitStatus?.untracked || [])
- ]);
- setSelectedFiles(allFiles);
- }}
- className="text-sm text-primary hover:text-primary/80 transition-colors"
- >
- {isMobile ? 'All' : 'Select All'}
-
- |
- setSelectedFiles(new Set())}
- className="text-sm text-primary hover:text-primary/80 transition-colors"
- >
- {isMobile ? 'None' : 'Deselect All'}
-
-
-
- )}
-
- {/* Status Legend Toggle - Hide on mobile by default */}
- {!gitStatus?.error && !isMobile && (
-
-
setShowLegend(!showLegend)}
- className="w-full px-4 py-2 bg-muted/30 hover:bg-muted/50 text-sm text-muted-foreground flex items-center justify-center gap-1 transition-colors"
- >
-
- File Status Guide
- {showLegend ? : }
-
-
- {showLegend && (
-
-
-
-
- M
-
- Modified
-
-
-
- A
-
- Added
-
-
-
- D
-
- Deleted
-
-
-
- U
-
- Untracked
-
-
-
- )}
-
- )}
- >
- )}
-
- {/* File List - Changes View - Only show when git is available */}
- {activeView === 'changes' && !gitStatus?.error && (
-
- {isLoading ? (
-
-
-
- ) : gitStatus?.hasCommits === false ? (
-
-
-
-
-
No commits yet
-
- This repository doesn't have any commits yet. Create your first commit to start tracking changes.
-
-
- {isCreatingInitialCommit ? (
- <>
-
- Creating Initial Commit...
- >
- ) : (
- <>
-
- Create Initial Commit
- >
- )}
-
-
- ) : !gitStatus || (!gitStatus.modified?.length && !gitStatus.added?.length && !gitStatus.deleted?.length && !gitStatus.untracked?.length) ? (
-
-
-
No changes detected
-
- ) : (
-
- {gitStatus.modified?.map(file => renderFileItem(file, 'M'))}
- {gitStatus.added?.map(file => renderFileItem(file, 'A'))}
- {gitStatus.deleted?.map(file => renderFileItem(file, 'D'))}
- {gitStatus.untracked?.map(file => renderFileItem(file, 'U'))}
-
- )}
-
- )}
-
- {/* History View - Only show when git is available */}
- {activeView === 'history' && !gitStatus?.error && (
-
- {isLoading ? (
-
-
-
- ) : recentCommits.length === 0 ? (
-
- ) : (
-
- {recentCommits.map(commit => renderCommitItem(commit))}
-
- )}
-
- )}
-
- {/* New Branch Modal */}
- {showNewBranchModal && (
-
-
setShowNewBranchModal(false)} />
-
-
-
Create New Branch
-
-
- Branch Name
-
- setNewBranchName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && !isCreatingBranch) {
- createBranch();
- }
- }}
- placeholder="feature/new-feature"
- className="w-full px-3 py-2 border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
- autoFocus
- />
-
-
- This will create a new branch from the current branch ({currentBranch})
-
-
-
{
- setShowNewBranchModal(false);
- setNewBranchName('');
- }}
- className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
- >
- Cancel
-
-
- {isCreatingBranch ? (
- <>
-
- Creating...
- >
- ) : (
- <>
-
- Create Branch
- >
- )}
-
-
-
-
-
- )}
-
- {/* Confirmation Modal */}
- {confirmAction && (
-
-
setConfirmAction(null)} />
-
-
-
-
-
- {confirmAction.type === 'discard' ? 'Discard Changes' :
- confirmAction.type === 'delete' ? 'Delete File' :
- confirmAction.type === 'commit' ? 'Confirm Commit' :
- confirmAction.type === 'pull' ? 'Confirm Pull' :
- confirmAction.type === 'publish' ? 'Publish Branch' : 'Confirm Push'}
-
-
-
-
- {confirmAction.message}
-
-
-
- setConfirmAction(null)}
- className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
- >
- Cancel
-
-
- {confirmAction.type === 'discard' ? (
- <>
-
- Discard
- >
- ) : confirmAction.type === 'delete' ? (
- <>
-
- Delete
- >
- ) : confirmAction.type === 'commit' ? (
- <>
-
- Commit
- >
- ) : confirmAction.type === 'pull' ? (
- <>
-
- Pull
- >
- ) : confirmAction.type === 'publish' ? (
- <>
-
- Publish
- >
- ) : (
- <>
-
- Push
- >
- )}
-
-
-
-
-
- )}
-
- );
-}
-
-export default GitPanel;
diff --git a/src/components/git-panel/constants/constants.ts b/src/components/git-panel/constants/constants.ts
new file mode 100644
index 0000000..5defa41
--- /dev/null
+++ b/src/components/git-panel/constants/constants.ts
@@ -0,0 +1,70 @@
+import type { ConfirmActionType, FileStatusCode, GitStatusGroupEntry } from '../types/types';
+
+export const DEFAULT_BRANCH = 'main';
+export const RECENT_COMMITS_LIMIT = 10;
+
+export const FILE_STATUS_GROUPS: GitStatusGroupEntry[] = [
+ { key: 'modified', status: 'M' },
+ { key: 'added', status: 'A' },
+ { key: 'deleted', status: 'D' },
+ { key: 'untracked', status: 'U' },
+];
+
+export const FILE_STATUS_LABELS: Record
= {
+ M: 'Modified',
+ A: 'Added',
+ D: 'Deleted',
+ U: 'Untracked',
+};
+
+export const FILE_STATUS_BADGE_CLASSES: Record = {
+ M: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800/50',
+ A: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800/50',
+ D: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 border-red-200 dark:border-red-800/50',
+ U: 'bg-muted text-muted-foreground border-border',
+};
+
+export const CONFIRMATION_TITLES: Record = {
+ discard: 'Discard Changes',
+ delete: 'Delete File',
+ commit: 'Confirm Commit',
+ pull: 'Confirm Pull',
+ push: 'Confirm Push',
+ publish: 'Publish Branch',
+};
+
+export const CONFIRMATION_ACTION_LABELS: Record = {
+ discard: 'Discard',
+ delete: 'Delete',
+ commit: 'Commit',
+ pull: 'Pull',
+ push: 'Push',
+ publish: 'Publish',
+};
+
+export const CONFIRMATION_BUTTON_CLASSES: Record = {
+ discard: 'bg-red-600 hover:bg-red-700',
+ delete: 'bg-red-600 hover:bg-red-700',
+ commit: 'bg-primary hover:bg-primary/90',
+ pull: 'bg-green-600 hover:bg-green-700',
+ push: 'bg-orange-600 hover:bg-orange-700',
+ publish: 'bg-purple-600 hover:bg-purple-700',
+};
+
+export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record = {
+ discard: 'bg-red-100 dark:bg-red-900/30',
+ delete: 'bg-red-100 dark:bg-red-900/30',
+ commit: 'bg-yellow-100 dark:bg-yellow-900/30',
+ pull: 'bg-yellow-100 dark:bg-yellow-900/30',
+ push: 'bg-yellow-100 dark:bg-yellow-900/30',
+ publish: 'bg-yellow-100 dark:bg-yellow-900/30',
+};
+
+export const CONFIRMATION_ICON_CLASSES: Record = {
+ discard: 'text-red-600 dark:text-red-400',
+ delete: 'text-red-600 dark:text-red-400',
+ commit: 'text-yellow-600 dark:text-yellow-400',
+ pull: 'text-yellow-600 dark:text-yellow-400',
+ push: 'text-yellow-600 dark:text-yellow-400',
+ publish: 'text-yellow-600 dark:text-yellow-400',
+};
diff --git a/src/components/git-panel/hooks/useGitPanelController.ts b/src/components/git-panel/hooks/useGitPanelController.ts
new file mode 100644
index 0000000..a74aa8f
--- /dev/null
+++ b/src/components/git-panel/hooks/useGitPanelController.ts
@@ -0,0 +1,645 @@
+import { useCallback, useEffect, useState } from 'react';
+import { authenticatedFetch } from '../../../utils/api';
+import { DEFAULT_BRANCH, RECENT_COMMITS_LIMIT } from '../constants/constants';
+import type {
+ GitApiErrorResponse,
+ GitBranchesResponse,
+ GitCommitSummary,
+ GitCommitsResponse,
+ GitDiffMap,
+ GitDiffResponse,
+ GitFileWithDiffResponse,
+ GitGenerateMessageResponse,
+ GitOperationResponse,
+ GitPanelController,
+ GitRemoteStatus,
+ GitStatusResponse,
+ UseGitPanelControllerOptions,
+} from '../types/types';
+import { getAllChangedFiles } from '../utils/gitPanelUtils';
+import { useSelectedProvider } from './useSelectedProvider';
+
+// ! use authenticatedFetch directly. fetchWithAuth is redundant
+const fetchWithAuth = authenticatedFetch as (url: string, options?: RequestInit) => Promise;
+
+async function readJson(response: Response): Promise {
+ return response.json() as Promise;
+}
+
+export function useGitPanelController({
+ selectedProject,
+ activeView,
+ onFileOpen,
+}: UseGitPanelControllerOptions): GitPanelController {
+ const [gitStatus, setGitStatus] = useState(null);
+ const [gitDiff, setGitDiff] = useState({});
+ const [isLoading, setIsLoading] = useState(false);
+ const [currentBranch, setCurrentBranch] = useState('');
+ const [branches, setBranches] = useState([]);
+ const [recentCommits, setRecentCommits] = useState([]);
+ const [commitDiffs, setCommitDiffs] = useState({});
+ const [remoteStatus, setRemoteStatus] = useState(null);
+ const [isCreatingBranch, setIsCreatingBranch] = useState(false);
+ const [isFetching, setIsFetching] = useState(false);
+ const [isPulling, setIsPulling] = useState(false);
+ const [isPushing, setIsPushing] = useState(false);
+ const [isPublishing, setIsPublishing] = useState(false);
+ const [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false);
+
+ const provider = useSelectedProvider();
+
+ const fetchFileDiff = useCallback(
+ async (filePath: string) => {
+ if (!selectedProject) {
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth(
+ `/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`,
+ );
+ const data = await readJson(response);
+
+ if (!data.error && data.diff) {
+ setGitDiff((previous) => ({
+ ...previous,
+ [filePath]: data.diff as string,
+ }));
+ }
+ } catch (error) {
+ console.error('Error fetching file diff:', error);
+ }
+ },
+ [selectedProject],
+ );
+
+ const fetchGitStatus = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ const response = await fetchWithAuth(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
+ const data = await readJson(response);
+
+ if (data.error) {
+ console.error('Git status error:', data.error);
+ setGitStatus({ error: data.error, details: data.details });
+ setCurrentBranch('');
+ return;
+ }
+
+ setGitStatus(data);
+ setCurrentBranch(data.branch || DEFAULT_BRANCH);
+
+ const changedFiles = getAllChangedFiles(data);
+ changedFiles.forEach((filePath) => {
+ void fetchFileDiff(filePath);
+ });
+ } catch (error) {
+ console.error('Error fetching git status:', error);
+ setGitStatus({ error: 'Git operation failed', details: String(error) });
+ setCurrentBranch('');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [fetchFileDiff, selectedProject]);
+
+ const fetchBranches = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
+ const data = await readJson(response);
+
+ if (!data.error && data.branches) {
+ setBranches(data.branches);
+ return;
+ }
+
+ setBranches([]);
+ } catch (error) {
+ console.error('Error fetching branches:', error);
+ setBranches([]);
+ }
+ }, [selectedProject]);
+
+ const fetchRemoteStatus = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.name)}`);
+ const data = await readJson(response);
+
+ if (!data.error) {
+ setRemoteStatus(data as GitRemoteStatus);
+ return;
+ }
+
+ setRemoteStatus(null);
+ } catch (error) {
+ console.error('Error fetching remote status:', error);
+ setRemoteStatus(null);
+ }
+ }, [selectedProject]);
+
+ const switchBranch = useCallback(
+ async (branchName: string) => {
+ if (!selectedProject) {
+ return false;
+ }
+
+ try {
+ const response = await fetchWithAuth('/api/git/checkout', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ branch: branchName,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (!data.success) {
+ console.error('Failed to switch branch:', data.error);
+ return false;
+ }
+
+ setCurrentBranch(branchName);
+ void fetchGitStatus();
+ return true;
+ } catch (error) {
+ console.error('Error switching branch:', error);
+ return false;
+ }
+ },
+ [fetchGitStatus, selectedProject],
+ );
+
+ const createBranch = useCallback(
+ async (branchName: string) => {
+ const trimmedBranchName = branchName.trim();
+ if (!selectedProject || !trimmedBranchName) {
+ return false;
+ }
+
+ setIsCreatingBranch(true);
+ try {
+ const response = await fetchWithAuth('/api/git/create-branch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ branch: trimmedBranchName,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (!data.success) {
+ console.error('Failed to create branch:', data.error);
+ return false;
+ }
+
+ setCurrentBranch(trimmedBranchName);
+ void fetchBranches();
+ void fetchGitStatus();
+ return true;
+ } catch (error) {
+ console.error('Error creating branch:', error);
+ return false;
+ } finally {
+ setIsCreatingBranch(false);
+ }
+ },
+ [fetchBranches, fetchGitStatus, selectedProject],
+ );
+
+ const handleFetch = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ setIsFetching(true);
+ try {
+ const response = await fetchWithAuth('/api/git/fetch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ void fetchRemoteStatus();
+ return;
+ }
+
+ console.error('Fetch failed:', data.error);
+ } catch (error) {
+ console.error('Error fetching from remote:', error);
+ } finally {
+ setIsFetching(false);
+ }
+ }, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
+
+ const handlePull = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ setIsPulling(true);
+ try {
+ const response = await fetchWithAuth('/api/git/pull', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ void fetchRemoteStatus();
+ return;
+ }
+
+ console.error('Pull failed:', data.error);
+ } catch (error) {
+ console.error('Error pulling from remote:', error);
+ } finally {
+ setIsPulling(false);
+ }
+ }, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
+
+ const handlePush = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ setIsPushing(true);
+ try {
+ const response = await fetchWithAuth('/api/git/push', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ void fetchRemoteStatus();
+ return;
+ }
+
+ console.error('Push failed:', data.error);
+ } catch (error) {
+ console.error('Error pushing to remote:', error);
+ } finally {
+ setIsPushing(false);
+ }
+ }, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
+
+ const handlePublish = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ setIsPublishing(true);
+ try {
+ const response = await fetchWithAuth('/api/git/publish', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ branch: currentBranch,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ void fetchRemoteStatus();
+ return;
+ }
+
+ console.error('Publish failed:', data.error);
+ } catch (error) {
+ console.error('Error publishing branch:', error);
+ } finally {
+ setIsPublishing(false);
+ }
+ }, [currentBranch, fetchGitStatus, fetchRemoteStatus, selectedProject]);
+
+ const discardChanges = useCallback(
+ async (filePath: string) => {
+ if (!selectedProject) {
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth('/api/git/discard', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ file: filePath,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ return;
+ }
+
+ console.error('Discard failed:', data.error);
+ } catch (error) {
+ console.error('Error discarding changes:', error);
+ }
+ },
+ [fetchGitStatus, selectedProject],
+ );
+
+ const deleteUntrackedFile = useCallback(
+ async (filePath: string) => {
+ if (!selectedProject) {
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth('/api/git/delete-untracked', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ file: filePath,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ return;
+ }
+
+ console.error('Delete failed:', data.error);
+ } catch (error) {
+ console.error('Error deleting untracked file:', error);
+ }
+ },
+ [fetchGitStatus, selectedProject],
+ );
+
+ const fetchRecentCommits = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth(
+ `/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=${RECENT_COMMITS_LIMIT}`,
+ );
+ const data = await readJson(response);
+
+ if (!data.error && data.commits) {
+ setRecentCommits(data.commits);
+ }
+ } catch (error) {
+ console.error('Error fetching commits:', error);
+ }
+ }, [selectedProject]);
+
+ const fetchCommitDiff = useCallback(
+ async (commitHash: string) => {
+ if (!selectedProject) {
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth(
+ `/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`,
+ );
+ const data = await readJson(response);
+
+ if (!data.error && data.diff) {
+ setCommitDiffs((previous) => ({
+ ...previous,
+ [commitHash]: data.diff as string,
+ }));
+ }
+ } catch (error) {
+ console.error('Error fetching commit diff:', error);
+ }
+ },
+ [selectedProject],
+ );
+
+ const generateCommitMessage = useCallback(
+ async (files: string[]) => {
+ if (!selectedProject || files.length === 0) {
+ return null;
+ }
+
+ try {
+ const response = await authenticatedFetch('/api/git/generate-commit-message', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ files,
+ provider,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.message) {
+ return data.message;
+ }
+
+ console.error('Failed to generate commit message:', data.error);
+ return null;
+ } catch (error) {
+ console.error('Error generating commit message:', error);
+ return null;
+ }
+ },
+ [provider, selectedProject],
+ );
+
+ const commitChanges = useCallback(
+ async (message: string, files: string[]) => {
+ if (!selectedProject || !message.trim() || files.length === 0) {
+ return false;
+ }
+
+ try {
+ const response = await fetchWithAuth('/api/git/commit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ message,
+ files,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ void fetchRemoteStatus();
+ return true;
+ }
+
+ console.error('Commit failed:', data.error);
+ return false;
+ } catch (error) {
+ console.error('Error committing changes:', error);
+ return false;
+ }
+ },
+ [fetchGitStatus, fetchRemoteStatus, selectedProject],
+ );
+
+ const createInitialCommit = useCallback(async () => {
+ if (!selectedProject) {
+ return false;
+ }
+
+ setIsCreatingInitialCommit(true);
+ try {
+ const response = await fetchWithAuth('/api/git/initial-commit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ void fetchRemoteStatus();
+ return true;
+ }
+
+ console.error('Initial commit failed:', data.error);
+ alert(data.error || 'Failed to create initial commit');
+ return false;
+ } catch (error) {
+ console.error('Error creating initial commit:', error);
+ alert('Failed to create initial commit');
+ return false;
+ } finally {
+ setIsCreatingInitialCommit(false);
+ }
+ }, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
+
+ const openFile = useCallback(
+ async (filePath: string) => {
+ if (!onFileOpen) {
+ return;
+ }
+
+ if (!selectedProject) {
+ onFileOpen(filePath);
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth(
+ `/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`,
+ );
+ const data = await readJson(response);
+
+ if (data.error) {
+ console.error('Error fetching file with diff:', data.error);
+ onFileOpen(filePath);
+ return;
+ }
+
+ onFileOpen(filePath, {
+ old_string: data.oldContent || '',
+ new_string: data.currentContent || '',
+ });
+ } catch (error) {
+ console.error('Error opening file:', error);
+ onFileOpen(filePath);
+ }
+ },
+ [onFileOpen, selectedProject],
+ );
+
+ const refreshAll = useCallback(() => {
+ void fetchGitStatus();
+ void fetchBranches();
+ void fetchRemoteStatus();
+ }, [fetchBranches, fetchGitStatus, fetchRemoteStatus]);
+
+ useEffect(() => {
+ // Reset repository-scoped state when project changes to avoid stale UI.
+ setCurrentBranch('');
+ setBranches([]);
+ setGitStatus(null);
+ setRemoteStatus(null);
+ setGitDiff({});
+ setRecentCommits([]);
+ setCommitDiffs({});
+
+ if (!selectedProject) {
+ return;
+ }
+
+ void fetchGitStatus();
+ void fetchBranches();
+ void fetchRemoteStatus();
+ }, [fetchBranches, fetchGitStatus, fetchRemoteStatus, selectedProject]);
+
+ useEffect(() => {
+ if (!selectedProject || activeView !== 'history') {
+ return;
+ }
+
+ void fetchRecentCommits();
+ }, [activeView, fetchRecentCommits, selectedProject]);
+
+ return {
+ gitStatus,
+ gitDiff,
+ isLoading,
+ currentBranch,
+ branches,
+ recentCommits,
+ commitDiffs,
+ remoteStatus,
+ isCreatingBranch,
+ isFetching,
+ isPulling,
+ isPushing,
+ isPublishing,
+ isCreatingInitialCommit,
+ refreshAll,
+ switchBranch,
+ createBranch,
+ handleFetch,
+ handlePull,
+ handlePush,
+ handlePublish,
+ discardChanges,
+ deleteUntrackedFile,
+ fetchCommitDiff,
+ generateCommitMessage,
+ commitChanges,
+ createInitialCommit,
+ openFile,
+ };
+}
diff --git a/src/components/git-panel/hooks/useSelectedProvider.ts b/src/components/git-panel/hooks/useSelectedProvider.ts
new file mode 100644
index 0000000..543a54d
--- /dev/null
+++ b/src/components/git-panel/hooks/useSelectedProvider.ts
@@ -0,0 +1,20 @@
+import { useEffect, useState } from 'react';
+
+export function useSelectedProvider() {
+ const [provider, setProvider] = useState(() => {
+ return localStorage.getItem('selected-provider') || 'claude';
+ });
+
+ useEffect(() => {
+ // Keep provider in sync when another tab changes the selected provider.
+ const handleStorageChange = () => {
+ const nextProvider = localStorage.getItem('selected-provider') || 'claude';
+ setProvider(nextProvider);
+ };
+
+ window.addEventListener('storage', handleStorageChange);
+ return () => window.removeEventListener('storage', handleStorageChange);
+ }, []);
+
+ return provider;
+}
diff --git a/src/components/git-panel/types/types.ts b/src/components/git-panel/types/types.ts
new file mode 100644
index 0000000..452f779
--- /dev/null
+++ b/src/components/git-panel/types/types.ts
@@ -0,0 +1,135 @@
+import type { Project } from '../../../types/app';
+
+export type GitPanelView = 'changes' | 'history';
+export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
+export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';
+export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish';
+
+export type FileDiffInfo = {
+ old_string: string;
+ new_string: string;
+};
+
+export type FileOpenHandler = (filePath: string, diffInfo?: FileDiffInfo) => void;
+
+export type GitPanelProps = {
+ selectedProject: Project | null;
+ isMobile?: boolean;
+ onFileOpen?: FileOpenHandler;
+};
+
+export type GitStatusResponse = {
+ branch?: string;
+ hasCommits?: boolean;
+ modified?: string[];
+ added?: string[];
+ deleted?: string[];
+ untracked?: string[];
+ error?: string;
+ details?: string;
+};
+
+export type GitRemoteStatus = {
+ hasRemote?: boolean;
+ hasUpstream?: boolean;
+ branch?: string;
+ remoteBranch?: string;
+ remoteName?: string | null;
+ ahead?: number;
+ behind?: number;
+ isUpToDate?: boolean;
+ message?: string;
+ error?: string;
+};
+
+export type GitCommitSummary = {
+ hash: string;
+ author: string;
+ email?: string;
+ date: string;
+ message: string;
+ stats?: string;
+};
+
+export type GitDiffMap = Record;
+
+export type GitStatusGroupEntry = {
+ key: GitStatusFileGroup;
+ status: FileStatusCode;
+};
+
+export type ConfirmationRequest = {
+ type: ConfirmActionType;
+ message: string;
+ onConfirm: () => Promise | void;
+};
+
+export type UseGitPanelControllerOptions = {
+ selectedProject: Project | null;
+ activeView: GitPanelView;
+ onFileOpen?: FileOpenHandler;
+};
+
+export type GitPanelController = {
+ gitStatus: GitStatusResponse | null;
+ gitDiff: GitDiffMap;
+ isLoading: boolean;
+ currentBranch: string;
+ branches: string[];
+ recentCommits: GitCommitSummary[];
+ commitDiffs: GitDiffMap;
+ remoteStatus: GitRemoteStatus | null;
+ isCreatingBranch: boolean;
+ isFetching: boolean;
+ isPulling: boolean;
+ isPushing: boolean;
+ isPublishing: boolean;
+ isCreatingInitialCommit: boolean;
+ refreshAll: () => void;
+ switchBranch: (branchName: string) => Promise;
+ createBranch: (branchName: string) => Promise;
+ handleFetch: () => Promise;
+ handlePull: () => Promise;
+ handlePush: () => Promise;
+ handlePublish: () => Promise;
+ discardChanges: (filePath: string) => Promise;
+ deleteUntrackedFile: (filePath: string) => Promise;
+ fetchCommitDiff: (commitHash: string) => Promise;
+ generateCommitMessage: (files: string[]) => Promise;
+ commitChanges: (message: string, files: string[]) => Promise;
+ createInitialCommit: () => Promise;
+ openFile: (filePath: string) => Promise;
+};
+
+export type GitApiErrorResponse = {
+ error?: string;
+ details?: string;
+};
+
+export type GitDiffResponse = GitApiErrorResponse & {
+ diff?: string;
+};
+
+export type GitBranchesResponse = GitApiErrorResponse & {
+ branches?: string[];
+};
+
+export type GitCommitsResponse = GitApiErrorResponse & {
+ commits?: GitCommitSummary[];
+};
+
+export type GitOperationResponse = GitApiErrorResponse & {
+ success?: boolean;
+ output?: string;
+};
+
+export type GitGenerateMessageResponse = GitApiErrorResponse & {
+ message?: string;
+};
+
+export type GitFileWithDiffResponse = GitApiErrorResponse & {
+ oldContent?: string;
+ currentContent?: string;
+ isDeleted?: boolean;
+ isUntracked?: boolean;
+};
diff --git a/src/components/git-panel/utils/gitPanelUtils.ts b/src/components/git-panel/utils/gitPanelUtils.ts
new file mode 100644
index 0000000..736deeb
--- /dev/null
+++ b/src/components/git-panel/utils/gitPanelUtils.ts
@@ -0,0 +1,26 @@
+import { FILE_STATUS_BADGE_CLASSES, FILE_STATUS_GROUPS, FILE_STATUS_LABELS } from '../constants/constants';
+import type { FileStatusCode, GitStatusResponse } from '../types/types';
+
+export function getAllChangedFiles(gitStatus: GitStatusResponse | null): string[] {
+ if (!gitStatus) {
+ return [];
+ }
+
+ return FILE_STATUS_GROUPS.flatMap(({ key }) => gitStatus[key] || []);
+}
+
+export function getChangedFileCount(gitStatus: GitStatusResponse | null): number {
+ return getAllChangedFiles(gitStatus).length;
+}
+
+export function hasChangedFiles(gitStatus: GitStatusResponse | null): boolean {
+ return getChangedFileCount(gitStatus) > 0;
+}
+
+export function getStatusLabel(status: FileStatusCode): string {
+ return FILE_STATUS_LABELS[status] || status;
+}
+
+export function getStatusBadgeClass(status: FileStatusCode): string {
+ return FILE_STATUS_BADGE_CLASSES[status] || FILE_STATUS_BADGE_CLASSES.U;
+}
diff --git a/src/components/git-panel/view/GitPanel.tsx b/src/components/git-panel/view/GitPanel.tsx
new file mode 100644
index 0000000..3560b12
--- /dev/null
+++ b/src/components/git-panel/view/GitPanel.tsx
@@ -0,0 +1,150 @@
+import { useCallback, useState } from 'react';
+import { useGitPanelController } from '../hooks/useGitPanelController';
+import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
+import ChangesView from '../view/changes/ChangesView';
+import HistoryView from '../view/history/HistoryView';
+import GitPanelHeader from '../view/GitPanelHeader';
+import GitRepositoryErrorState from '../view/GitRepositoryErrorState';
+import GitViewTabs from '../view/GitViewTabs';
+import ConfirmActionModal from '../view/modals/ConfirmActionModal';
+
+export default function GitPanel({ selectedProject, isMobile = false, onFileOpen }: GitPanelProps) {
+ const [activeView, setActiveView] = useState('changes');
+ const [wrapText, setWrapText] = useState(true);
+ const [hasExpandedFiles, setHasExpandedFiles] = useState(false);
+ const [confirmAction, setConfirmAction] = useState(null);
+
+ const {
+ gitStatus,
+ gitDiff,
+ isLoading,
+ currentBranch,
+ branches,
+ recentCommits,
+ commitDiffs,
+ remoteStatus,
+ isCreatingBranch,
+ isFetching,
+ isPulling,
+ isPushing,
+ isPublishing,
+ isCreatingInitialCommit,
+ refreshAll,
+ switchBranch,
+ createBranch,
+ handleFetch,
+ handlePull,
+ handlePush,
+ handlePublish,
+ discardChanges,
+ deleteUntrackedFile,
+ fetchCommitDiff,
+ generateCommitMessage,
+ commitChanges,
+ createInitialCommit,
+ openFile,
+ } = useGitPanelController({
+ selectedProject,
+ activeView,
+ onFileOpen,
+ });
+
+ const executeConfirmedAction = useCallback(async () => {
+ if (!confirmAction) {
+ return;
+ }
+
+ const actionToExecute = confirmAction;
+ setConfirmAction(null);
+
+ try {
+ await actionToExecute.onConfirm();
+ } catch (error) {
+ console.error('Error executing confirmation action:', error);
+ }
+ }, [confirmAction]);
+
+ if (!selectedProject) {
+ return (
+
+
Select a project to view source control
+
+ );
+ }
+
+ return (
+
+
+
+ {gitStatus?.error ? (
+
+ ) : (
+ <>
+
+
+ {activeView === 'changes' && (
+
+ )}
+
+ {activeView === 'history' && (
+
+ )}
+ >
+ )}
+
+ setConfirmAction(null)}
+ onConfirm={() => {
+ void executeConfirmedAction();
+ }}
+ />
+
+ );
+}
diff --git a/src/components/git-panel/view/GitPanelHeader.tsx b/src/components/git-panel/view/GitPanelHeader.tsx
new file mode 100644
index 0000000..127612b
--- /dev/null
+++ b/src/components/git-panel/view/GitPanelHeader.tsx
@@ -0,0 +1,251 @@
+import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, Upload } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
+import NewBranchModal from './modals/NewBranchModal';
+
+type GitPanelHeaderProps = {
+ isMobile: boolean;
+ currentBranch: string;
+ branches: string[];
+ remoteStatus: GitRemoteStatus | null;
+ isLoading: boolean;
+ isCreatingBranch: boolean;
+ isFetching: boolean;
+ isPulling: boolean;
+ isPushing: boolean;
+ isPublishing: boolean;
+ onRefresh: () => void;
+ onSwitchBranch: (branchName: string) => Promise;
+ onCreateBranch: (branchName: string) => Promise;
+ onFetch: () => Promise;
+ onPull: () => Promise;
+ onPush: () => Promise;
+ onPublish: () => Promise;
+ onRequestConfirmation: (request: ConfirmationRequest) => void;
+};
+
+export default function GitPanelHeader({
+ isMobile,
+ currentBranch,
+ branches,
+ remoteStatus,
+ isLoading,
+ isCreatingBranch,
+ isFetching,
+ isPulling,
+ isPushing,
+ isPublishing,
+ onRefresh,
+ onSwitchBranch,
+ onCreateBranch,
+ onFetch,
+ onPull,
+ onPush,
+ onPublish,
+ onRequestConfirmation,
+}: GitPanelHeaderProps) {
+ const [showBranchDropdown, setShowBranchDropdown] = useState(false);
+ const [showNewBranchModal, setShowNewBranchModal] = useState(false);
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setShowBranchDropdown(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const aheadCount = remoteStatus?.ahead || 0;
+ const behindCount = remoteStatus?.behind || 0;
+ const remoteName = remoteStatus?.remoteName || 'remote';
+ const shouldShowFetchButton = aheadCount > 0 || (behindCount > 0 && aheadCount > 0);
+
+ const requestPullConfirmation = () => {
+ onRequestConfirmation({
+ type: 'pull',
+ message: `Pull ${behindCount} commit${behindCount !== 1 ? 's' : ''} from ${remoteName}?`,
+ onConfirm: onPull,
+ });
+ };
+
+ const requestPushConfirmation = () => {
+ onRequestConfirmation({
+ type: 'push',
+ message: `Push ${aheadCount} commit${aheadCount !== 1 ? 's' : ''} to ${remoteName}?`,
+ onConfirm: onPush,
+ });
+ };
+
+ const requestPublishConfirmation = () => {
+ onRequestConfirmation({
+ type: 'publish',
+ message: `Publish branch "${currentBranch}" to ${remoteName}?`,
+ onConfirm: onPublish,
+ });
+ };
+
+ const handleSwitchBranch = async (branchName: string) => {
+ const success = await onSwitchBranch(branchName);
+ if (success) {
+ setShowBranchDropdown(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
setShowBranchDropdown((previous) => !previous)}
+ className={`flex items-center hover:bg-accent rounded-lg transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
+ >
+
+
+ {currentBranch}
+ {remoteStatus?.hasRemote && (
+
+ {aheadCount > 0 && (
+
+ {'\u2191'}
+ {aheadCount}
+
+ )}
+ {behindCount > 0 && (
+
+ {'\u2193'}
+ {behindCount}
+
+ )}
+ {remoteStatus.isUpToDate && (
+
+ {'\u2713'}
+
+ )}
+
+ )}
+
+
+
+
+ {showBranchDropdown && (
+
+
+ {branches.map((branch) => (
+ void handleSwitchBranch(branch)}
+ className={`w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors ${
+ branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground'
+ }`}
+ >
+
+ {branch === currentBranch && }
+ {branch}
+
+
+ ))}
+
+
+
{
+ setShowNewBranchModal(true);
+ setShowBranchDropdown(false);
+ }}
+ className="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors flex items-center space-x-2"
+ >
+
+ Create new branch
+
+
+
+ )}
+
+
+
+ {remoteStatus?.hasRemote && (
+ <>
+ {!remoteStatus.hasUpstream && (
+
+
+ {isPublishing ? 'Publishing...' : 'Publish'}
+
+ )}
+
+ {remoteStatus.hasUpstream && !remoteStatus.isUpToDate && (
+ <>
+ {behindCount > 0 && (
+
+
+ {isPulling ? 'Pulling...' : `Pull ${behindCount}`}
+
+ )}
+
+ {aheadCount > 0 && (
+
+
+ {isPushing ? 'Pushing...' : `Push ${aheadCount}`}
+
+ )}
+
+ {shouldShowFetchButton && (
+ void onFetch()}
+ disabled={isFetching}
+ className="px-2.5 py-1 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-1 transition-colors"
+ title={`Fetch from ${remoteName}`}
+ >
+
+ {isFetching ? 'Fetching...' : 'Fetch'}
+
+ )}
+ >
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+ setShowNewBranchModal(false)}
+ onCreateBranch={onCreateBranch}
+ />
+ >
+ );
+}
diff --git a/src/components/git-panel/view/GitRepositoryErrorState.tsx b/src/components/git-panel/view/GitRepositoryErrorState.tsx
new file mode 100644
index 0000000..be1e22d
--- /dev/null
+++ b/src/components/git-panel/view/GitRepositoryErrorState.tsx
@@ -0,0 +1,27 @@
+import { GitBranch } from 'lucide-react';
+
+type GitRepositoryErrorStateProps = {
+ error: string;
+ details?: string;
+};
+
+export default function GitRepositoryErrorState({ error, details }: GitRepositoryErrorStateProps) {
+ return (
+
+
+
+
+
{error}
+ {details && (
+
{details}
+ )}
+
+
+ Tip: Run{' '}
+ git init{' '}
+ in your project directory to initialize git source control.
+
+
+
+ );
+}
diff --git a/src/components/git-panel/view/GitViewTabs.tsx b/src/components/git-panel/view/GitViewTabs.tsx
new file mode 100644
index 0000000..004f750
--- /dev/null
+++ b/src/components/git-panel/view/GitViewTabs.tsx
@@ -0,0 +1,45 @@
+import { FileText, History } from 'lucide-react';
+import type { GitPanelView } from '../types/types';
+
+type GitViewTabsProps = {
+ activeView: GitPanelView;
+ isHidden: boolean;
+ onChange: (view: GitPanelView) => void;
+};
+
+export default function GitViewTabs({ activeView, isHidden, onChange }: GitViewTabsProps) {
+ return (
+
+ onChange('changes')}
+ className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
+ activeView === 'changes'
+ ? 'text-primary border-b-2 border-primary'
+ : 'text-muted-foreground hover:text-foreground'
+ }`}
+ >
+
+
+ Changes
+
+
+ onChange('history')}
+ className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
+ activeView === 'history'
+ ? 'text-primary border-b-2 border-primary'
+ : 'text-muted-foreground hover:text-foreground'
+ }`}
+ >
+
+
+ History
+
+
+
+ );
+}
diff --git a/src/components/git-panel/view/changes/ChangesView.tsx b/src/components/git-panel/view/changes/ChangesView.tsx
new file mode 100644
index 0000000..807215a
--- /dev/null
+++ b/src/components/git-panel/view/changes/ChangesView.tsx
@@ -0,0 +1,213 @@
+import { GitBranch, GitCommit, RefreshCw } from 'lucide-react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { ConfirmationRequest, FileStatusCode, GitDiffMap, GitStatusResponse } from '../../types/types';
+import { getAllChangedFiles, hasChangedFiles } from '../../utils/gitPanelUtils';
+import CommitComposer from './CommitComposer';
+import FileChangeList from './FileChangeList';
+import FileSelectionControls from './FileSelectionControls';
+import FileStatusLegend from './FileStatusLegend';
+
+type ChangesViewProps = {
+ isMobile: boolean;
+ gitStatus: GitStatusResponse | null;
+ gitDiff: GitDiffMap;
+ isLoading: boolean;
+ wrapText: boolean;
+ isCreatingInitialCommit: boolean;
+ onWrapTextChange: (wrapText: boolean) => void;
+ onCreateInitialCommit: () => Promise;
+ onOpenFile: (filePath: string) => Promise;
+ onDiscardFile: (filePath: string) => Promise;
+ onDeleteFile: (filePath: string) => Promise;
+ onCommitChanges: (message: string, files: string[]) => Promise;
+ onGenerateCommitMessage: (files: string[]) => Promise;
+ onRequestConfirmation: (request: ConfirmationRequest) => void;
+ onExpandedFilesChange: (hasExpandedFiles: boolean) => void;
+};
+
+export default function ChangesView({
+ isMobile,
+ gitStatus,
+ gitDiff,
+ isLoading,
+ wrapText,
+ isCreatingInitialCommit,
+ onWrapTextChange,
+ onCreateInitialCommit,
+ onOpenFile,
+ onDiscardFile,
+ onDeleteFile,
+ onCommitChanges,
+ onGenerateCommitMessage,
+ onRequestConfirmation,
+ onExpandedFilesChange,
+}: ChangesViewProps) {
+ const [expandedFiles, setExpandedFiles] = useState>(new Set());
+ const [selectedFiles, setSelectedFiles] = useState>(new Set());
+
+ const changedFiles = useMemo(() => getAllChangedFiles(gitStatus), [gitStatus]);
+ const hasExpandedFiles = expandedFiles.size > 0;
+
+ useEffect(() => {
+ if (!gitStatus || gitStatus.error) {
+ setSelectedFiles(new Set());
+ return;
+ }
+
+ // Preserve previous behavior: every fresh status snapshot reselects changed files.
+ setSelectedFiles(new Set(getAllChangedFiles(gitStatus)));
+ }, [gitStatus]);
+
+ useEffect(() => {
+ onExpandedFilesChange(hasExpandedFiles);
+ }, [hasExpandedFiles, onExpandedFilesChange]);
+
+ useEffect(() => {
+ return () => {
+ onExpandedFilesChange(false);
+ };
+ }, [onExpandedFilesChange]);
+
+ const toggleFileExpanded = useCallback((filePath: string) => {
+ setExpandedFiles((previous) => {
+ const next = new Set(previous);
+ if (next.has(filePath)) {
+ next.delete(filePath);
+ } else {
+ next.add(filePath);
+ }
+ return next;
+ });
+ }, []);
+
+ const toggleFileSelected = useCallback((filePath: string) => {
+ setSelectedFiles((previous) => {
+ const next = new Set(previous);
+ if (next.has(filePath)) {
+ next.delete(filePath);
+ } else {
+ next.add(filePath);
+ }
+ return next;
+ });
+ }, []);
+
+ const requestFileAction = useCallback(
+ (filePath: string, status: FileStatusCode) => {
+ if (status === 'U') {
+ onRequestConfirmation({
+ type: 'delete',
+ message: `Delete untracked file "${filePath}"? This action cannot be undone.`,
+ onConfirm: async () => {
+ await onDeleteFile(filePath);
+ },
+ });
+ return;
+ }
+
+ onRequestConfirmation({
+ type: 'discard',
+ message: `Discard all changes to "${filePath}"? This action cannot be undone.`,
+ onConfirm: async () => {
+ await onDiscardFile(filePath);
+ },
+ });
+ },
+ [onDeleteFile, onDiscardFile, onRequestConfirmation],
+ );
+
+ const commitSelectedFiles = useCallback(
+ (message: string) => {
+ return onCommitChanges(message, Array.from(selectedFiles));
+ },
+ [onCommitChanges, selectedFiles],
+ );
+
+ const generateMessageForSelection = useCallback(() => {
+ return onGenerateCommitMessage(Array.from(selectedFiles));
+ }, [onGenerateCommitMessage, selectedFiles]);
+
+ return (
+ <>
+
+
+ {gitStatus && !gitStatus.error && (
+ setSelectedFiles(new Set(changedFiles))}
+ onDeselectAll={() => setSelectedFiles(new Set())}
+ />
+ )}
+
+ {!gitStatus?.error && }
+
+
+ {isLoading ? (
+
+
+
+ ) : gitStatus?.hasCommits === false ? (
+
+
+
+
+
No commits yet
+
+ This repository doesn't have any commits yet. Create your first commit to start tracking changes.
+
+
void onCreateInitialCommit()}
+ disabled={isCreatingInitialCommit}
+ className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition-colors"
+ >
+ {isCreatingInitialCommit ? (
+ <>
+
+ Creating Initial Commit...
+ >
+ ) : (
+ <>
+
+ Create Initial Commit
+ >
+ )}
+
+
+ ) : !gitStatus || !hasChangedFiles(gitStatus) ? (
+
+
+
No changes detected
+
+ ) : (
+
+ {
+ void onOpenFile(filePath);
+ }}
+ onToggleWrapText={() => onWrapTextChange(!wrapText)}
+ onRequestFileAction={requestFileAction}
+ />
+
+ )}
+
+ >
+ );
+}
diff --git a/src/components/git-panel/view/changes/CommitComposer.tsx b/src/components/git-panel/view/changes/CommitComposer.tsx
new file mode 100644
index 0000000..7fe27cb
--- /dev/null
+++ b/src/components/git-panel/view/changes/CommitComposer.tsx
@@ -0,0 +1,177 @@
+import { Check, ChevronDown, GitCommit, RefreshCw, Sparkles } from 'lucide-react';
+import { useState } from 'react';
+import { MicButton } from '../../../MicButton.jsx';
+import type { ConfirmationRequest } from '../../types/types';
+
+type MicButtonProps = {
+ onTranscript?: (transcript: string) => void;
+ className?: string;
+ mode?: string;
+};
+
+const MicButtonComponent = MicButton as unknown as (props: MicButtonProps) => JSX.Element;
+
+type CommitComposerProps = {
+ isMobile: boolean;
+ selectedFileCount: number;
+ isHidden: boolean;
+ onCommit: (message: string) => Promise;
+ onGenerateMessage: () => Promise;
+ onRequestConfirmation: (request: ConfirmationRequest) => void;
+};
+
+export default function CommitComposer({
+ isMobile,
+ selectedFileCount,
+ isHidden,
+ onCommit,
+ onGenerateMessage,
+ onRequestConfirmation,
+}: CommitComposerProps) {
+ const [commitMessage, setCommitMessage] = useState('');
+ const [isCommitting, setIsCommitting] = useState(false);
+ const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
+ const [isCollapsed, setIsCollapsed] = useState(isMobile);
+
+ const handleCommit = async () => {
+ if (!commitMessage.trim() || selectedFileCount === 0) {
+ return false;
+ }
+
+ setIsCommitting(true);
+ try {
+ const success = await onCommit(commitMessage);
+ if (success) {
+ setCommitMessage('');
+ }
+ return success;
+ } finally {
+ setIsCommitting(false);
+ }
+ };
+
+ const handleGenerateMessage = async () => {
+ if (selectedFileCount === 0 || isGeneratingMessage) {
+ return;
+ }
+
+ setIsGeneratingMessage(true);
+ try {
+ const generatedMessage = await onGenerateMessage();
+ if (generatedMessage) {
+ setCommitMessage(generatedMessage);
+ }
+ } finally {
+ setIsGeneratingMessage(false);
+ }
+ };
+
+ const requestCommitConfirmation = () => {
+ const trimmedMessage = commitMessage.trim();
+ if (!trimmedMessage || selectedFileCount === 0 || isCommitting) {
+ return;
+ }
+
+ onRequestConfirmation({
+ type: 'commit',
+ message: `Commit ${selectedFileCount} file${selectedFileCount !== 1 ? 's' : ''} with message: "${trimmedMessage}"?`,
+ onConfirm: async () => {
+ setIsCommitting(true);
+ try {
+ const success = await onCommit(commitMessage);
+ if (success) {
+ setCommitMessage('');
+ }
+ } finally {
+ setIsCommitting(false);
+ }
+ },
+ });
+ };
+
+ return (
+
+ {isMobile && isCollapsed ? (
+
+ setIsCollapsed(false)}
+ className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
+ >
+
+ Commit {selectedFileCount} file{selectedFileCount !== 1 ? 's' : ''}
+
+
+
+ ) : (
+
+ {isMobile && (
+
+ Commit Changes
+ setIsCollapsed(true)}
+ className="p-1 hover:bg-accent rounded-lg transition-colors"
+ >
+
+
+
+ )}
+
+
+
+
+
+ {selectedFileCount} file{selectedFileCount !== 1 ? 's' : ''} selected
+
+
+
+ {isCommitting ? 'Committing...' : 'Commit'}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/git-panel/view/changes/FileChangeItem.tsx b/src/components/git-panel/view/changes/FileChangeItem.tsx
new file mode 100644
index 0000000..2d4bac4
--- /dev/null
+++ b/src/components/git-panel/view/changes/FileChangeItem.tsx
@@ -0,0 +1,138 @@
+import { ChevronRight, Trash2 } from 'lucide-react';
+import DiffViewer from '../../../DiffViewer.jsx';
+import type { FileStatusCode } from '../../types/types';
+import { getStatusBadgeClass, getStatusLabel } from '../../utils/gitPanelUtils';
+
+type DiffViewerProps = {
+ diff: string;
+ fileName: string;
+ isMobile: boolean;
+ wrapText: boolean;
+};
+
+const DiffViewerComponent = DiffViewer as unknown as (props: DiffViewerProps) => JSX.Element;
+
+type FileChangeItemProps = {
+ filePath: string;
+ status: FileStatusCode;
+ isMobile: boolean;
+ isExpanded: boolean;
+ isSelected: boolean;
+ diff?: string;
+ wrapText: boolean;
+ onToggleSelected: (filePath: string) => void;
+ onToggleExpanded: (filePath: string) => void;
+ onOpenFile: (filePath: string) => void;
+ onToggleWrapText: () => void;
+ onRequestFileAction: (filePath: string, status: FileStatusCode) => void;
+};
+
+export default function FileChangeItem({
+ filePath,
+ status,
+ isMobile,
+ isExpanded,
+ isSelected,
+ diff,
+ wrapText,
+ onToggleSelected,
+ onToggleExpanded,
+ onOpenFile,
+ onToggleWrapText,
+ onRequestFileAction,
+}: FileChangeItemProps) {
+ const statusLabel = getStatusLabel(status);
+ const badgeClass = getStatusBadgeClass(status);
+
+ return (
+
+
+
onToggleSelected(filePath)}
+ onClick={(event) => event.stopPropagation()}
+ className={`rounded border-border text-primary focus:ring-primary/40 bg-background checked:bg-primary ${isMobile ? 'mr-1.5' : 'mr-2'}`}
+ />
+
+
+ {
+ event.stopPropagation();
+ onToggleExpanded(filePath);
+ }}
+ className={`p-0.5 hover:bg-accent rounded cursor-pointer ${isMobile ? 'mr-1' : 'mr-2'}`}
+ title={isExpanded ? 'Collapse diff' : 'Expand diff'}
+ >
+
+
+
+ {
+ event.stopPropagation();
+ onOpenFile(filePath);
+ }}
+ title="Click to open file"
+ >
+ {filePath}
+
+
+
+ {(status === 'M' || status === 'D' || status === 'U') && (
+ {
+ event.stopPropagation();
+ onRequestFileAction(filePath, status);
+ }}
+ className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-destructive/10 rounded text-destructive font-medium flex items-center gap-1`}
+ title={status === 'U' ? 'Delete untracked file' : 'Discard changes'}
+ >
+
+ {isMobile && {status === 'U' ? 'Delete' : 'Discard'} }
+
+ )}
+
+
+ {status}
+
+
+
+
+
+
+
+
+
+ {status}
+
+ {statusLabel}
+
+ {isMobile && (
+ {
+ event.stopPropagation();
+ onToggleWrapText();
+ }}
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+ title={wrapText ? 'Switch to horizontal scroll' : 'Switch to text wrap'}
+ >
+ {wrapText ? 'Scroll' : 'Wrap'}
+
+ )}
+
+
+
+ {diff && }
+
+
+
+ );
+}
diff --git a/src/components/git-panel/view/changes/FileChangeList.tsx b/src/components/git-panel/view/changes/FileChangeList.tsx
new file mode 100644
index 0000000..0438382
--- /dev/null
+++ b/src/components/git-panel/view/changes/FileChangeList.tsx
@@ -0,0 +1,55 @@
+import { FILE_STATUS_GROUPS } from '../../constants/constants';
+import type { FileStatusCode, GitDiffMap, GitStatusResponse } from '../../types/types';
+import FileChangeItem from './FileChangeItem';
+
+type FileChangeListProps = {
+ gitStatus: GitStatusResponse;
+ gitDiff: GitDiffMap;
+ expandedFiles: Set;
+ selectedFiles: Set;
+ isMobile: boolean;
+ wrapText: boolean;
+ onToggleSelected: (filePath: string) => void;
+ onToggleExpanded: (filePath: string) => void;
+ onOpenFile: (filePath: string) => void;
+ onToggleWrapText: () => void;
+ onRequestFileAction: (filePath: string, status: FileStatusCode) => void;
+};
+
+export default function FileChangeList({
+ gitStatus,
+ gitDiff,
+ expandedFiles,
+ selectedFiles,
+ isMobile,
+ wrapText,
+ onToggleSelected,
+ onToggleExpanded,
+ onOpenFile,
+ onToggleWrapText,
+ onRequestFileAction,
+}: FileChangeListProps) {
+ return (
+ <>
+ {FILE_STATUS_GROUPS.map(({ key, status }) =>
+ (gitStatus[key] || []).map((filePath) => (
+
+ )),
+ )}
+ >
+ );
+}
diff --git a/src/components/git-panel/view/changes/FileSelectionControls.tsx b/src/components/git-panel/view/changes/FileSelectionControls.tsx
new file mode 100644
index 0000000..ed8f9a3
--- /dev/null
+++ b/src/components/git-panel/view/changes/FileSelectionControls.tsx
@@ -0,0 +1,44 @@
+type FileSelectionControlsProps = {
+ isMobile: boolean;
+ selectedCount: number;
+ totalCount: number;
+ isHidden: boolean;
+ onSelectAll: () => void;
+ onDeselectAll: () => void;
+};
+
+export default function FileSelectionControls({
+ isMobile,
+ selectedCount,
+ totalCount,
+ isHidden,
+ onSelectAll,
+ onDeselectAll,
+}: FileSelectionControlsProps) {
+ return (
+
+
+ {selectedCount} of {totalCount} {isMobile ? '' : 'files'} selected
+
+
+
+ {isMobile ? 'All' : 'Select All'}
+
+ |
+
+ {isMobile ? 'None' : 'Deselect All'}
+
+
+
+ );
+}
diff --git a/src/components/git-panel/view/changes/FileStatusLegend.tsx b/src/components/git-panel/view/changes/FileStatusLegend.tsx
new file mode 100644
index 0000000..c2b3292
--- /dev/null
+++ b/src/components/git-panel/view/changes/FileStatusLegend.tsx
@@ -0,0 +1,52 @@
+import { ChevronDown, ChevronRight, Info } from 'lucide-react';
+import { useState } from 'react';
+import { getStatusBadgeClass } from '../../utils/gitPanelUtils';
+
+type FileStatusLegendProps = {
+ isMobile: boolean;
+};
+
+const LEGEND_ITEMS = [
+ { status: 'M', label: 'Modified' },
+ { status: 'A', label: 'Added' },
+ { status: 'D', label: 'Deleted' },
+ { status: 'U', label: 'Untracked' },
+] as const;
+
+export default function FileStatusLegend({ isMobile }: FileStatusLegendProps) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ if (isMobile) {
+ return null;
+ }
+
+ return (
+
+
setIsOpen((previous) => !previous)}
+ className="w-full px-4 py-2 bg-muted/30 hover:bg-muted/50 text-sm text-muted-foreground flex items-center justify-center gap-1 transition-colors"
+ >
+
+ File Status Guide
+ {isOpen ? : }
+
+
+ {isOpen && (
+
+
+ {LEGEND_ITEMS.map((item) => (
+
+
+ {item.status}
+
+ {item.label}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/git-panel/view/history/CommitHistoryItem.tsx b/src/components/git-panel/view/history/CommitHistoryItem.tsx
new file mode 100644
index 0000000..3c84cef
--- /dev/null
+++ b/src/components/git-panel/view/history/CommitHistoryItem.tsx
@@ -0,0 +1,69 @@
+import { ChevronDown, ChevronRight } from 'lucide-react';
+import DiffViewer from '../../../DiffViewer.jsx';
+import type { GitCommitSummary } from '../../types/types';
+
+type DiffViewerProps = {
+ diff: string;
+ fileName: string;
+ isMobile: boolean;
+ wrapText: boolean;
+};
+
+const DiffViewerComponent = DiffViewer as unknown as (props: DiffViewerProps) => JSX.Element;
+
+type CommitHistoryItemProps = {
+ commit: GitCommitSummary;
+ isExpanded: boolean;
+ diff?: string;
+ isMobile: boolean;
+ wrapText: boolean;
+ onToggle: () => void;
+};
+
+export default function CommitHistoryItem({
+ commit,
+ isExpanded,
+ diff,
+ isMobile,
+ wrapText,
+ onToggle,
+}: CommitHistoryItemProps) {
+ return (
+
+
+
+ {isExpanded ? : }
+
+
+
+
+
{commit.message}
+
+ {commit.author}
+ {' \u2022 '}
+ {commit.date}
+
+
+
+ {commit.hash.substring(0, 7)}
+
+
+
+
+
+ {isExpanded && diff && (
+
+ )}
+
+ );
+}
diff --git a/src/components/git-panel/view/history/HistoryView.tsx b/src/components/git-panel/view/history/HistoryView.tsx
new file mode 100644
index 0000000..62ffaa6
--- /dev/null
+++ b/src/components/git-panel/view/history/HistoryView.tsx
@@ -0,0 +1,72 @@
+import { History, RefreshCw } from 'lucide-react';
+import { useCallback, useState } from 'react';
+import type { GitDiffMap, GitCommitSummary } from '../../types/types';
+import CommitHistoryItem from './CommitHistoryItem';
+
+type HistoryViewProps = {
+ isMobile: boolean;
+ isLoading: boolean;
+ recentCommits: GitCommitSummary[];
+ commitDiffs: GitDiffMap;
+ wrapText: boolean;
+ onFetchCommitDiff: (commitHash: string) => Promise;
+};
+
+export default function HistoryView({
+ isMobile,
+ isLoading,
+ recentCommits,
+ commitDiffs,
+ wrapText,
+ onFetchCommitDiff,
+}: HistoryViewProps) {
+ const [expandedCommits, setExpandedCommits] = useState>(new Set());
+
+ const toggleCommitExpanded = useCallback(
+ (commitHash: string) => {
+ setExpandedCommits((previous) => {
+ const next = new Set(previous);
+ if (next.has(commitHash)) {
+ next.delete(commitHash);
+ } else {
+ next.add(commitHash);
+ // Load commit diff lazily only the first time a commit is expanded.
+ if (!commitDiffs[commitHash]) {
+ void onFetchCommitDiff(commitHash);
+ }
+ }
+ return next;
+ });
+ },
+ [commitDiffs, onFetchCommitDiff],
+ );
+
+ return (
+
+ {isLoading ? (
+
+
+
+ ) : recentCommits.length === 0 ? (
+
+ ) : (
+
+ {recentCommits.map((commit) => (
+ toggleCommitExpanded(commit.hash)}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/git-panel/view/modals/ConfirmActionModal.tsx b/src/components/git-panel/view/modals/ConfirmActionModal.tsx
new file mode 100644
index 0000000..21e3a8d
--- /dev/null
+++ b/src/components/git-panel/view/modals/ConfirmActionModal.tsx
@@ -0,0 +1,71 @@
+import { AlertTriangle, Check, Download, Trash2, Upload } from 'lucide-react';
+import {
+ CONFIRMATION_ACTION_LABELS,
+ CONFIRMATION_BUTTON_CLASSES,
+ CONFIRMATION_ICON_CLASSES,
+ CONFIRMATION_ICON_CONTAINER_CLASSES,
+ CONFIRMATION_TITLES,
+} from '../../constants/constants';
+import type { ConfirmationRequest } from '../../types/types';
+
+type ConfirmActionModalProps = {
+ action: ConfirmationRequest | null;
+ onCancel: () => void;
+ onConfirm: () => void;
+};
+
+function renderConfirmActionIcon(actionType: ConfirmationRequest['type']) {
+ if (actionType === 'discard' || actionType === 'delete') {
+ return ;
+ }
+
+ if (actionType === 'commit') {
+ return ;
+ }
+
+ if (actionType === 'pull') {
+ return ;
+ }
+
+ return ;
+}
+
+export default function ConfirmActionModal({ action, onCancel, onConfirm }: ConfirmActionModalProps) {
+ if (!action) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ {renderConfirmActionIcon(action.type)}
+
+
{CONFIRMATION_TITLES[action.type]}
+
+
+
{action.message}
+
+
+
+ Cancel
+
+
+ {renderConfirmActionIcon(action.type)}
+ {CONFIRMATION_ACTION_LABELS[action.type]}
+
+
+
+
+
+ );
+}
diff --git a/src/components/git-panel/view/modals/NewBranchModal.tsx b/src/components/git-panel/view/modals/NewBranchModal.tsx
new file mode 100644
index 0000000..7af1df8
--- /dev/null
+++ b/src/components/git-panel/view/modals/NewBranchModal.tsx
@@ -0,0 +1,104 @@
+import { Plus, RefreshCw } from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+type NewBranchModalProps = {
+ isOpen: boolean;
+ currentBranch: string;
+ isCreatingBranch: boolean;
+ onClose: () => void;
+ onCreateBranch: (branchName: string) => Promise;
+};
+
+export default function NewBranchModal({
+ isOpen,
+ currentBranch,
+ isCreatingBranch,
+ onClose,
+ onCreateBranch,
+}: NewBranchModalProps) {
+ const [newBranchName, setNewBranchName] = useState('');
+
+ useEffect(() => {
+ if (!isOpen) {
+ setNewBranchName('');
+ }
+ }, [isOpen]);
+
+ const handleCreateBranch = async () => {
+ const branchName = newBranchName.trim();
+ if (!branchName) {
+ return;
+ }
+
+ const success = await onCreateBranch(branchName);
+ if (success) {
+ setNewBranchName('');
+ onClose();
+ }
+ };
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
Create New Branch
+
+
+
+ Branch Name
+
+ setNewBranchName(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' && !isCreatingBranch) {
+ void handleCreateBranch();
+ }
+ }}
+ placeholder="feature/new-feature"
+ className="w-full px-3 py-2 border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
+ autoFocus
+ />
+
+
+
+ This will create a new branch from the current branch ({currentBranch})
+
+
+
+
+ Cancel
+
+
void handleCreateBranch()}
+ disabled={!newBranchName.trim() || isCreatingBranch}
+ className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2 transition-colors"
+ >
+ {isCreatingBranch ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ <>
+
+ Create Branch
+ >
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx
index a6839de..7fd7f43 100644
--- a/src/components/main-content/view/MainContent.tsx
+++ b/src/components/main-content/view/MainContent.tsx
@@ -3,7 +3,7 @@ import React, { useEffect } from 'react';
import ChatInterface from '../../chat/view/ChatInterface';
import FileTree from '../../file-tree/FileTree';
import StandaloneShell from '../../StandaloneShell';
-import GitPanel from '../../GitPanel';
+import GitPanel from '../../git-panel/view/GitPanel';
import ErrorBoundary from '../../ErrorBoundary';
import MainContentHeader from './subcomponents/MainContentHeader';
@@ -19,7 +19,6 @@ import { useEditorSidebar } from '../hooks/useEditorSidebar';
import type { Project } from '../../../types/app';
const AnyStandaloneShell = StandaloneShell as any;
-const AnyGitPanel = GitPanel as any;
type TaskMasterContextValue = {
currentProject?: Project | null;
@@ -154,7 +153,7 @@ function MainContent({
{activeTab === 'git' && (
)}