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

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