mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-15 19:07:23 +00:00
feat: git panel redesign (#535)
* feat(git-panel): add Branches tab, Fetch always visible, inline error banners - Add dedicated Branches tab (local/remote sections, switch with confirmation, delete branch, create branch) - Rename History tab to Commits; add change-count badge on Changes tab - Fetch button always visible when remote exists (not only when both ahead & behind) - Inline error banner below header for failed push/pull/fetch, with dismiss button - Server: /api/git/branches now returns localBranches + remoteBranches separately - Server: add /api/git/delete-branch endpoint (prevents deleting current branch) - Controller: expose operationError, clearOperationError, deleteBranch, localBranches, remoteBranches - Constants: add deleteBranch to all ConfirmActionType record maps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: git log datetime * feat(git-panel): add staged/unstaged sections and enhanced commit details --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
This commit is contained in:
@@ -27,21 +27,23 @@ export const FILE_STATUS_BADGE_CLASSES: Record<FileStatusCode, string> = {
|
||||
export const CONFIRMATION_TITLES: Record<ConfirmActionType, string> = {
|
||||
discard: 'Discard Changes',
|
||||
delete: 'Delete File',
|
||||
commit: 'Confirm Commit',
|
||||
commit: 'Confirm Action',
|
||||
pull: 'Confirm Pull',
|
||||
push: 'Confirm Push',
|
||||
publish: 'Publish Branch',
|
||||
revertLocalCommit: 'Revert Local Commit',
|
||||
deleteBranch: 'Delete Branch',
|
||||
};
|
||||
|
||||
export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
|
||||
discard: 'Discard',
|
||||
delete: 'Delete',
|
||||
commit: 'Commit',
|
||||
commit: 'Confirm',
|
||||
pull: 'Pull',
|
||||
push: 'Push',
|
||||
publish: 'Publish',
|
||||
revertLocalCommit: 'Revert Commit',
|
||||
deleteBranch: 'Delete',
|
||||
};
|
||||
|
||||
export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
|
||||
@@ -52,6 +54,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
|
||||
push: 'bg-orange-600 hover:bg-orange-700',
|
||||
publish: 'bg-purple-600 hover:bg-purple-700',
|
||||
revertLocalCommit: 'bg-yellow-600 hover:bg-yellow-700',
|
||||
deleteBranch: 'bg-red-600 hover:bg-red-700',
|
||||
};
|
||||
|
||||
export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, string> = {
|
||||
@@ -62,6 +65,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, stri
|
||||
push: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
publish: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
revertLocalCommit: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
deleteBranch: 'bg-red-100 dark:bg-red-900/30',
|
||||
};
|
||||
|
||||
export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
|
||||
@@ -72,4 +76,5 @@ export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
|
||||
push: 'text-yellow-600 dark:text-yellow-400',
|
||||
publish: 'text-yellow-600 dark:text-yellow-400',
|
||||
revertLocalCommit: 'text-yellow-600 dark:text-yellow-400',
|
||||
deleteBranch: 'text-red-600 dark:text-red-400',
|
||||
};
|
||||
|
||||
@@ -53,12 +53,17 @@ export function useGitPanelController({
|
||||
const [recentCommits, setRecentCommits] = useState<GitCommitSummary[]>([]);
|
||||
const [commitDiffs, setCommitDiffs] = useState<GitDiffMap>({});
|
||||
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null);
|
||||
const [localBranches, setLocalBranches] = useState<string[]>([]);
|
||||
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
|
||||
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 [operationError, setOperationError] = useState<string | null>(null);
|
||||
|
||||
const clearOperationError = useCallback(() => setOperationError(null), []);
|
||||
const selectedProjectNameRef = useRef<string | null>(selectedProject?.name ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -169,13 +174,19 @@ export function useGitPanelController({
|
||||
|
||||
if (!data.error && data.branches) {
|
||||
setBranches(data.branches);
|
||||
setLocalBranches(data.localBranches ?? data.branches);
|
||||
setRemoteBranches(data.remoteBranches ?? []);
|
||||
return;
|
||||
}
|
||||
|
||||
setBranches([]);
|
||||
setLocalBranches([]);
|
||||
setRemoteBranches([]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching branches:', error);
|
||||
setBranches([]);
|
||||
setLocalBranches([]);
|
||||
setRemoteBranches([]);
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
@@ -271,6 +282,33 @@ export function useGitPanelController({
|
||||
[fetchBranches, fetchGitStatus, selectedProject],
|
||||
);
|
||||
|
||||
const deleteBranch = useCallback(
|
||||
async (branchName: string) => {
|
||||
if (!selectedProject) return false;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/git/delete-branch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project: selectedProject.name, branch: branchName }),
|
||||
});
|
||||
|
||||
const data = await readJson<GitOperationResponse>(response);
|
||||
if (!data.success) {
|
||||
setOperationError(data.error ?? 'Delete branch failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
void fetchBranches();
|
||||
return true;
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : 'Delete branch failed');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[fetchBranches, selectedProject],
|
||||
);
|
||||
|
||||
const handleFetch = useCallback(async () => {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
@@ -290,16 +328,17 @@ export function useGitPanelController({
|
||||
if (data.success) {
|
||||
void fetchGitStatus();
|
||||
void fetchRemoteStatus();
|
||||
void fetchBranches();
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Fetch failed:', data.error);
|
||||
setOperationError(data.error ?? 'Fetch failed');
|
||||
} catch (error) {
|
||||
console.error('Error fetching from remote:', error);
|
||||
setOperationError(error instanceof Error ? error.message : 'Fetch failed');
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
|
||||
}, [fetchBranches, fetchGitStatus, fetchRemoteStatus, selectedProject]);
|
||||
|
||||
const handlePull = useCallback(async () => {
|
||||
if (!selectedProject) {
|
||||
@@ -323,9 +362,9 @@ export function useGitPanelController({
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Pull failed:', data.error);
|
||||
setOperationError(data.error ?? 'Pull failed');
|
||||
} catch (error) {
|
||||
console.error('Error pulling from remote:', error);
|
||||
setOperationError(error instanceof Error ? error.message : 'Pull failed');
|
||||
} finally {
|
||||
setIsPulling(false);
|
||||
}
|
||||
@@ -353,9 +392,9 @@ export function useGitPanelController({
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Push failed:', data.error);
|
||||
setOperationError(data.error ?? 'Push failed');
|
||||
} catch (error) {
|
||||
console.error('Error pushing to remote:', error);
|
||||
setOperationError(error instanceof Error ? error.message : 'Push failed');
|
||||
} finally {
|
||||
setIsPushing(false);
|
||||
}
|
||||
@@ -640,12 +679,15 @@ export function useGitPanelController({
|
||||
// Reset repository-scoped state when project changes to avoid stale UI.
|
||||
setCurrentBranch('');
|
||||
setBranches([]);
|
||||
setLocalBranches([]);
|
||||
setRemoteBranches([]);
|
||||
setGitStatus(null);
|
||||
setRemoteStatus(null);
|
||||
setGitDiff({});
|
||||
setRecentCommits([]);
|
||||
setCommitDiffs({});
|
||||
setIsLoading(false);
|
||||
setOperationError(null);
|
||||
|
||||
if (!selectedProject) {
|
||||
return () => {
|
||||
@@ -666,7 +708,6 @@ export function useGitPanelController({
|
||||
if (!selectedProject || activeView !== 'history') {
|
||||
return;
|
||||
}
|
||||
|
||||
void fetchRecentCommits();
|
||||
}, [activeView, fetchRecentCommits, selectedProject]);
|
||||
|
||||
@@ -676,6 +717,8 @@ export function useGitPanelController({
|
||||
isLoading,
|
||||
currentBranch,
|
||||
branches,
|
||||
localBranches,
|
||||
remoteBranches,
|
||||
recentCommits,
|
||||
commitDiffs,
|
||||
remoteStatus,
|
||||
@@ -685,9 +728,12 @@ export function useGitPanelController({
|
||||
isPushing,
|
||||
isPublishing,
|
||||
isCreatingInitialCommit,
|
||||
operationError,
|
||||
clearOperationError,
|
||||
refreshAll,
|
||||
switchBranch,
|
||||
createBranch,
|
||||
deleteBranch,
|
||||
handleFetch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Project } from '../../../types/app';
|
||||
|
||||
export type GitPanelView = 'changes' | 'history';
|
||||
export type GitPanelView = 'changes' | 'history' | 'branches';
|
||||
export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
|
||||
export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';
|
||||
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit';
|
||||
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit' | 'deleteBranch';
|
||||
|
||||
export type FileDiffInfo = {
|
||||
old_string: string;
|
||||
@@ -76,6 +76,8 @@ export type GitPanelController = {
|
||||
isLoading: boolean;
|
||||
currentBranch: string;
|
||||
branches: string[];
|
||||
localBranches: string[];
|
||||
remoteBranches: string[];
|
||||
recentCommits: GitCommitSummary[];
|
||||
commitDiffs: GitDiffMap;
|
||||
remoteStatus: GitRemoteStatus | null;
|
||||
@@ -85,9 +87,12 @@ export type GitPanelController = {
|
||||
isPushing: boolean;
|
||||
isPublishing: boolean;
|
||||
isCreatingInitialCommit: boolean;
|
||||
operationError: string | null;
|
||||
clearOperationError: () => void;
|
||||
refreshAll: () => void;
|
||||
switchBranch: (branchName: string) => Promise<boolean>;
|
||||
createBranch: (branchName: string) => Promise<boolean>;
|
||||
deleteBranch: (branchName: string) => Promise<boolean>;
|
||||
handleFetch: () => Promise<void>;
|
||||
handlePull: () => Promise<void>;
|
||||
handlePush: () => Promise<void>;
|
||||
@@ -112,6 +117,8 @@ export type GitDiffResponse = GitApiErrorResponse & {
|
||||
|
||||
export type GitBranchesResponse = GitApiErrorResponse & {
|
||||
branches?: string[];
|
||||
localBranches?: string[];
|
||||
remoteBranches?: string[];
|
||||
};
|
||||
|
||||
export type GitCommitsResponse = GitApiErrorResponse & {
|
||||
|
||||
@@ -24,3 +24,70 @@ export function getStatusLabel(status: FileStatusCode): string {
|
||||
export function getStatusBadgeClass(status: FileStatusCode): string {
|
||||
return FILE_STATUS_BADGE_CLASSES[status] || FILE_STATUS_BADGE_CLASSES.U;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse `git show` output to extract per-file change info
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CommitFileChange = {
|
||||
path: string;
|
||||
directory: string;
|
||||
filename: string;
|
||||
status: FileStatusCode;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
};
|
||||
|
||||
export type CommitFileSummary = {
|
||||
files: CommitFileChange[];
|
||||
totalFiles: number;
|
||||
totalInsertions: number;
|
||||
totalDeletions: number;
|
||||
};
|
||||
|
||||
export function parseCommitFiles(showOutput: string): CommitFileSummary {
|
||||
const files: CommitFileChange[] = [];
|
||||
// Split on file diff boundaries
|
||||
const fileDiffs = showOutput.split(/^diff --git /m).slice(1);
|
||||
|
||||
for (const section of fileDiffs) {
|
||||
const lines = section.split('\n');
|
||||
// Extract path from "a/path b/path"
|
||||
const header = lines[0] ?? '';
|
||||
const match = header.match(/^a\/(.+?) b\/(.+)/);
|
||||
if (!match) continue;
|
||||
|
||||
const pathA = match[1];
|
||||
const pathB = match[2];
|
||||
|
||||
// Determine status
|
||||
let status: FileStatusCode = 'M';
|
||||
const joined = lines.slice(0, 6).join('\n');
|
||||
if (joined.includes('new file mode')) status = 'A';
|
||||
else if (joined.includes('deleted file mode')) status = 'D';
|
||||
|
||||
const filePath = status === 'D' ? pathA : pathB;
|
||||
|
||||
// Count insertions/deletions (lines starting with +/- but not +++/---)
|
||||
let insertions = 0;
|
||||
let deletions = 0;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
||||
if (line.startsWith('+')) insertions++;
|
||||
else if (line.startsWith('-')) deletions++;
|
||||
}
|
||||
|
||||
const lastSlash = filePath.lastIndexOf('/');
|
||||
const directory = lastSlash >= 0 ? filePath.substring(0, lastSlash + 1) : '';
|
||||
const filename = lastSlash >= 0 ? filePath.substring(lastSlash + 1) : filePath;
|
||||
|
||||
files.push({ path: filePath, directory, filename, status, insertions, deletions });
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
totalFiles: files.length,
|
||||
totalInsertions: files.reduce((sum, f) => sum + f.insertions, 0),
|
||||
totalDeletions: files.reduce((sum, f) => sum + f.deletions, 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useCallback, useState } from 'react';
|
||||
import { useGitPanelController } from '../hooks/useGitPanelController';
|
||||
import { useRevertLocalCommit } from '../hooks/useRevertLocalCommit';
|
||||
import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
|
||||
import { getChangedFileCount } from '../utils/gitPanelUtils';
|
||||
import ChangesView from '../view/changes/ChangesView';
|
||||
import HistoryView from '../view/history/HistoryView';
|
||||
import BranchesView from '../view/branches/BranchesView';
|
||||
import GitPanelHeader from '../view/GitPanelHeader';
|
||||
import GitRepositoryErrorState from '../view/GitRepositoryErrorState';
|
||||
import GitViewTabs from '../view/GitViewTabs';
|
||||
@@ -21,6 +23,8 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
isLoading,
|
||||
currentBranch,
|
||||
branches,
|
||||
localBranches,
|
||||
remoteBranches,
|
||||
recentCommits,
|
||||
commitDiffs,
|
||||
remoteStatus,
|
||||
@@ -30,9 +34,12 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
isPushing,
|
||||
isPublishing,
|
||||
isCreatingInitialCommit,
|
||||
operationError,
|
||||
clearOperationError,
|
||||
refreshAll,
|
||||
switchBranch,
|
||||
createBranch,
|
||||
deleteBranch,
|
||||
handleFetch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
@@ -56,13 +63,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
});
|
||||
|
||||
const executeConfirmedAction = useCallback(async () => {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirmAction) return;
|
||||
const actionToExecute = confirmAction;
|
||||
setConfirmAction(null);
|
||||
|
||||
try {
|
||||
await actionToExecute.onConfirm();
|
||||
} catch (error) {
|
||||
@@ -70,6 +73,8 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
}
|
||||
}, [confirmAction]);
|
||||
|
||||
const changeCount = getChangedFileCount(gitStatus);
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
@@ -92,6 +97,7 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
isPushing={isPushing}
|
||||
isPublishing={isPublishing}
|
||||
isRevertingLocalCommit={isRevertingLocalCommit}
|
||||
operationError={operationError}
|
||||
onRefresh={refreshAll}
|
||||
onRevertLocalCommit={revertLatestLocalCommit}
|
||||
onSwitchBranch={switchBranch}
|
||||
@@ -100,6 +106,7 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPublish={handlePublish}
|
||||
onClearError={clearOperationError}
|
||||
onRequestConfirmation={setConfirmAction}
|
||||
/>
|
||||
|
||||
@@ -110,6 +117,7 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
<GitViewTabs
|
||||
activeView={activeView}
|
||||
isHidden={hasExpandedFiles}
|
||||
changeCount={changeCount}
|
||||
onChange={setActiveView}
|
||||
/>
|
||||
|
||||
@@ -145,6 +153,22 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
onFetchCommitDiff={fetchCommitDiff}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === 'branches' && (
|
||||
<BranchesView
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
currentBranch={currentBranch}
|
||||
localBranches={localBranches}
|
||||
remoteBranches={remoteBranches}
|
||||
remoteStatus={remoteStatus}
|
||||
isCreatingBranch={isCreatingBranch}
|
||||
onSwitchBranch={switchBranch}
|
||||
onCreateBranch={createBranch}
|
||||
onDeleteBranch={deleteBranch}
|
||||
onRequestConfirmation={setConfirmAction}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload } from 'lucide-react';
|
||||
import { AlertCircle, Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
|
||||
import NewBranchModal from './modals/NewBranchModal';
|
||||
@@ -15,6 +15,7 @@ type GitPanelHeaderProps = {
|
||||
isPushing: boolean;
|
||||
isPublishing: boolean;
|
||||
isRevertingLocalCommit: boolean;
|
||||
operationError: string | null;
|
||||
onRefresh: () => void;
|
||||
onRevertLocalCommit: () => Promise<void>;
|
||||
onSwitchBranch: (branchName: string) => Promise<boolean>;
|
||||
@@ -23,6 +24,7 @@ type GitPanelHeaderProps = {
|
||||
onPull: () => Promise<void>;
|
||||
onPush: () => Promise<void>;
|
||||
onPublish: () => Promise<void>;
|
||||
onClearError: () => void;
|
||||
onRequestConfirmation: (request: ConfirmationRequest) => void;
|
||||
};
|
||||
|
||||
@@ -38,6 +40,7 @@ export default function GitPanelHeader({
|
||||
isPushing,
|
||||
isPublishing,
|
||||
isRevertingLocalCommit,
|
||||
operationError,
|
||||
onRefresh,
|
||||
onRevertLocalCommit,
|
||||
onSwitchBranch,
|
||||
@@ -46,6 +49,7 @@ export default function GitPanelHeader({
|
||||
onPull,
|
||||
onPush,
|
||||
onPublish,
|
||||
onClearError,
|
||||
onRequestConfirmation,
|
||||
}: GitPanelHeaderProps) {
|
||||
const [showBranchDropdown, setShowBranchDropdown] = useState(false);
|
||||
@@ -63,10 +67,10 @@ export default function GitPanelHeader({
|
||||
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 aheadCount = remoteStatus?.ahead ?? 0;
|
||||
const behindCount = remoteStatus?.behind ?? 0;
|
||||
const remoteName = remoteStatus?.remoteName ?? 'remote';
|
||||
const anyPending = isFetching || isPulling || isPushing || isPublishing;
|
||||
|
||||
const requestPullConfirmation = () => {
|
||||
onRequestConfirmation({
|
||||
@@ -103,57 +107,39 @@ export default function GitPanelHeader({
|
||||
const handleSwitchBranch = async (branchName: string) => {
|
||||
try {
|
||||
const success = await onSwitchBranch(branchName);
|
||||
if (success) {
|
||||
setShowBranchDropdown(false);
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
{/* Branch row + action buttons */}
|
||||
<div className={`flex items-center justify-between border-b border-border/60 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
|
||||
{/* Branch selector */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowBranchDropdown((previous) => !previous)}
|
||||
onClick={() => setShowBranchDropdown((prev) => !prev)}
|
||||
className={`flex items-center rounded-lg transition-colors hover:bg-accent ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
|
||||
>
|
||||
<GitBranch className={`text-muted-foreground ${isMobile ? 'h-3 w-3' : 'h-4 w-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">
|
||||
<span className="flex items-center gap-0.5 text-xs">
|
||||
{aheadCount > 0 && (
|
||||
<span
|
||||
className="text-green-600 dark:text-green-400"
|
||||
title={`${aheadCount} commit${aheadCount !== 1 ? 's' : ''} ahead`}
|
||||
>
|
||||
{'\u2191'}
|
||||
{aheadCount}
|
||||
<span className="text-green-600 dark:text-green-400" title={`${aheadCount} ahead`}>
|
||||
↑{aheadCount}
|
||||
</span>
|
||||
)}
|
||||
{behindCount > 0 && (
|
||||
<span
|
||||
className="text-primary"
|
||||
title={`${behindCount} commit${behindCount !== 1 ? 's' : ''} behind`}
|
||||
>
|
||||
{'\u2193'}
|
||||
{behindCount}
|
||||
<span className="text-primary" title={`${behindCount} behind`}>
|
||||
↓{behindCount}
|
||||
</span>
|
||||
)}
|
||||
{remoteStatus.isUpToDate && (
|
||||
<span className="text-muted-foreground" title="Up to date with remote">
|
||||
{'\u2713'}
|
||||
</span>
|
||||
<span className="text-muted-foreground" title="Up to date">✓</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
@@ -195,56 +181,54 @@ export default function GitPanelHeader({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>
|
||||
{remoteStatus?.hasRemote && (
|
||||
<>
|
||||
{!remoteStatus.hasUpstream && (
|
||||
{!remoteStatus.hasUpstream ? (
|
||||
<button
|
||||
onClick={requestPublishConfirmation}
|
||||
disabled={isPublishing}
|
||||
disabled={anyPending}
|
||||
className="flex items-center gap-1 rounded-lg bg-purple-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-purple-700 disabled:opacity-50"
|
||||
title={`Publish branch "${currentBranch}" to ${remoteName}`}
|
||||
title={`Publish "${currentBranch}" to ${remoteName}`}
|
||||
>
|
||||
<Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPublishing ? 'Publishing...' : 'Publish'}</span>
|
||||
{!isMobile && <span>{isPublishing ? 'Publishing…' : 'Publish'}</span>}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{remoteStatus.hasUpstream && !remoteStatus.isUpToDate && (
|
||||
) : (
|
||||
<>
|
||||
{/* Fetch — always visible when remote exists */}
|
||||
<button
|
||||
onClick={() => void onFetch()}
|
||||
disabled={anyPending}
|
||||
className="flex items-center gap-1 rounded-lg bg-primary px-2.5 py-1 text-sm text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
title={`Fetch from ${remoteName}`}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
{!isMobile && <span>{isFetching ? 'Fetching…' : 'Fetch'}</span>}
|
||||
</button>
|
||||
|
||||
{behindCount > 0 && (
|
||||
<button
|
||||
onClick={requestPullConfirmation}
|
||||
disabled={isPulling}
|
||||
disabled={anyPending}
|
||||
className="flex items-center gap-1 rounded-lg bg-green-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-green-700 disabled:opacity-50"
|
||||
title={`Pull ${behindCount} commit${behindCount !== 1 ? 's' : ''} from ${remoteName}`}
|
||||
title={`Pull ${behindCount} from ${remoteName}`}
|
||||
>
|
||||
<Download className={`h-3 w-3 ${isPulling ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPulling ? 'Pulling...' : `Pull ${behindCount}`}</span>
|
||||
{!isMobile && <span>{isPulling ? 'Pulling…' : `Pull ${behindCount}`}</span>}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{aheadCount > 0 && (
|
||||
<button
|
||||
onClick={requestPushConfirmation}
|
||||
disabled={isPushing}
|
||||
disabled={anyPending}
|
||||
className="flex items-center gap-1 rounded-lg bg-orange-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-orange-700 disabled:opacity-50"
|
||||
title={`Push ${aheadCount} commit${aheadCount !== 1 ? 's' : ''} to ${remoteName}`}
|
||||
title={`Push ${aheadCount} to ${remoteName}`}
|
||||
>
|
||||
<Upload className={`h-3 w-3 ${isPushing ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPushing ? 'Pushing...' : `Push ${aheadCount}`}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{shouldShowFetchButton && (
|
||||
<button
|
||||
onClick={() => void handleFetch()}
|
||||
disabled={isFetching}
|
||||
className="flex items-center gap-1 rounded-lg bg-primary px-2.5 py-1 text-sm text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
title={`Fetch from ${remoteName}`}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
|
||||
{!isMobile && <span>{isPushing ? 'Pushing…' : `Push ${aheadCount}`}</span>}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -274,6 +258,21 @@ export default function GitPanelHeader({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline error banner */}
|
||||
{operationError && (
|
||||
<div className="flex items-start gap-2 border-b border-destructive/20 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 leading-snug">{operationError}</span>
|
||||
<button
|
||||
onClick={onClearError}
|
||||
className="shrink-0 rounded p-0.5 hover:bg-destructive/20"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NewBranchModal
|
||||
isOpen={showNewBranchModal}
|
||||
currentBranch={currentBranch}
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
import { FileText, History } from 'lucide-react';
|
||||
import { FileText, GitBranch, History } from 'lucide-react';
|
||||
import type { GitPanelView } from '../types/types';
|
||||
|
||||
type GitViewTabsProps = {
|
||||
activeView: GitPanelView;
|
||||
isHidden: boolean;
|
||||
changeCount: number;
|
||||
onChange: (view: GitPanelView) => void;
|
||||
};
|
||||
|
||||
export default function GitViewTabs({ activeView, isHidden, onChange }: GitViewTabsProps) {
|
||||
const TABS: { id: GitPanelView; label: string; Icon: typeof FileText }[] = [
|
||||
{ id: 'changes', label: 'Changes', Icon: FileText },
|
||||
{ id: 'history', label: 'Commits', Icon: History },
|
||||
{ id: 'branches', label: 'Branches', Icon: GitBranch },
|
||||
];
|
||||
|
||||
export default function GitViewTabs({ activeView, isHidden, changeCount, onChange }: GitViewTabsProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex border-b border-border/60 transition-all duration-300 ease-in-out ${
|
||||
isHidden ? 'max-h-0 -translate-y-2 overflow-hidden opacity-0' : 'max-h-16 translate-y-0 opacity-100'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => onChange('changes')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeView === 'changes'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<FileText className="h-4 w-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'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<History className="h-4 w-4" />
|
||||
<span>History</span>
|
||||
</span>
|
||||
</button>
|
||||
{TABS.map(({ id, label, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => onChange(id)}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeView === id
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{label}</span>
|
||||
{id === 'changes' && changeCount > 0 && (
|
||||
<span className="rounded-full bg-primary/15 px-1.5 py-0.5 text-xs font-semibold text-primary">
|
||||
{changeCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
242
src/components/git-panel/view/branches/BranchesView.tsx
Normal file
242
src/components/git-panel/view/branches/BranchesView.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Check, GitBranch, Globe, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { ConfirmationRequest, GitRemoteStatus } from '../../types/types';
|
||||
import NewBranchModal from '../modals/NewBranchModal';
|
||||
|
||||
type BranchesViewProps = {
|
||||
isMobile: boolean;
|
||||
isLoading: boolean;
|
||||
currentBranch: string;
|
||||
localBranches: string[];
|
||||
remoteBranches: string[];
|
||||
remoteStatus: GitRemoteStatus | null;
|
||||
isCreatingBranch: boolean;
|
||||
onSwitchBranch: (branchName: string) => Promise<boolean>;
|
||||
onCreateBranch: (branchName: string) => Promise<boolean>;
|
||||
onDeleteBranch: (branchName: string) => Promise<boolean>;
|
||||
onRequestConfirmation: (request: ConfirmationRequest) => void;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branch row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BranchRowProps = {
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
isRemote: boolean;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
isMobile: boolean;
|
||||
onSwitch: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function BranchRow({ name, isCurrent, isRemote, aheadCount, behindCount, isMobile, onSwitch, onDelete }: BranchRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center gap-3 border-b border-border/40 px-4 transition-colors hover:bg-accent/40 ${
|
||||
isMobile ? 'py-2.5' : 'py-3'
|
||||
} ${isCurrent ? 'bg-primary/5' : ''}`}
|
||||
>
|
||||
{/* Branch icon */}
|
||||
<div className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${
|
||||
isCurrent
|
||||
? 'border-primary/30 bg-primary/10 text-primary'
|
||||
: isRemote
|
||||
? 'border-border bg-muted text-muted-foreground'
|
||||
: 'border-border bg-muted/50 text-muted-foreground'
|
||||
}`}>
|
||||
{isRemote ? <Globe className="h-3.5 w-3.5" /> : <GitBranch className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
|
||||
{/* Name + pills */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`truncate text-sm font-medium ${isCurrent ? 'text-foreground' : 'text-foreground/80'}`}>
|
||||
{name}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-0.5 text-xs font-semibold text-primary">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
{isRemote && !isCurrent && (
|
||||
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
remote
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Ahead/behind — only meaningful for the current branch */}
|
||||
{isCurrent && (aheadCount > 0 || behindCount > 0) && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{aheadCount > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400">↑{aheadCount} ahead</span>
|
||||
)}
|
||||
{behindCount > 0 && (
|
||||
<span className="text-primary">↓{behindCount} behind</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={`flex shrink-0 items-center gap-1 ${isCurrent ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
|
||||
{isCurrent ? (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
) : !isRemote ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onSwitch}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title={`Switch to ${name}`}
|
||||
>
|
||||
Switch
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
title={`Delete ${name}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionHeader({ label, count }: { label: string; count: number }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between bg-background/95 px-4 py-2 backdrop-blur-sm">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BranchesView
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function BranchesView({
|
||||
isMobile,
|
||||
isLoading,
|
||||
currentBranch,
|
||||
localBranches,
|
||||
remoteBranches,
|
||||
remoteStatus,
|
||||
isCreatingBranch,
|
||||
onSwitchBranch,
|
||||
onCreateBranch,
|
||||
onDeleteBranch,
|
||||
onRequestConfirmation,
|
||||
}: BranchesViewProps) {
|
||||
const [showNewBranchModal, setShowNewBranchModal] = useState(false);
|
||||
|
||||
const aheadCount = remoteStatus?.ahead ?? 0;
|
||||
const behindCount = remoteStatus?.behind ?? 0;
|
||||
|
||||
const requestSwitch = (branch: string) => {
|
||||
onRequestConfirmation({
|
||||
type: 'commit', // reuse neutral type for switch
|
||||
message: `Switch to branch "${branch}"? Make sure you have no uncommitted changes.`,
|
||||
onConfirm: () => void onSwitchBranch(branch),
|
||||
});
|
||||
};
|
||||
|
||||
const requestDelete = (branch: string) => {
|
||||
onRequestConfirmation({
|
||||
type: 'deleteBranch',
|
||||
message: `Delete branch "${branch}"? This cannot be undone.`,
|
||||
onConfirm: () => void onDeleteBranch(branch),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading && localBranches.length === 0) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-1 flex-col overflow-hidden ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
{/* Create branch button */}
|
||||
<div className="flex items-center justify-between border-b border-border/40 px-4 py-2.5">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{localBranches.length} local{remoteBranches.length > 0 ? `, ${remoteBranches.length} remote` : ''}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowNewBranchModal(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-sm font-medium text-primary transition-colors hover:bg-primary/20"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New branch
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Branch list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{localBranches.length > 0 && (
|
||||
<>
|
||||
<SectionHeader label="Local" count={localBranches.length} />
|
||||
{localBranches.map((branch) => (
|
||||
<BranchRow
|
||||
key={`local:${branch}`}
|
||||
name={branch}
|
||||
isCurrent={branch === currentBranch}
|
||||
isRemote={false}
|
||||
aheadCount={branch === currentBranch ? aheadCount : 0}
|
||||
behindCount={branch === currentBranch ? behindCount : 0}
|
||||
isMobile={isMobile}
|
||||
onSwitch={() => requestSwitch(branch)}
|
||||
onDelete={() => requestDelete(branch)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{remoteBranches.length > 0 && (
|
||||
<>
|
||||
<SectionHeader label="Remote" count={remoteBranches.length} />
|
||||
{remoteBranches.map((branch) => (
|
||||
<BranchRow
|
||||
key={`remote:${branch}`}
|
||||
name={branch}
|
||||
isCurrent={false}
|
||||
isRemote={true}
|
||||
aheadCount={0}
|
||||
behindCount={0}
|
||||
isMobile={isMobile}
|
||||
onSwitch={() => requestSwitch(branch)}
|
||||
onDelete={() => requestDelete(branch)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{localBranches.length === 0 && remoteBranches.length === 0 && (
|
||||
<div className="flex h-32 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<GitBranch className="h-10 w-10 opacity-30" />
|
||||
<p className="text-sm">No branches found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NewBranchModal
|
||||
isOpen={showNewBranchModal}
|
||||
currentBranch={currentBranch}
|
||||
isCreatingBranch={isCreatingBranch}
|
||||
onClose={() => setShowNewBranchModal(false)}
|
||||
onCreateBranch={onCreateBranch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import type { ConfirmationRequest, FileStatusCode, GitDiffMap, GitStatusResponse
|
||||
import { getAllChangedFiles, hasChangedFiles } from '../../utils/gitPanelUtils';
|
||||
import CommitComposer from './CommitComposer';
|
||||
import FileChangeList from './FileChangeList';
|
||||
import FileSelectionControls from './FileSelectionControls';
|
||||
import FileStatusLegend from './FileStatusLegend';
|
||||
|
||||
type ChangesViewProps = {
|
||||
@@ -56,8 +55,12 @@ export default function ChangesView({
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve previous behavior: every fresh status snapshot reselects changed files.
|
||||
setSelectedFiles(new Set(getAllChangedFiles(gitStatus)));
|
||||
// Remove any selected files that no longer exist in the status
|
||||
setSelectedFiles((prev) => {
|
||||
const allFiles = new Set(getAllChangedFiles(gitStatus));
|
||||
const next = new Set([...prev].filter((f) => allFiles.has(f)));
|
||||
return next;
|
||||
});
|
||||
}, [gitStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -129,6 +132,11 @@ export default function ChangesView({
|
||||
return onGenerateCommitMessage(Array.from(selectedFiles));
|
||||
}, [onGenerateCommitMessage, selectedFiles]);
|
||||
|
||||
const unstagedFiles = useMemo(
|
||||
() => new Set(changedFiles.filter((f) => !selectedFiles.has(f))),
|
||||
[changedFiles, selectedFiles],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommitComposer
|
||||
@@ -141,17 +149,6 @@ export default function ChangesView({
|
||||
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' : ''}`}>
|
||||
@@ -193,21 +190,71 @@ export default function ChangesView({
|
||||
</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}
|
||||
/>
|
||||
{/* STAGED section */}
|
||||
<div className="flex items-center justify-between border-b border-border/60 bg-muted/30 px-3 py-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Staged ({selectedFiles.size})
|
||||
</span>
|
||||
{selectedFiles.size > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedFiles(new Set())}
|
||||
className="text-xs text-primary transition-colors hover:text-primary/80"
|
||||
>
|
||||
Unstage All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{selectedFiles.size === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground italic">No staged files</div>
|
||||
) : (
|
||||
<FileChangeList
|
||||
gitStatus={gitStatus}
|
||||
gitDiff={gitDiff}
|
||||
expandedFiles={expandedFiles}
|
||||
selectedFiles={selectedFiles}
|
||||
isMobile={isMobile}
|
||||
wrapText={wrapText}
|
||||
filePaths={selectedFiles}
|
||||
onToggleSelected={toggleFileSelected}
|
||||
onToggleExpanded={toggleFileExpanded}
|
||||
onOpenFile={(filePath) => { void onOpenFile(filePath); }}
|
||||
onToggleWrapText={() => onWrapTextChange(!wrapText)}
|
||||
onRequestFileAction={requestFileAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* CHANGES section */}
|
||||
<div className="flex items-center justify-between border-b border-border/60 bg-muted/30 px-3 py-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Changes ({unstagedFiles.size})
|
||||
</span>
|
||||
{unstagedFiles.size > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedFiles(new Set(changedFiles))}
|
||||
className="text-xs text-primary transition-colors hover:text-primary/80"
|
||||
>
|
||||
Stage All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{unstagedFiles.size === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground italic">All changes staged</div>
|
||||
) : (
|
||||
<FileChangeList
|
||||
gitStatus={gitStatus}
|
||||
gitDiff={gitDiff}
|
||||
expandedFiles={expandedFiles}
|
||||
selectedFiles={selectedFiles}
|
||||
isMobile={isMobile}
|
||||
wrapText={wrapText}
|
||||
filePaths={unstagedFiles}
|
||||
onToggleSelected={toggleFileSelected}
|
||||
onToggleExpanded={toggleFileExpanded}
|
||||
onOpenFile={(filePath) => { void onOpenFile(filePath); }}
|
||||
onToggleWrapText={() => onWrapTextChange(!wrapText)}
|
||||
onRequestFileAction={requestFileAction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ type FileChangeListProps = {
|
||||
selectedFiles: Set<string>;
|
||||
isMobile: boolean;
|
||||
wrapText: boolean;
|
||||
filePaths?: Set<string>;
|
||||
onToggleSelected: (filePath: string) => void;
|
||||
onToggleExpanded: (filePath: string) => void;
|
||||
onOpenFile: (filePath: string) => void;
|
||||
@@ -23,6 +24,7 @@ export default function FileChangeList({
|
||||
selectedFiles,
|
||||
isMobile,
|
||||
wrapText,
|
||||
filePaths,
|
||||
onToggleSelected,
|
||||
onToggleExpanded,
|
||||
onOpenFile,
|
||||
@@ -32,23 +34,25 @@ export default function FileChangeList({
|
||||
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}
|
||||
/>
|
||||
)),
|
||||
(gitStatus[key] || [])
|
||||
.filter((filePath) => !filePaths || filePaths.has(filePath))
|
||||
.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}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import type { GitCommitSummary } from '../../types/types';
|
||||
import { getStatusBadgeClass, parseCommitFiles } from '../../utils/gitPanelUtils';
|
||||
import GitDiffViewer from '../shared/GitDiffViewer';
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
type CommitHistoryItemProps = {
|
||||
commit: GitCommitSummary;
|
||||
@@ -20,6 +29,11 @@ export default function CommitHistoryItem({
|
||||
wrapText,
|
||||
onToggle,
|
||||
}: CommitHistoryItemProps) {
|
||||
const fileSummary = useMemo(() => {
|
||||
if (!diff) return null;
|
||||
return parseCommitFiles(diff);
|
||||
}, [diff]);
|
||||
|
||||
return (
|
||||
<div className="border-b border-border last:border-0">
|
||||
<button
|
||||
@@ -50,10 +64,83 @@ export default function CommitHistoryItem({
|
||||
|
||||
{isExpanded && diff && (
|
||||
<div className="bg-muted/50">
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
<div className="mb-2 font-mono text-sm text-muted-foreground">
|
||||
{commit.stats}
|
||||
<div className="max-h-[32rem] overflow-y-auto p-3">
|
||||
{/* Full hash */}
|
||||
<p className="mb-2 select-all font-mono text-xs text-muted-foreground/70">
|
||||
{commit.hash}
|
||||
</p>
|
||||
|
||||
{/* Author + Date */}
|
||||
<div className="mb-3 flex gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<span className="text-muted-foreground/60">Author </span>
|
||||
{commit.author}
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground/60">Date </span>
|
||||
{formatDate(commit.date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stats card */}
|
||||
{fileSummary && (
|
||||
<div className="mb-3 flex gap-4 rounded-md bg-muted/80 px-4 py-2 text-center text-xs">
|
||||
<div>
|
||||
<div className="text-muted-foreground/60">Files</div>
|
||||
<div className="font-semibold text-foreground">{fileSummary.totalFiles}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground/60">Added</div>
|
||||
<div className="font-semibold text-green-600 dark:text-green-400">+{fileSummary.totalInsertions}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground/60">Removed</div>
|
||||
<div className="font-semibold text-red-600 dark:text-red-400">-{fileSummary.totalDeletions}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changed files list */}
|
||||
{fileSummary && fileSummary.files.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
Changed Files
|
||||
</p>
|
||||
<div className="rounded-md border border-border/60">
|
||||
{fileSummary.files.map((file, idx) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${
|
||||
idx < fileSummary.files.length - 1 ? 'border-b border-border/40' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border text-[9px] font-bold ${getStatusBadgeClass(file.status)}`}
|
||||
>
|
||||
{file.status}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{file.directory && (
|
||||
<span className="text-muted-foreground/60">{file.directory}</span>
|
||||
)}
|
||||
<span className="font-medium text-foreground">{file.filename}</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 font-mono text-muted-foreground/60">
|
||||
{file.insertions > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400">+{file.insertions}</span>
|
||||
)}
|
||||
{file.insertions > 0 && file.deletions > 0 && '/'}
|
||||
{file.deletions > 0 && (
|
||||
<span className="text-red-600 dark:text-red-400">-{file.deletions}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff viewer */}
|
||||
<GitDiffViewer diff={diff} isMobile={isMobile} wrapText={wrapText} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user