Refactor Settings, FileTree, GitPanel, Shell, and CodeEditor components (#402)

This commit is contained in:
Haileyesus
2026-02-25 19:07:07 +03:00
committed by GitHub
parent 23801e9cc1
commit 5e3a7b69d7
149 changed files with 11627 additions and 8453 deletions

View File

@@ -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<GitPanelView>('changes');
const [wrapText, setWrapText] = useState(true);
const [hasExpandedFiles, setHasExpandedFiles] = useState(false);
const [confirmAction, setConfirmAction] = useState<ConfirmationRequest | null>(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 (
<div className="h-full flex items-center justify-center text-muted-foreground">
<p>Select a project to view source control</p>
</div>
);
}
return (
<div className="h-full flex flex-col bg-background">
<GitPanelHeader
isMobile={isMobile}
currentBranch={currentBranch}
branches={branches}
remoteStatus={remoteStatus}
isLoading={isLoading}
isCreatingBranch={isCreatingBranch}
isFetching={isFetching}
isPulling={isPulling}
isPushing={isPushing}
isPublishing={isPublishing}
onRefresh={refreshAll}
onSwitchBranch={switchBranch}
onCreateBranch={createBranch}
onFetch={handleFetch}
onPull={handlePull}
onPush={handlePush}
onPublish={handlePublish}
onRequestConfirmation={setConfirmAction}
/>
{gitStatus?.error ? (
<GitRepositoryErrorState error={gitStatus.error} details={gitStatus.details} />
) : (
<>
<GitViewTabs
activeView={activeView}
isHidden={hasExpandedFiles}
onChange={setActiveView}
/>
{activeView === 'changes' && (
<ChangesView
isMobile={isMobile}
gitStatus={gitStatus}
gitDiff={gitDiff}
isLoading={isLoading}
wrapText={wrapText}
isCreatingInitialCommit={isCreatingInitialCommit}
onWrapTextChange={setWrapText}
onCreateInitialCommit={createInitialCommit}
onOpenFile={openFile}
onDiscardFile={discardChanges}
onDeleteFile={deleteUntrackedFile}
onCommitChanges={commitChanges}
onGenerateCommitMessage={generateCommitMessage}
onRequestConfirmation={setConfirmAction}
onExpandedFilesChange={setHasExpandedFiles}
/>
)}
{activeView === 'history' && (
<HistoryView
isMobile={isMobile}
isLoading={isLoading}
recentCommits={recentCommits}
commitDiffs={commitDiffs}
wrapText={wrapText}
onFetchCommitDiff={fetchCommitDiff}
/>
)}
</>
)}
<ConfirmActionModal
action={confirmAction}
onCancel={() => setConfirmAction(null)}
onConfirm={() => {
void executeConfirmedAction();
}}
/>
</div>
);
}

View File

