5b7b0ff1e4
- 添加 CSS 变量定义浅色和深色主题色板 - 扩展 Tailwind 配置支持语义化颜色 (surface-*, fg-*, line-*, code) - 创建 useTheme hook 管理主题状态和持久化 - 创建 ThemeToggle 组件支持三种模式 (light/dark/system) - 迁移所有组件从硬编码 gray-* 到语义化颜色 - 支持系统主题偏好检测 (prefers-color-scheme) - 添加主题初始化脚本防止闪烁 (FOUC)
610 lines
22 KiB
TypeScript
610 lines
22 KiB
TypeScript
/**
|
|
* 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<CheckpointListItem[]>([]);
|
|
const [stats, setStats] = useState<CheckpointStats | null>(null);
|
|
const [unrevertStatus, setUnrevertStatus] = useState<UnrevertStatus | null>(null);
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['Today']));
|
|
|
|
// UI 状态
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [actionLoading, setActionLoading] = useState<string | null>(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 = () => (
|
|
<div className="space-y-3 p-4">
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<div key={i} className="flex items-center gap-3 p-3 bg-surface-base/50 rounded-lg">
|
|
<Skeleton className="h-4 w-4 rounded" />
|
|
<div className="flex-1 space-y-2">
|
|
<Skeleton className="h-4 w-48" />
|
|
<Skeleton className="h-3 w-32" />
|
|
</div>
|
|
<Skeleton className="h-8 w-16" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
variants={modalOverlay}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
transition={{ duration: 0.2 }}
|
|
className={cn(
|
|
'fixed inset-0 bg-black/50 flex z-50',
|
|
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
|
)}
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
variants={modalContent}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
transition={smoothTransition}
|
|
onClick={(e) => 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 */}
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-between border-b border-line',
|
|
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
|
|
)}
|
|
>
|
|
{responsive && (
|
|
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-surface-emphasis rounded-full md:hidden" />
|
|
)}
|
|
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
<History size={20} className="text-primary-400" />
|
|
Checkpoints
|
|
</h2>
|
|
<p className="text-xs text-fg-subtle">
|
|
{stats ? `${stats.count} checkpoints` : 'Loading...'}
|
|
{stats?.oldestTimestamp && (
|
|
<> · Oldest: {formatTime(stats.oldestTimestamp)}</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleCreate}
|
|
disabled={creating}
|
|
title="Create checkpoint"
|
|
className={cn('text-green-400 hover:text-green-300', responsive && 'min-h-[44px]')}
|
|
>
|
|
{creating ? (
|
|
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-green-500" />
|
|
) : (
|
|
<>
|
|
<Plus size={16} className="mr-1" />
|
|
Create
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleRefresh}
|
|
disabled={refreshing}
|
|
title="Refresh"
|
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
|
>
|
|
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onClose}
|
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
|
>
|
|
<X size={20} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Unrevert Banner */}
|
|
{unrevertStatus?.canUnrevert && unrevertStatus.lastRollback && (
|
|
<div className="mx-4 mt-4 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-yellow-400">
|
|
<AlertTriangle size={16} />
|
|
<span className="text-sm">
|
|
Last rollback affected {unrevertStatus.lastRollback.restoredFiles.length} files
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleUnrevert}
|
|
disabled={actionLoading === 'unrevert'}
|
|
className="text-yellow-400 hover:text-yellow-300"
|
|
>
|
|
{actionLoading === 'unrevert' ? (
|
|
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-yellow-500" />
|
|
) : (
|
|
<>
|
|
<Undo2 size={14} className="mr-1" />
|
|
Unrevert
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Checkpoint List */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{loading ? (
|
|
<LoadingSkeleton />
|
|
) : checkpoints.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-fg-subtle">
|
|
<History size={48} className="mb-4 opacity-50" />
|
|
<p className="text-center">No checkpoints yet</p>
|
|
<p className="text-xs text-fg-subtle mt-2 text-center max-w-xs">
|
|
Checkpoints are created automatically when files are modified
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleCreate}
|
|
className="mt-4"
|
|
disabled={creating}
|
|
>
|
|
<Plus size={14} className="mr-1" />
|
|
Create First Checkpoint
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className={cn('space-y-2', responsive ? 'p-4' : 'p-4')}
|
|
>
|
|
{groups.map((group) => {
|
|
const isExpanded = expandedGroups.has(group.label);
|
|
|
|
return (
|
|
<div key={group.label} className="space-y-1">
|
|
{/* Group Header */}
|
|
<button
|
|
className="flex items-center gap-2 text-sm text-fg-muted hover:text-fg-secondary transition-colors w-full"
|
|
onClick={() => toggleGroup(group.label)}
|
|
>
|
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
<span className="font-medium">{group.label}</span>
|
|
<span className="text-xs text-fg-subtle">({group.items.length})</span>
|
|
</button>
|
|
|
|
{/* Group Items */}
|
|
<AnimatePresence>
|
|
{isExpanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="space-y-1 overflow-hidden"
|
|
>
|
|
{group.items.map((cp) => {
|
|
const triggerInfo = getTriggerInfo(cp.trigger);
|
|
const isLoading = actionLoading === cp.id;
|
|
|
|
return (
|
|
<motion.div
|
|
key={cp.id}
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
className="bg-surface-base/50 rounded-lg p-3 hover:bg-surface-base/80 transition-colors"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
{/* Trigger Icon */}
|
|
<span className="text-lg" title={triggerInfo.label}>
|
|
{triggerInfo.icon}
|
|
</span>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn('text-sm font-medium', triggerInfo.color)}>
|
|
{triggerInfo.label}
|
|
</span>
|
|
<span className="text-xs text-fg-subtle">
|
|
{formatTime(cp.timestamp)}
|
|
</span>
|
|
</div>
|
|
{cp.description && (
|
|
<p className="text-xs text-fg-muted mt-0.5 truncate">
|
|
{cp.description}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center gap-3 mt-1 text-xs text-fg-subtle">
|
|
<span className="flex items-center gap-1">
|
|
<FileText size={10} />
|
|
{cp.filesChanged} files
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Clock size={10} />
|
|
{formatFullTime(cp.timestamp)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-1">
|
|
{isLoading ? (
|
|
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-primary-500" />
|
|
) : (
|
|
<>
|
|
{onViewDiff && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => onViewDiff(cp.id)}
|
|
title="View diff"
|
|
className="text-blue-400 hover:text-blue-300"
|
|
>
|
|
<Eye size={14} />
|
|
</Button>
|
|
)}
|
|
{onRestore && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => onRestore(cp.id)}
|
|
title="Restore"
|
|
className="text-green-400 hover:text-green-300"
|
|
>
|
|
<RotateCcw size={14} />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleDelete(cp.id)}
|
|
title="Delete"
|
|
className="text-red-400 hover:text-red-300"
|
|
>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
})}
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-between border-t border-line',
|
|
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
|
)}
|
|
>
|
|
<span className="text-xs text-fg-subtle">
|
|
Auto-cleanup enabled (7 days / 100 max)
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleCleanup}
|
|
disabled={cleaningUp}
|
|
className="text-fg-muted hover:text-fg-secondary"
|
|
>
|
|
{cleaningUp ? (
|
|
<div className="animate-spin rounded-full h-3 w-3 border-t-2 border-b-2 border-fg-subtle mr-1" />
|
|
) : (
|
|
<Trash2 size={12} className="mr-1" />
|
|
)}
|
|
Cleanup Now
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
}
|