mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-20 11:03:08 +00:00
feat: Add git log parsing and rebase endpoint with input validation
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -149,6 +149,9 @@ export function CherryPickDialog({
|
||||
const [commitLimit, setCommitLimit] = useState(30);
|
||||
const [hasMoreCommits, setHasMoreCommits] = useState(false);
|
||||
|
||||
// Ref to track the latest fetchCommits request and ignore stale responses
|
||||
const fetchCommitsRequestRef = useRef(0);
|
||||
|
||||
// Cherry-pick state
|
||||
const [isCherryPicking, setIsCherryPicking] = useState(false);
|
||||
|
||||
@@ -182,6 +185,8 @@ export function CherryPickDialog({
|
||||
useEffect(() => {
|
||||
if (!open || !worktree) return;
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const fetchBranchData = async () => {
|
||||
setLoadingBranches(true);
|
||||
try {
|
||||
@@ -193,6 +198,8 @@ export function CherryPickDialog({
|
||||
api.worktree.listBranches(worktree.path, false),
|
||||
]);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (remotesResult.success && remotesResult.result) {
|
||||
setRemotes(remotesResult.result.remotes);
|
||||
// Default to first remote if available, otherwise local
|
||||
@@ -212,13 +219,20 @@ export function CherryPickDialog({
|
||||
setLocalBranches(branches);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
console.error('Failed to fetch branch data:', err);
|
||||
} finally {
|
||||
setLoadingBranches(false);
|
||||
if (mounted) {
|
||||
setLoadingBranches(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchBranchData();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [open, worktree]);
|
||||
|
||||
// Fetch commits when branch is selected
|
||||
@@ -226,6 +240,9 @@ export function CherryPickDialog({
|
||||
async (limit: number = 30, append: boolean = false) => {
|
||||
if (!worktree || !selectedBranch) return;
|
||||
|
||||
// Increment the request counter and capture the current request ID
|
||||
const requestId = ++fetchCommitsRequestRef.current;
|
||||
|
||||
if (append) {
|
||||
setLoadingMoreCommits(true);
|
||||
} else {
|
||||
@@ -239,18 +256,28 @@ export function CherryPickDialog({
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.getBranchCommitLog(worktree.path, selectedBranch, limit);
|
||||
|
||||
// Ignore stale responses from superseded requests
|
||||
if (requestId !== fetchCommitsRequestRef.current) return;
|
||||
|
||||
if (result.success && result.result) {
|
||||
setCommits(result.result.commits);
|
||||
// If we got exactly the limit, there may be more commits
|
||||
setHasMoreCommits(result.result.commits.length >= limit);
|
||||
} else {
|
||||
} else if (!append) {
|
||||
setCommitsError(result.error || 'Failed to load commits');
|
||||
}
|
||||
} catch (err) {
|
||||
setCommitsError(err instanceof Error ? err.message : 'Failed to load commits');
|
||||
// Ignore stale responses from superseded requests
|
||||
if (requestId !== fetchCommitsRequestRef.current) return;
|
||||
if (!append) {
|
||||
setCommitsError(err instanceof Error ? err.message : 'Failed to load commits');
|
||||
}
|
||||
} finally {
|
||||
setLoadingCommits(false);
|
||||
setLoadingMoreCommits(false);
|
||||
// Only update loading state if this is still the current request
|
||||
if (requestId === fetchCommitsRequestRef.current) {
|
||||
setLoadingCommits(false);
|
||||
setLoadingMoreCommits(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[worktree, selectedBranch]
|
||||
@@ -384,6 +411,7 @@ export function CherryPickDialog({
|
||||
sourceBranch: selectedBranch,
|
||||
targetBranch: conflictInfo.targetBranch,
|
||||
targetWorktreePath: conflictInfo.targetWorktreePath,
|
||||
operationType: 'merge',
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
@@ -703,7 +731,7 @@ export function CherryPickDialog({
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select source..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="text-black dark:text-black">
|
||||
<SelectContent className="text-foreground">
|
||||
<SelectItem value="__local__">Local Branches</SelectItem>
|
||||
{remotes.map((remote) => (
|
||||
<SelectItem key={remote.name} value={remote.name}>
|
||||
@@ -725,7 +753,7 @@ export function CherryPickDialog({
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a branch..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="text-black dark:text-black">
|
||||
<SelectContent className="text-foreground">
|
||||
{branchOptions.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>
|
||||
{branch}
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Download,
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
FileWarning,
|
||||
Wrench,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { MergeConflictInfo } from '../worktree-panel/types';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
type PullPhase =
|
||||
| 'checking' // Initial check for local changes
|
||||
| 'local-changes' // Local changes detected, asking user what to do
|
||||
| 'pulling' // Actively pulling (with or without stash)
|
||||
| 'success' // Pull completed successfully
|
||||
| 'conflict' // Merge conflicts detected
|
||||
| 'error'; // Something went wrong
|
||||
|
||||
interface PullResult {
|
||||
branch: string;
|
||||
pulled: boolean;
|
||||
message: string;
|
||||
hasLocalChanges?: boolean;
|
||||
localChangedFiles?: string[];
|
||||
hasConflicts?: boolean;
|
||||
conflictSource?: 'pull' | 'stash';
|
||||
conflictFiles?: string[];
|
||||
stashed?: boolean;
|
||||
stashRestored?: boolean;
|
||||
}
|
||||
|
||||
interface GitPullDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
remote?: string;
|
||||
onPulled?: () => void;
|
||||
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
}
|
||||
|
||||
export function GitPullDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
remote,
|
||||
onPulled,
|
||||
onCreateConflictResolutionFeature,
|
||||
}: GitPullDialogProps) {
|
||||
const [phase, setPhase] = useState<PullPhase>('checking');
|
||||
const [pullResult, setPullResult] = useState<PullResult | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
setPhase('checking');
|
||||
setPullResult(null);
|
||||
setErrorMessage(null);
|
||||
// Start the initial check
|
||||
checkForLocalChanges();
|
||||
}
|
||||
}, [open, worktree]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const checkForLocalChanges = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setPhase('checking');
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.pull) {
|
||||
setErrorMessage('Pull API not available');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Call pull without stashIfNeeded to just check status
|
||||
const result = await api.worktree.pull(worktree.path, remote);
|
||||
|
||||
if (!result.success) {
|
||||
setErrorMessage(result.error || 'Failed to pull');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.result?.hasLocalChanges) {
|
||||
// Local changes detected - ask user what to do
|
||||
setPullResult(result.result);
|
||||
setPhase('local-changes');
|
||||
} else if (result.result?.pulled !== undefined) {
|
||||
// No local changes, pull went through (or already up to date)
|
||||
setPullResult(result.result);
|
||||
setPhase('success');
|
||||
onPulled?.();
|
||||
}
|
||||
} catch (err) {
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes');
|
||||
setPhase('error');
|
||||
}
|
||||
}, [worktree, remote, onPulled]);
|
||||
|
||||
const handlePullWithStash = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setPhase('pulling');
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.pull) {
|
||||
setErrorMessage('Pull API not available');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Call pull with stashIfNeeded
|
||||
const result = await api.worktree.pull(worktree.path, remote, true);
|
||||
|
||||
if (!result.success) {
|
||||
setErrorMessage(result.error || 'Failed to pull');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setPullResult(result.result || null);
|
||||
|
||||
if (result.result?.hasConflicts) {
|
||||
setPhase('conflict');
|
||||
} else {
|
||||
setPhase('success');
|
||||
onPulled?.();
|
||||
}
|
||||
} catch (err) {
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
|
||||
setPhase('error');
|
||||
}
|
||||
}, [worktree, remote, onPulled]);
|
||||
|
||||
const handleResolveWithAI = useCallback(() => {
|
||||
if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return;
|
||||
|
||||
const conflictInfo: MergeConflictInfo = {
|
||||
sourceBranch: `${remote || 'origin'}/${pullResult.branch}`,
|
||||
targetBranch: pullResult.branch,
|
||||
targetWorktreePath: worktree.path,
|
||||
conflictFiles: pullResult.conflictFiles || [],
|
||||
operationType: 'merge',
|
||||
};
|
||||
|
||||
onCreateConflictResolutionFeature(conflictInfo);
|
||||
onOpenChange(false);
|
||||
}, [worktree, pullResult, remote, onCreateConflictResolutionFeature, onOpenChange]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
{/* Checking Phase */}
|
||||
{phase === 'checking' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Download className="w-5 h-5" />
|
||||
Pull Changes
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Checking for local changes on{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>...
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" />
|
||||
<span className="ml-3 text-sm text-muted-foreground">
|
||||
Fetching remote and checking status...
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Local Changes Detected Phase */}
|
||||
{phase === 'local-changes' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
Local Changes Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<span className="block">
|
||||
You have uncommitted changes on{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> that
|
||||
need to be handled before pulling.
|
||||
</span>
|
||||
|
||||
{pullResult?.localChangedFiles && pullResult.localChangedFiles.length > 0 && (
|
||||
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
|
||||
{pullResult.localChangedFiles.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"
|
||||
>
|
||||
<FileWarning className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
|
||||
<span className="truncate">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-blue-500/10 border border-blue-500/20">
|
||||
<Archive className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-blue-500 text-sm">
|
||||
Your changes will be automatically stashed before pulling and restored afterward. If
|
||||
restoring causes conflicts, you'll be able to resolve them.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handlePullWithStash}>
|
||||
<Archive className="w-4 h-4 mr-2" />
|
||||
Stash & Pull
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pulling Phase */}
|
||||
{phase === 'pulling' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Download className="w-5 h-5 animate-pulse" />
|
||||
Pulling Changes
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pullResult?.hasLocalChanges
|
||||
? 'Stashing changes, pulling from remote, and restoring your changes...'
|
||||
: 'Pulling latest changes from remote...'}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Success Phase */}
|
||||
{phase === 'success' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
Pull Complete
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<span className="block">
|
||||
{pullResult?.message || 'Changes pulled successfully'}
|
||||
</span>
|
||||
|
||||
{pullResult?.stashed && pullResult?.stashRestored && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-green-500/10 border border-green-500/20">
|
||||
<Archive className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-green-600 dark:text-green-400 text-sm">
|
||||
Your stashed changes have been restored successfully.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pullResult?.stashed && !pullResult?.stashRestored && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-amber-600 dark:text-amber-400 text-sm">
|
||||
{pullResult.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Conflict Phase */}
|
||||
{phase === 'conflict' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
Merge Conflicts Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<span className="block">
|
||||
{pullResult?.conflictSource === 'stash'
|
||||
? 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.'
|
||||
: 'The pull resulted in merge conflicts that need to be resolved.'}
|
||||
</span>
|
||||
|
||||
{pullResult?.conflictFiles && pullResult.conflictFiles.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Conflicting files ({pullResult.conflictFiles.length}):
|
||||
</span>
|
||||
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
|
||||
{pullResult.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> — Creates a task to analyze and
|
||||
resolve conflicts automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Resolve Manually</strong> — Leaves conflict markers in place
|
||||
for you to edit directly
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
toast.info('Conflict markers left in place', {
|
||||
description: 'Edit the conflicting files to resolve conflicts manually.',
|
||||
duration: 6000,
|
||||
});
|
||||
onPulled?.();
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
Resolve Manually
|
||||
</Button>
|
||||
{onCreateConflictResolutionFeature && (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error Phase */}
|
||||
{phase === 'error' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<XCircle className="w-5 h-5 text-destructive" />
|
||||
Pull Failed
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<span className="block">
|
||||
Failed to pull changes for{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>.
|
||||
</span>
|
||||
|
||||
{errorMessage && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-2 p-3 rounded-md',
|
||||
'bg-destructive/10 border border-destructive/20'
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<span className="text-destructive text-sm break-words">{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setErrorMessage(null);
|
||||
checkForLocalChanges();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -21,4 +21,6 @@ export { ExportFeaturesDialog } from './export-features-dialog';
|
||||
export { ImportFeaturesDialog } from './import-features-dialog';
|
||||
export { StashChangesDialog } from './stash-changes-dialog';
|
||||
export { ViewStashesDialog } from './view-stashes-dialog';
|
||||
export { StashApplyConflictDialog } from './stash-apply-conflict-dialog';
|
||||
export { CherryPickDialog } from './cherry-pick-dialog';
|
||||
export { GitPullDialog } from './git-pull-dialog';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -21,12 +21,28 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { GitMerge, RefreshCw, AlertTriangle, GitBranch } from 'lucide-react';
|
||||
import {
|
||||
GitMerge,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
GitBranch,
|
||||
Wrench,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import type { WorktreeInfo } from '../worktree-panel/types';
|
||||
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;
|
||||
@@ -49,6 +65,7 @@ interface MergeRebaseDialogProps {
|
||||
remoteBranch: string,
|
||||
strategy: PullStrategy
|
||||
) => void | Promise<void>;
|
||||
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
}
|
||||
|
||||
export function MergeRebaseDialog({
|
||||
@@ -56,6 +73,7 @@ export function MergeRebaseDialog({
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onConfirm,
|
||||
onCreateConflictResolutionFeature,
|
||||
}: MergeRebaseDialogProps) {
|
||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||
@@ -64,13 +82,15 @@ export function MergeRebaseDialog({
|
||||
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]);
|
||||
}, [open, worktree]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
@@ -79,6 +99,8 @@ export function MergeRebaseDialog({
|
||||
setSelectedBranch('');
|
||||
setSelectedStrategy('merge');
|
||||
setError(null);
|
||||
setStep('select');
|
||||
setConflictState(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -167,20 +189,300 @@ export function MergeRebaseDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const handleExecuteOperation = useCallback(async () => {
|
||||
if (!worktree || !selectedBranch) return;
|
||||
|
||||
setStep('executing');
|
||||
|
||||
try {
|
||||
await onConfirm(worktree, selectedBranch, selectedStrategy);
|
||||
onOpenChange(false);
|
||||
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');
|
||||
toast.error('Rebase conflicts detected', {
|
||||
description: 'Choose how to resolve the conflicts below.',
|
||||
});
|
||||
} 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');
|
||||
toast.error('Merge conflicts detected', {
|
||||
description: 'Choose how to resolve the conflicts below.',
|
||||
});
|
||||
} 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');
|
||||
toast.error('Merge conflicts detected', {
|
||||
description: 'Choose how to resolve the conflicts below.',
|
||||
});
|
||||
} else {
|
||||
// Non-conflict failure - fall back to creating a feature task
|
||||
toast.info('Direct operation failed, creating AI task instead', {
|
||||
description: result.error || 'The operation will be handled by an AI agent.',
|
||||
});
|
||||
try {
|
||||
await onConfirm(worktree, selectedBranch, selectedStrategy);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
logger.error('Failed to create feature task:', err);
|
||||
setStep('select');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to confirm merge/rebase:', err);
|
||||
throw err;
|
||||
logger.error('Failed to execute operation:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
const hasConflicts =
|
||||
errorMessage.toLowerCase().includes('conflict') || errorMessage.includes('CONFLICT');
|
||||
|
||||
if (hasConflicts) {
|
||||
setConflictState({
|
||||
conflictFiles: [],
|
||||
remoteBranch: selectedBranch,
|
||||
strategy: selectedStrategy,
|
||||
});
|
||||
setStep('conflict');
|
||||
} else {
|
||||
// Fall back to creating a feature task
|
||||
toast.info('Creating AI task to handle the operation', {
|
||||
description: 'The operation will be performed by an AI agent.',
|
||||
});
|
||||
try {
|
||||
await onConfirm(worktree, selectedBranch, selectedStrategy);
|
||||
onOpenChange(false);
|
||||
} catch (confirmErr) {
|
||||
logger.error('Failed to create feature task:', confirmErr);
|
||||
toast.error('Operation failed', { description: errorMessage });
|
||||
setStep('select');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [worktree, selectedBranch, selectedStrategy, selectedRemote, onConfirm, 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);
|
||||
} else {
|
||||
// Fallback: create via the onConfirm handler
|
||||
onConfirm(worktree, conflictState.remoteBranch, conflictState.strategy);
|
||||
onOpenChange(false);
|
||||
}
|
||||
}, [worktree, conflictState, onCreateConflictResolutionFeature, onConfirm, 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> — Creates a task to{' '}
|
||||
{isRebase ? 'rebase and ' : ''}resolve conflicts automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Resolve Manually</strong> —{' '}
|
||||
{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]">
|
||||
@@ -323,7 +625,7 @@ export function MergeRebaseDialog({
|
||||
{selectedBranch && (
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will create a feature task to{' '}
|
||||
This will attempt to{' '}
|
||||
{selectedStrategy === 'rebase' ? (
|
||||
<>
|
||||
rebase <span className="font-mono text-foreground">{worktree?.branch}</span>{' '}
|
||||
@@ -334,8 +636,8 @@ export function MergeRebaseDialog({
|
||||
merge <span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
|
||||
<span className="font-mono text-foreground">{worktree?.branch}</span>
|
||||
</>
|
||||
)}{' '}
|
||||
and resolve any conflicts.
|
||||
)}
|
||||
. If conflicts arise, you can choose to resolve them manually or with AI.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -347,16 +649,21 @@ export function MergeRebaseDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
onClick={handleExecuteOperation}
|
||||
disabled={!selectedBranch || isLoading}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<GitMerge className="w-4 h-4 mr-2" />
|
||||
{selectedStrategy === 'merge'
|
||||
? 'Merge'
|
||||
: selectedStrategy === 'rebase'
|
||||
? 'Rebase'
|
||||
: 'Merge & Rebase'}
|
||||
{selectedStrategy === 'rebase' ? (
|
||||
<>
|
||||
<GitBranch className="w-4 h-4 mr-2" />
|
||||
Rebase
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitMerge className="w-4 h-4 mr-2" />
|
||||
Merge
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitMerge, AlertTriangle, Trash2, Wrench } from 'lucide-react';
|
||||
import { GitMerge, AlertTriangle, Trash2, Wrench, Sparkles, XCircle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
@@ -116,17 +116,20 @@ export function MergeWorktreeDialog({
|
||||
const hasConflicts =
|
||||
errorMessage.toLowerCase().includes('conflict') ||
|
||||
errorMessage.toLowerCase().includes('merge failed') ||
|
||||
errorMessage.includes('CONFLICT');
|
||||
errorMessage.includes('CONFLICT') ||
|
||||
result.hasConflicts;
|
||||
|
||||
if (hasConflicts && onCreateConflictResolutionFeature) {
|
||||
if (hasConflicts) {
|
||||
// Set merge conflict state to show the conflict resolution UI
|
||||
setMergeConflict({
|
||||
sourceBranch: worktree.branch,
|
||||
targetBranch: targetBranch,
|
||||
targetWorktreePath: projectPath, // The merge happens in the target branch's worktree
|
||||
conflictFiles: result.conflictFiles || [],
|
||||
operationType: 'merge',
|
||||
});
|
||||
toast.error('Merge conflicts detected', {
|
||||
description: 'The merge has conflicts that need to be resolved manually.',
|
||||
description: 'Choose how to resolve the conflicts below.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to merge branch', {
|
||||
@@ -142,14 +145,16 @@ export function MergeWorktreeDialog({
|
||||
errorMessage.toLowerCase().includes('merge failed') ||
|
||||
errorMessage.includes('CONFLICT');
|
||||
|
||||
if (hasConflicts && onCreateConflictResolutionFeature) {
|
||||
if (hasConflicts) {
|
||||
setMergeConflict({
|
||||
sourceBranch: worktree.branch,
|
||||
targetBranch: targetBranch,
|
||||
targetWorktreePath: projectPath,
|
||||
conflictFiles: [],
|
||||
operationType: 'merge',
|
||||
});
|
||||
toast.error('Merge conflicts detected', {
|
||||
description: 'The merge has conflicts that need to be resolved manually.',
|
||||
description: 'Choose how to resolve the conflicts below.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to merge branch', {
|
||||
@@ -161,20 +166,28 @@ export function MergeWorktreeDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateConflictResolutionFeature = () => {
|
||||
const handleResolveWithAI = () => {
|
||||
if (mergeConflict && onCreateConflictResolutionFeature) {
|
||||
onCreateConflictResolutionFeature(mergeConflict);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolveManually = () => {
|
||||
toast.info('Conflict markers left in place', {
|
||||
description: 'Edit the conflicting files to resolve conflicts manually.',
|
||||
duration: 6000,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
// Show conflict resolution UI if there are merge conflicts
|
||||
if (mergeConflict) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
@@ -194,32 +207,38 @@ export function MergeWorktreeDialog({
|
||||
.
|
||||
</span>
|
||||
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-orange-500 text-sm">
|
||||
The merge could not be completed automatically. You can create a feature task to
|
||||
resolve the conflicts in the{' '}
|
||||
<code className="font-mono bg-muted px-0.5 rounded">
|
||||
{mergeConflict.targetBranch}
|
||||
</code>{' '}
|
||||
branch.
|
||||
</span>
|
||||
</div>
|
||||
{mergeConflict.conflictFiles && mergeConflict.conflictFiles.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Conflicting files ({mergeConflict.conflictFiles.length}):
|
||||
</span>
|
||||
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
|
||||
{mergeConflict.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">
|
||||
This will create a high-priority feature task that will:
|
||||
<p className="text-sm text-muted-foreground font-medium mb-2">
|
||||
Choose how to resolve:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground mt-2 list-disc list-inside space-y-1">
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
<li>
|
||||
Resolve merge conflicts in the{' '}
|
||||
<code className="font-mono bg-muted px-0.5 rounded">
|
||||
{mergeConflict.targetBranch}
|
||||
</code>{' '}
|
||||
branch
|
||||
<strong>Resolve with AI</strong> — Creates a task to analyze and resolve
|
||||
conflicts automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Resolve Manually</strong> — Leaves conflict markers in place for
|
||||
you to edit directly
|
||||
</li>
|
||||
<li>Ensure the code compiles and tests pass</li>
|
||||
<li>Complete the merge automatically</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,16 +249,19 @@ export function MergeWorktreeDialog({
|
||||
<Button variant="ghost" onClick={() => setMergeConflict(null)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateConflictResolutionFeature}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<Button variant="outline" onClick={handleResolveManually}>
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
Create Resolve Conflicts Feature
|
||||
Resolve Manually
|
||||
</Button>
|
||||
{onCreateConflictResolutionFeature && (
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Dialog shown when a stash apply/pop operation results in merge conflicts.
|
||||
* Presents the user with two options:
|
||||
* 1. Resolve Manually - leaves conflict markers in place
|
||||
* 2. Resolve with AI - creates a feature task for AI-powered conflict resolution
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, XCircle, Wrench, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { StashApplyConflictInfo } from '../worktree-panel/types';
|
||||
|
||||
interface StashApplyConflictDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
conflictInfo: StashApplyConflictInfo | null;
|
||||
onResolveWithAI?: (conflictInfo: StashApplyConflictInfo) => void;
|
||||
}
|
||||
|
||||
export function StashApplyConflictDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
conflictInfo,
|
||||
onResolveWithAI,
|
||||
}: StashApplyConflictDialogProps) {
|
||||
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 handleResolveWithAI = useCallback(() => {
|
||||
if (!conflictInfo || !onResolveWithAI) return;
|
||||
|
||||
onResolveWithAI(conflictInfo);
|
||||
onOpenChange(false);
|
||||
}, [conflictInfo, onResolveWithAI, onOpenChange]);
|
||||
|
||||
if (!conflictInfo) return null;
|
||||
|
||||
const operationLabel = conflictInfo.operation === 'pop' ? 'popped' : 'applied';
|
||||
|
||||
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" />
|
||||
Merge Conflicts Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<span className="block">
|
||||
Stash{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{conflictInfo.stashRef}</code> was{' '}
|
||||
{operationLabel} on branch{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{conflictInfo.branchName}</code>{' '}
|
||||
but resulted in merge conflicts.
|
||||
</span>
|
||||
|
||||
{conflictInfo.conflictFiles.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Conflicting files ({conflictInfo.conflictFiles.length}):
|
||||
</span>
|
||||
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
|
||||
{conflictInfo.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> — Creates a task to analyze and resolve
|
||||
conflicts automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Resolve Manually</strong> — Leaves conflict markers in place for
|
||||
you to edit directly
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleResolveManually}>
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
Resolve Manually
|
||||
</Button>
|
||||
{onResolveWithAI && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StashApplyConflictDialog } from './stash-apply-conflict-dialog';
|
||||
import type { StashApplyConflictInfo } from '../worktree-panel/types';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -43,6 +45,7 @@ interface ViewStashesDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onStashApplied?: () => void;
|
||||
onStashApplyConflict?: (conflictInfo: StashApplyConflictInfo) => void;
|
||||
}
|
||||
|
||||
function formatRelativeDate(dateStr: string): string {
|
||||
@@ -213,12 +216,15 @@ export function ViewStashesDialog({
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onStashApplied,
|
||||
onStashApplyConflict,
|
||||
}: ViewStashesDialogProps) {
|
||||
const [stashes, setStashes] = useState<StashEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [applyingIndex, setApplyingIndex] = useState<number | null>(null);
|
||||
const [droppingIndex, setDroppingIndex] = useState<number | null>(null);
|
||||
const [conflictDialogOpen, setConflictDialogOpen] = useState(false);
|
||||
const [conflictInfo, setConflictInfo] = useState<StashApplyConflictInfo | null>(null);
|
||||
|
||||
const fetchStashes = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
@@ -262,13 +268,20 @@ export function ViewStashesDialog({
|
||||
|
||||
if (result.success && result.result) {
|
||||
if (result.result.hasConflicts) {
|
||||
toast.warning('Stash applied with conflicts', {
|
||||
description: 'Please resolve the merge conflicts.',
|
||||
});
|
||||
const info: StashApplyConflictInfo = {
|
||||
worktreePath: worktree.path,
|
||||
branchName: worktree.branch,
|
||||
stashRef: `stash@{${stashIndex}}`,
|
||||
operation: 'apply',
|
||||
conflictFiles: result.result.conflictFiles || [],
|
||||
};
|
||||
setConflictInfo(info);
|
||||
setConflictDialogOpen(true);
|
||||
onStashApplied?.();
|
||||
} else {
|
||||
toast.success('Stash applied');
|
||||
onStashApplied?.();
|
||||
}
|
||||
onStashApplied?.();
|
||||
} else {
|
||||
toast.error('Failed to apply stash', {
|
||||
description: result.error || 'Unknown error',
|
||||
@@ -293,9 +306,15 @@ export function ViewStashesDialog({
|
||||
|
||||
if (result.success && result.result) {
|
||||
if (result.result.hasConflicts) {
|
||||
toast.warning('Stash popped with conflicts', {
|
||||
description: 'Please resolve the merge conflicts. The stash was removed.',
|
||||
});
|
||||
const info: StashApplyConflictInfo = {
|
||||
worktreePath: worktree.path,
|
||||
branchName: worktree.branch,
|
||||
stashRef: `stash@{${stashIndex}}`,
|
||||
operation: 'pop',
|
||||
conflictFiles: result.result.conflictFiles || [],
|
||||
};
|
||||
setConflictInfo(info);
|
||||
setConflictDialogOpen(true);
|
||||
} else {
|
||||
toast.success('Stash popped', {
|
||||
description: 'Changes applied and stash removed.',
|
||||
@@ -403,6 +422,14 @@ export function ViewStashesDialog({
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Stash Apply Conflict Resolution Dialog */}
|
||||
<StashApplyConflictDialog
|
||||
open={conflictDialogOpen}
|
||||
onOpenChange={setConflictDialogOpen}
|
||||
conflictInfo={conflictInfo}
|
||||
onResolveWithAI={onStashApplyConflict}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { createLogger } from '@automaker/utils/logger';
|
||||
|
||||
const logger = createLogger('BoardActions');
|
||||
|
||||
const MAX_DUPLICATES = 50;
|
||||
|
||||
interface UseBoardActionsProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
features: Feature[];
|
||||
@@ -1199,10 +1201,22 @@ export function useBoardActions({
|
||||
|
||||
const handleDuplicateAsChildMultiple = useCallback(
|
||||
async (feature: Feature, count: number) => {
|
||||
// Guard: reject non-positive counts
|
||||
if (count <= 0) {
|
||||
toast.error('Invalid duplicate count', {
|
||||
description: 'Count must be a positive number.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Cap count to prevent runaway API calls
|
||||
const effectiveCount = Math.min(count, MAX_DUPLICATES);
|
||||
|
||||
// Create a chain of duplicates, each a child of the previous, so they execute sequentially
|
||||
let parentFeature = feature;
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
for (let i = 0; i < effectiveCount; i++) {
|
||||
const {
|
||||
id: _id,
|
||||
status: _status,
|
||||
@@ -1223,18 +1237,39 @@ export function useBoardActions({
|
||||
dependencies: [parentFeature.id],
|
||||
};
|
||||
|
||||
const newFeature = await handleAddFeature(duplicatedFeatureData);
|
||||
try {
|
||||
const newFeature = await handleAddFeature(duplicatedFeatureData);
|
||||
|
||||
// Use the returned feature directly as the parent for the next iteration,
|
||||
// avoiding a fragile assumption that the newest feature is the last item in the store
|
||||
if (newFeature) {
|
||||
parentFeature = newFeature;
|
||||
// Use the returned feature directly as the parent for the next iteration,
|
||||
// avoiding a fragile assumption that the newest feature is the last item in the store
|
||||
if (newFeature) {
|
||||
parentFeature = newFeature;
|
||||
}
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
toast.error(
|
||||
`Failed after creating ${successCount} of ${effectiveCount} duplicate${effectiveCount !== 1 ? 's' : ''}`,
|
||||
{
|
||||
description: errorMessage,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Created ${count} chained duplicates`, {
|
||||
description: `Created ${count} sequential copies of: ${truncateDescription(feature.description || feature.title || '')}`,
|
||||
});
|
||||
if (successCount === effectiveCount) {
|
||||
toast.success(`Created ${successCount} chained duplicate${successCount !== 1 ? 's' : ''}`, {
|
||||
description: `Created ${successCount} sequential ${successCount !== 1 ? 'copies' : 'copy'} of: ${truncateDescription(feature.description || feature.title || '')}`,
|
||||
});
|
||||
} else {
|
||||
toast.info(
|
||||
`Partially created ${successCount} of ${effectiveCount} chained duplicate${effectiveCount !== 1 ? 's' : ''}`,
|
||||
{
|
||||
description: `Created ${successCount} sequential ${successCount !== 1 ? 'copies' : 'copy'} of: ${truncateDescription(feature.description || feature.title || '')}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[handleAddFeature]
|
||||
);
|
||||
|
||||
@@ -449,51 +449,68 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{/* Stash operations - combined submenu */}
|
||||
{/* Stash operations - combined submenu or simple item */}
|
||||
{(onStashChanges || onViewStashes) && (
|
||||
<TooltipWrapper showTooltip={!canPerformGitOps} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - stash changes (primary action) */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!canPerformGitOps) return;
|
||||
if (worktree.hasChanges && onStashChanges) {
|
||||
{onViewStashes && worktree.hasChanges && onStashChanges ? (
|
||||
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - stash changes (primary action) */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!canPerformGitOps) return;
|
||||
onStashChanges(worktree);
|
||||
} else if (onViewStashes) {
|
||||
onViewStashes(worktree);
|
||||
}
|
||||
}}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn(
|
||||
'text-xs flex-1 pr-0 rounded-r-none',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{/* Chevron trigger for submenu with stash options */}
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(
|
||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
disabled={!canPerformGitOps}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
{onViewStashes && (
|
||||
}}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn(
|
||||
'text-xs flex-1 pr-0 rounded-r-none',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||
Stash Changes
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{/* Chevron trigger for submenu with stash options */}
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(
|
||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
disabled={!canPerformGitOps}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||
View Stashes
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
// Only one action is meaningful - render a simple menu item without submenu
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!canPerformGitOps) return;
|
||||
if (worktree.hasChanges && onStashChanges) {
|
||||
onStashChanges(worktree);
|
||||
} else if (onViewStashes) {
|
||||
onViewStashes(worktree);
|
||||
}
|
||||
}}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -78,6 +78,10 @@ export interface MergeConflictInfo {
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
targetWorktreePath: string;
|
||||
/** List of files with conflicts, if available */
|
||||
conflictFiles?: string[];
|
||||
/** Type of operation that caused the conflict */
|
||||
operationType?: 'merge' | 'rebase';
|
||||
}
|
||||
|
||||
export interface BranchSwitchConflictInfo {
|
||||
@@ -93,6 +97,15 @@ export interface StashPopConflictInfo {
|
||||
stashPopConflictMessage: string;
|
||||
}
|
||||
|
||||
/** Info passed when a stash apply/pop operation results in merge conflicts */
|
||||
export interface StashApplyConflictInfo {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
stashRef: string;
|
||||
operation: 'apply' | 'pop';
|
||||
conflictFiles: string[];
|
||||
}
|
||||
|
||||
export interface WorktreePanelProps {
|
||||
projectPath: string;
|
||||
onCreateWorktree: () => void;
|
||||
@@ -107,6 +120,8 @@ export interface WorktreePanelProps {
|
||||
onBranchSwitchConflict?: (conflictInfo: BranchSwitchConflictInfo) => void;
|
||||
/** Called when checkout fails and the stash-pop restoration itself produces merge conflicts */
|
||||
onStashPopConflict?: (conflictInfo: StashPopConflictInfo) => void;
|
||||
/** Called when stash apply/pop results in merge conflicts and user wants AI resolution */
|
||||
onStashApplyConflict?: (conflictInfo: StashApplyConflictInfo) => void;
|
||||
/** Called when a branch is deleted during merge - features should be reassigned to main */
|
||||
onBranchDeletedDuringMerge?: (branchName: string) => void;
|
||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
StashChangesDialog,
|
||||
ViewStashesDialog,
|
||||
CherryPickDialog,
|
||||
GitPullDialog,
|
||||
} from '../dialogs';
|
||||
import type { SelectRemoteOperation } from '../dialogs';
|
||||
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||
@@ -61,6 +62,7 @@ export function WorktreePanel({
|
||||
onCreateMergeConflictResolutionFeature,
|
||||
onBranchSwitchConflict,
|
||||
onStashPopConflict,
|
||||
onStashApplyConflict,
|
||||
onBranchDeletedDuringMerge,
|
||||
onRemovedWorktrees,
|
||||
runningFeatureIds = [],
|
||||
@@ -107,7 +109,7 @@ export function WorktreePanel({
|
||||
isSwitching,
|
||||
isActivating,
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePull: _handlePull,
|
||||
handlePush,
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleOpenInEditor,
|
||||
@@ -423,6 +425,11 @@ export function WorktreePanel({
|
||||
const [cherryPickDialogOpen, setCherryPickDialogOpen] = useState(false);
|
||||
const [cherryPickWorktree, setCherryPickWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Pull dialog states
|
||||
const [pullDialogOpen, setPullDialogOpen] = useState(false);
|
||||
const [pullDialogWorktree, setPullDialogWorktree] = useState<WorktreeInfo | null>(null);
|
||||
const [pullDialogRemote, setPullDialogRemote] = useState<string | undefined>(undefined);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Periodic interval check (30 seconds) to detect branch changes on disk
|
||||
@@ -553,33 +560,42 @@ export function WorktreePanel({
|
||||
setPushToRemoteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle pull with remote selection when multiple remotes exist
|
||||
const handlePullWithRemoteSelection = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
// Handle pull completed - refresh worktrees
|
||||
const handlePullCompleted = useCallback(() => {
|
||||
fetchWorktrees({ silent: true });
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||
// Multiple remotes - show selection dialog
|
||||
setSelectRemoteWorktree(worktree);
|
||||
setSelectRemoteOperation('pull');
|
||||
setSelectRemoteDialogOpen(true);
|
||||
} else if (result.success && result.result && result.result.remotes.length === 1) {
|
||||
// Exactly one remote - use it directly
|
||||
const remoteName = result.result.remotes[0].name;
|
||||
handlePull(worktree, remoteName);
|
||||
} else {
|
||||
// No remotes - proceed with default behavior
|
||||
handlePull(worktree);
|
||||
}
|
||||
} catch {
|
||||
// If listing remotes fails, fall back to default behavior
|
||||
handlePull(worktree);
|
||||
// Handle pull with remote selection when multiple remotes exist
|
||||
// Now opens the pull dialog which handles stash management and conflict resolution
|
||||
const handlePullWithRemoteSelection = useCallback(async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||
// Multiple remotes - show selection dialog first
|
||||
setSelectRemoteWorktree(worktree);
|
||||
setSelectRemoteOperation('pull');
|
||||
setSelectRemoteDialogOpen(true);
|
||||
} else if (result.success && result.result && result.result.remotes.length === 1) {
|
||||
// Exactly one remote - open pull dialog directly with that remote
|
||||
const remoteName = result.result.remotes[0].name;
|
||||
setPullDialogRemote(remoteName);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
} else {
|
||||
// No remotes - open pull dialog with default
|
||||
setPullDialogRemote(undefined);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[handlePull]
|
||||
);
|
||||
} catch {
|
||||
// If listing remotes fails, open pull dialog with default
|
||||
setPullDialogRemote(undefined);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle push with remote selection when multiple remotes exist
|
||||
const handlePushWithRemoteSelection = useCallback(
|
||||
@@ -613,6 +629,10 @@ export function WorktreePanel({
|
||||
const handleConfirmSelectRemote = useCallback(
|
||||
async (worktree: WorktreeInfo, remote: string) => {
|
||||
if (selectRemoteOperation === 'pull') {
|
||||
// Open the pull dialog with the selected remote
|
||||
setPullDialogRemote(remote);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
await handlePull(worktree, remote);
|
||||
} else {
|
||||
await handlePush(worktree, remote);
|
||||
@@ -620,7 +640,7 @@ export function WorktreePanel({
|
||||
fetchBranches(worktree.path);
|
||||
fetchWorktrees();
|
||||
},
|
||||
[selectRemoteOperation, handlePull, handlePush, fetchBranches, fetchWorktrees]
|
||||
[selectRemoteOperation, handlePush, fetchBranches, fetchWorktrees]
|
||||
);
|
||||
|
||||
// Handle confirming the push to remote dialog
|
||||
@@ -822,6 +842,7 @@ export function WorktreePanel({
|
||||
onOpenChange={setViewStashesDialogOpen}
|
||||
worktree={viewStashesWorktree}
|
||||
onStashApplied={handleStashApplied}
|
||||
onStashApplyConflict={onStashApplyConflict}
|
||||
/>
|
||||
|
||||
{/* Cherry Pick Dialog */}
|
||||
@@ -833,6 +854,16 @@ export function WorktreePanel({
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Git Pull Dialog */}
|
||||
<GitPullDialog
|
||||
open={pullDialogOpen}
|
||||
onOpenChange={setPullDialogOpen}
|
||||
worktree={pullDialogWorktree}
|
||||
remote={pullDialogRemote}
|
||||
onPulled={handlePullCompleted}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Dev Server Logs Panel */}
|
||||
<DevServerLogsPanel
|
||||
open={logPanelOpen}
|
||||
@@ -1054,6 +1085,7 @@ export function WorktreePanel({
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
@@ -1130,6 +1162,7 @@ export function WorktreePanel({
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
@@ -1261,6 +1294,16 @@ export function WorktreePanel({
|
||||
onCherryPicked={handleCherryPicked}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Git Pull Dialog */}
|
||||
<GitPullDialog
|
||||
open={pullDialogOpen}
|
||||
onOpenChange={setPullDialogOpen}
|
||||
worktree={pullDialogWorktree}
|
||||
remote={pullDialogRemote}
|
||||
onPulled={handlePullCompleted}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user