Phase 4 completion: - Add ApprovalQueue component for managing pending approvals - Add ExecutionResult component for displaying hand/workflow results - Update Sidebar navigation to use unified AutomationPanel - Replace separate 'hands' and 'workflow' tabs with single 'automation' tab - Fix TypeScript type safety issues with unknown types in JSX expressions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
/**
|
|
* CodeSnippetPanel - 代码片段快速浏览面板
|
|
*
|
|
* 功能:
|
|
* - 搜索过滤代码片段
|
|
* - 按语言筛选
|
|
* - 展开/折叠查看完整代码
|
|
* - 一键复制
|
|
* - 下载为文件
|
|
*/
|
|
|
|
import { useState, useMemo, useCallback } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
Search, Copy, Download, ChevronDown, ChevronUp,
|
|
FileCode, X, Check, Code
|
|
} from 'lucide-react';
|
|
import { Button, EmptyState } from './ui';
|
|
import type { CodeBlock } from '../store/chatStore';
|
|
|
|
// === Types ===
|
|
|
|
export interface CodeSnippet {
|
|
id: string;
|
|
block: CodeBlock;
|
|
messageIndex: number;
|
|
}
|
|
|
|
interface CodeSnippetPanelProps {
|
|
snippets: CodeSnippet[];
|
|
}
|
|
|
|
// === Language Colors ===
|
|
|
|
const LANGUAGE_COLORS: Record<string, string> = {
|
|
python: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
|
javascript: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300',
|
|
typescript: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
|
rust: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
|
go: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300',
|
|
java: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
|
cpp: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
|
c: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
|
html: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
|
css: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
|
|
sql: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
|
|
bash: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
|
shell: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
|
json: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
|
yaml: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
|
markdown: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
|
text: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
|
};
|
|
|
|
function getLanguageColor(lang: string): string {
|
|
return LANGUAGE_COLORS[lang.toLowerCase()] || LANGUAGE_COLORS.text;
|
|
}
|
|
|
|
function getFileExtension(lang?: string): string {
|
|
const extensions: Record<string, string> = {
|
|
python: 'py',
|
|
javascript: 'js',
|
|
typescript: 'ts',
|
|
rust: 'rs',
|
|
go: 'go',
|
|
java: 'java',
|
|
cpp: 'cpp',
|
|
c: 'c',
|
|
html: 'html',
|
|
css: 'css',
|
|
sql: 'sql',
|
|
bash: 'sh',
|
|
shell: 'sh',
|
|
json: 'json',
|
|
yaml: 'yaml',
|
|
markdown: 'md',
|
|
};
|
|
return extensions[lang?.toLowerCase() || ''] || 'txt';
|
|
}
|
|
|
|
// === Snippet Card Component ===
|
|
|
|
interface SnippetCardProps {
|
|
snippet: CodeSnippet;
|
|
isExpanded: boolean;
|
|
onToggle: () => void;
|
|
}
|
|
|
|
function SnippetCard({ snippet, isExpanded, onToggle }: SnippetCardProps) {
|
|
const [copied, setCopied] = useState(false);
|
|
const { block, messageIndex } = snippet;
|
|
|
|
const handleCopy = useCallback(async (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
try {
|
|
await navigator.clipboard.writeText(block.content || '');
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
}
|
|
}, [block.content]);
|
|
|
|
const handleDownload = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
const filename = block.filename || `snippet_${messageIndex + 1}.${getFileExtension(block.language)}`;
|
|
const blob = new Blob([block.content || ''], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}, [block.content, block.filename, block.language, messageIndex]);
|
|
|
|
const lineCount = (block.content || '').split('\n').length;
|
|
const charCount = (block.content || '').length;
|
|
const previewLines = (block.content || '').split('\n').slice(0, 2).join('\n');
|
|
|
|
return (
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
{/* Header */}
|
|
<button
|
|
onClick={onToggle}
|
|
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
|
|
>
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<FileCode className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-gray-800 dark:text-gray-200 truncate text-sm">
|
|
{block.filename || `代码片段 #${messageIndex + 1}`}
|
|
</span>
|
|
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${getLanguageColor(block.language || 'text')}`}>
|
|
{block.language || 'text'}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{lineCount} 行 · {charCount > 1024 ? `${(charCount / 1024).toFixed(1)} KB` : `${charCount} 字符`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleCopy}
|
|
className="p-1.5 opacity-60 hover:opacity-100"
|
|
title="复制代码"
|
|
>
|
|
{copied ? (
|
|
<Check className="w-4 h-4 text-green-500" />
|
|
) : (
|
|
<Copy className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleDownload}
|
|
className="p-1.5 opacity-60 hover:opacity-100"
|
|
title="下载文件"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</Button>
|
|
{isExpanded ? (
|
|
<ChevronUp className="w-4 h-4 text-gray-400 ml-1" />
|
|
) : (
|
|
<ChevronDown className="w-4 h-4 text-gray-400 ml-1" />
|
|
)}
|
|
</div>
|
|
</button>
|
|
|
|
{/* Preview / Full Content */}
|
|
<AnimatePresence initial={false}>
|
|
{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="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 p-3">
|
|
<pre className="text-xs font-mono text-gray-700 dark:text-gray-300 overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto">
|
|
{block.content}
|
|
</pre>
|
|
</div>
|
|
</motion.div>
|
|
) : (
|
|
<div className="px-3 pb-3">
|
|
<pre className="text-xs font-mono text-gray-500 dark:text-gray-400 overflow-hidden whitespace-pre-wrap break-all line-clamp-2">
|
|
{previewLines}
|
|
{(block.content || '').split('\n').length > 2 && '...'}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === Main Component ===
|
|
|
|
export function CodeSnippetPanel({ snippets }: CodeSnippetPanelProps) {
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedLanguage, setSelectedLanguage] = useState<string | null>(null);
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
|
|
// Get unique languages with counts
|
|
const languages = useMemo(() => {
|
|
const langMap = new Map<string, number>();
|
|
snippets.forEach(s => {
|
|
const lang = s.block.language?.toLowerCase() || 'text';
|
|
langMap.set(lang, (langMap.get(lang) || 0) + 1);
|
|
});
|
|
return Array.from(langMap.entries())
|
|
.map(([lang, count]) => ({ lang, count }))
|
|
.sort((a, b) => b.count - a.count);
|
|
}, [snippets]);
|
|
|
|
// Filter snippets
|
|
const filteredSnippets = useMemo(() => {
|
|
return snippets.filter(snippet => {
|
|
const block = snippet.block;
|
|
|
|
// Language filter
|
|
if (selectedLanguage && (block.language?.toLowerCase() || 'text') !== selectedLanguage) {
|
|
return false;
|
|
}
|
|
|
|
// Search filter
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
const matchesFilename = block.filename?.toLowerCase().includes(query);
|
|
const matchesContent = block.content?.toLowerCase().includes(query);
|
|
const matchesLanguage = block.language?.toLowerCase().includes(query);
|
|
return matchesFilename || matchesContent || matchesLanguage;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}, [snippets, searchQuery, selectedLanguage]);
|
|
|
|
const handleToggle = useCallback((id: string) => {
|
|
setExpandedId(prev => prev === id ? null : id);
|
|
}, []);
|
|
|
|
if (snippets.length === 0) {
|
|
return (
|
|
<EmptyState
|
|
icon={<Code className="w-10 h-10" />}
|
|
title="暂无代码片段"
|
|
description="对话中生成的代码会自动出现在这里"
|
|
className="py-8"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Search Bar */}
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="搜索代码..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-9 pr-8 py-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
|
>
|
|
<X className="w-3 h-3 text-gray-400" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Language Filters */}
|
|
{languages.length > 1 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<button
|
|
onClick={() => setSelectedLanguage(null)}
|
|
className={`px-2 py-1 text-xs rounded-full transition-colors ${
|
|
selectedLanguage === null
|
|
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
全部 ({snippets.length})
|
|
</button>
|
|
{languages.map(({ lang, count }) => (
|
|
<button
|
|
key={lang}
|
|
onClick={() => setSelectedLanguage(selectedLanguage === lang ? null : lang)}
|
|
className={`px-2 py-1 text-xs rounded-full transition-colors ${
|
|
selectedLanguage === lang
|
|
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{lang} ({count})
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Results Count */}
|
|
{(searchQuery || selectedLanguage) && (
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
找到 {filteredSnippets.length} 个代码片段
|
|
</div>
|
|
)}
|
|
|
|
{/* Snippet List */}
|
|
<div className="space-y-2 overflow-y-auto" style={{ maxHeight: 'calc(100vh - 380px)' }}>
|
|
<AnimatePresence mode="popLayout">
|
|
{filteredSnippets.map((snippet) => (
|
|
<motion.div
|
|
key={snippet.id}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -10 }}
|
|
layout
|
|
>
|
|
<SnippetCard
|
|
snippet={snippet}
|
|
isExpanded={expandedId === snippet.id}
|
|
onToggle={() => handleToggle(snippet.id)}
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
|
|
{filteredSnippets.length === 0 && (
|
|
<div className="text-center py-4 text-sm text-gray-500 dark:text-gray-400">
|
|
没有找到匹配的代码片段
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|