mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-20 11:03:08 +00:00
refactor: Improve all git operations, add stash support, add improved pull request flow, add worktree file copy options, address code review comments, add cherry pick options
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user