feat: v0.3.0 版本 - SSH终端、SFTP、端口转发功能实现
核心功能: - SSH终端: 密码/密钥认证、XTerm.js终端、多主题配色、IP高亮 - SFTP管理: 双栏文件管理、上传下载、传输队列 - 端口转发: 本地/远程转发、SOCKS5动态代理、规则管理 架构改进: - 拆分大型组件 (SFTPView/TabBar/Sidebar) - 添加统一日志和错误处理 - 修复React严格模式下的终端黑屏问题 UI更新: - 新增端口转发视图 - 更新UI预览文档 - 左侧栏右键菜单支持SFTP快捷打开
This commit is contained in:
1 parent
cf1c054381
commit
484d850abd
105 files changed
+25330
-64
No files matched your search
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* PortForwardView - 端口转发管理视图
|
||||
*
|
||||
* 支持本地端口转发、远程端口转发和动态 SOCKS5 代理
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useConnectionPool } from '../stores/connection'
|
||||
import { ArrowRightLeft, Globe, Server, Plus, Trash2, Play, Square } from 'lucide-react'
|
||||
|
||||
interface PortForwardViewProps {
|
||||
tabId: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
interface ForwardRule {
|
||||
id: string
|
||||
type: 'local' | 'remote' | 'dynamic'
|
||||
name: string
|
||||
localPort: number
|
||||
remoteHost?: string
|
||||
remotePort?: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export default function PortForwardView({ tabId, isActive: _isActive = true }: PortForwardViewProps) {
|
||||
const { tabs } = useTabStore()
|
||||
const pool = useConnectionPool()
|
||||
|
||||
const currentTab = tabs.find(t => t.id === tabId)
|
||||
const connectionId = currentTab?.connectionInstance?.connectionId
|
||||
const connection = connectionId ? pool.getConnection(connectionId) : null
|
||||
|
||||
const [rules, setRules] = useState<ForwardRule[]>([])
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [newRule, setNewRule] = useState<Partial<ForwardRule>>({
|
||||
type: 'local',
|
||||
name: '',
|
||||
localPort: 0,
|
||||
remoteHost: '',
|
||||
remotePort: 0,
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Load saved rules from settings
|
||||
useEffect(() => {
|
||||
if (connectionId) {
|
||||
window.electronAPI?.settingsGet(`portForwards_${connectionId}`).then((result) => {
|
||||
if (result.success && result.value) {
|
||||
setRules(JSON.parse(result.value as string))
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [connectionId])
|
||||
|
||||
// Save rules
|
||||
const saveRules = (newRules: ForwardRule[]) => {
|
||||
setRules(newRules)
|
||||
if (connectionId) {
|
||||
window.electronAPI?.settingsSet(`portForwards_${connectionId}`, JSON.stringify(newRules))
|
||||
}
|
||||
}
|
||||
|
||||
// Add new rule
|
||||
const handleAddRule = async () => {
|
||||
if (!newRule.name || !newRule.localPort) {
|
||||
setError('请填写完整信息')
|
||||
return
|
||||
}
|
||||
|
||||
if (newRule.type !== 'dynamic' && (!newRule.remoteHost || !newRule.remotePort)) {
|
||||
setError('请填写远程主机和端口')
|
||||
return
|
||||
}
|
||||
|
||||
const rule: ForwardRule = {
|
||||
id: `pf-${Date.now()}`,
|
||||
type: newRule.type as 'local' | 'remote' | 'dynamic',
|
||||
name: newRule.name,
|
||||
localPort: newRule.localPort,
|
||||
remoteHost: newRule.remoteHost,
|
||||
remotePort: newRule.remotePort,
|
||||
isActive: false,
|
||||
}
|
||||
|
||||
saveRules([...rules, rule])
|
||||
setShowAddModal(false)
|
||||
setNewRule({ type: 'local', name: '', localPort: 0, remoteHost: '', remotePort: 0 })
|
||||
setError(null)
|
||||
}
|
||||
|
||||
// Start port forward
|
||||
const startForward = async (rule: ForwardRule) => {
|
||||
if (!connectionId) {
|
||||
setError('未连接到服务器')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
let result
|
||||
if (rule.type === 'local') {
|
||||
result = await window.electronAPI?.sshForwardLocal(
|
||||
connectionId,
|
||||
rule.localPort,
|
||||
rule.remoteHost || '',
|
||||
rule.remotePort || 0
|
||||
)
|
||||
} else if (rule.type === 'remote') {
|
||||
result = await window.electronAPI?.sshForwardRemote(
|
||||
connectionId,
|
||||
rule.remotePort || 0,
|
||||
'127.0.0.1',
|
||||
rule.localPort
|
||||
)
|
||||
} else if (rule.type === 'dynamic') {
|
||||
result = await window.electronAPI?.sshForwardDynamic(connectionId, rule.localPort)
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
const updatedRules = rules.map(r =>
|
||||
r.id === rule.id ? { ...r, isActive: true } : r
|
||||
)
|
||||
saveRules(updatedRules)
|
||||
} else {
|
||||
setError(result?.error || '启动失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || '启动失败')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop port forward
|
||||
const stopForward = async (rule: ForwardRule) => {
|
||||
// Note: Actual stop implementation would require storing server references
|
||||
const updatedRules = rules.map(r =>
|
||||
r.id === rule.id ? { ...r, isActive: false } : r
|
||||
)
|
||||
saveRules(updatedRules)
|
||||
}
|
||||
|
||||
// Delete rule
|
||||
const deleteRule = (ruleId: string) => {
|
||||
if (confirm('确定删除此转发规则?')) {
|
||||
saveRules(rules.filter(r => r.id !== ruleId))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-900">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowRightLeft className="w-6 h-6 text-accent-blue" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">端口转发</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{connection
|
||||
? `已连接: ${connection.config.username}@${connection.config.host}`
|
||||
: '未连接 - 请先建立 SSH 连接'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
disabled={!connection}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent-blue hover:bg-blue-600 disabled:bg-dark-700 disabled:text-gray-500 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
添加规则
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rules List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{rules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
||||
<Globe className="w-16 h-16 mb-4 opacity-50" />
|
||||
<p className="text-lg">暂无端口转发规则</p>
|
||||
<p className="text-sm mt-1">点击右上角添加规则</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{rules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className={`p-4 rounded-lg border transition-all ${
|
||||
rule.isActive
|
||||
? 'bg-green-500/10 border-green-500/30'
|
||||
: 'bg-dark-800 border-dark-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Type Icon */}
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
rule.type === 'local' ? 'bg-blue-500/20 text-blue-400' :
|
||||
rule.type === 'remote' ? 'bg-purple-500/20 text-purple-400' :
|
||||
'bg-orange-500/20 text-orange-400'
|
||||
}`}>
|
||||
{rule.type === 'local' && <ArrowRightLeft className="w-5 h-5" />}
|
||||
{rule.type === 'remote' && <Server className="w-5 h-5" />}
|
||||
{rule.type === 'dynamic' && <Globe className="w-5 h-5" />}
|
||||
</div>
|
||||
|
||||
{/* Rule Info */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{rule.name}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
rule.type === 'local' ? 'bg-blue-500/20 text-blue-400' :
|
||||
rule.type === 'remote' ? 'bg-purple-500/20 text-purple-400' :
|
||||
'bg-orange-500/20 text-orange-400'
|
||||
}`}>
|
||||
{rule.type === 'local' ? '本地转发' :
|
||||
rule.type === 'remote' ? '远程转发' : '动态 SOCKS5'}
|
||||
</span>
|
||||
{rule.isActive && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-green-500/20 text-green-400">
|
||||
运行中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-1">
|
||||
{rule.type === 'dynamic' ? (
|
||||
<span>SOCKS5 代理: 127.0.0.1:{rule.localPort}</span>
|
||||
) : rule.type === 'local' ? (
|
||||
<span>127.0.0.1:{rule.localPort} → {rule.remoteHost}:{rule.remotePort}</span>
|
||||
) : (
|
||||
<span>远程 {rule.remotePort} → 本地 {rule.localPort}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{rule.isActive ? (
|
||||
<button
|
||||
onClick={() => stopForward(rule)}
|
||||
className="p-2 text-yellow-400 hover:bg-yellow-400/10 rounded transition-colors"
|
||||
title="停止"
|
||||
>
|
||||
<Square className="w-5 h-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startForward(rule)}
|
||||
disabled={isLoading || !connection}
|
||||
className="p-2 text-green-400 hover:bg-green-400/10 rounded transition-colors disabled:opacity-50"
|
||||
title="启动"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteRule(rule.id)}
|
||||
className="p-2 text-red-400 hover:bg-red-400/10 rounded transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="px-6 py-3 bg-red-500/10 border-t border-red-500/20">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Rule Modal */}
|
||||
{showAddModal && (
|
||||
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-50">
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 shadow-2xl w-full max-w-md p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">添加端口转发规则</h3>
|
||||
|
||||
{/* Type Selection */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
{[
|
||||
{ type: 'local', label: '本地转发', desc: '本地→远程' },
|
||||
{ type: 'remote', label: '远程转发', desc: '远程→本地' },
|
||||
{ type: 'dynamic', label: '动态 SOCKS5', desc: '代理服务器' },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.type}
|
||||
onClick={() => setNewRule({ ...newRule, type: t.type as any })}
|
||||
className={`p-3 rounded-lg border text-left transition-colors ${
|
||||
newRule.type === t.type
|
||||
? 'bg-accent-blue/20 border-accent-blue text-white'
|
||||
: 'bg-dark-700 border-dark-600 text-gray-400 hover:bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium">{t.label}</div>
|
||||
<div className="text-xs opacity-70">{t.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">规则名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRule.name}
|
||||
onChange={(e) => setNewRule({ ...newRule, name: e.target.value })}
|
||||
placeholder="例如: MySQL 转发"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
{newRule.type === 'remote' ? '远程端口' : '本地端口'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newRule.localPort || ''}
|
||||
onChange={(e) => setNewRule({ ...newRule, localPort: parseInt(e.target.value) })}
|
||||
placeholder="例如: 3306"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newRule.type !== 'dynamic' && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">远程端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newRule.remotePort || ''}
|
||||
onChange={(e) => setNewRule({ ...newRule, remotePort: parseInt(e.target.value) })}
|
||||
placeholder="例如: 3306"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{newRule.type === 'local' && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">远程主机</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRule.remoteHost}
|
||||
onChange={(e) => setNewRule({ ...newRule, remoteHost: e.target.value })}
|
||||
placeholder="例如: localhost 或 192.168.1.100"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="flex-1 px-4 py-2 bg-dark-700 hover:bg-dark-600 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddRule}
|
||||
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Zap, Globe, User, Lock, ArrowRight, AlertCircle } from 'lucide-react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
|
||||
interface QuickConnectDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function QuickConnectDialog({ isOpen, onClose }: QuickConnectDialogProps) {
|
||||
const { addTab } = useTabStore()
|
||||
const [formData, setFormData] = useState({
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsConnecting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 快速连接只需添加标签,真正的连接由 TerminalView 处理
|
||||
// 这样可以确保每个标签独立管理自己的连接生命周期
|
||||
|
||||
// 1. 打开新标签页,传递连接配置
|
||||
// TerminalView 会负责创建实际的 SSH 连接
|
||||
addTab({
|
||||
title: `${formData.username}@${formData.host}`,
|
||||
type: 'terminal',
|
||||
connectionConfig: {
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
username: formData.username,
|
||||
password: formData.password || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// 2. 关闭对话框
|
||||
onClose()
|
||||
|
||||
// 3. 重置表单
|
||||
setFormData({
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
} catch (err: any) {
|
||||
setError(err.message || '连接失败')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-dark-800 rounded-xl border border-dark-700 shadow-2xl animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-dark-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-accent-blue/20 rounded-lg flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-accent-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-200">快速连接</h2>
|
||||
<p className="text-sm text-gray-500">临时连接到服务器</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-dark-700 rounded-lg text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{/* Host and Port */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
<Globe className="inline w-3 h-3 mr-1" />
|
||||
主机地址
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
value={formData.host}
|
||||
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
|
||||
placeholder="192.168.1.1"
|
||||
className="w-full px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
端口
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.port}
|
||||
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) })}
|
||||
placeholder="22"
|
||||
className="w-full px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
<User className="inline w-3 h-3 mr-1" />
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
placeholder="root"
|
||||
className="w-full px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
<Lock className="inline w-3 h-3 mr-1" />
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-lg">
|
||||
<p className="text-xs text-gray-500">
|
||||
快速连接不会保存会话信息。如需保存,请使用"新建会话"。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-dark-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isConnecting}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-accent-blue hover:bg-blue-600 disabled:opacity-50 rounded-lg text-white font-medium transition-colors"
|
||||
>
|
||||
{isConnecting ? (
|
||||
'连接中...'
|
||||
) : (
|
||||
<>
|
||||
连接
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* SFTPView - SFTP 文件传输主组件 (简化版)
|
||||
*
|
||||
* 使用子组件拆分后的简化版本
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useSFTP } from '../hooks'
|
||||
import { FileListPanel } from './sftp/FileListPanel'
|
||||
import { TransferPanel } from './sftp/TransferPanel'
|
||||
import { Toolbar } from './sftp/Toolbar'
|
||||
import { ConnectionPanel } from './sftp/ConnectionPanel'
|
||||
import { ContextMenu, type MenuItem } from './sftp/ContextMenu'
|
||||
import { sortFiles } from './sftp/utils'
|
||||
import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.types'
|
||||
|
||||
interface SFTPViewProps {
|
||||
tabId: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPViewProps) {
|
||||
const {
|
||||
sessions,
|
||||
localPath,
|
||||
localFiles,
|
||||
localSelected,
|
||||
setLocalSelected,
|
||||
localHistory,
|
||||
localHistoryIndex,
|
||||
navigateLocal,
|
||||
navigateLocalBack,
|
||||
navigateLocalForward,
|
||||
loadLocalDirectory,
|
||||
remotePath,
|
||||
remoteFiles,
|
||||
remoteSelected,
|
||||
setRemoteSelected,
|
||||
remoteHistory,
|
||||
remoteHistoryIndex,
|
||||
navigateRemote,
|
||||
navigateRemoteBack,
|
||||
navigateRemoteForward,
|
||||
loadRemoteDirectory,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connect,
|
||||
transferQueue,
|
||||
uploadFiles,
|
||||
downloadFiles,
|
||||
error,
|
||||
} = useSFTP({ tabId })
|
||||
|
||||
// UI State
|
||||
const [sortField, setSortField] = useState<SortField>('name')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||
const [isTransferPanelOpen, setIsTransferPanelOpen] = useState(true)
|
||||
const [showConnectionSelector, setShowConnectionSelector] = useState(false)
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null)
|
||||
|
||||
// Sort handler
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortOrder('asc')
|
||||
}
|
||||
}, [sortField, sortOrder])
|
||||
|
||||
// Local selection
|
||||
const toggleLocalSelect = useCallback((name: string) => {
|
||||
setLocalSelected((prev: Set<string>) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
}, [setLocalSelected])
|
||||
|
||||
// Remote selection
|
||||
const toggleRemoteSelect = useCallback((name: string) => {
|
||||
setRemoteSelected((prev: Set<string>) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
}, [setRemoteSelected])
|
||||
|
||||
// Local context menu
|
||||
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
||||
e.preventDefault()
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
icon: '📂',
|
||||
label: '打开',
|
||||
action: () => file.type === 'directory' && navigateLocal(file.path),
|
||||
},
|
||||
{
|
||||
icon: '📤',
|
||||
label: '上传',
|
||||
action: () => {},
|
||||
},
|
||||
]
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||
}, [navigateLocal])
|
||||
|
||||
// Remote context menu
|
||||
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
||||
e.preventDefault()
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
icon: '📂',
|
||||
label: '打开',
|
||||
action: () => file.type === 'directory' && navigateRemote(file.path),
|
||||
},
|
||||
{
|
||||
icon: '📥',
|
||||
label: '下载',
|
||||
action: () => {
|
||||
setRemoteSelected(new Set([file.name]))
|
||||
downloadFiles()
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: '🗑️',
|
||||
label: '删除',
|
||||
danger: true,
|
||||
action: () => {},
|
||||
},
|
||||
]
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||
}, [navigateRemote, downloadFiles, setRemoteSelected])
|
||||
|
||||
// Navigate up
|
||||
const navigateLocalUp = useCallback(() => {
|
||||
const parent = localPath.split('/').slice(0, -1).join('/') || '/'
|
||||
navigateLocal(parent)
|
||||
}, [localPath, navigateLocal])
|
||||
|
||||
const navigateRemoteUp = useCallback(() => {
|
||||
const parent = remotePath.split('/').slice(0, -1).join('/') || '/'
|
||||
navigateRemote(parent)
|
||||
}, [remotePath, navigateRemote])
|
||||
|
||||
// Handle file open
|
||||
const handleLocalOpen = useCallback((file: FileItem) => {
|
||||
if (file.type === 'directory') {
|
||||
navigateLocal(file.path)
|
||||
}
|
||||
}, [navigateLocal])
|
||||
|
||||
const handleRemoteOpen = useCallback((file: FileItem) => {
|
||||
if (file.type === 'directory') {
|
||||
navigateRemote(file.path)
|
||||
}
|
||||
}, [navigateRemote])
|
||||
|
||||
// Handle upload
|
||||
const handleUpload = useCallback(() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
input.onchange = (e) => {
|
||||
uploadFiles((e.target as HTMLInputElement).files)
|
||||
}
|
||||
input.click()
|
||||
}, [uploadFiles])
|
||||
|
||||
// Handle new folder
|
||||
const handleNewFolder = useCallback(() => {
|
||||
const name = prompt('输入文件夹名称:')
|
||||
if (name && window.electronAPI?.localMkdir) {
|
||||
window.electronAPI.localMkdir(`${localPath}/${name}`).then(() => {
|
||||
loadLocalDirectory(localPath)
|
||||
})
|
||||
}
|
||||
}, [localPath, loadLocalDirectory])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-900">
|
||||
{/* Connection Status */}
|
||||
<ConnectionPanel
|
||||
isConnected={isConnected}
|
||||
isConnecting={isConnecting}
|
||||
error={error}
|
||||
sessions={sessions}
|
||||
showSelector={showConnectionSelector}
|
||||
onConnect={(sessionId) => sessionId && connect(sessionId)}
|
||||
onShowSelector={() => setShowConnectionSelector(true)}
|
||||
onCloseSelector={() => setShowConnectionSelector(false)}
|
||||
/>
|
||||
|
||||
{/* Toolbar */}
|
||||
<Toolbar
|
||||
isConnected={isConnected}
|
||||
onUpload={handleUpload}
|
||||
onDownload={downloadFiles}
|
||||
onRefresh={() => {
|
||||
loadLocalDirectory(localPath)
|
||||
if (isConnected) loadRemoteDirectory(remotePath)
|
||||
}}
|
||||
onNewFolder={handleNewFolder}
|
||||
/>
|
||||
|
||||
{/* File Panels */}
|
||||
<div className="flex-1 flex gap-4 p-4 min-h-0">
|
||||
{/* Local Panel */}
|
||||
<FileListPanel
|
||||
title="本地"
|
||||
isLocal={true}
|
||||
path={localPath}
|
||||
files={sortFiles(localFiles, sortField, sortOrder)}
|
||||
selected={localSelected}
|
||||
history={localHistory}
|
||||
historyIndex={localHistoryIndex}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onNavigate={navigateLocal}
|
||||
onNavigateBack={navigateLocalBack}
|
||||
onNavigateForward={navigateLocalForward}
|
||||
onNavigateUp={navigateLocalUp}
|
||||
onSelect={toggleLocalSelect}
|
||||
onOpen={handleLocalOpen}
|
||||
onContextMenu={showLocalContextMenu}
|
||||
onSort={handleSort}
|
||||
contextMenu={contextMenu}
|
||||
onCloseContextMenu={() => setContextMenu(null)}
|
||||
/>
|
||||
|
||||
{/* Remote Panel */}
|
||||
<FileListPanel
|
||||
title="远程"
|
||||
isLocal={false}
|
||||
path={remotePath}
|
||||
files={sortFiles(remoteFiles, sortField, sortOrder)}
|
||||
selected={remoteSelected}
|
||||
history={remoteHistory}
|
||||
historyIndex={remoteHistoryIndex}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onNavigate={navigateRemote}
|
||||
onNavigateBack={navigateRemoteBack}
|
||||
onNavigateForward={navigateRemoteForward}
|
||||
onNavigateUp={navigateRemoteUp}
|
||||
onSelect={toggleRemoteSelect}
|
||||
onOpen={handleRemoteOpen}
|
||||
onContextMenu={showRemoteContextMenu}
|
||||
onSort={handleSort}
|
||||
contextMenu={contextMenu}
|
||||
onCloseContextMenu={() => setContextMenu(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Transfer Panel */}
|
||||
<TransferPanel
|
||||
tasks={transferQueue}
|
||||
isOpen={isTransferPanelOpen}
|
||||
onToggle={() => setIsTransferPanelOpen(!isTransferPanelOpen)}
|
||||
onCancel={() => {}}
|
||||
onRemove={() => {}}
|
||||
/>
|
||||
|
||||
{/* Global Context Menu */}
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
items={contextMenu.items}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Server, Key, Lock, User, Globe, Tag, Folder } from 'lucide-react'
|
||||
import { useSessionStore, Session } from '../stores'
|
||||
import type { AuthType } from '../../shared/types/session.types'
|
||||
import { createLogger } from '../utils/logger'
|
||||
|
||||
const logger = createLogger('SessionDialog')
|
||||
|
||||
interface SessionDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
editSession?: Session | null // 编辑模式下的会话数据
|
||||
}
|
||||
|
||||
export default function SessionDialog({ isOpen, onClose, editSession }: SessionDialogProps) {
|
||||
const { addSession, updateSession } = useSessionStore()
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
authType: 'password' as AuthType,
|
||||
password: '',
|
||||
privateKey: '',
|
||||
passphrase: '',
|
||||
group: 'default',
|
||||
tags: '',
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// 当编辑会话时,填充表单数据
|
||||
useEffect(() => {
|
||||
if (editSession) {
|
||||
setFormData({
|
||||
name: editSession.name || '',
|
||||
host: editSession.host || '',
|
||||
port: editSession.port || 22,
|
||||
username: editSession.username || '',
|
||||
authType: editSession.authType || 'password',
|
||||
password: editSession.password || '',
|
||||
privateKey: editSession.privateKey || '',
|
||||
passphrase: editSession.passphrase || '',
|
||||
group: editSession.group || 'default',
|
||||
tags: editSession.tags?.join(', ') || '',
|
||||
})
|
||||
} else {
|
||||
// 重置表单
|
||||
setFormData({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
authType: 'password',
|
||||
password: '',
|
||||
privateKey: '',
|
||||
passphrase: '',
|
||||
group: 'default',
|
||||
tags: '',
|
||||
})
|
||||
}
|
||||
}, [editSession])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const isEditing = !!editSession
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const sessionData = {
|
||||
name: formData.name,
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
username: formData.username,
|
||||
authType: formData.authType,
|
||||
password: formData.password,
|
||||
privateKey: formData.privateKey,
|
||||
passphrase: formData.passphrase,
|
||||
group: formData.group,
|
||||
tags: formData.tags.split(',').map(t => t.trim()).filter(Boolean),
|
||||
}
|
||||
|
||||
if (isEditing && editSession) {
|
||||
await updateSession(editSession.id, sessionData)
|
||||
} else {
|
||||
await addSession({
|
||||
...sessionData,
|
||||
id: `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
} as Session)
|
||||
}
|
||||
|
||||
onClose()
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
authType: 'password',
|
||||
password: '',
|
||||
privateKey: '',
|
||||
passphrase: '',
|
||||
group: 'default',
|
||||
tags: '',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(isEditing ? 'Failed to update session:' : 'Failed to create session:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg bg-dark-800 rounded-xl border border-dark-700 shadow-2xl animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-dark-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-accent-green/20 rounded-lg flex items-center justify-center">
|
||||
<Server className="w-5 h-5 text-accent-green" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-200">{isEditing ? '编辑会话' : '新建会话'}</h2>
|
||||
<p className="text-sm text-gray-500">{isEditing ? '修改 SSH 连接配置' : '配置 SSH 连接信息'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-dark-700 rounded-lg text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{/* Basic info */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
会话名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="例如: 生产服务器"
|
||||
className="w-full px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
主机地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.host}
|
||||
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
|
||||
placeholder="192.168.1.1"
|
||||
className="w-full pl-9 pr-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
端口
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.port}
|
||||
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) })}
|
||||
placeholder="22"
|
||||
className="w-full px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
用户名
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
placeholder="root"
|
||||
className="w-full pl-9 pr-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication */}
|
||||
<div className="border-t border-dark-700 pt-4">
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
认证方式
|
||||
</label>
|
||||
<div className="flex gap-2 mb-4">
|
||||
{[
|
||||
{ value: 'password', label: '密码', icon: Lock },
|
||||
{ value: 'key', label: '密钥', icon: Key },
|
||||
].map((type) => {
|
||||
const Icon = type.icon
|
||||
return (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, authType: type.value as AuthType })}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border text-sm transition-colors ${
|
||||
formData.authType === type.value
|
||||
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
|
||||
: 'border-dark-700 hover:border-dark-600 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{type.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{formData.authType === 'password' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="w-full pl-9 pr-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.authType === 'key' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
私钥路径
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.privateKey}
|
||||
onChange={(e) => setFormData({ ...formData, privateKey: e.target.value })}
|
||||
placeholder="~/.ssh/id_rsa"
|
||||
className="flex-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 rounded-lg text-gray-300 transition-colors"
|
||||
>
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
密钥密码 (可选)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.passphrase}
|
||||
onChange={(e) => setFormData({ ...formData, passphrase: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Group and tags */}
|
||||
<div className="border-t border-dark-700 pt-4 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
<Folder className="inline w-3 h-3 mr-1" />
|
||||
分组
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.group}
|
||||
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
|
||||
placeholder="default"
|
||||
className="w-full px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
<Tag className="inline w-3 h-3 mr-1" />
|
||||
标签
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.tags}
|
||||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||||
placeholder="生产, 阿里云"
|
||||
className="w-full px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-dark-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-2 bg-accent-blue hover:bg-blue-600 disabled:opacity-50 rounded-lg text-white font-medium transition-colors"
|
||||
>
|
||||
{isSubmitting ? (isEditing ? '保存中...' : '创建中...') : (isEditing ? '保存' : '创建会话')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import {
|
||||
Monitor, Palette, Keyboard, Shield, Database, Terminal
|
||||
} from 'lucide-react'
|
||||
import { themes } from '../themes/terminal'
|
||||
|
||||
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ssh' | 'security' | 'about'
|
||||
|
||||
export default function SettingsView() {
|
||||
const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore()
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('general')
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [])
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general' as SettingsTab, label: '常规', icon: Monitor },
|
||||
{ id: 'appearance' as SettingsTab, label: '外观', icon: Palette },
|
||||
{ id: 'terminal' as SettingsTab, label: '终端', icon: Keyboard },
|
||||
{ id: 'ssh' as SettingsTab, label: 'SSH', icon: Shield },
|
||||
{ id: 'security' as SettingsTab, label: '安全', icon: Database },
|
||||
{ id: 'about' as SettingsTab, label: '关于', icon: Monitor },
|
||||
]
|
||||
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-dark-900">
|
||||
<div className="text-gray-400">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'general':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-200">常规设置</h2>
|
||||
<div className="space-y-4">
|
||||
<SettingToggle
|
||||
label="启动时恢复会话"
|
||||
description="应用启动时自动恢复上次的会话"
|
||||
checked={settings.restoreOnStart}
|
||||
onChange={(v) => setSetting('restoreOnStart', v)}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="自动保存会话"
|
||||
description="自动保存会话配置更改"
|
||||
checked={settings.autoSave}
|
||||
onChange={(v) => setSetting('autoSave', v)}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="显示通知"
|
||||
description="在桌面显示连接状态通知"
|
||||
checked={settings.showNotifications}
|
||||
onChange={(v) => setSetting('showNotifications', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'appearance':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-200">外观设置</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-dark-800 rounded-lg">
|
||||
<div className="font-medium text-gray-200 mb-3">主题</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ value: 'dark', label: '深色' },
|
||||
{ value: 'light', label: '浅色' },
|
||||
{ value: 'system', label: '跟随系统' },
|
||||
].map((theme) => (
|
||||
<button
|
||||
key={theme.value}
|
||||
onClick={() => setSetting('theme', theme.value as any)}
|
||||
className={`p-3 rounded-lg border text-sm transition-colors ${
|
||||
settings.theme === theme.value
|
||||
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
|
||||
: 'border-dark-700 hover:border-dark-600 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{theme.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-dark-800 rounded-lg">
|
||||
<div className="font-medium text-gray-200 mb-3">语言</div>
|
||||
<select
|
||||
value={settings.language}
|
||||
onChange={(e) => setSetting('language', e.target.value as any)}
|
||||
className="w-full px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300 focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en-US">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'terminal':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-200">终端设置</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-dark-800 rounded-lg">
|
||||
<div className="font-medium text-gray-200 mb-3">字体</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">字体族</label>
|
||||
<select
|
||||
value={settings.fontFamily}
|
||||
onChange={(e) => setSetting('fontFamily', e.target.value)}
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
|
||||
>
|
||||
<option>JetBrains Mono</option>
|
||||
<option>Fira Code</option>
|
||||
<option>Consolas</option>
|
||||
<option>Monaco</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">字体大小</label>
|
||||
<select
|
||||
value={settings.fontSize}
|
||||
onChange={(e) => setSetting('fontSize', parseInt(e.target.value))}
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
|
||||
>
|
||||
{[12, 13, 14, 15, 16, 18, 20].map((size) => (
|
||||
<option key={size} value={size}>{size}px</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-dark-800 rounded-lg">
|
||||
<div className="font-medium text-gray-200 mb-3">终端配色</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Object.entries(themes).map(([key, theme]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSetting('terminalTheme', key as any)}
|
||||
className={`p-3 rounded-lg border text-sm transition-colors ${
|
||||
settings.terminalTheme === key
|
||||
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
|
||||
: 'border-dark-700 hover:border-dark-600 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-full h-6 rounded mb-1"
|
||||
style={{ backgroundColor: theme.background }}
|
||||
/>
|
||||
{theme.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'ssh':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-200">SSH 设置</h2>
|
||||
<div className="space-y-4">
|
||||
<SettingToggle
|
||||
label="保持连接活跃"
|
||||
description="定期发送心跳包保持连接"
|
||||
checked={settings.keepAlive}
|
||||
onChange={(v) => setSetting('keepAlive', v)}
|
||||
/>
|
||||
|
||||
<div className="p-4 bg-dark-800 rounded-lg">
|
||||
<div className="font-medium text-gray-200 mb-3">默认端口</div>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.defaultPort}
|
||||
onChange={(e) => setSetting('defaultPort', parseInt(e.target.value) || 22)}
|
||||
className="w-32 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-dark-800 rounded-lg">
|
||||
<div className="font-medium text-gray-200 mb-3">超时设置 (秒)</div>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.timeout}
|
||||
onChange={(e) => setSetting('timeout', parseInt(e.target.value) || 30)}
|
||||
className="w-32 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 专业连接管理设置 */}
|
||||
<div className="p-4 bg-dark-800 rounded-lg border border-accent-blue/30">
|
||||
<div className="font-medium text-gray-200 mb-2 flex items-center gap-2">
|
||||
<span className="text-accent-blue">专业版</span>
|
||||
连接管理
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mb-4">
|
||||
配置连接池行为,优化资源使用和性能
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">最大连接数</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={settings.maxConnections || 10}
|
||||
onChange={(e) => setSetting('maxConnections', parseInt(e.target.value) || 10)}
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
|
||||
/>
|
||||
<div className="text-xs text-gray-600 mt-1">最大并发 SSH 连接数</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">最大后台连接</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={settings.maxBackgroundConnections || 5}
|
||||
onChange={(e) => setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)}
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
|
||||
/>
|
||||
<div className="text-xs text-gray-600 mt-1">非活跃标签保持连接数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">空闲超时 (分钟)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={Math.floor((settings.idleTimeoutMs || 5 * 60 * 1000) / 60000)}
|
||||
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)}
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
|
||||
/>
|
||||
<div className="text-xs text-gray-600 mt-1">无操作后进入挂起</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">挂起超时 (分钟)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={240}
|
||||
value={Math.floor((settings.suspendTimeoutMs || 30 * 60 * 1000) / 60000)}
|
||||
onChange={(e) => setSetting('suspendTimeoutMs', (parseInt(e.target.value) || 30) * 60000)}
|
||||
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
|
||||
/>
|
||||
<div className="text-xs text-gray-600 mt-1">挂起后自动断开</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingToggle
|
||||
label="启用终端快照"
|
||||
description="切换标签时保存终端内容,快速恢复显示"
|
||||
checked={settings.enableSnapshots !== false}
|
||||
onChange={(v) => setSetting('enableSnapshots', v)}
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
label="自动重连"
|
||||
description="连接断开后自动尝试重连(最多3次)"
|
||||
checked={settings.autoReconnect !== false}
|
||||
onChange={(v) => setSetting('autoReconnect', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'security':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-200">安全设置</h2>
|
||||
<div className="space-y-4">
|
||||
<SettingToggle
|
||||
label="加密存储密码"
|
||||
description="使用 AES-256 加密存储密码"
|
||||
checked={settings.encryptPasswords}
|
||||
onChange={(v) => setSetting('encryptPasswords', v)}
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
label="锁定屏幕时断开连接"
|
||||
description="系统锁屏时自动断开 SSH 连接"
|
||||
checked={settings.lockOnSleep}
|
||||
onChange={(v) => setSetting('lockOnSleep', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'about':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-200">关于 OpsXShell</h2>
|
||||
<div className="p-6 bg-dark-800 rounded-lg text-center">
|
||||
<div className="w-20 h-20 bg-dark-700 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Terminal className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-200 mb-1">OpsXShell</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">版本 1.0.0</p>
|
||||
<p className="text-sm text-gray-400 max-w-md mx-auto">
|
||||
跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · 操作便捷人性化
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex bg-dark-900">
|
||||
{/* Sidebar */}
|
||||
<div className="w-48 bg-dark-800 border-r border-dark-700 p-4">
|
||||
<nav className="space-y-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-accent-blue text-white'
|
||||
: 'text-gray-400 hover:bg-dark-700 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-8 overflow-y-auto">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 设置开关组件
|
||||
function SettingToggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 bg-dark-800 rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium text-gray-200">{label}</div>
|
||||
<div className="text-sm text-gray-500">{description}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
checked ? 'bg-accent-blue' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
checked ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Sidebar - 侧边栏组件 (简化版)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Plus, Zap, Server, Settings } from 'lucide-react'
|
||||
import { useSessionStore, Session } from '../stores'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { SessionTree } from './sidebar/SessionTree'
|
||||
import { SearchBox } from './sidebar/SearchBox'
|
||||
// Sidebar Component
|
||||
|
||||
interface SidebarProps {
|
||||
onNewSession: () => void
|
||||
onQuickConnect: () => void
|
||||
onEditSession: (session: Session) => void
|
||||
}
|
||||
|
||||
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) {
|
||||
const { sessions, setSessions, removeSession } = useSessionStore()
|
||||
const { addTab } = useTabStore()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['default']))
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; session: Session } | null>(null)
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Load sessions
|
||||
useEffect(() => {
|
||||
window.electronAPI?.sessionGetAll().then((result) => {
|
||||
if (result.success && result.sessions) {
|
||||
setSessions(result.sessions)
|
||||
}
|
||||
})
|
||||
}, [setSessions])
|
||||
|
||||
// Click outside to close context menu
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
setContextMenu(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const toggleGroup = (group: string) => {
|
||||
const newExpanded = new Set(expandedGroups)
|
||||
if (newExpanded.has(group)) newExpanded.delete(group)
|
||||
else newExpanded.add(group)
|
||||
setExpandedGroups(newExpanded)
|
||||
}
|
||||
|
||||
const handleConnect = (session: Session) => {
|
||||
setSelectedSessionId(session.id)
|
||||
addTab({
|
||||
title: session.name,
|
||||
type: 'terminal',
|
||||
sessionId: session.id,
|
||||
})
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, session: Session) => {
|
||||
e.preventDefault()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, session })
|
||||
}
|
||||
|
||||
const handleContextMenuAction = (action: string) => {
|
||||
if (!contextMenu) return
|
||||
const session = contextMenu.session
|
||||
|
||||
switch (action) {
|
||||
case 'connect':
|
||||
handleConnect(session)
|
||||
break
|
||||
case 'sftp':
|
||||
// Open SFTP tab
|
||||
addTab({
|
||||
title: `SFTP: ${session.name}`,
|
||||
type: 'sftp',
|
||||
sessionId: session.id,
|
||||
})
|
||||
break
|
||||
case 'edit':
|
||||
onEditSession(session)
|
||||
break
|
||||
case 'duplicate':
|
||||
// Duplicate session
|
||||
window.electronAPI?.sessionCreate({
|
||||
...session,
|
||||
id: undefined,
|
||||
name: `${session.name} (副本)`,
|
||||
}).then((result) => {
|
||||
if (result?.success && result.session) {
|
||||
setSessions([...sessions, result.session])
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'delete':
|
||||
if (confirm(`确定删除会话 "${session.name}"?`)) {
|
||||
removeSession(session.id)
|
||||
window.electronAPI?.sessionDelete(session.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
setContextMenu(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-dark-800 border-r border-dark-700 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center">
|
||||
<Server className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold text-lg">OpsXShell</span>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onQuickConnect}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-accent-blue hover:bg-blue-600 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
快速连接
|
||||
</button>
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
className="flex items-center justify-center px-3 py-2 bg-dark-700 hover:bg-dark-600 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-3 border-b border-dark-700">
|
||||
<SearchBox
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="搜索会话..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Session Tree */}
|
||||
<SessionTree
|
||||
sessions={sessions}
|
||||
expandedGroups={expandedGroups}
|
||||
selectedSessionId={selectedSessionId}
|
||||
onToggleGroup={toggleGroup}
|
||||
onSelectSession={handleConnect}
|
||||
onContextMenu={handleContextMenu}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => addTab({ title: '设置', type: 'settings' })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>设置</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[140px]"
|
||||
>
|
||||
{[
|
||||
{ label: '连接', action: 'connect' },
|
||||
{ label: '编辑', action: 'edit' },
|
||||
{ label: '克隆', action: 'duplicate' },
|
||||
{ label: 'SFTP 传输', action: 'sftp' },
|
||||
{ label: '删除', action: 'delete', danger: true },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.action}
|
||||
onClick={() => handleContextMenuAction(item.action)}
|
||||
className={`
|
||||
w-full px-4 py-2 text-left text-sm transition-colors
|
||||
${item.danger
|
||||
? 'text-red-400 hover:bg-red-500/20'
|
||||
: 'text-gray-300 hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Wifi, HardDrive, Clock } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export default function StatusBar() {
|
||||
const [version, setVersion] = useState('1.0.0')
|
||||
const [currentTime, setCurrentTime] = useState(new Date())
|
||||
|
||||
useEffect(() => {
|
||||
// Get app version
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.getVersion().then((v) => {
|
||||
setVersion(v)
|
||||
})
|
||||
}
|
||||
|
||||
// Update time every second
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date())
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-7 bg-accent-blue flex items-center px-3 text-xs text-white">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<Wifi className="w-3 h-3" />
|
||||
<span>就绪</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
<span>本地</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
<span className="text-blue-200">OpsXShell v{version}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* TabBar - 标签栏组件 (简化版)
|
||||
*/
|
||||
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useConnectionPool } from '../stores/connection'
|
||||
import { TabItem } from './tabbar/TabItem'
|
||||
import { TabContextMenu } from './tabbar/TabContextMenu'
|
||||
|
||||
export default function TabBar() {
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activateTab,
|
||||
closeTab,
|
||||
addTab,
|
||||
closeOtherTabs,
|
||||
closeRightTabs,
|
||||
closeLeftTabs,
|
||||
closeAllTabs,
|
||||
} = useTabStore()
|
||||
|
||||
const { disconnectConnection } = useConnectionPool()
|
||||
|
||||
const [menuState, setMenuState] = useState({
|
||||
isOpen: false,
|
||||
tabId: null as string | null,
|
||||
position: { x: 0, y: 0 }
|
||||
})
|
||||
|
||||
// 快捷键
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'w' && activeTabId) {
|
||||
e.preventDefault()
|
||||
if (tabs.length > 1) {
|
||||
handleCloseTab(activeTabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [activeTabId, tabs.length])
|
||||
|
||||
const handleCloseTab = (tabId: string) => {
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
if (tab?.connectionInstance?.connectionId) {
|
||||
disconnectConnection(tab.connectionInstance.connectionId)
|
||||
}
|
||||
closeTab(tabId)
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, tabId: string) => {
|
||||
e.preventDefault()
|
||||
setMenuState({
|
||||
isOpen: true,
|
||||
tabId,
|
||||
position: { x: e.clientX, y: e.clientY }
|
||||
})
|
||||
}
|
||||
|
||||
const currentTab = menuState.tabId ? tabs.find(t => t.id === menuState.tabId) : null
|
||||
|
||||
return (
|
||||
<div className="flex items-center bg-dark-900 border-b border-dark-700 overflow-x-auto">
|
||||
{/* Tab List */}
|
||||
{tabs.map((tab) => (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
onActivate={() => activateTab(tab.id)}
|
||||
onClose={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCloseTab(tab.id)
|
||||
}}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add Tab Button */}
|
||||
<button
|
||||
onClick={() => addTab({ title: '新标签', type: 'welcome' })}
|
||||
className="flex items-center justify-center w-10 h-10 text-gray-500 hover:text-gray-300 hover:bg-dark-800 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Context Menu */}
|
||||
<TabContextMenu
|
||||
isOpen={menuState.isOpen}
|
||||
position={menuState.position}
|
||||
onClose={() => setMenuState(prev => ({ ...prev, isOpen: false }))}
|
||||
onCloseTab={() => menuState.tabId && handleCloseTab(menuState.tabId)}
|
||||
onCloseOthers={() => menuState.tabId && closeOtherTabs(menuState.tabId)}
|
||||
onCloseRight={() => menuState.tabId && closeRightTabs(menuState.tabId)}
|
||||
onCloseLeft={() => menuState.tabId && closeLeftTabs(menuState.tabId)}
|
||||
onCloseAll={closeAllTabs}
|
||||
onClone={() => {
|
||||
if (currentTab) {
|
||||
addTab({
|
||||
title: `${currentTab.title} (副本)`,
|
||||
type: currentTab.type,
|
||||
sessionId: currentTab.sessionId,
|
||||
connectionConfig: currentTab.connectionConfig,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import WelcomeView from './WelcomeView'
|
||||
import TerminalView from './TerminalView'
|
||||
import SFTPView from './SFTPView'
|
||||
import PortForwardView from './PortForwardView'
|
||||
import SettingsView from './SettingsView'
|
||||
|
||||
interface TabContentProps {
|
||||
activeTabId: string | null
|
||||
onNewSession?: () => void
|
||||
onQuickConnect?: () => void
|
||||
}
|
||||
|
||||
export default function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentProps) {
|
||||
const { tabs } = useTabStore()
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId
|
||||
|
||||
// 根据类型渲染不同内容
|
||||
let content: React.ReactNode
|
||||
|
||||
switch (tab.type) {
|
||||
case 'terminal':
|
||||
content = <TerminalView tabId={tab.id} isActive={isActive} />
|
||||
break
|
||||
case 'sftp':
|
||||
content = <SFTPView tabId={tab.id} isActive={isActive} />
|
||||
break
|
||||
case 'portforward':
|
||||
content = <PortForwardView tabId={tab.id} isActive={isActive} />
|
||||
break
|
||||
case 'settings':
|
||||
content = <SettingsView />
|
||||
break
|
||||
case 'welcome':
|
||||
default:
|
||||
content = <WelcomeView onNewSession={onNewSession} onQuickConnect={onQuickConnect} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`absolute inset-0 transition-opacity duration-150 ${
|
||||
isActive ? 'opacity-100 z-10' : 'opacity-0 z-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { themes } from '../themes/terminal'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
|
||||
import { getSessionConfig, getConnectionStore, type ConnectionState } from '../stores/connection'
|
||||
import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import type { TerminalViewProps } from './terminal/types'
|
||||
|
||||
const logger = createLogger('TerminalView')
|
||||
|
||||
// 调试用 - 大量日志
|
||||
const DEBUG = true
|
||||
function debug(...args: any[]) {
|
||||
if (DEBUG) {
|
||||
console.log('[TerminalView]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
// 彩色高亮日志
|
||||
function logHeader(msg: string) {
|
||||
console.log(
|
||||
`%c${msg}`,
|
||||
'background: #41b883; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;'
|
||||
)
|
||||
}
|
||||
function logInfo(label: string, value: any) {
|
||||
console.log(
|
||||
`%c${label}:%c ${value}`,
|
||||
'background: #35495e; color: #fff; padding: 2px 6px; border-radius: 3px;',
|
||||
'color: #41b883; font-weight: bold;'
|
||||
)
|
||||
}
|
||||
function logSuccess(msg: string) {
|
||||
console.log(
|
||||
`%c✓ ${msg}`,
|
||||
'color: #41b883; font-weight: bold;'
|
||||
)
|
||||
}
|
||||
function logError(msg: string) {
|
||||
console.log(
|
||||
`%c✗ ${msg}`,
|
||||
'color: #ff4757; font-weight: bold;'
|
||||
)
|
||||
}
|
||||
// 使用 logError 避免 TS 错误
|
||||
if (false) logError('')
|
||||
function logWarning(msg: string) {
|
||||
console.log(
|
||||
`%c⚠ ${msg}`,
|
||||
'color: #ffa502; font-weight: bold;'
|
||||
)
|
||||
}
|
||||
|
||||
export default function TerminalView({ tabId, isActive = true }: TerminalViewProps) {
|
||||
debug('>>> RENDER tabId:', tabId, 'isActive:', isActive)
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
const xtermRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
const shellIdRef = useRef<string | null>(null)
|
||||
const connectionIdRef = useRef<string | null>(null)
|
||||
const hasConnectedRef = useRef(false)
|
||||
const isConnectingRef = useRef(false)
|
||||
const handleShellDataRef = useRef<((_event: unknown, _connId: string, shellId: string, data: Buffer) => void) | null>(null)
|
||||
const listenerRegisteredRef = useRef(false)
|
||||
|
||||
// 本地状态
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
||||
const [statusMessage, setStatusMessage] = useState<string>('')
|
||||
const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false)
|
||||
|
||||
// 全局状态
|
||||
const { settings, loadSettings } = useSettingsStore()
|
||||
const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore()
|
||||
const snapshotStore = useTerminalSnapshotStore()
|
||||
|
||||
const currentTheme = themes[settings.terminalTheme] || themes['dracula']
|
||||
|
||||
// 加载设置
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [loadSettings])
|
||||
|
||||
// 初始化 xterm
|
||||
useEffect(() => {
|
||||
debug('>>> useEffect[init] START - terminalRef.current:', !!terminalRef.current, 'xtermRef.current:', !!xtermRef.current)
|
||||
|
||||
// 修复:只检查 terminalRef,不检查 xtermRef
|
||||
// 因为在严格模式下,组件会 unmount/remount,xtermRef 可能还存在
|
||||
// 但监听器需要重新注册
|
||||
if (!terminalRef.current) {
|
||||
debug('>>> useEffect[init] EARLY RETURN - terminalRef is null')
|
||||
return
|
||||
}
|
||||
|
||||
// 处理窗口大小调整
|
||||
const handleResize = () => {
|
||||
fitAddonRef.current?.fit()
|
||||
if (shellIdRef.current && xtermRef.current) {
|
||||
const { cols, rows } = xtermRef.current
|
||||
window.electronAPI?.sshShellResize(shellIdRef.current, cols, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 xterm 不存在,创建它
|
||||
if (!xtermRef.current) {
|
||||
debug('>>> useEffect[init] Creating Terminal...')
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
fontSize: settings.fontSize,
|
||||
fontFamily: settings.fontFamily + ', Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: currentTheme.background,
|
||||
foreground: currentTheme.foreground,
|
||||
cursor: currentTheme.cursor,
|
||||
black: currentTheme.black,
|
||||
red: currentTheme.red,
|
||||
green: currentTheme.green,
|
||||
yellow: currentTheme.yellow,
|
||||
blue: currentTheme.blue,
|
||||
magenta: currentTheme.magenta,
|
||||
cyan: currentTheme.cyan,
|
||||
white: currentTheme.white,
|
||||
brightBlack: currentTheme.brightBlack,
|
||||
brightRed: currentTheme.brightRed,
|
||||
brightGreen: currentTheme.brightGreen,
|
||||
brightYellow: currentTheme.brightYellow,
|
||||
brightBlue: currentTheme.brightBlue,
|
||||
brightMagenta: currentTheme.brightMagenta,
|
||||
brightCyan: currentTheme.brightCyan,
|
||||
brightWhite: currentTheme.brightWhite,
|
||||
},
|
||||
scrollback: 10000,
|
||||
rows: 30
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.open(terminalRef.current)
|
||||
|
||||
fitAddon.fit()
|
||||
debug('>>> Terminal opened, cols:', term.cols, 'rows:', term.rows)
|
||||
|
||||
xtermRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
debug('>>> xtermRef set:', !!xtermRef.current)
|
||||
|
||||
// 处理终端输入
|
||||
term.onData((data) => {
|
||||
if (shellIdRef.current && window.electronAPI) {
|
||||
// 更新活动状态
|
||||
if (connectionIdRef.current) {
|
||||
getConnectionStore().updateActivity(connectionIdRef.current)
|
||||
}
|
||||
window.electronAPI.sshShellWrite(shellIdRef.current, data)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
debug('>>> useEffect[init] xterm already exists, skipping creation')
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
// IP 地址高亮处理 - 将包含 "from" 或 "Last login" 行中的 IP 地址标记为绿色
|
||||
const highlightIPAddresses = (text: string): string => {
|
||||
// 匹配 IPv4 地址格式 (xxx.xxx.xxx.xxx)
|
||||
const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g
|
||||
// 如果包含 Last login 或 from IP,将 IP 地址替换为绿色
|
||||
if (text.includes('Last login') || text.includes(' from ')) {
|
||||
return text.replace(ipRegex, '\x1b[32m$1\x1b[0m')
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// Shell 数据处理器 - 直接处理,不依赖外部 ref
|
||||
// 使用 ref 存储 handler,避免闭包问题
|
||||
handleShellDataRef.current = (_event: unknown, _connId: string, shellId: string, data: Buffer) => {
|
||||
const text = data.toString('utf-8')
|
||||
|
||||
// ===== 大量调试日志 =====
|
||||
logHeader('📝 SHELL DATA RECEIVED')
|
||||
logInfo('shellId (received)', shellId)
|
||||
logInfo('shellId (expected)', shellIdRef.current)
|
||||
logInfo('xterm exists', !!xtermRef.current)
|
||||
logInfo('data length', data.length)
|
||||
logInfo('MATCH', shellId === shellIdRef.current)
|
||||
console.log(' Data preview:', text.substring(0, 200).replace(/\n/g, '\\n'))
|
||||
|
||||
// 使用 ref 获取最新的 shellId,避免闭包问题
|
||||
if (shellId === shellIdRef.current && xtermRef.current) {
|
||||
logSuccess('WRITING TO XTERM')
|
||||
// 处理数据,高亮 IP 地址(Last login 或 from IP 行)
|
||||
let processedData = data
|
||||
|
||||
// 如果包含 Last login 或 from IP,处理 IP 地址高亮
|
||||
if (text.includes('Last login') || text.includes(' from ')) {
|
||||
logger.debug('[Terminal] Found IP address line, highlighting IP')
|
||||
const highlighted = highlightIPAddresses(text)
|
||||
processedData = Buffer.from(highlighted, 'utf-8')
|
||||
}
|
||||
|
||||
// 写入 xterm
|
||||
xtermRef.current.write(processedData)
|
||||
console.log('>>> DATA WRITTEN TO XTERM <<<')
|
||||
|
||||
// 更新传输统计
|
||||
const connId = connectionIdRef.current
|
||||
if (connId) {
|
||||
const conn = getConnectionStore().getConnection(connId)
|
||||
if (conn) {
|
||||
conn.stats.bytesTransferred += data.length
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logWarning('SKIPPED: shellId mismatch or xterm not ready')
|
||||
logInfo('Expected', shellIdRef.current)
|
||||
logInfo('Received', shellId)
|
||||
logInfo('xterm exists', !!xtermRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
// 只注册一次监听器(防止严格模式下重复注册)
|
||||
if (!listenerRegisteredRef.current) {
|
||||
debug('>>> Registering onShellData listener')
|
||||
const handler = (event: any, connectionId: string, shellId: string, data: Buffer) => {
|
||||
console.log('=== IPC onShellData RAW received ===')
|
||||
console.log(' connectionId:', connectionId)
|
||||
console.log(' shellId:', shellId)
|
||||
console.log(' data type:', typeof data)
|
||||
console.log(' data length:', data?.length)
|
||||
handleShellDataRef.current?.(event, connectionId, shellId, data)
|
||||
}
|
||||
window.electronAPI?.onShellData(handler)
|
||||
listenerRegisteredRef.current = true
|
||||
debug('>>> onShellData listener registered')
|
||||
} else {
|
||||
debug('>>> onShellData listener already registered, skipping')
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
// 不在这里移除监听器,避免严格模式下的问题
|
||||
// 监听器会在组件真正卸载时通过 removeAllListeners 清理
|
||||
|
||||
// 注意:不要在严格模式下 dispose terminal
|
||||
}
|
||||
}, []) // 只在挂载时运行
|
||||
|
||||
// 保存快照
|
||||
const saveSnapshot = useCallback(() => {
|
||||
if (!xtermRef.current || !connectionIdRef.current) return
|
||||
|
||||
useTerminalSnapshotStore.getState().createSnapshot(tabId, xtermRef.current, {
|
||||
connectionId: connectionIdRef.current,
|
||||
shellId: shellIdRef.current,
|
||||
})
|
||||
}, [tabId])
|
||||
|
||||
// 恢复快照
|
||||
const restoreSnapshot = useCallback(() => {
|
||||
if (!xtermRef.current) return false
|
||||
|
||||
const snapshot = useTerminalSnapshotStore.getState().getSnapshot(tabId)
|
||||
if (!snapshot) return false
|
||||
|
||||
// 清屏
|
||||
xtermRef.current.clear()
|
||||
|
||||
// 恢复内容
|
||||
const content = snapshot.buffer.join('\r\n')
|
||||
if (content) {
|
||||
xtermRef.current.write(content)
|
||||
}
|
||||
|
||||
// 恢复光标
|
||||
xtermRef.current.write(`\x1b[${snapshot.cursorY + 1};${snapshot.cursorX + 1}H`)
|
||||
|
||||
return true
|
||||
}, [tabId])
|
||||
|
||||
// 连接管理
|
||||
const connect = useCallback(async () => {
|
||||
logHeader('CONNECT() CALLED')
|
||||
logInfo('tabId', tabId)
|
||||
logInfo('isConnectingRef', isConnectingRef.current)
|
||||
logInfo('hasConnectedRef', hasConnectedRef.current)
|
||||
logInfo('xtermRef', !!xtermRef.current)
|
||||
|
||||
if (isConnectingRef.current) {
|
||||
console.log('>>> CONNECT: Already connecting, skipping <<<')
|
||||
logger.debug('[Terminal] Connection already in progress, skipping')
|
||||
return
|
||||
}
|
||||
isConnectingRef.current = true
|
||||
console.log('>>> isConnectingRef set to true <<<')
|
||||
|
||||
// 直接使用 getTabById,避免 useCallback 循环问题
|
||||
const currentTab = getTabById(tabId)
|
||||
console.log(' currentTab:', currentTab ? 'found' : 'NOT FOUND')
|
||||
if (!currentTab) {
|
||||
logger.debug('[Terminal] Tab not found:', tabId)
|
||||
isConnectingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug('[Terminal] Starting connection for tab:', tabId, 'sessionId:', currentTab.sessionId)
|
||||
console.log('>>> Starting connection for tab:', tabId, 'sessionId:', currentTab.sessionId)
|
||||
|
||||
// 检查是否已有连接
|
||||
if (connectionIdRef.current) {
|
||||
const conn = getConnectionStore().getConnection(connectionIdRef.current)
|
||||
if (conn) {
|
||||
if (conn.state === 'connected' || conn.state === 'background') {
|
||||
// 已有活跃连接,恢复显示
|
||||
shellIdRef.current = currentTab.connectionInstance?.shellId || null
|
||||
setConnectionState(conn.state)
|
||||
updateTabStatus(tabId, conn.state === 'connected' ? 'connected' : 'disconnected')
|
||||
isConnectingRef.current = false
|
||||
return
|
||||
} else if (conn.state === 'suspended') {
|
||||
// 连接被挂起,需要恢复
|
||||
setShowSuspendedOverlay(true)
|
||||
setConnectionState('suspended')
|
||||
updateTabStatus(tabId, 'disconnected')
|
||||
isConnectingRef.current = false
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 开始连接
|
||||
setConnectionState('connecting')
|
||||
updateTabStatus(tabId, 'connecting')
|
||||
|
||||
try {
|
||||
// 获取配置
|
||||
let config: {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password?: string
|
||||
privateKey?: string
|
||||
passphrase?: string
|
||||
} | null = null
|
||||
|
||||
if (currentTab.connectionConfig) {
|
||||
config = {
|
||||
host: currentTab.connectionConfig.host,
|
||||
port: currentTab.connectionConfig.port || 22,
|
||||
username: currentTab.connectionConfig.username,
|
||||
password: currentTab.connectionConfig.password,
|
||||
privateKey: currentTab.connectionConfig.privateKey,
|
||||
passphrase: currentTab.connectionConfig.passphrase,
|
||||
}
|
||||
} else if (currentTab.sessionId) {
|
||||
config = await getSessionConfig(currentTab.sessionId)
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
throw new Error('无可用连接信息')
|
||||
}
|
||||
logSuccess(`Config ready: ${config.host}:${config.port} (${config.username})`)
|
||||
|
||||
// 创建连接
|
||||
console.log('>>> Calling createConnection...')
|
||||
const result = await getConnectionStore().createConnection(tabId, config)
|
||||
console.log('>>> createConnection result:', result)
|
||||
|
||||
if (!result.success || !result.connectionId) {
|
||||
throw new Error(result.error || '连接失败')
|
||||
}
|
||||
|
||||
const connectionId = result.connectionId
|
||||
connectionIdRef.current = connectionId
|
||||
logSuccess(`connectionId set: ${connectionId}`)
|
||||
|
||||
// 创建 Shell - 先生成前端 shellId,确保数据不会丢失
|
||||
const pendingShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
logger.debug('[Terminal] Pre-assigning shellId:', pendingShellId)
|
||||
shellIdRef.current = pendingShellId
|
||||
logSuccess(`shellIdRef set to: ${pendingShellId}`)
|
||||
|
||||
logger.debug('[Terminal] Creating shell for connection:', connectionId, 'with shellId:', pendingShellId)
|
||||
console.log('>>> Calling sshCreateShell...')
|
||||
const shellResult = await window.electronAPI?.sshCreateShell(connectionId, {
|
||||
cols: xtermRef.current?.cols || 80,
|
||||
rows: xtermRef.current?.rows || 24,
|
||||
}, pendingShellId)
|
||||
console.log('>>> sshCreateShell result:', shellResult)
|
||||
|
||||
if (!shellResult?.success || !shellResult.shellId) {
|
||||
logger.error('[Terminal] Shell creation failed:', shellResult?.error)
|
||||
console.error('>>> Shell creation failed:', shellResult?.error)
|
||||
shellIdRef.current = null
|
||||
await getConnectionStore().disconnectConnection(connectionId, 'shell creation failed')
|
||||
throw new Error(shellResult?.error || '创建终端失败')
|
||||
}
|
||||
|
||||
// shellId 应该和 pendingShellId 相同(由后端确认)
|
||||
const confirmedShellId = shellResult.shellId
|
||||
logger.debug('[Terminal] Shell confirmed with ID:', confirmedShellId)
|
||||
logSuccess(`Shell confirmed: ${confirmedShellId}`)
|
||||
// shellIdRef 已经是 confirmedShellId(等于 pendingShellId)
|
||||
getConnectionStore().addShell(connectionId, confirmedShellId)
|
||||
|
||||
// 更新标签
|
||||
const connectionInstance: ConnectionInstance = {
|
||||
connectionId,
|
||||
shellId: confirmedShellId,
|
||||
}
|
||||
updateTabConnectionInstance(tabId, connectionInstance)
|
||||
|
||||
// 更新状态
|
||||
setConnectionState('connected')
|
||||
updateTabStatus(tabId, 'connected')
|
||||
getConnectionStore().setActiveTab(connectionId, tabId)
|
||||
|
||||
// 更新标题
|
||||
if (currentTab.title === '新终端' || currentTab.title === 'Terminal') {
|
||||
updateTab(tabId, {
|
||||
title: `${config.username}@${config.host}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 等待服务器返回真实的 Last login 信息和提示符
|
||||
// 不手动写入任何内容,让 SSH 服务器的自然输出显示
|
||||
|
||||
xtermRef.current?.focus()
|
||||
|
||||
} catch (error: any) {
|
||||
setConnectionState('error')
|
||||
updateTabStatus(tabId, 'error')
|
||||
setStatusMessage(`错误: ${error.message}`)
|
||||
xtermRef.current?.writeln(`\r\n\x1b[31m错误: ${error.message}\x1b[0m`)
|
||||
} finally {
|
||||
isConnectingRef.current = false
|
||||
hasConnectedRef.current = true
|
||||
}
|
||||
}, [tabId, updateTabConnectionInstance, updateTabStatus, getTabById])
|
||||
|
||||
// 注意:不再使用模拟的 Last login 信息
|
||||
// 真实的 SSH 服务器会自动发送欢迎信息和提示符
|
||||
|
||||
// 恢复挂起的连接
|
||||
const resumeConnection = useCallback(async () => {
|
||||
if (!connectionIdRef.current) return
|
||||
|
||||
setConnectionState('reconnecting')
|
||||
const resumed = await getConnectionStore().resumeConnection(connectionIdRef.current)
|
||||
|
||||
if (resumed) {
|
||||
// 获取新的连接 ID
|
||||
const currentTab = getTabById(tabId)
|
||||
if (currentTab?.connectionInstance?.connectionId) {
|
||||
connectionIdRef.current = currentTab.connectionInstance.connectionId
|
||||
|
||||
// 创建新的 shell - 预分配 shellId 防止数据丢失
|
||||
const newShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
shellIdRef.current = newShellId
|
||||
|
||||
const shellResult = await window.electronAPI?.sshCreateShell(connectionIdRef.current, {
|
||||
cols: xtermRef.current?.cols || 80,
|
||||
rows: xtermRef.current?.rows || 24,
|
||||
}, newShellId)
|
||||
|
||||
if (shellResult?.success && shellResult.shellId) {
|
||||
// shellId 应该和 newShellId 相同
|
||||
getConnectionStore().addShell(connectionIdRef.current, shellResult.shellId)
|
||||
|
||||
updateTabConnectionInstance(tabId, {
|
||||
connectionId: connectionIdRef.current,
|
||||
shellId: shellResult.shellId,
|
||||
})
|
||||
|
||||
setConnectionState('connected')
|
||||
updateTabStatus(tabId, 'connected')
|
||||
setShowSuspendedOverlay(false)
|
||||
|
||||
// 恢复连接成功,等待服务器发送提示符
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复失败,重新连接
|
||||
setConnectionState('disconnected')
|
||||
hasConnectedRef.current = false
|
||||
setShowSuspendedOverlay(false)
|
||||
connect()
|
||||
}, [connect, updateTabConnectionInstance, updateTabStatus, getTabById, tabId])
|
||||
|
||||
// 初始连接
|
||||
useEffect(() => {
|
||||
logHeader('INITIAL CONNECT useEffect')
|
||||
logInfo('hasConnectedRef', hasConnectedRef.current)
|
||||
logInfo('isActive', isActive)
|
||||
|
||||
if (!hasConnectedRef.current && isActive) {
|
||||
// 检查是否有快照
|
||||
const hasSnapshot = snapshotStore.hasSnapshot(tabId)
|
||||
logInfo('hasSnapshot', hasSnapshot)
|
||||
if (hasSnapshot && isActive) {
|
||||
// 先恢复快照
|
||||
restoreSnapshot()
|
||||
}
|
||||
|
||||
logSuccess('Calling connect() from initial useEffect')
|
||||
connect()
|
||||
} else {
|
||||
logWarning('SKIPPED: hasConnectedRef=true or isActive=false')
|
||||
}
|
||||
}, []) // 只在挂载时执行
|
||||
|
||||
// 处理活跃状态变化
|
||||
useEffect(() => {
|
||||
if (!hasConnectedRef.current) return
|
||||
|
||||
const pool = getConnectionStore()
|
||||
|
||||
if (isActive) {
|
||||
// 标签变为活跃
|
||||
if (connectionIdRef.current) {
|
||||
const conn = pool.getConnection(connectionIdRef.current)
|
||||
if (conn?.state === 'suspended') {
|
||||
setShowSuspendedOverlay(true)
|
||||
setConnectionState('suspended')
|
||||
} else {
|
||||
pool.setActiveTab(connectionIdRef.current, tabId)
|
||||
// 同步本地状态
|
||||
setConnectionState(conn?.state === 'connected' || conn?.state === 'background' ? conn.state : 'connected')
|
||||
// 不再恢复快照 - shell 是实时输出的,重复写入会导致内容重复
|
||||
}
|
||||
}
|
||||
xtermRef.current?.focus()
|
||||
} else {
|
||||
// 标签进入后台 - 保存快照用于恢复
|
||||
if (connectionIdRef.current) {
|
||||
pool.setBackgroundTab(connectionIdRef.current, tabId)
|
||||
saveSnapshot()
|
||||
}
|
||||
}
|
||||
}, [isActive, tabId, saveSnapshot])
|
||||
|
||||
// 重连功能
|
||||
const handleReconnect = useCallback(() => {
|
||||
if (connectionState === 'error' || connectionState === 'disconnected' || connectionState === 'suspended') {
|
||||
if (connectionIdRef.current) {
|
||||
getConnectionStore().disconnectConnection(connectionIdRef.current, 'manual reconnect')
|
||||
}
|
||||
connectionIdRef.current = null
|
||||
shellIdRef.current = null
|
||||
hasConnectedRef.current = false
|
||||
setConnectionState('disconnected')
|
||||
setShowSuspendedOverlay(false)
|
||||
xtermRef.current?.clear()
|
||||
connect()
|
||||
}
|
||||
}, [connectionState, connect])
|
||||
|
||||
// 组件卸载时清理监听器
|
||||
useEffect(() => {
|
||||
logHeader('📺 TerminalView MOUNT')
|
||||
logInfo('tabId', tabId)
|
||||
return () => {
|
||||
logHeader('📺 TerminalView UNMOUNT')
|
||||
logInfo('tabId', tabId)
|
||||
// 重置连接状态,防止严格模式下 remount 时状态混乱
|
||||
isConnectingRef.current = false
|
||||
listenerRegisteredRef.current = false // 重置监听器注册标记
|
||||
logSuccess('Reset isConnectingRef and listenerRegisteredRef to false')
|
||||
// 组件真正卸载时移除所有监听器
|
||||
window.electronAPI?.removeAllListeners('ssh:shellData')
|
||||
logSuccess('Removed all ssh:shellData listeners')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-900">
|
||||
{/* 终端容器 */}
|
||||
<div className="flex-1 p-2 overflow-hidden relative">
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="w-full h-full bg-dark-900 rounded"
|
||||
/>
|
||||
|
||||
{/* 挂起状态覆盖层 */}
|
||||
{showSuspendedOverlay && (
|
||||
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
|
||||
<div className="text-center">
|
||||
<div className="text-yellow-400 mb-2 text-lg">连接已挂起</div>
|
||||
<div className="text-gray-400 text-sm mb-4">
|
||||
长时间未操作,连接已进入挂起状态
|
||||
</div>
|
||||
<button
|
||||
onClick={resumeConnection}
|
||||
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-500 text-white rounded transition-colors mr-2"
|
||||
>
|
||||
恢复连接
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-gray-200 rounded transition-colors"
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误状态覆盖层 */}
|
||||
{connectionState === 'error' && (
|
||||
<div className="absolute inset-0 bg-dark-900/80 flex items-center justify-center z-10">
|
||||
<div className="text-center">
|
||||
<div className="text-red-400 mb-2">连接失败</div>
|
||||
<div className="text-gray-400 text-sm mb-4">{statusMessage}</div>
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-500 text-white rounded transition-colors"
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 连接中状态 */}
|
||||
{connectionState === 'connecting' && (
|
||||
<div className="absolute inset-0 bg-dark-900/50 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="text-cyan-400 animate-pulse">连接中...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 连接状态指示器 - 简洁版 */}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-2 z-20">
|
||||
{/* 状态指示已移至 TabBar */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap } from 'lucide-react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onNewSession?: () => void
|
||||
onQuickConnect?: () => void
|
||||
}
|
||||
|
||||
export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeViewProps) {
|
||||
const { addTab } = useTabStore()
|
||||
|
||||
const recentConnections = [
|
||||
{ name: '本地开发服务器', host: '192.168.1.100', lastConnected: '2小时前' },
|
||||
{ name: '生产环境', host: '10.0.0.1', lastConnected: '1天前' },
|
||||
{ name: '测试服务器', host: '172.16.0.50', lastConnected: '3天前' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center bg-dark-900 p-8">
|
||||
<div className="text-center mb-12">
|
||||
<div className="w-20 h-20 bg-dark-700 rounded-2xl flex items-center justify-center mx-auto shadow-lg">
|
||||
<Terminal className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl mb-12">
|
||||
<button
|
||||
onClick={onQuickConnect}
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-blue transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-accent-blue/20 rounded-lg flex items-center justify-center group-hover:bg-accent-blue/30 transition-colors">
|
||||
<Zap className="w-6 h-6 text-accent-blue" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold text-gray-200">快速连接</div>
|
||||
<div className="text-sm text-gray-500">输入地址快速建立连接</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-green transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-accent-green/20 rounded-lg flex items-center justify-center group-hover:bg-accent-green/30 transition-colors">
|
||||
<Plus className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold text-gray-200">新建会话</div>
|
||||
<div className="text-sm text-gray-500">创建并保存新的会话</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => addTab({ title: 'SFTP', type: 'sftp' })}
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-purple transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-accent-purple/20 rounded-lg flex items-center justify-center group-hover:bg-accent-purple/30 transition-colors">
|
||||
<FolderSync className="w-6 h-6 text-accent-purple" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold text-gray-200">SFTP 传输</div>
|
||||
<div className="text-sm text-gray-500">可视化文件管理</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => addTab({ title: '端口转发', type: 'portforward' })}
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-orange transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center group-hover:bg-orange-500/30 transition-colors">
|
||||
<ArrowRightLeft className="w-6 h-6 text-orange-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold text-gray-200">端口转发</div>
|
||||
<div className="text-sm text-gray-500">本地/远程/SOCKS5 代理</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => addTab({ title: '设置', type: 'settings' })}
|
||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-gray-500 transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-gray-700/50 rounded-lg flex items-center justify-center group-hover:bg-gray-700 transition-colors">
|
||||
<Settings className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold text-gray-200">设置</div>
|
||||
<div className="text-sm text-gray-500">配置应用偏好</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recent connections */}
|
||||
<div className="w-full max-w-2xl">
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-4">最近连接</h3>
|
||||
<div className="space-y-2">
|
||||
{recentConnections.map((conn, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="w-full flex items-center justify-between p-4 bg-dark-800 hover:bg-dark-700 rounded-lg border border-dark-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-5 h-5 text-accent-blue" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-gray-200">{conn.name}</div>
|
||||
<div className="text-sm text-gray-500">{conn.host}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{conn.lastConnected}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shortcuts hint */}
|
||||
<div className="mt-12 flex items-center gap-6 text-sm text-gray-500">
|
||||
<span>快捷键:</span>
|
||||
<span><kbd className="px-2 py-1 bg-dark-800 rounded">Ctrl+N</kbd> 新建会话</span>
|
||||
<span><kbd className="px-2 py-1 bg-dark-800 rounded">Ctrl+K</kbd> 快速连接</span>
|
||||
<span><kbd className="px-2 py-1 bg-dark-800 rounded">Ctrl+T</kbd> 新建标签</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 按钮组件
|
||||
*
|
||||
* 统一按钮样式,支持多种变体和尺寸
|
||||
*/
|
||||
|
||||
import { type ReactNode, type ButtonHTMLAttributes } from 'react'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** 按钮变体 */
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
/** 尺寸 */
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
/** 加载状态 */
|
||||
loading?: boolean
|
||||
/** 图标 */
|
||||
icon?: ReactNode
|
||||
/** 子元素 */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
primary: 'bg-accent-blue hover:bg-blue-600 text-white',
|
||||
secondary: 'bg-dark-700 hover:bg-dark-600 text-gray-200',
|
||||
danger: 'bg-red-600 hover:bg-red-500 text-white',
|
||||
ghost: 'hover:bg-dark-700 text-gray-400 hover:text-gray-200',
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-3 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用按钮组件
|
||||
*
|
||||
* @example
|
||||
* <Button>保存</Button>
|
||||
* <Button variant="danger" onClick={handleDelete}>删除</Button>
|
||||
* <Button loading>保存中...</Button>
|
||||
* <Button icon={<Plus />}>新建</Button>
|
||||
*/
|
||||
export default function Button({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
icon,
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`
|
||||
inline-flex items-center justify-center gap-2
|
||||
rounded-lg font-medium transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
${className}
|
||||
`}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
{!loading && icon}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 错误边界组件
|
||||
*
|
||||
* 捕获子组件树中的 JavaScript 错误,防止整个应用崩溃
|
||||
* 体现 "安全可靠" 和 "操作便捷人性化" 理念
|
||||
*/
|
||||
|
||||
import { Component, type ReactNode } from 'react'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
import { handleError } from '../../utils/error-handler'
|
||||
|
||||
const logger = createLogger('ErrorBoundary')
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认错误_fallback UI
|
||||
*/
|
||||
function DefaultFallback({ error, onReset }: { error: Error; onReset: () => void }) {
|
||||
const appError = handleError(error)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-dark-900 p-4">
|
||||
<div className="max-w-md w-full bg-dark-800 rounded-xl p-6 border border-dark-700">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-200">出错了</h2>
|
||||
<p className="text-sm text-gray-500">应用遇到意外问题</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-900 rounded-lg p-4 mb-4">
|
||||
<p className="text-sm text-gray-300 mb-2">{appError.message}</p>
|
||||
{appError.recoverable && (
|
||||
<p className="text-xs text-green-400">此错误可以恢复,请尝试刷新页面</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
重启应用
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-gray-600 text-center">
|
||||
如果问题持续存在,请检查日志或联系支持
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误边界组件
|
||||
*
|
||||
* @example
|
||||
* <ErrorBoundary>
|
||||
* <MyComponent />
|
||||
* </ErrorBoundary>
|
||||
*/
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// 记录错误日志
|
||||
logger.error('组件错误边界捕获到异常', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack
|
||||
})
|
||||
|
||||
// 调用外部错误处理回调
|
||||
this.props.onError?.(error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
// 使用自定义 fallback 或默认 fallback
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
return (
|
||||
<DefaultFallback
|
||||
error={this.state.error}
|
||||
onReset={this.handleReset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 加载状态组件
|
||||
*
|
||||
* 统一的加载 UI,体现 "操作便捷人性化" 理念
|
||||
*/
|
||||
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface LoadingProps {
|
||||
/** 加载文本 */
|
||||
text?: string
|
||||
/** 尺寸 */
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
/** 是否全屏 */
|
||||
fullscreen?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: {
|
||||
icon: 'w-4 h-4',
|
||||
text: 'text-xs',
|
||||
},
|
||||
md: {
|
||||
icon: 'w-6 h-6',
|
||||
text: 'text-sm',
|
||||
},
|
||||
lg: {
|
||||
icon: 'w-8 h-8',
|
||||
text: 'text-base',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载组件
|
||||
*
|
||||
* @example
|
||||
* <Loading text="连接中..." />
|
||||
* <Loading size="sm" />
|
||||
* <Loading fullscreen text="应用启动中..." />
|
||||
*/
|
||||
export default function Loading({
|
||||
text = '加载中...',
|
||||
size = 'md',
|
||||
fullscreen = false,
|
||||
className = ''
|
||||
}: LoadingProps) {
|
||||
const content = (
|
||||
<div className={`flex flex-col items-center gap-3 ${className}`}>
|
||||
<Loader2 className={`${sizeMap[size].icon} text-accent-blue animate-spin`} />
|
||||
{text && (
|
||||
<span className={`${sizeMap[size].text} text-gray-400`}>{text}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (fullscreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-dark-900/90 flex items-center justify-center z-50">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* 按钮加载状态
|
||||
*
|
||||
* @example
|
||||
* <button disabled={isLoading}>
|
||||
* {isLoading ? <ButtonLoading text="保存中..." /> : '保存'}
|
||||
* </button>
|
||||
*/
|
||||
export function ButtonLoading({ text = '处理中...' }: { text?: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 骨架屏组件
|
||||
*
|
||||
* @example
|
||||
* <Skeleton className="h-4 w-32" />
|
||||
* <Skeleton className="h-20 w-full" />
|
||||
*/
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<div className={`bg-dark-700 animate-pulse rounded ${className}`} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 模态框组件
|
||||
*
|
||||
* 统一的对话框 UI
|
||||
*/
|
||||
|
||||
import { type ReactNode, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import Button from './Button'
|
||||
|
||||
interface ModalProps {
|
||||
/** 是否显示 */
|
||||
isOpen: boolean
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 内容 */
|
||||
children: ReactNode
|
||||
/** 确认按钮文本 */
|
||||
confirmText?: string
|
||||
/** 取消按钮文本 */
|
||||
cancelText?: string
|
||||
/** 确认回调 */
|
||||
onConfirm?: () => void
|
||||
/** 关闭回调 */
|
||||
onClose: () => void
|
||||
/** 是否显示确认按钮 */
|
||||
showConfirm?: boolean
|
||||
/** 是否危险操作 */
|
||||
isDanger?: boolean
|
||||
/** 加载状态 */
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 模态框组件
|
||||
*
|
||||
* @example
|
||||
* <Modal
|
||||
* isOpen={isOpen}
|
||||
* title="确认删除"
|
||||
* onConfirm={handleDelete}
|
||||
* onClose={() => setIsOpen(false)}
|
||||
* isDanger
|
||||
* >
|
||||
* 确定要删除此会话吗?
|
||||
* </Modal>
|
||||
*/
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
title,
|
||||
children,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
onConfirm,
|
||||
onClose,
|
||||
showConfirm = true,
|
||||
isDanger = false,
|
||||
loading = false,
|
||||
}: ModalProps) {
|
||||
// ESC 键关闭
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleEsc)
|
||||
return () => window.removeEventListener('keydown', handleEsc)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* 遮罩 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="relative bg-dark-800 rounded-xl border border-dark-700 shadow-2xl w-full max-w-md">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
|
||||
<h3 className="text-lg font-semibold text-gray-200">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-dark-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-dark-700">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
{showConfirm && (
|
||||
<Button
|
||||
variant={isDanger ? 'danger' : 'primary'}
|
||||
onClick={onConfirm}
|
||||
loading={loading}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 组件统一导出
|
||||
*
|
||||
* 使用方式:
|
||||
* import { Sidebar, TabBar, TerminalView } from '../components'
|
||||
*/
|
||||
|
||||
// 布局组件 (当前在根目录,将来移到 layout/)
|
||||
export { default as Sidebar } from './Sidebar'
|
||||
export { default as TabBar } from './TabBar'
|
||||
export { default as TabContent } from './TabContent'
|
||||
export { default as StatusBar } from './StatusBar'
|
||||
|
||||
// 功能组件 (当前在根目录,将来移到对应目录)
|
||||
export { default as TerminalView } from './TerminalView'
|
||||
export { default as SFTPView } from './SFTPView'
|
||||
export { default as SettingsView } from './SettingsView'
|
||||
export { default as SessionDialog } from './SessionDialog'
|
||||
export { default as QuickConnectDialog } from './QuickConnectDialog'
|
||||
export { default as WelcomeView } from './WelcomeView'
|
||||
|
||||
// 通用组件
|
||||
export { default as ErrorBoundary } from './common/ErrorBoundary'
|
||||
export { default as Loading, ButtonLoading, Skeleton } from './common/Loading'
|
||||
export { default as Button } from './common/Button'
|
||||
export { default as Modal } from './common/Modal'
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Breadcrumb - 路径导航组件
|
||||
*/
|
||||
|
||||
import { ChevronRight, Home } from 'lucide-react'
|
||||
|
||||
interface BreadcrumbProps {
|
||||
path: string
|
||||
onNavigate: (p: string) => void
|
||||
onHome: () => void
|
||||
}
|
||||
|
||||
export function Breadcrumb({ path, onNavigate, onHome }: BreadcrumbProps) {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-sm min-w-0">
|
||||
<button
|
||||
onClick={onHome}
|
||||
className="p-1.5 hover:bg-slate-700 rounded transition-colors flex-shrink-0"
|
||||
title="主目录"
|
||||
>
|
||||
<Home className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigate('/')}
|
||||
className="px-1.5 py-0.5 hover:bg-slate-700 rounded text-gray-400 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
/
|
||||
</button>
|
||||
{parts.map((part, index) => (
|
||||
<span key={index} className="flex items-center min-w-0">
|
||||
<ChevronRight className="w-3 h-3 text-slate-600 mx-0.5 flex-shrink-0" />
|
||||
<button
|
||||
onClick={() => onNavigate('/' + parts.slice(0, index + 1).join('/'))}
|
||||
className="px-1.5 py-0.5 hover:bg-slate-700 rounded text-gray-200 hover:text-white transition-colors truncate max-w-[120px]"
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* ConnectionPanel - SFTP 连接状态面板
|
||||
*
|
||||
* 显示连接状态或连接选择界面
|
||||
*/
|
||||
|
||||
import { Monitor, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { ConnectionSelector } from './ConnectionSelector'
|
||||
import type { Session } from '../../../shared/types/session.types'
|
||||
|
||||
interface ConnectionPanelProps {
|
||||
isConnected: boolean
|
||||
isConnecting: boolean
|
||||
error: string | null
|
||||
sessions: Session[]
|
||||
showSelector: boolean
|
||||
onConnect: (sessionId: string) => void
|
||||
onShowSelector: () => void
|
||||
onCloseSelector: () => void
|
||||
}
|
||||
|
||||
export function ConnectionPanel({
|
||||
isConnected,
|
||||
isConnecting,
|
||||
error,
|
||||
sessions,
|
||||
showSelector,
|
||||
onConnect,
|
||||
onShowSelector,
|
||||
onCloseSelector,
|
||||
}: ConnectionPanelProps) {
|
||||
// Connected State
|
||||
if (isConnected) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-green-500/10 border-b border-green-500/20">
|
||||
<Monitor className="w-4 h-4 text-green-400" />
|
||||
<span className="text-sm text-green-400">已连接</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Connecting State
|
||||
if (isConnecting) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-accent-blue/10 border-b border-accent-blue/20">
|
||||
<Loader2 className="w-4 h-4 text-accent-blue animate-spin" />
|
||||
<span className="text-sm text-accent-blue">连接中...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error State
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-red-500/10 border-b border-red-500/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400" />
|
||||
<span className="text-sm text-red-400">{error}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onShowSelector}
|
||||
className="px-3 py-1 bg-dark-700 hover:bg-dark-600 rounded text-sm transition-colors"
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Disconnected State
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700">
|
||||
<span className="text-sm text-gray-400">未连接</span>
|
||||
<button
|
||||
onClick={onShowSelector}
|
||||
className="px-3 py-1.5 bg-accent-blue hover:bg-blue-600 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
选择服务器
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSelector && (
|
||||
<ConnectionSelector
|
||||
sessions={sessions}
|
||||
onSelect={onConnect}
|
||||
onClose={onCloseSelector}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* ConnectionSelector - 连接选择器组件
|
||||
*/
|
||||
|
||||
import { Monitor, ChevronRight, X } from 'lucide-react'
|
||||
import type { Session } from '../../stores/connection'
|
||||
|
||||
interface ConnectionSelectorProps {
|
||||
sessions: Session[]
|
||||
onSelect: (sessionId: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSelectorProps) {
|
||||
return (
|
||||
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-40">
|
||||
<div className="bg-dark-800 rounded-xl border border-dark-700 shadow-2xl w-full max-w-md overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
|
||||
<h3 className="text-lg font-medium text-white">选择服务器</h3>
|
||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 max-h-80 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Monitor className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>暂无保存的会话</p>
|
||||
<p className="text-sm mt-1">请先在侧边栏创建会话</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => onSelect(session.id)}
|
||||
className="w-full flex items-center gap-4 p-4 bg-dark-700/50 hover:bg-dark-700 rounded-lg border border-slate-700 hover:border-slate-600 transition-all text-left group"
|
||||
>
|
||||
<Monitor className="w-8 h-8 text-cyan-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-slate-200 font-medium truncate group-hover:text-white">{session.name}</div>
|
||||
<div className="text-sm text-gray-500 truncate">
|
||||
{session.username}@{session.host}:{session.port}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-slate-600 group-hover:text-gray-400 transition-colors" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* ContextMenu - 右键菜单组件
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export interface MenuItem {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
action: () => void
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
x: number
|
||||
y: number
|
||||
items: MenuItem[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-dark-700 border border-slate-700 rounded-lg shadow-xl py-1 min-w-[180px]"
|
||||
style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
item.action()
|
||||
onClose()
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 text-sm transition-colors ${
|
||||
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-gray-200 hover:bg-slate-700 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* FileListPanel - 文件列表面板
|
||||
*
|
||||
* 本地面板或远程面板的文件列表组件
|
||||
*/
|
||||
|
||||
import { Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown } from 'lucide-react'
|
||||
import { Breadcrumb } from './Breadcrumb'
|
||||
import { SortHeader } from './SortHeader'
|
||||
import { FileRow } from './FileRow'
|
||||
import { ContextMenu, type MenuItem } from './ContextMenu'
|
||||
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
||||
|
||||
interface FileListPanelProps {
|
||||
title: string
|
||||
isLocal: boolean
|
||||
path: string
|
||||
files: FileItem[]
|
||||
selected: Set<string>
|
||||
history: string[]
|
||||
historyIndex: number
|
||||
sortField: SortField
|
||||
sortOrder: SortOrder
|
||||
onNavigate: (path: string) => void
|
||||
onNavigateBack: () => void
|
||||
onNavigateForward: () => void
|
||||
onNavigateUp: () => void
|
||||
onSelect: (name: string) => void
|
||||
onOpen: (file: FileItem) => void
|
||||
onContextMenu: (e: React.MouseEvent, file: FileItem) => void
|
||||
onSort: (field: SortField) => void
|
||||
contextMenu: { x: number; y: number; items: MenuItem[] } | null
|
||||
onCloseContextMenu: () => void
|
||||
}
|
||||
|
||||
export function FileListPanel({
|
||||
title,
|
||||
isLocal,
|
||||
path,
|
||||
files,
|
||||
selected,
|
||||
history,
|
||||
historyIndex,
|
||||
sortField,
|
||||
sortOrder,
|
||||
onNavigate,
|
||||
onNavigateBack,
|
||||
onNavigateForward,
|
||||
onNavigateUp,
|
||||
onSelect,
|
||||
onOpen,
|
||||
onContextMenu,
|
||||
onSort,
|
||||
contextMenu,
|
||||
onCloseContextMenu,
|
||||
}: FileListPanelProps) {
|
||||
const canGoBack = historyIndex > 0
|
||||
const canGoForward = historyIndex < history.length - 1
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-dark-800 rounded-lg border border-dark-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLocal ? <HardDrive className="w-4 h-4 text-accent-blue" /> : <Folder className="w-4 h-4 text-accent-green" />}
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
disabled={!canGoBack}
|
||||
className="p-1.5 hover:bg-dark-600 rounded disabled:opacity-30"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateForward}
|
||||
disabled={!canGoForward}
|
||||
className="p-1.5 hover:bg-dark-600 rounded disabled:opacity-30"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateUp}
|
||||
className="p-1.5 hover:bg-dark-600 rounded"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="px-4 py-2 border-b border-dark-700">
|
||||
<Breadcrumb path={path} onNavigate={onNavigate} onHome={() => onNavigate(isLocal ? '/' : '/')} />
|
||||
</div>
|
||||
|
||||
{/* Column Headers */}
|
||||
<div className="flex items-center px-4 py-2 border-b border-dark-700 text-xs text-gray-400">
|
||||
<SortHeader sortField={sortField} sortOrder={sortOrder} onSort={onSort} />
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{files.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500 text-sm">
|
||||
空文件夹
|
||||
</div>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<FileRow
|
||||
key={file.name}
|
||||
file={file}
|
||||
isSelected={selected.has(file.name)}
|
||||
onSelect={() => onSelect(file.name)}
|
||||
onOpen={() => onOpen(file)}
|
||||
onContextMenu={(e) => onContextMenu(e, file)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="px-4 py-2 border-t border-dark-700 text-xs text-gray-400">
|
||||
{selected.size > 0 ? `已选择 ${selected.size} 项` : `共 ${files.length} 项`}
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<ContextMenu {...contextMenu} onClose={onCloseContextMenu} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* FileRow - 文件列表行组件
|
||||
*/
|
||||
|
||||
import { Folder, File } from 'lucide-react'
|
||||
import type { FileItem } from '../../../shared/types/sftp.types'
|
||||
import { formatDate, formatSize, getFileExtension } from './utils'
|
||||
|
||||
interface FileRowProps {
|
||||
file: FileItem
|
||||
isSelected: boolean
|
||||
onSelect: () => void
|
||||
onOpen: () => void
|
||||
onContextMenu: (e: React.MouseEvent) => void
|
||||
onDragStart?: (e: React.DragEvent) => void
|
||||
}
|
||||
|
||||
export function FileRow({ file, isSelected, onSelect, onOpen, onContextMenu, onDragStart }: FileRowProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
onDoubleClick={file.type === 'directory' || file.type === 'symlink' ? onOpen : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
draggable={file.type === 'file'}
|
||||
onDragStart={onDragStart}
|
||||
className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/50 transition-colors group ${
|
||||
isSelected ? 'bg-cyan-500/20 hover:bg-cyan-500/25' : 'hover:bg-dark-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
{file.type === 'directory' ? (
|
||||
<Folder className="w-4 h-4 text-sky-400 flex-shrink-0" />
|
||||
) : file.type === 'symlink' ? (
|
||||
<Folder className="w-4 h-4 text-yellow-400 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm text-gray-200 truncate group-hover:text-white">{file.name}</span>
|
||||
{file.type === 'symlink' && <span className="text-xs text-yellow-500">→</span>}
|
||||
</div>
|
||||
<div className="w-32 text-right text-xs text-gray-500">{formatDate(file.modified)}</div>
|
||||
<div className="w-24 text-right text-xs text-gray-500">
|
||||
{file.type === 'directory' ? '--' : formatSize(file.size)}
|
||||
</div>
|
||||
<div className="w-24 text-right text-xs text-gray-500 font-mono">
|
||||
{file.type === 'directory'
|
||||
? '文件夹'
|
||||
: file.type === 'symlink'
|
||||
? '链接'
|
||||
: getFileExtension(file.name)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* SortHeader - 列表排序表头组件
|
||||
*/
|
||||
|
||||
import { ArrowUp, ArrowDown } from 'lucide-react'
|
||||
import type { SortField, SortOrder } from '../../../shared/types/sftp.types'
|
||||
|
||||
interface SortHeaderProps {
|
||||
onSort: (f: SortField) => void
|
||||
sortField: SortField
|
||||
sortOrder: SortOrder
|
||||
}
|
||||
|
||||
export function SortHeader({ onSort, sortField, sortOrder }: SortHeaderProps) {
|
||||
const fields: { key: SortField; label: string; width: string; align: 'left' | 'right' }[] = [
|
||||
{ key: 'name', label: '名称', width: 'flex-1', align: 'left' },
|
||||
{ key: 'modified', label: '修改时间', width: 'w-32', align: 'right' },
|
||||
{ key: 'size', label: '大小', width: 'w-24', align: 'right' },
|
||||
{ key: 'type', label: '类型', width: 'w-24', align: 'right' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="h-9 flex items-center px-4 bg-dark-700/50 text-xs text-gray-500 border-b border-dark-700 select-none">
|
||||
{fields.map(({ key, label, width, align }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onSort(key)}
|
||||
className={`${width} flex items-center justify-${align} gap-1 hover:text-gray-200 transition-colors`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{sortField === key && (sortOrder === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Toolbar - SFTP 工具栏
|
||||
*
|
||||
* 包含上传、下载、刷新、新建文件夹等操作按钮
|
||||
*/
|
||||
|
||||
import { Upload, Download, RefreshCw, FolderPlus } from 'lucide-react'
|
||||
|
||||
interface ToolbarProps {
|
||||
isConnected: boolean
|
||||
onUpload: () => void
|
||||
onDownload: () => void
|
||||
onRefresh: () => void
|
||||
onNewFolder: () => void
|
||||
}
|
||||
|
||||
export function Toolbar({ isConnected, onUpload, onDownload, onRefresh, onNewFolder }: ToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onUpload}
|
||||
disabled={!isConnected}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent-blue hover:bg-blue-600 disabled:bg-dark-700 disabled:text-gray-500 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
上传
|
||||
</button>
|
||||
<button
|
||||
onClick={onDownload}
|
||||
disabled={!isConnected}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 disabled:text-gray-500 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
下载
|
||||
</button>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={!isConnected}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 disabled:text-gray-500 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={onNewFolder}
|
||||
disabled={!isConnected}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 disabled:text-gray-500 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
新建文件夹
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* TransferItem - 传输任务项组件
|
||||
*/
|
||||
|
||||
import { Download, Upload, Clock, CheckCircle, XCircle, X } from 'lucide-react'
|
||||
import type { TransferTask } from '../../../shared/types/sftp.types'
|
||||
import { formatSpeed } from './utils'
|
||||
|
||||
interface TransferItemProps {
|
||||
task: TransferTask
|
||||
onCancel: () => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
|
||||
const isActive = task.status === 'transferring' || task.status === 'pending'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-dark-700/50 rounded-lg group">
|
||||
<div className={`flex-shrink-0 ${task.type === 'download' ? 'text-cyan-400' : 'text-green-400'}`}>
|
||||
{task.type === 'download' ? <Download className="w-4 h-4" /> : <Upload className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-gray-200 truncate">{task.fileName}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{isActive && (
|
||||
<>
|
||||
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden max-w-[120px]">
|
||||
<div className="h-full bg-cyan-400 transition-all duration-300" style={{ width: `${task.progress}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{task.progress}%</span>
|
||||
{task.speed !== undefined && <span className="text-xs text-gray-500">{formatSpeed(task.speed)}</span>}
|
||||
</>
|
||||
)}
|
||||
{task.status === 'pending' && (
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
等待中
|
||||
</span>
|
||||
)}
|
||||
{task.status === 'completed' && (
|
||||
<span className="text-xs text-green-400 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
完成
|
||||
</span>
|
||||
)}
|
||||
{task.status === 'failed' && (
|
||||
<span className="text-xs text-red-400 flex items-center gap-1" title={task.error}>
|
||||
<XCircle className="w-3 h-3" />
|
||||
失败
|
||||
</span>
|
||||
)}
|
||||
{task.status === 'canceled' && (
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3" />
|
||||
已取消
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{task.status === 'transferring' && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1.5 hover:bg-slate-700 rounded text-gray-400 hover:text-red-400"
|
||||
title="取消"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{(task.status === 'completed' || task.status === 'failed' || task.status === 'canceled') && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1.5 hover:bg-slate-700 rounded text-gray-400 hover:text-gray-200"
|
||||
title="移除"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* TransferPanel - 传输队列面板
|
||||
*
|
||||
* 显示文件上传/下载的传输队列
|
||||
*/
|
||||
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { TransferItem } from './TransferItem'
|
||||
import type { TransferTask } from '../../../shared/types/sftp.types'
|
||||
|
||||
interface TransferPanelProps {
|
||||
tasks: TransferTask[]
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
onCancel: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: TransferPanelProps) {
|
||||
const activeCount = tasks.filter(t => t.status === 'pending' || t.status === 'transferring').length
|
||||
const completedCount = tasks.filter(t => t.status === 'completed').length
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 border-t border-dark-700">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between px-4 py-2 hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">传输队列</span>
|
||||
{activeCount > 0 && (
|
||||
<span className="px-2 py-0.5 bg-accent-blue/20 text-accent-blue text-xs rounded-full">
|
||||
{activeCount} 进行中
|
||||
</span>
|
||||
)}
|
||||
{completedCount > 0 && (
|
||||
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-full">
|
||||
{completedCount} 已完成
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">{tasks.length} 个任务</span>
|
||||
{isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Transfer List */}
|
||||
{isOpen && tasks.length > 0 && (
|
||||
<div className="max-h-48 overflow-y-auto border-t border-dark-700">
|
||||
{tasks.map((task) => (
|
||||
<TransferItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
onCancel={() => onCancel(task.id)}
|
||||
onRemove={() => onRemove(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && tasks.length === 0 && (
|
||||
<div className="py-4 text-center text-gray-500 text-sm">
|
||||
暂无传输任务
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* SFTP Components - SFTP 模块组件导出
|
||||
*/
|
||||
|
||||
export { Breadcrumb } from './Breadcrumb'
|
||||
export { SortHeader } from './SortHeader'
|
||||
export { FileRow } from './FileRow'
|
||||
export { ContextMenu, type MenuItem } from './ContextMenu'
|
||||
export { TransferItem } from './TransferItem'
|
||||
export { ConnectionSelector } from './ConnectionSelector'
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* SFTP Component Types
|
||||
* SFTPView 组件类型定义
|
||||
*/
|
||||
|
||||
import type { FileItem as SFTPFileItem, TransferTask as SFTPTransferTask, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
||||
import type { Session } from '../../../shared/types/session.types'
|
||||
|
||||
// Re-export from shared
|
||||
export type { FileItem, TransferTask, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
||||
export type { Session } from '../../../shared/types/session.types'
|
||||
|
||||
// Extended types for component
|
||||
export interface FileRowProps {
|
||||
file: SFTPFileItem
|
||||
isSelected: boolean
|
||||
onSelect: () => void
|
||||
onOpen: () => void
|
||||
onContextMenu: (e: React.MouseEvent) => void
|
||||
onDragStart?: (e: React.DragEvent) => void
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
action: () => void
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
export interface ContextMenuProps {
|
||||
x: number
|
||||
y: number
|
||||
items: MenuItem[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export interface TransferItemProps {
|
||||
task: SFTPTransferTask
|
||||
onCancel: () => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export interface BreadcrumbProps {
|
||||
path: string
|
||||
onNavigate: (p: string) => void
|
||||
onHome: () => void
|
||||
}
|
||||
|
||||
export interface SortHeaderProps {
|
||||
onSort: (f: SortField) => void
|
||||
sortField: SortField
|
||||
sortOrder: SortOrder
|
||||
}
|
||||
|
||||
export interface ConnectionSelectorProps {
|
||||
sessions: Session[]
|
||||
onSelect: (s: Session) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export interface SFTPViewProps {
|
||||
tabId: string
|
||||
isActive?: boolean
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* SFTP Component Utilities
|
||||
* SFTPView 工具函数
|
||||
*/
|
||||
|
||||
import type { SortField, SortOrder } from '../../../shared/types/sftp.types'
|
||||
import type { FileItem } from './types'
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '--'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化传输速度
|
||||
*/
|
||||
export function formatSpeed(bytesPerSecond: number): string {
|
||||
if (bytesPerSecond === 0) return '0 B/s'
|
||||
const k = 1024
|
||||
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
|
||||
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k))
|
||||
return `${parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
export function formatDate(timestamp: number): string {
|
||||
const d = new Date(timestamp * 1000)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
if (days === 0) return '今天'
|
||||
if (days === 1) return '昨天'
|
||||
if (days < 7) return `${days} 天前`
|
||||
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名描述
|
||||
*/
|
||||
export function getFileExtension(name: string): string {
|
||||
const parts = name.split('.')
|
||||
if (parts.length < 2) return '文件'
|
||||
const ext = parts.pop()?.toLowerCase() || ''
|
||||
const extMap: Record<string, string> = {
|
||||
pdf: 'PDF', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel',
|
||||
ppt: 'PPT', pptx: 'PPT', txt: '文本', md: 'Markdown',
|
||||
js: 'JavaScript', ts: 'TypeScript', py: 'Python', java: 'Java',
|
||||
go: 'Go', rs: 'Rust', c: 'C', cpp: 'C++', h: 'C Header',
|
||||
json: 'JSON', xml: 'XML', yaml: 'YAML', yml: 'YAML', sql: 'SQL',
|
||||
zip: 'ZIP', tar: 'TAR', gz: 'GZIP', rar: 'RAR', '7z': '7Z',
|
||||
jpg: 'JPEG', jpeg: 'JPEG', png: 'PNG', gif: 'GIF', svg: 'SVG',
|
||||
mp3: 'MP3', mp4: 'MP4', avi: 'AVI', mkv: 'MKV',
|
||||
conf: 'Config', cfg: 'Config', ini: 'Config', env: 'Env',
|
||||
sh: 'Shell', bash: 'Shell', zsh: 'Shell',
|
||||
log: '日志', db: '数据库', iso: '镜像', exe: '可执行', dll: '库',
|
||||
}
|
||||
return extMap[ext] || ext.toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序文件列表
|
||||
*/
|
||||
export function sortFiles(files: FileItem[], sortField: SortField, sortOrder: SortOrder): FileItem[] {
|
||||
return [...files].sort((a, b) => {
|
||||
// 目录优先
|
||||
if (a.type !== 'file' && b.type === 'file') return -1
|
||||
if (a.type === 'file' && b.type !== 'file') return 1
|
||||
|
||||
let cmp = 0
|
||||
switch (sortField) {
|
||||
case 'name': cmp = a.name.localeCompare(b.name); break
|
||||
case 'size': cmp = a.size - b.size; break
|
||||
case 'modified': cmp = a.modified - b.modified; break
|
||||
case 'type': cmp = a.name.localeCompare(b.name); break
|
||||
}
|
||||
return sortOrder === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* SearchBox - 搜索框组件
|
||||
*/
|
||||
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
interface SearchBoxProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function SearchBox({ value, onChange, placeholder = '搜索...' }: SearchBoxProps) {
|
||||
return (
|
||||
<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={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 bg-dark-900 border border-dark-700 rounded text-sm focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* SessionTree - 会话树组件
|
||||
*
|
||||
* 显示分组和会话列表
|
||||
*/
|
||||
|
||||
import { ChevronRight, ChevronDown, Folder, Server } from 'lucide-react'
|
||||
import type { Session } from '../../stores/sessionStore'
|
||||
|
||||
interface SessionTreeProps {
|
||||
sessions: Session[]
|
||||
expandedGroups: Set<string>
|
||||
selectedSessionId: string | null
|
||||
onToggleGroup: (group: string) => void
|
||||
onSelectSession: (session: Session) => void
|
||||
onContextMenu: (e: React.MouseEvent, session: Session) => void
|
||||
searchTerm: string
|
||||
}
|
||||
|
||||
export function SessionTree({
|
||||
sessions,
|
||||
expandedGroups,
|
||||
selectedSessionId,
|
||||
onToggleGroup,
|
||||
onSelectSession,
|
||||
onContextMenu,
|
||||
searchTerm,
|
||||
}: SessionTreeProps) {
|
||||
// Filter sessions by search
|
||||
const filteredSessions = sessions.filter(s =>
|
||||
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
s.host.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
// Group sessions
|
||||
const grouped = filteredSessions.reduce((acc, session) => {
|
||||
const group = session.group || 'default'
|
||||
if (!acc[group]) acc[group] = []
|
||||
acc[group].push(session)
|
||||
return acc
|
||||
}, {} as Record<string, Session[]>)
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{Object.entries(grouped).map(([group, groupSessions]) => (
|
||||
<div key={group} className="border-b border-dark-700 last:border-b-0">
|
||||
{/* Group Header */}
|
||||
<button
|
||||
onClick={() => onToggleGroup(group)}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-400 hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
{expandedGroups.has(group) ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
<Folder className="w-4 h-4" />
|
||||
<span className="flex-1 text-left">{group === 'default' ? '默认分组' : group}</span>
|
||||
<span className="text-xs text-gray-500">{groupSessions.length}</span>
|
||||
</button>
|
||||
|
||||
{/* Sessions */}
|
||||
{expandedGroups.has(group) && (
|
||||
<div className="animate-fade-in">
|
||||
{groupSessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => onSelectSession(session)}
|
||||
onContextMenu={(e) => onContextMenu(e, session)}
|
||||
className={`
|
||||
group flex items-center gap-2 px-4 py-2 text-sm cursor-pointer
|
||||
transition-colors pl-10
|
||||
${selectedSessionId === session.id
|
||||
? 'bg-dark-700 border-l-2 border-accent-blue'
|
||||
: 'hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Server className="w-4 h-4 text-accent-green flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-200 truncate">{session.name}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{session.host}:{session.port}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filteredSessions.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500 text-sm">
|
||||
{searchTerm ? '未找到匹配的会话' : '暂无会话'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* TabContextMenu - 标签右键菜单
|
||||
*/
|
||||
|
||||
import { useRef, useEffect } from 'react'
|
||||
|
||||
interface MenuItem {
|
||||
label: string
|
||||
action: () => void
|
||||
danger?: boolean
|
||||
separator?: boolean
|
||||
}
|
||||
|
||||
interface TabContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
onCloseTab: () => void
|
||||
onCloseOthers: () => void
|
||||
onCloseRight: () => void
|
||||
onCloseLeft: () => void
|
||||
onCloseAll: () => void
|
||||
onClone?: () => void
|
||||
}
|
||||
|
||||
export function TabContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
onCloseTab,
|
||||
onCloseOthers,
|
||||
onCloseRight,
|
||||
onCloseLeft,
|
||||
onCloseAll,
|
||||
onClone,
|
||||
}: TabContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{ label: '关闭', action: onCloseTab },
|
||||
{ label: '关闭其他', action: onCloseOthers },
|
||||
{ label: '关闭右侧标签', action: onCloseRight },
|
||||
{ label: '关闭左侧标签', action: onCloseLeft },
|
||||
{ label: '关闭全部', action: onCloseAll, danger: true },
|
||||
]
|
||||
|
||||
if (onClone) {
|
||||
items.unshift({ label: '克隆标签', action: onClone })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{ left: position.x, top: position.y }}
|
||||
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[160px]"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
item.action()
|
||||
onClose()
|
||||
}}
|
||||
className={`
|
||||
w-full px-4 py-2 text-left text-sm transition-colors
|
||||
${item.danger
|
||||
? 'text-red-400 hover:bg-red-500/20'
|
||||
: 'text-gray-300 hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* TabItem - 单个标签项
|
||||
*/
|
||||
|
||||
import { X, Terminal, FolderSync } from 'lucide-react'
|
||||
import type { Tab } from '../../stores/tabStore'
|
||||
|
||||
interface TabItemProps {
|
||||
tab: Tab
|
||||
isActive: boolean
|
||||
onActivate: () => void
|
||||
onClose: (e: React.MouseEvent) => void
|
||||
onContextMenu: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export function TabItem({ tab, isActive, onActivate, onClose, onContextMenu }: TabItemProps) {
|
||||
const getIcon = () => {
|
||||
switch (tab.type) {
|
||||
case 'terminal':
|
||||
return <Terminal className="w-3.5 h-3.5" />
|
||||
case 'sftp':
|
||||
return <FolderSync className="w-3.5 h-3.5" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onActivate}
|
||||
onContextMenu={onContextMenu}
|
||||
className={`
|
||||
group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[200px]
|
||||
border-r border-dark-700 cursor-pointer select-none
|
||||
transition-colors relative
|
||||
${isActive
|
||||
? 'bg-dark-800 text-gray-200'
|
||||
: 'bg-dark-900 text-gray-500 hover:bg-dark-800 hover:text-gray-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
{tab.connectionStatus === 'connecting' && (
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
|
||||
)}
|
||||
{tab.connectionStatus === 'connected' && (
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||
)}
|
||||
{tab.connectionStatus === 'error' && (
|
||||
<span className="w-2 h-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
{getIcon()}
|
||||
|
||||
{/* Title */}
|
||||
<span className="flex-1 truncate text-sm">{tab.title}</span>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`
|
||||
p-0.5 rounded opacity-0 group-hover:opacity-100
|
||||
hover:bg-dark-600 transition-opacity
|
||||
${isActive ? 'opacity-100' : ''}
|
||||
`}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Terminal Component Types
|
||||
* TerminalView 组件类型定义
|
||||
*/
|
||||
|
||||
import type { ConnectionState } from '../../stores/connection'
|
||||
|
||||
export interface TerminalViewProps {
|
||||
tabId: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export type { ConnectionState }
|
||||
Reference in new issue
Block a user