9365e07df1
- 新增 Server Hooks API 路由 (CRUD + 测试执行) - 新增 HooksPanel 组件用于管理所有钩子类型 - 新增 HookEditor 组件用于编辑单个钩子规则 - 支持 file_edited/file_created/file_deleted/session_completed 四种钩子 - 集成到 web 和 desktop 应用
617 lines
21 KiB
TypeScript
617 lines
21 KiB
TypeScript
/**
|
|
* 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: <FileEdit size={16} />, description: 'Triggered after a file is edited' },
|
|
{ type: 'file_created', label: 'File Created', icon: <FilePlus size={16} />, description: 'Triggered after a file is created' },
|
|
{ type: 'file_deleted', label: 'File Deleted', icon: <FileX size={16} />, description: 'Triggered after a file is deleted' },
|
|
{ type: 'session_completed', label: 'Session Completed', icon: <CheckCircle size={16} />, description: 'Triggered when a session ends' },
|
|
];
|
|
|
|
export function HooksPanel({ onClose, responsive = false }: HooksPanelProps) {
|
|
// 数据状态
|
|
const [config, setConfig] = useState<HookConfig>({});
|
|
const [expandedTypes, setExpandedTypes] = useState<Set<HookType>>(new Set(['file_edited']));
|
|
|
|
// UI 状态
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [testingCommand, setTestingCommand] = useState<string | null>(null);
|
|
|
|
// 编辑状态
|
|
const [editingHook, setEditingHook] = useState<EditingHook | null>(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(
|
|
<div>
|
|
<div className="font-medium">Command succeeded</div>
|
|
<div className="text-xs text-gray-400 mt-1">
|
|
Exit code: {result.data.exitCode} ({result.data.duration}ms)
|
|
</div>
|
|
{result.data.stdout && (
|
|
<pre className="text-xs bg-gray-900 p-2 rounded mt-2 max-h-32 overflow-auto">
|
|
{result.data.stdout.slice(0, 500)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
} else {
|
|
toast.error(
|
|
<div>
|
|
<div className="font-medium">Command failed</div>
|
|
<div className="text-xs text-gray-400 mt-1">
|
|
Exit code: {result.data.exitCode} ({result.data.duration}ms)
|
|
</div>
|
|
{result.data.stderr && (
|
|
<pre className="text-xs bg-gray-900 p-2 rounded mt-2 max-h-32 overflow-auto text-red-400">
|
|
{result.data.stderr.slice(0, 500)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
} 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 = () => (
|
|
<div className="space-y-3 p-4">
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<div key={i} className="bg-gray-900/50 rounded-lg p-3">
|
|
<div className="flex items-center gap-3">
|
|
<Skeleton className="h-4 w-4" />
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-4 w-16 ml-auto" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// 渲染文件钩子内容
|
|
const renderFileHooks = (type: HookType) => {
|
|
const hooks = config[type] as FileHookConfig | undefined;
|
|
const patterns = Object.keys(hooks || {});
|
|
|
|
if (patterns.length === 0) {
|
|
return (
|
|
<div className="text-xs text-gray-500 py-2 px-3">
|
|
No hooks configured
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{patterns.map((pattern) => {
|
|
const commands = hooks![pattern];
|
|
const cmdId = `${type}-${pattern}`;
|
|
|
|
return (
|
|
<div key={pattern} className="bg-gray-800/50 rounded p-2">
|
|
<div className="flex items-center justify-between">
|
|
<code className="text-xs font-mono text-blue-400">{pattern}</code>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => openFileHookEditor(type, pattern, commands)}
|
|
className="h-6 w-6 p-0"
|
|
title="Edit"
|
|
>
|
|
<Edit2 size={12} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDeleteFileHook(type, pattern)}
|
|
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={12} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-1 space-y-1">
|
|
{commands.map((cmd, idx) => (
|
|
<div key={idx} className="flex items-center justify-between text-xs">
|
|
<code className="font-mono text-gray-400 truncate flex-1">
|
|
{cmd.command.join(' ')}
|
|
</code>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleTestCommand(cmd, `${cmdId}-${idx}`)}
|
|
disabled={testingCommand === `${cmdId}-${idx}`}
|
|
className="h-5 px-1.5 text-green-400 hover:text-green-300"
|
|
title="Test"
|
|
>
|
|
{testingCommand === `${cmdId}-${idx}` ? (
|
|
<RefreshCw size={10} className="animate-spin" />
|
|
) : (
|
|
<Play size={10} />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 渲染 session hooks
|
|
const renderSessionHooks = () => {
|
|
const commands = config.session_completed || [];
|
|
|
|
if (commands.length === 0) {
|
|
return (
|
|
<div className="text-xs text-gray-500 py-2 px-3">
|
|
No hooks configured
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{commands.map((cmd, idx) => {
|
|
const cmdId = `session-${idx}`;
|
|
return (
|
|
<div key={idx} className="bg-gray-800/50 rounded p-2 flex items-center justify-between">
|
|
<code className="text-xs font-mono text-gray-400 truncate flex-1">
|
|
{cmd.command.join(' ')}
|
|
</code>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleTestCommand(cmd, cmdId)}
|
|
disabled={testingCommand === cmdId}
|
|
className="h-6 px-1.5 text-green-400 hover:text-green-300"
|
|
title="Test"
|
|
>
|
|
{testingCommand === cmdId ? (
|
|
<RefreshCw size={10} className="animate-spin" />
|
|
) : (
|
|
<Play size={10} />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => openSessionHookEditor(cmd, idx)}
|
|
className="h-6 w-6 p-0"
|
|
title="Edit"
|
|
>
|
|
<Edit2 size={12} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDeleteSessionHook(idx)}
|
|
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={12} />
|
|
</Button>
|
|
</div>
|
|
</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">
|
|
<Zap size={20} className="text-yellow-400" />
|
|
Hooks Configuration
|
|
</h2>
|
|
<p className="text-xs text-gray-500">
|
|
{totalHooks} hooks configured
|
|
</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>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{loading ? (
|
|
<LoadingSkeleton />
|
|
) : (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className={cn('space-y-2', responsive ? 'p-4' : 'p-4')}
|
|
>
|
|
{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 (
|
|
<motion.div
|
|
key={type}
|
|
layout
|
|
className="bg-gray-900/50 rounded-lg overflow-hidden"
|
|
>
|
|
{/* Type Header */}
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-3 p-3',
|
|
'hover:bg-gray-900/80 transition-colors cursor-pointer'
|
|
)}
|
|
onClick={() => toggleExpanded(type)}
|
|
>
|
|
<button className="text-gray-500 hover:text-gray-300">
|
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
|
</button>
|
|
|
|
<div className="text-primary-400">{icon}</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-gray-200">{label}</span>
|
|
{hookCount > 0 && (
|
|
<span className="text-xs bg-gray-700 px-1.5 py-0.5 rounded">
|
|
{hookCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-gray-500">{description}</div>
|
|
</div>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (isFileHook) {
|
|
openFileHookEditor(type);
|
|
} else {
|
|
openSessionHookEditor();
|
|
}
|
|
}}
|
|
className="text-primary-400 hover:text-primary-300"
|
|
>
|
|
<Plus size={14} className="mr-1" />
|
|
Add
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Expanded 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"
|
|
>
|
|
<div className="px-4 pb-3 pt-1 border-t border-gray-700/50">
|
|
{isFileHook ? renderFileHooks(type) : renderSessionHooks()}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div
|
|
className={cn(
|
|
'border-t border-gray-700 text-xs text-gray-500 px-4 py-3 flex items-start gap-2',
|
|
responsive && 'safe-area-pb'
|
|
)}
|
|
>
|
|
<AlertCircle size={14} className="flex-shrink-0 mt-0.5 text-yellow-500" />
|
|
<span>
|
|
Commands are executed in your project directory. Use caution with destructive operations.
|
|
</span>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
|
|
{/* Hook Editor Modal */}
|
|
{editingHook && (
|
|
<HookEditor
|
|
type={editingHook.type}
|
|
pattern={editingHook.pattern}
|
|
commands={editingHook.command ? [editingHook.command] : []}
|
|
isNew={editingHook.isNew}
|
|
onSave={handleSaveHook}
|
|
onCancel={() => setEditingHook(null)}
|
|
onTest={handleTestCommand}
|
|
saving={saving}
|
|
responsive={responsive}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|