diff --git a/package-lock.json b/package-lock.json index 82ad891..1799af5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": [ diff --git a/server/routes/git.js b/server/routes/git.js index 701c3be..a439563 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -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//' 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 }, ); diff --git a/src/components/git-panel/constants/constants.ts b/src/components/git-panel/constants/constants.ts index 420f955..5e2ff19 100644 --- a/src/components/git-panel/constants/constants.ts +++ b/src/components/git-panel/constants/constants.ts @@ -27,21 +27,23 @@ export const FILE_STATUS_BADGE_CLASSES: Record = { export const CONFIRMATION_TITLES: Record = { 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 = { 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 = { @@ -52,6 +54,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record = { 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 = { @@ -62,6 +65,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record = { @@ -72,4 +76,5 @@ export const CONFIRMATION_ICON_CLASSES: Record = { 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', }; diff --git a/src/components/git-panel/hooks/useGitPanelController.ts b/src/components/git-panel/hooks/useGitPanelController.ts index 7fe8635..cb34cf1 100644 --- a/src/components/git-panel/hooks/useGitPanelController.ts +++ b/src/components/git-panel/hooks/useGitPanelController.ts @@ -53,12 +53,17 @@ export function useGitPanelController({ const [recentCommits, setRecentCommits] = useState([]); const [commitDiffs, setCommitDiffs] = useState({}); const [remoteStatus, setRemoteStatus] = useState(null); + const [localBranches, setLocalBranches] = useState([]); + const [remoteBranches, setRemoteBranches] = useState([]); 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(null); + + const clearOperationError = useCallback(() => setOperationError(null), []); const selectedProjectNameRef = useRef(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(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, diff --git a/src/components/git-panel/types/types.ts b/src/components/git-panel/types/types.ts index c8188e9..7abf982 100644 --- a/src/components/git-panel/types/types.ts +++ b/src/components/git-panel/types/types.ts @@ -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; createBranch: (branchName: string) => Promise; + deleteBranch: (branchName: string) => Promise; handleFetch: () => Promise; handlePull: () => Promise; handlePush: () => Promise; @@ -112,6 +117,8 @@ export type GitDiffResponse = GitApiErrorResponse & { export type GitBranchesResponse = GitApiErrorResponse & { branches?: string[]; + localBranches?: string[]; + remoteBranches?: string[]; }; export type GitCommitsResponse = GitApiErrorResponse & { diff --git a/src/components/git-panel/utils/gitPanelUtils.ts b/src/components/git-panel/utils/gitPanelUtils.ts index 736deeb..7a66ba6 100644 --- a/src/components/git-panel/utils/gitPanelUtils.ts +++ b/src/components/git-panel/utils/gitPanelUtils.ts @@ -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), + }; +} diff --git a/src/components/git-panel/view/GitPanel.tsx b/src/components/git-panel/view/GitPanel.tsx index d670f65..fc6438b 100644 --- a/src/components/git-panel/view/GitPanel.tsx +++ b/src/components/git-panel/view/GitPanel.tsx @@ -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 (
@@ -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 @@ -145,6 +153,22 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen onFetchCommitDiff={fetchCommitDiff} /> )} + + {activeView === 'branches' && ( + + )} )} diff --git a/src/components/git-panel/view/GitPanelHeader.tsx b/src/components/git-panel/view/GitPanelHeader.tsx index 2710d4b..9913cef 100644 --- a/src/components/git-panel/view/GitPanelHeader.tsx +++ b/src/components/git-panel/view/GitPanelHeader.tsx @@ -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; onSwitchBranch: (branchName: string) => Promise; @@ -23,6 +24,7 @@ type GitPanelHeaderProps = { onPull: () => Promise; onPush: () => Promise; onPublish: () => Promise; + 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 */}
+ {/* Branch selector */}
+ {/* Action buttons */}
{remoteStatus?.hasRemote && ( <> - {!remoteStatus.hasUpstream && ( + {!remoteStatus.hasUpstream ? ( - )} - - {remoteStatus.hasUpstream && !remoteStatus.isUpToDate && ( + ) : ( <> + {/* Fetch — always visible when remote exists */} + + {behindCount > 0 && ( )} {aheadCount > 0 && ( - )} - - {shouldShowFetchButton && ( - )} @@ -274,6 +258,21 @@ export default function GitPanelHeader({
+ {/* Inline error banner */} + {operationError && ( +
+ + {operationError} + +
+ )} + 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 (
- - + {TABS.map(({ id, label, Icon }) => ( + + ))}
); } diff --git a/src/components/git-panel/view/branches/BranchesView.tsx b/src/components/git-panel/view/branches/BranchesView.tsx new file mode 100644 index 0000000..6fd06b0 --- /dev/null +++ b/src/components/git-panel/view/branches/BranchesView.tsx @@ -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; + onCreateBranch: (branchName: string) => Promise; + onDeleteBranch: (branchName: string) => Promise; + 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 ( +
+ {/* Branch icon */} +
+ {isRemote ? : } +
+ + {/* Name + pills */} +
+
+ + {name} + + {isCurrent && ( + + current + + )} + {isRemote && !isCurrent && ( + + remote + + )} +
+ {/* Ahead/behind — only meaningful for the current branch */} + {isCurrent && (aheadCount > 0 || behindCount > 0) && ( +
+ {aheadCount > 0 && ( + ↑{aheadCount} ahead + )} + {behindCount > 0 && ( + ↓{behindCount} behind + )} +
+ )} +
+ + {/* Actions */} +
+ {isCurrent ? ( + + ) : !isRemote ? ( + <> + + + + ) : null} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Section header +// --------------------------------------------------------------------------- + +function SectionHeader({ label, count }: { label: string; count: number }) { + return ( +
+ {label} + {count} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+ +
+ ); + } + + return ( +
+ {/* Create branch button */} +
+ + {localBranches.length} local{remoteBranches.length > 0 ? `, ${remoteBranches.length} remote` : ''} + + +
+ + {/* Branch list */} +
+ {localBranches.length > 0 && ( + <> + + {localBranches.map((branch) => ( + requestSwitch(branch)} + onDelete={() => requestDelete(branch)} + /> + ))} + + )} + + {remoteBranches.length > 0 && ( + <> + + {remoteBranches.map((branch) => ( + requestSwitch(branch)} + onDelete={() => requestDelete(branch)} + /> + ))} + + )} + + {localBranches.length === 0 && remoteBranches.length === 0 && ( +
+ +

No branches found

+
+ )} +
+ + setShowNewBranchModal(false)} + onCreateBranch={onCreateBranch} + /> +
+ ); +} diff --git a/src/components/git-panel/view/changes/ChangesView.tsx b/src/components/git-panel/view/changes/ChangesView.tsx index 4810bb2..cfcb29f 100644 --- a/src/components/git-panel/view/changes/ChangesView.tsx +++ b/src/components/git-panel/view/changes/ChangesView.tsx @@ -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 ( <> - {gitStatus && !gitStatus.error && ( - setSelectedFiles(new Set(changedFiles))} - onDeselectAll={() => setSelectedFiles(new Set())} - /> - )} - {!gitStatus?.error && }
@@ -193,21 +190,71 @@ export default function ChangesView({
) : (
- { - void onOpenFile(filePath); - }} - onToggleWrapText={() => onWrapTextChange(!wrapText)} - onRequestFileAction={requestFileAction} - /> + {/* STAGED section */} +
+ + Staged ({selectedFiles.size}) + + {selectedFiles.size > 0 && ( + + )} +
+ {selectedFiles.size === 0 ? ( +
No staged files
+ ) : ( + { void onOpenFile(filePath); }} + onToggleWrapText={() => onWrapTextChange(!wrapText)} + onRequestFileAction={requestFileAction} + /> + )} + + {/* CHANGES section */} +
+ + Changes ({unstagedFiles.size}) + + {unstagedFiles.size > 0 && ( + + )} +
+ {unstagedFiles.size === 0 ? ( +
All changes staged
+ ) : ( + { void onOpenFile(filePath); }} + onToggleWrapText={() => onWrapTextChange(!wrapText)} + onRequestFileAction={requestFileAction} + /> + )}
)}
diff --git a/src/components/git-panel/view/changes/FileChangeList.tsx b/src/components/git-panel/view/changes/FileChangeList.tsx index 0438382..59f7b3d 100644 --- a/src/components/git-panel/view/changes/FileChangeList.tsx +++ b/src/components/git-panel/view/changes/FileChangeList.tsx @@ -9,6 +9,7 @@ type FileChangeListProps = { selectedFiles: Set; isMobile: boolean; wrapText: boolean; + filePaths?: Set; 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) => ( - - )), + (gitStatus[key] || []) + .filter((filePath) => !filePaths || filePaths.has(filePath)) + .map((filePath) => ( + + )), )} ); diff --git a/src/components/git-panel/view/history/CommitHistoryItem.tsx b/src/components/git-panel/view/history/CommitHistoryItem.tsx index 60fd5ab..33a4139 100644 --- a/src/components/git-panel/view/history/CommitHistoryItem.tsx +++ b/src/components/git-panel/view/history/CommitHistoryItem.tsx @@ -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 (