feat(checkpoint): 添加 Checkpoint 可视化管理功能
Core 层增强:
- 添加 safety.ts: 7点安全检查机制
- 添加 session-tracker.ts: 会话级检查点跟踪
- 添加 lock.ts: 并发控制文件锁
- 添加 lfs.ts: Git LFS 大文件支持
- 添加 path-validator.ts: 路径验证
- 添加 commit-message.ts: 智能提交消息生成
- 增强 manager.ts: 支持三种恢复模式、unrevert 撤销回滚
Server 层:
- 添加 checkpoints.ts: 16个 REST API 端点
- GET/POST /checkpoints: 列表/创建检查点
- GET/DELETE /checkpoints/🆔 获取/删除检查点
- GET /checkpoints/:id/diff: 获取差异
- POST /checkpoints/:id/restore: 恢复到检查点
- POST /checkpoints/unrevert: 撤销回滚
- GET /checkpoints/:id/safety-check: 安全检查
UI 层:
- 添加 CheckpointPanel.tsx: 检查点列表面板
- 添加 CheckpointDiffViewer.tsx: 差异查看器
- 添加 RestoreDialog.tsx: 恢复确认对话框
- 添加 16 个 API 客户端函数
- 添加完整的 TypeScript 类型定义
Web/Desktop 集成:
- 添加 History 按钮到工具栏
- 集成 CheckpointPanel 组件
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* CheckpointDiffViewer Component
|
||||
*
|
||||
* 检查点差异查看器:显示检查点与当前工作区的差异
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
X,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
Check,
|
||||
RotateCcw,
|
||||
} 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 {
|
||||
getCheckpoint,
|
||||
getCheckpointDiff,
|
||||
getFileDiff,
|
||||
type CheckpointDetail,
|
||||
type DiffInfo,
|
||||
type FileDiffDetail,
|
||||
type FileChangeType,
|
||||
} from '../api/client.js';
|
||||
|
||||
interface CheckpointDiffViewerProps {
|
||||
/** 检查点 ID */
|
||||
checkpointId: string;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 恢复选中文件 */
|
||||
onRestoreSelected?: (checkpointId: string, files: string[]) => void;
|
||||
/** 恢复全部 */
|
||||
onRestoreAll?: (checkpointId: string) => void;
|
||||
/** 是否启用响应式布局 */
|
||||
responsive?: boolean;
|
||||
}
|
||||
|
||||
// 文件变更类型标签
|
||||
function getChangeLabel(type: FileChangeType) {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'A';
|
||||
case 'modified':
|
||||
return 'M';
|
||||
case 'deleted':
|
||||
return 'D';
|
||||
case 'renamed':
|
||||
return 'R';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
// 文件变更类型颜色
|
||||
function getChangeColor(type: FileChangeType) {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'text-green-400 bg-green-400/10';
|
||||
case 'modified':
|
||||
return 'text-yellow-400 bg-yellow-400/10';
|
||||
case 'deleted':
|
||||
return 'text-red-400 bg-red-400/10';
|
||||
case 'renamed':
|
||||
return 'text-blue-400 bg-blue-400/10';
|
||||
default:
|
||||
return 'text-gray-400 bg-gray-400/10';
|
||||
}
|
||||
}
|
||||
|
||||
export function CheckpointDiffViewer({
|
||||
checkpointId,
|
||||
onClose,
|
||||
onRestoreSelected,
|
||||
onRestoreAll,
|
||||
responsive = false,
|
||||
}: CheckpointDiffViewerProps) {
|
||||
// 数据状态
|
||||
const [checkpoint, setCheckpoint] = useState<CheckpointDetail | null>(null);
|
||||
const [diff, setDiff] = useState<DiffInfo | null>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
||||
const [fileDiff, setFileDiff] = useState<FileDiffDetail | null>(null);
|
||||
|
||||
// UI 状态
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loadingFileDiff, setLoadingFileDiff] = useState(false);
|
||||
|
||||
// 加载数据
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [cpResult, diffResult] = await Promise.all([
|
||||
getCheckpoint(checkpointId),
|
||||
getCheckpointDiff(checkpointId),
|
||||
]);
|
||||
|
||||
if (cpResult.success && cpResult.data) {
|
||||
setCheckpoint(cpResult.data);
|
||||
} else {
|
||||
toast.error(cpResult.error || 'Failed to load checkpoint');
|
||||
}
|
||||
|
||||
if (diffResult.success && diffResult.data) {
|
||||
setDiff(diffResult.data);
|
||||
// 默认全选
|
||||
setSelectedFiles(new Set(diffResult.data.files.map((f) => f.path)));
|
||||
} else {
|
||||
toast.error(diffResult.error || 'Failed to load diff');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load data');
|
||||
}
|
||||
}, [checkpointId]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadData().finally(() => setLoading(false));
|
||||
}, [loadData]);
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadData();
|
||||
setRefreshing(false);
|
||||
toast.success('Diff refreshed');
|
||||
};
|
||||
|
||||
// 切换文件选择
|
||||
const toggleFileSelection = (path: string) => {
|
||||
const newSelected = new Set(selectedFiles);
|
||||
if (newSelected.has(path)) {
|
||||
newSelected.delete(path);
|
||||
} else {
|
||||
newSelected.add(path);
|
||||
}
|
||||
setSelectedFiles(newSelected);
|
||||
};
|
||||
|
||||
// 全选/取消全选
|
||||
const toggleSelectAll = () => {
|
||||
if (diff) {
|
||||
if (selectedFiles.size === diff.files.length) {
|
||||
setSelectedFiles(new Set());
|
||||
} else {
|
||||
setSelectedFiles(new Set(diff.files.map((f) => f.path)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 查看文件差异
|
||||
const handleViewFileDiff = async (path: string) => {
|
||||
if (expandedFile === path) {
|
||||
setExpandedFile(null);
|
||||
setFileDiff(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedFile(path);
|
||||
setLoadingFileDiff(true);
|
||||
|
||||
try {
|
||||
const result = await getFileDiff(checkpointId, path);
|
||||
if (result.success && result.data) {
|
||||
setFileDiff(result.data);
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to load file diff');
|
||||
setFileDiff(null);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load file diff');
|
||||
setFileDiff(null);
|
||||
} finally {
|
||||
setLoadingFileDiff(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复选中文件
|
||||
const handleRestoreSelected = () => {
|
||||
if (onRestoreSelected && selectedFiles.size > 0) {
|
||||
onRestoreSelected(checkpointId, Array.from(selectedFiles));
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复全部
|
||||
const handleRestoreAll = () => {
|
||||
if (onRestoreAll) {
|
||||
onRestoreAll(checkpointId);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading 骨架屏
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="space-y-3 p-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-2 bg-gray-900/50 rounded">
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-16 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染 diff 补丁
|
||||
const renderPatch = (patch: string) => {
|
||||
const lines = patch.split('\n');
|
||||
return (
|
||||
<pre className="text-xs font-mono overflow-x-auto">
|
||||
{lines.map((line, index) => {
|
||||
let className = 'px-2 py-0.5';
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
className += ' bg-green-500/10 text-green-400';
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
className += ' bg-red-500/10 text-red-400';
|
||||
} else if (line.startsWith('@@')) {
|
||||
className += ' bg-blue-500/10 text-blue-400';
|
||||
} else {
|
||||
className += ' text-gray-400';
|
||||
}
|
||||
return (
|
||||
<div key={index} className={className}>
|
||||
{line || ' '}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
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-gray-800 max-h-[90vh] overflow-hidden flex flex-col',
|
||||
responsive
|
||||
? 'w-full md:w-full md:max-w-3xl md:mx-4 rounded-t-2xl md:rounded-lg'
|
||||
: 'rounded-lg w-full max-w-3xl mx-4'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between border-b border-gray-700',
|
||||
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-gray-600 rounded-full md:hidden" />
|
||||
)}
|
||||
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Eye size={20} className="text-primary-400" />
|
||||
Checkpoint Diff
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
{checkpoint ? (
|
||||
<>
|
||||
Comparing <code className="bg-gray-700 px-1 rounded">{checkpoint.commitHash.slice(0, 7)}</code> → Current
|
||||
</>
|
||||
) : (
|
||||
'Loading...'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
|
||||
{/* Summary */}
|
||||
{diff && (
|
||||
<div className="px-4 py-3 bg-gray-900/50 border-b border-gray-700">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-green-400">+{diff.totalInsertions}</span>
|
||||
<span className="text-red-400">-{diff.totalDeletions}</span>
|
||||
<span className="text-gray-400">across {diff.files.length} files</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
className="text-xs text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
{selectedFiles.size === diff.files.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<LoadingSkeleton />
|
||||
) : !diff || diff.files.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<Eye size={48} className="mb-4 opacity-50" />
|
||||
<p className="text-center">No changes detected</p>
|
||||
<p className="text-xs text-gray-600 mt-2 text-center">
|
||||
The workspace matches this checkpoint
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="divide-y divide-gray-700/50"
|
||||
>
|
||||
{diff.files.map((file) => {
|
||||
const isSelected = selectedFiles.has(file.path);
|
||||
const isExpanded = expandedFile === file.path;
|
||||
|
||||
return (
|
||||
<div key={file.path}>
|
||||
{/* File Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-2',
|
||||
'hover:bg-gray-900/50 transition-colors cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => toggleFileSelection(file.path)}
|
||||
className={cn(
|
||||
'w-4 h-4 rounded border transition-colors flex items-center justify-center',
|
||||
isSelected
|
||||
? 'bg-primary-500 border-primary-500'
|
||||
: 'border-gray-600 hover:border-gray-500'
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check size={12} className="text-white" />}
|
||||
</button>
|
||||
|
||||
{/* Expand Icon */}
|
||||
<button
|
||||
onClick={() => handleViewFileDiff(file.path)}
|
||||
className="text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
|
||||
{/* Change Type */}
|
||||
<span
|
||||
className={cn(
|
||||
'w-5 h-5 rounded text-xs font-bold flex items-center justify-center',
|
||||
getChangeColor(file.type)
|
||||
)}
|
||||
>
|
||||
{getChangeLabel(file.type)}
|
||||
</span>
|
||||
|
||||
{/* File Path */}
|
||||
<span
|
||||
className="flex-1 text-sm font-mono truncate text-gray-300"
|
||||
onClick={() => handleViewFileDiff(file.path)}
|
||||
>
|
||||
{file.path}
|
||||
</span>
|
||||
|
||||
{/* Stats */}
|
||||
{(file.insertions !== undefined || file.deletions !== undefined) && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{file.insertions !== undefined && (
|
||||
<span className="text-green-400 mr-2">+{file.insertions}</span>
|
||||
)}
|
||||
{file.deletions !== undefined && (
|
||||
<span className="text-red-400">-{file.deletions}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Diff Content */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden bg-gray-900/30"
|
||||
>
|
||||
{loadingFileDiff ? (
|
||||
<div className="p-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
) : fileDiff?.patch ? (
|
||||
<div className="max-h-64 overflow-auto border-t border-gray-700/50">
|
||||
{renderPatch(fileDiff.patch)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
No diff content available
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
{diff && diff.files.length > 0 && (onRestoreSelected || onRestoreAll) && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between border-t border-gray-700',
|
||||
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-gray-500">
|
||||
{selectedFiles.size} of {diff.files.length} files selected
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{onRestoreSelected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestoreSelected}
|
||||
disabled={selectedFiles.size === 0}
|
||||
className="text-green-400 border-green-400/50 hover:border-green-400"
|
||||
>
|
||||
<RotateCcw size={14} className="mr-1" />
|
||||
Restore Selected ({selectedFiles.size})
|
||||
</Button>
|
||||
)}
|
||||
{onRestoreAll && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleRestoreAll}
|
||||
>
|
||||
<RotateCcw size={14} className="mr-1" />
|
||||
Restore All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* 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-gray-400' };
|
||||
case 'auto':
|
||||
default:
|
||||
return { icon: '⚪', label: 'Auto', color: 'text-gray-400' };
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
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-gray-900/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-gray-800 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-gray-700',
|
||||
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-gray-600 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-gray-500">
|
||||
{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-gray-500">
|
||||
<History size={48} className="mb-4 opacity-50" />
|
||||
<p className="text-center">No checkpoints yet</p>
|
||||
<p className="text-xs text-gray-600 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-gray-400 hover:text-gray-300 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-gray-500">({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-gray-900/50 rounded-lg p-3 hover:bg-gray-900/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-gray-500">
|
||||
{formatTime(cp.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{cp.description && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 truncate">
|
||||
{cp.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
|
||||
<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-gray-700',
|
||||
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-gray-500">
|
||||
Auto-cleanup enabled (7 days / 100 max)
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCleanup}
|
||||
disabled={cleaningUp}
|
||||
className="text-gray-400 hover:text-gray-300"
|
||||
>
|
||||
{cleaningUp ? (
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-t-2 border-b-2 border-gray-500 mr-1" />
|
||||
) : (
|
||||
<Trash2 size={12} className="mr-1" />
|
||||
)}
|
||||
Cleanup Now
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* RestoreDialog Component
|
||||
*
|
||||
* 检查点恢复确认对话框:显示安全检查、选择恢复模式、确认操作
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
X,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
RotateCcw,
|
||||
FileText,
|
||||
Loader2,
|
||||
} 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 {
|
||||
getCheckpoint,
|
||||
checkSafety,
|
||||
previewRestore,
|
||||
restoreCheckpoint,
|
||||
type CheckpointDetail,
|
||||
type SafetyCheckResult,
|
||||
type RestoreResult,
|
||||
type RestoreMode,
|
||||
} from '../api/client.js';
|
||||
|
||||
interface RestoreDialogProps {
|
||||
/** 检查点 ID */
|
||||
checkpointId: string;
|
||||
/** 要恢复的文件列表(可选,为空则恢复全部) */
|
||||
files?: string[];
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 恢复成功回调 */
|
||||
onRestored?: (result: RestoreResult) => void;
|
||||
/** 是否启用响应式布局 */
|
||||
responsive?: boolean;
|
||||
}
|
||||
|
||||
// 恢复模式选项
|
||||
const RESTORE_MODES: { value: RestoreMode; label: string; description: string }[] = [
|
||||
{
|
||||
value: 'ai_changes_only',
|
||||
label: 'AI Changes Only',
|
||||
description: 'Only restore files that were modified by AI',
|
||||
},
|
||||
{
|
||||
value: 'workspace_only',
|
||||
label: 'Workspace Only',
|
||||
description: 'Only restore workspace changes (not AI modifications)',
|
||||
},
|
||||
{
|
||||
value: 'full',
|
||||
label: 'Full Restore',
|
||||
description: 'Restore all files to checkpoint state',
|
||||
},
|
||||
];
|
||||
|
||||
export function RestoreDialog({
|
||||
checkpointId,
|
||||
files,
|
||||
onClose,
|
||||
onRestored,
|
||||
responsive = false,
|
||||
}: RestoreDialogProps) {
|
||||
// 数据状态
|
||||
const [checkpoint, setCheckpoint] = useState<CheckpointDetail | null>(null);
|
||||
const [safetyResult, setSafetyResult] = useState<SafetyCheckResult | null>(null);
|
||||
const [previewResult, setPreviewResult] = useState<RestoreResult | null>(null);
|
||||
|
||||
// 表单状态
|
||||
const [selectedMode, setSelectedMode] = useState<RestoreMode>('full');
|
||||
const [skipSafetyCheck, setSkipSafetyCheck] = useState(false);
|
||||
|
||||
// UI 状态
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
|
||||
// 加载数据
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [cpResult, safetyRes, previewRes] = await Promise.all([
|
||||
getCheckpoint(checkpointId),
|
||||
checkSafety(checkpointId),
|
||||
previewRestore(checkpointId, { mode: selectedMode, files }),
|
||||
]);
|
||||
|
||||
if (cpResult.success && cpResult.data) {
|
||||
setCheckpoint(cpResult.data);
|
||||
} else {
|
||||
toast.error(cpResult.error || 'Failed to load checkpoint');
|
||||
}
|
||||
|
||||
if (safetyRes.success && safetyRes.data) {
|
||||
setSafetyResult(safetyRes.data);
|
||||
}
|
||||
|
||||
if (previewRes.success && previewRes.data) {
|
||||
setPreviewResult(previewRes.data);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load data');
|
||||
}
|
||||
}, [checkpointId, selectedMode, files]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadData().finally(() => setLoading(false));
|
||||
}, [loadData]);
|
||||
|
||||
// 模式变化时重新预览
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
previewRestore(checkpointId, { mode: selectedMode, files })
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setPreviewResult(result.data);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [checkpointId, selectedMode, files, loading]);
|
||||
|
||||
// 执行恢复
|
||||
const handleRestore = async () => {
|
||||
setRestoring(true);
|
||||
try {
|
||||
const result = await restoreCheckpoint(checkpointId, {
|
||||
mode: selectedMode,
|
||||
files,
|
||||
skipSafetyCheck,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
toast.success(`Restored ${result.data.restoredFiles.length} files`);
|
||||
onRestored?.(result.data);
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to restore checkpoint');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to restore');
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp: number) => new Date(timestamp).toLocaleString();
|
||||
|
||||
// Loading 骨架屏
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="space-y-4 p-4">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</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-gray-800 max-h-[90vh] overflow-hidden flex flex-col',
|
||||
responsive
|
||||
? 'w-full md:w-full md:max-w-lg md:mx-4 rounded-t-2xl md:rounded-lg'
|
||||
: 'rounded-lg w-full max-w-lg mx-4'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between border-b border-gray-700',
|
||||
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-gray-600 rounded-full md:hidden" />
|
||||
)}
|
||||
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<RotateCcw size={20} className="text-primary-400" />
|
||||
Restore Checkpoint
|
||||
</h2>
|
||||
{checkpoint && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatTime(checkpoint.timestamp)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{loading ? (
|
||||
<LoadingSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{/* Safety Check Results */}
|
||||
{safetyResult && (
|
||||
<div className="space-y-2">
|
||||
{/* Errors */}
|
||||
{safetyResult.errors.length > 0 && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-400 mb-2">
|
||||
<AlertCircle size={16} />
|
||||
<span className="font-medium">Safety Errors</span>
|
||||
</div>
|
||||
<ul className="text-sm text-red-300 space-y-1">
|
||||
{safetyResult.errors.map((error, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-red-500">•</span>
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{safetyResult.warnings.length > 0 && (
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-yellow-400 mb-2">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="font-medium">Warnings</span>
|
||||
</div>
|
||||
<ul className="text-sm text-yellow-300 space-y-1">
|
||||
{safetyResult.warnings.map((warning, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-yellow-500">•</span>
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Safe */}
|
||||
{safetyResult.safe && safetyResult.errors.length === 0 && safetyResult.warnings.length === 0 && (
|
||||
<div className="p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<CheckCircle size={16} />
|
||||
<span className="font-medium">Safety check passed</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore Mode Selection */}
|
||||
{!files && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-300">Restore Mode</label>
|
||||
<div className="space-y-2">
|
||||
{RESTORE_MODES.map((mode) => (
|
||||
<label
|
||||
key={mode.value}
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-colors',
|
||||
selectedMode === mode.value
|
||||
? 'bg-primary-500/10 border border-primary-500/50'
|
||||
: 'bg-gray-900/50 border border-gray-700 hover:border-gray-600'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="restoreMode"
|
||||
value={mode.value}
|
||||
checked={selectedMode === mode.value}
|
||||
onChange={(e) => setSelectedMode(e.target.value as RestoreMode)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-200">{mode.label}</div>
|
||||
<div className="text-xs text-gray-500">{mode.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files to Restore */}
|
||||
{previewResult && previewResult.restoredFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-300 flex items-center gap-2">
|
||||
<FileText size={14} />
|
||||
Files to restore ({previewResult.restoredFiles.length})
|
||||
</label>
|
||||
<div className="max-h-40 overflow-y-auto bg-gray-900/50 rounded-lg p-2 space-y-1">
|
||||
{previewResult.restoredFiles.map((file) => (
|
||||
<div
|
||||
key={file}
|
||||
className="text-xs font-mono text-gray-400 px-2 py-1 hover:bg-gray-800 rounded"
|
||||
>
|
||||
{file}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skip Safety Check Option */}
|
||||
{safetyResult && !safetyResult.safe && (
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipSafetyCheck}
|
||||
onChange={(e) => setSkipSafetyCheck(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Skip safety check and proceed anyway
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-end gap-3 border-t border-gray-700',
|
||||
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleRestore}
|
||||
disabled={
|
||||
loading ||
|
||||
restoring ||
|
||||
(safetyResult && !safetyResult.safe && !skipSafetyCheck) ||
|
||||
!previewResult ||
|
||||
previewResult.restoredFiles.length === 0
|
||||
}
|
||||
>
|
||||
{restoring ? (
|
||||
<>
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
Restoring...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw size={16} className="mr-2" />
|
||||
Restore Now
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user