Improve branch switch safety and recent branch status (#3302)

* feat(ui): block branch switches on dirty trees

* feat(ui): show unpushed commits in git branch selector

* feat(ui): show recent branches in git selector

* fix(ui): persist recent branch status

* feat(ui): add mobile branch picker

* fix(ui): guard mobile branch checkout

* fix(i18n): restore Turkish git empty state labels

* feat(ui): flag dirty draft directories on the branch selector

Replaces the draft dirty-directory banner with an indicator on the branch
selector: a warning icon plus a hover tooltip that opens by itself for five
seconds when the dirty state first appears, then stays hover-only. The copy
states the situation and the options (commit or worktree) without prescribing
either.

* feat(ui): optional push in the dirty branch switch dialog

Commit-and-switch gains an opt-in "Push after commit" checkbox. When the
push fails the commit stands but the switch is cancelled with an explicit
toast, so the user is never moved off a branch without knowing its push did
not happen. Without the checkbox the toast states the commit is local only.

* fix(i18n): align dirty-directory copy across locales

* fix(a11y): name the unpushed-commit badge in the branch picker

The badge showed a bare arrow and number with no accessible name or tooltip.
Both the desktop recents list and the mobile picker now carry a localized
"N commits not pushed" title and aria-label.

* fix(mobile): push before switching dirty branches

Honor the dirty-switch dialog's push option on the mobile Changes surface.
A failed push leaves the new commit on its source branch, refreshes state, and
cancels checkout. Mobile branch selection now also shows the existing dirty
switch notice.
This commit is contained in:
𝖎𝖚𝖑𝖎𝖎𝖆
2026-09-03 01:42:50 +03:00
committed by GitHub
parent 40e4b6f857
commit e885afbe89
35 changed files with 1105 additions and 28 deletions
@@ -16,9 +16,13 @@ import {
} from '@/components/ui/command';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import type { GitRemote } from '@/lib/api/types';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { useI18n } from '@/lib/i18n';
import { useDeviceInfo } from '@/lib/device';
import { getGitUnpushedBranchCounts } from '@/lib/gitApi';
import { getRecentBranches, rememberRecentBranch } from './recentBranches';
interface BranchInfo {
ahead?: number;
@@ -30,10 +34,18 @@ interface BranchSelectorProps {
localBranches: string[];
remoteBranches: string[];
branchInfo: Record<string, BranchInfo> | undefined;
currentBranchAhead?: number;
onCheckout: (branch: string) => void;
onCreate: (name: string, remote?: GitRemote) => Promise<void>;
remotes?: GitRemote[];
disabled?: boolean;
directory: string;
/**
* Shown above the branch list while the working tree has uncommitted
* changes: selecting a branch will not switch directly but opens the
* commit-or-revert resolution instead.
*/
switchBlockedNotice?: string | null;
}
const sanitizeBranchNameInput = (value: string): string => {
@@ -54,18 +66,24 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
localBranches,
remoteBranches,
branchInfo,
currentBranchAhead = 0,
onCheckout,
onCreate,
remotes = [],
disabled = false,
directory,
switchBlockedNotice = null,
}) => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
const [isOpen, setIsOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const [showCreate, setShowCreate] = React.useState(false);
const [showRemoteSelect, setShowRemoteSelect] = React.useState(false);
const [newBranchName, setNewBranchName] = React.useState('');
const [isCreating, setIsCreating] = React.useState(false);
const [recentBranches, setRecentBranches] = React.useState<string[]>(() => getRecentBranches(directory));
const [unpushedCounts, setUnpushedCounts] = React.useState<Record<string, number>>({});
const createInputRef = React.useRef<HTMLInputElement>(null);
const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
@@ -94,6 +112,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
setIsOpen(false);
return;
}
setRecentBranches(rememberRecentBranch(directory, branch));
onCheckout(branch);
setIsOpen(false);
setSearch('');
@@ -158,6 +177,109 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
}
}, [isOpen]);
React.useEffect(() => {
if (!directory) return;
setRecentBranches(currentBranch
? rememberRecentBranch(directory, currentBranch)
: getRecentBranches(directory));
}, [currentBranch, directory]);
React.useEffect(() => {
if (!isOpen) return;
const branches = recentBranches.filter((branch) => localBranches.includes(branch)).slice(0, 5);
if (branches.length === 0) return setUnpushedCounts({});
let cancelled = false;
getGitUnpushedBranchCounts(directory, branches)
.then(({ counts }) => { if (!cancelled) setUnpushedCounts(counts); })
.catch(() => { if (!cancelled) setUnpushedCounts({}); });
return () => { cancelled = true; };
}, [directory, isOpen, localBranches, recentBranches]);
if (isMobile) {
const recentLocalBranches = recentBranches.filter((branch) => localBranches.includes(branch));
const renderBranch = (branch: string, remote = false) => {
const ahead = unpushedCounts[branch] ?? (branch === currentBranch ? currentBranchAhead : 0);
const aheadLabel = ahead === 1
? t('gitView.branch.unpushedSingle')
: t('gitView.branch.unpushedPlural', { count: ahead });
return (
<button
key={`${remote ? 'remote' : 'local'}-${branch}`}
type="button"
onClick={() => handleCheckout(branch)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-2.5 text-left typography-ui-label hover:bg-interactive-hover"
>
<span className="min-w-0 flex-1 truncate">{branch}</span>
{ahead > 0 ? (
<span
className="inline-flex shrink-0 items-center gap-1 typography-micro text-muted-foreground"
title={aheadLabel}
aria-label={aheadLabel}
>
<Icon name="arrow-up" className="size-3" aria-hidden="true" />
<span aria-hidden="true">{ahead}</span>
</span>
) : null}
{currentBranch === branch ? <Icon name="check" className="size-4 shrink-0 text-primary" /> : null}
</button>
);
};
return (
<>
<Button
variant="ghost"
size="sm"
className="h-8 min-w-0 max-w-full justify-start gap-1.5 px-2 py-1"
disabled={disabled}
onClick={() => setIsOpen(true)}
>
<Icon name="git-branch" className="size-4 text-primary" />
<span className="min-w-0 truncate font-medium text-left">
{currentBranch || t('gitView.branch.detachedHead')}
</span>
<Icon name="arrow-down-s" className="size-4 opacity-60" />
</Button>
<MobileOverlayPanel
open={isOpen}
title={t('gitView.branch.currentBranchTooltip')}
onClose={() => setIsOpen(false)}
>
<div className="flex flex-col gap-2 px-3 pb-4 pt-1">
<input
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('gitView.branch.searchPlaceholder')}
className="h-9 w-full rounded-lg border border-border bg-transparent px-3 typography-meta outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary"
/>
{switchBlockedNotice ? (
<div className="flex items-start gap-2 px-2 py-1">
<Icon name="alert" className="mt-0.5 size-3.5 shrink-0 text-[var(--status-warning)]" aria-hidden="true" />
<span className="typography-micro text-muted-foreground">{switchBlockedNotice}</span>
</div>
) : null}
{recentLocalBranches.length > 0 ? (
<section>
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.recentBranches')}</p>
{recentLocalBranches.map((branch) => renderBranch(branch))}
</section>
) : null}
<section>
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.localBranches')}</p>
{filteredLocal.map((branch) => renderBranch(branch))}
</section>
<section>
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.remoteBranches')}</p>
{filteredRemote.map((branch) => renderBranch(branch, true))}
</section>
</div>
</MobileOverlayPanel>
</>
);
}
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
@@ -192,6 +314,12 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
onValueChange={setSearch}
onKeyDown={stopDropdownTypeahead}
/>
{switchBlockedNotice ? (
<div className="flex items-start gap-2 border-b border-border/60 px-3 py-2">
<Icon name="alert" className="mt-0.5 size-3.5 shrink-0 text-[var(--status-warning)]" aria-hidden="true" />
<span className="typography-micro text-muted-foreground">{switchBlockedNotice}</span>
</div>
) : null}
<CommandList
scrollbarClassName="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
disableHorizontal
@@ -288,6 +416,38 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
<CommandSeparator />
{recentBranches.filter((branch) => localBranches.includes(branch)).length > 0 ? (
<>
<CommandGroup heading={t('gitView.branch.recentBranches')}>
{recentBranches.filter((branch) => localBranches.includes(branch)).map((branch) => (
<CommandItem key={`recent-${branch}`} onSelect={() => handleCheckout(branch)}>
<span className="flex flex-1 items-center gap-2 min-w-0">
<span className="typography-ui-label text-foreground truncate">{branch}</span>
{(() => {
const ahead = unpushedCounts[branch] ?? (branch === currentBranch ? currentBranchAhead : 0);
const aheadLabel = ahead === 1
? t('gitView.branch.unpushedSingle')
: t('gitView.branch.unpushedPlural', { count: ahead });
return ahead > 0 ? (
<span
className="inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 typography-micro text-muted-foreground"
title={aheadLabel}
aria-label={aheadLabel}
>
<Icon name="arrow-up" className="size-3" aria-hidden="true" />
<span aria-hidden="true">{ahead}</span>
</span>
) : null;
})()}
</span>
{currentBranch === branch ? <span className="typography-micro text-primary">{t('gitView.branch.currentBadge')}</span> : null}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
) : null}
<CommandGroup heading={t('gitView.branch.localBranches')}>
{filteredLocal.map((branch) => (
<CommandItem