/** * CheckpointPanel Component * * 检查点管理面板:显示所有检查点、创建/删除、查看差异、恢复 */ import { useState, useEffect, useCallback } from 'react'; import { X, RefreshCw, History, Plus, Trash2, ChevronDown, ChevronRight, FileText, Clock, AlertTriangle, RotateCcw, Undo2, Eye, } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { toast } from 'sonner'; import { cn } from '../utils/cn'; import { modalOverlay, modalContent, smoothTransition } from '../utils/animations'; import { Button } from '../primitives/Button'; import { Skeleton } from './Skeleton'; import { listCheckpoints, getCheckpointStats, createCheckpoint, deleteCheckpoint, getUnrevertStatus, unrevert, cleanupCheckpoints, type CheckpointListItem, type CheckpointStats, type CheckpointTrigger, type UnrevertStatus, } from '../api/client.js'; interface CheckpointPanelProps { onClose: () => void; /** 点击查看差异时触发 */ onViewDiff?: (checkpointId: string) => void; /** 点击恢复时触发 */ onRestore?: (checkpointId: string) => void; /** 是否启用响应式布局 */ responsive?: boolean; } // 触发类型图标和颜色 function getTriggerInfo(trigger: CheckpointTrigger) { switch (trigger) { case 'tool:write_file': return { icon: '🟢', label: 'Write File', color: 'text-green-400' }; case 'tool:edit_file': return { icon: '🟡', label: 'Edit File', color: 'text-yellow-400' }; case 'tool:delete_file': return { icon: '🔴', label: 'Delete File', color: 'text-red-400' }; case 'tool:move_file': case 'tool:copy_file': return { icon: '🟠', label: 'Move/Copy', color: 'text-orange-400' }; case 'tool:bash': return { icon: '⚡', label: 'Bash', color: 'text-purple-400' }; case 'manual': return { icon: '🔵', label: 'Manual', color: 'text-blue-400' }; case 'session_start': return { icon: '▶️', label: 'Session Start', color: 'text-cyan-400' }; case 'session_end': return { icon: '⏹️', label: 'Session End', color: 'text-cyan-400' }; case 'pre_rollback': return { icon: '🔙', label: 'Pre-Rollback', color: 'text-fg-muted' }; case 'auto': default: return { icon: '⚪', label: 'Auto', color: 'text-fg-muted' }; } } // 格式化时间 function formatTime(timestamp: number) { const date = new Date(timestamp); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / (1000 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffMins < 1) return 'Just now'; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } // 格式化完整时间 function formatFullTime(timestamp: number) { return new Date(timestamp).toLocaleString(); } // 按日期分组 function groupByDate(checkpoints: CheckpointListItem[]) { const groups: { label: string; items: CheckpointListItem[] }[] = []; const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); let currentLabel = ''; let currentItems: CheckpointListItem[] = []; for (const cp of checkpoints) { const cpDate = new Date(cp.timestamp); const cpDay = new Date(cpDate.getFullYear(), cpDate.getMonth(), cpDate.getDate()); let label: string; if (cpDay.getTime() === today.getTime()) { label = 'Today'; } else if (cpDay.getTime() === yesterday.getTime()) { label = 'Yesterday'; } else { label = cpDate.toLocaleDateString(); } if (label !== currentLabel) { if (currentItems.length > 0) { groups.push({ label: currentLabel, items: currentItems }); } currentLabel = label; currentItems = [cp]; } else { currentItems.push(cp); } } if (currentItems.length > 0) { groups.push({ label: currentLabel, items: currentItems }); } return groups; } export function CheckpointPanel({ onClose, onViewDiff, onRestore, responsive = false, }: CheckpointPanelProps) { // 数据状态 const [checkpoints, setCheckpoints] = useState([]); const [stats, setStats] = useState(null); const [unrevertStatus, setUnrevertStatus] = useState(null); const [expandedGroups, setExpandedGroups] = useState>(new Set(['Today'])); // UI 状态 const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [actionLoading, setActionLoading] = useState(null); const [creating, setCreating] = useState(false); const [cleaningUp, setCleaningUp] = useState(false); // 加载数据 const loadData = useCallback(async (showToast = false) => { try { const [cpResult, statsResult, unrevertResult] = await Promise.all([ listCheckpoints(), getCheckpointStats(), getUnrevertStatus(), ]); if (cpResult.success) { setCheckpoints(cpResult.data); } else { toast.error(cpResult.error || 'Failed to load checkpoints'); } if (statsResult.success) { setStats(statsResult.data); } if (unrevertResult.success) { setUnrevertStatus(unrevertResult.data || null); } if (showToast) { toast.success('Checkpoints refreshed'); } } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to load checkpoints'); } }, []); // 初始加载 useEffect(() => { setLoading(true); loadData().finally(() => setLoading(false)); }, [loadData]); // 刷新 const handleRefresh = async () => { setRefreshing(true); await loadData(true); setRefreshing(false); }; // 创建检查点 const handleCreate = async () => { setCreating(true); try { const result = await createCheckpoint({ description: 'Manual checkpoint', }); if (result.success) { toast.success('Checkpoint created'); await loadData(); } else { toast.error(result.error || 'Failed to create checkpoint'); } } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to create checkpoint'); } finally { setCreating(false); } }; // 删除检查点 const handleDelete = async (id: string) => { setActionLoading(id); try { const result = await deleteCheckpoint(id); if (result.success) { toast.success('Checkpoint deleted'); await loadData(); } else { toast.error(result.error || 'Failed to delete checkpoint'); } } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to delete checkpoint'); } finally { setActionLoading(null); } }; // 撤销回滚 const handleUnrevert = async () => { setActionLoading('unrevert'); try { const result = await unrevert(); if (result.success) { toast.success(`Unrevert successful: ${result.data?.filesRestored} files restored`); await loadData(); } else { toast.error(result.error || 'Failed to unrevert'); } } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to unrevert'); } finally { setActionLoading(null); } }; // 清理检查点 const handleCleanup = async () => { setCleaningUp(true); try { const result = await cleanupCheckpoints(); if (result.success) { toast.success(`Cleaned up ${result.data?.deleted || 0} checkpoints`); await loadData(); } else { toast.error(result.error || 'Failed to cleanup'); } } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to cleanup'); } finally { setCleaningUp(false); } }; // 切换分组展开 const toggleGroup = (label: string) => { const newExpanded = new Set(expandedGroups); if (newExpanded.has(label)) { newExpanded.delete(label); } else { newExpanded.add(label); } setExpandedGroups(newExpanded); }; // 分组数据 const groups = groupByDate(checkpoints); // Loading 骨架屏 const LoadingSkeleton = () => (
{[1, 2, 3, 4].map((i) => (
))}
); return ( e.stopPropagation()} className={cn( 'bg-surface-subtle max-h-[90vh] overflow-hidden flex flex-col', responsive ? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg' : 'rounded-lg w-full max-w-2xl mx-4' )} > {/* Header */}
{responsive && (
)}

Checkpoints

{stats ? `${stats.count} checkpoints` : 'Loading...'} {stats?.oldestTimestamp && ( <> · Oldest: {formatTime(stats.oldestTimestamp)} )}

{/* Unrevert Banner */} {unrevertStatus?.canUnrevert && unrevertStatus.lastRollback && (
Last rollback affected {unrevertStatus.lastRollback.restoredFiles.length} files
)} {/* Checkpoint List */}
{loading ? ( ) : checkpoints.length === 0 ? (

No checkpoints yet

Checkpoints are created automatically when files are modified

) : ( {groups.map((group) => { const isExpanded = expandedGroups.has(group.label); return (
{/* Group Header */} {/* Group Items */} {isExpanded && ( {group.items.map((cp) => { const triggerInfo = getTriggerInfo(cp.trigger); const isLoading = actionLoading === cp.id; return (
{/* Trigger Icon */} {triggerInfo.icon} {/* Info */}
{triggerInfo.label} {formatTime(cp.timestamp)}
{cp.description && (

{cp.description}

)}
{cp.filesChanged} files {formatFullTime(cp.timestamp)}
{/* Actions */}
{isLoading ? (
) : ( <> {onViewDiff && ( )} {onRestore && ( )} )}
); })} )}
); })}
)}
{/* Footer */}
Auto-cleanup enabled (7 days / 100 max)
); }