@@ -0,0 +1,263 @@
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<boolean>;
onCreateBranch: (branchName: string) => Promise<boolean>;
onFetch: () => Promise<void>;
onPull: () => Promise<void>;
onPush: () => Promise<void>;
onPublish: () => Promise<void>;
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<HTMLDivElement | null>(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;
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) => {
try {
const success = await onSwitchBranch(branchName);
if (success) {
setShowBranchDropdown(false);
}
} catch (error) {
console.error('[GitPanelHeader] Failed to switch branch:', error);
}
};
const handleFetch = async () => {
try {
await onFetch();
} catch (error) {
console.error('[GitPanelHeader] Failed to fetch remote changes:', error);
}
};
return (
<>
<div className={`flex items-center justify-between border-b border-border/60 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => 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'}`}
>
<GitBranch className={`text-muted-foreground ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
<span className="flex items-center gap-1">
<span className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{currentBranch}</span>
{remoteStatus?.hasRemote && (
<span className="flex items-center gap-1 text-xs">
{aheadCount > 0 && (
<span
className="text-green-600 dark:text-green-400"
title={`${aheadCount} commit${aheadCount !== 1 ? 's' : ''} ahead`}
>
{'\u2191'}
{aheadCount}
</span>
)}
{behindCount > 0 && (
<span
className="text-primary"
title={`${behindCount} commit${behindCount !== 1 ? 's' : ''} behind`}
>
{'\u2193'}
{behindCount}
</span>
)}
{remoteStatus.isUpToDate && (
<span className="text-muted-foreground" title="Up to date with remote">
{'\u2713'}
</span>
)}
</span>
)}
</span>
<ChevronDown className={`w-3 h-3 text-muted-foreground transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
</button>
{showBranchDropdown && (
<div className="absolute top-full left-0 mt-1 w-64 bg-card rounded-xl shadow-lg border border-border z-50 overflow-hidden">
<div className="py-1 max-h-64 overflow-y-auto">
{branches.map((branch) => (
<button
key={branch}
onClick={() => 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'
}`}
>
<span className="flex items-center space-x-2">
{branch === currentBranch && <Check className="w-3 h-3 text-primary" />}
<span className={branch === currentBranch ? 'font-medium' : ''}>{branch}</span>
</span>
</button>
))}
</div>
<div className="border-t border-border py-1">
<button
onClick={() => {
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"
>
<Plus className="w-3 h-3" />
<span>Create new branch</span>
</button>
</div>
</div>
)}
</div>
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>
{remoteStatus?.hasRemote && (
<>
{!remoteStatus.hasUpstream && (
<button
onClick={requestPublishConfirmation}
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 ${remoteName}`}
>
<Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} />
<span>{isPublishing ? 'Publishing...' : 'Publish'}</span>
</button>
)}
{remoteStatus.hasUpstream && !remoteStatus.isUpToDate && (
<>
{behindCount > 0 && (
<button
onClick={requestPullConfirmation}
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 ${behindCount} commit${behindCount !== 1 ? 's' : ''} from ${remoteName}`}
>
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
<span>{isPulling ? 'Pulling...' : `Pull ${behindCount}`}</span>
</button>
)}
{aheadCount > 0 && (
<button
onClick={requestPushConfirmation}
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 ${aheadCount} commit${aheadCount !== 1 ? 's' : ''} to ${remoteName}`}
>
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
<span>{isPushing ? 'Pushing...' : `Push ${aheadCount}`}</span>
</button>
)}
{shouldShowFetchButton && (
<button
onClick={() => void handleFetch()}
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}`}
>
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
</button>
)}
</>
)}
</>
)}
<button
onClick={onRefresh}
disabled={isLoading}
className={`hover:bg-accent rounded-lg transition-colors ${isMobile ? 'p-1' : 'p-1.5'}`}
title="Refresh git status"
>
<RefreshCw className={`text-muted-foreground ${isLoading ? 'animate-spin' : ''} ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
</button>
</div>
</div>
<NewBranchModal
isOpen={showNewBranchModal}
currentBranch={currentBranch}
isCreatingBranch={isCreatingBranch}
onClose={() => setShowNewBranchModal(false)}
onCreateBranch={onCreateBranch}
/>
</>
);
}

View File

@@ -0,0 +1,27 @@
import { GitBranch } from 'lucide-react';
type GitRepositoryErrorStateProps = {
error: string;
details?: string;
};
export default function GitRepositoryErrorState({ error, details }: GitRepositoryErrorStateProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground px-6 py-12">
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-6">
<GitBranch className="w-8 h-8 opacity-40" />
</div>
<h3 className="text-lg font-medium mb-3 text-center text-foreground">{error}</h3>
{details && (
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{details}</p>
)}
<div className="p-4 bg-primary/5 rounded-xl border border-primary/10 max-w-md">
<p className="text-sm text-primary text-center">
<strong>Tip:</strong> Run{' '}
<code className="bg-primary/10 px-2 py-1 rounded-md font-mono text-xs">git init</code>{' '}
in your project directory to initialize git source control.
</p>
</div>
</div>
);
}

View File

@@ -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 (
<div
className={`flex border-b border-border/60 transition-all duration-300 ease-in-out ${
isHidden ? 'max-h-0 opacity-0 -translate-y-2 overflow-hidden' : 'max-h-16 opacity-100 translate-y-0'
}`}
>
<button
onClick={() => 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'
}`}
>
<span className="flex items-center justify-center gap-2">
<FileText className="w-4 h-4" />
<span>Changes</span>
</span>
</button>
<button
onClick={() => 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'
}`}
>
<span className="flex items-center justify-center gap-2">
<History className="w-4 h-4" />
<span>History</span>
</span>
</button>
</div>
);
}

