Files
ai-terminal-assistant/packages/ui/src/components/CommandPanel.tsx
T
kurihada 5b7b0ff1e4 feat(ui): 实现深色/浅色主题切换功能
- 添加 CSS 变量定义浅色和深色主题色板
- 扩展 Tailwind 配置支持语义化颜色 (surface-*, fg-*, line-*, code)
- 创建 useTheme hook 管理主题状态和持久化
- 创建 ThemeToggle 组件支持三种模式 (light/dark/system)
- 迁移所有组件从硬编码 gray-* 到语义化颜色
- 支持系统主题偏好检测 (prefers-color-scheme)
- 添加主题初始化脚本防止闪烁 (FOUC)
2025-12-15 15:47:32 +08:00

404 lines
13 KiB
TypeScript

/**
* CommandPanel Component
*
* 命令管理面板:列出、搜索、创建、编辑、删除命令
*/
import { useState, useEffect, useCallback } from 'react';
import {
X,
Plus,
Search,
Pencil,
Trash2,
RefreshCw,
Terminal,
User,
FolderOpen,
Cog,
} 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 { Input } from '../primitives/Input';
import { Skeleton } from './Skeleton';
import { CommandEditor } from './CommandEditor';
import {
listCommands,
deleteCommand,
reloadCommands,
type CommandListResponse,
} from '../api/client.js';
interface CommandPanelProps {
onClose: () => void;
/** 是否启用响应式布局 */
responsive?: boolean;
}
type CommandItem = CommandListResponse['commands'][number];
// Source 图标映射
function getSourceIcon(source: string) {
switch (source) {
case 'builtin':
return <Cog size={14} className="text-fg-muted" />;
case 'user':
return <User size={14} className="text-blue-400" />;
case 'project':
return <FolderOpen size={14} className="text-green-400" />;
default:
return <Terminal size={14} className="text-fg-muted" />;
}
}
// Source 标签样式
function getSourceBadgeClass(source: string) {
switch (source) {
case 'builtin':
return 'bg-surface-muted text-fg-secondary';
case 'user':
return 'bg-blue-500/20 text-blue-400';
case 'project':
return 'bg-green-500/20 text-green-400';
default:
return 'bg-surface-muted text-fg-secondary';
}
}
export function CommandPanel({ onClose, responsive = false }: CommandPanelProps) {
// 数据状态
const [commands, setCommands] = useState<CommandItem[]>([]);
const [filteredCommands, setFilteredCommands] = useState<CommandItem[]>([]);
const [stats, setStats] = useState<{ total: number; bySource: Record<string, number> } | null>(
null
);
// UI 状态
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [deletingCommand, setDeletingCommand] = useState<string | null>(null);
const [reloading, setReloading] = useState(false);
// 编辑器状态
const [editorOpen, setEditorOpen] = useState(false);
const [editingCommand, setEditingCommand] = useState<string | undefined>(undefined);
// 加载命令列表
const loadCommands = useCallback(async () => {
setLoading(true);
try {
const result = await listCommands();
if (result.success) {
setCommands(result.data.commands);
setFilteredCommands(result.data.commands);
setStats(result.data.stats);
} else {
toast.error('Failed to load commands');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load commands');
} finally {
setLoading(false);
}
}, []);
// 初始加载
useEffect(() => {
loadCommands();
}, [loadCommands]);
// 搜索过滤
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredCommands(commands);
return;
}
const query = searchQuery.toLowerCase();
const filtered = commands.filter(
(cmd) =>
cmd.name.toLowerCase().includes(query) || cmd.description?.toLowerCase().includes(query)
);
setFilteredCommands(filtered);
}, [searchQuery, commands]);
// 删除命令
const handleDelete = async (name: string) => {
if (!confirm(`Are you sure you want to delete command "/${name}"?`)) {
return;
}
setDeletingCommand(name);
try {
const result = await deleteCommand(name);
if (result.success) {
toast.success(`Command "/${name}" deleted`);
loadCommands();
} else {
toast.error(result.error || 'Failed to delete command');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete command');
} finally {
setDeletingCommand(null);
}
};
// 重新加载命令
const handleReload = async () => {
setReloading(true);
try {
const result = await reloadCommands();
if (result.success) {
toast.success('Commands reloaded');
loadCommands();
} else {
toast.error('Failed to reload commands');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to reload commands');
} finally {
setReloading(false);
}
};
// 打开编辑器(创建)
const openCreateEditor = () => {
setEditingCommand(undefined);
setEditorOpen(true);
};
// 打开编辑器(编辑)
const openEditEditor = (name: string) => {
setEditingCommand(name);
setEditorOpen(true);
};
// 编辑器保存回调
const handleEditorSaved = () => {
loadCommands();
};
// Loading 骨架屏
const LoadingSkeleton = () => (
<div className="space-y-3 p-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-3 p-3 bg-surface-base/50 rounded-lg">
<Skeleton className="h-5 w-5 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</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-muted rounded-full md:hidden" />
)}
<div className={cn(responsive && 'mt-2 md:mt-0')}>
<h2 className="text-lg font-semibold">Commands</h2>
{stats && (
<p className="text-xs text-fg-subtle">
{stats.total} commands ({stats.bySource.builtin || 0} builtin,{' '}
{stats.bySource.user || 0} user, {stats.bySource.project || 0} project)
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<X size={20} />
</Button>
</div>
{/* Toolbar */}
<div
className={cn(
'flex items-center gap-3 border-b border-line',
responsive ? 'px-4 md:px-6 py-3' : 'px-6 py-3'
)}
>
<div className="flex-1 relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-fg-subtle"
/>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search commands..."
className="pl-9"
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleReload}
disabled={reloading}
title="Reload commands"
>
<RefreshCw size={18} className={cn(reloading && 'animate-spin')} />
</Button>
<Button onClick={openCreateEditor}>
<Plus size={16} className="mr-1" />
New
</Button>
</div>
{/* Command List */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<LoadingSkeleton />
) : filteredCommands.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-fg-subtle">
<Terminal size={48} className="mb-4 opacity-50" />
<p>
{searchQuery
? `No commands matching "${searchQuery}"`
: 'No commands found'}
</p>
{!searchQuery && (
<Button variant="ghost" className="mt-4" onClick={openCreateEditor}>
<Plus size={16} className="mr-2" />
Create your first command
</Button>
)}
</div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn('space-y-2', responsive ? 'p-4' : 'p-4')}
>
{filteredCommands.map((command) => (
<motion.div
key={command.name}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={cn(
'flex items-center gap-3 p-3 bg-surface-base/50 rounded-lg',
'hover:bg-surface-base/80 transition-colors group'
)}
>
{/* Icon */}
{getSourceIcon(command.source)}
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-fg-secondary">/{command.name}</span>
<span
className={cn(
'px-1.5 py-0.5 text-xs rounded',
getSourceBadgeClass(command.source)
)}
>
{command.source}
</span>
</div>
{command.description && (
<p className="text-xs text-fg-subtle truncate mt-0.5">
{command.description}
</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEditEditor(command.name)}
title="Edit"
>
<Pencil size={14} />
</Button>
{command.source !== 'builtin' && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleDelete(command.name)}
disabled={deletingCommand === command.name}
title="Delete"
>
{deletingCommand === command.name ? (
<div className="animate-spin rounded-full h-3.5 w-3.5 border-t border-b border-red-400" />
) : (
<Trash2 size={14} />
)}
</Button>
)}
</div>
</motion.div>
))}
</motion.div>
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
{/* Command Editor */}
{editorOpen && (
<CommandEditor
commandName={editingCommand}
onClose={() => setEditorOpen(false)}
onSaved={handleEditorSaved}
responsive={responsive}
/>
)}
</>
);
}