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:
Simos Mikelatos
2026-03-13 15:38:53 +01:00
committed by GitHub
parent 1d31c3ec83
commit adb3a06d7e
13 changed files with 732 additions and 172 deletions

2
package-lock.json generated
View File

@@ -1909,6 +1909,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1925,6 +1926,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [

View File

@@ -651,26 +651,28 @@ router.get('/branches', async (req, res) => {
// Get all branches
const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
// Parse branches
const branches = stdout
const rawLines = stdout
.split('\n')
.map(branch => branch.trim())
.filter(branch => branch && !branch.includes('->')) // Remove empty lines and HEAD pointer
.map(branch => {
// Remove asterisk from current branch
if (branch.startsWith('* ')) {
return branch.substring(2);
}
// Remove remotes/ prefix
if (branch.startsWith('remotes/origin/')) {
return branch.substring(15);
}
return branch;
})
.filter((branch, index, self) => self.indexOf(branch) === index); // Remove duplicates
res.json({ branches });
.map(b => b.trim())
.filter(b => b && !b.includes('->'));
// Local branches (may start with '* ' for current)
const localBranches = rawLines
.filter(b => !b.startsWith('remotes/'))
.map(b => (b.startsWith('* ') ? b.substring(2) : b));
// Remote branches — strip 'remotes/<remote>/' prefix
const remoteBranches = rawLines
.filter(b => b.startsWith('remotes/'))
.map(b => b.replace(/^remotes\/[^/]+\//, ''))
.filter(name => !localBranches.includes(name)); // skip if already a local branch
// Backward-compat flat list (local + unique remotes, deduplicated)
const branches = [...localBranches, ...remoteBranches]
.filter((b, i, arr) => arr.indexOf(b) === i);
res.json({ branches, localBranches, remoteBranches });
} catch (error) {
console.error('Git branches error:', error);
res.json({ error: error.message });
@@ -721,6 +723,32 @@ router.post('/create-branch', async (req, res) => {
}
});
// Delete a local branch
router.post('/delete-branch', async (req, res) => {
const { project, branch } = req.body;
if (!project || !branch) {
return res.status(400).json({ error: 'Project name and branch name are required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Safety: cannot delete the currently checked-out branch
const { stdout: currentBranch } = await spawnAsync('git', ['branch', '--show-current'], { cwd: projectPath });
if (currentBranch.trim() === branch) {
return res.status(400).json({ error: 'Cannot delete the currently checked-out branch' });
}
const { stdout } = await spawnAsync('git', ['branch', '-d', branch], { cwd: projectPath });
res.json({ success: true, output: stdout });
} catch (error) {
console.error('Git delete branch error:', error);
res.status(500).json({ error: error.message });
}
});
// Get recent commits
router.get('/commits', async (req, res) => {
const { project, limit = 10 } = req.query;
@@ -740,7 +768,7 @@ router.get('/commits', async (req, res) => {
// Get commit log with stats
const { stdout } = await spawnAsync(
'git',
['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(safeLimit)],
['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)],
{ cwd: projectPath },
);

View File

@@ -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',
};

View File

@@ -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,

View File

@@ -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 & {

View File

@@ -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),
};
}

View File

@@ -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}
/>
)}
</>
)}

View File

@@ -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}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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}
/>
)),
)}
</>
);

View File

@@ -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>