Files
opsxshell/src/components/snippets/SnippetPanel.tsx
T

398 lines
13 KiB
TypeScript

/**
* SnippetPanel — 命令片段库侧边面板
*
* 功能:搜索、新建、编辑、删除命令片段,并插入到活跃终端
*/
import { useState, useEffect, useCallback } from 'react'
import { Code2, Plus, Search, Trash2, Copy, Pencil, X } from 'lucide-react'
import { useSnippetStore, type Snippet } from '../../stores/snippetStore'
import { useTabStore } from '../../stores/tabStore'
import Button from '../common/Button'
import Modal from '../common/Modal'
import EmptyState from '../common/EmptyState'
// ─── Snippet 编辑对话框 ─────────────────────────────────────────
interface SnippetFormData {
name: string
content: string
tags: string
}
interface SnippetFormProps {
isOpen: boolean
initial: SnippetFormData
title: string
confirmText: string
onConfirm: (data: SnippetFormData) => void
onClose: () => void
}
function SnippetFormModal({ isOpen, initial, title, confirmText, onConfirm, onClose }: SnippetFormProps) {
const [form, setForm] = useState<SnippetFormData>(initial)
useEffect(() => {
if (isOpen) {
setForm(initial)
}
}, [isOpen, initial])
const handleConfirm = () => {
if (!form.name.trim() || !form.content.trim()) return
onConfirm(form)
}
return (
<Modal
isOpen={isOpen}
title={title}
confirmText={confirmText}
onClose={onClose}
onConfirm={handleConfirm}
>
<div className="space-y-4">
{/* 名称 */}
<div>
<label className="block text-sm text-gray-400 mb-1">名称</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="例如:重启 Nginx"
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue text-sm"
/>
</div>
{/* 命令内容 */}
<div>
<label className="block text-sm text-gray-400 mb-1">命令</label>
<textarea
value={form.content}
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
placeholder="sudo systemctl restart nginx"
rows={4}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue text-sm font-mono resize-none"
/>
</div>
{/* 标签 */}
<div>
<label className="block text-sm text-gray-400 mb-1">标签 <span className="text-gray-600">(逗号分隔)</span></label>
<input
type="text"
value={form.tags}
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
placeholder="linux, nginx, ops"
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue text-sm"
/>
</div>
</div>
</Modal>
)
}
const EMPTY_FORM: SnippetFormData = { name: '', content: '', tags: '' }
// ─── 主面板组件 ─────────────────────────────────────────────────
export default function SnippetPanel() {
const { snippets, isLoaded, loadSnippets, addSnippet, updateSnippet, deleteSnippet, searchSnippets } = useSnippetStore()
const { tabs, activeTabId } = useTabStore()
const [query, setQuery] = useState('')
const [copiedId, setCopiedId] = useState<string | null>(null)
// 编辑对话框状态
const [formOpen, setFormOpen] = useState(false)
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null)
const [formData, setFormData] = useState<SnippetFormData>(EMPTY_FORM)
const [formTitle, setFormTitle] = useState('新建片段')
const [formConfirmText, setFormConfirmText] = useState('创建')
// 初始化加载
useEffect(() => {
loadSnippets()
}, [loadSnippets])
// 复制反馈计时器
useEffect(() => {
if (!copiedId) return
const timer = setTimeout(() => setCopiedId(null), 1500)
return () => clearTimeout(timer)
}, [copiedId])
// 获取当前活跃终端的 shellId
const getActiveShellId = useCallback((): string | null => {
const activeTab = tabs.find((t) => t.id === activeTabId)
return activeTab?.connectionInstance?.shellId ?? null
}, [tabs, activeTabId])
// 插入命令到终端
const handleInsert = useCallback(async (content: string) => {
const shellId = getActiveShellId()
if (!shellId) return
try {
await window.electronAPI?.sshShellWrite(shellId, content + '\n')
} catch (err) {
console.error('Failed to insert snippet:', err)
}
}, [getActiveShellId])
// 复制到剪贴板
const handleCopy = useCallback(async (snippet: Snippet) => {
try {
await navigator.clipboard.writeText(snippet.content)
setCopiedId(snippet.id)
} catch {
// fallback
console.error('Clipboard write failed')
}
}, [])
// 新建片段
const handleCreate = () => {
setEditingSnippet(null)
setFormData(EMPTY_FORM)
setFormTitle('新建片段')
setFormConfirmText('创建')
setFormOpen(true)
}
// 编辑片段
const handleEdit = (snippet: Snippet) => {
setEditingSnippet(snippet)
setFormData({
name: snippet.name,
content: snippet.content,
tags: snippet.tags.join(', '),
})
setFormTitle('编辑片段')
setFormConfirmText('保存')
setFormOpen(true)
}
// 表单提交
const handleFormSubmit = async (data: SnippetFormData) => {
const tags = data.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
if (editingSnippet) {
await updateSnippet(editingSnippet.id, {
name: data.name.trim(),
content: data.content.trim(),
tags,
})
} else {
await addSnippet(data.name.trim(), data.content.trim(), tags)
}
setFormOpen(false)
}
// 搜索结果
const filteredSnippets = query.trim() ? searchSnippets(query) : snippets
// 是否有活跃终端
const hasActiveTerminal = !!getActiveShellId()
return (
<div className="flex flex-col h-full bg-dark-800">
{/* 头部 */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-dark-700">
<Code2 className="w-5 h-5 text-accent-blue flex-shrink-0" />
<h2 className="text-sm font-semibold text-gray-200">命令片段</h2>
</div>
{/* 搜索框 */}
<div className="p-3 border-b border-dark-700">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索片段..."
className="w-full pl-9 pr-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-dark-600 rounded"
>
<X className="w-3.5 h-3.5 text-gray-500" />
</button>
)}
</div>
</div>
{/* 片段列表 */}
<div className="flex-1 overflow-y-auto">
{isLoaded && filteredSnippets.length === 0 ? (
<EmptyState
icon={Code2}
title={query ? '没有匹配的片段' : '暂无片段'}
description={query ? '尝试其他关键词搜索' : '保存常用命令,快速插入到终端'}
action={
!query
? { label: '新建片段', onClick: handleCreate }
: undefined
}
/>
) : (
<div className="p-2 space-y-1">
{filteredSnippets.map((snippet) => (
<SnippetItem
key={snippet.id}
snippet={snippet}
copied={copiedId === snippet.id}
canInsert={hasActiveTerminal}
onInsert={handleInsert}
onCopy={handleCopy}
onEdit={handleEdit}
onDelete={deleteSnippet}
/>
))}
</div>
)}
</div>
{/* 底部新建按钮 */}
<div className="p-3 border-t border-dark-700">
<Button
variant="primary"
size="sm"
className="w-full"
icon={<Plus className="w-4 h-4" />}
onClick={handleCreate}
>
新建片段
</Button>
</div>
{/* 编辑/新建对话框 */}
<SnippetFormModal
isOpen={formOpen}
initial={formData}
title={formTitle}
confirmText={formConfirmText}
onConfirm={handleFormSubmit}
onClose={() => setFormOpen(false)}
/>
</div>
)
}
// ─── 单个片段项 ─────────────────────────────────────────────────
interface SnippetItemProps {
snippet: Snippet
copied: boolean
canInsert: boolean
onInsert: (content: string) => void
onCopy: (snippet: Snippet) => void
onEdit: (snippet: Snippet) => void
onDelete: (id: string) => Promise<void>
}
function SnippetItem({ snippet, copied, canInsert, onInsert, onCopy, onEdit, onDelete }: SnippetItemProps) {
const [confirmDelete, setConfirmDelete] = useState(false)
// 命令预览:显示第一行,截断到 60 字符
const preview = snippet.content.split('\n')[0].slice(0, 60) + (snippet.content.length > 60 ? '…' : '')
return (
<div className="group rounded-lg border border-dark-700 hover:border-dark-600 bg-dark-800 p-2.5 transition-colors duration-150">
{/* 名称行 */}
<div className="flex items-start justify-between gap-2 mb-1">
<span className="text-sm font-medium text-gray-200 truncate flex-1" title={snippet.name}>
{snippet.name}
</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-150 flex-shrink-0">
{/* 插入 */}
<button
onClick={() => onInsert(snippet.content)}
disabled={!canInsert}
title={canInsert ? '插入到终端' : '无活跃终端'}
className="p-1 rounded hover:bg-accent-blue/20 text-accent-blue disabled:text-gray-600 disabled:hover:bg-transparent transition-colors"
>
<Code2 className="w-3.5 h-3.5" />
</button>
{/* 复制 */}
<button
onClick={() => onCopy(snippet)}
title="复制"
className="p-1 rounded hover:bg-dark-600 text-gray-400 transition-colors"
>
{copied ? (
<span className="text-xs text-green-400 px-1 animate-scale-in">✓</span>
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
{/* 编辑 */}
<button
onClick={() => onEdit(snippet)}
title="编辑"
className="p-1 rounded hover:bg-dark-600 text-gray-400 transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
{/* 删除 */}
{confirmDelete ? (
<div className="flex items-center gap-0.5">
<button
onClick={async () => {
await onDelete(snippet.id)
setConfirmDelete(false)
}}
className="px-1.5 py-0.5 text-xs bg-red-600 hover:bg-red-500 text-white rounded transition-colors"
>
确认
</button>
<button
onClick={() => setConfirmDelete(false)}
className="px-1.5 py-0.5 text-xs text-gray-400 hover:text-gray-200 transition-colors"
>
取消
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
title="删除"
className="p-1 rounded hover:bg-red-500/20 text-gray-400 hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* 命令预览 */}
<p
className="text-xs font-mono text-gray-500 truncate mb-1.5"
title={snippet.content}
>
{preview}
</p>
{/* 标签 */}
{snippet.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{snippet.tags.map((tag) => (
<span
key={tag}
className="px-1.5 py-0.5 text-[10px] bg-dark-700 text-gray-400 rounded"
>
{tag}
</span>
))}
</div>
)}
</div>
)
}