v1.3.0: 主题系统+代码分割+设置重构+命令片段库+命令面板+连接面板 | 19 files +2141/-257 | tsc零错误 构建成功
This commit is contained in:
1 parent
caba5f4c0d
commit
1f2de032a4
19 files changed
+2142
-258
No files matched your search
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* 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-850 p-2.5 transition-colors">
|
||||
{/* 名称行 */}
|
||||
<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 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">✓</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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user