mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
apply the patches
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import React, { memo, useLayoutEffect, useState } from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import React, { memo, useLayoutEffect, useState, useCallback } from 'react';
|
||||
import { useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -123,12 +123,39 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'waiting_approval' ||
|
||||
feature.status === 'verified' ||
|
||||
feature.status.startsWith('pipeline_') ||
|
||||
(feature.status === 'in_progress' && !isCurrentAutoTask));
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setDraggableRef,
|
||||
isDragging,
|
||||
} = useDraggable({
|
||||
id: feature.id,
|
||||
disabled: !isDraggable || isOverlay || isSelectionMode,
|
||||
});
|
||||
|
||||
// Make the card a drop target for creating dependency links
|
||||
// Only backlog cards can be link targets (to avoid complexity with running features)
|
||||
const isDroppable = !isOverlay && feature.status === 'backlog' && !isSelectionMode;
|
||||
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||
id: `card-drop-${feature.id}`,
|
||||
disabled: !isDroppable,
|
||||
data: {
|
||||
type: 'card',
|
||||
featureId: feature.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Combine refs for both draggable and droppable
|
||||
const setNodeRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
setDraggableRef(node);
|
||||
setDroppableRef(node);
|
||||
},
|
||||
[setDraggableRef, setDroppableRef]
|
||||
);
|
||||
|
||||
const dndStyle = {
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
};
|
||||
@@ -141,7 +168,9 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
const wrapperClasses = cn(
|
||||
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
|
||||
getCursorClass(isOverlay, isDraggable, isSelectable),
|
||||
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
|
||||
isOverlay && isLifted && 'scale-105 rotate-1 z-50',
|
||||
// Visual feedback when another card is being dragged over this one
|
||||
isOver && !isDragging && 'ring-2 ring-primary ring-offset-2 ring-offset-background scale-[1.02]'
|
||||
);
|
||||
|
||||
const isInteractive = !isDragging && !isOverlay;
|
||||
|
||||
@@ -23,7 +23,6 @@ interface ColumnDef {
|
||||
|
||||
/**
|
||||
* Default column definitions for the list view
|
||||
* Only showing title column with full width for a cleaner, more spacious layout
|
||||
*/
|
||||
export const LIST_COLUMNS: ColumnDef[] = [
|
||||
{
|
||||
@@ -34,6 +33,14 @@ export const LIST_COLUMNS: ColumnDef[] = [
|
||||
minWidth: 'min-w-0',
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: '',
|
||||
sortable: true,
|
||||
width: 'w-18',
|
||||
minWidth: 'min-w-[16px]',
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
export interface ListHeaderProps {
|
||||
@@ -117,6 +124,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
column.width,
|
||||
column.minWidth,
|
||||
column.width !== 'flex-1' && 'shrink-0',
|
||||
column.align === 'center' && 'justify-center',
|
||||
column.align === 'right' && 'justify-end',
|
||||
isSorted && 'text-foreground',
|
||||
@@ -141,6 +149,7 @@ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column
|
||||
'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground',
|
||||
column.width,
|
||||
column.minWidth,
|
||||
column.width !== 'flex-1' && 'shrink-0',
|
||||
column.align === 'center' && 'justify-center',
|
||||
column.align === 'right' && 'justify-end',
|
||||
column.className
|
||||
|
||||
@@ -281,7 +281,7 @@ export const ListRow = memo(function ListRow({
|
||||
<div
|
||||
role="cell"
|
||||
className={cn(
|
||||
'flex items-center px-3 py-3 gap-2',
|
||||
'flex items-center pl-3 pr-0 py-3 gap-0',
|
||||
getColumnWidth('title'),
|
||||
getColumnAlign('title')
|
||||
)}
|
||||
@@ -315,6 +315,42 @@ export const ListRow = memo(function ListRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority column */}
|
||||
<div
|
||||
role="cell"
|
||||
className={cn(
|
||||
'flex items-center pl-0 pr-3 py-3 shrink-0',
|
||||
getColumnWidth('priority'),
|
||||
getColumnAlign('priority')
|
||||
)}
|
||||
data-testid={`list-row-priority-${feature.id}`}
|
||||
>
|
||||
{feature.priority ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center w-6 h-6 rounded-md border-[1.5px] font-bold text-xs',
|
||||
feature.priority === 1 &&
|
||||
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
|
||||
feature.priority === 2 &&
|
||||
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
|
||||
feature.priority === 3 &&
|
||||
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
|
||||
)}
|
||||
title={
|
||||
feature.priority === 1
|
||||
? 'High Priority'
|
||||
: feature.priority === 2
|
||||
? 'Medium Priority'
|
||||
: 'Low Priority'
|
||||
}
|
||||
>
|
||||
{feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions column */}
|
||||
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
|
||||
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export function useBoardActions({
|
||||
skipVerificationInAutoMode,
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
getAutoModeState,
|
||||
} = useAppStore();
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
@@ -485,10 +486,22 @@ export function useBoardActions({
|
||||
|
||||
const handleStartImplementation = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!autoMode.canStartNewTask) {
|
||||
// Check capacity for the feature's specific worktree, not the current view
|
||||
const featureBranchName = feature.branchName ?? null;
|
||||
const featureWorktreeState = currentProject
|
||||
? getAutoModeState(currentProject.id, featureBranchName)
|
||||
: null;
|
||||
const featureMaxConcurrency = featureWorktreeState?.maxConcurrency ?? autoMode.maxConcurrency;
|
||||
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
|
||||
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
|
||||
|
||||
if (!canStartInWorktree) {
|
||||
const worktreeDesc = featureBranchName
|
||||
? `worktree "${featureBranchName}"`
|
||||
: 'main worktree';
|
||||
toast.error('Concurrency limit reached', {
|
||||
description: `You can only have ${autoMode.maxConcurrency} task${
|
||||
autoMode.maxConcurrency > 1 ? 's' : ''
|
||||
description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${
|
||||
featureMaxConcurrency > 1 ? 's' : ''
|
||||
} running at a time. Wait for a task to complete or increase the limit.`,
|
||||
});
|
||||
return false;
|
||||
@@ -552,6 +565,8 @@ export function useBoardActions({
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
handleRunFeature,
|
||||
currentProject,
|
||||
getAutoModeState,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import { COLUMNS, ColumnId } from '../constants';
|
||||
|
||||
const logger = createLogger('BoardDragDrop');
|
||||
|
||||
export interface PendingDependencyLink {
|
||||
draggedFeature: Feature;
|
||||
targetFeature: Feature;
|
||||
}
|
||||
|
||||
interface UseBoardDragDropProps {
|
||||
features: Feature[];
|
||||
currentProject: { path: string; id: string } | null;
|
||||
@@ -24,7 +29,10 @@ export function useBoardDragDrop({
|
||||
handleStartImplementation,
|
||||
}: UseBoardDragDropProps) {
|
||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||
const { moveFeature } = useAppStore();
|
||||
const [pendingDependencyLink, setPendingDependencyLink] = useState<PendingDependencyLink | null>(
|
||||
null
|
||||
);
|
||||
const { moveFeature, updateFeature } = useAppStore();
|
||||
|
||||
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
|
||||
// at execution time based on feature.branchName
|
||||
@@ -40,6 +48,11 @@ export function useBoardDragDrop({
|
||||
[features]
|
||||
);
|
||||
|
||||
// Clear pending dependency link
|
||||
const clearPendingDependencyLink = useCallback(() => {
|
||||
setPendingDependencyLink(null);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
@@ -57,6 +70,85 @@ export function useBoardDragDrop({
|
||||
// Check if this is a running task (non-skipTests, TDD)
|
||||
const isRunningTask = runningAutoTasks.includes(featureId);
|
||||
|
||||
// Check if dropped on another card (for creating dependency links)
|
||||
if (overId.startsWith('card-drop-')) {
|
||||
const cardData = over.data.current as {
|
||||
type: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (cardData?.type === 'card') {
|
||||
const targetFeatureId = cardData.featureId;
|
||||
|
||||
// Don't link to self
|
||||
if (targetFeatureId === featureId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetFeature = features.find((f) => f.id === targetFeatureId);
|
||||
if (!targetFeature) return;
|
||||
|
||||
// Only allow linking backlog features (both must be in backlog)
|
||||
if (draggedFeature.status !== 'backlog' || targetFeature.status !== 'backlog') {
|
||||
toast.error('Cannot link features', {
|
||||
description: 'Both features must be in the backlog to create a dependency link.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Set pending dependency link to trigger dialog
|
||||
setPendingDependencyLink({
|
||||
draggedFeature,
|
||||
targetFeature,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if dropped on a worktree tab
|
||||
if (overId.startsWith('worktree-drop-')) {
|
||||
// Handle dropping on a worktree - change the feature's branchName
|
||||
const worktreeData = over.data.current as {
|
||||
type: string;
|
||||
branch: string;
|
||||
path: string;
|
||||
isMain: boolean;
|
||||
};
|
||||
|
||||
if (worktreeData?.type === 'worktree') {
|
||||
// Don't allow moving running tasks to a different worktree
|
||||
if (isRunningTask) {
|
||||
logger.debug('Cannot move running feature to different worktree');
|
||||
toast.error('Cannot move feature', {
|
||||
description: 'This feature is currently running and cannot be moved.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const targetBranch = worktreeData.branch;
|
||||
const currentBranch = draggedFeature.branchName;
|
||||
|
||||
// If already on the same branch, nothing to do
|
||||
if (currentBranch === targetBranch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For main worktree, set branchName to undefined/null to indicate it should use main
|
||||
// For other worktrees, set branchName to the target branch
|
||||
const newBranchName = worktreeData.isMain ? undefined : targetBranch;
|
||||
|
||||
// Update feature's branchName
|
||||
updateFeature(featureId, { branchName: newBranchName });
|
||||
await persistFeatureUpdate(featureId, { branchName: newBranchName });
|
||||
|
||||
const branchDisplay = worktreeData.isMain ? targetBranch : targetBranch;
|
||||
toast.success('Feature moved to branch', {
|
||||
description: `Moved to ${branchDisplay}: ${draggedFeature.description.slice(0, 40)}${draggedFeature.description.length > 40 ? '...' : ''}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if dragging is allowed based on status and skipTests
|
||||
// - Backlog items can always be dragged
|
||||
// - waiting_approval items can always be dragged (to allow manual verification via drag)
|
||||
@@ -205,12 +297,21 @@ export function useBoardDragDrop({
|
||||
}
|
||||
}
|
||||
},
|
||||
[features, runningAutoTasks, moveFeature, persistFeatureUpdate, handleStartImplementation]
|
||||
[
|
||||
features,
|
||||
runningAutoTasks,
|
||||
moveFeature,
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
activeFeature,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
pendingDependencyLink,
|
||||
clearPendingDependencyLink,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
|
||||
const logger = createLogger('BoardEffects');
|
||||
@@ -65,37 +64,8 @@ export function useBoardEffects({
|
||||
};
|
||||
}, [specCreatingForProject, setSpecCreatingForProject]);
|
||||
|
||||
// Sync running tasks from electron backend on mount
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const syncRunningTasks = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
|
||||
const status = await api.autoMode.status(currentProject.path);
|
||||
if (status.success) {
|
||||
const projectId = currentProject.id;
|
||||
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
|
||||
|
||||
if (status.runningFeatures) {
|
||||
logger.info('Syncing running tasks from backend:', status.runningFeatures);
|
||||
|
||||
clearRunningTasks(projectId);
|
||||
|
||||
status.runningFeatures.forEach((featureId: string) => {
|
||||
addRunningTask(projectId, featureId);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync running tasks:', error);
|
||||
}
|
||||
};
|
||||
|
||||
syncRunningTasks();
|
||||
}, [currentProject]);
|
||||
// Note: Running tasks sync is now handled by useAutoMode hook in BoardView
|
||||
// which correctly handles worktree/branch scoping.
|
||||
|
||||
// Check which features have context files
|
||||
useEffect(() => {
|
||||
|
||||
@@ -123,7 +123,9 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
} else if (event.type === 'auto_mode_error') {
|
||||
// Remove from running tasks
|
||||
if (event.featureId) {
|
||||
removeRunningTask(eventProjectId, event.featureId);
|
||||
const eventBranchName =
|
||||
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
|
||||
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||
}
|
||||
|
||||
// Show error toast
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode, UIEvent, RefObject } from 'react';
|
||||
import { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import { useMemo } from 'react';
|
||||
import { DragOverlay } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
|
||||
@@ -11,10 +10,6 @@ import { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||
import type { PipelineConfig } from '@automaker/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
interface KanbanBoardProps {
|
||||
sensors: any;
|
||||
collisionDetectionStrategy: (args: any) => any;
|
||||
onDragStart: (event: any) => void;
|
||||
onDragEnd: (event: any) => void;
|
||||
activeFeature: Feature | null;
|
||||
getColumnFeatures: (columnId: ColumnId) => Feature[];
|
||||
backgroundImageStyle: React.CSSProperties;
|
||||
@@ -259,10 +254,6 @@ function VirtualizedList<Item extends VirtualListItem>({
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
sensors,
|
||||
collisionDetectionStrategy,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
activeFeature,
|
||||
getColumnFeatures,
|
||||
backgroundImageStyle,
|
||||
@@ -319,131 +310,99 @@ export function KanbanBoard({
|
||||
)}
|
||||
style={backgroundImageStyle}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div className="h-full py-1" style={containerStyle}>
|
||||
{columns.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
||||
return (
|
||||
<VirtualizedList
|
||||
key={column.id}
|
||||
items={columnFeatures}
|
||||
isDragging={isDragging}
|
||||
estimatedItemHeight={KANBAN_CARD_ESTIMATED_HEIGHT_PX}
|
||||
itemGap={KANBAN_CARD_GAP_PX}
|
||||
overscan={KANBAN_OVERSCAN_COUNT}
|
||||
virtualizationThreshold={KANBAN_VIRTUALIZATION_THRESHOLD}
|
||||
>
|
||||
{({
|
||||
contentRef,
|
||||
onScroll,
|
||||
itemIds,
|
||||
visibleItems,
|
||||
totalHeight,
|
||||
offsetTop,
|
||||
startIndex,
|
||||
shouldVirtualize,
|
||||
registerItem,
|
||||
}) => (
|
||||
<KanbanColumn
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
colorClass={column.colorClass}
|
||||
count={columnFeatures.length}
|
||||
width={columnWidth}
|
||||
opacity={backgroundSettings.columnOpacity}
|
||||
showBorder={backgroundSettings.columnBorderEnabled}
|
||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||
contentRef={contentRef}
|
||||
onScroll={shouldVirtualize ? onScroll : undefined}
|
||||
disableItemSpacing={shouldVirtualize}
|
||||
contentClassName="perf-contain"
|
||||
headerAction={
|
||||
column.id === 'verified' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{columnFeatures.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onArchiveAllVerified}
|
||||
data-testid="archive-all-verified-button"
|
||||
>
|
||||
<Archive className="w-3 h-3 mr-1" />
|
||||
Complete All
|
||||
</Button>
|
||||
)}
|
||||
<div className="h-full py-1" style={containerStyle}>
|
||||
{columns.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
||||
return (
|
||||
<VirtualizedList
|
||||
key={column.id}
|
||||
items={columnFeatures}
|
||||
isDragging={isDragging}
|
||||
estimatedItemHeight={KANBAN_CARD_ESTIMATED_HEIGHT_PX}
|
||||
itemGap={KANBAN_CARD_GAP_PX}
|
||||
overscan={KANBAN_OVERSCAN_COUNT}
|
||||
virtualizationThreshold={KANBAN_VIRTUALIZATION_THRESHOLD}
|
||||
>
|
||||
{({
|
||||
contentRef,
|
||||
onScroll,
|
||||
itemIds,
|
||||
visibleItems,
|
||||
totalHeight,
|
||||
offsetTop,
|
||||
startIndex,
|
||||
shouldVirtualize,
|
||||
registerItem,
|
||||
}) => (
|
||||
<KanbanColumn
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
colorClass={column.colorClass}
|
||||
count={columnFeatures.length}
|
||||
width={columnWidth}
|
||||
opacity={backgroundSettings.columnOpacity}
|
||||
showBorder={backgroundSettings.columnBorderEnabled}
|
||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||
contentRef={contentRef}
|
||||
onScroll={shouldVirtualize ? onScroll : undefined}
|
||||
disableItemSpacing={shouldVirtualize}
|
||||
contentClassName="perf-contain"
|
||||
headerAction={
|
||||
column.id === 'verified' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{columnFeatures.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 relative"
|
||||
onClick={onShowCompletedModal}
|
||||
title={`Completed Features (${completedCount})`}
|
||||
data-testid="completed-features-button"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onArchiveAllVerified}
|
||||
data-testid="archive-all-verified-button"
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{completedCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
|
||||
{completedCount > 99 ? '99+' : completedCount}
|
||||
</span>
|
||||
)}
|
||||
<Archive className="w-3 h-3 mr-1" />
|
||||
Complete All
|
||||
</Button>
|
||||
</div>
|
||||
) : column.id === 'backlog' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={onAddFeature}
|
||||
title="Add Feature"
|
||||
data-testid="add-feature-button"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => onToggleSelectionMode?.('backlog')}
|
||||
title={
|
||||
selectionTarget === 'backlog'
|
||||
? 'Switch to Drag Mode'
|
||||
: 'Select Multiple'
|
||||
}
|
||||
data-testid="selection-mode-button"
|
||||
>
|
||||
{selectionTarget === 'backlog' ? (
|
||||
<>
|
||||
<GripVertical className="w-3.5 h-3.5 mr-1" />
|
||||
Drag
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckSquare className="w-3.5 h-3.5 mr-1" />
|
||||
Select
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : column.id === 'waiting_approval' ? (
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => onToggleSelectionMode?.('waiting_approval')}
|
||||
className="h-6 w-6 p-0 relative"
|
||||
onClick={onShowCompletedModal}
|
||||
title={`Completed Features (${completedCount})`}
|
||||
data-testid="completed-features-button"
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{completedCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
|
||||
{completedCount > 99 ? '99+' : completedCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : column.id === 'backlog' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={onAddFeature}
|
||||
title="Add Feature"
|
||||
data-testid="add-feature-button"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => onToggleSelectionMode?.('backlog')}
|
||||
title={
|
||||
selectionTarget === 'waiting_approval'
|
||||
selectionTarget === 'backlog'
|
||||
? 'Switch to Drag Mode'
|
||||
: 'Select Multiple'
|
||||
}
|
||||
data-testid="waiting-approval-selection-mode-button"
|
||||
data-testid="selection-mode-button"
|
||||
>
|
||||
{selectionTarget === 'waiting_approval' ? (
|
||||
{selectionTarget === 'backlog' ? (
|
||||
<>
|
||||
<GripVertical className="w-3.5 h-3.5 mr-1" />
|
||||
Drag
|
||||
@@ -455,221 +414,242 @@ export function KanbanBoard({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : column.id === 'in_progress' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenPipelineSettings}
|
||||
title="Pipeline Settings"
|
||||
data-testid="pipeline-settings-button"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
) : column.isPipelineStep ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenPipelineSettings}
|
||||
title="Edit Pipeline Step"
|
||||
data-testid="edit-pipeline-step-button"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
footerAction={
|
||||
column.id === 'backlog' ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full h-9 text-sm"
|
||||
onClick={onAddFeature}
|
||||
data-testid="add-feature-floating-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
|
||||
{formatShortcut(addFeatureShortcut, true)}
|
||||
</span>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{(() => {
|
||||
const reduceEffects = shouldVirtualize;
|
||||
const effectiveCardOpacity = reduceEffects
|
||||
? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT)
|
||||
: backgroundSettings.cardOpacity;
|
||||
const effectiveGlassmorphism =
|
||||
backgroundSettings.cardGlassmorphism && !reduceEffects;
|
||||
</div>
|
||||
) : column.id === 'waiting_approval' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => onToggleSelectionMode?.('waiting_approval')}
|
||||
title={
|
||||
selectionTarget === 'waiting_approval'
|
||||
? 'Switch to Drag Mode'
|
||||
: 'Select Multiple'
|
||||
}
|
||||
data-testid="waiting-approval-selection-mode-button"
|
||||
>
|
||||
{selectionTarget === 'waiting_approval' ? (
|
||||
<>
|
||||
<GripVertical className="w-3.5 h-3.5 mr-1" />
|
||||
Drag
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckSquare className="w-3.5 h-3.5 mr-1" />
|
||||
Select
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : column.id === 'in_progress' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenPipelineSettings}
|
||||
title="Pipeline Settings"
|
||||
data-testid="pipeline-settings-button"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
) : column.isPipelineStep ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenPipelineSettings}
|
||||
title="Edit Pipeline Step"
|
||||
data-testid="edit-pipeline-step-button"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
footerAction={
|
||||
column.id === 'backlog' ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full h-9 text-sm"
|
||||
onClick={onAddFeature}
|
||||
data-testid="add-feature-floating-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
|
||||
{formatShortcut(addFeatureShortcut, true)}
|
||||
</span>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{(() => {
|
||||
const reduceEffects = shouldVirtualize;
|
||||
const effectiveCardOpacity = reduceEffects
|
||||
? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT)
|
||||
: backgroundSettings.cardOpacity;
|
||||
const effectiveGlassmorphism =
|
||||
backgroundSettings.cardGlassmorphism && !reduceEffects;
|
||||
|
||||
return (
|
||||
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
||||
{/* Empty state card when column has no features */}
|
||||
{columnFeatures.length === 0 && !isDragging && (
|
||||
<EmptyStateCard
|
||||
columnId={column.id}
|
||||
columnTitle={column.title}
|
||||
addFeatureShortcut={addFeatureShortcut}
|
||||
isReadOnly={isReadOnly}
|
||||
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
|
||||
opacity={effectiveCardOpacity}
|
||||
glassmorphism={effectiveGlassmorphism}
|
||||
customConfig={
|
||||
column.isPipelineStep
|
||||
? {
|
||||
title: `${column.title} Empty`,
|
||||
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{shouldVirtualize ? (
|
||||
<div className="relative" style={{ height: totalHeight }}>
|
||||
<div
|
||||
className="absolute left-0 right-0"
|
||||
style={{ transform: `translateY(${offsetTop}px)` }}
|
||||
>
|
||||
{visibleItems.map((feature, index) => {
|
||||
const absoluteIndex = startIndex + index;
|
||||
let shortcutKey: string | undefined;
|
||||
if (column.id === 'in_progress' && absoluteIndex < 10) {
|
||||
shortcutKey =
|
||||
absoluteIndex === 9 ? '0' : String(absoluteIndex + 1);
|
||||
return (
|
||||
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
||||
{/* Empty state card when column has no features */}
|
||||
{columnFeatures.length === 0 && !isDragging && (
|
||||
<EmptyStateCard
|
||||
columnId={column.id}
|
||||
columnTitle={column.title}
|
||||
addFeatureShortcut={addFeatureShortcut}
|
||||
isReadOnly={isReadOnly}
|
||||
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
|
||||
opacity={effectiveCardOpacity}
|
||||
glassmorphism={effectiveGlassmorphism}
|
||||
customConfig={
|
||||
column.isPipelineStep
|
||||
? {
|
||||
title: `${column.title} Empty`,
|
||||
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={feature.id}
|
||||
ref={registerItem(feature.id)}
|
||||
style={{ marginBottom: `${KANBAN_CARD_GAP_PX}px` }}
|
||||
>
|
||||
<KanbanCard
|
||||
feature={feature}
|
||||
onEdit={() => onEdit(feature)}
|
||||
onDelete={() => onDelete(feature.id)}
|
||||
onViewOutput={() => onViewOutput(feature)}
|
||||
onVerify={() => onVerify(feature)}
|
||||
onResume={() => onResume(feature)}
|
||||
onForceStop={() => onForceStop(feature)}
|
||||
onManualVerify={() => onManualVerify(feature)}
|
||||
onMoveBackToInProgress={() =>
|
||||
onMoveBackToInProgress(feature)
|
||||
}
|
||||
onFollowUp={() => onFollowUp(feature)}
|
||||
onComplete={() => onComplete(feature)}
|
||||
onImplement={() => onImplement(feature)}
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
opacity={effectiveCardOpacity}
|
||||
glassmorphism={effectiveGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
reduceEffects={reduceEffects}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectionTarget={selectionTarget}
|
||||
isSelected={selectedFeatureIds.has(feature.id)}
|
||||
onToggleSelect={() =>
|
||||
onToggleFeatureSelection?.(feature.id)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{shouldVirtualize ? (
|
||||
<div className="relative" style={{ height: totalHeight }}>
|
||||
<div
|
||||
className="absolute left-0 right-0"
|
||||
style={{ transform: `translateY(${offsetTop}px)` }}
|
||||
>
|
||||
{visibleItems.map((feature, index) => {
|
||||
const absoluteIndex = startIndex + index;
|
||||
let shortcutKey: string | undefined;
|
||||
if (column.id === 'in_progress' && absoluteIndex < 10) {
|
||||
shortcutKey =
|
||||
absoluteIndex === 9 ? '0' : String(absoluteIndex + 1);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={feature.id}
|
||||
ref={registerItem(feature.id)}
|
||||
style={{ marginBottom: `${KANBAN_CARD_GAP_PX}px` }}
|
||||
>
|
||||
<KanbanCard
|
||||
feature={feature}
|
||||
onEdit={() => onEdit(feature)}
|
||||
onDelete={() => onDelete(feature.id)}
|
||||
onViewOutput={() => onViewOutput(feature)}
|
||||
onVerify={() => onVerify(feature)}
|
||||
onResume={() => onResume(feature)}
|
||||
onForceStop={() => onForceStop(feature)}
|
||||
onManualVerify={() => onManualVerify(feature)}
|
||||
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
|
||||
onFollowUp={() => onFollowUp(feature)}
|
||||
onComplete={() => onComplete(feature)}
|
||||
onImplement={() => onImplement(feature)}
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
opacity={effectiveCardOpacity}
|
||||
glassmorphism={effectiveGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
reduceEffects={reduceEffects}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectionTarget={selectionTarget}
|
||||
isSelected={selectedFeatureIds.has(feature.id)}
|
||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
columnFeatures.map((feature, index) => {
|
||||
let shortcutKey: string | undefined;
|
||||
if (column.id === 'in_progress' && index < 10) {
|
||||
shortcutKey = index === 9 ? '0' : String(index + 1);
|
||||
}
|
||||
return (
|
||||
<KanbanCard
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
onEdit={() => onEdit(feature)}
|
||||
onDelete={() => onDelete(feature.id)}
|
||||
onViewOutput={() => onViewOutput(feature)}
|
||||
onVerify={() => onVerify(feature)}
|
||||
onResume={() => onResume(feature)}
|
||||
onForceStop={() => onForceStop(feature)}
|
||||
onManualVerify={() => onManualVerify(feature)}
|
||||
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
|
||||
onFollowUp={() => onFollowUp(feature)}
|
||||
onComplete={() => onComplete(feature)}
|
||||
onImplement={() => onImplement(feature)}
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
opacity={effectiveCardOpacity}
|
||||
glassmorphism={effectiveGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
reduceEffects={reduceEffects}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectionTarget={selectionTarget}
|
||||
isSelected={selectedFeatureIds.has(feature.id)}
|
||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</SortableContext>
|
||||
);
|
||||
})()}
|
||||
</KanbanColumn>
|
||||
)}
|
||||
</VirtualizedList>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
columnFeatures.map((feature, index) => {
|
||||
let shortcutKey: string | undefined;
|
||||
if (column.id === 'in_progress' && index < 10) {
|
||||
shortcutKey = index === 9 ? '0' : String(index + 1);
|
||||
}
|
||||
return (
|
||||
<KanbanCard
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
onEdit={() => onEdit(feature)}
|
||||
onDelete={() => onDelete(feature.id)}
|
||||
onViewOutput={() => onViewOutput(feature)}
|
||||
onVerify={() => onVerify(feature)}
|
||||
onResume={() => onResume(feature)}
|
||||
onForceStop={() => onForceStop(feature)}
|
||||
onManualVerify={() => onManualVerify(feature)}
|
||||
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
|
||||
onFollowUp={() => onFollowUp(feature)}
|
||||
onComplete={() => onComplete(feature)}
|
||||
onImplement={() => onImplement(feature)}
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
opacity={effectiveCardOpacity}
|
||||
glassmorphism={effectiveGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
reduceEffects={reduceEffects}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectionTarget={selectionTarget}
|
||||
isSelected={selectedFeatureIds.has(feature.id)}
|
||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</SortableContext>
|
||||
);
|
||||
})()}
|
||||
</KanbanColumn>
|
||||
)}
|
||||
</VirtualizedList>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DragOverlay
|
||||
dropAnimation={{
|
||||
duration: 200,
|
||||
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
|
||||
}}
|
||||
>
|
||||
{activeFeature && (
|
||||
<div style={{ width: `${columnWidth}px` }}>
|
||||
<KanbanCard
|
||||
feature={activeFeature}
|
||||
isOverlay
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onViewOutput={() => {}}
|
||||
onVerify={() => {}}
|
||||
onResume={() => {}}
|
||||
onForceStop={() => {}}
|
||||
onManualVerify={() => {}}
|
||||
onMoveBackToInProgress={() => {}}
|
||||
onFollowUp={() => {}}
|
||||
onImplement={() => {}}
|
||||
onComplete={() => {}}
|
||||
onViewPlan={() => {}}
|
||||
onApprovePlan={() => {}}
|
||||
onSpawnTask={() => {}}
|
||||
hasContext={featuresWithContext.has(activeFeature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
|
||||
opacity={backgroundSettings.cardOpacity}
|
||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<DragOverlay
|
||||
dropAnimation={{
|
||||
duration: 200,
|
||||
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
|
||||
}}
|
||||
>
|
||||
{activeFeature && (
|
||||
<div style={{ width: `${columnWidth}px` }}>
|
||||
<KanbanCard
|
||||
feature={activeFeature}
|
||||
isOverlay
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onViewOutput={() => {}}
|
||||
onVerify={() => {}}
|
||||
onResume={() => {}}
|
||||
onForceStop={() => {}}
|
||||
onManualVerify={() => {}}
|
||||
onMoveBackToInProgress={() => {}}
|
||||
onFollowUp={() => {}}
|
||||
onImplement={() => {}}
|
||||
onComplete={() => {}}
|
||||
onViewPlan={() => {}}
|
||||
onApprovePlan={() => {}}
|
||||
onSpawnTask={() => {}}
|
||||
hasContext={featuresWithContext.has(activeFeature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
|
||||
opacity={backgroundSettings.cardOpacity}
|
||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,11 +27,12 @@ import {
|
||||
Copy,
|
||||
Eye,
|
||||
ScrollText,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
SquarePlus,
|
||||
SplitSquareHorizontal,
|
||||
Zap,
|
||||
Undo2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -51,6 +52,7 @@ interface WorktreeActionsDropdownProps {
|
||||
isSelected: boolean;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
hasRemoteBranch: boolean;
|
||||
isPulling: boolean;
|
||||
isPushing: boolean;
|
||||
isStartingDevServer: boolean;
|
||||
@@ -64,6 +66,7 @@ interface WorktreeActionsDropdownProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onPushNewBranch: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
@@ -73,7 +76,6 @@ interface WorktreeActionsDropdownProps {
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
@@ -81,6 +83,7 @@ interface WorktreeActionsDropdownProps {
|
||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
|
||||
@@ -89,6 +92,7 @@ export function WorktreeActionsDropdown({
|
||||
isSelected,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
isPulling,
|
||||
isPushing,
|
||||
isStartingDevServer,
|
||||
@@ -100,6 +104,7 @@ export function WorktreeActionsDropdown({
|
||||
onOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
onPushNewBranch,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
@@ -109,7 +114,6 @@ export function WorktreeActionsDropdown({
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onMerge,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
@@ -117,6 +121,7 @@ export function WorktreeActionsDropdown({
|
||||
onViewDevServerLogs,
|
||||
onRunInitScript,
|
||||
onToggleAutoMode,
|
||||
onMerge,
|
||||
hasInitScript,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
// Get available editors for the "Open In" submenu
|
||||
@@ -264,14 +269,27 @@ export function WorktreeActionsDropdown({
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onPush(worktree)}
|
||||
disabled={isPushing || aheadCount === 0 || !canPerformGitOps}
|
||||
onClick={() => {
|
||||
if (!canPerformGitOps) return;
|
||||
if (!hasRemoteBranch) {
|
||||
onPushNewBranch(worktree);
|
||||
} else {
|
||||
onPush(worktree);
|
||||
}
|
||||
}}
|
||||
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !canPerformGitOps}
|
||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
||||
{isPushing ? 'Pushing...' : 'Push'}
|
||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
||||
{canPerformGitOps && aheadCount > 0 && (
|
||||
{canPerformGitOps && !hasRemoteBranch && (
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
<Sparkles className="w-2.5 h-2.5" />
|
||||
new
|
||||
</span>
|
||||
)}
|
||||
{canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
{aheadCount} ahead
|
||||
</span>
|
||||
@@ -292,27 +310,6 @@ export function WorktreeActionsDropdown({
|
||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
{!worktree.isMain && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onMerge(worktree)}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn(
|
||||
'text-xs text-green-600 focus:text-green-700',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Merge to Main
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{/* Open in editor - split button: click main area for default, chevron for other options */}
|
||||
{effectiveDefaultEditor && (
|
||||
@@ -546,6 +543,26 @@ export function WorktreeActionsDropdown({
|
||||
)}
|
||||
{!worktree.isMain && (
|
||||
<>
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onMerge(worktree)}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn(
|
||||
'text-xs text-green-600 focus:text-green-700',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Merge Branch
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteWorktree(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
@@ -28,6 +29,7 @@ interface WorktreeTabProps {
|
||||
isStartingDevServer: boolean;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
hasRemoteBranch: boolean;
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
/** Whether auto mode is running for this worktree */
|
||||
isAutoModeRunning?: boolean;
|
||||
@@ -39,6 +41,7 @@ interface WorktreeTabProps {
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onPushNewBranch: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
@@ -79,6 +82,7 @@ export function WorktreeTab({
|
||||
isStartingDevServer,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
gitRepoStatus,
|
||||
isAutoModeRunning = false,
|
||||
onSelectWorktree,
|
||||
@@ -89,6 +93,7 @@ export function WorktreeTab({
|
||||
onCreateBranch,
|
||||
onPull,
|
||||
onPush,
|
||||
onPushNewBranch,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
@@ -108,6 +113,16 @@ export function WorktreeTab({
|
||||
onToggleAutoMode,
|
||||
hasInitScript,
|
||||
}: WorktreeTabProps) {
|
||||
// Make the worktree tab a drop target for feature cards
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `worktree-drop-${worktree.branch}`,
|
||||
data: {
|
||||
type: 'worktree',
|
||||
branch: worktree.branch,
|
||||
path: worktree.path,
|
||||
isMain: worktree.isMain,
|
||||
},
|
||||
});
|
||||
let prBadge: JSX.Element | null = null;
|
||||
if (worktree.pr) {
|
||||
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
|
||||
@@ -194,7 +209,13 @@ export function WorktreeTab({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center rounded-md">
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex items-center rounded-md transition-all duration-150',
|
||||
isOver && 'ring-2 ring-primary ring-offset-2 ring-offset-background scale-105'
|
||||
)}
|
||||
>
|
||||
{worktree.isMain ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -366,6 +387,7 @@ export function WorktreeTab({
|
||||
isSelected={isSelected}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
@@ -376,6 +398,7 @@ export function WorktreeTab({
|
||||
onOpenChange={onActionsDropdownOpenChange}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onPushNewBranch={onPushNewBranch}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||
|
||||
@@ -22,6 +22,7 @@ export function useBranches() {
|
||||
const branches = branchData?.branches ?? [];
|
||||
const aheadCount = branchData?.aheadCount ?? 0;
|
||||
const behindCount = branchData?.behindCount ?? 0;
|
||||
const hasRemoteBranch = branchData?.hasRemoteBranch ?? false;
|
||||
// Use conservative defaults (false) until data is confirmed
|
||||
// This prevents the UI from assuming git capabilities before the query completes
|
||||
const gitRepoStatus: GitRepoStatus = {
|
||||
@@ -55,6 +56,7 @@ export function useBranches() {
|
||||
filteredBranches,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
isLoadingBranches,
|
||||
branchFilter,
|
||||
setBranchFilter,
|
||||
|
||||
@@ -17,6 +17,11 @@ export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFe
|
||||
|
||||
// Match by branchName only (worktreePath is no longer stored)
|
||||
if (feature.branchName) {
|
||||
// Special case: if feature is on 'main' branch, it belongs to main worktree
|
||||
// irrespective of whether the branch name matches exactly (it should, but strict equality might fail if refs differ)
|
||||
if (worktree.isMain && feature.branchName === 'main') {
|
||||
return true;
|
||||
}
|
||||
return worktree.branch === feature.branchName;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ export interface PRInfo {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MergeConflictInfo {
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
targetWorktreePath: string;
|
||||
}
|
||||
|
||||
export interface WorktreePanelProps {
|
||||
projectPath: string;
|
||||
onCreateWorktree: () => void;
|
||||
@@ -70,7 +76,9 @@ export interface WorktreePanelProps {
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => 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;
|
||||
runningFeatureIds?: string[];
|
||||
features?: FeatureInfo[];
|
||||
|
||||
@@ -23,9 +23,10 @@ import {
|
||||
BranchSwitchDropdown,
|
||||
} from './components';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { ViewWorktreeChangesDialog } from '../dialogs';
|
||||
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
@@ -36,7 +37,8 @@ export function WorktreePanel({
|
||||
onCreateBranch,
|
||||
onAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onMerge,
|
||||
onCreateMergeConflictResolutionFeature,
|
||||
onBranchDeletedDuringMerge,
|
||||
onRemovedWorktrees,
|
||||
runningFeatureIds = [],
|
||||
features = [],
|
||||
@@ -67,6 +69,7 @@ export function WorktreePanel({
|
||||
filteredBranches,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
isLoadingBranches,
|
||||
branchFilter,
|
||||
setBranchFilter,
|
||||
@@ -170,6 +173,14 @@ export function WorktreePanel({
|
||||
const [logPanelOpen, setLogPanelOpen] = useState(false);
|
||||
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Push to remote dialog state
|
||||
const [pushToRemoteDialogOpen, setPushToRemoteDialogOpen] = useState(false);
|
||||
const [pushToRemoteWorktree, setPushToRemoteWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Merge branch dialog state
|
||||
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
|
||||
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Periodic interval check (5 seconds) to detect branch changes on disk
|
||||
@@ -280,6 +291,54 @@ export function WorktreePanel({
|
||||
// Keep logPanelWorktree set for smooth close animation
|
||||
}, []);
|
||||
|
||||
// Handle opening the push to remote dialog
|
||||
const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
|
||||
setPushToRemoteWorktree(worktree);
|
||||
setPushToRemoteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle confirming the push to remote dialog
|
||||
const handleConfirmPushToRemote = useCallback(
|
||||
async (worktree: WorktreeInfo, remote: string) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.push) {
|
||||
toast.error('Push API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.push(worktree.path, false, remote);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
fetchBranches(worktree.path);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to push changes');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to push changes');
|
||||
}
|
||||
},
|
||||
[fetchBranches, fetchWorktrees]
|
||||
);
|
||||
|
||||
// Handle opening the merge dialog
|
||||
const handleMerge = useCallback((worktree: WorktreeInfo) => {
|
||||
setMergeWorktree(worktree);
|
||||
setMergeDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle merge completion - refresh worktrees and reassign features if branch was deleted
|
||||
const handleMerged = useCallback(
|
||||
(mergedWorktree: WorktreeInfo, deletedBranch: boolean) => {
|
||||
fetchWorktrees();
|
||||
// If the branch was deleted, notify parent to reassign features to main
|
||||
if (deletedBranch && onBranchDeletedDuringMerge) {
|
||||
onBranchDeletedDuringMerge(mergedWorktree.branch);
|
||||
}
|
||||
},
|
||||
[fetchWorktrees, onBranchDeletedDuringMerge]
|
||||
);
|
||||
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
||||
|
||||
@@ -325,6 +384,7 @@ export function WorktreePanel({
|
||||
standalone={true}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
@@ -335,6 +395,7 @@ export function WorktreePanel({
|
||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
@@ -344,7 +405,7 @@ export function WorktreePanel({
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={onMerge}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
@@ -415,6 +476,24 @@ export function WorktreePanel({
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
|
||||
{/* Push to Remote Dialog */}
|
||||
<PushToRemoteDialog
|
||||
open={pushToRemoteDialogOpen}
|
||||
onOpenChange={setPushToRemoteDialogOpen}
|
||||
worktree={pushToRemoteWorktree}
|
||||
onConfirm={handleConfirmPushToRemote}
|
||||
/>
|
||||
|
||||
{/* Merge Branch Dialog */}
|
||||
<MergeWorktreeDialog
|
||||
open={mergeDialogOpen}
|
||||
onOpenChange={setMergeDialogOpen}
|
||||
projectPath={projectPath}
|
||||
worktree={mergeWorktree}
|
||||
onMerged={handleMerged}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -448,6 +527,7 @@ export function WorktreePanel({
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
@@ -458,6 +538,7 @@ export function WorktreePanel({
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
@@ -467,7 +548,7 @@ export function WorktreePanel({
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={onMerge}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
@@ -512,6 +593,7 @@ export function WorktreePanel({
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
@@ -522,6 +604,7 @@ export function WorktreePanel({
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
@@ -531,7 +614,7 @@ export function WorktreePanel({
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={onMerge}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
@@ -602,6 +685,24 @@ export function WorktreePanel({
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
|
||||
{/* Push to Remote Dialog */}
|
||||
<PushToRemoteDialog
|
||||
open={pushToRemoteDialogOpen}
|
||||
onOpenChange={setPushToRemoteDialogOpen}
|
||||
worktree={pushToRemoteWorktree}
|
||||
onConfirm={handleConfirmPushToRemote}
|
||||
/>
|
||||
|
||||
{/* Merge Branch Dialog */}
|
||||
<MergeWorktreeDialog
|
||||
open={mergeDialogOpen}
|
||||
onOpenChange={setMergeDialogOpen}
|
||||
projectPath={projectPath}
|
||||
worktree={mergeWorktree}
|
||||
onMerged={handleMerged}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user