mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-23 12:03:07 +00:00
* Changes from fix/bug-fixes * feat: Refactor worktree iteration and improve error logging across services * feat: Extract URL/port patterns to module level and fix abort condition * fix: Improve IPv6 loopback handling, select component layout, and terminal UI * feat: Add thinking level defaults and adjust list row padding * Update apps/ui/src/store/app-store.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * feat: Add worktree-aware terminal creation and split options, fix npm security issues from audit * feat: Add tracked remote detection to pull dialog flow * feat: Add merge state tracking to git operations * feat: Improve merge detection and add post-merge action preferences * Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Pass merge detection info to stash reapplication and handle merge state consistently * fix: Call onPulled callback in merge handlers and add validation checks --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
785 lines
29 KiB
TypeScript
785 lines
29 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } 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);
|
|
|
|
// Ref to track the latest fetchCommits request and ignore stale responses
|
|
const fetchCommitsRequestRef = useRef(0);
|
|
|
|
// 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);
|
|
setLoadingBranches(false);
|
|
}
|
|
}, [open]);
|
|
|
|
// Fetch remotes and local branches when dialog opens
|
|
useEffect(() => {
|
|
if (!open || !worktree) return;
|
|
|
|
let mounted = true;
|
|
|
|
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 (!mounted) return;
|
|
|
|
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) {
|
|
if (!mounted) return;
|
|
console.error('Failed to fetch branch data:', err);
|
|
} finally {
|
|
if (mounted) {
|
|
setLoadingBranches(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
fetchBranchData();
|
|
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, [open, worktree]);
|
|
|
|
// Fetch commits when branch is selected
|
|
const fetchCommits = useCallback(
|
|
async (limit: number = 30, append: boolean = false) => {
|
|
if (!worktree || !selectedBranch) return;
|
|
|
|
// Increment the request counter and capture the current request ID
|
|
const requestId = ++fetchCommitsRequestRef.current;
|
|
|
|
if (append) {
|
|
setLoadingMoreCommits(true);
|
|
} else {
|
|
setLoadingCommits(true);
|
|
setCommitsError(null);
|
|
setCommits([]);
|
|
setSelectedCommitHashes(new Set());
|
|
}
|
|
|
|
try {
|
|
const api = getHttpApiClient();
|
|
const result = await api.worktree.getBranchCommitLog(worktree.path, selectedBranch, limit);
|
|
|
|
// Ignore stale responses from superseded requests
|
|
if (requestId !== fetchCommitsRequestRef.current) return;
|
|
|
|
if (result.success && result.result) {
|
|
setCommits(result.result.commits);
|
|
// If we got exactly the limit, there may be more commits
|
|
setHasMoreCommits(result.result.commits.length >= limit);
|
|
} else if (!append) {
|
|
setCommitsError(result.error || 'Failed to load commits');
|
|
}
|
|
} catch (err) {
|
|
// Ignore stale responses from superseded requests
|
|
if (requestId !== fetchCommitsRequestRef.current) return;
|
|
if (!append) {
|
|
setCommitsError(err instanceof Error ? err.message : 'Failed to load commits');
|
|
}
|
|
} finally {
|
|
// Only update loading state if this is still the current request
|
|
if (requestId === fetchCommitsRequestRef.current) {
|
|
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.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 was aborted due to conflicts. No changes were applied.',
|
|
});
|
|
} 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 was aborted due to conflicts. No changes were applied.',
|
|
});
|
|
} 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,
|
|
operationType: 'cherry-pick',
|
|
});
|
|
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 cherry-pick 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-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Cherry className="w-5 h-5 text-foreground" />
|
|
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-foreground" />
|
|
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-foreground">
|
|
<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-foreground">
|
|
{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>
|
|
);
|
|
}
|