mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
feat: Fix new branch issues and address code review comments
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,3 +29,8 @@ export {
|
||||
type BranchConflictData,
|
||||
type BranchConflictType,
|
||||
} from './branch-conflict-dialog';
|
||||
export {
|
||||
StashConfirmDialog,
|
||||
type UncommittedChangesInfo,
|
||||
type StashConfirmAction,
|
||||
} from './stash-confirm-dialog';
|
||||
|
||||
@@ -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> — Saves your changes, performs the
|
||||
operation, then restores them
|
||||
</li>
|
||||
<li>
|
||||
<strong>Proceed Without Stashing</strong> — 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user