View File

@@ -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<boolean>;
onOpenFile: (filePath: string) => Promise<void>;
onDiscardFile: (filePath: string) => Promise<void>;
onDeleteFile: (filePath: string) => Promise<void>;
onCommitChanges: (message: string, files: string[]) => Promise<boolean>;
onGenerateCommitMessage: (files: string[]) => Promise<string | null>;
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<Set<string>>(new Set());
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(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 (
<>
<CommitComposer
isMobile={isMobile}
selectedFileCount={selectedFiles.size}
isHidden={hasExpandedFiles}
onCommit={commitSelectedFiles}
onGenerateMessage={generateMessageForSelection}
onRequestConfirmation={onRequestConfirmation}
/>
{gitStatus && !gitStatus.error && (
<FileSelectionControls
isMobile={isMobile}
selectedCount={selectedFiles.size}
totalCount={changedFiles.length}
isHidden={hasExpandedFiles}
onSelectAll={() => setSelectedFiles(new Set(changedFiles))}
onDeselectAll={() => setSelectedFiles(new Set())}
/>
)}
{!gitStatus?.error && <FileStatusLegend isMobile={isMobile} />}
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : gitStatus?.hasCommits === false ? (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="w-14 h-14 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<GitBranch className="w-7 h-7 text-muted-foreground/50" />
</div>
<h3 className="text-lg font-medium mb-2 text-foreground">No commits yet</h3>
<p className="text-sm text-muted-foreground mb-6 max-w-md">
This repository doesn&apos;t have any commits yet. Create your first commit to start tracking changes.
</p>
<button
onClick={() => 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 ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
<span>Creating Initial Commit...</span>
</>
) : (
<>
<GitCommit className="w-4 h-4" />
<span>Create Initial Commit</span>
</>
)}
</button>
</div>
) : !gitStatus || !hasChangedFiles(gitStatus) ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
<GitCommit className="w-10 h-10 mb-2 opacity-40" />
<p className="text-sm">No changes detected</p>
</div>
) : (
<div className={isMobile ? 'pb-4' : ''}>
<FileChangeList
gitStatus={gitStatus}
gitDiff={gitDiff}
expandedFiles={expandedFiles}
selectedFiles={selectedFiles}
isMobile={isMobile}
wrapText={wrapText}
onToggleSelected={toggleFileSelected}
onToggleExpanded={toggleFileExpanded}
onOpenFile={(filePath) => {
void onOpenFile(filePath);
}}
onToggleWrapText={() => onWrapTextChange(!wrapText)}
onRequestFileAction={requestFileAction}
/>
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,162 @@
import { Check, ChevronDown, GitCommit, RefreshCw, Sparkles } from 'lucide-react';
import { useState } from 'react';
import MicButton from '../../../mic-button/view/MicButton';
import type { ConfirmationRequest } from '../../types/types';
type CommitComposerProps = {
isMobile: boolean;
selectedFileCount: number;
isHidden: boolean;
onCommit: (message: string) => Promise<boolean>;
onGenerateMessage: () => Promise<string | null>;
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 (message = commitMessage) => {
const trimmedMessage = message.trim();
if (!trimmedMessage || selectedFileCount === 0 || isCommitting) {
return false;
}
setIsCommitting(true);
try {
const success = await onCommit(trimmedMessage);
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 () => {
await handleCommit(trimmedMessage);
},
});
};
return (
<div
className={`transition-all duration-300 ease-in-out ${
isHidden ? 'max-h-0 opacity-0 -translate-y-2 overflow-hidden' : 'max-h-96 opacity-100 translate-y-0'
}`}
>
{isMobile && isCollapsed ? (
<div className="px-4 py-2 border-b border-border/60">
<button
onClick={() => 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"
>
<GitCommit className="w-4 h-4" />
<span>Commit {selectedFileCount} file{selectedFileCount !== 1 ? 's' : ''}</span>
<ChevronDown className="w-3 h-3" />
</button>
</div>
) : (
<div className="px-4 py-3 border-b border-border/60">
{isMobile && (
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">Commit Changes</span>
<button
onClick={() => setIsCollapsed(true)}
className="p-1 hover:bg-accent rounded-lg transition-colors"
>
<ChevronDown className="w-4 h-4 rotate-180" />
</button>
</div>
)}
<div className="relative">
<textarea
value={commitMessage}
onChange={(event) => setCommitMessage(event.target.value)}
placeholder="Message (Ctrl+Enter to commit)"
className="w-full px-3 py-2 text-sm border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground resize-none pr-20 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
rows={3}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
void handleCommit();
}
}}
/>
<div className="absolute right-2 top-2 flex gap-1">
<button
onClick={() => void handleGenerateMessage()}
disabled={selectedFileCount === 0 || isGeneratingMessage}
className="p-1.5 text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Generate commit message"
>
{isGeneratingMessage ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
</button>
<div style={{ display: 'none' }}>
<MicButton
onTranscript={(transcript) => setCommitMessage(transcript)}
mode="default"
className="p-1.5"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-sm text-muted-foreground">
{selectedFileCount} file{selectedFileCount !== 1 ? 's' : ''} selected
</span>
<button
onClick={requestCommitConfirmation}
disabled={!commitMessage.trim() || selectedFileCount === 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"
>
<Check className="w-3 h-3" />
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="border-b border-border last:border-0">
<div className={`flex items-center hover:bg-accent/50 transition-colors ${isMobile ? 'px-2 py-1.5' : 'px-3 py-2'}`}>
<input
type="checkbox"
checked={isSelected}
onChange={() => 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'}`}
/>
<div className="flex items-center flex-1 min-w-0">
<button
onClick={(event) => {
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'}
>
<ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />
</button>
<span
className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'} cursor-pointer hover:text-primary hover:underline`}
onClick={(event) => {
event.stopPropagation();
onOpenFile(filePath);
}}
title="Click to open file"
>
{filePath}
</span>
<span className="flex items-center gap-1">
{(status === 'M' || status === 'D' || status === 'U') && (
<button
onClick={(event) => {
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'}
>
<Trash2 className="w-3 h-3" />
{isMobile && <span>{status === 'U' ? 'Delete' : 'Discard'}</span>}
</button>
)}
<span
className={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold border ${badgeClass}`}
title={statusLabel}
>
{status}
</span>
</span>
</div>
</div>
<div
className={`bg-muted/50 transition-all duration-400 ease-in-out overflow-hidden ${
isExpanded && diff ? 'max-h-[600px] opacity-100 translate-y-0' : 'max-h-0 opacity-0 -translate-y-1'
}`}
>
<div className="flex items-center justify-between p-2 border-b border-border">
<span className="flex items-center gap-2">
<span className={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold border ${badgeClass}`}>
{status}
</span>
<span className="text-sm font-medium text-foreground">{statusLabel}</span>
</span>
{isMobile && (
<button
onClick={(event) => {
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'}
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{diff && <DiffViewerComponent diff={diff} fileName={filePath} isMobile={isMobile} wrapText={wrapText} />}
</div>
</div>
</div>
);
}

View File

@@ -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<string>;
selectedFiles: Set<string>;
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) => (
<FileChangeItem
key={filePath}
filePath={filePath}
status={status}
isMobile={isMobile}
isExpanded={expandedFiles.has(filePath)}
isSelected={selectedFiles.has(filePath)}
diff={gitDiff[filePath]}
wrapText={wrapText}
onToggleSelected={onToggleSelected}
onToggleExpanded={onToggleExpanded}
onOpenFile={onOpenFile}
onToggleWrapText={onToggleWrapText}
onRequestFileAction={onRequestFileAction}
/>
)),
)}
</>
);
}

View File

@@ -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 (
<div
className={`border-b border-border/60 flex items-center justify-between transition-all duration-300 ease-in-out ${
isMobile ? 'px-3 py-1.5' : 'px-4 py-2'
} ${isHidden ? 'max-h-0 opacity-0 -translate-y-2 overflow-hidden' : 'max-h-16 opacity-100 translate-y-0'}`}
>
<span className="text-sm text-muted-foreground">
{selectedCount} of {totalCount} {isMobile ? '' : 'files'} selected
</span>
<span className={`flex ${isMobile ? 'gap-1' : 'gap-2'}`}>
<button
onClick={onSelectAll}
className="text-sm text-primary hover:text-primary/80 transition-colors"
>
{isMobile ? 'All' : 'Select All'}
</button>
<span className="text-border">|</span>
<button
onClick={onDeselectAll}
className="text-sm text-primary hover:text-primary/80 transition-colors"
>
{isMobile ? 'None' : 'Deselect All'}
</button>
</span>
</div>
);
}

View File

@@ -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 (
<div className="border-b border-border/60">
<button
onClick={() => 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"
>
<Info className="w-3 h-3" />
<span>File Status Guide</span>
{isOpen ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
{isOpen && (
<div className="px-4 py-3 bg-muted/30 text-sm">
<div className="flex justify-center gap-6">
{LEGEND_ITEMS.map((item) => (
<span key={item.status} className="flex items-center gap-2">
<span
className={`inline-flex items-center justify-center w-5 h-5 rounded border font-bold text-[10px] ${getStatusBadgeClass(item.status)}`}
>
{item.status}
</span>
<span className="text-muted-foreground italic">{item.label}</span>
</span>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
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 (
<div className="border-b border-border last:border-0">
<button
type="button"
aria-expanded={isExpanded}
className="w-full flex items-start p-3 hover:bg-accent/50 cursor-pointer transition-colors text-left bg-transparent border-0"
onClick={onToggle}
>
<span className="mr-2 mt-1 p-0.5 hover:bg-accent rounded">
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{commit.message}</p>
<p className="text-sm text-muted-foreground mt-1">
{commit.author}
{' \u2022 '}
{commit.date}
</p>
</div>
<span className="text-sm font-mono text-muted-foreground/60 flex-shrink-0">
{commit.hash.substring(0, 7)}
</span>
</div>
</div>
</button>
{isExpanded && diff && (
<div className="bg-muted/50">
<div className="max-h-96 overflow-y-auto p-2">
<div className="text-sm font-mono text-muted-foreground mb-2">
{commit.stats}
</div>
<DiffViewerComponent diff={diff} fileName="commit" isMobile={isMobile} wrapText={wrapText} />
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
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<void>;
};
export default function HistoryView({
isMobile,
isLoading,
recentCommits,
commitDiffs,
wrapText,
onFetchCommitDiff,
}: HistoryViewProps) {
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(new Set());
const toggleCommitExpanded = useCallback(
(commitHash: string) => {
const isExpanding = !expandedCommits.has(commitHash);
setExpandedCommits((previous) => {
const next = new Set(previous);
if (next.has(commitHash)) {
next.delete(commitHash);
} else {
next.add(commitHash);
}
return next;
});
// Load commit diff lazily only the first time a commit is expanded.
if (isExpanding && !commitDiffs[commitHash]) {
onFetchCommitDiff(commitHash).catch((err) => {
console.error('Failed to fetch commit diff:', err);
});
}
},
[commitDiffs, expandedCommits, onFetchCommitDiff, setExpandedCommits],
);
return (
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : recentCommits.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
<History className="w-10 h-10 mb-2 opacity-40" />
<p className="text-sm">No commits found</p>
</div>
) : (
<div className={isMobile ? 'pb-4' : ''}>
{recentCommits.map((commit) => (
<CommitHistoryItem
key={commit.hash}
commit={commit}
isExpanded={expandedCommits.has(commit.hash)}
diff={commitDiffs[commit.hash]}
isMobile={isMobile}
wrapText={wrapText}
onToggle={() => toggleCommitExpanded(commit.hash)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect } from 'react';
import { Check, Download, Trash2, Upload } from 'lucide-react';
import {
CONFIRMATION_ACTION_LABELS,
CONFIRMATION_BUTTON_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 <Trash2 className="w-4 h-4" />;
}
if (actionType === 'commit') {
return <Check className="w-4 h-4" />;
}
if (actionType === 'pull') {
return <Download className="w-4 h-4" />;
}
return <Upload className="w-4 h-4" />;
}
export default function ConfirmActionModal({ action, onCancel, onConfirm }: ConfirmActionModalProps) {
const titleId = action ? `confirmation-title-${action.type}` : undefined;
useEffect(() => {
if (!action) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onCancel();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [action, onCancel]);
if (!action) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
<div
className="relative bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="p-6">
<div className="flex items-center mb-4">
<div className={`p-2 rounded-full mr-3 ${CONFIRMATION_ICON_CONTAINER_CLASSES[action.type]}`}>
{renderConfirmActionIcon(action.type)}
</div>
<h3 id={titleId} className="text-lg font-semibold text-foreground">
{CONFIRMATION_TITLES[action.type]}
</h3>
</div>
<p className="text-sm text-muted-foreground mb-6">{action.message}</p>
<div className="flex justify-end space-x-3">
<button
onClick={onCancel}
className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 text-sm text-white rounded-lg transition-colors flex items-center space-x-2 ${CONFIRMATION_BUTTON_CLASSES[action.type]}`}
>
{renderConfirmActionIcon(action.type)}
<span>{CONFIRMATION_ACTION_LABELS[action.type]}</span>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
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<boolean>;
};
export default function NewBranchModal({
isOpen,
currentBranch,
isCreatingBranch,
onClose,
onCreateBranch,
}: NewBranchModalProps) {
const [newBranchName, setNewBranchName] = useState('');
useEffect(() => {
if (!isOpen) {
setNewBranchName('');
}
}, [isOpen]);
const handleCreateBranch = async (): Promise<boolean> => {
const branchName = newBranchName.trim();
if (!branchName) {
return false;
}
try {
const success = await onCreateBranch(branchName);
if (success) {
setNewBranchName('');
onClose();
}
return success;
} catch (error) {
console.error('Failed to create branch:', error);
return false;
}
};
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div
className="relative bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden"
role="dialog"
aria-modal="true"
aria-labelledby="new-branch-title"
>
<div className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">Create New Branch</h3>
<div className="mb-4">
<label htmlFor="git-new-branch-name" className="block text-sm font-medium text-foreground/80 mb-2">
Branch Name
</label>
<input
id="git-new-branch-name"
type="text"
value={newBranchName}
onChange={(event) => setNewBranchName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !isCreatingBranch) {
event.preventDefault();
event.stopPropagation();
void handleCreateBranch();
return;
}
if (event.key === 'Escape' && !isCreatingBranch) {
event.preventDefault();
event.stopPropagation();
onClose();
}
}}
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
/>
</div>
<p className="text-sm text-muted-foreground mb-4">
This will create a new branch from the current branch ({currentBranch})
</p>
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={() => 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 ? (
<>
<RefreshCw className="w-3 h-3 animate-spin" />
<span>Creating...</span>
</>
) : (
<>
<Plus className="w-3 h-3" />
<span>Create Branch</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
);
}