Files
automaker/apps/ui/src/components/views/board-view/dialogs/view-commits-dialog.tsx

324 lines
11 KiB
TypeScript

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>
);
}