feat: Fix new branch issues and address code review comments

This commit is contained in:
gsxdsm
2026-02-18 21:36:00 -08:00
parent 2d907938cc
commit 53d07fefb8
30 changed files with 1604 additions and 367 deletions

View File

@@ -534,8 +534,14 @@ export function GitDiffPanel({
onStart: () => void,
onFinally: () => void
) => {
if (!worktreePath && !projectPath) return;
onStart();
if (!worktreePath && !projectPath) {
toast.error(failurePrefix, {
description: 'No project or worktree path configured',
});
onFinally();
return;
}
try {
const api = getElectronAPI();
let result: { success: boolean; error?: string } | undefined;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -55,11 +55,6 @@ function formatResetTime(unixTimestamp: number, isMilliseconds = false): string
return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
}
// Legacy alias for Codex
function formatCodexResetTime(unixTimestamp: number): string {
return formatResetTime(unixTimestamp, false);
}
// Helper to format window duration for Codex
function getCodexWindowLabel(durationMins: number): { title: string; subtitle: string } {
if (durationMins < 60) {
@@ -95,6 +90,8 @@ export function UsagePopover() {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai' | 'gemini'>('claude');
// Track whether the user has manually selected a tab so we don't override their choice
const userHasSelected = useRef(false);
// Check authentication status
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
@@ -190,8 +187,24 @@ export function UsagePopover() {
return { code: ERROR_CODES.AUTH_ERROR, message };
}, [geminiQueryError]);
// Determine which tab to show by default
// Determine which tab to show by default.
// Only apply the default when the popover opens (open transitions to true) and the user has
// not yet made a manual selection during this session. This prevents auth-flag re-renders from
// overriding a tab the user explicitly clicked.
useEffect(() => {
if (!open) {
// Reset the user-selection guard each time the popover closes so the next open always gets
// a fresh default.
userHasSelected.current = false;
return;
}
// The user already picked a tab respect their choice.
if (userHasSelected.current) {
return;
}
// Pick the first available provider in priority order.
if (isClaudeAuthenticated) {
setActiveTab('claude');
} else if (isCodexAuthenticated) {
@@ -201,7 +214,13 @@ export function UsagePopover() {
} else if (isGeminiAuthenticated) {
setActiveTab('gemini');
}
}, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated, isGeminiAuthenticated]);
}, [
open,
isClaudeAuthenticated,
isCodexAuthenticated,
isZaiAuthenticated,
isGeminiAuthenticated,
]);
// Check if data is stale (older than 2 minutes)
const isClaudeStale = useMemo(() => {
@@ -463,7 +482,10 @@ export function UsagePopover() {
>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'claude' | 'codex' | 'zai' | 'gemini')}
onValueChange={(v) => {
userHasSelected.current = true;
setActiveTab(v as 'claude' | 'codex' | 'zai' | 'gemini');
}}
>
{/* Tabs Header */}
{tabCount > 1 && (
@@ -684,7 +706,7 @@ export function UsagePopover() {
.subtitle
}
percentage={codexUsage.rateLimits.primary.usedPercent}
resetText={formatCodexResetTime(codexUsage.rateLimits.primary.resetsAt)}
resetText={formatResetTime(codexUsage.rateLimits.primary.resetsAt)}
isPrimary={true}
stale={isCodexStale}
pacePercentage={getExpectedCodexPacePercentage(
@@ -705,7 +727,7 @@ export function UsagePopover() {
.subtitle
}
percentage={codexUsage.rateLimits.secondary.usedPercent}
resetText={formatCodexResetTime(codexUsage.rateLimits.secondary.resetsAt)}
resetText={formatResetTime(codexUsage.rateLimits.secondary.resetsAt)}
stale={isCodexStale}
pacePercentage={getExpectedCodexPacePercentage(
codexUsage.rateLimits.secondary.resetsAt,

View File

@@ -27,6 +27,11 @@ import { toast } from 'sonner';
import { Check, ChevronsUpDown, GitBranchPlus, Globe, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import {
StashConfirmDialog,
type UncommittedChangesInfo,
type StashConfirmAction,
} from './stash-confirm-dialog';
interface WorktreeInfo {
path: string;
@@ -67,6 +72,17 @@ export function CreateBranchDialog({
const baseBranchTriggerRef = useRef<HTMLButtonElement>(null);
const [baseBranchTriggerWidth, setBaseBranchTriggerWidth] = useState<number>(0);
// Stash confirmation state
const [showStashConfirm, setShowStashConfirm] = useState(false);
const [uncommittedChanges, setUncommittedChanges] = useState<UncommittedChangesInfo | null>(null);
// Keep a ref in sync with baseBranch so fetchBranches can read the latest value
// without needing it in its dependency array (which would cause re-fetch loops)
const baseBranchRef = useRef<string>(baseBranch);
useEffect(() => {
baseBranchRef.current = baseBranch;
}, [baseBranch]);
const fetchBranches = useCallback(async () => {
if (!worktree) return;
@@ -81,7 +97,8 @@ export function CreateBranchDialog({
// Only set the default base branch if no branch is currently selected,
// or if the currently selected branch is no longer present in the fetched list
const branchNames = result.result.branches.map((b: BranchInfo) => b.name);
if (!baseBranch || !branchNames.includes(baseBranch)) {
const currentBaseBranch = baseBranchRef.current;
if (!currentBaseBranch || !branchNames.includes(currentBaseBranch)) {
if (result.result.currentBranch) {
setBaseBranch(result.result.currentBranch);
}
@@ -92,7 +109,7 @@ export function CreateBranchDialog({
} finally {
setIsLoadingBranches(false);
}
}, [worktree, baseBranch]);
}, [worktree]);
// Reset state and fetch branches when dialog opens
useEffect(() => {
@@ -102,6 +119,8 @@ export function CreateBranchDialog({
setError(null);
setBranches([]);
setBaseBranchPopoverOpen(false);
setShowStashConfirm(false);
setUncommittedChanges(null);
fetchBranches();
}
}, [open, fetchBranches]);
@@ -118,6 +137,65 @@ export function CreateBranchDialog({
return () => observer.disconnect();
}, [baseBranchPopoverOpen]);
/**
* Execute the actual branch creation, optionally with stash handling
*/
const doCreate = useCallback(
async (stashChanges: boolean) => {
if (!worktree || !branchName.trim()) return;
setIsCreating(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.checkoutBranch) {
toast.error('Branch API not available');
return;
}
const selectedBase = baseBranch || undefined;
const result = await api.worktree.checkoutBranch(
worktree.path,
branchName.trim(),
selectedBase,
stashChanges,
true // includeUntracked
);
if (result.success && result.result) {
// Check if there were conflicts from stash reapply
if (result.result.hasConflicts) {
toast.warning('Branch created with conflicts', {
description: result.result.message,
duration: 8000,
});
} else {
const desc = result.result.stashedChanges
? 'Local changes were stashed and reapplied'
: undefined;
toast.success(result.result.message, { description: desc });
}
onCreated();
onOpenChange(false);
} else {
setError(result.error || 'Failed to create branch');
}
} catch (err) {
logger.error('Create branch failed:', err);
setError('Failed to create branch');
} finally {
setIsCreating(false);
setShowStashConfirm(false);
}
},
[worktree, branchName, baseBranch, onCreated, onOpenChange]
);
/**
* Handle the initial "Create Branch" click.
* Checks for uncommitted changes first and shows confirmation if needed.
*/
const handleCreate = async () => {
if (!worktree || !branchName.trim()) return;
@@ -128,39 +206,53 @@ export function CreateBranchDialog({
return;
}
setIsCreating(true);
setError(null);
// Check for uncommitted changes before proceeding
try {
const api = getElectronAPI();
if (!api?.worktree?.checkoutBranch) {
toast.error('Branch API not available');
const api = getHttpApiClient();
const changesResult = await api.worktree.checkChanges(worktree.path);
if (changesResult.success && changesResult.result?.hasChanges) {
// Show the stash confirmation dialog
setUncommittedChanges({
staged: changesResult.result.staged,
unstaged: changesResult.result.unstaged,
untracked: changesResult.result.untracked,
totalFiles: changesResult.result.totalFiles,
});
setShowStashConfirm(true);
return;
}
// Pass baseBranch if user selected one different from the current branch
const selectedBase = baseBranch || undefined;
const result = await api.worktree.checkoutBranch(
worktree.path,
branchName.trim(),
selectedBase
);
if (result.success && result.result) {
toast.success(result.result.message);
onCreated();
onOpenChange(false);
} else {
setError(result.error || 'Failed to create branch');
}
} catch (err) {
logger.error('Create branch failed:', err);
setError('Failed to create branch');
} finally {
setIsCreating(false);
// If we can't check for changes, proceed without stashing
logger.warn('Failed to check for uncommitted changes, proceeding without stash:', err);
}
// No changes detected, proceed directly
doCreate(false);
};
/**
* Handle the user's decision in the stash confirmation dialog
*/
const handleStashConfirmAction = useCallback(
(action: StashConfirmAction) => {
switch (action) {
case 'stash-and-proceed':
doCreate(true);
break;
case 'proceed-without-stash':
doCreate(false);
break;
case 'cancel':
setShowStashConfirm(false);
break;
}
},
[doCreate]
);
// Separate local and remote branches
const localBranches = useMemo(() => branches.filter((b) => !b.isRemote), [branches]);
const remoteBranches = useMemo(() => branches.filter((b) => b.isRemote), [branches]);
@@ -174,124 +266,94 @@ export function CreateBranchDialog({
}, [baseBranch, branches]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranchPlus className="w-5 h-5" />
Create New Branch
</DialogTitle>
<DialogDescription>Create a new branch from a base branch</DialogDescription>
</DialogHeader>
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranchPlus className="w-5 h-5" />
Create New Branch
</DialogTitle>
<DialogDescription>Create a new branch from a base branch</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="branch-name">Branch Name</Label>
<Input
id="branch-name"
placeholder="feature/my-new-feature"
value={branchName}
onChange={(e) => {
setBranchName(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && branchName.trim() && !isCreating) {
handleCreate();
}
}}
disabled={isCreating}
autoFocus
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="base-branch">Base Branch</Label>
<Button
variant="ghost"
size="sm"
onClick={fetchBranches}
disabled={isLoadingBranches || isCreating}
className="h-6 px-2 text-xs"
>
{isLoadingBranches ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="branch-name">Branch Name</Label>
<Input
id="branch-name"
placeholder="feature/my-new-feature"
value={branchName}
onChange={(e) => {
setBranchName(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && branchName.trim() && !isCreating) {
handleCreate();
}
}}
disabled={isCreating}
autoFocus
/>
</div>
{isLoadingBranches && branches.length === 0 ? (
<div className="flex items-center justify-center py-3 border rounded-md border-input">
<Spinner size="sm" className="mr-2" />
<span className="text-sm text-muted-foreground">Loading branches...</span>
</div>
) : (
<Popover open={baseBranchPopoverOpen} onOpenChange={setBaseBranchPopoverOpen}>
<PopoverTrigger asChild>
<Button
id="base-branch"
ref={baseBranchTriggerRef}
variant="outline"
role="combobox"
aria-expanded={baseBranchPopoverOpen}
disabled={isCreating}
className="w-full justify-between font-normal"
>
<span className="truncate text-sm">
{baseBranchDisplayLabel ?? (
<span className="text-muted-foreground">Select base branch</span>
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: Math.max(baseBranchTriggerWidth, 200) }}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="base-branch">Base Branch</Label>
<Button
variant="ghost"
size="sm"
onClick={fetchBranches}
disabled={isLoadingBranches || isCreating}
className="h-6 px-2 text-xs"
>
<Command shouldFilter={true}>
<CommandInput placeholder="Filter branches..." className="h-9" />
<CommandList>
<CommandEmpty>No matching branches</CommandEmpty>
{localBranches.length > 0 && (
<CommandGroup heading="Local Branches">
{localBranches.map((branch) => (
<CommandItem
key={branch.name}
value={branch.name}
onSelect={(value) => {
setBaseBranch(value);
setBaseBranchPopoverOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4 shrink-0',
baseBranch === branch.name ? 'opacity-100' : 'opacity-0'
)}
/>
<span className={cn('truncate', branch.isCurrent && 'font-medium')}>
{branch.name}
</span>
{branch.isCurrent && (
<span className="ml-1.5 text-xs text-muted-foreground shrink-0">
(current)
</span>
)}
</CommandItem>
))}
</CommandGroup>
)}
{remoteBranches.length > 0 && (
<>
{localBranches.length > 0 && <CommandSeparator />}
<CommandGroup heading="Remote Branches">
{remoteBranches.map((branch) => (
{isLoadingBranches ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
{isLoadingBranches && branches.length === 0 ? (
<div className="flex items-center justify-center py-3 border rounded-md border-input">
<Spinner size="sm" className="mr-2" />
<span className="text-sm text-muted-foreground">Loading branches...</span>
</div>
) : (
<Popover open={baseBranchPopoverOpen} onOpenChange={setBaseBranchPopoverOpen}>
<PopoverTrigger asChild>
<Button
id="base-branch"
ref={baseBranchTriggerRef}
variant="outline"
role="combobox"
aria-expanded={baseBranchPopoverOpen}
disabled={isCreating}
className="w-full justify-between font-normal"
>
<span className="truncate text-sm">
{baseBranchDisplayLabel ?? (
<span className="text-muted-foreground">Select base branch</span>
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: Math.max(baseBranchTriggerWidth, 200) }}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
<Command shouldFilter={true}>
<CommandInput placeholder="Filter branches..." className="h-9" />
<CommandList>
<CommandEmpty>No matching branches</CommandEmpty>
{localBranches.length > 0 && (
<CommandGroup heading="Local Branches">
{localBranches.map((branch) => (
<CommandItem
key={branch.name}
value={branch.name}
@@ -306,39 +368,81 @@ export function CreateBranchDialog({
baseBranch === branch.name ? 'opacity-100' : 'opacity-0'
)}
/>
<Globe className="mr-1.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{branch.name}</span>
<span className={cn('truncate', branch.isCurrent && 'font-medium')}>
{branch.name}
</span>
{branch.isCurrent && (
<span className="ml-1.5 text-xs text-muted-foreground shrink-0">
(current)
</span>
)}
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
)}
{remoteBranches.length > 0 && (
<>
{localBranches.length > 0 && <CommandSeparator />}
<CommandGroup heading="Remote Branches">
{remoteBranches.map((branch) => (
<CommandItem
key={branch.name}
value={branch.name}
onSelect={(value) => {
setBaseBranch(value);
setBaseBranchPopoverOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4 shrink-0',
baseBranch === branch.name ? 'opacity-100' : 'opacity-0'
)}
/>
<Globe className="mr-1.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{branch.name}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
{isCreating ? (
<>
<Spinner size="sm" className="mr-2" />
Creating...
</>
) : (
'Create Branch'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
{isCreating ? (
<>
<Spinner size="sm" className="mr-2" />
Creating...
</>
) : (
'Create Branch'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Stash confirmation dialog - shown when uncommitted changes are detected */}
<StashConfirmDialog
open={showStashConfirm}
onOpenChange={setShowStashConfirm}
operationDescription={`create branch '${branchName.trim()}'`}
changesInfo={uncommittedChanges}
onConfirm={handleStashConfirmAction}
isLoading={isCreating}
/>
</>
);
}

View File

@@ -29,3 +29,8 @@ export {
type BranchConflictData,
type BranchConflictType,
} from './branch-conflict-dialog';
export {
StashConfirmDialog,
type UncommittedChangesInfo,
type StashConfirmAction,
} from './stash-confirm-dialog';

View File

@@ -0,0 +1,185 @@
/**
* Dialog shown when uncommitted changes are detected before a branch operation.
* Presents the user with options to:
* 1. Stash and proceed - stash changes, perform the operation, then restore
* 2. Proceed without stashing - discard local changes and proceed
* 3. Cancel - abort the operation
*
* Displays a summary of affected files (staged, unstaged, untracked) so the
* user can make an informed decision.
*/
import { useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle, Archive, XCircle, FileEdit, FilePlus, FileQuestion } from 'lucide-react';
export interface UncommittedChangesInfo {
staged: string[];
unstaged: string[];
untracked: string[];
totalFiles: number;
}
export type StashConfirmAction = 'stash-and-proceed' | 'proceed-without-stash' | 'cancel';
interface StashConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The branch operation being attempted (e.g., "switch to feature/xyz" or "create feature/xyz") */
operationDescription: string;
/** Summary of uncommitted changes */
changesInfo: UncommittedChangesInfo | null;
/** Called with the user's decision */
onConfirm: (action: StashConfirmAction) => void;
/** Whether the operation is currently in progress */
isLoading?: boolean;
}
export function StashConfirmDialog({
open,
onOpenChange,
operationDescription,
changesInfo,
onConfirm,
isLoading = false,
}: StashConfirmDialogProps) {
const handleStashAndProceed = useCallback(() => {
onConfirm('stash-and-proceed');
}, [onConfirm]);
const handleProceedWithoutStash = useCallback(() => {
onConfirm('proceed-without-stash');
}, [onConfirm]);
const handleCancel = useCallback(() => {
onConfirm('cancel');
onOpenChange(false);
}, [onConfirm, onOpenChange]);
if (!changesInfo) return null;
const { staged, unstaged, untracked } = changesInfo;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isLoading && onOpenChange(isOpen)}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" />
Uncommitted Changes Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
You have uncommitted changes that may be affected when you{' '}
<strong>{operationDescription}</strong>.
</span>
{/* File summary */}
<div className="space-y-2">
{staged.length > 0 && (
<FileSection
icon={<FileEdit className="w-3.5 h-3.5 text-green-500" />}
label="Staged"
files={staged}
/>
)}
{unstaged.length > 0 && (
<FileSection
icon={<XCircle className="w-3.5 h-3.5 text-orange-500" />}
label="Unstaged"
files={unstaged}
/>
)}
{untracked.length > 0 && (
<FileSection
icon={<FilePlus className="w-3.5 h-3.5 text-blue-500" />}
label="Untracked"
files={untracked}
/>
)}
</div>
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to proceed:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Stash & Proceed</strong> &mdash; Saves your changes, performs the
operation, then restores them
</li>
<li>
<strong>Proceed Without Stashing</strong> &mdash; Carries your uncommitted
changes into the new branch as-is
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={handleCancel} disabled={isLoading}>
Cancel
</Button>
<Button variant="secondary" onClick={handleProceedWithoutStash} disabled={isLoading}>
<FileQuestion className="w-4 h-4 mr-2" />
Proceed Without Stashing
</Button>
<Button onClick={handleStashAndProceed} disabled={isLoading}>
<Archive className="w-4 h-4 mr-2" />
Stash & Proceed
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Renders a collapsible section of files with a category label */
function FileSection({
icon,
label,
files,
}: {
icon: React.ReactNode;
label: string;
files: string[];
}) {
const maxDisplay = 5;
const displayFiles = files.slice(0, maxDisplay);
const remaining = files.length - maxDisplay;
return (
<div className="space-y-1">
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{icon}
{label} ({files.length})
</span>
<div className="border border-border rounded-lg overflow-hidden max-h-[120px] overflow-y-auto scrollbar-visible">
{displayFiles.map((file) => (
<div
key={file}
className="flex items-center px-3 py-1 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<span className="truncate">{file}</span>
</div>
))}
{remaining > 0 && (
<div className="px-3 py-1 text-xs text-muted-foreground border-b border-border last:border-b-0">
...and {remaining} more {remaining === 1 ? 'file' : 'files'}
</div>
)}
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import {
useSwitchBranch,
@@ -10,9 +11,17 @@ import {
useOpenInEditor,
} from '@/hooks/mutations';
import type { WorktreeInfo } from '../types';
import type { UncommittedChangesInfo } from '../../dialogs/stash-confirm-dialog';
const logger = createLogger('WorktreeActions');
/** Pending branch switch details, stored while awaiting user confirmation */
export interface PendingSwitchInfo {
worktree: WorktreeInfo;
branchName: string;
changesInfo: UncommittedChangesInfo;
}
interface UseWorktreeActionsOptions {
/** Callback when merge conflicts occur after branch switch stash reapply */
onBranchSwitchConflict?: (info: {
@@ -32,6 +41,9 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
const navigate = useNavigate();
const [isActivating, setIsActivating] = useState(false);
// Pending branch switch state (waiting for user stash decision)
const [pendingSwitch, setPendingSwitch] = useState<PendingSwitchInfo | null>(null);
// Use React Query mutations
const switchBranchMutation = useSwitchBranch({
onConflict: options?.onBranchSwitchConflict,
@@ -41,9 +53,40 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
const pushMutation = usePushWorktree();
const openInEditorMutation = useOpenInEditor();
/**
* Initiate a branch switch.
* First checks for uncommitted changes and, if found, stores the pending
* switch so the caller can show a confirmation dialog.
*/
const handleSwitchBranch = useCallback(
async (worktree: WorktreeInfo, branchName: string) => {
if (switchBranchMutation.isPending || branchName === worktree.branch) return;
// Check for uncommitted changes before switching
try {
const api = getHttpApiClient();
const changesResult = await api.worktree.checkChanges(worktree.path);
if (changesResult.success && changesResult.result?.hasChanges) {
// Store the pending switch and let the UI show the confirmation dialog
setPendingSwitch({
worktree,
branchName,
changesInfo: {
staged: changesResult.result.staged,
unstaged: changesResult.result.unstaged,
untracked: changesResult.result.untracked,
totalFiles: changesResult.result.totalFiles,
},
});
return;
}
} catch (err) {
// If we can't check for changes, proceed with the switch (server will auto-stash)
logger.warn('Failed to check for uncommitted changes, proceeding with switch:', err);
}
// No changes detected, proceed directly (server still handles stash as safety net)
switchBranchMutation.mutate({
worktreePath: worktree.path,
branchName,
@@ -52,6 +95,39 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
[switchBranchMutation]
);
/**
* Confirm the pending branch switch after the user chooses an action.
* The server-side performSwitchBranch always auto-stashes when there are changes,
* so when the user chooses "proceed without stashing" we still switch (the server
* detects and stashes automatically). When "cancel", we just clear the pending state.
*/
const confirmPendingSwitch = useCallback(
(action: 'stash-and-proceed' | 'proceed-without-stash' | 'cancel') => {
if (!pendingSwitch) return;
if (action === 'cancel') {
setPendingSwitch(null);
return;
}
// Both 'stash-and-proceed' and 'proceed-without-stash' trigger the switch.
// The server-side performSwitchBranch handles the stash/pop cycle automatically.
// 'proceed-without-stash' means the user is OK with the server's auto-stash behavior.
switchBranchMutation.mutate({
worktreePath: pendingSwitch.worktree.path,
branchName: pendingSwitch.branchName,
});
setPendingSwitch(null);
},
[pendingSwitch, switchBranchMutation]
);
/** Clear the pending switch without performing any action */
const cancelPendingSwitch = useCallback(() => {
setPendingSwitch(null);
}, []);
const handlePull = useCallback(
async (worktree: WorktreeInfo, remote?: string) => {
if (pullMutation.isPending) return;
@@ -130,5 +206,9 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
handleOpenInIntegratedTerminal,
handleOpenInEditor,
handleOpenInExternalTerminal,
// Stash confirmation state for branch switching
pendingSwitch,
confirmPendingSwitch,
cancelPendingSwitch,
};
}

View File

@@ -43,6 +43,7 @@ import {
CherryPickDialog,
GitPullDialog,
} from '../dialogs';
import { StashConfirmDialog } from '../dialogs/stash-confirm-dialog';
import type { SelectRemoteOperation } from '../dialogs';
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
import { getElectronAPI } from '@/lib/electron';
@@ -114,6 +115,9 @@ export function WorktreePanel({
handleOpenInIntegratedTerminal,
handleOpenInEditor,
handleOpenInExternalTerminal,
pendingSwitch,
confirmPendingSwitch,
cancelPendingSwitch,
} = useWorktreeActions({
onBranchSwitchConflict: onBranchSwitchConflict,
onStashPopConflict: onStashPopConflict,
@@ -880,6 +884,20 @@ export function WorktreePanel({
onStashed={handleStashCompleted}
/>
{/* Stash Confirm Dialog for Branch Switching */}
<StashConfirmDialog
open={!!pendingSwitch}
onOpenChange={(isOpen) => {
if (!isOpen) cancelPendingSwitch();
}}
operationDescription={
pendingSwitch ? `switch to branch '${pendingSwitch.branchName}'` : ''
}
changesInfo={pendingSwitch?.changesInfo ?? null}
onConfirm={confirmPendingSwitch}
isLoading={isSwitching}
/>
{/* View Stashes Dialog */}
<ViewStashesDialog
open={viewStashesDialogOpen}
@@ -1328,6 +1346,18 @@ export function WorktreePanel({
onStashed={handleStashCompleted}
/>
{/* Stash Confirm Dialog for Branch Switching */}
<StashConfirmDialog
open={!!pendingSwitch}
onOpenChange={(isOpen) => {
if (!isOpen) cancelPendingSwitch();
}}
operationDescription={pendingSwitch ? `switch to branch '${pendingSwitch.branchName}'` : ''}
changesInfo={pendingSwitch?.changesInfo ?? null}
onConfirm={confirmPendingSwitch}
isLoading={isSwitching}
/>
{/* View Stashes Dialog */}
<ViewStashesDialog
open={viewStashesDialogOpen}

View File

@@ -447,35 +447,143 @@ export function useSwitchBranch(options?: {
/**
* Checkout a new branch
*
* Supports automatic stash handling. When stashChanges is true in the mutation
* variables, local changes are stashed before creating the branch and reapplied
* after. If the reapply causes merge conflicts, the onConflict callback is called.
*
* If the checkout itself fails and the stash-pop used to restore changes also
* produces conflicts, the onStashPopConflict callback is called.
*
* @param options.onConflict - Callback when merge conflicts occur after stash reapply
* @param options.onStashPopConflict - Callback when checkout fails AND stash-pop restoration has conflicts
* @returns Mutation for creating and checking out a new branch
*/
export function useCheckoutBranch() {
export function useCheckoutBranch(options?: {
onConflict?: (info: { worktreePath: string; branchName: string; previousBranch: string }) => void;
onStashPopConflict?: (info: {
worktreePath: string;
branchName: string;
stashPopConflictMessage: string;
}) => void;
}) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
worktreePath,
branchName,
baseBranch,
stashChanges,
includeUntracked,
}: {
worktreePath: string;
branchName: string;
baseBranch?: string;
stashChanges?: boolean;
includeUntracked?: boolean;
}) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.checkoutBranch(worktreePath, branchName);
const result = await api.worktree.checkoutBranch(
worktreePath,
branchName,
baseBranch,
stashChanges,
includeUntracked
);
if (!result.success) {
// When the checkout failed and restoring the stash produced conflicts
if (result.stashPopConflicts) {
const conflictError = new Error(result.error || 'Failed to checkout branch');
(
conflictError as Error & {
stashPopConflicts: boolean;
stashPopConflictMessage: string;
worktreePath: string;
branchName: string;
}
).stashPopConflicts = true;
(
conflictError as Error & {
stashPopConflicts: boolean;
stashPopConflictMessage: string;
worktreePath: string;
branchName: string;
}
).stashPopConflictMessage =
result.stashPopConflictMessage ??
'Stash pop resulted in conflicts: please resolve conflicts before retrying.';
(
conflictError as Error & {
stashPopConflicts: boolean;
stashPopConflictMessage: string;
worktreePath: string;
branchName: string;
}
).worktreePath = worktreePath;
(
conflictError as Error & {
stashPopConflicts: boolean;
stashPopConflictMessage: string;
worktreePath: string;
branchName: string;
}
).branchName = branchName;
throw conflictError;
}
throw new Error(result.error || 'Failed to checkout branch');
}
return result.result;
},
onSuccess: () => {
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
toast.success('New branch created and checked out');
if (data?.hasConflicts) {
toast.warning('Branch created with conflicts', {
description: data.message,
duration: 8000,
});
options?.onConflict?.({
worktreePath: variables.worktreePath,
branchName: data.newBranch ?? variables.branchName,
previousBranch: data.previousBranch ?? '',
});
} else {
const desc = data?.stashedChanges ? 'Local changes were stashed and reapplied' : undefined;
toast.success('New branch created and checked out', { description: desc });
}
},
onError: (error: Error) => {
toast.error('Failed to checkout branch', {
description: error.message,
});
const enrichedError = error as Error & {
stashPopConflicts?: boolean;
stashPopConflictMessage?: string;
worktreePath?: string;
branchName?: string;
};
if (
enrichedError.stashPopConflicts &&
enrichedError.worktreePath &&
enrichedError.branchName
) {
toast.error('Branch creation failed with stash conflicts', {
description:
enrichedError.stashPopConflictMessage ??
'Stash pop resulted in conflicts. Please resolve the conflicts in your working tree.',
duration: 10000,
});
options?.onStashPopConflict?.({
worktreePath: enrichedError.worktreePath,
branchName: enrichedError.branchName,
stashPopConflictMessage:
enrichedError.stashPopConflictMessage ??
'Stash pop resulted in conflicts. Please resolve the conflicts in your working tree.',
});
} else {
toast.error('Failed to checkout branch', {
description: error.message,
});
}
},
});
}

View File

@@ -2291,11 +2291,18 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
checkoutBranch: async (worktreePath: string, branchName: string, baseBranch?: string) => {
checkoutBranch: async (
worktreePath: string,
branchName: string,
baseBranch?: string,
stashChanges?: boolean,
_includeUntracked?: boolean
) => {
console.log('[Mock] Creating and checking out branch:', {
worktreePath,
branchName,
baseBranch,
stashChanges,
});
return {
success: true,
@@ -2303,6 +2310,22 @@ function createMockWorktreeAPI(): WorktreeAPI {
previousBranch: 'main',
newBranch: branchName,
message: `Created and checked out branch '${branchName}'`,
hasConflicts: false,
stashedChanges: stashChanges ?? false,
},
};
},
checkChanges: async (worktreePath: string) => {
console.log('[Mock] Checking for uncommitted changes:', worktreePath);
return {
success: true,
result: {
hasChanges: false,
staged: [],
unstaged: [],
untracked: [],
totalFiles: 0,
},
};
},

View File

@@ -2139,8 +2139,22 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/stage-files', { worktreePath, files, operation }),
pull: (worktreePath: string, remote?: string, stashIfNeeded?: boolean) =>
this.post('/api/worktree/pull', { worktreePath, remote, stashIfNeeded }),
checkoutBranch: (worktreePath: string, branchName: string, baseBranch?: string) =>
this.post('/api/worktree/checkout-branch', { worktreePath, branchName, baseBranch }),
checkoutBranch: (
worktreePath: string,
branchName: string,
baseBranch?: string,
stashChanges?: boolean,
includeUntracked?: boolean
) =>
this.post('/api/worktree/checkout-branch', {
worktreePath,
branchName,
baseBranch,
stashChanges,
includeUntracked,
}),
checkChanges: (worktreePath: string) =>
this.post('/api/worktree/check-changes', { worktreePath }),
listBranches: (worktreePath: string, includeRemote?: boolean) =>
this.post('/api/worktree/list-branches', { worktreePath, includeRemote }),
switchBranch: (worktreePath: string, branchName: string) =>

View File

@@ -1026,20 +1026,39 @@ export interface WorktreeAPI {
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
}>;
// Create and checkout a new branch
// Check for uncommitted changes in a worktree
checkChanges: (worktreePath: string) => Promise<{
success: boolean;
result?: {
hasChanges: boolean;
staged: string[];
unstaged: string[];
untracked: string[];
totalFiles: number;
};
error?: string;
}>;
// Create and checkout a new branch (with optional stash handling)
checkoutBranch: (
worktreePath: string,
branchName: string,
baseBranch?: string
baseBranch?: string,
stashChanges?: boolean,
includeUntracked?: boolean
) => Promise<{
success: boolean;
result?: {
previousBranch: string;
newBranch: string;
message: string;
hasConflicts?: boolean;
stashedChanges?: boolean;
};
error?: string;
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
stashPopConflicts?: boolean;
stashPopConflictMessage?: string;
}>;
// List branches (local and optionally remote)