mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-21 11:23: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,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