Files
zclaw_openfang/desktop/src/components/CodeSnippetPanel.tsx
iven 3518fc8ece feat(automation): complete unified automation system redesign
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>
2026-03-18 17:12:05 +08:00

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>
);
}