Files
automaker/apps/ui/src/components/views/board-view/dialogs/merge-rebase-dialog.tsx

635 lines
22 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import {
GitMerge,
RefreshCw,
AlertTriangle,
GitBranch,
Wrench,
Sparkles,
XCircle,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo, MergeConflictInfo } from '../worktree-panel/types';
export type PullStrategy = 'merge' | 'rebase';
type DialogStep = 'select' | 'executing' | 'conflict' | 'success';
interface ConflictState {
conflictFiles: string[];
remoteBranch: string;
strategy: PullStrategy;
}
interface RemoteBranch {
name: string;
fullRef: string;
}
interface RemoteInfo {
name: string;
url: string;
branches: RemoteBranch[];
}
const logger = createLogger('MergeRebaseDialog');
interface MergeRebaseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
export function MergeRebaseDialog({
open,
onOpenChange,
worktree,
onCreateConflictResolutionFeature,
}: MergeRebaseDialogProps) {
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [selectedBranch, setSelectedBranch] = useState<string>('');
const [selectedStrategy, setSelectedStrategy] = useState<PullStrategy>('merge');
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [step, setStep] = useState<DialogStep>('select');
const [conflictState, setConflictState] = useState<ConflictState | null>(null);
// Fetch remotes when dialog opens
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree]); // eslint-disable-line react-hooks/exhaustive-deps
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setSelectedRemote('');
setSelectedBranch('');
setSelectedStrategy('merge');
setError(null);
setStep('select');
setConflictState(null);
}
}, [open]);
// Auto-select default remote and branch when remotes are loaded
useEffect(() => {
if (remotes.length > 0 && !selectedRemote) {
// Default to 'origin' if available, otherwise first remote
const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0];
setSelectedRemote(defaultRemote.name);
// Try to select a matching branch name or default to main/master
if (defaultRemote.branches.length > 0 && worktree) {
const matchingBranch = defaultRemote.branches.find((b) => b.name === worktree.branch);
const mainBranch = defaultRemote.branches.find(
(b) => b.name === 'main' || b.name === 'master'
);
const defaultBranch = matchingBranch || mainBranch || defaultRemote.branches[0];
setSelectedBranch(defaultBranch.fullRef);
}
}
}, [remotes, selectedRemote, worktree]);
// Update selected branch when remote changes
useEffect(() => {
if (selectedRemote && remotes.length > 0 && worktree) {
const remote = remotes.find((r) => r.name === selectedRemote);
if (remote && remote.branches.length > 0) {
// Try to select a matching branch name or default to main/master
const matchingBranch = remote.branches.find((b) => b.name === worktree.branch);
const mainBranch = remote.branches.find((b) => b.name === 'main' || b.name === 'master');
const defaultBranch = matchingBranch || mainBranch || remote.branches[0];
setSelectedBranch(defaultBranch.fullRef);
} else {
setSelectedBranch('');
}
}
}, [selectedRemote, remotes, worktree]);
const fetchRemotes = async () => {
if (!worktree) return;
setIsLoading(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
setRemotes(result.result.remotes);
if (result.result.remotes.length === 0) {
setError('No remotes found in this repository');
}
} else {
setError(result.error || 'Failed to fetch remotes');
}
} catch (err) {
logger.error('Failed to fetch remotes:', err);
setError('Failed to fetch remotes');
} finally {
setIsLoading(false);
}
};
const handleRefresh = async () => {
if (!worktree) return;
setIsRefreshing(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
setRemotes(result.result.remotes);
toast.success('Remotes refreshed');
} else {
toast.error(result.error || 'Failed to refresh remotes');
}
} catch (err) {
logger.error('Failed to refresh remotes:', err);
toast.error('Failed to refresh remotes');
} finally {
setIsRefreshing(false);
}
};
const handleExecuteOperation = useCallback(async () => {
if (!worktree || !selectedBranch) return;
setStep('executing');
try {
const api = getHttpApiClient();
if (selectedStrategy === 'rebase') {
// First fetch the remote to ensure we have latest refs
try {
await api.worktree.pull(worktree.path, selectedRemote);
} catch {
// Fetch may fail if no upstream - that's okay, we'll try rebase anyway
}
// Attempt the rebase operation
const result = await api.worktree.rebase(worktree.path, selectedBranch);
if (result.success) {
toast.success(`Rebased onto ${selectedBranch}`, {
description: result.result?.message || 'Rebase completed successfully',
});
setStep('success');
onOpenChange(false);
} else if (result.hasConflicts) {
// Rebase had conflicts - show conflict resolution UI
setConflictState({
conflictFiles: result.conflictFiles || [],
remoteBranch: selectedBranch,
strategy: 'rebase',
});
setStep('conflict');
} else {
toast.error('Rebase failed', {
description: result.error || 'Unknown error',
});
setStep('select');
}
} else {
// Merge strategy - attempt to merge the remote branch
// Use the pull endpoint for merging remote branches
const result = await api.worktree.pull(worktree.path, selectedRemote, true);
if (result.success && result.result) {
if (result.result.hasConflicts) {
// Pull had conflicts
setConflictState({
conflictFiles: result.result.conflictFiles || [],
remoteBranch: selectedBranch,
strategy: 'merge',
});
setStep('conflict');
} else {
toast.success(`Merged ${selectedBranch}`, {
description: result.result.message || 'Merge completed successfully',
});
setStep('success');
onOpenChange(false);
}
} else {
// Check for conflict indicators in error
const errorMessage = result.error || '';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') || errorMessage.includes('CONFLICT');
if (hasConflicts) {
setConflictState({
conflictFiles: [],
remoteBranch: selectedBranch,
strategy: 'merge',
});
setStep('conflict');
} else {
// Non-conflict failure - show conflict resolution UI so user can choose
// how to handle it (resolve manually or with AI) rather than auto-creating a task
setConflictState({
conflictFiles: [],
remoteBranch: selectedBranch,
strategy: 'merge',
});
setStep('conflict');
}
}
}
} catch (err) {
logger.error('Failed to execute operation:', err);
// Show conflict resolution UI so user can choose how to handle it
setConflictState({
conflictFiles: [],
remoteBranch: selectedBranch,
strategy: selectedStrategy,
});
setStep('conflict');
}
}, [worktree, selectedBranch, selectedStrategy, selectedRemote, onOpenChange]);
const handleResolveWithAI = useCallback(() => {
if (!worktree || !conflictState) return;
if (onCreateConflictResolutionFeature) {
const conflictInfo: MergeConflictInfo = {
sourceBranch: conflictState.remoteBranch,
targetBranch: worktree.branch,
targetWorktreePath: worktree.path,
conflictFiles: conflictState.conflictFiles,
operationType: conflictState.strategy,
};
onCreateConflictResolutionFeature(conflictInfo);
}
onOpenChange(false);
}, [worktree, conflictState, onCreateConflictResolutionFeature, onOpenChange]);
const handleResolveManually = useCallback(() => {
toast.info('Conflict markers left in place', {
description: 'Edit the conflicting files to resolve conflicts manually.',
duration: 6000,
});
onOpenChange(false);
}, [onOpenChange]);
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
const branches = selectedRemoteData?.branches || [];
if (!worktree) return null;
// Conflict resolution UI
if (step === 'conflict' && conflictState) {
const isRebase = conflictState.strategy === 'rebase';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
{isRebase ? 'Rebase' : 'Merge'} Conflicts Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<span className="block">
{isRebase ? (
<>
Conflicts detected when rebasing{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
onto{' '}
<code className="font-mono bg-muted px-1 rounded">
{conflictState.remoteBranch}
</code>
. The rebase was aborted and no changes were applied.
</>
) : (
<>
Conflicts detected when merging{' '}
<code className="font-mono bg-muted px-1 rounded">
{conflictState.remoteBranch}
</code>{' '}
into{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>.
</>
)}
</span>
{conflictState.conflictFiles.length > 0 && (
<div className="space-y-1.5">
<span className="text-sm font-medium text-foreground">
Conflicting files ({conflictState.conflictFiles.length}):
</span>
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
{conflictState.conflictFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
</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 resolve:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Resolve with AI</strong> &mdash; Creates a task to{' '}
{isRebase ? 'rebase and ' : ''}resolve conflicts automatically
</li>
<li>
<strong>Resolve Manually</strong> &mdash;{' '}
{isRebase
? 'Leaves the branch unchanged for you to rebase manually'
: 'Leaves conflict markers in place for you to edit directly'}
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
setStep('select');
setConflictState(null);
}}
>
Back
</Button>
<Button variant="outline" onClick={handleResolveManually}>
<Wrench className="w-4 h-4 mr-2" />
Resolve Manually
</Button>
<Button
onClick={handleResolveWithAI}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
Resolve with AI
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Executing phase
if (step === 'executing') {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selectedStrategy === 'rebase' ? (
<GitBranch className="w-5 h-5 text-blue-500 animate-pulse" />
) : (
<GitMerge className="w-5 h-5 text-purple-500 animate-pulse" />
)}
{selectedStrategy === 'rebase' ? 'Rebasing...' : 'Merging...'}
</DialogTitle>
<DialogDescription>
{selectedStrategy === 'rebase'
? `Rebasing ${worktree.branch} onto ${selectedBranch}...`
: `Merging ${selectedBranch} into ${worktree.branch}...`}
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
<span className="ml-3 text-sm text-muted-foreground">This may take a moment...</span>
</div>
</DialogContent>
</Dialog>
);
}
// Selection UI
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-purple-500" />
Merge & Rebase
</DialogTitle>
<DialogDescription>
Select a remote branch to merge or rebase with{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
</div>
) : error ? (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" />
<span className="text-sm">{error}</span>
</div>
<Button variant="outline" size="sm" onClick={fetchRemotes}>
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
</div>
) : (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Remote</Label>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="h-6 px-2 text-xs"
>
{isRefreshing ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="branch-select">Branch</Label>
<Select
value={selectedBranch}
onValueChange={setSelectedBranch}
disabled={!selectedRemote || branches.length === 0}
>
<SelectTrigger id="branch-select">
<SelectValue placeholder="Select a branch" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{selectedRemote} branches</SelectLabel>
{branches.map((branch) => (
<SelectItem key={branch.fullRef} value={branch.fullRef}>
{branch.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{selectedRemote && branches.length === 0 && (
<p className="text-sm text-muted-foreground">No branches found for this remote</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="strategy-select">Strategy</Label>
<Select
value={selectedStrategy}
onValueChange={(value) => setSelectedStrategy(value as PullStrategy)}
>
<SelectTrigger id="strategy-select">
<SelectValue placeholder="Select a strategy" />
</SelectTrigger>
<SelectContent>
<SelectItem
value="merge"
description={
<span className="text-xs text-muted-foreground">
Creates a merge commit preserving history
</span>
}
>
<span className="flex items-center gap-2">
<GitMerge className="w-3.5 h-3.5 text-purple-500" />
<span className="font-medium">Merge</span>
</span>
</SelectItem>
<SelectItem
value="rebase"
description={
<span className="text-xs text-muted-foreground">
Replays commits on top for linear history
</span>
}
>
<span className="flex items-center gap-2">
<GitBranch className="w-3.5 h-3.5 text-blue-500" />
<span className="font-medium">Rebase</span>
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
{selectedBranch && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will attempt to{' '}
{selectedStrategy === 'rebase' ? (
<>
rebase <span className="font-mono text-foreground">{worktree?.branch}</span>{' '}
onto <span className="font-mono text-foreground">{selectedBranch}</span>
</>
) : (
<>
merge <span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
<span className="font-mono text-foreground">{worktree?.branch}</span>
</>
)}
. If conflicts arise, you can choose to resolve them manually or with AI.
</p>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleExecuteOperation}
disabled={!selectedBranch || isLoading}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
{selectedStrategy === 'rebase' ? (
<>
<GitBranch className="w-4 h-4 mr-2" />
Rebase
</>
) : (
<>
<GitMerge className="w-4 h-4 mr-2" />
Merge
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}