mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
refactor: Improve all git operations, add stash support, add improved pull request flow, add worktree file copy options, address code review comments, add cherry pick options
This commit is contained in:
436
apps/ui/src/components/dialogs/project-file-selector-dialog.tsx
Normal file
436
apps/ui/src/components/dialogs/project-file-selector-dialog.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
FolderOpen,
|
||||
Folder,
|
||||
FileCode,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd';
|
||||
import { useOSDetection } from '@/hooks';
|
||||
import { apiPost } from '@/lib/api-fetch';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProjectFileEntry {
|
||||
name: string;
|
||||
relativePath: string;
|
||||
isDirectory: boolean;
|
||||
isFile: boolean;
|
||||
}
|
||||
|
||||
interface BrowseResult {
|
||||
success: boolean;
|
||||
currentRelativePath: string;
|
||||
parentRelativePath: string | null;
|
||||
entries: ProjectFileEntry[];
|
||||
warning?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ProjectFileSelectorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (paths: string[]) => void;
|
||||
projectPath: string;
|
||||
existingFiles?: string[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function ProjectFileSelectorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
projectPath,
|
||||
existingFiles = [],
|
||||
title = 'Select Files to Copy',
|
||||
description = 'Browse your project and select files or directories to copy into new worktrees.',
|
||||
}: ProjectFileSelectorDialogProps) {
|
||||
const { isMac } = useOSDetection();
|
||||
const [currentRelativePath, setCurrentRelativePath] = useState('');
|
||||
const [parentRelativePath, setParentRelativePath] = useState<string | null>(null);
|
||||
const [entries, setEntries] = useState<ProjectFileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [warning, setWarning] = useState('');
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Track the path segments for breadcrumb navigation
|
||||
const breadcrumbs = useMemo(() => {
|
||||
if (!currentRelativePath) return [];
|
||||
const parts = currentRelativePath.split('/').filter(Boolean);
|
||||
return parts.map((part, index) => ({
|
||||
name: part,
|
||||
path: parts.slice(0, index + 1).join('/'),
|
||||
}));
|
||||
}, [currentRelativePath]);
|
||||
|
||||
const browseDirectory = useCallback(
|
||||
async (relativePath?: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setWarning('');
|
||||
setSearchQuery('');
|
||||
|
||||
try {
|
||||
const result = await apiPost<BrowseResult>('/api/fs/browse-project-files', {
|
||||
projectPath,
|
||||
relativePath: relativePath || '',
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setCurrentRelativePath(result.currentRelativePath);
|
||||
setParentRelativePath(result.parentRelativePath);
|
||||
setEntries(result.entries);
|
||||
setWarning(result.warning || '');
|
||||
} else {
|
||||
setError(result.error || 'Failed to browse directory');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load directory contents');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedPaths(new Set());
|
||||
setSearchQuery('');
|
||||
browseDirectory();
|
||||
} else {
|
||||
setCurrentRelativePath('');
|
||||
setParentRelativePath(null);
|
||||
setEntries([]);
|
||||
setError('');
|
||||
setWarning('');
|
||||
setSelectedPaths(new Set());
|
||||
setSearchQuery('');
|
||||
}
|
||||
}, [open, browseDirectory]);
|
||||
|
||||
const handleNavigateInto = useCallback(
|
||||
(entry: ProjectFileEntry) => {
|
||||
if (entry.isDirectory) {
|
||||
browseDirectory(entry.relativePath);
|
||||
}
|
||||
},
|
||||
[browseDirectory]
|
||||
);
|
||||
|
||||
const handleGoBack = useCallback(() => {
|
||||
if (parentRelativePath !== null) {
|
||||
browseDirectory(parentRelativePath || undefined);
|
||||
}
|
||||
}, [parentRelativePath, browseDirectory]);
|
||||
|
||||
const handleGoToRoot = useCallback(() => {
|
||||
browseDirectory();
|
||||
}, [browseDirectory]);
|
||||
|
||||
const handleBreadcrumbClick = useCallback(
|
||||
(path: string) => {
|
||||
browseDirectory(path);
|
||||
},
|
||||
[browseDirectory]
|
||||
);
|
||||
|
||||
const handleToggleSelect = useCallback((entry: ProjectFileEntry) => {
|
||||
setSelectedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(entry.relativePath)) {
|
||||
next.delete(entry.relativePath);
|
||||
} else {
|
||||
next.add(entry.relativePath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirmSelection = useCallback(() => {
|
||||
const paths = Array.from(selectedPaths);
|
||||
if (paths.length > 0) {
|
||||
onSelect(paths);
|
||||
onOpenChange(false);
|
||||
}
|
||||
}, [selectedPaths, onSelect, onOpenChange]);
|
||||
|
||||
// Check if a path is already configured
|
||||
const isAlreadyConfigured = useCallback(
|
||||
(relativePath: string) => {
|
||||
return existingFiles.includes(relativePath);
|
||||
},
|
||||
[existingFiles]
|
||||
);
|
||||
|
||||
// Filter entries based on search query
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (!searchQuery.trim()) return entries;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return entries.filter((entry) => entry.name.toLowerCase().includes(query));
|
||||
}, [entries, searchQuery]);
|
||||
|
||||
// Handle Command/Ctrl+Enter keyboard shortcut
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (selectedPaths.size > 0 && !loading) {
|
||||
handleConfirmSelection();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, selectedPaths, loading, handleConfirmSelection]);
|
||||
|
||||
const selectedCount = selectedPaths.size;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col p-4 focus:outline-none focus-visible:outline-none">
|
||||
<DialogHeader className="pb-1">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<FolderOpen className="w-4 h-4 text-brand-500" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground text-xs">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-2 min-h-[300px] flex-1 overflow-hidden py-1">
|
||||
{/* Navigation bar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleGoBack}
|
||||
className="h-7 w-7 shrink-0"
|
||||
disabled={loading || parentRelativePath === null}
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Breadcrumb path */}
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1 h-8 px-3 rounded-md border border-input bg-background/50 overflow-x-auto scrollbar-none">
|
||||
<button
|
||||
onClick={handleGoToRoot}
|
||||
className={cn(
|
||||
'text-xs font-mono shrink-0 transition-colors',
|
||||
currentRelativePath
|
||||
? 'text-muted-foreground hover:text-foreground'
|
||||
: 'text-foreground font-medium'
|
||||
)}
|
||||
disabled={loading}
|
||||
>
|
||||
Project Root
|
||||
</button>
|
||||
{breadcrumbs.map((crumb) => (
|
||||
<span key={crumb.path} className="flex items-center gap-1 shrink-0">
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
|
||||
<button
|
||||
onClick={() => handleBreadcrumbClick(crumb.path)}
|
||||
className={cn(
|
||||
'text-xs font-mono truncate max-w-[150px] transition-colors',
|
||||
crumb.path === currentRelativePath
|
||||
? 'text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
disabled={loading}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search filter */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Filter files and directories..."
|
||||
className="h-8 text-xs pl-8 pr-8"
|
||||
disabled={loading}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected items indicator */}
|
||||
{selectedCount > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-brand-500/10 border border-brand-500/20 text-xs">
|
||||
<Check className="w-3.5 h-3.5 text-brand-500" />
|
||||
<span className="text-brand-500 font-medium">
|
||||
{selectedCount} {selectedCount === 1 ? 'item' : 'items'} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedPaths(new Set())}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File/directory list */}
|
||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md scrollbar-styled">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-xs text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-xs text-destructive">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{warning && (
|
||||
<div className="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md mb-1">
|
||||
<div className="text-xs text-yellow-500">{warning}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && filteredEntries.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{searchQuery ? 'No matching files or directories' : 'This directory is empty'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && filteredEntries.length > 0 && (
|
||||
<div className="divide-y divide-sidebar-border">
|
||||
{filteredEntries.map((entry) => {
|
||||
const isSelected = selectedPaths.has(entry.relativePath);
|
||||
const isConfigured = isAlreadyConfigured(entry.relativePath);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.relativePath}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-2 py-1.5 transition-colors text-left group',
|
||||
isConfigured
|
||||
? 'opacity-50'
|
||||
: isSelected
|
||||
? 'bg-brand-500/10'
|
||||
: 'hover:bg-sidebar-accent/10'
|
||||
)}
|
||||
>
|
||||
{/* Checkbox for selection */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelect(entry)}
|
||||
disabled={isConfigured}
|
||||
className="shrink-0"
|
||||
aria-label={`Select ${entry.name}`}
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
{entry.isDirectory ? (
|
||||
<Folder className="w-4 h-4 text-brand-500 shrink-0" />
|
||||
) : (
|
||||
<FileCode className="w-4 h-4 text-muted-foreground/60 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* File/directory name */}
|
||||
<span
|
||||
className="flex-1 truncate text-xs font-mono cursor-pointer"
|
||||
onClick={() => {
|
||||
if (!isConfigured) {
|
||||
handleToggleSelect(entry);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
|
||||
{/* Already configured badge */}
|
||||
{isConfigured && (
|
||||
<span className="text-[10px] text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded shrink-0">
|
||||
Already added
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Navigate into directory button */}
|
||||
{entry.isDirectory && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNavigateInto(entry);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-accent/50 transition-all shrink-0"
|
||||
title={`Open ${entry.name}`}
|
||||
>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Select files or directories to copy into new worktrees. Directories are copied
|
||||
recursively. Click the arrow to browse into a directory.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t border-border pt-3 gap-2 mt-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleConfirmSelection}
|
||||
disabled={selectedCount === 0 || loading}
|
||||
title={`Add ${selectedCount} selected items (${isMac ? '⌘' : 'Ctrl'}+Enter)`}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5 mr-1.5" />
|
||||
Add {selectedCount > 0 ? `${selectedCount} ` : ''}
|
||||
{selectedCount === 1 ? 'Item' : 'Items'}
|
||||
<KbdGroup className="ml-1">
|
||||
<Kbd>{isMac ? '⌘' : 'Ctrl'}</Kbd>
|
||||
<Kbd>↵</Kbd>
|
||||
</KbdGroup>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -37,18 +37,21 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="bg-popover border-border max-w-lg"
|
||||
className="bg-popover border-border max-w-lg flex flex-col"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
<ShieldAlert className="w-6 h-6 flex-shrink-0" />
|
||||
Sandbox Environment Not Detected
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 -mx-6 px-6">
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-4 pt-2 pb-2">
|
||||
<p className="text-muted-foreground">
|
||||
<strong>Warning:</strong> This application is running outside of a containerized
|
||||
sandbox environment. AI agents will have direct access to your filesystem and can
|
||||
@@ -94,9 +97,9 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col gap-4 sm:flex-col pt-4">
|
||||
<DialogFooter className="flex-col gap-4 sm:flex-col pt-4 flex-shrink-0 border-t border-border mt-4">
|
||||
<div className="flex items-center space-x-2 self-start">
|
||||
<Checkbox
|
||||
id="skip-sandbox-warning"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, memo, useCallback, useMemo } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor, LogOut } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type ThemeMode, useAppStore } from '@/store/app-store';
|
||||
@@ -196,11 +196,13 @@ export function ProjectContextMenu({
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
moveProjectToTrash,
|
||||
removeProject,
|
||||
theme: globalTheme,
|
||||
setProjectTheme,
|
||||
setPreviewTheme,
|
||||
} = useAppStore();
|
||||
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
|
||||
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
|
||||
const themeSubmenuRef = useRef<HTMLDivElement>(null);
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -282,7 +284,7 @@ export function ProjectContextMenu({
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: globalThis.MouseEvent) => {
|
||||
// Don't close if a confirmation dialog is open (dialog is in a portal)
|
||||
if (showRemoveDialog) return;
|
||||
if (showRemoveDialog || showRemoveFromAutomakerDialog) return;
|
||||
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as globalThis.Node)) {
|
||||
setPreviewTheme(null);
|
||||
@@ -292,7 +294,7 @@ export function ProjectContextMenu({
|
||||
|
||||
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
||||
// Don't close if a confirmation dialog is open (let the dialog handle escape)
|
||||
if (showRemoveDialog) return;
|
||||
if (showRemoveDialog || showRemoveFromAutomakerDialog) return;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
setPreviewTheme(null);
|
||||
@@ -307,7 +309,7 @@ export function ProjectContextMenu({
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [onClose, setPreviewTheme, showRemoveDialog]);
|
||||
}, [onClose, setPreviewTheme, showRemoveDialog, showRemoveFromAutomakerDialog]);
|
||||
|
||||
const handleEdit = () => {
|
||||
onEdit(project);
|
||||
@@ -359,10 +361,31 @@ export function ProjectContextMenu({
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const handleRemoveFromAutomaker = () => {
|
||||
setShowRemoveFromAutomakerDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmRemoveFromAutomaker = useCallback(() => {
|
||||
removeProject(project.id);
|
||||
toast.success('Project removed from Automaker', {
|
||||
description: `${project.name} has been removed. The folder remains on disk.`,
|
||||
});
|
||||
}, [removeProject, project.id, project.name]);
|
||||
|
||||
const handleRemoveFromAutomakerDialogClose = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
setShowRemoveFromAutomakerDialog(isOpen);
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hide context menu when confirm dialog is open */}
|
||||
{!showRemoveDialog && (
|
||||
{!showRemoveDialog && !showRemoveFromAutomakerDialog && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
@@ -509,7 +532,22 @@ export function ProjectContextMenu({
|
||||
data-testid="remove-project-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>Remove Project</span>
|
||||
<span>Move to Trash</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRemoveFromAutomaker}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||
'text-sm font-medium text-left',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
'transition-colors',
|
||||
'focus:outline-none focus:bg-accent'
|
||||
)}
|
||||
data-testid="remove-from-automaker-button"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Remove from Automaker</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -519,13 +557,25 @@ export function ProjectContextMenu({
|
||||
open={showRemoveDialog}
|
||||
onOpenChange={handleDialogClose}
|
||||
onConfirm={handleConfirmRemove}
|
||||
title="Remove Project"
|
||||
description={`Are you sure you want to remove "${project.name}" from the project list? This won't delete any files on disk.`}
|
||||
title="Move to Trash"
|
||||
description={`Are you sure you want to move "${project.name}" to Trash? You can restore it later from the Recycle Bin.`}
|
||||
icon={Trash2}
|
||||
iconClassName="text-destructive"
|
||||
confirmText="Remove"
|
||||
confirmText="Move to Trash"
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showRemoveFromAutomakerDialog}
|
||||
onOpenChange={handleRemoveFromAutomakerDialogClose}
|
||||
onConfirm={handleConfirmRemoveFromAutomaker}
|
||||
title="Remove from Automaker"
|
||||
description={`Remove "${project.name}" from Automaker? The folder will remain on disk and can be re-added later by opening it.`}
|
||||
icon={LogOut}
|
||||
iconClassName="text-muted-foreground"
|
||||
confirmText="Remove from Automaker"
|
||||
confirmVariant="secondary"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Search,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
|
||||
@@ -47,6 +48,8 @@ interface ProjectSelectorWithOptionsProps {
|
||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
/** Callback to show the delete project confirmation dialog */
|
||||
setShowDeleteProjectDialog: (show: boolean) => void;
|
||||
/** Callback to show the remove from automaker confirmation dialog */
|
||||
setShowRemoveFromAutomakerDialog: (show: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +73,7 @@ export function ProjectSelectorWithOptions({
|
||||
isProjectPickerOpen,
|
||||
setIsProjectPickerOpen,
|
||||
setShowDeleteProjectDialog,
|
||||
setShowRemoveFromAutomakerDialog,
|
||||
}: ProjectSelectorWithOptionsProps) {
|
||||
const {
|
||||
projects,
|
||||
@@ -371,8 +375,16 @@ export function ProjectSelectorWithOptions({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Move to Trash Section */}
|
||||
{/* Remove / Trash Section */}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowRemoveFromAutomakerDialog(true)}
|
||||
className="text-muted-foreground focus:text-foreground"
|
||||
data-testid="remove-from-automaker"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
<span>Remove from Automaker</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowDeleteProjectDialog(true)}
|
||||
className="text-destructive focus:text-destructive focus:bg-destructive/10"
|
||||
|
||||
@@ -39,6 +39,7 @@ import { EditProjectDialog } from '../project-switcher/components/edit-project-d
|
||||
|
||||
// Import shared dialogs
|
||||
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
|
||||
import { RemoveFromAutomakerDialog } from '@/components/views/settings-view/components/remove-from-automaker-dialog';
|
||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
||||
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
||||
|
||||
@@ -65,6 +66,7 @@ export function Sidebar() {
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
moveProjectToTrash,
|
||||
removeProject,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
setCurrentProject,
|
||||
@@ -91,6 +93,8 @@ export function Sidebar() {
|
||||
|
||||
// State for delete project confirmation dialog
|
||||
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
|
||||
// State for remove from automaker confirmation dialog
|
||||
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
|
||||
|
||||
// State for trash dialog
|
||||
const [showTrashDialog, setShowTrashDialog] = useState(false);
|
||||
@@ -488,6 +492,14 @@ export function Sidebar() {
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
|
||||
{/* Remove from Automaker Confirmation Dialog */}
|
||||
<RemoveFromAutomakerDialog
|
||||
open={showRemoveFromAutomakerDialog}
|
||||
onOpenChange={setShowRemoveFromAutomakerDialog}
|
||||
project={currentProject}
|
||||
onConfirm={removeProject}
|
||||
/>
|
||||
|
||||
{/* New Project Modal */}
|
||||
<NewProjectModal
|
||||
open={showNewProjectModal}
|
||||
|
||||
@@ -106,8 +106,11 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
/** Content to display below the item text in the dropdown only (not shown in trigger). */
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
>(({ className, children, description, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@@ -122,7 +125,14 @@ const SelectItem = React.forwardRef<
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description ? (
|
||||
<div className="flex flex-col items-start">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description}
|
||||
</div>
|
||||
) : (
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
)}
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
@@ -34,13 +34,18 @@ function formatResetTime(unixTimestamp: number, isMilliseconds = false): string
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
// Guard against past timestamps: clamp negative diffs to a friendly fallback
|
||||
if (diff <= 0) {
|
||||
return 'Resets now';
|
||||
}
|
||||
|
||||
if (diff < 3600000) {
|
||||
const mins = Math.ceil(diff / 60000);
|
||||
const mins = Math.max(0, Math.ceil(diff / 60000));
|
||||
return `Resets in ${mins}m`;
|
||||
}
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const mins = Math.ceil((diff % 3600000) / 60000);
|
||||
const hours = Math.max(0, Math.floor(diff / 3600000));
|
||||
const mins = Math.max(0, Math.ceil((diff % 3600000) / 60000));
|
||||
return `Resets in ${hours}h ${mins > 0 ? `${mins}m` : ''}`;
|
||||
}
|
||||
return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
|
||||
@@ -49,12 +49,13 @@ import {
|
||||
ArchiveAllVerifiedDialog,
|
||||
DeleteCompletedFeatureDialog,
|
||||
DependencyLinkDialog,
|
||||
DuplicateCountDialog,
|
||||
EditFeatureDialog,
|
||||
FollowUpDialog,
|
||||
PlanApprovalDialog,
|
||||
PullResolveConflictsDialog,
|
||||
MergeRebaseDialog,
|
||||
} from './board-view/dialogs';
|
||||
import type { DependencyLinkType } from './board-view/dialogs';
|
||||
import type { DependencyLinkType, PullStrategy } from './board-view/dialogs';
|
||||
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
|
||||
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
|
||||
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
|
||||
@@ -170,13 +171,16 @@ export function BoardView() {
|
||||
// State for spawn task mode
|
||||
const [spawnParentFeature, setSpawnParentFeature] = useState<Feature | null>(null);
|
||||
|
||||
// State for duplicate as child multiple times dialog
|
||||
const [duplicateMultipleFeature, setDuplicateMultipleFeature] = useState<Feature | null>(null);
|
||||
|
||||
// Worktree dialog states
|
||||
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false);
|
||||
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
|
||||
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
|
||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||
const [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false);
|
||||
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
|
||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
|
||||
null
|
||||
);
|
||||
@@ -596,6 +600,7 @@ export function BoardView() {
|
||||
handleStartNextFeatures,
|
||||
handleArchiveAllVerified,
|
||||
handleDuplicateFeature,
|
||||
handleDuplicateAsChildMultiple,
|
||||
} = useBoardActions({
|
||||
currentProject,
|
||||
features: hookFeatures,
|
||||
@@ -917,17 +922,25 @@ export function BoardView() {
|
||||
// Handler for resolving conflicts - opens dialog to select remote branch, then creates a feature
|
||||
const handleResolveConflicts = useCallback((worktree: WorktreeInfo) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowPullResolveConflictsDialog(true);
|
||||
setShowMergeRebaseDialog(true);
|
||||
}, []);
|
||||
|
||||
// Handler called when user confirms the pull & resolve conflicts dialog
|
||||
// Handler called when user confirms the merge & rebase dialog
|
||||
const handleConfirmResolveConflicts = useCallback(
|
||||
async (worktree: WorktreeInfo, remoteBranch: string) => {
|
||||
const description = `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
|
||||
async (worktree: WorktreeInfo, remoteBranch: string, strategy: PullStrategy) => {
|
||||
const isRebase = strategy === 'rebase';
|
||||
|
||||
const description = isRebase
|
||||
? `Fetch the latest changes from ${remoteBranch} and rebase the current branch (${worktree.branch}) onto ${remoteBranch}. Use "git fetch" followed by "git rebase ${remoteBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.`
|
||||
: `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
|
||||
|
||||
const title = isRebase
|
||||
? `Rebase & Resolve Conflicts: ${worktree.branch} onto ${remoteBranch}`
|
||||
: `Resolve Merge Conflicts: ${remoteBranch} → ${worktree.branch}`;
|
||||
|
||||
// Create the feature
|
||||
const featureData = {
|
||||
title: `Resolve Merge Conflicts: ${remoteBranch} → ${worktree.branch}`,
|
||||
title,
|
||||
category: 'Maintenance',
|
||||
description,
|
||||
images: [],
|
||||
@@ -1562,6 +1575,7 @@ export function BoardView() {
|
||||
},
|
||||
onDuplicate: (feature) => handleDuplicateFeature(feature, false),
|
||||
onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true),
|
||||
onDuplicateAsChildMultiple: (feature) => setDuplicateMultipleFeature(feature),
|
||||
}}
|
||||
runningAutoTasks={runningAutoTasksAllWorktrees}
|
||||
pipelineConfig={pipelineConfig}
|
||||
@@ -1603,6 +1617,7 @@ export function BoardView() {
|
||||
}}
|
||||
onDuplicate={(feature) => handleDuplicateFeature(feature, false)}
|
||||
onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)}
|
||||
onDuplicateAsChildMultiple={(feature) => setDuplicateMultipleFeature(feature)}
|
||||
featuresWithContext={featuresWithContext}
|
||||
runningAutoTasks={runningAutoTasksAllWorktrees}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
@@ -1752,6 +1767,21 @@ export function BoardView() {
|
||||
branchName={outputFeature?.branchName}
|
||||
/>
|
||||
|
||||
{/* Duplicate as Child Multiple Times Dialog */}
|
||||
<DuplicateCountDialog
|
||||
open={duplicateMultipleFeature !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDuplicateMultipleFeature(null);
|
||||
}}
|
||||
onConfirm={async (count) => {
|
||||
if (duplicateMultipleFeature) {
|
||||
await handleDuplicateAsChildMultiple(duplicateMultipleFeature, count);
|
||||
setDuplicateMultipleFeature(null);
|
||||
}
|
||||
}}
|
||||
featureTitle={duplicateMultipleFeature?.title || duplicateMultipleFeature?.description}
|
||||
/>
|
||||
|
||||
{/* Archive All Verified Dialog */}
|
||||
<ArchiveAllVerifiedDialog
|
||||
open={showArchiveAllVerifiedDialog}
|
||||
@@ -1899,10 +1929,10 @@ export function BoardView() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pull & Resolve Conflicts Dialog */}
|
||||
<PullResolveConflictsDialog
|
||||
open={showPullResolveConflictsDialog}
|
||||
onOpenChange={setShowPullResolveConflictsDialog}
|
||||
{/* Merge & Rebase Dialog */}
|
||||
<MergeRebaseDialog
|
||||
open={showMergeRebaseDialog}
|
||||
onOpenChange={setShowMergeRebaseDialog}
|
||||
worktree={selectedWorktreeForAction}
|
||||
onConfirm={handleConfirmResolveConflicts}
|
||||
/>
|
||||
|
||||
@@ -48,7 +48,7 @@ interface AgentInfoPanelProps {
|
||||
projectPath: string;
|
||||
contextContent?: string;
|
||||
summary?: string;
|
||||
isCurrentAutoTask?: boolean;
|
||||
isActivelyRunning?: boolean;
|
||||
}
|
||||
|
||||
export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
@@ -56,7 +56,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
projectPath,
|
||||
contextContent,
|
||||
summary,
|
||||
isCurrentAutoTask,
|
||||
isActivelyRunning,
|
||||
}: AgentInfoPanelProps) {
|
||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
||||
@@ -107,7 +107,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
// - If not receiving WebSocket events but in_progress: use normal interval (3s)
|
||||
// - Otherwise: no polling
|
||||
const pollingInterval = useMemo((): number | false => {
|
||||
if (!(isCurrentAutoTask || feature.status === 'in_progress')) {
|
||||
if (!(isActivelyRunning || feature.status === 'in_progress')) {
|
||||
return false;
|
||||
}
|
||||
// If receiving WebSocket events, use longer polling interval as fallback
|
||||
@@ -116,7 +116,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
}
|
||||
// Default polling interval
|
||||
return 3000;
|
||||
}, [isCurrentAutoTask, feature.status, isReceivingWsEvents]);
|
||||
}, [isActivelyRunning, feature.status, isReceivingWsEvents]);
|
||||
|
||||
// Fetch fresh feature data for planSpec (store data can be stale for task progress)
|
||||
const { data: freshFeature } = useFeature(projectPath, feature.id, {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ChevronUp,
|
||||
GitFork,
|
||||
Copy,
|
||||
Repeat,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { CountUpTimer } from '@/components/ui/count-up-timer';
|
||||
@@ -33,9 +34,11 @@ import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||
function DuplicateMenuItems({
|
||||
onDuplicate,
|
||||
onDuplicateAsChild,
|
||||
onDuplicateAsChildMultiple,
|
||||
}: {
|
||||
onDuplicate?: () => void;
|
||||
onDuplicateAsChild?: () => void;
|
||||
onDuplicateAsChildMultiple?: () => void;
|
||||
}) {
|
||||
if (!onDuplicate) return null;
|
||||
|
||||
@@ -55,25 +58,23 @@ function DuplicateMenuItems({
|
||||
);
|
||||
}
|
||||
|
||||
// When sub-child action is available, render a proper DropdownMenuSub with
|
||||
// DropdownMenuSubTrigger and DropdownMenuSubContent per Radix conventions
|
||||
// Split-button pattern: main click duplicates immediately, disclosure arrow shows submenu
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="text-xs">
|
||||
<Copy className="w-3 h-3 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
className="text-xs"
|
||||
className="flex-1 pr-0 rounded-r-none text-xs"
|
||||
>
|
||||
<Copy className="w-3 h-3 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8 text-xs" />
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -84,6 +85,18 @@ function DuplicateMenuItems({
|
||||
<GitFork className="w-3 h-3 mr-2" />
|
||||
Duplicate as Child
|
||||
</DropdownMenuItem>
|
||||
{onDuplicateAsChildMultiple && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicateAsChildMultiple();
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Repeat className="w-3 h-3 mr-2" />
|
||||
Duplicate as Child ×N
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
@@ -100,6 +113,7 @@ interface CardHeaderProps {
|
||||
onSpawnTask?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onDuplicateAsChild?: () => void;
|
||||
onDuplicateAsChildMultiple?: () => void;
|
||||
dragHandleListeners?: DraggableSyntheticListeners;
|
||||
dragHandleAttributes?: DraggableAttributes;
|
||||
}
|
||||
@@ -115,6 +129,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
onSpawnTask,
|
||||
onDuplicate,
|
||||
onDuplicateAsChild,
|
||||
onDuplicateAsChildMultiple,
|
||||
dragHandleListeners,
|
||||
dragHandleAttributes,
|
||||
}: CardHeaderProps) {
|
||||
@@ -183,6 +198,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
<DuplicateMenuItems
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
|
||||
/>
|
||||
{/* Model info in dropdown */}
|
||||
{(() => {
|
||||
@@ -251,6 +267,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
<DuplicateMenuItems
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -343,6 +360,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
<DuplicateMenuItems
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -417,6 +435,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
<DuplicateMenuItems
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
|
||||
/>
|
||||
{/* Model info in dropdown */}
|
||||
{(() => {
|
||||
|
||||
@@ -54,6 +54,7 @@ interface KanbanCardProps {
|
||||
onSpawnTask?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onDuplicateAsChild?: () => void;
|
||||
onDuplicateAsChildMultiple?: () => void;
|
||||
hasContext?: boolean;
|
||||
isCurrentAutoTask?: boolean;
|
||||
shortcutKey?: string;
|
||||
@@ -90,6 +91,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onSpawnTask,
|
||||
onDuplicate,
|
||||
onDuplicateAsChild,
|
||||
onDuplicateAsChildMultiple,
|
||||
hasContext,
|
||||
isCurrentAutoTask,
|
||||
shortcutKey,
|
||||
@@ -266,6 +268,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onSpawnTask={onSpawnTask}
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
|
||||
dragHandleListeners={isDraggable ? listeners : undefined}
|
||||
dragHandleAttributes={isDraggable ? attributes : undefined}
|
||||
/>
|
||||
@@ -280,7 +283,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
projectPath={currentProject?.path ?? ''}
|
||||
contextContent={contextContent}
|
||||
summary={summary}
|
||||
isCurrentAutoTask={isCurrentAutoTask}
|
||||
isActivelyRunning={isActivelyRunning}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface ListViewActionHandlers {
|
||||
onSpawnTask?: (feature: Feature) => void;
|
||||
onDuplicate?: (feature: Feature) => void;
|
||||
onDuplicateAsChild?: (feature: Feature) => void;
|
||||
onDuplicateAsChildMultiple?: (feature: Feature) => void;
|
||||
}
|
||||
|
||||
export interface ListViewProps {
|
||||
@@ -332,6 +333,12 @@ export const ListView = memo(function ListView({
|
||||
if (f) actionHandlers.onDuplicateAsChild?.(f);
|
||||
}
|
||||
: undefined,
|
||||
duplicateAsChildMultiple: actionHandlers.onDuplicateAsChildMultiple
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onDuplicateAsChildMultiple?.(f);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
[actionHandlers, allFeatures]
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
GitFork,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Repeat,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -49,6 +50,7 @@ export interface RowActionHandlers {
|
||||
onSpawnTask?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onDuplicateAsChild?: () => void;
|
||||
onDuplicateAsChildMultiple?: () => void;
|
||||
}
|
||||
|
||||
export interface RowActionsProps {
|
||||
@@ -443,6 +445,13 @@ export const RowActions = memo(function RowActions({
|
||||
label="Duplicate as Child"
|
||||
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||
/>
|
||||
{handlers.onDuplicateAsChildMultiple && (
|
||||
<MenuItem
|
||||
icon={Repeat}
|
||||
label="Duplicate as Child ×N"
|
||||
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
)}
|
||||
</DropdownMenuSub>
|
||||
@@ -565,6 +574,13 @@ export const RowActions = memo(function RowActions({
|
||||
label="Duplicate as Child"
|
||||
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||
/>
|
||||
{handlers.onDuplicateAsChildMultiple && (
|
||||
<MenuItem
|
||||
icon={Repeat}
|
||||
label="Duplicate as Child ×N"
|
||||
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
)}
|
||||
</DropdownMenuSub>
|
||||
@@ -636,6 +652,13 @@ export const RowActions = memo(function RowActions({
|
||||
label="Duplicate as Child"
|
||||
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||
/>
|
||||
{handlers.onDuplicateAsChildMultiple && (
|
||||
<MenuItem
|
||||
icon={Repeat}
|
||||
label="Duplicate as Child ×N"
|
||||
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
)}
|
||||
</DropdownMenuSub>
|
||||
@@ -712,6 +735,13 @@ export const RowActions = memo(function RowActions({
|
||||
label="Duplicate as Child"
|
||||
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||
/>
|
||||
{handlers.onDuplicateAsChildMultiple && (
|
||||
<MenuItem
|
||||
icon={Repeat}
|
||||
label="Duplicate as Child ×N"
|
||||
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
)}
|
||||
</DropdownMenuSub>
|
||||
@@ -764,6 +794,13 @@ export const RowActions = memo(function RowActions({
|
||||
label="Duplicate as Child"
|
||||
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||
/>
|
||||
{handlers.onDuplicateAsChildMultiple && (
|
||||
<MenuItem
|
||||
icon={Repeat}
|
||||
label="Duplicate as Child ×N"
|
||||
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
)}
|
||||
</DropdownMenuSub>
|
||||
@@ -804,6 +841,7 @@ export function createRowActionHandlers(
|
||||
spawnTask?: (id: string) => void;
|
||||
duplicate?: (id: string) => void;
|
||||
duplicateAsChild?: (id: string) => void;
|
||||
duplicateAsChildMultiple?: (id: string) => void;
|
||||
}
|
||||
): RowActionHandlers {
|
||||
return {
|
||||
@@ -824,5 +862,8 @@ export function createRowActionHandlers(
|
||||
onDuplicateAsChild: actions.duplicateAsChild
|
||||
? () => actions.duplicateAsChild!(featureId)
|
||||
: undefined,
|
||||
onDuplicateAsChildMultiple: actions.duplicateAsChildMultiple
|
||||
? () => actions.duplicateAsChildMultiple!(featureId)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
GitCommit,
|
||||
AlertTriangle,
|
||||
Wrench,
|
||||
User,
|
||||
Clock,
|
||||
Copy,
|
||||
Check,
|
||||
Cherry,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, MergeConflictInfo } from '../worktree-panel/types';
|
||||
|
||||
export interface CherryPickConflictInfo {
|
||||
commitHashes: string[];
|
||||
targetBranch: string;
|
||||
targetWorktreePath: string;
|
||||
}
|
||||
|
||||
interface RemoteInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
branches: Array<{
|
||||
name: string;
|
||||
fullRef: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CommitInfo {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
author: string;
|
||||
authorEmail: string;
|
||||
date: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
interface CherryPickDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onCherryPicked: () => void;
|
||||
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
}
|
||||
|
||||
function formatRelativeDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
|
||||
if (diffSecs < 60) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
if (diffWeeks < 5) return `${diffWeeks}w ago`;
|
||||
if (diffMonths < 12) return `${diffMonths}mo ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function CopyHashButton({ hash }: { hash: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(hash);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Failed to copy hash');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-1 font-mono text-[11px] bg-muted hover:bg-muted/80 px-1.5 py-0.5 rounded cursor-pointer transition-colors"
|
||||
title={`Copy full hash: ${hash}`}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-2.5 h-2.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-2.5 h-2.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-muted-foreground">{hash.slice(0, 7)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type Step = 'select-branch' | 'select-commits' | 'conflict';
|
||||
|
||||
export function CherryPickDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onCherryPicked,
|
||||
onCreateConflictResolutionFeature,
|
||||
}: CherryPickDialogProps) {
|
||||
// Step management
|
||||
const [step, setStep] = useState<Step>('select-branch');
|
||||
|
||||
// Branch selection state
|
||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||
const [localBranches, setLocalBranches] = useState<string[]>([]);
|
||||
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||
const [selectedBranch, setSelectedBranch] = useState<string>('');
|
||||
const [loadingBranches, setLoadingBranches] = useState(false);
|
||||
|
||||
// Commits state
|
||||
const [commits, setCommits] = useState<CommitInfo[]>([]);
|
||||
const [selectedCommitHashes, setSelectedCommitHashes] = useState<Set<string>>(new Set());
|
||||
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(new Set());
|
||||
const [loadingCommits, setLoadingCommits] = useState(false);
|
||||
const [loadingMoreCommits, setLoadingMoreCommits] = useState(false);
|
||||
const [commitsError, setCommitsError] = useState<string | null>(null);
|
||||
const [commitLimit, setCommitLimit] = useState(30);
|
||||
const [hasMoreCommits, setHasMoreCommits] = useState(false);
|
||||
|
||||
// Cherry-pick state
|
||||
const [isCherryPicking, setIsCherryPicking] = useState(false);
|
||||
|
||||
// Conflict state
|
||||
const [conflictInfo, setConflictInfo] = useState<CherryPickConflictInfo | null>(null);
|
||||
|
||||
// All available branch options for the current remote selection
|
||||
const branchOptions =
|
||||
selectedRemote === '__local__'
|
||||
? localBranches.filter((b) => b !== worktree?.branch)
|
||||
: (remotes.find((r) => r.name === selectedRemote)?.branches || []).map((b) => b.fullRef);
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('select-branch');
|
||||
setSelectedRemote('');
|
||||
setSelectedBranch('');
|
||||
setCommits([]);
|
||||
setSelectedCommitHashes(new Set());
|
||||
setExpandedCommits(new Set());
|
||||
setConflictInfo(null);
|
||||
setCommitsError(null);
|
||||
setCommitLimit(30);
|
||||
setHasMoreCommits(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Fetch remotes and local branches when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open || !worktree) return;
|
||||
|
||||
const fetchBranchData = async () => {
|
||||
setLoadingBranches(true);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
|
||||
// Fetch remotes and local branches in parallel
|
||||
const [remotesResult, branchesResult] = await Promise.all([
|
||||
api.worktree.listRemotes(worktree.path),
|
||||
api.worktree.listBranches(worktree.path, false),
|
||||
]);
|
||||
|
||||
if (remotesResult.success && remotesResult.result) {
|
||||
setRemotes(remotesResult.result.remotes);
|
||||
// Default to first remote if available, otherwise local
|
||||
if (remotesResult.result.remotes.length > 0) {
|
||||
setSelectedRemote(remotesResult.result.remotes[0].name);
|
||||
} else {
|
||||
setSelectedRemote('__local__');
|
||||
}
|
||||
}
|
||||
|
||||
if (branchesResult.success && branchesResult.result) {
|
||||
const branches = branchesResult.result.branches
|
||||
.filter(
|
||||
(b: { isRemote: boolean; name: string }) => !b.isRemote && b.name !== worktree.branch
|
||||
)
|
||||
.map((b: { name: string }) => b.name);
|
||||
setLocalBranches(branches);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch branch data:', err);
|
||||
} finally {
|
||||
setLoadingBranches(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBranchData();
|
||||
}, [open, worktree]);
|
||||
|
||||
// Fetch commits when branch is selected
|
||||
const fetchCommits = useCallback(
|
||||
async (limit: number = 30, append: boolean = false) => {
|
||||
if (!worktree || !selectedBranch) return;
|
||||
|
||||
if (append) {
|
||||
setLoadingMoreCommits(true);
|
||||
} else {
|
||||
setLoadingCommits(true);
|
||||
setCommitsError(null);
|
||||
setCommits([]);
|
||||
setSelectedCommitHashes(new Set());
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.getBranchCommitLog(worktree.path, selectedBranch, limit);
|
||||
|
||||
if (result.success && result.result) {
|
||||
setCommits(result.result.commits);
|
||||
// If we got exactly the limit, there may be more commits
|
||||
setHasMoreCommits(result.result.commits.length >= limit);
|
||||
} else {
|
||||
setCommitsError(result.error || 'Failed to load commits');
|
||||
}
|
||||
} catch (err) {
|
||||
setCommitsError(err instanceof Error ? err.message : 'Failed to load commits');
|
||||
} finally {
|
||||
setLoadingCommits(false);
|
||||
setLoadingMoreCommits(false);
|
||||
}
|
||||
},
|
||||
[worktree, selectedBranch]
|
||||
);
|
||||
|
||||
// Handle proceeding from branch selection to commit selection
|
||||
const handleProceedToCommits = useCallback(() => {
|
||||
if (!selectedBranch) return;
|
||||
setStep('select-commits');
|
||||
fetchCommits(commitLimit);
|
||||
}, [selectedBranch, fetchCommits, commitLimit]);
|
||||
|
||||
// Handle loading more commits
|
||||
const handleLoadMore = useCallback(() => {
|
||||
const newLimit = Math.min(commitLimit + 30, 100);
|
||||
setCommitLimit(newLimit);
|
||||
fetchCommits(newLimit, true);
|
||||
}, [commitLimit, fetchCommits]);
|
||||
|
||||
// Toggle commit selection
|
||||
const toggleCommitSelection = useCallback((hash: string) => {
|
||||
setSelectedCommitHashes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(hash)) {
|
||||
next.delete(hash);
|
||||
} else {
|
||||
next.add(hash);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Toggle commit file list expansion
|
||||
const toggleCommitExpanded = useCallback((hash: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setExpandedCommits((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(hash)) {
|
||||
next.delete(hash);
|
||||
} else {
|
||||
next.add(hash);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle cherry-pick execution
|
||||
const handleCherryPick = useCallback(async () => {
|
||||
if (!worktree || selectedCommitHashes.size === 0) return;
|
||||
|
||||
setIsCherryPicking(true);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
// Order commits from oldest to newest (reverse of display order)
|
||||
// so they're applied in chronological order
|
||||
const orderedHashes = commits
|
||||
.filter((c) => selectedCommitHashes.has(c.hash))
|
||||
.reverse()
|
||||
.map((c) => c.hash);
|
||||
|
||||
const result = await api.worktree.cherryPick(worktree.path, orderedHashes);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`Cherry-picked ${orderedHashes.length} commit(s)`, {
|
||||
description: `Successfully applied to ${worktree.branch}`,
|
||||
});
|
||||
onCherryPicked();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
// Check for conflicts
|
||||
const errorMessage = result.error || '';
|
||||
const hasConflicts =
|
||||
errorMessage.toLowerCase().includes('conflict') ||
|
||||
(result as { hasConflicts?: boolean }).hasConflicts;
|
||||
|
||||
if (hasConflicts && onCreateConflictResolutionFeature) {
|
||||
setConflictInfo({
|
||||
commitHashes: orderedHashes,
|
||||
targetBranch: worktree.branch,
|
||||
targetWorktreePath: worktree.path,
|
||||
});
|
||||
setStep('conflict');
|
||||
toast.error('Cherry-pick conflicts detected', {
|
||||
description: 'The cherry-pick has conflicts that need to be resolved.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Cherry-pick failed', {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
const hasConflicts =
|
||||
errorMessage.toLowerCase().includes('conflict') ||
|
||||
errorMessage.toLowerCase().includes('cherry-pick failed');
|
||||
|
||||
if (hasConflicts && onCreateConflictResolutionFeature) {
|
||||
const orderedHashes = commits
|
||||
.filter((c) => selectedCommitHashes.has(c.hash))
|
||||
.reverse()
|
||||
.map((c) => c.hash);
|
||||
setConflictInfo({
|
||||
commitHashes: orderedHashes,
|
||||
targetBranch: worktree.branch,
|
||||
targetWorktreePath: worktree.path,
|
||||
});
|
||||
setStep('conflict');
|
||||
toast.error('Cherry-pick conflicts detected', {
|
||||
description: 'The cherry-pick has conflicts that need to be resolved.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Cherry-pick failed', {
|
||||
description: errorMessage,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsCherryPicking(false);
|
||||
}
|
||||
}, [
|
||||
worktree,
|
||||
selectedCommitHashes,
|
||||
commits,
|
||||
onCherryPicked,
|
||||
onOpenChange,
|
||||
onCreateConflictResolutionFeature,
|
||||
]);
|
||||
|
||||
// Handle creating a conflict resolution feature
|
||||
const handleCreateConflictResolutionFeature = useCallback(() => {
|
||||
if (conflictInfo && onCreateConflictResolutionFeature) {
|
||||
onCreateConflictResolutionFeature({
|
||||
sourceBranch: selectedBranch,
|
||||
targetBranch: conflictInfo.targetBranch,
|
||||
targetWorktreePath: conflictInfo.targetWorktreePath,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
}, [conflictInfo, selectedBranch, onCreateConflictResolutionFeature, onOpenChange]);
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
// Conflict resolution UI
|
||||
if (step === 'conflict' && conflictInfo) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
Cherry-Pick Conflicts Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<span className="block">
|
||||
There are conflicts when cherry-picking commits from{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{selectedBranch}</code> into{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">
|
||||
{conflictInfo.targetBranch}
|
||||
</code>
|
||||
.
|
||||
</span>
|
||||
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-orange-500 text-sm">
|
||||
The cherry-pick 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">
|
||||
{conflictInfo.targetBranch}
|
||||
</code>{' '}
|
||||
branch.
|
||||
</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>
|
||||
Cherry-pick the selected commit(s) from{' '}
|
||||
<code className="font-mono bg-muted px-0.5 rounded">{selectedBranch}</code>
|
||||
</li>
|
||||
<li>Resolve any merge conflicts</li>
|
||||
<li>Ensure the code compiles and tests pass</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setStep('select-commits')}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateConflictResolutionFeature}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
Create Resolve Conflicts Feature
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Select commits
|
||||
if (step === 'select-commits') {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Cherry className="w-5 h-5 text-black dark:text-black" />
|
||||
Cherry Pick Commits
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select commits from{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{selectedBranch}</code> to apply to{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||
<div className="h-full px-6 pb-6">
|
||||
{loadingCommits && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="md" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Loading commits...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{commitsError && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-destructive">{commitsError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingCommits && !commitsError && commits.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-muted-foreground">No commits found on this branch</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingCommits && !commitsError && commits.length > 0 && (
|
||||
<div className="space-y-0.5 mt-2">
|
||||
{commits.map((commit, index) => {
|
||||
const isSelected = selectedCommitHashes.has(commit.hash);
|
||||
const isExpanded = expandedCommits.has(commit.hash);
|
||||
const hasFiles = commit.files && commit.files.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={commit.hash}
|
||||
className={cn(
|
||||
'group relative rounded-md transition-colors',
|
||||
isSelected
|
||||
? 'bg-primary/10 border border-primary/30'
|
||||
: 'border border-transparent',
|
||||
index === 0 && !isSelected && 'bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
onClick={() => toggleCommitSelection(commit.hash)}
|
||||
className={cn(
|
||||
'flex gap-3 py-2.5 px-3 cursor-pointer rounded-md transition-colors',
|
||||
!isSelected && 'hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div className="flex items-start pt-1 shrink-0">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleCommitSelection(commit.hash)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Commit content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-snug break-words">
|
||||
{commit.subject}
|
||||
</p>
|
||||
<CopyHashButton hash={commit.hash} />
|
||||
</div>
|
||||
{commit.body && (
|
||||
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap break-words line-clamp-2">
|
||||
{commit.body}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{commit.author}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<time
|
||||
dateTime={commit.date}
|
||||
title={new Date(commit.date).toLocaleString()}
|
||||
>
|
||||
{formatRelativeDate(commit.date)}
|
||||
</time>
|
||||
</span>
|
||||
{hasFiles && (
|
||||
<button
|
||||
onClick={(e) => toggleCommitExpanded(commit.hash, e)}
|
||||
className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
<FileText className="w-3 h-3" />
|
||||
{commit.files.length} file{commit.files.length !== 1 ? 's' : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded file list */}
|
||||
{isExpanded && hasFiles && (
|
||||
<div className="border-t mx-3 px-3 py-2 bg-muted/30 rounded-b-md ml-8">
|
||||
<div className="space-y-0.5">
|
||||
{commit.files.map((file) => (
|
||||
<div
|
||||
key={file}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground py-0.5"
|
||||
>
|
||||
<FileText className="w-3 h-3 shrink-0" />
|
||||
<span className="font-mono break-all">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Load More button */}
|
||||
{hasMoreCommits && commitLimit < 100 && (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleLoadMore();
|
||||
}}
|
||||
disabled={loadingMoreCommits}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{loadingMoreCommits ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-3.5 h-3.5 mr-1.5" />
|
||||
Load More Commits
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4 pt-4 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setStep('select-branch');
|
||||
setSelectedBranch('');
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isCherryPicking}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCherryPick}
|
||||
disabled={selectedCommitHashes.size === 0 || isCherryPicking}
|
||||
>
|
||||
{isCherryPicking ? (
|
||||
<>
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Cherry Picking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Cherry className="w-4 h-4 mr-2" />
|
||||
Cherry Pick
|
||||
{selectedCommitHashes.size > 0 ? ` (${selectedCommitHashes.size})` : ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 1: Select branch (and optionally remote)
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Cherry className="w-5 h-5 text-black dark:text-black" />
|
||||
Cherry Pick
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<span className="block">
|
||||
Select a branch to cherry-pick commits from into{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
|
||||
</span>
|
||||
|
||||
{loadingBranches ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner size="sm" />
|
||||
Loading branches...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Remote selector - only show if there are remotes */}
|
||||
{remotes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-foreground">Source</Label>
|
||||
<Select
|
||||
value={selectedRemote}
|
||||
onValueChange={(value) => {
|
||||
setSelectedRemote(value);
|
||||
setSelectedBranch('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select source..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="text-black dark:text-black">
|
||||
<SelectItem value="__local__">Local Branches</SelectItem>
|
||||
{remotes.map((remote) => (
|
||||
<SelectItem key={remote.name} value={remote.name}>
|
||||
{remote.name} ({remote.url})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branch selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-foreground">Branch</Label>
|
||||
{branchOptions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No other branches available</p>
|
||||
) : (
|
||||
<Select value={selectedBranch} onValueChange={setSelectedBranch}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a branch..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="text-black dark:text-black">
|
||||
{branchOptions.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>
|
||||
{branch}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleProceedToCommits} disabled={!selectedBranch || loadingBranches}>
|
||||
<GitCommit className="w-4 h-4 mr-2" />
|
||||
View Commits
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -307,6 +307,8 @@ export function CommitWorktreeDialog({
|
||||
setSelectedFiles(new Set());
|
||||
setExpandedFile(null);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadDiffs = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
@@ -314,20 +316,24 @@ export function CommitWorktreeDialog({
|
||||
const result = await api.git.getDiffs(worktree.path);
|
||||
if (result.success) {
|
||||
const fileList = result.files ?? [];
|
||||
setFiles(fileList);
|
||||
setDiffContent(result.diff ?? '');
|
||||
if (!cancelled) setFiles(fileList);
|
||||
if (!cancelled) setDiffContent(result.diff ?? '');
|
||||
// Select all files by default
|
||||
setSelectedFiles(new Set(fileList.map((f) => f.path)));
|
||||
if (!cancelled) setSelectedFiles(new Set(fileList.map((f) => f.path)));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load diffs for commit dialog:', err);
|
||||
} finally {
|
||||
setIsLoadingDiffs(false);
|
||||
if (!cancelled) setIsLoadingDiffs(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDiffs();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
}, [open, worktree]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -11,9 +11,20 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { GitBranchPlus } from 'lucide-react';
|
||||
import { GitBranchPlus, RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface WorktreeInfo {
|
||||
@@ -24,6 +35,12 @@ interface WorktreeInfo {
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface BranchInfo {
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
isRemote: boolean;
|
||||
}
|
||||
|
||||
const logger = createLogger('CreateBranchDialog');
|
||||
|
||||
interface CreateBranchDialogProps {
|
||||
@@ -40,16 +57,45 @@ export function CreateBranchDialog({
|
||||
onCreated,
|
||||
}: CreateBranchDialogProps) {
|
||||
const [branchName, setBranchName] = useState('');
|
||||
const [baseBranch, setBaseBranch] = useState('');
|
||||
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
const fetchBranches = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setIsLoadingBranches(true);
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listBranches(worktree.path, true);
|
||||
|
||||
if (result.success && result.result) {
|
||||
setBranches(result.result.branches);
|
||||
// Default to current branch
|
||||
if (result.result.currentBranch) {
|
||||
setBaseBranch(result.result.currentBranch);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch branches:', err);
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
}, [worktree]);
|
||||
|
||||
// Reset state and fetch branches when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setBranchName('');
|
||||
setBaseBranch('');
|
||||
setError(null);
|
||||
setBranches([]);
|
||||
fetchBranches();
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, fetchBranches]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!worktree || !branchName.trim()) return;
|
||||
@@ -71,7 +117,13 @@ export function CreateBranchDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.checkoutBranch(worktree.path, branchName.trim());
|
||||
// Pass baseBranch if user selected one different from the current branch
|
||||
const selectedBase = baseBranch || undefined;
|
||||
const result = await api.worktree.checkoutBranch(
|
||||
worktree.path,
|
||||
branchName.trim(),
|
||||
selectedBase
|
||||
);
|
||||
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
@@ -88,6 +140,10 @@ export function CreateBranchDialog({
|
||||
}
|
||||
};
|
||||
|
||||
// Separate local and remote branches
|
||||
const localBranches = branches.filter((b) => !b.isRemote);
|
||||
const remoteBranches = branches.filter((b) => b.isRemote);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
@@ -96,12 +152,7 @@ export function CreateBranchDialog({
|
||||
<GitBranchPlus className="w-5 h-5" />
|
||||
Create New Branch
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new branch from{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{worktree?.branch || 'current branch'}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
<DialogDescription>Create a new branch from a base branch</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
@@ -123,8 +174,74 @@ export function CreateBranchDialog({
|
||||
disabled={isCreating}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="base-branch">Base Branch</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchBranches}
|
||||
disabled={isLoadingBranches || isCreating}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{isLoadingBranches ? (
|
||||
<Spinner size="xs" className="mr-1" />
|
||||
) : (
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{isLoadingBranches && branches.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-3 border rounded-md border-input">
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<span className="text-sm text-muted-foreground">Loading branches...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Select value={baseBranch} onValueChange={setBaseBranch} disabled={isCreating}>
|
||||
<SelectTrigger id="base-branch">
|
||||
<SelectValue placeholder="Select base branch" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{localBranches.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Local Branches</SelectLabel>
|
||||
{localBranches.map((branch) => (
|
||||
<SelectItem key={branch.name} value={branch.name}>
|
||||
<span className={branch.isCurrent ? 'font-medium' : ''}>
|
||||
{branch.name}
|
||||
{branch.isCurrent ? ' (current)' : ''}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{remoteBranches.length > 0 && (
|
||||
<>
|
||||
{localBranches.length > 0 && <SelectSeparator />}
|
||||
<SelectGroup>
|
||||
<SelectLabel>Remote Branches</SelectLabel>
|
||||
{remoteBranches.map((branch) => (
|
||||
<SelectItem key={branch.name} value={branch.name}>
|
||||
{branch.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
{localBranches.length === 0 && remoteBranches.length === 0 && (
|
||||
<SelectItem value="HEAD" disabled>
|
||||
No branches found
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -13,12 +13,25 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||
import { GitPullRequest, ExternalLink } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { GitPullRequest, ExternalLink, Sparkles, RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { useWorktreeBranches } from '@/hooks/queries';
|
||||
|
||||
interface RemoteInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
@@ -58,6 +71,14 @@ export function CreatePRDialog({
|
||||
// Track whether an operation completed that warrants a refresh
|
||||
const operationCompletedRef = useRef(false);
|
||||
|
||||
// Remote selection state
|
||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
|
||||
|
||||
// Generate description state
|
||||
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
|
||||
|
||||
// Use React Query for branch fetching - only enabled when dialog is open
|
||||
const { data: branchesData, isLoading: isLoadingBranches } = useWorktreeBranches(
|
||||
open ? worktree?.path : undefined,
|
||||
@@ -70,6 +91,44 @@ export function CreatePRDialog({
|
||||
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
|
||||
}, [branchesData?.branches, worktree?.branch]);
|
||||
|
||||
// Fetch remotes when dialog opens
|
||||
const fetchRemotes = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setIsLoadingRemotes(true);
|
||||
|
||||
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: string; url: string }) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
})
|
||||
);
|
||||
setRemotes(remoteInfos);
|
||||
|
||||
// Auto-select 'origin' if available, otherwise first remote
|
||||
if (remoteInfos.length > 0) {
|
||||
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
|
||||
setSelectedRemote(defaultRemote.name);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - remotes selector will just not show
|
||||
} finally {
|
||||
setIsLoadingRemotes(false);
|
||||
}
|
||||
}, [worktree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
fetchRemotes();
|
||||
}
|
||||
}, [open, worktree, fetchRemotes]);
|
||||
|
||||
// Common state reset function to avoid duplication
|
||||
const resetState = useCallback(() => {
|
||||
setTitle('');
|
||||
@@ -81,6 +140,9 @@ export function CreatePRDialog({
|
||||
setPrUrl(null);
|
||||
setBrowserUrl(null);
|
||||
setShowBrowserFallback(false);
|
||||
setRemotes([]);
|
||||
setSelectedRemote('');
|
||||
setIsGeneratingDescription(false);
|
||||
operationCompletedRef.current = false;
|
||||
}, [defaultBaseBranch]);
|
||||
|
||||
@@ -90,6 +152,37 @@ export function CreatePRDialog({
|
||||
resetState();
|
||||
}, [open, worktree?.path, resetState]);
|
||||
|
||||
const handleGenerateDescription = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setIsGeneratingDescription(true);
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.generatePRDescription(worktree.path, baseBranch);
|
||||
|
||||
if (result.success) {
|
||||
if (result.title) {
|
||||
setTitle(result.title);
|
||||
}
|
||||
if (result.body) {
|
||||
setBody(result.body);
|
||||
}
|
||||
toast.success('PR description generated');
|
||||
} else {
|
||||
toast.error('Failed to generate description', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to generate description', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsGeneratingDescription(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
@@ -109,6 +202,7 @@ export function CreatePRDialog({
|
||||
prBody: body || `Changes from branch ${worktree.branch}`,
|
||||
baseBranch,
|
||||
draft: isDraft,
|
||||
remote: selectedRemote || undefined,
|
||||
});
|
||||
|
||||
if (result.success && result.result) {
|
||||
@@ -329,7 +423,33 @@ export function CreatePRDialog({
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="pr-title">PR Title</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="pr-title">PR Title</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGenerateDescription}
|
||||
disabled={isGeneratingDescription || isLoading}
|
||||
className="h-6 px-2 text-xs"
|
||||
title={
|
||||
worktree.hasChanges
|
||||
? 'Generate title and description from commits and uncommitted changes'
|
||||
: 'Generate title and description from commits'
|
||||
}
|
||||
>
|
||||
{isGeneratingDescription ? (
|
||||
<>
|
||||
<Spinner size="xs" className="mr-1" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-3 h-3 mr-1" />
|
||||
Generate with AI
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
id="pr-title"
|
||||
placeholder={worktree.branch}
|
||||
@@ -350,6 +470,49 @@ export function CreatePRDialog({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Remote selector - only show if multiple remotes are available */}
|
||||
{remotes.length > 1 && (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="remote-select">Push to Remote</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchRemotes}
|
||||
disabled={isLoadingRemotes}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{isLoadingRemotes ? (
|
||||
<Spinner size="xs" className="mr-1" />
|
||||
) : (
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
|
||||
<SelectTrigger id="remote-select">
|
||||
<SelectValue placeholder="Select a remote" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remotes.map((remote) => (
|
||||
<SelectItem
|
||||
key={remote.name}
|
||||
value={remote.name}
|
||||
description={
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{remote.url}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="base-branch">Base Branch</Label>
|
||||
<BranchAutocomplete
|
||||
|
||||
@@ -313,8 +313,8 @@ export function DiscardWorktreeChangesDialog({
|
||||
const fileList = result.files ?? [];
|
||||
setFiles(fileList);
|
||||
setDiffContent(result.diff ?? '');
|
||||
// Select all files by default
|
||||
setSelectedFiles(new Set(fileList.map((f) => f.path)));
|
||||
// No files selected by default
|
||||
setSelectedFiles(new Set());
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Copy } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface DuplicateCountDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (count: number) => void;
|
||||
featureTitle?: string;
|
||||
}
|
||||
|
||||
export function DuplicateCountDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
featureTitle,
|
||||
}: DuplicateCountDialogProps) {
|
||||
const [count, setCount] = useState(2);
|
||||
|
||||
// Reset count when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCount(2);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (count >= 1 && count <= 50) {
|
||||
onConfirm(count);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-popover border-border max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Copy className="w-5 h-5 text-primary" />
|
||||
Duplicate as Child ×N
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Creates a chain of duplicates where each is a child of the previous, so they execute
|
||||
sequentially.
|
||||
{featureTitle && (
|
||||
<span className="block mt-1 text-xs">
|
||||
Source: <span className="font-medium">{featureTitle}</span>
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2">
|
||||
<label htmlFor="duplicate-count" className="text-sm text-muted-foreground mb-2 block">
|
||||
Number of copies
|
||||
</label>
|
||||
<Input
|
||||
id="duplicate-count"
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={count}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val)) {
|
||||
setCount(Math.min(50, Math.max(1, val)));
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">Enter a number between 1 and 50</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2 pt-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} className="px-4">
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
variant="default"
|
||||
onClick={handleConfirm}
|
||||
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
||||
hotkeyActive={open}
|
||||
className="px-4"
|
||||
disabled={count < 1 || count > 50}
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Create {count} {count === 1 ? 'Copy' : 'Copies'}
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,20 @@ 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 { DuplicateCountDialog } from './duplicate-count-dialog';
|
||||
export { DiscardWorktreeChangesDialog } from './discard-worktree-changes-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 { MergeRebaseDialog, type PullStrategy } from './merge-rebase-dialog';
|
||||
export { PushToRemoteDialog } from './push-to-remote-dialog';
|
||||
export { SelectRemoteDialog, type SelectRemoteOperation } from './select-remote-dialog';
|
||||
export { ViewCommitsDialog } from './view-commits-dialog';
|
||||
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
|
||||
export { ExportFeaturesDialog } from './export-features-dialog';
|
||||
export { ImportFeaturesDialog } from './import-features-dialog';
|
||||
export { StashChangesDialog } from './stash-changes-dialog';
|
||||
export { ViewStashesDialog } from './view-stashes-dialog';
|
||||
export { CherryPickDialog } from './cherry-pick-dialog';
|
||||
|
||||
@@ -21,10 +21,12 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { GitMerge, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
import { GitMerge, RefreshCw, AlertTriangle, GitBranch } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import type { WorktreeInfo } from '../worktree-panel/types';
|
||||
|
||||
export type PullStrategy = 'merge' | 'rebase';
|
||||
|
||||
interface RemoteBranch {
|
||||
name: string;
|
||||
fullRef: string;
|
||||
@@ -36,24 +38,29 @@ interface RemoteInfo {
|
||||
branches: RemoteBranch[];
|
||||
}
|
||||
|
||||
const logger = createLogger('PullResolveConflictsDialog');
|
||||
const logger = createLogger('MergeRebaseDialog');
|
||||
|
||||
interface PullResolveConflictsDialogProps {
|
||||
interface MergeRebaseDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void | Promise<void>;
|
||||
onConfirm: (
|
||||
worktree: WorktreeInfo,
|
||||
remoteBranch: string,
|
||||
strategy: PullStrategy
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function PullResolveConflictsDialog({
|
||||
export function MergeRebaseDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onConfirm,
|
||||
}: PullResolveConflictsDialogProps) {
|
||||
}: MergeRebaseDialogProps) {
|
||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||
const [selectedBranch, setSelectedBranch] = useState<string>('');
|
||||
const [selectedStrategy, setSelectedStrategy] = useState<PullStrategy>('merge');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -70,6 +77,7 @@ export function PullResolveConflictsDialog({
|
||||
if (!open) {
|
||||
setSelectedRemote('');
|
||||
setSelectedBranch('');
|
||||
setSelectedStrategy('merge');
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
@@ -161,7 +169,7 @@ export function PullResolveConflictsDialog({
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!worktree || !selectedBranch) return;
|
||||
onConfirm(worktree, selectedBranch);
|
||||
onConfirm(worktree, selectedBranch, selectedStrategy);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -174,10 +182,10 @@ export function PullResolveConflictsDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitMerge className="w-5 h-5 text-purple-500" />
|
||||
Pull & Resolve Conflicts
|
||||
Merge & Rebase
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a remote branch to pull from and resolve conflicts with{' '}
|
||||
Select a remote branch to merge or rebase with{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{worktree?.branch || 'current branch'}
|
||||
</span>
|
||||
@@ -225,13 +233,16 @@ export function PullResolveConflictsDialog({
|
||||
</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>
|
||||
<SelectItem
|
||||
key={remote.name}
|
||||
value={remote.name}
|
||||
description={
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{remote.url}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -264,13 +275,62 @@ export function PullResolveConflictsDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="strategy-select">Strategy</Label>
|
||||
<Select
|
||||
value={selectedStrategy}
|
||||
onValueChange={(value) => setSelectedStrategy(value as PullStrategy)}
|
||||
>
|
||||
<SelectTrigger id="strategy-select">
|
||||
<SelectValue placeholder="Select a strategy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
value="merge"
|
||||
description={
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Creates a merge commit preserving history
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<GitMerge className="w-3.5 h-3.5 text-purple-500" />
|
||||
<span className="font-medium">Merge</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="rebase"
|
||||
description={
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Replays commits on top for linear history
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<GitBranch className="w-3.5 h-3.5 text-blue-500" />
|
||||
<span className="font-medium">Rebase</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedBranch && (
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will 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.
|
||||
This will create a feature task to{' '}
|
||||
{selectedStrategy === 'rebase' ? (
|
||||
<>
|
||||
rebase <span className="font-mono text-foreground">{worktree?.branch}</span>{' '}
|
||||
onto <span className="font-mono text-foreground">{selectedBranch}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
merge <span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
|
||||
<span className="font-mono text-foreground">{worktree?.branch}</span>
|
||||
</>
|
||||
)}{' '}
|
||||
and resolve any conflicts.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -287,7 +347,7 @@ export function PullResolveConflictsDialog({
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<GitMerge className="w-4 h-4 mr-2" />
|
||||
Pull & Resolve
|
||||
Merge & Rebase
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -306,13 +306,16 @@ export function PushToRemoteDialog({
|
||||
</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>
|
||||
<SelectItem
|
||||
key={remote.name}
|
||||
value={remote.name}
|
||||
description={
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{remote.url}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { getErrorMessage } from '@/lib/utils';
|
||||
import { Download, Upload, RefreshCw, AlertTriangle } 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('SelectRemoteDialog');
|
||||
|
||||
export type SelectRemoteOperation = 'pull' | 'push';
|
||||
|
||||
interface SelectRemoteDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
operation: SelectRemoteOperation;
|
||||
onConfirm: (worktree: WorktreeInfo, remote: string) => void;
|
||||
}
|
||||
|
||||
export function SelectRemoteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
operation,
|
||||
onConfirm,
|
||||
}: SelectRemoteDialogProps) {
|
||||
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);
|
||||
|
||||
const fetchRemotes = useCallback(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) {
|
||||
const remoteInfos = result.result.remotes.map((r: { name: string; url: string }) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
}));
|
||||
setRemotes(remoteInfos);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch remotes');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch remotes:', err);
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [worktree]);
|
||||
|
||||
// Fetch remotes when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
fetchRemotes();
|
||||
}
|
||||
}, [open, worktree, fetchRemotes]);
|
||||
|
||||
// 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 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 = result.result.remotes.map((r: { name: string; url: string }) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
}));
|
||||
setRemotes(remoteInfos);
|
||||
} else {
|
||||
setError(result.error || 'Failed to refresh remotes');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh remotes:', err);
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!worktree || !selectedRemote) return;
|
||||
onConfirm(worktree, selectedRemote);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const isPull = operation === 'pull';
|
||||
const Icon = isPull ? Download : Upload;
|
||||
const title = isPull ? 'Pull from Remote' : 'Push to Remote';
|
||||
const actionLabel = isPull
|
||||
? `Pull from ${selectedRemote || 'Remote'}`
|
||||
: `Push to ${selectedRemote || 'Remote'}`;
|
||||
const description = isPull ? (
|
||||
<>
|
||||
Select a remote to pull changes into{' '}
|
||||
<span className="font-mono text-foreground">{worktree?.branch || 'current branch'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Select a remote to push{' '}
|
||||
<span className="font-mono text-foreground">{worktree?.branch || 'current branch'}</span> to
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Icon className="w-5 h-5 text-primary" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{description}</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}
|
||||
description={
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{remote.url}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedRemote && (
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isPull ? (
|
||||
<>
|
||||
This will pull changes from{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{selectedRemote}/{worktree?.branch}
|
||||
</span>{' '}
|
||||
into your local branch.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will push your local changes to{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{selectedRemote}/{worktree?.branch}
|
||||
</span>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Archive,
|
||||
FilePlus,
|
||||
FileX,
|
||||
FilePen,
|
||||
FileText,
|
||||
File,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface StashChangesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onStashed?: () => void;
|
||||
}
|
||||
|
||||
interface ParsedDiffHunk {
|
||||
header: string;
|
||||
lines: {
|
||||
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ParsedFileDiff {
|
||||
filePath: string;
|
||||
hunks: ParsedDiffHunk[];
|
||||
isNew?: boolean;
|
||||
isDeleted?: boolean;
|
||||
isRenamed?: boolean;
|
||||
}
|
||||
|
||||
const getFileIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
case '?':
|
||||
return <FilePlus className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />;
|
||||
case 'D':
|
||||
return <FileX className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />;
|
||||
case 'M':
|
||||
case 'U':
|
||||
return <FilePen className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />;
|
||||
case 'R':
|
||||
case 'C':
|
||||
return <File className="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />;
|
||||
default:
|
||||
return <FileText className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
return 'Added';
|
||||
case '?':
|
||||
return 'Untracked';
|
||||
case 'D':
|
||||
return 'Deleted';
|
||||
case 'M':
|
||||
return 'Modified';
|
||||
case 'U':
|
||||
return 'Updated';
|
||||
case 'R':
|
||||
return 'Renamed';
|
||||
case 'C':
|
||||
return 'Copied';
|
||||
default:
|
||||
return 'Changed';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
case '?':
|
||||
return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||
case 'D':
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||
case 'M':
|
||||
case 'U':
|
||||
return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
|
||||
case 'R':
|
||||
case 'C':
|
||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground border-border';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse unified diff format into structured data
|
||||
*/
|
||||
function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
if (!diffText) return [];
|
||||
|
||||
const files: ParsedFileDiff[] = [];
|
||||
const lines = diffText.split('\n');
|
||||
let currentFile: ParsedFileDiff | null = null;
|
||||
let currentHunk: ParsedDiffHunk | null = null;
|
||||
let oldLineNum = 0;
|
||||
let newLineNum = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('diff --git')) {
|
||||
if (currentFile) {
|
||||
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||
files.push(currentFile);
|
||||
}
|
||||
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
|
||||
currentFile = {
|
||||
filePath: match ? match[2] : 'unknown',
|
||||
hunks: [],
|
||||
};
|
||||
currentHunk = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('new file mode')) {
|
||||
if (currentFile) currentFile.isNew = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('deleted file mode')) {
|
||||
if (currentFile) currentFile.isDeleted = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('rename from') || line.startsWith('rename to')) {
|
||||
if (currentFile) currentFile.isRenamed = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('@@')) {
|
||||
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
|
||||
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
|
||||
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
|
||||
currentHunk = {
|
||||
header: line,
|
||||
lines: [{ type: 'header', content: line }],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
if (line.startsWith('+')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'addition',
|
||||
content: line.substring(1),
|
||||
lineNumber: { new: newLineNum },
|
||||
});
|
||||
newLineNum++;
|
||||
} else if (line.startsWith('-')) {
|
||||
currentHunk.lines.push({
|
||||
type: 'deletion',
|
||||
content: line.substring(1),
|
||||
lineNumber: { old: oldLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
currentHunk.lines.push({
|
||||
type: 'context',
|
||||
content: line.substring(1) || '',
|
||||
lineNumber: { old: oldLineNum, new: newLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFile) {
|
||||
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||
files.push(currentFile);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function DiffLine({
|
||||
type,
|
||||
content,
|
||||
lineNumber,
|
||||
}: {
|
||||
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}) {
|
||||
const bgClass = {
|
||||
context: 'bg-transparent',
|
||||
addition: 'bg-green-500/10',
|
||||
deletion: 'bg-red-500/10',
|
||||
header: 'bg-blue-500/10',
|
||||
};
|
||||
|
||||
const textClass = {
|
||||
context: 'text-foreground-secondary',
|
||||
addition: 'text-green-400',
|
||||
deletion: 'text-red-400',
|
||||
header: 'text-blue-400',
|
||||
};
|
||||
|
||||
const prefix = {
|
||||
context: ' ',
|
||||
addition: '+',
|
||||
deletion: '-',
|
||||
header: '',
|
||||
};
|
||||
|
||||
if (type === 'header') {
|
||||
return (
|
||||
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex font-mono text-xs', bgClass[type])}>
|
||||
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
|
||||
{lineNumber?.old ?? ''}
|
||||
</span>
|
||||
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
|
||||
{lineNumber?.new ?? ''}
|
||||
</span>
|
||||
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
|
||||
{prefix[type]}
|
||||
</span>
|
||||
<span className={cn('flex-1 px-1.5 whitespace-pre-wrap break-all', textClass[type])}>
|
||||
{content || '\u00A0'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StashChangesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onStashed,
|
||||
}: StashChangesDialogProps) {
|
||||
const [message, setMessage] = useState('');
|
||||
const [isStashing, setIsStashing] = useState(false);
|
||||
|
||||
// File selection state
|
||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
||||
const [diffContent, setDiffContent] = useState('');
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
||||
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
|
||||
|
||||
// Parse diffs
|
||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||
|
||||
// Create a map of file path to parsed diff for quick lookup
|
||||
const diffsByFile = useMemo(() => {
|
||||
const map = new Map<string, ParsedFileDiff>();
|
||||
for (const diff of parsedDiffs) {
|
||||
map.set(diff.filePath, diff);
|
||||
}
|
||||
return map;
|
||||
}, [parsedDiffs]);
|
||||
|
||||
// Load diffs when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
setIsLoadingDiffs(true);
|
||||
setFiles([]);
|
||||
setDiffContent('');
|
||||
setSelectedFiles(new Set());
|
||||
setExpandedFile(null);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadDiffs = async () => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.git.getDiffs(worktree.path);
|
||||
if (result.success) {
|
||||
const fileList = result.files ?? [];
|
||||
if (!cancelled) setFiles(fileList);
|
||||
if (!cancelled) setDiffContent(result.diff ?? '');
|
||||
// Select all files by default
|
||||
if (!cancelled) setSelectedFiles(new Set(fileList.map((f: FileStatus) => f.path)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load diffs for stash dialog:', err);
|
||||
} finally {
|
||||
if (!cancelled) setIsLoadingDiffs(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDiffs();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
}, [open, worktree]);
|
||||
|
||||
const handleToggleFile = useCallback((filePath: string) => {
|
||||
setSelectedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(filePath)) {
|
||||
next.delete(filePath);
|
||||
} else {
|
||||
next.add(filePath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleAll = useCallback(() => {
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.size === files.length) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(files.map((f) => f.path));
|
||||
});
|
||||
}, [files]);
|
||||
|
||||
const handleFileClick = useCallback((filePath: string) => {
|
||||
setExpandedFile((prev) => (prev === filePath ? null : filePath));
|
||||
}, []);
|
||||
|
||||
const handleStash = async () => {
|
||||
if (!worktree || selectedFiles.size === 0) return;
|
||||
|
||||
setIsStashing(true);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
|
||||
// Pass selected files if not all files are selected
|
||||
const filesToStash =
|
||||
selectedFiles.size === files.length ? undefined : Array.from(selectedFiles);
|
||||
|
||||
const result = await api.worktree.stashPush(
|
||||
worktree.path,
|
||||
message.trim() || undefined,
|
||||
filesToStash
|
||||
);
|
||||
|
||||
if (result.success && result.result) {
|
||||
if (result.result.stashed) {
|
||||
toast.success('Changes stashed', {
|
||||
description: result.result.message || 'Your changes have been stashed',
|
||||
});
|
||||
setMessage('');
|
||||
onOpenChange(false);
|
||||
onStashed?.();
|
||||
} else {
|
||||
toast.info('No changes to stash');
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to stash changes', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to stash changes', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsStashing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !isStashing && selectedFiles.size > 0) {
|
||||
e.preventDefault();
|
||||
handleStash();
|
||||
}
|
||||
};
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
const allSelected = selectedFiles.size === files.length && files.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setMessage('');
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="sm:max-w-[700px] max-h-[85vh] flex flex-col"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Archive className="w-5 h-5" />
|
||||
Stash Changes
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Stash uncommitted changes on{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
|
||||
{/* File Selection */}
|
||||
<div className="flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
Files to stash
|
||||
{isLoadingDiffs ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground font-normal">
|
||||
({selectedFiles.size}/{files.length} selected)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{files.length > 0 && (
|
||||
<button
|
||||
onClick={handleToggleAll}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{allSelected ? 'Deselect all' : 'Select all'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoadingDiffs ? (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<span className="text-sm">Loading changes...</span>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
|
||||
<span className="text-sm">No changes detected</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto scrollbar-visible">
|
||||
{files.map((file) => {
|
||||
const isChecked = selectedFiles.has(file.path);
|
||||
const isExpanded = expandedFile === file.path;
|
||||
const fileDiff = diffsByFile.get(file.path);
|
||||
const additions = fileDiff
|
||||
? fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
|
||||
0
|
||||
)
|
||||
: 0;
|
||||
const deletions = fileDiff
|
||||
? fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
|
||||
0
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={file.path} className="border-b border-border last:border-b-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group',
|
||||
isExpanded && 'bg-accent/30'
|
||||
)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => handleToggleFile(file.path)}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
{/* Clickable file row to show diff */}
|
||||
<button
|
||||
onClick={() => handleFileClick(file.path)}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{getFileIcon(file.status)}
|
||||
<span className="text-xs font-mono truncate flex-1 text-foreground">
|
||||
{file.path}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
|
||||
getStatusBadgeColor(file.status)
|
||||
)}
|
||||
>
|
||||
{getStatusLabel(file.status)}
|
||||
</span>
|
||||
{additions > 0 && (
|
||||
<span className="text-[10px] text-green-400 flex-shrink-0">
|
||||
+{additions}
|
||||
</span>
|
||||
)}
|
||||
{deletions > 0 && (
|
||||
<span className="text-[10px] text-red-400 flex-shrink-0">
|
||||
-{deletions}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded diff view */}
|
||||
{isExpanded && fileDiff && (
|
||||
<div className="bg-background border-t border-border max-h-[200px] overflow-y-auto scrollbar-visible">
|
||||
{fileDiff.hunks.map((hunk, hunkIndex) => (
|
||||
<div
|
||||
key={hunkIndex}
|
||||
className="border-b border-border-glass last:border-b-0"
|
||||
>
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<DiffLine
|
||||
key={lineIndex}
|
||||
type={line.type}
|
||||
content={line.content}
|
||||
lineNumber={line.lineNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && !fileDiff && (
|
||||
<div className="px-4 py-3 text-xs text-muted-foreground bg-background border-t border-border">
|
||||
{file.status === '?' ? (
|
||||
<span>New file - diff preview not available</span>
|
||||
) : file.status === 'D' ? (
|
||||
<span>File deleted</span>
|
||||
) : (
|
||||
<span>Diff content not available</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stash Message */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="stash-message" className="text-sm font-medium">
|
||||
Stash message <span className="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="stash-message"
|
||||
placeholder="e.g., Work in progress on login page"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
disabled={isStashing}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A descriptive message helps identify this stash later. Press{' '}
|
||||
<kbd className="px-1 py-0.5 text-[10px] bg-muted rounded border">
|
||||
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter
|
||||
</kbd>{' '}
|
||||
to stash.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isStashing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleStash} disabled={isStashing || selectedFiles.size === 0}>
|
||||
{isStashing ? (
|
||||
<>
|
||||
<Spinner size="xs" className="mr-2" />
|
||||
Stashing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="w-4 h-4 mr-2" />
|
||||
Stash
|
||||
{selectedFiles.size > 0 && selectedFiles.size < files.length
|
||||
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
|
||||
: ' Changes'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
GitCommit,
|
||||
User,
|
||||
Clock,
|
||||
Copy,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface CommitInfo {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
author: string;
|
||||
authorEmail: string;
|
||||
date: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
interface ViewCommitsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
}
|
||||
|
||||
function formatRelativeDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
|
||||
if (diffSecs < 60) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
if (diffWeeks < 5) return `${diffWeeks}w ago`;
|
||||
if (diffMonths < 12) return `${diffMonths}mo ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function CopyHashButton({ hash }: { hash: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(hash);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Failed to copy hash');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-1 font-mono text-[11px] bg-muted hover:bg-muted/80 px-1.5 py-0.5 rounded cursor-pointer transition-colors"
|
||||
title={`Copy full hash: ${hash}`}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-2.5 h-2.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-2.5 h-2.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-muted-foreground">{hash.slice(0, 7)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CommitEntryItem({
|
||||
commit,
|
||||
index,
|
||||
isLast,
|
||||
}: {
|
||||
commit: CommitInfo;
|
||||
index: number;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasFiles = commit.files && commit.files.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group relative rounded-md transition-colors', index === 0 && 'bg-muted/30')}
|
||||
>
|
||||
<div className="flex gap-3 py-2.5 px-3 hover:bg-muted/50 transition-colors rounded-md">
|
||||
{/* Timeline dot and line */}
|
||||
<div className="flex flex-col items-center pt-1.5 shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full border-2',
|
||||
index === 0 ? 'border-primary bg-primary' : 'border-muted-foreground/40 bg-background'
|
||||
)}
|
||||
/>
|
||||
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
|
||||
</div>
|
||||
|
||||
{/* Commit content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-snug break-words">{commit.subject}</p>
|
||||
<CopyHashButton hash={commit.hash} />
|
||||
</div>
|
||||
{commit.body && (
|
||||
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap break-words line-clamp-3">
|
||||
{commit.body}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{commit.author}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<time dateTime={commit.date} title={new Date(commit.date).toLocaleString()}>
|
||||
{formatRelativeDate(commit.date)}
|
||||
</time>
|
||||
</span>
|
||||
{hasFiles && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
<FileText className="w-3 h-3" />
|
||||
{commit.files.length} file{commit.files.length !== 1 ? 's' : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded file list */}
|
||||
{expanded && hasFiles && (
|
||||
<div className="border-t px-3 py-2 bg-muted/30">
|
||||
<div className="space-y-0.5">
|
||||
{commit.files.map((file) => (
|
||||
<div
|
||||
key={file}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground py-0.5"
|
||||
>
|
||||
<FileText className="w-3 h-3 shrink-0" />
|
||||
<span className="font-mono break-all">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const INITIAL_COMMIT_LIMIT = 30;
|
||||
const LOAD_MORE_INCREMENT = 30;
|
||||
const MAX_COMMIT_LIMIT = 100;
|
||||
|
||||
export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsDialogProps) {
|
||||
const [commits, setCommits] = useState<CommitInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [limit, setLimit] = useState(INITIAL_COMMIT_LIMIT);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
const fetchCommits = useCallback(
|
||||
async (fetchLimit: number, isLoadMore = false) => {
|
||||
if (isLoadMore) {
|
||||
setIsLoadingMore(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setCommits([]);
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.getCommitLog(worktree!.path, fetchLimit);
|
||||
|
||||
if (result.success && result.result) {
|
||||
// Ensure each commit has a files array (backwards compat if server hasn't been rebuilt)
|
||||
const fetchedCommits = result.result.commits.map((c: CommitInfo) => ({
|
||||
...c,
|
||||
files: c.files || [],
|
||||
}));
|
||||
setCommits(fetchedCommits);
|
||||
// If we got back exactly as many commits as we requested, there may be more
|
||||
setHasMore(fetchedCommits.length === fetchLimit && fetchLimit < MAX_COMMIT_LIMIT);
|
||||
} else {
|
||||
setError(result.error || 'Failed to load commits');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load commits');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[worktree]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !worktree) return;
|
||||
setLimit(INITIAL_COMMIT_LIMIT);
|
||||
setHasMore(false);
|
||||
fetchCommits(INITIAL_COMMIT_LIMIT);
|
||||
}, [open, worktree, fetchCommits]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
const newLimit = Math.min(limit + LOAD_MORE_INCREMENT, MAX_COMMIT_LIMIT);
|
||||
setLimit(newLimit);
|
||||
fetchCommits(newLimit, true);
|
||||
};
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitCommit className="w-5 h-5" />
|
||||
Commit History
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Recent commits on{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||
<div className="h-full px-6 pb-6">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="md" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Loading commits...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && commits.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-muted-foreground">No commits found</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && commits.length > 0 && (
|
||||
<div className="space-y-0.5 mt-2">
|
||||
{commits.map((commit, index) => (
|
||||
<CommitEntryItem
|
||||
key={commit.hash}
|
||||
commit={commit}
|
||||
index={index}
|
||||
isLast={index === commits.length - 1 && !hasMore}
|
||||
/>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer px-4 py-2 rounded-md hover:bg-muted/50"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
Loading more commits...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
Load more commits
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Archive,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Play,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface StashEntry {
|
||||
index: number;
|
||||
message: string;
|
||||
branch: string;
|
||||
date: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
interface ViewStashesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onStashApplied?: () => void;
|
||||
}
|
||||
|
||||
function formatRelativeDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return 'Unknown date';
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
|
||||
if (diffSecs < 60) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
if (diffWeeks < 5) return `${diffWeeks}w ago`;
|
||||
if (diffMonths < 12) return `${diffMonths}mo ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function StashEntryItem({
|
||||
stash,
|
||||
onApply,
|
||||
onPop,
|
||||
onDrop,
|
||||
isApplying,
|
||||
isDropping,
|
||||
}: {
|
||||
stash: StashEntry;
|
||||
onApply: (index: number) => void;
|
||||
onPop: (index: number) => void;
|
||||
onDrop: (index: number) => void;
|
||||
isApplying: boolean;
|
||||
isDropping: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isBusy = isApplying || isDropping;
|
||||
|
||||
// Clean up the stash message for display
|
||||
const displayMessage =
|
||||
stash.message.replace(/^(WIP on|On) [^:]+:\s*[a-f0-9]+\s*/, '').trim() || stash.message;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative rounded-md border bg-card transition-colors',
|
||||
'hover:border-primary/30'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
{/* Expand toggle & stash icon */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 pt-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={stash.files.length === 0}
|
||||
>
|
||||
{stash.files.length > 0 ? (
|
||||
expanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
<Archive className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium leading-snug break-words">{displayMessage}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-1 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1 font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
||||
stash@{'{' + stash.index + '}'}
|
||||
</span>
|
||||
{stash.branch && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
{stash.branch}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<time
|
||||
dateTime={stash.date}
|
||||
title={
|
||||
!isNaN(new Date(stash.date).getTime())
|
||||
? new Date(stash.date).toLocaleString()
|
||||
: stash.date
|
||||
}
|
||||
>
|
||||
{formatRelativeDate(stash.date)}
|
||||
</time>
|
||||
</span>
|
||||
{stash.files.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<FileText className="w-3 h-3" />
|
||||
{stash.files.length} file{stash.files.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => onApply(stash.index)}
|
||||
disabled={isBusy}
|
||||
title="Apply stash (keep in stash list)"
|
||||
>
|
||||
{isApplying ? <Spinner size="xs" /> : <Play className="w-3 h-3 mr-1" />}
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => onPop(stash.index)}
|
||||
disabled={isBusy}
|
||||
title="Pop stash (apply and remove from stash list)"
|
||||
>
|
||||
{isApplying ? <Spinner size="xs" /> : 'Pop'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
onClick={() => onDrop(stash.index)}
|
||||
disabled={isBusy}
|
||||
title="Delete this stash"
|
||||
>
|
||||
{isDropping ? <Spinner size="xs" /> : <Trash2 className="w-3 h-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded file list */}
|
||||
{expanded && stash.files.length > 0 && (
|
||||
<div className="border-t px-3 py-2 bg-muted/30">
|
||||
<div className="space-y-0.5">
|
||||
{stash.files.map((file) => (
|
||||
<div
|
||||
key={file}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground py-0.5"
|
||||
>
|
||||
<FileText className="w-3 h-3 shrink-0" />
|
||||
<span className="font-mono break-all">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ViewStashesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onStashApplied,
|
||||
}: ViewStashesDialogProps) {
|
||||
const [stashes, setStashes] = useState<StashEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [applyingIndex, setApplyingIndex] = useState<number | null>(null);
|
||||
const [droppingIndex, setDroppingIndex] = useState<number | null>(null);
|
||||
|
||||
const fetchStashes = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.stashList(worktree.path);
|
||||
|
||||
if (result.success && result.result) {
|
||||
setStashes(result.result.stashes);
|
||||
} else {
|
||||
setError(result.error || 'Failed to load stashes');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load stashes');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [worktree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
fetchStashes();
|
||||
}
|
||||
if (!open) {
|
||||
setStashes([]);
|
||||
setError(null);
|
||||
}
|
||||
}, [open, worktree, fetchStashes]);
|
||||
|
||||
const handleApply = async (stashIndex: number) => {
|
||||
if (!worktree) return;
|
||||
|
||||
setApplyingIndex(stashIndex);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.stashApply(worktree.path, stashIndex, false);
|
||||
|
||||
if (result.success && result.result) {
|
||||
if (result.result.hasConflicts) {
|
||||
toast.warning('Stash applied with conflicts', {
|
||||
description: 'Please resolve the merge conflicts.',
|
||||
});
|
||||
} else {
|
||||
toast.success('Stash applied');
|
||||
}
|
||||
onStashApplied?.();
|
||||
} else {
|
||||
toast.error('Failed to apply stash', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to apply stash', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setApplyingIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePop = async (stashIndex: number) => {
|
||||
if (!worktree) return;
|
||||
|
||||
setApplyingIndex(stashIndex);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.stashApply(worktree.path, stashIndex, true);
|
||||
|
||||
if (result.success && result.result) {
|
||||
if (result.result.hasConflicts) {
|
||||
toast.warning('Stash popped with conflicts', {
|
||||
description: 'Please resolve the merge conflicts. The stash was removed.',
|
||||
});
|
||||
} else {
|
||||
toast.success('Stash popped', {
|
||||
description: 'Changes applied and stash removed.',
|
||||
});
|
||||
}
|
||||
// Refresh the stash list since the stash was removed
|
||||
await fetchStashes();
|
||||
onStashApplied?.();
|
||||
} else {
|
||||
toast.error('Failed to pop stash', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to pop stash', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setApplyingIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (stashIndex: number) => {
|
||||
if (!worktree) return;
|
||||
|
||||
setDroppingIndex(stashIndex);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.stashDrop(worktree.path, stashIndex);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Stash deleted');
|
||||
// Refresh the stash list
|
||||
await fetchStashes();
|
||||
} else {
|
||||
toast.error('Failed to delete stash', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete stash', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setDroppingIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Archive className="w-5 h-5" />
|
||||
Stashes
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Stashed changes in{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 sm:min-h-[300px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||
<div className="h-full px-6 pb-6">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="md" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Loading stashes...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && stashes.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Archive className="w-8 h-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No stashes found</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use "Stash Changes" to save your uncommitted changes
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && stashes.length > 0 && (
|
||||
<div className="space-y-2 mt-2">
|
||||
{stashes.map((stash) => (
|
||||
<StashEntryItem
|
||||
key={stash.index}
|
||||
stash={stash}
|
||||
onApply={handleApply}
|
||||
onPop={handlePop}
|
||||
onDrop={handleDrop}
|
||||
isApplying={applyingIndex === stash.index}
|
||||
isDropping={droppingIndex === stash.index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -660,9 +660,28 @@ export function useBoardActions({
|
||||
const handleVerifyFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
verifyFeatureMutation.mutate(feature.id);
|
||||
try {
|
||||
const result = await verifyFeatureMutation.mutateAsync(feature.id);
|
||||
if (result.passes) {
|
||||
// Immediately move card to verified column (optimistic update)
|
||||
moveFeature(feature.id, 'verified');
|
||||
persistFeatureUpdate(feature.id, {
|
||||
status: 'verified',
|
||||
justFinishedAt: undefined,
|
||||
});
|
||||
toast.success('Verification passed', {
|
||||
description: `Verified: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
} else {
|
||||
toast.error('Verification failed', {
|
||||
description: `Tests did not pass for: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Error toast is already shown by the mutation's onError handler
|
||||
}
|
||||
},
|
||||
[currentProject, verifyFeatureMutation]
|
||||
[currentProject, verifyFeatureMutation, moveFeature, persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleResumeFeature = useCallback(
|
||||
@@ -1176,6 +1195,49 @@ export function useBoardActions({
|
||||
[handleAddFeature]
|
||||
);
|
||||
|
||||
const handleDuplicateAsChildMultiple = useCallback(
|
||||
async (feature: Feature, count: number) => {
|
||||
// Create a chain of duplicates, each a child of the previous, so they execute sequentially
|
||||
let parentFeature = feature;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const {
|
||||
id: _id,
|
||||
status: _status,
|
||||
startedAt: _startedAt,
|
||||
error: _error,
|
||||
summary: _summary,
|
||||
spec: _spec,
|
||||
passes: _passes,
|
||||
planSpec: _planSpec,
|
||||
descriptionHistory: _descriptionHistory,
|
||||
titleGenerating: _titleGenerating,
|
||||
...featureData
|
||||
} = parentFeature;
|
||||
|
||||
const duplicatedFeatureData = {
|
||||
...featureData,
|
||||
// Each duplicate depends on the previous one in the chain
|
||||
dependencies: [parentFeature.id],
|
||||
};
|
||||
|
||||
await handleAddFeature(duplicatedFeatureData);
|
||||
|
||||
// Get the newly created feature (last added feature) to use as parent for next iteration
|
||||
const currentFeatures = useAppStore.getState().features;
|
||||
const newestFeature = currentFeatures[currentFeatures.length - 1];
|
||||
if (newestFeature) {
|
||||
parentFeature = newestFeature;
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Created ${count} chained duplicates`, {
|
||||
description: `Created ${count} sequential copies of: ${truncateDescription(feature.description || feature.title || '')}`,
|
||||
});
|
||||
},
|
||||
[handleAddFeature]
|
||||
);
|
||||
|
||||
return {
|
||||
handleAddFeature,
|
||||
handleUpdateFeature,
|
||||
@@ -1197,5 +1259,6 @@ export function useBoardActions({
|
||||
handleStartNextFeatures,
|
||||
handleArchiveAllVerified,
|
||||
handleDuplicateFeature,
|
||||
handleDuplicateAsChildMultiple,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,19 +180,17 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
(existing) => (existing ? existing.filter((f) => f.id !== featureId) : existing)
|
||||
);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
// Rollback optimistic deletion since we can't persist
|
||||
if (previousFeatures) {
|
||||
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
throw new Error('Features API not available');
|
||||
}
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
// Rollback optimistic deletion since we can't persist
|
||||
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
throw new Error('Features API not available');
|
||||
}
|
||||
|
||||
try {
|
||||
await api.features.delete(currentProject.path, featureId);
|
||||
// Invalidate to sync with server state
|
||||
queryClient.invalidateQueries({
|
||||
@@ -207,6 +205,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[currentProject, queryClient]
|
||||
|
||||
@@ -48,6 +48,7 @@ interface KanbanBoardProps {
|
||||
onSpawnTask?: (feature: Feature) => void;
|
||||
onDuplicate?: (feature: Feature) => void;
|
||||
onDuplicateAsChild?: (feature: Feature) => void;
|
||||
onDuplicateAsChildMultiple?: (feature: Feature) => void;
|
||||
featuresWithContext: Set<string>;
|
||||
runningAutoTasks: string[];
|
||||
onArchiveAllVerified: () => void;
|
||||
@@ -286,6 +287,7 @@ export function KanbanBoard({
|
||||
onSpawnTask,
|
||||
onDuplicate,
|
||||
onDuplicateAsChild,
|
||||
onDuplicateAsChildMultiple,
|
||||
featuresWithContext,
|
||||
runningAutoTasks,
|
||||
onArchiveAllVerified,
|
||||
@@ -575,6 +577,11 @@ export function KanbanBoard({
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
onDuplicate={() => onDuplicate?.(feature)}
|
||||
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
|
||||
onDuplicateAsChildMultiple={
|
||||
onDuplicateAsChildMultiple
|
||||
? () => onDuplicateAsChildMultiple(feature)
|
||||
: undefined
|
||||
}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
@@ -619,6 +626,11 @@ export function KanbanBoard({
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
onDuplicate={() => onDuplicate?.(feature)}
|
||||
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
|
||||
onDuplicateAsChildMultiple={
|
||||
onDuplicateAsChildMultiple
|
||||
? () => onDuplicateAsChildMultiple(feature)
|
||||
: undefined
|
||||
}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
|
||||
@@ -34,9 +34,13 @@ import {
|
||||
Undo2,
|
||||
Zap,
|
||||
FlaskConical,
|
||||
History,
|
||||
Archive,
|
||||
Cherry,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus, TestSessionInfo } from '../types';
|
||||
import { TooltipWrapper } from './tooltip-wrapper';
|
||||
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
||||
@@ -60,6 +64,8 @@ interface WorktreeActionsDropdownProps {
|
||||
isDevServerRunning: boolean;
|
||||
devServerInfo?: DevServerInfo;
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
/** When true, git repo status is still being loaded */
|
||||
isLoadingGitStatus?: boolean;
|
||||
/** When true, renders as a standalone button (not attached to another element) */
|
||||
standalone?: boolean;
|
||||
/** Whether auto mode is running for this worktree */
|
||||
@@ -80,6 +86,7 @@ interface WorktreeActionsDropdownProps {
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onViewChanges: (worktree: WorktreeInfo) => void;
|
||||
onViewCommits: (worktree: WorktreeInfo) => void;
|
||||
onDiscardChanges: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
@@ -99,6 +106,12 @@ interface WorktreeActionsDropdownProps {
|
||||
onStopTests?: (worktree: WorktreeInfo) => void;
|
||||
/** View test logs for this worktree */
|
||||
onViewTestLogs?: (worktree: WorktreeInfo) => void;
|
||||
/** Stash changes for this worktree */
|
||||
onStashChanges?: (worktree: WorktreeInfo) => void;
|
||||
/** View stashes for this worktree */
|
||||
onViewStashes?: (worktree: WorktreeInfo) => void;
|
||||
/** Cherry-pick commits from another branch */
|
||||
onCherryPick?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
|
||||
@@ -114,6 +127,7 @@ export function WorktreeActionsDropdown({
|
||||
isDevServerRunning,
|
||||
devServerInfo,
|
||||
gitRepoStatus,
|
||||
isLoadingGitStatus = false,
|
||||
standalone = false,
|
||||
isAutoModeRunning = false,
|
||||
hasTestCommand = false,
|
||||
@@ -128,6 +142,7 @@ export function WorktreeActionsDropdown({
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onViewChanges,
|
||||
onViewCommits,
|
||||
onDiscardChanges,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
@@ -144,6 +159,9 @@ export function WorktreeActionsDropdown({
|
||||
onStartTests,
|
||||
onStopTests,
|
||||
onViewTestLogs,
|
||||
onStashChanges,
|
||||
onViewStashes,
|
||||
onCherryPick,
|
||||
hasInitScript,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
// Get available editors for the "Open In" submenu
|
||||
@@ -203,8 +221,18 @@ export function WorktreeActionsDropdown({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{/* Warning label when git operations are not available */}
|
||||
{!canPerformGitOps && (
|
||||
{/* Loading indicator while git status is being determined */}
|
||||
{isLoadingGitStatus && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-muted-foreground">
|
||||
<Spinner size="xs" variant="muted" />
|
||||
Checking git status...
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{/* Warning label when git operations are not available (only show once loaded) */}
|
||||
{!isLoadingGitStatus && !canPerformGitOps && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
@@ -387,10 +415,90 @@ export function WorktreeActionsDropdown({
|
||||
)}
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Pull & Resolve Conflicts
|
||||
Merge & Rebase
|
||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onViewCommits(worktree)}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<History className="w-3.5 h-3.5 mr-2" />
|
||||
View Commits
|
||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
{/* Cherry-pick commits from another branch */}
|
||||
{onCherryPick && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onCherryPick(worktree)}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Cherry className="w-3.5 h-3.5 mr-2" />
|
||||
Cherry Pick
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{/* Stash operations - combined submenu */}
|
||||
{(onStashChanges || onViewStashes) && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!gitRepoStatus.isGitRepo}
|
||||
tooltipContent="Not a git repository"
|
||||
>
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - stash changes (primary action) */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!gitRepoStatus.isGitRepo) return;
|
||||
if (worktree.hasChanges && onStashChanges) {
|
||||
onStashChanges(worktree);
|
||||
} else if (onViewStashes) {
|
||||
onViewStashes(worktree);
|
||||
}
|
||||
}}
|
||||
disabled={!gitRepoStatus.isGitRepo}
|
||||
className={cn(
|
||||
'text-xs flex-1 pr-0 rounded-r-none',
|
||||
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
|
||||
{!gitRepoStatus.isGitRepo && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{/* Chevron trigger for submenu with stash options */}
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(
|
||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
disabled={!gitRepoStatus.isGitRepo}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
{onViewStashes && (
|
||||
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||
View Stashes
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{/* Open in editor - split button: click main area for default, chevron for other options */}
|
||||
{effectiveDefaultEditor && (
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface WorktreeDropdownProps {
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onViewChanges: (worktree: WorktreeInfo) => void;
|
||||
onViewCommits: (worktree: WorktreeInfo) => void;
|
||||
onDiscardChanges: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
@@ -107,6 +108,12 @@ export interface WorktreeDropdownProps {
|
||||
onStartTests: (worktree: WorktreeInfo) => void;
|
||||
onStopTests: (worktree: WorktreeInfo) => void;
|
||||
onViewTestLogs: (worktree: WorktreeInfo) => void;
|
||||
/** Stash changes for this worktree */
|
||||
onStashChanges?: (worktree: WorktreeInfo) => void;
|
||||
/** View stashes for this worktree */
|
||||
onViewStashes?: (worktree: WorktreeInfo) => void;
|
||||
/** Cherry-pick commits from another branch */
|
||||
onCherryPick?: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,6 +175,7 @@ export function WorktreeDropdown({
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onViewChanges,
|
||||
onViewCommits,
|
||||
onDiscardChanges,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
@@ -184,6 +192,9 @@ export function WorktreeDropdown({
|
||||
onStartTests,
|
||||
onStopTests,
|
||||
onViewTestLogs,
|
||||
onStashChanges,
|
||||
onViewStashes,
|
||||
onCherryPick,
|
||||
}: WorktreeDropdownProps) {
|
||||
// Find the currently selected worktree to display in the trigger
|
||||
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
|
||||
@@ -442,6 +453,7 @@ export function WorktreeDropdown({
|
||||
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isLoadingGitStatus={isLoadingBranches}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
@@ -455,6 +467,7 @@ export function WorktreeDropdown({
|
||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||
onViewChanges={onViewChanges}
|
||||
onViewCommits={onViewCommits}
|
||||
onDiscardChanges={onDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
@@ -471,6 +484,9 @@ export function WorktreeDropdown({
|
||||
onStartTests={onStartTests}
|
||||
onStopTests={onStopTests}
|
||||
onViewTestLogs={onViewTestLogs}
|
||||
onStashChanges={onStashChanges}
|
||||
onViewStashes={onViewStashes}
|
||||
onCherryPick={onCherryPick}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -59,6 +59,7 @@ interface WorktreeTabProps {
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onViewChanges: (worktree: WorktreeInfo) => void;
|
||||
onViewCommits: (worktree: WorktreeInfo) => void;
|
||||
onDiscardChanges: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
@@ -78,6 +79,12 @@ interface WorktreeTabProps {
|
||||
onStopTests?: (worktree: WorktreeInfo) => void;
|
||||
/** View test logs for this worktree */
|
||||
onViewTestLogs?: (worktree: WorktreeInfo) => void;
|
||||
/** Stash changes for this worktree */
|
||||
onStashChanges?: (worktree: WorktreeInfo) => void;
|
||||
/** View stashes for this worktree */
|
||||
onViewStashes?: (worktree: WorktreeInfo) => void;
|
||||
/** Cherry-pick commits from another branch */
|
||||
onCherryPick?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
/** Whether a test command is configured in project settings */
|
||||
hasTestCommand?: boolean;
|
||||
@@ -122,6 +129,7 @@ export function WorktreeTab({
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onViewChanges,
|
||||
onViewCommits,
|
||||
onDiscardChanges,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
@@ -138,6 +146,9 @@ export function WorktreeTab({
|
||||
onStartTests,
|
||||
onStopTests,
|
||||
onViewTestLogs,
|
||||
onStashChanges,
|
||||
onViewStashes,
|
||||
onCherryPick,
|
||||
hasInitScript,
|
||||
hasTestCommand = false,
|
||||
}: WorktreeTabProps) {
|
||||
@@ -418,6 +429,7 @@ export function WorktreeTab({
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
devServerInfo={devServerInfo}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isLoadingGitStatus={isLoadingBranches}
|
||||
isAutoModeRunning={isAutoModeRunning}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
@@ -431,6 +443,7 @@ export function WorktreeTab({
|
||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||
onViewChanges={onViewChanges}
|
||||
onViewCommits={onViewCommits}
|
||||
onDiscardChanges={onDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
@@ -447,6 +460,9 @@ export function WorktreeTab({
|
||||
onStartTests={onStartTests}
|
||||
onStopTests={onStopTests}
|
||||
onViewTestLogs={onViewTestLogs}
|
||||
onStashChanges={onStashChanges}
|
||||
onViewStashes={onViewStashes}
|
||||
onCherryPick={onCherryPick}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -46,18 +46,22 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
||||
);
|
||||
|
||||
const handlePull = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
async (worktree: WorktreeInfo, remote?: string) => {
|
||||
if (pullMutation.isPending) return;
|
||||
pullMutation.mutate(worktree.path);
|
||||
pullMutation.mutate({
|
||||
worktreePath: worktree.path,
|
||||
remote,
|
||||
});
|
||||
},
|
||||
[pullMutation]
|
||||
);
|
||||
|
||||
const handlePush = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
async (worktree: WorktreeInfo, remote?: string) => {
|
||||
if (pushMutation.isPending) return;
|
||||
pushMutation.mutate({
|
||||
worktreePath: worktree.path,
|
||||
remote,
|
||||
});
|
||||
},
|
||||
[pushMutation]
|
||||
|
||||
@@ -33,10 +33,16 @@ import {
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import {
|
||||
ViewWorktreeChangesDialog,
|
||||
ViewCommitsDialog,
|
||||
PushToRemoteDialog,
|
||||
MergeWorktreeDialog,
|
||||
DiscardWorktreeChangesDialog,
|
||||
SelectRemoteDialog,
|
||||
StashChangesDialog,
|
||||
ViewStashesDialog,
|
||||
CherryPickDialog,
|
||||
} from '../dialogs';
|
||||
import type { SelectRemoteOperation } from '../dialogs';
|
||||
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
@@ -380,6 +386,10 @@ export function WorktreePanel({
|
||||
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
||||
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// View commits dialog state
|
||||
const [viewCommitsDialogOpen, setViewCommitsDialogOpen] = useState(false);
|
||||
const [viewCommitsWorktree, setViewCommitsWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Discard changes confirmation dialog state
|
||||
const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false);
|
||||
const [discardChangesWorktree, setDiscardChangesWorktree] = useState<WorktreeInfo | null>(null);
|
||||
@@ -396,6 +406,21 @@ export function WorktreePanel({
|
||||
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
|
||||
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Select remote dialog state (for pull/push with multiple remotes)
|
||||
const [selectRemoteDialogOpen, setSelectRemoteDialogOpen] = useState(false);
|
||||
const [selectRemoteWorktree, setSelectRemoteWorktree] = useState<WorktreeInfo | null>(null);
|
||||
const [selectRemoteOperation, setSelectRemoteOperation] = useState<SelectRemoteOperation>('pull');
|
||||
|
||||
// Stash dialog states
|
||||
const [stashChangesDialogOpen, setStashChangesDialogOpen] = useState(false);
|
||||
const [stashChangesWorktree, setStashChangesWorktree] = useState<WorktreeInfo | null>(null);
|
||||
const [viewStashesDialogOpen, setViewStashesDialogOpen] = useState(false);
|
||||
const [viewStashesWorktree, setViewStashesWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Cherry-pick dialog states
|
||||
const [cherryPickDialogOpen, setCherryPickDialogOpen] = useState(false);
|
||||
const [cherryPickWorktree, setCherryPickWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Periodic interval check (30 seconds) to detect branch changes on disk
|
||||
@@ -464,6 +489,11 @@ export function WorktreePanel({
|
||||
setViewChangesDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleViewCommits = useCallback((worktree: WorktreeInfo) => {
|
||||
setViewCommitsWorktree(worktree);
|
||||
setViewCommitsDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDiscardChanges = useCallback((worktree: WorktreeInfo) => {
|
||||
setDiscardChangesWorktree(worktree);
|
||||
setDiscardChangesDialogOpen(true);
|
||||
@@ -473,6 +503,36 @@ export function WorktreePanel({
|
||||
fetchWorktrees({ silent: true });
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
// Handle stash changes dialog
|
||||
const handleStashChanges = useCallback((worktree: WorktreeInfo) => {
|
||||
setStashChangesWorktree(worktree);
|
||||
setStashChangesDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleStashCompleted = useCallback(() => {
|
||||
fetchWorktrees({ silent: true });
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
// Handle view stashes dialog
|
||||
const handleViewStashes = useCallback((worktree: WorktreeInfo) => {
|
||||
setViewStashesWorktree(worktree);
|
||||
setViewStashesDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleStashApplied = useCallback(() => {
|
||||
fetchWorktrees({ silent: true });
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
// Handle cherry-pick dialog
|
||||
const handleCherryPick = useCallback((worktree: WorktreeInfo) => {
|
||||
setCherryPickWorktree(worktree);
|
||||
setCherryPickDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCherryPicked = useCallback(() => {
|
||||
fetchWorktrees({ silent: true });
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
// Handle opening the log panel for a specific worktree
|
||||
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
|
||||
setLogPanelWorktree(worktree);
|
||||
@@ -491,6 +551,68 @@ export function WorktreePanel({
|
||||
setPushToRemoteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle pull with remote selection when multiple remotes exist
|
||||
const handlePullWithRemoteSelection = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||
// Multiple remotes - show selection dialog
|
||||
setSelectRemoteWorktree(worktree);
|
||||
setSelectRemoteOperation('pull');
|
||||
setSelectRemoteDialogOpen(true);
|
||||
} else {
|
||||
// Single or no remote - proceed with default behavior
|
||||
handlePull(worktree);
|
||||
}
|
||||
} catch {
|
||||
// If listing remotes fails, fall back to default behavior
|
||||
handlePull(worktree);
|
||||
}
|
||||
},
|
||||
[handlePull]
|
||||
);
|
||||
|
||||
// Handle push with remote selection when multiple remotes exist
|
||||
const handlePushWithRemoteSelection = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||
// Multiple remotes - show selection dialog
|
||||
setSelectRemoteWorktree(worktree);
|
||||
setSelectRemoteOperation('push');
|
||||
setSelectRemoteDialogOpen(true);
|
||||
} else {
|
||||
// Single or no remote - proceed with default behavior
|
||||
handlePush(worktree);
|
||||
}
|
||||
} catch {
|
||||
// If listing remotes fails, fall back to default behavior
|
||||
handlePush(worktree);
|
||||
}
|
||||
},
|
||||
[handlePush]
|
||||
);
|
||||
|
||||
// Handle confirming remote selection for pull/push
|
||||
const handleConfirmSelectRemote = useCallback(
|
||||
async (worktree: WorktreeInfo, remote: string) => {
|
||||
if (selectRemoteOperation === 'pull') {
|
||||
handlePull(worktree, remote);
|
||||
} else {
|
||||
handlePush(worktree, remote);
|
||||
}
|
||||
fetchBranches(worktree.path);
|
||||
fetchWorktrees();
|
||||
},
|
||||
[selectRemoteOperation, handlePull, handlePush, fetchBranches, fetchWorktrees]
|
||||
);
|
||||
|
||||
// Handle confirming the push to remote dialog
|
||||
const handleConfirmPushToRemote = useCallback(
|
||||
async (worktree: WorktreeInfo, remote: string) => {
|
||||
@@ -585,19 +707,21 @@ export function WorktreePanel({
|
||||
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isLoadingGitStatus={isLoadingBranches}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPull={handlePullWithRemoteSelection}
|
||||
onPush={handlePushWithRemoteSelection}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onViewCommits={handleViewCommits}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
@@ -614,6 +738,9 @@ export function WorktreePanel({
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
@@ -656,6 +783,13 @@ export function WorktreePanel({
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
|
||||
{/* View Commits Dialog */}
|
||||
<ViewCommitsDialog
|
||||
open={viewCommitsDialogOpen}
|
||||
onOpenChange={setViewCommitsDialogOpen}
|
||||
worktree={viewCommitsWorktree}
|
||||
/>
|
||||
|
||||
{/* Discard Changes Dialog */}
|
||||
<DiscardWorktreeChangesDialog
|
||||
open={discardChangesDialogOpen}
|
||||
@@ -664,6 +798,31 @@ export function WorktreePanel({
|
||||
onDiscarded={handleDiscardCompleted}
|
||||
/>
|
||||
|
||||
{/* Stash Changes Dialog */}
|
||||
<StashChangesDialog
|
||||
open={stashChangesDialogOpen}
|
||||
onOpenChange={setStashChangesDialogOpen}
|
||||
worktree={stashChangesWorktree}
|
||||
onStashed={handleStashCompleted}
|
||||
/>
|
||||
|
||||
{/* View Stashes Dialog */}
|
||||
<ViewStashesDialog
|
||||
open={viewStashesDialogOpen}
|
||||
onOpenChange={setViewStashesDialogOpen}
|
||||
worktree={viewStashesWorktree}
|
||||
onStashApplied={handleStashApplied}
|
||||
/>
|
||||
|
||||
{/* Cherry Pick Dialog */}
|
||||
<CherryPickDialog
|
||||
open={cherryPickDialogOpen}
|
||||
onOpenChange={setCherryPickDialogOpen}
|
||||
worktree={cherryPickWorktree}
|
||||
onCherryPicked={handleCherryPicked}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Dev Server Logs Panel */}
|
||||
<DevServerLogsPanel
|
||||
open={logPanelOpen}
|
||||
@@ -681,6 +840,15 @@ export function WorktreePanel({
|
||||
onConfirm={handleConfirmPushToRemote}
|
||||
/>
|
||||
|
||||
{/* Select Remote Dialog (for pull/push with multiple remotes) */}
|
||||
<SelectRemoteDialog
|
||||
open={selectRemoteDialogOpen}
|
||||
onOpenChange={setSelectRemoteDialogOpen}
|
||||
worktree={selectRemoteWorktree}
|
||||
operation={selectRemoteOperation}
|
||||
onConfirm={handleConfirmSelectRemote}
|
||||
/>
|
||||
|
||||
{/* Merge Branch Dialog */}
|
||||
<MergeWorktreeDialog
|
||||
open={mergeDialogOpen}
|
||||
@@ -753,13 +921,14 @@ export function WorktreePanel({
|
||||
isStartingTests={isStartingTests}
|
||||
hasInitScript={hasInitScript}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPull={handlePullWithRemoteSelection}
|
||||
onPush={handlePushWithRemoteSelection}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onViewCommits={handleViewCommits}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
@@ -776,6 +945,9 @@ export function WorktreePanel({
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
onCherryPick={handleCherryPick}
|
||||
/>
|
||||
|
||||
{useWorktreesEnabled && (
|
||||
@@ -846,13 +1018,14 @@ export function WorktreePanel({
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPull={handlePullWithRemoteSelection}
|
||||
onPush={handlePushWithRemoteSelection}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onViewCommits={handleViewCommits}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
@@ -869,6 +1042,8 @@ export function WorktreePanel({
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
@@ -919,13 +1094,14 @@ export function WorktreePanel({
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPull={handlePullWithRemoteSelection}
|
||||
onPush={handlePushWithRemoteSelection}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onViewCommits={handleViewCommits}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
@@ -942,6 +1118,8 @@ export function WorktreePanel({
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
onStashChanges={handleStashChanges}
|
||||
onViewStashes={handleViewStashes}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
@@ -987,6 +1165,13 @@ export function WorktreePanel({
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
|
||||
{/* View Commits Dialog */}
|
||||
<ViewCommitsDialog
|
||||
open={viewCommitsDialogOpen}
|
||||
onOpenChange={setViewCommitsDialogOpen}
|
||||
worktree={viewCommitsWorktree}
|
||||
/>
|
||||
|
||||
{/* Discard Changes Dialog */}
|
||||
<DiscardWorktreeChangesDialog
|
||||
open={discardChangesDialogOpen}
|
||||
@@ -1012,6 +1197,15 @@ export function WorktreePanel({
|
||||
onConfirm={handleConfirmPushToRemote}
|
||||
/>
|
||||
|
||||
{/* Select Remote Dialog (for pull/push with multiple remotes) */}
|
||||
<SelectRemoteDialog
|
||||
open={selectRemoteDialogOpen}
|
||||
onOpenChange={setSelectRemoteDialogOpen}
|
||||
worktree={selectRemoteWorktree}
|
||||
operation={selectRemoteOperation}
|
||||
onConfirm={handleConfirmSelectRemote}
|
||||
/>
|
||||
|
||||
{/* Merge Branch Dialog */}
|
||||
<MergeWorktreeDialog
|
||||
open={mergeDialogOpen}
|
||||
@@ -1032,6 +1226,31 @@ export function WorktreePanel({
|
||||
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stash Changes Dialog */}
|
||||
<StashChangesDialog
|
||||
open={stashChangesDialogOpen}
|
||||
onOpenChange={setStashChangesDialogOpen}
|
||||
worktree={stashChangesWorktree}
|
||||
onStashed={handleStashCompleted}
|
||||
/>
|
||||
|
||||
{/* View Stashes Dialog */}
|
||||
<ViewStashesDialog
|
||||
open={viewStashesDialogOpen}
|
||||
onOpenChange={setViewStashesDialogOpen}
|
||||
worktree={viewStashesWorktree}
|
||||
onStashApplied={handleStashApplied}
|
||||
/>
|
||||
|
||||
{/* Cherry Pick Dialog */}
|
||||
<CherryPickDialog
|
||||
open={cherryPickDialogOpen}
|
||||
onOpenChange={setCherryPickDialogOpen}
|
||||
worktree={cherryPickWorktree}
|
||||
onCherryPicked={handleCherryPicked}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ export function LoginView() {
|
||||
|
||||
// Login form (awaiting_login or logging_in)
|
||||
const isLoggingIn = state.phase === 'logging_in';
|
||||
const apiKey = state.phase === 'awaiting_login' ? state.apiKey : state.apiKey;
|
||||
const apiKey = state.apiKey;
|
||||
const error = state.phase === 'awaiting_login' ? state.error : null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ProjectModelsSection } from './project-models-section';
|
||||
import { DataManagementSection } from './data-management-section';
|
||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||
import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-from-automaker-dialog';
|
||||
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
import { useProjectSettingsView } from './hooks/use-project-settings-view';
|
||||
import type { Project as ElectronProject } from '@/lib/electron';
|
||||
@@ -28,8 +29,9 @@ interface SettingsProject {
|
||||
}
|
||||
|
||||
export function ProjectSettingsView() {
|
||||
const { currentProject, moveProjectToTrash } = useAppStore();
|
||||
const { currentProject, moveProjectToTrash, removeProject } = useAppStore();
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
|
||||
|
||||
// Use project settings view navigation hook
|
||||
const { activeView, navigateTo } = useProjectSettingsView();
|
||||
@@ -98,6 +100,7 @@ export function ProjectSettingsView() {
|
||||
<DangerZoneSection
|
||||
project={settingsProject}
|
||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
||||
onRemoveFromAutomakerClick={() => setShowRemoveFromAutomakerDialog(true)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
@@ -178,6 +181,14 @@ export function ProjectSettingsView() {
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
|
||||
{/* Remove from Automaker Confirmation Dialog */}
|
||||
<RemoveFromAutomakerDialog
|
||||
open={showRemoveFromAutomakerDialog}
|
||||
onOpenChange={setShowRemoveFromAutomakerDialog}
|
||||
project={currentProject}
|
||||
onConfirm={removeProject}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
|
||||
import {
|
||||
GitBranch,
|
||||
@@ -11,6 +12,9 @@ import {
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
PanelBottomClose,
|
||||
Copy,
|
||||
Plus,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -19,6 +23,7 @@ import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { ProjectFileSelectorDialog } from '@/components/dialogs/project-file-selector-dialog';
|
||||
|
||||
interface WorktreePreferencesSectionProps {
|
||||
project: Project;
|
||||
@@ -42,6 +47,8 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
|
||||
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
|
||||
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
|
||||
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
|
||||
const getWorktreeCopyFiles = useAppStore((s) => s.getWorktreeCopyFiles);
|
||||
const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles);
|
||||
|
||||
// Get effective worktrees setting (project override or global fallback)
|
||||
const projectUseWorktrees = getProjectUseWorktrees(project.path);
|
||||
@@ -54,6 +61,11 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Copy files state
|
||||
const [newCopyFilePath, setNewCopyFilePath] = useState('');
|
||||
const [fileSelectorOpen, setFileSelectorOpen] = useState(false);
|
||||
const copyFiles = getWorktreeCopyFiles(project.path);
|
||||
|
||||
// Get the current settings for this project
|
||||
const showIndicator = getShowInitScriptIndicator(project.path);
|
||||
const defaultDeleteBranch = getDefaultDeleteBranch(project.path);
|
||||
@@ -93,6 +105,9 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
|
||||
response.settings.autoDismissInitScriptIndicator
|
||||
);
|
||||
}
|
||||
if (response.settings.worktreeCopyFiles !== undefined) {
|
||||
setWorktreeCopyFiles(currentPath, response.settings.worktreeCopyFiles);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
@@ -112,6 +127,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
|
||||
setShowInitScriptIndicator,
|
||||
setDefaultDeleteBranch,
|
||||
setAutoDismissInitScriptIndicator,
|
||||
setWorktreeCopyFiles,
|
||||
]);
|
||||
|
||||
// Load init script content when project changes
|
||||
@@ -219,6 +235,97 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
|
||||
setScriptContent(value);
|
||||
}, []);
|
||||
|
||||
// Add a new file path to copy list
|
||||
const handleAddCopyFile = useCallback(async () => {
|
||||
const trimmed = newCopyFilePath.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// Normalize: remove leading ./ or /
|
||||
const normalized = trimmed.replace(/^\.\//, '').replace(/^\//, '');
|
||||
if (!normalized) return;
|
||||
|
||||
// Check for duplicates
|
||||
const currentFiles = getWorktreeCopyFiles(project.path);
|
||||
if (currentFiles.includes(normalized)) {
|
||||
toast.error('File already in list', {
|
||||
description: `"${normalized}" is already configured for copying.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedFiles = [...currentFiles, normalized];
|
||||
setWorktreeCopyFiles(project.path, updatedFiles);
|
||||
setNewCopyFilePath('');
|
||||
|
||||
// Persist to server
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
await httpClient.settings.updateProject(project.path, {
|
||||
worktreeCopyFiles: updatedFiles,
|
||||
});
|
||||
toast.success('Copy file added', {
|
||||
description: `"${normalized}" will be copied to new worktrees.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist worktreeCopyFiles:', error);
|
||||
toast.error('Failed to save copy files setting');
|
||||
}
|
||||
}, [project.path, newCopyFilePath, getWorktreeCopyFiles, setWorktreeCopyFiles]);
|
||||
|
||||
// Remove a file path from copy list
|
||||
const handleRemoveCopyFile = useCallback(
|
||||
async (filePath: string) => {
|
||||
const currentFiles = getWorktreeCopyFiles(project.path);
|
||||
const updatedFiles = currentFiles.filter((f) => f !== filePath);
|
||||
setWorktreeCopyFiles(project.path, updatedFiles);
|
||||
|
||||
// Persist to server
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
await httpClient.settings.updateProject(project.path, {
|
||||
worktreeCopyFiles: updatedFiles,
|
||||
});
|
||||
toast.success('Copy file removed');
|
||||
} catch (error) {
|
||||
console.error('Failed to persist worktreeCopyFiles:', error);
|
||||
toast.error('Failed to save copy files setting');
|
||||
}
|
||||
},
|
||||
[project.path, getWorktreeCopyFiles, setWorktreeCopyFiles]
|
||||
);
|
||||
|
||||
// Handle files selected from the file selector dialog
|
||||
const handleFileSelectorSelect = useCallback(
|
||||
async (paths: string[]) => {
|
||||
const currentFiles = getWorktreeCopyFiles(project.path);
|
||||
|
||||
// Filter out duplicates
|
||||
const newPaths = paths.filter((p) => !currentFiles.includes(p));
|
||||
if (newPaths.length === 0) {
|
||||
toast.info('All selected files are already in the list');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedFiles = [...currentFiles, ...newPaths];
|
||||
setWorktreeCopyFiles(project.path, updatedFiles);
|
||||
|
||||
// Persist to server
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
await httpClient.settings.updateProject(project.path, {
|
||||
worktreeCopyFiles: updatedFiles,
|
||||
});
|
||||
toast.success(`${newPaths.length} ${newPaths.length === 1 ? 'file' : 'files'} added`, {
|
||||
description: newPaths.map((p) => `"${p}"`).join(', '),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist worktreeCopyFiles:', error);
|
||||
toast.error('Failed to save copy files setting');
|
||||
}
|
||||
},
|
||||
[project.path, getWorktreeCopyFiles, setWorktreeCopyFiles]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -387,6 +494,92 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Copy Files Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Copy className="w-4 h-4 text-brand-500" />
|
||||
<Label className="text-foreground font-medium">Copy Files to Worktrees</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Specify files or directories (relative to project root) to automatically copy into new
|
||||
worktrees. Useful for untracked files like{' '}
|
||||
<code className="font-mono text-foreground/60">.env</code>,{' '}
|
||||
<code className="font-mono text-foreground/60">.env.local</code>, or local config files
|
||||
that aren't committed to git.
|
||||
</p>
|
||||
|
||||
{/* Current file list */}
|
||||
{copyFiles.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{copyFiles.map((filePath) => (
|
||||
<div
|
||||
key={filePath}
|
||||
className="flex items-center gap-2 group/item px-3 py-1.5 rounded-lg bg-accent/20 hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<FileCode className="w-3.5 h-3.5 text-muted-foreground/60 flex-shrink-0" />
|
||||
<code className="font-mono text-sm text-foreground/80 flex-1 truncate">
|
||||
{filePath}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => handleRemoveCopyFile(filePath)}
|
||||
className="p-0.5 rounded text-muted-foreground/50 hover:bg-destructive/10 hover:text-destructive transition-all flex-shrink-0"
|
||||
title={`Remove ${filePath}`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new file input */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newCopyFilePath}
|
||||
onChange={(e) => setNewCopyFilePath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddCopyFile();
|
||||
}
|
||||
}}
|
||||
placeholder=".env, config/local.json, etc."
|
||||
className="flex-1 h-8 text-sm font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddCopyFile}
|
||||
disabled={!newCopyFilePath.trim()}
|
||||
className="gap-1.5 h-8"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFileSelectorOpen(true)}
|
||||
className="gap-1.5 h-8"
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* File selector dialog */}
|
||||
<ProjectFileSelectorDialog
|
||||
open={fileSelectorOpen}
|
||||
onOpenChange={setFileSelectorOpen}
|
||||
onSelect={handleFileSelectorSelect}
|
||||
projectPath={project.path}
|
||||
existingFiles={copyFiles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Init Script Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { DeleteProjectDialog } from './delete-project-dialog';
|
||||
export { RemoveFromAutomakerDialog } from './remove-from-automaker-dialog';
|
||||
export { KeyboardMapDialog } from './keyboard-map-dialog';
|
||||
export { SettingsHeader } from './settings-header';
|
||||
export { SettingsNavigation } from './settings-navigation';
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Folder, LogOut } from 'lucide-react';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface RemoveFromAutomakerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: Project | null;
|
||||
onConfirm: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export function RemoveFromAutomakerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
project,
|
||||
onConfirm,
|
||||
}: RemoveFromAutomakerDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
if (project) {
|
||||
onConfirm(project.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onConfirm={handleConfirm}
|
||||
title="Remove from Automaker"
|
||||
description="Remove this project from Automaker? The folder will remain on disk and can be re-added later."
|
||||
icon={LogOut}
|
||||
iconClassName="text-muted-foreground"
|
||||
confirmText="Remove from Automaker"
|
||||
confirmVariant="secondary"
|
||||
>
|
||||
{project && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<div className="w-10 h-10 rounded-lg bg-sidebar-accent/20 border border-sidebar-border flex items-center justify-center shrink-0">
|
||||
<Folder className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">{project.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{project.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2, Folder, AlertTriangle } from 'lucide-react';
|
||||
import { Trash2, Folder, AlertTriangle, LogOut } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Project } from '../shared/types';
|
||||
|
||||
interface DangerZoneSectionProps {
|
||||
project: Project | null;
|
||||
onDeleteClick: () => void;
|
||||
onRemoveFromAutomakerClick?: () => void;
|
||||
}
|
||||
|
||||
export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionProps) {
|
||||
export function DangerZoneSection({
|
||||
project,
|
||||
onDeleteClick,
|
||||
onRemoveFromAutomakerClick,
|
||||
}: DangerZoneSectionProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -28,33 +33,57 @@ export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionP
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">Destructive project actions.</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Project Delete */}
|
||||
{project ? (
|
||||
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
|
||||
<Folder className="w-5 h-5 text-brand-500" />
|
||||
<>
|
||||
{/* Remove from Automaker */}
|
||||
{onRemoveFromAutomakerClick && (
|
||||
<div className="flex items-start justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">Remove from Automaker</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Remove this project from Automaker without deleting any files from disk. You can
|
||||
re-add it later by opening the folder.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onRemoveFromAutomakerClick}
|
||||
data-testid="remove-from-automaker-button"
|
||||
className="shrink-0 transition-all duration-200 ease-out hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">{project.name}</p>
|
||||
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
|
||||
)}
|
||||
|
||||
{/* Project Delete / Move to Trash */}
|
||||
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
|
||||
<Folder className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">{project.name}</p>
|
||||
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onDeleteClick}
|
||||
data-testid="delete-project-button"
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Move to Trash
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onDeleteClick}
|
||||
data-testid="delete-project-button"
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Project
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground/60 text-center py-4">No project selected.</p>
|
||||
)}
|
||||
|
||||
@@ -88,6 +88,9 @@ export function MobileTerminalShortcuts({
|
||||
/** Handles arrow key press with long-press repeat support. */
|
||||
const handleArrowPress = useCallback(
|
||||
(data: string) => {
|
||||
// Cancel any in-flight timeout/interval before starting a new one
|
||||
// to prevent timer leaks when multiple touches occur.
|
||||
clearRepeat();
|
||||
sendKey(data);
|
||||
// Start repeat after 400ms hold, then every 80ms
|
||||
repeatTimeoutRef.current = setTimeout(() => {
|
||||
@@ -96,7 +99,7 @@ export function MobileTerminalShortcuts({
|
||||
}, 80);
|
||||
}, 400);
|
||||
},
|
||||
[sendKey]
|
||||
[clearRepeat, sendKey]
|
||||
);
|
||||
|
||||
const handleArrowRelease = useCallback(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { toast } from 'sonner';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
|
||||
/**
|
||||
* Start running a feature in auto mode
|
||||
@@ -159,9 +160,26 @@ export function useVerifyFeature(projectPath: string) {
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to verify feature');
|
||||
}
|
||||
return result;
|
||||
return { ...result, featureId };
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
// If verification passed, optimistically update React Query cache
|
||||
// to move the feature to 'verified' status immediately
|
||||
if (data.passes) {
|
||||
const previousFeatures = queryClient.getQueryData<Feature[]>(
|
||||
queryKeys.features.all(projectPath)
|
||||
);
|
||||
if (previousFeatures) {
|
||||
queryClient.setQueryData<Feature[]>(
|
||||
queryKeys.features.all(projectPath),
|
||||
previousFeatures.map((f) =>
|
||||
f.id === data.featureId
|
||||
? { ...f, status: 'verified' as const, justFinishedAt: undefined }
|
||||
: f
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
|
||||
@@ -126,10 +126,18 @@ export function usePushWorktree() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ worktreePath, force }: { worktreePath: string; force?: boolean }) => {
|
||||
mutationFn: async ({
|
||||
worktreePath,
|
||||
force,
|
||||
remote,
|
||||
}: {
|
||||
worktreePath: string;
|
||||
force?: boolean;
|
||||
remote?: string;
|
||||
}) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.worktree) throw new Error('Worktree API not available');
|
||||
const result = await api.worktree.push(worktreePath, force);
|
||||
const result = await api.worktree.push(worktreePath, force, remote);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to push changes');
|
||||
}
|
||||
@@ -156,10 +164,10 @@ export function usePullWorktree() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (worktreePath: string) => {
|
||||
mutationFn: async ({ worktreePath, remote }: { worktreePath: string; remote?: string }) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.worktree) throw new Error('Worktree API not available');
|
||||
const result = await api.worktree.pull(worktreePath);
|
||||
const result = await api.worktree.pull(worktreePath, remote);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to pull changes');
|
||||
}
|
||||
@@ -283,17 +291,6 @@ export function useMergeWorktree(projectPath: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from the switch branch API call
|
||||
*/
|
||||
interface SwitchBranchResult {
|
||||
previousBranch: string;
|
||||
currentBranch: string;
|
||||
message: string;
|
||||
hasConflicts?: boolean;
|
||||
stashedChanges?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different branch
|
||||
*
|
||||
@@ -316,14 +313,17 @@ export function useSwitchBranch(options?: {
|
||||
}: {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
}): Promise<SwitchBranchResult> => {
|
||||
}) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.worktree) throw new Error('Worktree API not available');
|
||||
const result = await api.worktree.switchBranch(worktreePath, branchName);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to switch branch');
|
||||
}
|
||||
return result.result as SwitchBranchResult;
|
||||
if (!result.result) {
|
||||
throw new Error('Switch branch returned no result');
|
||||
}
|
||||
return result.result;
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
|
||||
@@ -388,6 +388,36 @@ export function useCheckoutBranch() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a PR title and description from branch diff
|
||||
*
|
||||
* @returns Mutation for generating a PR description
|
||||
*/
|
||||
export function useGeneratePRDescription() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
worktreePath,
|
||||
baseBranch,
|
||||
}: {
|
||||
worktreePath: string;
|
||||
baseBranch?: string;
|
||||
}) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.worktree) throw new Error('Worktree API not available');
|
||||
const result = await api.worktree.generatePRDescription(worktreePath, baseBranch);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to generate PR description');
|
||||
}
|
||||
return { title: result.title ?? '', body: result.body ?? '' };
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Failed to generate PR description', {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a commit message from git diff
|
||||
*
|
||||
|
||||
@@ -144,8 +144,10 @@ export function useGeminiUsage(enabled = true) {
|
||||
throw new Error('Gemini API bridge unavailable');
|
||||
}
|
||||
const result = await api.gemini.getUsage();
|
||||
// Server always returns a response with 'authenticated' field, even on error
|
||||
// So we can safely cast to GeminiUsage
|
||||
// Check if result is an error-only response (no 'authenticated' field means it's the error variant)
|
||||
if (!('authenticated' in result) && 'error' in result) {
|
||||
throw new Error(result.message || result.error);
|
||||
}
|
||||
return result as GeminiUsage;
|
||||
},
|
||||
enabled,
|
||||
|
||||
@@ -86,6 +86,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch,
|
||||
globalMaxConcurrency,
|
||||
} = useAppStore(
|
||||
useShallow((state) => ({
|
||||
autoModeByWorktree: state.autoModeByWorktree,
|
||||
@@ -100,6 +101,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
|
||||
globalMaxConcurrency: state.maxConcurrency,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -143,11 +145,13 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
|
||||
const isAutoModeRunning = worktreeAutoModeState.isRunning;
|
||||
const runningAutoTasks = worktreeAutoModeState.runningTasks;
|
||||
// Use getMaxConcurrencyForWorktree which properly falls back to the global
|
||||
// maxConcurrency setting, instead of DEFAULT_MAX_CONCURRENCY (1) which would
|
||||
// incorrectly block agents when the user has set a higher global limit
|
||||
// Use the subscribed worktreeAutoModeState.maxConcurrency (from the reactive
|
||||
// autoModeByWorktree store slice) so canStartNewTask stays reactive when
|
||||
// refreshStatus updates worktree state or when the global setting changes.
|
||||
// Falls back to the subscribed globalMaxConcurrency (also reactive) when no
|
||||
// per-worktree value is set, and to DEFAULT_MAX_CONCURRENCY when no project.
|
||||
const maxConcurrency = projectId
|
||||
? getMaxConcurrencyForWorktree(projectId, branchName)
|
||||
? (worktreeAutoModeState.maxConcurrency ?? globalMaxConcurrency)
|
||||
: DEFAULT_MAX_CONCURRENCY;
|
||||
|
||||
// Check if we can start a new task based on concurrency limit
|
||||
|
||||
@@ -25,6 +25,7 @@ export function useProjectSettingsLoader() {
|
||||
const setAutoDismissInitScriptIndicator = useAppStore(
|
||||
(state) => state.setAutoDismissInitScriptIndicator
|
||||
);
|
||||
const setWorktreeCopyFiles = useAppStore((state) => state.setWorktreeCopyFiles);
|
||||
const setCurrentProject = useAppStore((state) => state.setCurrentProject);
|
||||
|
||||
const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null);
|
||||
@@ -95,6 +96,11 @@ export function useProjectSettingsLoader() {
|
||||
setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator);
|
||||
}
|
||||
|
||||
// Apply worktreeCopyFiles if present
|
||||
if (settings.worktreeCopyFiles !== undefined) {
|
||||
setWorktreeCopyFiles(projectPath, settings.worktreeCopyFiles);
|
||||
}
|
||||
|
||||
// Apply activeClaudeApiProfileId and phaseModelOverrides if present
|
||||
// These are stored directly on the project, so we need to update both
|
||||
// currentProject AND the projects array to keep them in sync
|
||||
@@ -152,6 +158,7 @@ export function useProjectSettingsLoader() {
|
||||
setShowInitScriptIndicator,
|
||||
setDefaultDeleteBranch,
|
||||
setAutoDismissInitScriptIndicator,
|
||||
setWorktreeCopyFiles,
|
||||
setCurrentProject,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
type ClaudeAuthMethod,
|
||||
type CodexAuthMethod,
|
||||
type ZaiAuthMethod,
|
||||
type GeminiAuthMethod,
|
||||
} from '@/store/setup-store';
|
||||
import type { GeminiAuthStatus } from '@automaker/types';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
|
||||
@@ -159,11 +159,16 @@ export function useProviderAuthInit() {
|
||||
// Set Auth status - always set a status to mark initialization as complete
|
||||
if (result.auth) {
|
||||
const auth = result.auth;
|
||||
const validMethods: GeminiAuthMethod[] = ['cli_login', 'api_key_env', 'api_key', 'none'];
|
||||
const validMethods: GeminiAuthStatus['method'][] = [
|
||||
'google_login',
|
||||
'api_key',
|
||||
'vertex_ai',
|
||||
'none',
|
||||
];
|
||||
|
||||
const method = validMethods.includes(auth.method as GeminiAuthMethod)
|
||||
? (auth.method as GeminiAuthMethod)
|
||||
: ((auth.authenticated ? 'cli_login' : 'none') as GeminiAuthMethod);
|
||||
const method = validMethods.includes(auth.method as GeminiAuthStatus['method'])
|
||||
? (auth.method as GeminiAuthStatus['method'])
|
||||
: ((auth.authenticated ? 'google_login' : 'none') as GeminiAuthStatus['method']);
|
||||
|
||||
setGeminiAuthStatus({
|
||||
authenticated: auth.authenticated,
|
||||
|
||||
@@ -202,6 +202,7 @@ export interface CreatePROptions {
|
||||
prBody?: string;
|
||||
baseBranch?: string;
|
||||
draft?: boolean;
|
||||
remote?: string;
|
||||
}
|
||||
|
||||
// Re-export types from electron.d.ts for external use
|
||||
@@ -2195,6 +2196,15 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
generatePRDescription: async (worktreePath: string, baseBranch?: string) => {
|
||||
console.log('[Mock] Generating PR description for:', { worktreePath, baseBranch });
|
||||
return {
|
||||
success: true,
|
||||
title: 'Add new feature implementation',
|
||||
body: '## Summary\n- Added new feature\n\n## Changes\n- Implementation details here',
|
||||
};
|
||||
},
|
||||
|
||||
push: async (worktreePath: string, force?: boolean, remote?: string) => {
|
||||
const targetRemote = remote || 'origin';
|
||||
console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote });
|
||||
@@ -2249,22 +2259,24 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
pull: async (worktreePath: string) => {
|
||||
console.log('[Mock] Pulling latest changes for:', worktreePath);
|
||||
pull: async (worktreePath: string, remote?: string) => {
|
||||
const targetRemote = remote || 'origin';
|
||||
console.log('[Mock] Pulling latest changes for:', { worktreePath, remote: targetRemote });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
branch: 'main',
|
||||
pulled: true,
|
||||
message: 'Pulled latest changes',
|
||||
message: `Pulled latest changes from ${targetRemote}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
checkoutBranch: async (worktreePath: string, branchName: string) => {
|
||||
checkoutBranch: async (worktreePath: string, branchName: string, baseBranch?: string) => {
|
||||
console.log('[Mock] Creating and checking out branch:', {
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
@@ -2303,6 +2315,8 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
previousBranch: 'main',
|
||||
currentBranch: branchName,
|
||||
message: `Switched to branch '${branchName}'`,
|
||||
hasConflicts: false,
|
||||
stashedChanges: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -2631,6 +2645,101 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
console.log('[Mock] Unsubscribing from test runner events');
|
||||
};
|
||||
},
|
||||
|
||||
getCommitLog: async (worktreePath: string, limit?: number) => {
|
||||
console.log('[Mock] Getting commit log:', { worktreePath, limit });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
branch: 'main',
|
||||
commits: [
|
||||
{
|
||||
hash: 'abc1234567890',
|
||||
shortHash: 'abc1234',
|
||||
author: 'Mock User',
|
||||
authorEmail: 'mock@example.com',
|
||||
date: new Date().toISOString(),
|
||||
subject: 'Mock commit message',
|
||||
body: '',
|
||||
files: ['src/index.ts', 'package.json'],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
stashPush: async (worktreePath: string, message?: string, files?: string[]) => {
|
||||
console.log('[Mock] Stash push:', { worktreePath, message, files });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
stashed: true,
|
||||
branch: 'main',
|
||||
message: message || 'WIP on main',
|
||||
},
|
||||
};
|
||||
},
|
||||
stashList: async (worktreePath: string) => {
|
||||
console.log('[Mock] Stash list:', { worktreePath });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
stashes: [],
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
stashApply: async (worktreePath: string, stashIndex: number, pop?: boolean) => {
|
||||
console.log('[Mock] Stash apply:', { worktreePath, stashIndex, pop });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
applied: true,
|
||||
hasConflicts: false,
|
||||
operation: pop ? ('pop' as const) : ('apply' as const),
|
||||
stashIndex,
|
||||
message: `Stash ${pop ? 'popped' : 'applied'} successfully`,
|
||||
},
|
||||
};
|
||||
},
|
||||
stashDrop: async (worktreePath: string, stashIndex: number) => {
|
||||
console.log('[Mock] Stash drop:', { worktreePath, stashIndex });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
dropped: true,
|
||||
stashIndex,
|
||||
message: `Stash stash@{${stashIndex}} dropped successfully`,
|
||||
},
|
||||
};
|
||||
},
|
||||
cherryPick: async (
|
||||
worktreePath: string,
|
||||
commitHashes: string[],
|
||||
options?: { noCommit?: boolean }
|
||||
) => {
|
||||
console.log('[Mock] Cherry-pick:', { worktreePath, commitHashes, options });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
cherryPicked: true,
|
||||
commitHashes,
|
||||
branch: 'main',
|
||||
message: `Cherry-picked ${commitHashes.length} commit(s) successfully`,
|
||||
},
|
||||
};
|
||||
},
|
||||
getBranchCommitLog: async (worktreePath: string, branchName?: string, limit?: number) => {
|
||||
console.log('[Mock] Get branch commit log:', { worktreePath, branchName, limit });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
branch: branchName || 'main',
|
||||
commits: [],
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2121,6 +2121,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post('/api/worktree/commit', { worktreePath, message, files }),
|
||||
generateCommitMessage: (worktreePath: string) =>
|
||||
this.post('/api/worktree/generate-commit-message', { worktreePath }),
|
||||
generatePRDescription: (worktreePath: string, baseBranch?: string) =>
|
||||
this.post('/api/worktree/generate-pr-description', { worktreePath, baseBranch }),
|
||||
push: (worktreePath: string, force?: boolean, remote?: string) =>
|
||||
this.post('/api/worktree/push', { worktreePath, force, remote }),
|
||||
createPR: (worktreePath: string, options?: CreatePROptions) =>
|
||||
@@ -2133,9 +2135,10 @@ export class HttpApiClient implements ElectronAPI {
|
||||
featureId,
|
||||
filePath,
|
||||
}),
|
||||
pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }),
|
||||
checkoutBranch: (worktreePath: string, branchName: string) =>
|
||||
this.post('/api/worktree/checkout-branch', { worktreePath, branchName }),
|
||||
pull: (worktreePath: string, remote?: string) =>
|
||||
this.post('/api/worktree/pull', { worktreePath, remote }),
|
||||
checkoutBranch: (worktreePath: string, branchName: string, baseBranch?: string) =>
|
||||
this.post('/api/worktree/checkout-branch', { worktreePath, branchName, baseBranch }),
|
||||
listBranches: (worktreePath: string, includeRemote?: boolean) =>
|
||||
this.post('/api/worktree/list-branches', { worktreePath, includeRemote }),
|
||||
switchBranch: (worktreePath: string, branchName: string) =>
|
||||
@@ -2216,6 +2219,19 @@ export class HttpApiClient implements ElectronAPI {
|
||||
startTests: (worktreePath: string, options?: { projectPath?: string; testFile?: string }) =>
|
||||
this.post('/api/worktree/start-tests', { worktreePath, ...options }),
|
||||
stopTests: (sessionId: string) => this.post('/api/worktree/stop-tests', { sessionId }),
|
||||
getCommitLog: (worktreePath: string, limit?: number) =>
|
||||
this.post('/api/worktree/commit-log', { worktreePath, limit }),
|
||||
stashPush: (worktreePath: string, message?: string, files?: string[]) =>
|
||||
this.post('/api/worktree/stash-push', { worktreePath, message, files }),
|
||||
stashList: (worktreePath: string) => this.post('/api/worktree/stash-list', { worktreePath }),
|
||||
stashApply: (worktreePath: string, stashIndex: number, pop?: boolean) =>
|
||||
this.post('/api/worktree/stash-apply', { worktreePath, stashIndex, pop }),
|
||||
stashDrop: (worktreePath: string, stashIndex: number) =>
|
||||
this.post('/api/worktree/stash-drop', { worktreePath, stashIndex }),
|
||||
cherryPick: (worktreePath: string, commitHashes: string[], options?: { noCommit?: boolean }) =>
|
||||
this.post('/api/worktree/cherry-pick', { worktreePath, commitHashes, options }),
|
||||
getBranchCommitLog: (worktreePath: string, branchName?: string, limit?: number) =>
|
||||
this.post('/api/worktree/branch-commit-log', { worktreePath, branchName, limit }),
|
||||
getTestLogs: (worktreePath?: string, sessionId?: string): Promise<TestLogsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (worktreePath) params.append('worktreePath', worktreePath);
|
||||
@@ -2582,6 +2598,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
showInitScriptIndicator?: boolean;
|
||||
defaultDeleteBranchWithWorktree?: boolean;
|
||||
autoDismissInitScriptIndicator?: boolean;
|
||||
worktreeCopyFiles?: string[];
|
||||
lastSelectedSessionId?: string;
|
||||
testCommand?: string;
|
||||
};
|
||||
|
||||
@@ -335,6 +335,7 @@ const initialState: AppState = {
|
||||
defaultDeleteBranchByProject: {},
|
||||
autoDismissInitScriptIndicatorByProject: {},
|
||||
useWorktreesByProject: {},
|
||||
worktreeCopyFilesByProject: {},
|
||||
worktreePanelCollapsed: false,
|
||||
lastProjectDir: '',
|
||||
recentFolders: [],
|
||||
@@ -359,10 +360,15 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
removeProject: (projectId) =>
|
||||
removeProject: (projectId: string) => {
|
||||
set((state) => ({
|
||||
projects: state.projects.filter((p) => p.id !== projectId),
|
||||
})),
|
||||
currentProject: state.currentProject?.id === projectId ? null : state.currentProject,
|
||||
}));
|
||||
|
||||
// Persist to storage
|
||||
saveProjects(get().projects);
|
||||
},
|
||||
|
||||
moveProjectToTrash: (projectId: string) => {
|
||||
const project = get().projects.find((p) => p.id === projectId);
|
||||
@@ -2394,6 +2400,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
return projectOverride !== undefined ? projectOverride : get().useWorktrees;
|
||||
},
|
||||
|
||||
// Worktree Copy Files actions
|
||||
setWorktreeCopyFiles: (projectPath, files) =>
|
||||
set((state) => ({
|
||||
worktreeCopyFilesByProject: {
|
||||
...state.worktreeCopyFilesByProject,
|
||||
[projectPath]: files,
|
||||
},
|
||||
})),
|
||||
getWorktreeCopyFiles: (projectPath) => get().worktreeCopyFilesByProject[projectPath] ?? [],
|
||||
|
||||
// UI State actions
|
||||
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
|
||||
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { GeminiAuthStatus } from '@automaker/types';
|
||||
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
|
||||
|
||||
// CLI Installation Status
|
||||
@@ -127,21 +128,8 @@ export interface ZaiAuthStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Gemini Auth Method
|
||||
export type GeminiAuthMethod =
|
||||
| 'cli_login' // Gemini CLI is installed and authenticated
|
||||
| 'api_key_env' // GOOGLE_API_KEY or GEMINI_API_KEY environment variable
|
||||
| 'api_key' // Manually stored API key
|
||||
| 'none';
|
||||
|
||||
// Gemini Auth Status
|
||||
export interface GeminiAuthStatus {
|
||||
authenticated: boolean;
|
||||
method: GeminiAuthMethod;
|
||||
hasApiKey?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
// GeminiAuthStatus is imported from @automaker/types (method: 'google_login' | 'api_key' | 'vertex_ai' | 'none')
|
||||
export type { GeminiAuthStatus };
|
||||
|
||||
// Claude Auth Method - all possible authentication sources
|
||||
export type ClaudeAuthMethod =
|
||||
|
||||
@@ -341,6 +341,10 @@ export interface AppState {
|
||||
// undefined = use global setting, true/false = project-specific override
|
||||
useWorktreesByProject: Record<string, boolean | undefined>;
|
||||
|
||||
// Worktree Copy Files (per-project, keyed by project path)
|
||||
// List of relative file paths to copy from project root into new worktrees
|
||||
worktreeCopyFilesByProject: Record<string, string[]>;
|
||||
|
||||
// UI State (previously in localStorage, now synced via API)
|
||||
/** Whether worktree panel is collapsed in board view */
|
||||
worktreePanelCollapsed: boolean;
|
||||
@@ -756,6 +760,10 @@ export interface AppActions {
|
||||
getProjectUseWorktrees: (projectPath: string) => boolean | undefined; // undefined = using global
|
||||
getEffectiveUseWorktrees: (projectPath: string) => boolean; // Returns actual value (project or global fallback)
|
||||
|
||||
// Worktree Copy Files actions (per-project)
|
||||
setWorktreeCopyFiles: (projectPath: string, files: string[]) => void;
|
||||
getWorktreeCopyFiles: (projectPath: string) => string[];
|
||||
|
||||
// UI State actions (previously in localStorage, now synced via API)
|
||||
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
||||
setLastProjectDir: (dir: string) => void;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface UICacheState {
|
||||
/** ID of the currently selected project */
|
||||
@@ -82,13 +83,27 @@ export function syncUICache(appState: {
|
||||
worktreePanelCollapsed?: boolean;
|
||||
collapsedNavSections?: Record<string, boolean>;
|
||||
}): void {
|
||||
useUICacheStore.getState().updateFromAppStore({
|
||||
cachedProjectId: appState.currentProject?.id ?? null,
|
||||
cachedSidebarOpen: appState.sidebarOpen ?? true,
|
||||
cachedSidebarStyle: appState.sidebarStyle ?? 'unified',
|
||||
cachedWorktreePanelCollapsed: appState.worktreePanelCollapsed ?? false,
|
||||
cachedCollapsedNavSections: appState.collapsedNavSections ?? {},
|
||||
});
|
||||
const update: Partial<UICacheState> = {};
|
||||
|
||||
if ('currentProject' in appState) {
|
||||
update.cachedProjectId = appState.currentProject?.id ?? null;
|
||||
}
|
||||
if ('sidebarOpen' in appState) {
|
||||
update.cachedSidebarOpen = appState.sidebarOpen;
|
||||
}
|
||||
if ('sidebarStyle' in appState) {
|
||||
update.cachedSidebarStyle = appState.sidebarStyle;
|
||||
}
|
||||
if ('worktreePanelCollapsed' in appState) {
|
||||
update.cachedWorktreePanelCollapsed = appState.worktreePanelCollapsed;
|
||||
}
|
||||
if ('collapsedNavSections' in appState) {
|
||||
update.cachedCollapsedNavSections = appState.collapsedNavSections;
|
||||
}
|
||||
|
||||
if (Object.keys(update).length > 0) {
|
||||
useUICacheStore.getState().updateFromAppStore(update);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +115,7 @@ export function syncUICache(appState: {
|
||||
* This is reconciled later when hydrateStoreFromSettings() overwrites
|
||||
* the app store with authoritative server data.
|
||||
*
|
||||
* @param appStoreSetState - The setState function from the app store (avoids circular import)
|
||||
* @param appStoreSetState - The setState function from the app store
|
||||
*/
|
||||
export function restoreFromUICache(
|
||||
appStoreSetState: (state: Record<string, unknown>) => void
|
||||
@@ -112,12 +127,29 @@ export function restoreFromUICache(
|
||||
return false;
|
||||
}
|
||||
|
||||
appStoreSetState({
|
||||
// Attempt to resolve the cached project ID to a full project object.
|
||||
// At early startup the projects array may be empty (server data not yet loaded),
|
||||
// but if projects are already in the store (e.g. optimistic hydration has run)
|
||||
// this will restore the project context immediately so tab-discard recovery
|
||||
// does not lose the selected project when cached settings are missing.
|
||||
const existingProjects = useAppStore.getState().projects;
|
||||
const cachedProject = existingProjects.find((p) => p.id === cache.cachedProjectId) ?? null;
|
||||
|
||||
const stateUpdate: Record<string, unknown> = {
|
||||
sidebarOpen: cache.cachedSidebarOpen,
|
||||
sidebarStyle: cache.cachedSidebarStyle,
|
||||
worktreePanelCollapsed: cache.cachedWorktreePanelCollapsed,
|
||||
collapsedNavSections: cache.cachedCollapsedNavSections,
|
||||
});
|
||||
};
|
||||
|
||||
// Restore the project context when the project object is available.
|
||||
// When projects are not yet loaded (empty array), currentProject remains
|
||||
// null and will be properly set later by hydrateStoreFromSettings().
|
||||
if (cachedProject !== null) {
|
||||
stateUpdate.currentProject = cachedProject;
|
||||
}
|
||||
|
||||
appStoreSetState(stateUpdate);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -396,30 +396,19 @@
|
||||
|
||||
/* iOS Safari: position:fixed on body prevents pull-to-refresh and overscroll bounce.
|
||||
Scoped to touch devices only to avoid breaking desktop browser behaviours. */
|
||||
`@media` (hover: none) and (pointer: coarse) {
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
html,
|
||||
body {
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
/* App container: full viewport, no scroll, safe-area insets for notched devices */
|
||||
#app {
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* Prevent pull-to-refresh and rubber-band scrolling on mobile */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
body {
|
||||
/* iOS Safari specific: prevent overscroll bounce */
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area insets for devices with notches/home indicators (viewport-fit=cover) */
|
||||
#app {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
padding-left: env(safe-area-inset-left, 0px);
|
||||
@@ -559,6 +548,11 @@
|
||||
@apply backdrop-blur-md border-white/10;
|
||||
}
|
||||
|
||||
/* Disable iOS long-press context menu - apply only to non-interactive chrome elements */
|
||||
.no-touch-callout {
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.glass-subtle {
|
||||
@apply backdrop-blur-sm border-white/5;
|
||||
}
|
||||
|
||||
167
apps/ui/src/types/electron.d.ts
vendored
167
apps/ui/src/types/electron.d.ts
vendored
@@ -2,7 +2,12 @@
|
||||
* Electron API type definitions
|
||||
*/
|
||||
|
||||
import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
||||
import type {
|
||||
ClaudeUsageResponse,
|
||||
CodexUsageResponse,
|
||||
ZaiUsageResponse,
|
||||
GeminiUsageResponse,
|
||||
} from '@/store/app-store';
|
||||
import type { ParsedTask, FeatureStatusWithPipeline } from '@automaker/types';
|
||||
|
||||
export interface ImageAttachment {
|
||||
@@ -710,6 +715,16 @@ export interface ElectronAPI {
|
||||
getUsage: () => Promise<CodexUsageResponse>;
|
||||
};
|
||||
|
||||
// z.ai Usage API
|
||||
zai: {
|
||||
getUsage: () => Promise<ZaiUsageResponse>;
|
||||
};
|
||||
|
||||
// Gemini Usage API
|
||||
gemini: {
|
||||
getUsage: () => Promise<GeminiUsageResponse>;
|
||||
};
|
||||
|
||||
// Worktree Management APIs
|
||||
worktree: WorktreeAPI;
|
||||
|
||||
@@ -884,6 +899,17 @@ export interface WorktreeAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Generate an AI PR title and description from branch diff
|
||||
generatePRDescription: (
|
||||
worktreePath: string,
|
||||
baseBranch?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
title?: string;
|
||||
body?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Push a worktree branch to remote
|
||||
push: (
|
||||
worktreePath: string,
|
||||
@@ -910,6 +936,7 @@ export interface WorktreeAPI {
|
||||
prBody?: string;
|
||||
baseBranch?: string;
|
||||
draft?: boolean;
|
||||
remote?: string;
|
||||
}
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
@@ -940,7 +967,10 @@ export interface WorktreeAPI {
|
||||
) => Promise<FileDiffResult>;
|
||||
|
||||
// Pull latest changes from remote
|
||||
pull: (worktreePath: string) => Promise<{
|
||||
pull: (
|
||||
worktreePath: string,
|
||||
remote?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
branch: string;
|
||||
@@ -954,7 +984,8 @@ export interface WorktreeAPI {
|
||||
// Create and checkout a new branch
|
||||
checkoutBranch: (
|
||||
worktreePath: string,
|
||||
branchName: string
|
||||
branchName: string,
|
||||
baseBranch?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
@@ -998,6 +1029,8 @@ export interface WorktreeAPI {
|
||||
previousBranch: string;
|
||||
currentBranch: string;
|
||||
message: string;
|
||||
hasConflicts: boolean;
|
||||
stashedChanges: boolean;
|
||||
};
|
||||
error?: string;
|
||||
code?: 'NOT_GIT_REPO' | 'NO_COMMITS' | 'UNCOMMITTED_CHANGES';
|
||||
@@ -1388,6 +1421,134 @@ export interface WorktreeAPI {
|
||||
}
|
||||
) => void
|
||||
) => () => void;
|
||||
|
||||
// Get recent commit history for a worktree
|
||||
getCommitLog: (
|
||||
worktreePath: string,
|
||||
limit?: number
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
branch: string;
|
||||
commits: Array<{
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
author: string;
|
||||
authorEmail: string;
|
||||
date: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
files: string[];
|
||||
}>;
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Stash changes in a worktree (with optional message and optional file selection)
|
||||
stashPush: (
|
||||
worktreePath: string,
|
||||
message?: string,
|
||||
files?: string[]
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
stashed: boolean;
|
||||
branch?: string;
|
||||
message?: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// List all stashes in a worktree
|
||||
stashList: (worktreePath: string) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
stashes: Array<{
|
||||
index: number;
|
||||
message: string;
|
||||
branch: string;
|
||||
date: string;
|
||||
files: string[];
|
||||
}>;
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Apply or pop a stash entry
|
||||
stashApply: (
|
||||
worktreePath: string,
|
||||
stashIndex: number,
|
||||
pop?: boolean
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
applied: boolean;
|
||||
hasConflicts: boolean;
|
||||
operation: 'apply' | 'pop';
|
||||
stashIndex: number;
|
||||
message: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Drop (delete) a stash entry
|
||||
stashDrop: (
|
||||
worktreePath: string,
|
||||
stashIndex: number
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
dropped: boolean;
|
||||
stashIndex: number;
|
||||
message: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Cherry-pick one or more commits into the current branch
|
||||
cherryPick: (
|
||||
worktreePath: string,
|
||||
commitHashes: string[],
|
||||
options?: {
|
||||
noCommit?: boolean;
|
||||
}
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
cherryPicked: boolean;
|
||||
commitHashes: string[];
|
||||
branch: string;
|
||||
message: string;
|
||||
};
|
||||
error?: string;
|
||||
hasConflicts?: boolean;
|
||||
}>;
|
||||
|
||||
// Get commit log for a specific branch (not just the current one)
|
||||
getBranchCommitLog: (
|
||||
worktreePath: string,
|
||||
branchName?: string,
|
||||
limit?: number
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
branch: string;
|
||||
commits: Array<{
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
author: string;
|
||||
authorEmail: string;
|
||||
date: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
files: string[];
|
||||
}>;
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Test runner status type
|
||||
|
||||
Reference in New Issue
Block a user