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
@@ -2584,6 +2584,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
selectedDraftDirectory,
selectedDraftBranchLabel,
selectedDraftBranchIsKnown,
selectedDraftDirectoryHasUncommittedChanges,
projectRootBranchOption,
worktreeBranchOptions,
draftBranchItems,
@@ -2851,6 +2852,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
selectedDirectory={selectedDraftDirectory}
selectedBranchLabel={selectedDraftBranchLabel}
selectedBranchIsKnown={selectedDraftBranchIsKnown}
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
projectRootBranchOption={projectRootBranchOption}
worktreeBranchOptions={worktreeBranchOptions}
branchItems={draftBranchItems}
@@ -2865,6 +2867,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
<MobileDraftTargetTriggers
selectedProject={selectedDraftProject}
selectedBranchLabel={selectedDraftBranchLabel}
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
showBranchSelector={shouldShowDraftBranchSelector}
theme={currentTheme}
onOpenPicker={setMobileDraftPicker}
@@ -3276,6 +3279,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
selectedDirectory={selectedDraftDirectory}
selectedBranchLabel={selectedDraftBranchLabel}
selectedBranchIsKnown={selectedDraftBranchIsKnown}
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
projectRootBranchOption={projectRootBranchOption}
worktreeBranchOptions={worktreeBranchOptions}
branchItems={draftBranchItems}
@@ -169,7 +169,9 @@ and the send path reading the same grammar.
recorded before a queued write could resurrect it.
- `state/useDraftTarget.ts` — the draft can target a directory that does not
exist yet (a worktree being created). It must survive not appearing in the
branch list, or the selector snaps back to the project root mid-creation.
branch list, or the selector snaps back to the project root mid-creation. It
also owns the advisory dirty state for the selected directory, clearing it as
soon as the target changes so a warning never names a previous branch.
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
state and registers its application shortcuts locally. The selectors only
consume their shared prefix while the draft target UI is mounted.
@@ -25,6 +25,7 @@ import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
import { normalizePath } from '../attachments/filePaths';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
import { useI18n } from '@/lib/i18n';
import { getGitStatus } from '@/lib/gitApi';
/** How long a cached branch list is served before it is refreshed. */
const BRANCHES_SWR_TTL_MS = 30_000;
@@ -98,6 +99,7 @@ export function useDraftTarget(enabled: boolean) {
const hasDraftBranchList = Boolean(selectedDraftProjectBranches?.all);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false);
const [dirtyDraftDirectory, setDirtyDraftDirectory] = React.useState<string | null>(null);
React.useEffect(() => {
if (!enabled || !selectedDraftProjectPath || !runtimeGit || selectedDraftProjectIsGitRepo !== null) {
@@ -189,6 +191,35 @@ export function useDraftTarget(enabled: boolean) {
[newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath],
);
React.useEffect(() => {
if (
!enabled
|| !selectedDraftDirectory
|| selectedDraftProject?.kind === 'chat'
|| newSessionDraft?.pendingWorktreeRequestId
|| newSessionDraft?.bootstrapPendingDirectory
) {
setDirtyDraftDirectory(null);
return;
}
let cancelled = false;
setDirtyDraftDirectory(null);
getGitStatus(selectedDraftDirectory, { mode: 'light' })
.then((status) => {
if (!cancelled && (status.files?.length ?? 0) > 0) {
setDirtyDraftDirectory(selectedDraftDirectory);
}
})
.catch(() => {
if (!cancelled) setDirtyDraftDirectory(null);
});
return () => {
cancelled = true;
};
}, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftProject?.kind]);
const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => {
const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
return Boolean(
@@ -306,6 +337,7 @@ export function useDraftTarget(enabled: boolean) {
selectedDraftDirectory,
selectedDraftBranchLabel,
selectedDraftBranchIsKnown,
selectedDraftDirectoryHasUncommittedChanges: dirtyDraftDirectory === selectedDraftDirectory,
projectRootBranchOption,
worktreeBranchOptions,
draftBranchItems,
@@ -12,6 +12,7 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
import {
Select,
@@ -44,6 +45,7 @@ export interface DraftTargetProps {
selectedDirectory: string | null;
selectedBranchLabel: string | null;
selectedBranchIsKnown: boolean;
hasUncommittedChanges: boolean;
projectRootBranchOption: BranchOption | null;
worktreeBranchOptions: readonly BranchOption[];
branchItems: readonly BranchOption[];
@@ -92,14 +94,39 @@ function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme:
}
/** Desktop: inline project and branch selects. */
/** How long the dirty-directory tooltip announces itself before becoming hover-only. */
const DIRTY_TOOLTIP_FLASH_MS = 5000;
/**
* Opens the tooltip for a few seconds when the dirty state first appears, so
* the warning is seen without hovering, then hands control back to hover.
*/
function useDirtyFlashTooltip(hasUncommittedChanges: boolean) {
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
if (!hasUncommittedChanges) {
setOpen(false);
return;
}
setOpen(true);
const timer = window.setTimeout(() => setOpen(false), DIRTY_TOOLTIP_FLASH_MS);
return () => window.clearTimeout(timer);
}, [hasUncommittedChanges]);
return { open, onOpenChange: setOpen };
}
export function DraftTargetSelectors(props: DraftTargetProps) {
const { t } = useI18n();
const dirtyTooltip = useDirtyFlashTooltip(props.hasUncommittedChanges);
const {
projects,
selectedProject,
selectedDirectory,
selectedBranchLabel,
selectedBranchIsKnown,
hasUncommittedChanges,
projectRootBranchOption,
worktreeBranchOptions,
branchItems,
@@ -176,16 +203,32 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
onValueChange={handleDirectoryChange}
disableGlobalShortcuts
>
<SelectTrigger
ref={worktreeTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
<SelectValue>
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
<TooltipTrigger asChild>
<SelectTrigger
ref={worktreeTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
{hasUncommittedChanges ? (
<Icon
name="alert"
className="size-3.5 shrink-0 text-[var(--status-warning)]"
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
/>
) : null}
<SelectValue>
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
</TooltipTrigger>
{hasUncommittedChanges ? (
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
</TooltipContent>
) : null}
</Tooltip>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
{projectRootBranchOption ? (
<SelectGroup>
@@ -228,11 +271,12 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
/** Mobile: buttons that open the bottom sheets below. */
export function MobileDraftTargetTriggers(
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'theme'>
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'hasUncommittedChanges' | 'theme'>
& { onOpenPicker: (picker: 'project' | 'branch') => void },
) {
const { t } = useI18n();
const { selectedProject, selectedBranchLabel, showBranchSelector, theme, onOpenPicker } = props;
const { selectedProject, selectedBranchLabel, showBranchSelector, hasUncommittedChanges, theme, onOpenPicker } = props;
const dirtyTooltip = useDirtyFlashTooltip(hasUncommittedChanges);
return (
<div className="mb-1.5 flex min-w-0 items-center gap-x-2 px-0.5">
@@ -247,14 +291,30 @@ export function MobileDraftTargetTriggers(
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
{showBranchSelector ? (
<button
type="button"
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('branch')}
>
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('branch')}
>
{hasUncommittedChanges ? (
<Icon
name="alert"
className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]"
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
/>
) : null}
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
</TooltipTrigger>
{hasUncommittedChanges ? (
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
</TooltipContent>
) : null}
</Tooltip>
) : null}
</div>
);
+7 -2
View File
@@ -256,6 +256,7 @@ type ContentProps = React.ComponentProps<typeof BaseTooltip.Popup> & {
sideOffset?: number;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
showArrow?: boolean;
};
function TooltipContent({
@@ -265,6 +266,7 @@ function TooltipContent({
align,
children,
style,
showArrow = false,
...props
}: ContentProps) {
return (
@@ -277,14 +279,17 @@ function TooltipContent({
// data-instant is set when moving between grouped tooltips
// (shared TooltipProvider): reposition without replaying the
// full exit/enter animation.
"oc-glass-tooltip text-[var(--surface-elevated-foreground)] border border-border/60 transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 data-[instant]:transition-none data-[instant]:duration-0 z-50 w-fit origin-[var(--transform-origin)] rounded-xl px-3 py-1.5 typography-meta text-balance overflow-hidden",
"oc-glass-tooltip text-[var(--surface-elevated-foreground)] border border-border/60 transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 data-[instant]:transition-none data-[instant]:duration-0 z-50 w-fit origin-[var(--transform-origin)] rounded-xl px-3 py-1.5 typography-meta text-balance",
showArrow ? "overflow-visible" : "overflow-hidden",
className
)}
style={{ ...style }}
{...props}
>
{children}
<BaseTooltip.Arrow className="fill-[var(--surface-elevated)] z-50 size-2" />
{showArrow ? (
<BaseTooltip.Arrow className="relative z-50 block h-1.5 w-3 overflow-clip data-[side=bottom]:top-[-6px] data-[side=left]:right-[-9px] data-[side=left]:rotate-90 data-[side=right]:left-[-9px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-6px] data-[side=top]:rotate-180 before:absolute before:bottom-0 before:left-1/2 before:block before:h-[calc(6px*sqrt(2))] before:w-[calc(6px*sqrt(2))] before:border before:border-border/60 before:bg-[var(--surface-elevated)] before:content-[''] before:[transform:translate(-50%,50%)_rotate(45deg)]" />
) : null}
</BaseTooltip.Popup>
</BaseTooltip.Positioner>
</BaseTooltip.Portal>
+97 -1
View File
@@ -58,6 +58,7 @@ import { GitEmptyState } from './git/GitEmptyState';
import { HistorySection } from './git/HistorySection';
import { ConflictDialog } from './git/ConflictDialog';
import { StashDialog } from './git/StashDialog';
import { DirtyBranchSwitchDialog } from './git/DirtyBranchSwitchDialog';
import { InProgressOperationBanner } from './git/InProgressOperationBanner';
import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection';
import { deriveBaseBranch } from './git/baseBranch';
@@ -706,6 +707,8 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}
}, [conflictStorageKey, gitDirectory]);
const [stashDialogOpen, setStashDialogOpen] = React.useState(false);
// Branch a dirty-tree switch is waiting on; null when no switch is blocked.
const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState<string | null>(null);
const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge');
const [stashDialogBranch, setStashDialogBranch] = React.useState('');
@@ -1371,6 +1374,21 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
return;
}
// A checkout over uncommitted changes can carry them onto the target
// branch, conflict, or silently rewrite what the user was editing. The
// switch is blocked until the working tree is resolved: commit, or
// explicitly revert (DirtyBranchSwitchDialog).
if ((status?.files?.length ?? 0) > 0) {
setPendingDirtySwitchBranch(normalized);
return;
}
await performCheckout(normalized);
};
const performCheckout = async (branch: string) => {
if (!gitDirectory) return;
const normalized = branch;
try {
// Picking a remote-tracking branch checks out the local branch that
// tracks it, so report the branch the repository actually landed on.
@@ -2369,7 +2387,8 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
return (
<div className={cn('flex h-full flex-col overflow-hidden')}>
<GitHeader
<GitHeader
directory={gitDirectory ?? ''}
status={status}
localBranches={localBranches}
remoteBranches={remoteBranches}
@@ -2670,6 +2689,83 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
/>
)}
<DirtyBranchSwitchDialog
open={pendingDirtySwitchBranch !== null}
onOpenChange={(open) => { if (!open) setPendingDirtySwitchBranch(null); }}
targetBranch={pendingDirtySwitchBranch ?? ''}
changedFileCount={status?.files?.length ?? 0}
onCommitAndSwitch={async (message, pushAfter) => {
const branch = pendingDirtySwitchBranch;
if (!branch || !gitDirectory) return;
const sourceBranch = status?.current ?? null;
await git.createGitCommit(gitDirectory, message, { addAll: true });
bumpIndexRevision(gitDirectory);
let pushedRemoteName: string | null = null;
if (pushAfter) {
const trackingRemoteName = status?.tracking?.split('/')[0];
const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0];
try {
if (!remote) throw new Error(t('mobile.changes.noRemote'));
await git.gitPush(gitDirectory, status?.tracking
? { remote: remote.name }
: { remote: remote.name, branch: sourceBranch ?? undefined, options: ['--set-upstream'] });
pushedRemoteName = remote.name;
} catch (error) {
// The commit stands, so nothing is lost — but the switch is
// cancelled: the user must see the failed push on the branch it
// belongs to instead of discovering it later from elsewhere.
console.error('Push after commit failed:', error);
toast.error(t('gitView.dirtySwitch.pushFailed'));
await refreshStatusAndBranches();
await refreshLog();
setPendingDirtySwitchBranch(null);
return;
}
}
// Without a push the commit stays local on the branch being left;
// after the switch nothing on screen would say so, so the toast must.
toast.success(pushedRemoteName
? t('gitView.toast.pushedToUpstream', { name: pushedRemoteName })
: sourceBranch
? t('gitView.dirtySwitch.committedNotPushed', { branch: sourceBranch })
: t('gitView.toast.commitCreated'));
await refreshStatusAndBranches();
await refreshLog();
setPendingDirtySwitchBranch(null);
await performCheckout(branch);
}}
onGenerateMessage={async () => {
if (!gitDirectory) return '';
const paths = (status?.files ?? []).map((file) => file.path).sort();
const { message } = await generateSessionCommitMessage(gitDirectory, paths);
const subject = message.subject?.trim() ?? '';
// Same gitmoji decoration as the commit panel's Generate button.
if (subject && settingsGitmojiEnabled && gitmojiEmojis.length > 0) {
const match = matchGitmojiFromSubject(subject, gitmojiEmojis);
if (match && !subject.startsWith(match.code) && !subject.startsWith(match.emoji)) {
return `${match.code} ${subject}`;
}
}
return subject;
}}
onRevertAndSwitch={async () => {
const branch = pendingDirtySwitchBranch;
if (!branch || !gitDirectory) return;
const paths = (status?.files ?? []).map((file) => file.path);
await handleRevertPaths(paths, true, 'all');
// The revert reports its own partial failures; the checkout happens
// only once the tree is verifiably clean, so a half-reverted tree is
// never switched over.
const fresh = await git.getGitStatus(gitDirectory);
if (!fresh.isClean && (fresh.files?.length ?? 0) > 0) {
toast.error(t('gitView.dirtySwitch.revertIncomplete'));
return;
}
setPendingDirtySwitchBranch(null);
await performCheckout(branch);
}}
/>
<StashDialog
open={stashDialogOpen}
onOpenChange={setStashDialogOpen}
@@ -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
@@ -0,0 +1,197 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
interface DirtyBranchSwitchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
targetBranch: string;
changedFileCount: number;
/**
* Commit every uncommitted change with this message pushing the commit
* first when the user opted in then perform the checkout.
*/
onCommitAndSwitch: (message: string, pushAfter: boolean) => Promise<void>;
/** Produce an AI commit message for the current changes, same as the commit panel. */
onGenerateMessage: () => Promise<string>;
/** Revert every uncommitted change, then perform the checkout. */
onRevertAndSwitch: () => Promise<void>;
}
/**
* Switching branches with uncommitted changes is blocked so a checkout can
* never silently carry, conflict with, or drop the user's work. The user
* resolves the working tree with one explicit choice: commit and switch
* (message written, generated on demand, or generated automatically when the
* field is left empty same pipeline as the commit panel), or revert and
* switch. Cancel leaves everything untouched for a fully manual flow.
*/
export const DirtyBranchSwitchDialog: React.FC<DirtyBranchSwitchDialogProps> = ({
open,
onOpenChange,
targetBranch,
changedFileCount,
onCommitAndSwitch,
onGenerateMessage,
onRevertAndSwitch,
}) => {
const { t } = useI18n();
const [commitMessage, setCommitMessage] = React.useState('');
const [pendingAction, setPendingAction] = React.useState<'generate' | 'commit' | 'revert' | null>(null);
const [pushAfter, setPushAfter] = React.useState(false);
const isProcessing = pendingAction !== null;
React.useEffect(() => {
if (!open) {
setCommitMessage('');
setPushAfter(false);
}
}, [open]);
const handleGenerate = async () => {
setPendingAction('generate');
try {
const generated = await onGenerateMessage();
if (generated) setCommitMessage(generated);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed'));
} finally {
setPendingAction(null);
}
};
// An empty field is not an obstacle: the message is generated on the spot,
// through the same pipeline as the commit panel, and the commit proceeds.
const handleCommitAndSwitch = async () => {
setPendingAction('commit');
try {
let message = commitMessage.trim();
if (!message) {
message = (await onGenerateMessage()).trim();
if (!message) {
toast.error(t('gitView.toast.enterCommitMessage'));
return;
}
setCommitMessage(message);
}
await onCommitAndSwitch(message, pushAfter);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed'));
} finally {
setPendingAction(null);
}
};
const handleRevertAndSwitch = async () => {
setPendingAction('revert');
try {
await onRevertAndSwitch();
} catch (err) {
toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed'));
} finally {
setPendingAction(null);
}
};
return (
<Dialog open={open} onOpenChange={(next) => { if (!isProcessing) onOpenChange(next); }}>
<DialogContent className="max-w-md w-[calc(100vw-2rem)]">
<div className="flex flex-col gap-4">
<DialogHeader>
<div className="flex items-center gap-2">
<Icon name="alert" className="size-5 shrink-0 text-[var(--status-warning)]" />
<DialogTitle>{t('gitView.dirtySwitch.title')}</DialogTitle>
</div>
<DialogDescription>
{changedFileCount === 1
? t('gitView.dirtySwitch.descriptionSingle', { branch: targetBranch })
: t('gitView.dirtySwitch.descriptionPlural', { branch: targetBranch, count: changedFileCount })}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 rounded-lg border border-border/60 px-3 py-2 focus-within:border-border">
<input
value={commitMessage}
onChange={(event) => setCommitMessage(event.target.value)}
placeholder={t('gitView.commit.messagePlaceholder')}
disabled={isProcessing}
onKeyDown={(event) => {
if (event.key === 'Enter' && !isProcessing) {
event.preventDefault();
void handleCommitAndSwitch();
}
}}
className="min-w-0 flex-1 bg-transparent typography-meta text-foreground outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
onClick={() => { void handleGenerate(); }}
disabled={isProcessing}
aria-label={t('gitView.commit.generate')}
title={t('gitView.commit.generate')}
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-50"
>
{pendingAction === 'generate' ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name="ai-generate-2" className="size-4 text-primary" />
)}
</button>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={pushAfter}
onChange={setPushAfter}
disabled={isProcessing}
ariaLabel={t('gitView.dirtySwitch.pushAfterCommit')}
/>
<span
className="typography-ui-label text-foreground cursor-pointer select-none"
onClick={() => !isProcessing && setPushAfter(!pushAfter)}
>
{t('gitView.dirtySwitch.pushAfterCommit')}
</span>
</div>
<div className="flex items-center justify-between gap-2 pt-1">
<Button
variant="destructive"
size="sm"
onClick={() => { void handleRevertAndSwitch(); }}
disabled={isProcessing}
className="gap-2"
>
{pendingAction === 'revert' ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : null}
{t('gitView.dirtySwitch.revertAndSwitch')}
</Button>
<Button
variant="default"
size="sm"
onClick={() => { void handleCommitAndSwitch(); }}
disabled={isProcessing}
className="gap-2"
>
{pendingAction === 'commit' ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : null}
{t('gitView.dirtySwitch.commitAndSwitch')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -22,10 +22,12 @@ import type {
GitHubChecksSummary,
} from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { useDeviceInfo } from '@/lib/device';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
interface GitHeaderProps {
directory: string;
status: GitStatus | null;
localBranches: string[];
remoteBranches: string[];
@@ -240,6 +242,7 @@ const UpstreamStatusPill: React.FC<UpstreamStatusPillProps> = ({
};
export const GitHeader: React.FC<GitHeaderProps> = ({
directory,
status,
localBranches,
remoteBranches,
@@ -272,6 +275,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
repositoryRoot,
}) => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
if (!status) {
return null;
}
@@ -425,20 +429,23 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
<header className="@container/git-header px-3 py-2 bg-transparent">
<div className="flex items-center justify-between gap-2 min-w-0">
<div className="flex min-w-0 flex-1 items-center gap-1">
{isWorktreeMode ? (
{isWorktreeMode && !isMobile ? (
<WorktreeBranchDisplay
currentBranch={status.current}
onRename={onRenameBranch}
/>
) : (
<BranchSelector
directory={directory}
currentBranch={status.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branchInfo}
currentBranchAhead={status.ahead}
onCheckout={onCheckoutBranch}
onCreate={onCreateBranch}
remotes={remotes}
switchBlockedNotice={(status.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null}
/>
)}
{repositoryOptionsForPicker.length > 0 && onSelectRepository ? (
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { getRecentBranches, rememberRecentBranch } from './recentBranches';
class TestStorage implements Storage {
#values = new Map<string, string>();
get length(): number { return this.#values.size; }
clear(): void { this.#values.clear(); }
getItem(key: string): string | null { return this.#values.get(key) ?? null; }
key(index: number): string | null { return [...this.#values.keys()][index] ?? null; }
removeItem(key: string): void { this.#values.delete(key); }
setItem(key: string, value: string): void { this.#values.set(key, value); }
}
const originalLocalStorage = globalThis.localStorage;
let storage: TestStorage;
beforeEach(() => {
storage = new TestStorage();
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage });
});
afterEach(() => {
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: originalLocalStorage });
});
describe('recent Git branches', () => {
test('persists a branch list for a later UI mount', () => {
rememberRecentBranch('/repo', 'feature/one');
rememberRecentBranch('/repo', 'feature/two');
expect(getRecentBranches('/repo')).toEqual(['feature/two', 'feature/one']);
});
test('keeps only the five most recently used branches', () => {
for (let index = 1; index <= 6; index += 1) {
rememberRecentBranch('/repo', `feature/${index}`);
}
expect(getRecentBranches('/repo')).toEqual([
'feature/6', 'feature/5', 'feature/4', 'feature/3', 'feature/2',
]);
});
});
@@ -0,0 +1,38 @@
import { normalizePath } from '@/lib/pathNormalization';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { z } from 'zod';
const KEY = 'openchamber:recent-git-branches:v1';
const LIMIT = 5;
const entriesSchema = z.record(z.string(), z.array(z.string()));
type Entries = z.infer<typeof entriesSchema>;
const keyFor = (directory: string): string | null => {
const normalized = normalizePath(directory);
return normalized ? `${getRuntimeKey()}:${normalized}` : null;
};
const read = (): Entries => {
try {
const raw = localStorage.getItem(KEY);
const parsed = entriesSchema.safeParse(raw ? JSON.parse(raw) : null);
return parsed.success
? Object.fromEntries(Object.entries(parsed.data).map(([key, branches]) => [key, branches.slice(0, LIMIT)]))
: {};
} catch { return {}; }
};
export const getRecentBranches = (directory: string): string[] => {
const key = keyFor(directory);
return key ? read()[key] ?? [] : [];
};
export const rememberRecentBranch = (directory: string, branch: string): string[] => {
const key = keyFor(directory);
if (!key || !branch) return [];
const entries = read();
const next = [branch, ...(entries[key] ?? []).filter((item) => item !== branch)].slice(0, LIMIT);
try { localStorage.setItem(KEY, JSON.stringify({ ...entries, [key]: next })); } catch { /* convenience only */ }
return next;
};