mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
apply the patches
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type DependencyLinkType = 'parent' | 'child';
|
||||
|
||||
interface DependencyLinkDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
draggedFeature: Feature | null;
|
||||
targetFeature: Feature | null;
|
||||
onLink: (linkType: DependencyLinkType) => void;
|
||||
}
|
||||
|
||||
export function DependencyLinkDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
draggedFeature,
|
||||
targetFeature,
|
||||
onLink,
|
||||
}: DependencyLinkDialogProps) {
|
||||
if (!draggedFeature || !targetFeature) return null;
|
||||
|
||||
// Check if a dependency relationship already exists
|
||||
const draggedDependsOnTarget =
|
||||
Array.isArray(draggedFeature.dependencies) &&
|
||||
draggedFeature.dependencies.includes(targetFeature.id);
|
||||
const targetDependsOnDragged =
|
||||
Array.isArray(targetFeature.dependencies) &&
|
||||
targetFeature.dependencies.includes(draggedFeature.id);
|
||||
const existingLink = draggedDependsOnTarget || targetDependsOnDragged;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid="dependency-link-dialog" className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5" />
|
||||
Link Features
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a dependency relationship between these features.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
{/* Dragged feature */}
|
||||
<div className="p-3 rounded-lg border bg-muted/30">
|
||||
<div className="text-xs text-muted-foreground mb-1">Dragged Feature</div>
|
||||
<div className="text-sm font-medium line-clamp-3 break-words">
|
||||
{draggedFeature.description}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/70 mt-1">{draggedFeature.category}</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow indicating direction */}
|
||||
<div className="flex justify-center">
|
||||
<ArrowDown className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Target feature */}
|
||||
<div className="p-3 rounded-lg border bg-muted/30">
|
||||
<div className="text-xs text-muted-foreground mb-1">Target Feature</div>
|
||||
<div className="text-sm font-medium line-clamp-3 break-words">
|
||||
{targetFeature.description}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/70 mt-1">{targetFeature.category}</div>
|
||||
</div>
|
||||
|
||||
{/* Existing link warning */}
|
||||
{existingLink && (
|
||||
<div className="p-3 rounded-lg border border-yellow-500/50 bg-yellow-500/10 text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{draggedDependsOnTarget
|
||||
? 'The dragged feature already depends on the target feature.'
|
||||
: 'The target feature already depends on the dragged feature.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-col sm:!justify-start">
|
||||
{/* Set as Parent - top */}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => onLink('child')}
|
||||
disabled={draggedDependsOnTarget}
|
||||
className={cn('w-full', draggedDependsOnTarget && 'opacity-50 cursor-not-allowed')}
|
||||
title={
|
||||
draggedDependsOnTarget
|
||||
? 'This would create a circular dependency'
|
||||
: 'Make target feature depend on dragged (dragged is parent)'
|
||||
}
|
||||
data-testid="link-as-parent"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4 mr-2" />
|
||||
Set as Parent
|
||||
<span className="text-xs ml-1 opacity-70">(target depends on this)</span>
|
||||
</Button>
|
||||
{/* Set as Child - middle */}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => onLink('parent')}
|
||||
disabled={targetDependsOnDragged}
|
||||
className={cn('w-full', targetDependsOnDragged && 'opacity-50 cursor-not-allowed')}
|
||||
title={
|
||||
targetDependsOnDragged
|
||||
? 'This would create a circular dependency'
|
||||
: 'Make dragged feature depend on target (target is parent)'
|
||||
}
|
||||
data-testid="link-as-child"
|
||||
>
|
||||
<ArrowDown className="w-4 h-4 mr-2" />
|
||||
Set as Child
|
||||
<span className="text-xs ml-1 opacity-70">(depends on target)</span>
|
||||
</Button>
|
||||
{/* Cancel - bottom */}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="w-full">
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,12 @@ export { BacklogPlanDialog } from './backlog-plan-dialog';
|
||||
export { CompletedFeaturesModal } from './completed-features-modal';
|
||||
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
|
||||
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
|
||||
export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog';
|
||||
export { EditFeatureDialog } from './edit-feature-dialog';
|
||||
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
|
||||
export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog';
|
||||
export { PlanApprovalDialog } from './plan-approval-dialog';
|
||||
export { MassEditDialog } from './mass-edit-dialog';
|
||||
export { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
|
||||
export { PushToRemoteDialog } from './push-to-remote-dialog';
|
||||
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
|
||||
|
||||
@@ -8,58 +8,81 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { GitMerge, AlertTriangle, Trash2, Wrench } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||
import type { WorktreeInfo, BranchInfo, MergeConflictInfo } from '../worktree-panel/types';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
export type { MergeConflictInfo } from '../worktree-panel/types';
|
||||
|
||||
interface MergeWorktreeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectPath: string;
|
||||
worktree: WorktreeInfo | null;
|
||||
onMerged: (mergedWorktree: WorktreeInfo) => void;
|
||||
/** Number of features assigned to this worktree's branch */
|
||||
affectedFeatureCount?: number;
|
||||
/** Called when merge is successful. deletedBranch indicates if the branch was also deleted. */
|
||||
onMerged: (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
}
|
||||
|
||||
type DialogStep = 'confirm' | 'verify';
|
||||
|
||||
export function MergeWorktreeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectPath,
|
||||
worktree,
|
||||
onMerged,
|
||||
affectedFeatureCount = 0,
|
||||
onCreateConflictResolutionFeature,
|
||||
}: MergeWorktreeDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [step, setStep] = useState<DialogStep>('confirm');
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [targetBranch, setTargetBranch] = useState('main');
|
||||
const [availableBranches, setAvailableBranches] = useState<string[]>([]);
|
||||
const [loadingBranches, setLoadingBranches] = useState(false);
|
||||
const [deleteWorktreeAndBranch, setDeleteWorktreeAndBranch] = useState(false);
|
||||
const [mergeConflict, setMergeConflict] = useState<MergeConflictInfo | null>(null);
|
||||
|
||||
// Fetch available branches when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree && projectPath) {
|
||||
setLoadingBranches(true);
|
||||
const api = getElectronAPI();
|
||||
if (api?.worktree?.listBranches) {
|
||||
api.worktree
|
||||
.listBranches(projectPath, false)
|
||||
.then((result) => {
|
||||
if (result.success && result.result?.branches) {
|
||||
// Filter out the source branch (can't merge into itself) and remote branches
|
||||
const branches = result.result.branches
|
||||
.filter((b: BranchInfo) => !b.isRemote && b.name !== worktree.branch)
|
||||
.map((b: BranchInfo) => b.name);
|
||||
setAvailableBranches(branches);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch branches:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingBranches(false);
|
||||
});
|
||||
} else {
|
||||
setLoadingBranches(false);
|
||||
}
|
||||
}
|
||||
}, [open, worktree, projectPath]);
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setIsLoading(false);
|
||||
setStep('confirm');
|
||||
setConfirmText('');
|
||||
setTargetBranch('main');
|
||||
setDeleteWorktreeAndBranch(false);
|
||||
setMergeConflict(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleProceedToVerify = () => {
|
||||
setStep('verify');
|
||||
};
|
||||
|
||||
const handleMerge = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
@@ -71,96 +94,151 @@ export function MergeWorktreeDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass branchName and worktreePath directly to the API
|
||||
const result = await api.worktree.mergeFeature(projectPath, worktree.branch, worktree.path);
|
||||
// Pass branchName, worktreePath, targetBranch, and options to the API
|
||||
const result = await api.worktree.mergeFeature(
|
||||
projectPath,
|
||||
worktree.branch,
|
||||
worktree.path,
|
||||
targetBranch,
|
||||
{ deleteWorktreeAndBranch }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Branch merged to main', {
|
||||
description: `Branch "${worktree.branch}" has been merged and cleaned up`,
|
||||
});
|
||||
onMerged(worktree);
|
||||
const description = deleteWorktreeAndBranch
|
||||
? `Branch "${worktree.branch}" has been merged into "${targetBranch}" and the worktree and branch were deleted`
|
||||
: `Branch "${worktree.branch}" has been merged into "${targetBranch}"`;
|
||||
toast.success(`Branch merged to ${targetBranch}`, { description });
|
||||
onMerged(worktree, deleteWorktreeAndBranch);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error('Failed to merge branch', {
|
||||
description: result.error,
|
||||
});
|
||||
// Check if the error indicates merge conflicts
|
||||
const errorMessage = result.error || '';
|
||||
const hasConflicts =
|
||||
errorMessage.toLowerCase().includes('conflict') ||
|
||||
errorMessage.toLowerCase().includes('merge failed') ||
|
||||
errorMessage.includes('CONFLICT');
|
||||
|
||||
if (hasConflicts && onCreateConflictResolutionFeature) {
|
||||
// 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
|
||||
});
|
||||
toast.error('Merge conflicts detected', {
|
||||
description: 'The merge has conflicts that need to be resolved manually.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to merge branch', {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to merge branch', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
// Check if the error indicates merge conflicts
|
||||
const hasConflicts =
|
||||
errorMessage.toLowerCase().includes('conflict') ||
|
||||
errorMessage.toLowerCase().includes('merge failed') ||
|
||||
errorMessage.includes('CONFLICT');
|
||||
|
||||
if (hasConflicts && onCreateConflictResolutionFeature) {
|
||||
setMergeConflict({
|
||||
sourceBranch: worktree.branch,
|
||||
targetBranch: targetBranch,
|
||||
targetWorktreePath: projectPath,
|
||||
});
|
||||
toast.error('Merge conflicts detected', {
|
||||
description: 'The merge has conflicts that need to be resolved manually.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to merge branch', {
|
||||
description: errorMessage,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateConflictResolutionFeature = () => {
|
||||
if (mergeConflict && onCreateConflictResolutionFeature) {
|
||||
onCreateConflictResolutionFeature(mergeConflict);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
const confirmationWord = 'merge';
|
||||
const isConfirmValid = confirmText.toLowerCase() === confirmationWord;
|
||||
|
||||
// First step: Show what will happen and ask for confirmation
|
||||
if (step === 'confirm') {
|
||||
// Show conflict resolution UI if there are merge conflicts
|
||||
if (mergeConflict) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitMerge className="w-5 h-5 text-green-600" />
|
||||
Merge to Main
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
Merge Conflicts Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<span className="block">
|
||||
Merge branch{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> into
|
||||
main?
|
||||
There are conflicts when merging{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">
|
||||
{mergeConflict.sourceBranch}
|
||||
</code>{' '}
|
||||
into{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">
|
||||
{mergeConflict.targetBranch}
|
||||
</code>
|
||||
.
|
||||
</span>
|
||||
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
This will:
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Merge the branch into the main branch</li>
|
||||
<li>Remove the worktree directory</li>
|
||||
<li>Delete the branch</li>
|
||||
</ul>
|
||||
<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>
|
||||
|
||||
{worktree.hasChanges && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-yellow-500 text-sm">
|
||||
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
|
||||
commit or discard them before merging.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{affectedFeatureCount > 0 && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-blue-500/10 border border-blue-500/20 mt-2">
|
||||
<AlertTriangle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-blue-500 text-sm">
|
||||
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? 's' : ''}{' '}
|
||||
{affectedFeatureCount !== 1 ? 'are' : 'is'} assigned to this branch and will
|
||||
be unassigned after merge.
|
||||
</span>
|
||||
</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>
|
||||
<ul className="text-sm text-muted-foreground mt-2 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
|
||||
</li>
|
||||
<li>Ensure the code compiles and tests pass</li>
|
||||
<li>Complete the merge automatically</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Button variant="ghost" onClick={() => setMergeConflict(null)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleProceedToVerify}
|
||||
disabled={worktree.hasChanges}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
onClick={handleCreateConflictResolutionFeature}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<GitMerge className="w-4 h-4 mr-2" />
|
||||
Continue
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
Create Resolve Conflicts Feature
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -168,52 +246,86 @@ export function MergeWorktreeDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// Second step: Type confirmation
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
Confirm Merge
|
||||
<GitMerge className="w-5 h-5 text-green-600" />
|
||||
Merge Branch
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<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-600 dark:text-orange-400 text-sm">
|
||||
This action cannot be undone. The branch{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> will be
|
||||
permanently deleted after merging.
|
||||
</span>
|
||||
</div>
|
||||
<span className="block">
|
||||
Merge <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
|
||||
into:
|
||||
</span>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-merge" className="text-sm text-foreground">
|
||||
Type <span className="font-bold text-foreground">{confirmationWord}</span> to
|
||||
confirm:
|
||||
<Label htmlFor="target-branch" className="text-sm text-foreground">
|
||||
Target Branch
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-merge"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder={confirmationWord}
|
||||
disabled={isLoading}
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{loadingBranches ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner size="sm" />
|
||||
Loading branches...
|
||||
</div>
|
||||
) : (
|
||||
<BranchAutocomplete
|
||||
value={targetBranch}
|
||||
onChange={setTargetBranch}
|
||||
branches={availableBranches}
|
||||
placeholder="Select target branch..."
|
||||
data-testid="merge-target-branch"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{worktree.hasChanges && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-yellow-500 text-sm">
|
||||
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
|
||||
commit or discard them before merging.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center space-x-2 py-2">
|
||||
<Checkbox
|
||||
id="delete-worktree-branch"
|
||||
checked={deleteWorktreeAndBranch}
|
||||
onCheckedChange={(checked) => setDeleteWorktreeAndBranch(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="delete-worktree-branch"
|
||||
className="text-sm cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
||||
Delete worktree and branch after merging
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{deleteWorktreeAndBranch && (
|
||||
<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 worktree and branch will be permanently deleted. Any features assigned to this
|
||||
branch will be unassigned.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setStep('confirm')} disabled={isLoading}>
|
||||
Back
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMerge}
|
||||
disabled={isLoading || !isConfirmValid}
|
||||
disabled={worktree.hasChanges || !targetBranch || loadingBranches || isLoading}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
@@ -223,8 +335,8 @@ export function MergeWorktreeDialog({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Merge to Main
|
||||
<GitMerge className="w-4 h-4 mr-2" />
|
||||
Merge
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useState, useEffect } 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 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface RemoteBranch {
|
||||
name: string;
|
||||
fullRef: string;
|
||||
}
|
||||
|
||||
interface RemoteInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
branches: RemoteBranch[];
|
||||
}
|
||||
|
||||
const logger = createLogger('PullResolveConflictsDialog');
|
||||
|
||||
interface PullResolveConflictsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void;
|
||||
}
|
||||
|
||||
export function PullResolveConflictsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onConfirm,
|
||||
}: PullResolveConflictsDialogProps) {
|
||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||
const [selectedBranch, setSelectedBranch] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch remotes when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
fetchRemotes();
|
||||
}
|
||||
}, [open, worktree]);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedRemote('');
|
||||
setSelectedBranch('');
|
||||
setError(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 handleConfirm = () => {
|
||||
if (!worktree || !selectedBranch) return;
|
||||
onConfirm(worktree, selectedBranch);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
|
||||
const branches = selectedRemoteData?.branches || [];
|
||||
|
||||
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" />
|
||||
Pull & Resolve Conflicts
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a remote branch to pull from and resolve conflicts 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}>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{remote.url}
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{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 pull from{' '}
|
||||
<span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
|
||||
<span className="font-mono text-foreground">{worktree?.branch}</span> and resolve
|
||||
any merge conflicts.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedBranch || isLoading}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<GitMerge className="w-4 h-4 mr-2" />
|
||||
Pull & Resolve
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useState, useEffect } 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,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { Upload, RefreshCw, AlertTriangle, Sparkles } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import type { WorktreeInfo } from '../worktree-panel/types';
|
||||
|
||||
interface RemoteInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const logger = createLogger('PushToRemoteDialog');
|
||||
|
||||
interface PushToRemoteDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onConfirm: (worktree: WorktreeInfo, remote: string) => void;
|
||||
}
|
||||
|
||||
export function PushToRemoteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onConfirm,
|
||||
}: PushToRemoteDialogProps) {
|
||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch remotes when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
fetchRemotes();
|
||||
}
|
||||
}, [open, worktree]);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedRemote('');
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Auto-select default remote 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);
|
||||
}
|
||||
}, [remotes, selectedRemote]);
|
||||
|
||||
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) {
|
||||
// Extract just the remote info (name and URL), not the branches
|
||||
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
}));
|
||||
setRemotes(remoteInfos);
|
||||
if (remoteInfos.length === 0) {
|
||||
setError('No remotes found in this repository. Please add a remote first.');
|
||||
}
|
||||
} 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) {
|
||||
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
}));
|
||||
setRemotes(remoteInfos);
|
||||
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 handleConfirm = () => {
|
||||
if (!worktree || !selectedRemote) return;
|
||||
onConfirm(worktree, selectedRemote);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-primary" />
|
||||
Push New Branch to Remote
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded-full ml-2">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
new
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Push{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{worktree?.branch || 'current branch'}
|
||||
</span>{' '}
|
||||
to a remote repository for the first time.
|
||||
</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">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}>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{remote.url}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedRemote && (
|
||||
<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 new remote branch{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{selectedRemote}/{worktree?.branch}
|
||||
</span>{' '}
|
||||
and set up tracking.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Push to {selectedRemote || 'Remote'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user