/** * HooksPanel Component * * Hooks 钩子配置管理面板:显示和编辑所有钩子类型 */ import { useState, useEffect, useCallback } from 'react'; import { X, RefreshCw, Zap, Plus, ChevronDown, ChevronRight, FileEdit, FilePlus, FileX, CheckCircle, Trash2, Play, Edit2, AlertCircle, } 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 { HookEditor } from './HookEditor'; import { getHooksConfig, updateHooksConfig, testHookCommand, type HookConfig, type FileHookConfig, type ShellCommandConfig, } from '../api/client.js'; interface HooksPanelProps { onClose: () => void; /** 是否启用响应式布局 */ responsive?: boolean; } type HookType = 'file_edited' | 'file_created' | 'file_deleted' | 'session_completed'; interface EditingHook { type: HookType; pattern?: string; // 仅用于文件钩子 command?: ShellCommandConfig; isNew: boolean; } const HOOK_TYPES: { type: HookType; label: string; icon: React.ReactNode; description: string }[] = [ { type: 'file_edited', label: 'File Edited', icon: , description: 'Triggered after a file is edited' }, { type: 'file_created', label: 'File Created', icon: , description: 'Triggered after a file is created' }, { type: 'file_deleted', label: 'File Deleted', icon: , description: 'Triggered after a file is deleted' }, { type: 'session_completed', label: 'Session Completed', icon: , description: 'Triggered when a session ends' }, ]; export function HooksPanel({ onClose, responsive = false }: HooksPanelProps) { // 数据状态 const [config, setConfig] = useState({}); const [expandedTypes, setExpandedTypes] = useState>(new Set(['file_edited'])); // UI 状态 const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [saving, setSaving] = useState(false); const [testingCommand, setTestingCommand] = useState(null); // 编辑状态 const [editingHook, setEditingHook] = useState(null); // 加载配置 const loadConfig = useCallback(async (showToast = false) => { try { const result = await getHooksConfig(); if (result.success) { setConfig(result.data); if (showToast) { toast.success('Configuration refreshed'); } } else { toast.error(result.error || 'Failed to load configuration'); } } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to load configuration'); } }, []); // 初始加载 useEffect(() => { setLoading(true); loadConfig().finally(() => setLoading(false)); }, [loadConfig]); // 刷新 const handleRefresh = async () => { setRefreshing(true); await loadConfig(true); setRefreshing(false); }; // 保存配置 const saveConfig = async (newConfig: HookConfig) => { setSaving(true); try { const result = await updateHooksConfig(newConfig); if (result.success) { setConfig(result.data); toast.success('Configuration saved'); return true; } else { toast.error(result.error || 'Failed to save configuration'); return false; } } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to save configuration'); return false; } finally { setSaving(false); } }; // 切换展开 const toggleExpanded = (type: HookType) => { const newExpanded = new Set(expandedTypes); if (newExpanded.has(type)) { newExpanded.delete(type); } else { newExpanded.add(type); } setExpandedTypes(newExpanded); }; // 测试命令 const handleTestCommand = async (cmd: ShellCommandConfig, id: string) => { setTestingCommand(id); try { const result = await testHookCommand(cmd); if (result.success && result.data) { if (result.data.success) { toast.success(
Command succeeded
Exit code: {result.data.exitCode} ({result.data.duration}ms)
{result.data.stdout && (
                  {result.data.stdout.slice(0, 500)}
                
)}
); } else { toast.error(
Command failed
Exit code: {result.data.exitCode} ({result.data.duration}ms)
{result.data.stderr && (
                  {result.data.stderr.slice(0, 500)}
                
)}
); } } else { toast.error(result.error || 'Test failed'); } } catch (err) { toast.error(err instanceof Error ? err.message : 'Test failed'); } finally { setTestingCommand(null); } }; // 删除文件钩子的某个 pattern const handleDeleteFileHook = async (type: HookType, pattern: string) => { if (type === 'session_completed') return; const fileHooks = config[type] as FileHookConfig | undefined; if (!fileHooks) return; const newFileHooks = { ...fileHooks }; delete newFileHooks[pattern]; const newConfig = { ...config, [type]: newFileHooks }; await saveConfig(newConfig); }; // 删除 session_completed 的某个命令 const handleDeleteSessionHook = async (index: number) => { const commands = config.session_completed || []; const newCommands = commands.filter((_, i) => i !== index); const newConfig = { ...config, session_completed: newCommands }; await saveConfig(newConfig); }; // 打开编辑器 - 文件钩子 const openFileHookEditor = (type: HookType, pattern?: string, commands?: ShellCommandConfig[]) => { setEditingHook({ type, pattern, command: commands?.[0], isNew: !pattern, }); }; // 打开编辑器 - Session 钩子 const openSessionHookEditor = (command?: ShellCommandConfig, index?: number) => { setEditingHook({ type: 'session_completed', pattern: index !== undefined ? String(index) : undefined, command, isNew: index === undefined, }); }; // 保存编辑的钩子 const handleSaveHook = async (type: HookType, pattern: string, commands: ShellCommandConfig[]) => { let newConfig: HookConfig; if (type === 'session_completed') { // Session hook const existingCommands = [...(config.session_completed || [])]; const index = editingHook?.pattern !== undefined ? parseInt(editingHook.pattern) : -1; if (index >= 0 && index < existingCommands.length) { // 更新现有命令 existingCommands[index] = commands[0]; } else { // 添加新命令 existingCommands.push(commands[0]); } newConfig = { ...config, session_completed: existingCommands }; } else { // File hook const fileHooks = { ...(config[type] as FileHookConfig || {}) }; // 如果是重命名 pattern if (editingHook?.pattern && editingHook.pattern !== pattern) { delete fileHooks[editingHook.pattern]; } fileHooks[pattern] = commands; newConfig = { ...config, [type]: fileHooks }; } const success = await saveConfig(newConfig); if (success) { setEditingHook(null); } }; // 统计 const totalHooks = Object.values(config).reduce((sum, hooks) => { if (Array.isArray(hooks)) { return sum + hooks.length; } if (hooks && typeof hooks === 'object') { return sum + Object.keys(hooks).length; } return sum; }, 0); // Loading 骨架屏 const LoadingSkeleton = () => (
{[1, 2, 3, 4].map((i) => (
))}
); // 渲染文件钩子内容 const renderFileHooks = (type: HookType) => { const hooks = config[type] as FileHookConfig | undefined; const patterns = Object.keys(hooks || {}); if (patterns.length === 0) { return (
No hooks configured
); } return (
{patterns.map((pattern) => { const commands = hooks![pattern]; const cmdId = `${type}-${pattern}`; return (
{pattern}
{commands.map((cmd, idx) => (
{cmd.command.join(' ')}
))}
); })}
); }; // 渲染 session hooks const renderSessionHooks = () => { const commands = config.session_completed || []; if (commands.length === 0) { return (
No hooks configured
); } return (
{commands.map((cmd, idx) => { const cmdId = `session-${idx}`; return (
{cmd.command.join(' ')}
); })}
); }; return ( <> 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 */}
{responsive && (
)}

Hooks Configuration

{totalHooks} hooks configured

{/* Content */}
{loading ? ( ) : ( {HOOK_TYPES.map(({ type, label, icon, description }) => { const isExpanded = expandedTypes.has(type); const isFileHook = type !== 'session_completed'; const hookCount = isFileHook ? Object.keys((config[type] as FileHookConfig) || {}).length : (config.session_completed || []).length; return ( {/* Type Header */}
toggleExpanded(type)} >
{icon}
{label} {hookCount > 0 && ( {hookCount} )}
{description}
{/* Expanded Content */} {isExpanded && (
{isFileHook ? renderFileHooks(type) : renderSessionHooks()}
)}
); })}
)}
{/* Footer */}
Commands are executed in your project directory. Use caution with destructive operations.
{/* Hook Editor Modal */} {editingHook && ( setEditingHook(null)} onTest={handleTestCommand} saving={saving} responsive={responsive} /> )} ); }