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