mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-23 12:03:07 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user