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:
@@ -5,7 +5,9 @@ import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel';
|
||||
import { BranchSelector } from '@/components/views/git/BranchSelector';
|
||||
import { CommitSection } from '@/components/views/git/CommitSection';
|
||||
import { DirtyBranchSwitchDialog } from '@/components/views/git/DirtyBranchSwitchDialog';
|
||||
import { SyncActions } from '@/components/views/git/SyncActions';
|
||||
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
@@ -19,6 +21,7 @@ import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
|
||||
import {
|
||||
useGitStore,
|
||||
useGitStatus,
|
||||
useGitBranches,
|
||||
useIsGitRepo,
|
||||
useGitLoadingStatus,
|
||||
} from '@/stores/useGitStore';
|
||||
@@ -65,6 +68,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null);
|
||||
const currentDirectory = gitDirectory ?? rootDirectory;
|
||||
const status = useGitStatus(currentDirectory || null);
|
||||
const branches = useGitBranches(currentDirectory || null);
|
||||
const isGitRepo = useIsGitRepo(currentDirectory || null);
|
||||
const isLoadingStatus = useGitLoadingStatus(currentDirectory || null);
|
||||
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
||||
@@ -104,6 +108,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
|
||||
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
||||
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
||||
const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState<string | null>(null);
|
||||
|
||||
const changeEntries = React.useMemo(() => {
|
||||
const files = status?.files ?? [];
|
||||
@@ -157,6 +162,55 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
}
|
||||
}, [currentDirectory, fetchBranches, fetchStatus, git, t]);
|
||||
|
||||
const localBranches = React.useMemo(
|
||||
() => (branches?.all ?? []).filter((branch) => !branch.startsWith('remotes/')).sort(),
|
||||
[branches],
|
||||
);
|
||||
|
||||
const remoteBranches = React.useMemo(
|
||||
() => (branches?.all ?? [])
|
||||
.filter((branch) => branch.startsWith('remotes/'))
|
||||
.map((branch) => branch.replace(/^remotes\//, ''))
|
||||
.sort(),
|
||||
[branches],
|
||||
);
|
||||
|
||||
const performCheckout = React.useCallback(async (branch: string) => {
|
||||
if (!currentDirectory) return;
|
||||
const normalized = branch.replace(/^remotes\//, '');
|
||||
try {
|
||||
const result = await git.checkoutBranch(currentDirectory, normalized);
|
||||
toast.success(t('gitView.toast.checkedOut', { name: result.branch || normalized }));
|
||||
await refreshStatusAndBranches();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.checkoutFailed', { name: normalized }));
|
||||
}
|
||||
}, [currentDirectory, git, refreshStatusAndBranches, t]);
|
||||
|
||||
const handleCheckoutBranch = React.useCallback((branch: string) => {
|
||||
const normalized = branch.replace(/^remotes\//, '');
|
||||
if ((status?.files?.length ?? 0) > 0) {
|
||||
setPendingDirtySwitchBranch(normalized);
|
||||
return;
|
||||
}
|
||||
void performCheckout(normalized);
|
||||
}, [performCheckout, status?.files]);
|
||||
|
||||
const handleCreateBranch = React.useCallback(async (branch: string, remote?: GitRemote) => {
|
||||
if (!currentDirectory) return;
|
||||
try {
|
||||
await git.createBranch(currentDirectory, branch, status?.current ?? 'HEAD');
|
||||
await git.checkoutBranch(currentDirectory, branch);
|
||||
if (remote) {
|
||||
await git.gitPush(currentDirectory, { remote: remote.name, branch, options: ['--set-upstream'] });
|
||||
}
|
||||
await refreshStatusAndBranches();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.toast.createBranchFailed'));
|
||||
throw error;
|
||||
}
|
||||
}, [currentDirectory, git, refreshStatusAndBranches, status?.current, t]);
|
||||
|
||||
const refreshRemotes = React.useCallback(async () => {
|
||||
if (!currentDirectory) {
|
||||
setRemotes([]);
|
||||
@@ -542,9 +596,19 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
<h2 className="typography-ui-label text-foreground">{t('mobile.nav.changes')}</h2>
|
||||
<p className="truncate typography-micro text-muted-foreground">
|
||||
{status?.current || currentDirectory}
|
||||
</p>
|
||||
<BranchSelector
|
||||
currentBranch={status?.current}
|
||||
localBranches={localBranches}
|
||||
remoteBranches={remoteBranches}
|
||||
branchInfo={branches?.branches}
|
||||
currentBranchAhead={status?.ahead}
|
||||
onCheckout={(branch) => void handleCheckoutBranch(branch)}
|
||||
onCreate={handleCreateBranch}
|
||||
remotes={effectiveRemotes}
|
||||
disabled={isLoadingStatus}
|
||||
directory={currentDirectory}
|
||||
switchBlockedNotice={(status?.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null}
|
||||
/>
|
||||
</div>
|
||||
<SyncActions
|
||||
syncAction={syncAction}
|
||||
@@ -594,6 +658,61 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
<MobileChangesState icon message={t('gitView.empty.cleanTitle')} description={t('mobile.changes.cleanDescription')} />
|
||||
</div>
|
||||
)}
|
||||
<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 || !currentDirectory) return;
|
||||
const sourceBranch = status?.current ?? null;
|
||||
await git.createGitCommit(currentDirectory, message, { addAll: true });
|
||||
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(currentDirectory, status?.tracking
|
||||
? { remote: remote.name }
|
||||
: { remote: remote.name, branch: sourceBranch ?? undefined, options: ['--set-upstream'] });
|
||||
pushedRemoteName = remote.name;
|
||||
} catch {
|
||||
toast.error(t('gitView.dirtySwitch.pushFailed'));
|
||||
await refreshStatusAndBranches();
|
||||
setPendingDirtySwitchBranch(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
toast.success(sourceBranch
|
||||
? pushedRemoteName
|
||||
? t('gitView.toast.pushedToUpstream', { name: pushedRemoteName })
|
||||
: t('gitView.dirtySwitch.committedNotPushed', { branch: sourceBranch })
|
||||
: t('gitView.toast.commitCreated'));
|
||||
await refreshStatusAndBranches();
|
||||
setPendingDirtySwitchBranch(null);
|
||||
await performCheckout(branch);
|
||||
}}
|
||||
onGenerateMessage={async () => {
|
||||
if (!currentDirectory) return '';
|
||||
const paths = (status?.files ?? []).map((file) => file.path).sort();
|
||||
const { message } = await generateCommitMessage(currentDirectory, paths);
|
||||
return message.subject?.trim() ?? '';
|
||||
}}
|
||||
onRevertAndSwitch={async () => {
|
||||
const branch = pendingDirtySwitchBranch;
|
||||
if (!branch || !currentDirectory) return;
|
||||
await handleRevertAll((status?.files ?? []).map((file) => file.path));
|
||||
const fresh = await git.getGitStatus(currentDirectory);
|
||||
if (!fresh.isClean && (fresh.files?.length ?? 0) > 0) {
|
||||
toast.error(t('gitView.dirtySwitch.revertIncomplete'));
|
||||
return;
|
||||
}
|
||||
setPendingDirtySwitchBranch(null);
|
||||
await performCheckout(branch);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user