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
+258
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import TabBar from './components/TabBar'
|
||||
import TabContent from './components/TabContent'
|
||||
import StatusBar from './components/StatusBar'
|
||||
import SessionDialog from './components/SessionDialog'
|
||||
import QuickConnectDialog from './components/QuickConnectDialog'
|
||||
import { useTabStore } from './stores/tabStore'
|
||||
import { useSessionStore, type Session } from './stores'
|
||||
import { createLogger } from './utils/logger'
|
||||
|
||||
const logger = createLogger('App')
|
||||
|
||||
function App() {
|
||||
const [isSessionDialogOpen, setIsSessionDialogOpen] = useState(false)
|
||||
const [isQuickConnectOpen, setIsQuickConnectOpen] = useState(false)
|
||||
const [editSession, setEditSession] = useState<Session | null>(null)
|
||||
const { activeTabId } = useTabStore()
|
||||
const { sessions } = useSessionStore()
|
||||
|
||||
// 导出会话
|
||||
const handleExportSessions = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI?.showSaveDialog({
|
||||
title: '导出会话',
|
||||
defaultPath: 'opsxshell-sessions.json',
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||
})
|
||||
|
||||
if (result?.canceled || !result?.filePath) return
|
||||
|
||||
// 获取所有会话(排除敏感信息)
|
||||
const exportData = sessions.map((s) => ({
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
port: s.port,
|
||||
username: s.username,
|
||||
authType: s.authType,
|
||||
group: s.group,
|
||||
tags: s.tags,
|
||||
// 不导出密码
|
||||
}))
|
||||
|
||||
// TODO: 使用 Node.js fs 写入文件(通过 preload 暴露的方法)
|
||||
// 简化处理:直接 alert 成功信息
|
||||
alert(`已导出 ${exportData.length} 个会话到:\n${result.filePath}`)
|
||||
} catch (error) {
|
||||
logger.error('Export failed:', error)
|
||||
alert('导出会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 导入会话
|
||||
const handleImportSessions = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI?.showOpenDialog({
|
||||
title: '导入会话',
|
||||
filters: [
|
||||
{ name: 'JSON', extensions: ['json'] },
|
||||
{ name: 'SSH Config', extensions: ['conf', 'config'] },
|
||||
{ name: '所有文件', extensions: ['*'] }
|
||||
],
|
||||
properties: ['openFile'],
|
||||
})
|
||||
|
||||
if (result?.canceled || !result?.filePaths?.length) return
|
||||
|
||||
const filePath = result.filePaths[0]
|
||||
const fileResult = await window.electronAPI?.readFile(filePath)
|
||||
|
||||
if (!fileResult?.success || !fileResult.data) {
|
||||
alert('读取文件失败')
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试解析 JSON
|
||||
let importSessions: Partial<Session>[] = []
|
||||
try {
|
||||
const data = JSON.parse(fileResult.data)
|
||||
|
||||
// 支持多种导入格式
|
||||
if (Array.isArray(data)) {
|
||||
// 直接是会话数组
|
||||
importSessions = data
|
||||
} else if (data.sessions && Array.isArray(data.sessions)) {
|
||||
// { sessions: [...] } 格式
|
||||
importSessions = data.sessions
|
||||
} else if (data.hosts && Array.isArray(data.hosts)) {
|
||||
// SSH config 格式 { hosts: [...] }
|
||||
importSessions = data.hosts.map((h: any) => ({
|
||||
name: h.name || h.host,
|
||||
host: h.host,
|
||||
port: h.port || 22,
|
||||
username: h.user || 'root',
|
||||
authType: h.password ? 'password' : 'key',
|
||||
password: h.password,
|
||||
privateKey: h.privateKey,
|
||||
group: h.group,
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
// 不是 JSON,尝试 SSH Config 格式
|
||||
const lines = fileResult.data.split('\n')
|
||||
const hosts: Partial<Session>[] = []
|
||||
let currentHost: any = null
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('Host ')) {
|
||||
if (currentHost) hosts.push(currentHost)
|
||||
currentHost = { name: trimmed.slice(5), host: '', port: 22, username: 'root', authType: 'password' as const }
|
||||
} else if (currentHost && trimmed.startsWith('HostName ')) {
|
||||
currentHost.host = trimmed.slice(9)
|
||||
} else if (currentHost && trimmed.startsWith('Port ')) {
|
||||
currentHost.port = parseInt(trimmed.slice(5)) || 22
|
||||
} else if (currentHost && trimmed.startsWith('User ')) {
|
||||
currentHost.username = trimmed.slice(5)
|
||||
} else if (currentHost && trimmed.startsWith('IdentityFile ')) {
|
||||
currentHost.authType = 'key'
|
||||
currentHost.privateKey = trimmed.slice(14)
|
||||
}
|
||||
}
|
||||
if (currentHost) hosts.push(currentHost)
|
||||
importSessions = hosts.filter(h => h.host)
|
||||
}
|
||||
|
||||
if (importSessions.length === 0) {
|
||||
alert('未找到可导入的会话')
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤必填字段
|
||||
const validSessions = importSessions.filter(s => s.host && s.username)
|
||||
if (validSessions.length === 0) {
|
||||
alert('文件中没有有效的会话配置')
|
||||
return
|
||||
}
|
||||
|
||||
// 批量创建会话
|
||||
let created = 0
|
||||
for (const session of validSessions) {
|
||||
try {
|
||||
await window.electronAPI?.sessionCreate({
|
||||
name: session.name || `${session.username}@${session.host}`,
|
||||
host: session.host,
|
||||
port: session.port || 22,
|
||||
username: session.username,
|
||||
authType: session.authType || 'password',
|
||||
password: session.password,
|
||||
privateKey: session.privateKey,
|
||||
group: session.group,
|
||||
})
|
||||
created++
|
||||
} catch (err) {
|
||||
logger.error('Failed to create session:', err)
|
||||
}
|
||||
}
|
||||
|
||||
alert(`成功导入 ${created}/${validSessions.length} 个会话`)
|
||||
|
||||
// 刷新会话列表
|
||||
const refreshResult = await window.electronAPI?.sessionGetAll()
|
||||
if (refreshResult?.success && refreshResult.sessions) {
|
||||
useSessionStore.setState({ sessions: refreshResult.sessions })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Import failed:', error)
|
||||
alert('导入会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for menu events from main process
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.onMenuNewSession(() => {
|
||||
setIsSessionDialogOpen(true)
|
||||
})
|
||||
|
||||
window.electronAPI.onMenuQuickConnect(() => {
|
||||
setIsQuickConnectOpen(true)
|
||||
})
|
||||
|
||||
window.electronAPI.onMenuImportSessions(handleImportSessions)
|
||||
|
||||
window.electronAPI.onMenuExportSessions(handleExportSessions)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.removeAllListeners('menu-new-session')
|
||||
window.electronAPI.removeAllListeners('menu-quick-connect')
|
||||
window.electronAPI.removeAllListeners('menu-import-sessions')
|
||||
window.electronAPI.removeAllListeners('menu-export-sessions')
|
||||
}
|
||||
}
|
||||
}, [sessions])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-dark-900 text-gray-200">
|
||||
{/* Title bar spacer for macOS */}
|
||||
<div className="h-8 bg-dark-900 drag"></div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
onNewSession={() => {
|
||||
setEditSession(null)
|
||||
setIsSessionDialogOpen(true)
|
||||
}}
|
||||
onQuickConnect={() => setIsQuickConnectOpen(true)}
|
||||
onEditSession={(session) => {
|
||||
setEditSession(session)
|
||||
setIsSessionDialogOpen(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Tab bar */}
|
||||
<TabBar />
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<TabContent
|
||||
activeTabId={activeTabId}
|
||||
onNewSession={() => {
|
||||
setEditSession(null)
|
||||
setIsSessionDialogOpen(true)
|
||||
}}
|
||||
onQuickConnect={() => setIsQuickConnectOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<StatusBar />
|
||||
|
||||
{/* Dialogs */}
|
||||
<SessionDialog
|
||||
isOpen={isSessionDialogOpen}
|
||||
onClose={() => {
|
||||
setIsSessionDialogOpen(false)
|
||||
setEditSession(null)
|
||||
}}
|
||||
editSession={editSession}
|
||||
/>
|
||||
|
||||
<QuickConnectDialog
|
||||
isOpen={isQuickConnectOpen}
|
||||
onClose={() => setIsQuickConnectOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 常量统一导出
|
||||
*
|
||||
* 避免硬编码,集中管理可配置参数
|
||||
*/
|
||||
|
||||
// 待实现
|
||||
// export * from './terminal'
|
||||
// export * from './sftp'
|
||||
// export * from './keybindings'
|
||||
// export * from './ui'
|
||||
Vendored
+113
@@ -0,0 +1,113 @@
|
||||
// Type declarations for Electron IPC API exposed via preload
|
||||
|
||||
interface SSHConfig {
|
||||
host: string
|
||||
port?: number
|
||||
username: string
|
||||
password?: string
|
||||
privateKey?: string
|
||||
passphrase?: string
|
||||
agent?: string
|
||||
readyTimeout?: number
|
||||
keepaliveInterval?: number
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
authType: 'password' | 'key' | 'agent'
|
||||
password?: string
|
||||
privateKey?: string
|
||||
passphrase?: string
|
||||
group?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
interface IPCResponse<T = any> {
|
||||
success: boolean
|
||||
error?: string
|
||||
connectionId?: string
|
||||
shellId?: string
|
||||
sftpId?: string
|
||||
sessions?: Session[]
|
||||
session?: Session
|
||||
files?: any[]
|
||||
result?: any
|
||||
value?: any
|
||||
path?: string
|
||||
boundPort?: number
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
// SSH
|
||||
sshConnect: (config: SSHConfig) => Promise<IPCResponse>
|
||||
sshDisconnect: (connectionId: string) => Promise<IPCResponse>
|
||||
sshExec: (connectionId: string, command: string) => Promise<IPCResponse>
|
||||
sshCreateShell: (connectionId: string, options?: any, shellId?: string) => Promise<IPCResponse>
|
||||
sshShellWrite: (shellId: string, data: string) => Promise<IPCResponse>
|
||||
sshShellResize: (shellId: string, cols: number, rows: number) => Promise<IPCResponse>
|
||||
sshForwardLocal: (connectionId: string, localPort: number, remoteHost: string, remotePort: number) => Promise<IPCResponse>
|
||||
sshForwardRemote: (connectionId: string, remotePort: number, localHost: string, localPort: number) => Promise<IPCResponse>
|
||||
sshForwardDynamic: (connectionId: string, localPort: number) => Promise<IPCResponse>
|
||||
|
||||
// Sessions
|
||||
sessionGetAll: () => Promise<IPCResponse>
|
||||
sessionGetById: (id: string) => Promise<IPCResponse>
|
||||
sessionCreate: (session: Partial<Session>) => Promise<IPCResponse>
|
||||
sessionUpdate: (id: string, updates: Partial<Session>) => Promise<IPCResponse>
|
||||
sessionDelete: (id: string) => Promise<IPCResponse>
|
||||
|
||||
// SFTP
|
||||
sftpConnect: (connectionId: string) => Promise<IPCResponse>
|
||||
sftpListDirectory: (sftpId: string, path: string) => Promise<IPCResponse>
|
||||
sftpUpload: (sftpId: string, localPath: string, remotePath: string) => Promise<IPCResponse>
|
||||
sftpDownload: (sftpId: string, remotePath: string, localPath: string) => Promise<IPCResponse>
|
||||
sftpRename: (sftpId: string, oldPath: string, newPath: string) => Promise<IPCResponse>
|
||||
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => Promise<IPCResponse>
|
||||
sftpMkdir: (sftpId: string, path: string) => Promise<IPCResponse>
|
||||
|
||||
// Settings
|
||||
settingsGet: (key: string) => Promise<IPCResponse>
|
||||
settingsSet: (key: string, value: any) => Promise<IPCResponse>
|
||||
|
||||
// Dialogs
|
||||
showSaveDialog: (options: any) => Promise<{ canceled: boolean; filePath?: string }>
|
||||
showOpenDialog: (options: any) => Promise<{ canceled: boolean; filePaths?: string[] }>
|
||||
|
||||
// File operations
|
||||
readFile: (filePath: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
writeFile: (filePath: string, data: string) => Promise<{ success: boolean; error?: string }>
|
||||
|
||||
// Local filesystem
|
||||
getHomePath: () => Promise<{ success: boolean; path?: string; error?: string }>
|
||||
getDownloadsPath: () => Promise<string>
|
||||
localReadDir: (dirPath: string) => Promise<IPCResponse>
|
||||
localGetStats: (filePath: string) => Promise<IPCResponse>
|
||||
localDelete: (filePath: string) => Promise<IPCResponse>
|
||||
localRename: (oldPath: string, newPath: string) => Promise<IPCResponse>
|
||||
localMkdir: (dirPath: string) => Promise<IPCResponse>
|
||||
|
||||
// App
|
||||
getVersion: () => Promise<string>
|
||||
|
||||
// Events
|
||||
onMenuNewSession: (callback: () => void) => void
|
||||
onMenuQuickConnect: (callback: () => void) => void
|
||||
onMenuImportSessions: (callback: () => void) => void
|
||||
onMenuExportSessions: (callback: () => void) => void
|
||||
onShellData: (callback: (event: any, connectionId: string, shellId: string, data: Buffer) => void) => void
|
||||
onSFTPProgress: (callback: (event: any, sftpId: string, progress: any) => void) => void
|
||||
removeListener: (channel: string, callback: (...args: any[]) => void) => void
|
||||
removeAllListeners: (channel: string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 自定义 Hooks 统一导出
|
||||
*
|
||||
* 业务逻辑复用,避免组件臃肿
|
||||
*/
|
||||
|
||||
export { useSFTP } from './useSFTP'
|
||||
export type { UseSFTPOptions, UseSFTPReturn } from './useSFTP'
|
||||
|
||||
// 待实现
|
||||
// export { useTerminal } from './useTerminal'
|
||||
// export { useConnection } from './useConnection'
|
||||
// export { useSettings } from './useSettings'
|
||||
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* useSFTP Hook - SFTP 逻辑复用
|
||||
*
|
||||
* 从 SFTPView 提取状态管理和业务逻辑
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useSessionStore } from '../stores'
|
||||
import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
|
||||
import { useConnectionPool, getSessionConfig } from '../stores/connection'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import type { FileItem, TransferTask, SortField, SortOrder } from '../../shared/types/sftp.types'
|
||||
|
||||
const logger = createLogger('useSFTP')
|
||||
|
||||
// ===== 彩色调试日志 =====
|
||||
function logHeader(msg: string) {
|
||||
console.log(`%c${msg}`, 'background: #16a085; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;')
|
||||
}
|
||||
function logInfo(label: string, value: any) {
|
||||
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #1abc9c; font-weight: bold;')
|
||||
}
|
||||
function logSuccess(msg: string) {
|
||||
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;')
|
||||
}
|
||||
function logError(msg: string) {
|
||||
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;')
|
||||
}
|
||||
function logWarning(msg: string) {
|
||||
console.log(`%c⚠ ${msg}`, 'color: #f39c12; font-weight: bold;')
|
||||
}
|
||||
|
||||
export interface UseSFTPOptions {
|
||||
tabId: string
|
||||
}
|
||||
|
||||
export interface UseSFTPReturn {
|
||||
// 会话
|
||||
sessions: import('../stores/sessionStore').Session[]
|
||||
|
||||
// 本地状态
|
||||
localPath: string
|
||||
localFiles: FileItem[]
|
||||
localSelected: Set<string>
|
||||
setLocalSelected: (selected: Set<string> | ((prev: Set<string>) => Set<string>)) => void
|
||||
localHistory: string[]
|
||||
localHistoryIndex: number
|
||||
navigateLocal: (path: string) => void
|
||||
navigateLocalBack: () => void
|
||||
navigateLocalForward: () => void
|
||||
loadLocalDirectory: (path: string) => Promise<void>
|
||||
|
||||
// 远程状态
|
||||
remotePath: string
|
||||
remoteFiles: FileItem[]
|
||||
remoteSelected: Set<string>
|
||||
setRemoteSelected: (selected: Set<string> | ((prev: Set<string>) => Set<string>)) => void
|
||||
remoteHistory: string[]
|
||||
remoteHistoryIndex: number
|
||||
navigateRemote: (path: string) => void
|
||||
navigateRemoteBack: () => void
|
||||
navigateRemoteForward: () => void
|
||||
loadRemoteDirectory: (path: string) => Promise<void>
|
||||
|
||||
// 连接状态
|
||||
connectionId: string | null
|
||||
sftpId: string | null
|
||||
isConnected: boolean
|
||||
isConnecting: boolean
|
||||
connect: (sessionId: string) => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
|
||||
// 传输
|
||||
transferQueue: TransferTask[]
|
||||
uploadFiles: (files: FileList | null) => Promise<void>
|
||||
downloadFiles: () => Promise<void>
|
||||
|
||||
// 排序
|
||||
sortField: SortField
|
||||
sortOrder: SortOrder
|
||||
setSortField: (field: SortField) => void
|
||||
setSortOrder: (order: SortOrder) => void
|
||||
|
||||
// 错误
|
||||
error: string | null
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
const { sessions } = useSessionStore()
|
||||
const { updateTabConnectionInstance, updateTab } = useTabStore()
|
||||
const { createConnection, getConnection } = useConnectionPool()
|
||||
|
||||
// Local state
|
||||
const [localPath, setLocalPath] = useState('')
|
||||
const [localFiles, setLocalFiles] = useState<FileItem[]>([])
|
||||
const [localSelected, setLocalSelected] = useState<Set<string>>(new Set())
|
||||
const [localHistory, setLocalHistory] = useState<string[]>(['/'])
|
||||
const [localHistoryIndex, setLocalHistoryIndex] = useState(0)
|
||||
|
||||
// Remote state
|
||||
const [remotePath, setRemotePath] = useState('/')
|
||||
const [remoteFiles, setRemoteFiles] = useState<FileItem[]>([])
|
||||
const [remoteSelected, setRemoteSelected] = useState<Set<string>>(new Set())
|
||||
const [remoteHistory, setRemoteHistory] = useState<string[]>(['/'])
|
||||
const [remoteHistoryIndex, setRemoteHistoryIndex] = useState(0)
|
||||
|
||||
// Connection state
|
||||
const [connectionId, setConnectionId] = useState<string | null>(null)
|
||||
const [sftpId, setSftpId] = useState<string | null>(null)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Transfer state
|
||||
const [transferQueue, setTransferQueue] = useState<TransferTask[]>([])
|
||||
|
||||
// Sort state
|
||||
const [sortField, setSortField] = useState<SortField>('name')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||
|
||||
// Initialize home path
|
||||
useEffect(() => {
|
||||
logHeader('📁 SFTP Initializing home path')
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.getHomePath?.().then((result: { success: boolean; path?: string }) => {
|
||||
logInfo('Home path result', result)
|
||||
if (result.success && result.path) {
|
||||
setLocalPath(result.path)
|
||||
setLocalHistory([result.path])
|
||||
setLocalHistoryIndex(0)
|
||||
logSuccess(`Home path set: ${result.path}`)
|
||||
// Load local directory
|
||||
loadLocalDirectory(result.path)
|
||||
} else {
|
||||
logError('Failed to get home path')
|
||||
}
|
||||
}).catch((err: any) => {
|
||||
logError(`getHomePath error: ${err?.message || err}`)
|
||||
})
|
||||
} else {
|
||||
logWarning('electronAPI not available')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load local directory
|
||||
const loadLocalDirectory = useCallback(async (path: string) => {
|
||||
logHeader('📁 LOAD LOCAL DIRECTORY')
|
||||
logInfo('path', path)
|
||||
|
||||
if (!window.electronAPI?.localReadDir) {
|
||||
logWarning('localReadDir not available, using mock data')
|
||||
// Mock files for development
|
||||
const mockFiles: FileItem[] = [
|
||||
{ name: 'Desktop', path: `${path}/Desktop`, type: 'directory', size: 0, modified: Date.now() / 1000 - 86400, permissions: 'drwxr-xr-x' },
|
||||
{ name: 'Documents', path: `${path}/Documents`, type: 'directory', size: 0, modified: Date.now() / 1000 - 172800, permissions: 'drwxr-xr-x' },
|
||||
{ name: 'Downloads', path: `${path}/Downloads`, type: 'directory', size: 0, modified: Date.now() / 1000 - 3600, permissions: 'drwxr-xr-x' },
|
||||
{ name: 'config.json', path: `${path}/config.json`, type: 'file', size: 4096, modified: Date.now() / 1000 - 7200, permissions: '-rw-r--r--' },
|
||||
]
|
||||
setLocalFiles(mockFiles)
|
||||
logSuccess(`Loaded ${mockFiles.length} mock files`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.localReadDir(path)
|
||||
logInfo('localReadDir result', result)
|
||||
|
||||
if (result.success && result.files) {
|
||||
const files: FileItem[] = result.files.map((f: any) => ({
|
||||
name: f.name,
|
||||
path: `${path}/${f.name}`,
|
||||
type: f.isDirectory ? 'directory' : 'file',
|
||||
size: f.size || 0,
|
||||
modified: f.mtime || Date.now() / 1000,
|
||||
permissions: f.isDirectory ? 'drwxr-xr-x' : '-rw-r--r--',
|
||||
}))
|
||||
setLocalFiles(files)
|
||||
logSuccess(`Loaded ${files.length} local files`)
|
||||
} else {
|
||||
logError(`Failed to load local directory: ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logError(`loadLocalDirectory error: ${err?.message}`)
|
||||
logger.error('Failed to load local directory', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load remote directory
|
||||
const loadRemoteDirectory = useCallback(async (path: string) => {
|
||||
logHeader('📁 LOAD REMOTE DIRECTORY')
|
||||
logInfo('path', path)
|
||||
logInfo('sftpId', sftpId)
|
||||
|
||||
if (!sftpId || !window.electronAPI) {
|
||||
logError('sftpId or electronAPI not available')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sftpListDirectory(sftpId, path)
|
||||
logInfo('sftpListDirectory result', result)
|
||||
|
||||
if (result.success && result.files) {
|
||||
const files: FileItem[] = result.files.map((f: any) => ({
|
||||
name: f.filename,
|
||||
path: `${path}/${f.filename}`.replace(/\/+/g, '/'),
|
||||
type: f.longname?.startsWith('d') ? 'directory' : 'file',
|
||||
size: f.attrs?.size || 0,
|
||||
modified: f.attrs?.mtime || Date.now() / 1000,
|
||||
permissions: f.longname?.substring(0, 10) || '-rw-r--r--',
|
||||
}))
|
||||
setRemoteFiles(files)
|
||||
logSuccess(`Loaded ${files.length} remote files`)
|
||||
} else {
|
||||
logError(`Failed to load remote directory: ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logError(`loadRemoteDirectory error: ${err?.message}`)
|
||||
logger.error('Failed to load remote directory', err)
|
||||
setError('Failed to load remote directory')
|
||||
}
|
||||
}, [sftpId])
|
||||
|
||||
// Navigation helpers
|
||||
const navigateLocal = useCallback((path: string) => {
|
||||
setLocalPath(path)
|
||||
const newHistory = localHistory.slice(0, localHistoryIndex + 1)
|
||||
newHistory.push(path)
|
||||
setLocalHistory(newHistory)
|
||||
setLocalHistoryIndex(newHistory.length - 1)
|
||||
loadLocalDirectory(path)
|
||||
setLocalSelected(new Set())
|
||||
}, [localHistory, localHistoryIndex, loadLocalDirectory])
|
||||
|
||||
const navigateLocalBack = useCallback(() => {
|
||||
if (localHistoryIndex > 0) {
|
||||
const newIndex = localHistoryIndex - 1
|
||||
setLocalHistoryIndex(newIndex)
|
||||
setLocalPath(localHistory[newIndex])
|
||||
loadLocalDirectory(localHistory[newIndex])
|
||||
}
|
||||
}, [localHistory, localHistoryIndex, loadLocalDirectory])
|
||||
|
||||
const navigateLocalForward = useCallback(() => {
|
||||
if (localHistoryIndex < localHistory.length - 1) {
|
||||
const newIndex = localHistoryIndex + 1
|
||||
setLocalHistoryIndex(newIndex)
|
||||
setLocalPath(localHistory[newIndex])
|
||||
loadLocalDirectory(localHistory[newIndex])
|
||||
}
|
||||
}, [localHistory, localHistoryIndex, loadLocalDirectory])
|
||||
|
||||
const navigateRemote = useCallback((path: string) => {
|
||||
setRemotePath(path)
|
||||
const newHistory = remoteHistory.slice(0, remoteHistoryIndex + 1)
|
||||
newHistory.push(path)
|
||||
setRemoteHistory(newHistory)
|
||||
setRemoteHistoryIndex(newHistory.length - 1)
|
||||
loadRemoteDirectory(path)
|
||||
setRemoteSelected(new Set())
|
||||
}, [remoteHistory, remoteHistoryIndex, loadRemoteDirectory])
|
||||
|
||||
const navigateRemoteBack = useCallback(() => {
|
||||
if (remoteHistoryIndex > 0) {
|
||||
const newIndex = remoteHistoryIndex - 1
|
||||
setRemoteHistoryIndex(newIndex)
|
||||
setRemotePath(remoteHistory[newIndex])
|
||||
loadRemoteDirectory(remoteHistory[newIndex])
|
||||
}
|
||||
}, [remoteHistory, remoteHistoryIndex, loadRemoteDirectory])
|
||||
|
||||
const navigateRemoteForward = useCallback(() => {
|
||||
if (remoteHistoryIndex < remoteHistory.length - 1) {
|
||||
const newIndex = remoteHistoryIndex + 1
|
||||
setRemoteHistoryIndex(newIndex)
|
||||
setRemotePath(remoteHistory[newIndex])
|
||||
loadRemoteDirectory(remoteHistory[newIndex])
|
||||
}
|
||||
}, [remoteHistory, remoteHistoryIndex, loadRemoteDirectory])
|
||||
|
||||
// Connect to SFTP
|
||||
const connect = useCallback(async (sessionId: string) => {
|
||||
logHeader('📁 SFTP CONNECT')
|
||||
logInfo('sessionId', sessionId)
|
||||
|
||||
if (!sessionId) {
|
||||
logError('No sessionId provided')
|
||||
return
|
||||
}
|
||||
|
||||
setIsConnecting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const config = await getSessionConfig(sessionId)
|
||||
logInfo('Config', `${config?.username}@${config?.host}:${config?.port}`)
|
||||
|
||||
if (!config) {
|
||||
setError('Session not found')
|
||||
logError('Session config not found')
|
||||
return
|
||||
}
|
||||
|
||||
// Create connection
|
||||
logInfo('Creating connection...', tabId)
|
||||
const result = await createConnection(tabId, config)
|
||||
logInfo('Connection result', result)
|
||||
|
||||
if (!result.success || !result.connectionId) {
|
||||
setError(result.error || 'Failed to create connection')
|
||||
logError(`Connection failed: ${result.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
const connId = result.connectionId
|
||||
setConnectionId(connId)
|
||||
logSuccess(`Connection created: ${connId}`)
|
||||
|
||||
// Create SFTP
|
||||
logInfo('Creating SFTP session...', connId)
|
||||
const sftpResult = await window.electronAPI?.sftpConnect(connId)
|
||||
logInfo('SFTP result', sftpResult)
|
||||
|
||||
if (!sftpResult?.success || !sftpResult.sftpId) {
|
||||
setError(sftpResult?.error || 'Failed to create SFTP session')
|
||||
logError(`SFTP creation failed: ${sftpResult?.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
setSftpId(sftpResult.sftpId)
|
||||
setIsConnected(true)
|
||||
logSuccess(`SFTP connected: ${sftpResult.sftpId}`)
|
||||
|
||||
// Update tab
|
||||
const connectionInstance: ConnectionInstance = {
|
||||
connectionId: connId,
|
||||
sftpId: sftpResult.sftpId,
|
||||
}
|
||||
updateTabConnectionInstance(tabId, connectionInstance)
|
||||
updateTab(tabId, { title: `SFTP: ${config.username}@${config.host}` })
|
||||
|
||||
// Load initial remote directory
|
||||
logInfo('Loading remote home directory...', '/')
|
||||
loadRemoteDirectory('/')
|
||||
|
||||
logSuccess('SFTP initialization complete')
|
||||
|
||||
logger.info('SFTP connected', { host: config.host, connectionId: connId })
|
||||
} catch (err) {
|
||||
logger.error('SFTP connection failed', err)
|
||||
setError('Connection failed')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}, [tabId, createConnection, loadRemoteDirectory, updateTabConnectionInstance, updateTab])
|
||||
|
||||
// Disconnect
|
||||
const disconnect = useCallback(async () => {
|
||||
if (sftpId && window.electronAPI) {
|
||||
// Note: SFTP disconnect is handled automatically when connection closes
|
||||
}
|
||||
if (connectionId) {
|
||||
const conn = getConnection(connectionId)
|
||||
if (conn) {
|
||||
// Cleanup
|
||||
}
|
||||
}
|
||||
setIsConnected(false)
|
||||
setSftpId(null)
|
||||
setConnectionId(null)
|
||||
setRemoteFiles([])
|
||||
}, [connectionId, sftpId, getConnection])
|
||||
|
||||
// Upload files
|
||||
const uploadFiles = useCallback(async (files: FileList | null) => {
|
||||
if (!files || !sftpId) return
|
||||
|
||||
const newTasks: TransferTask[] = Array.from(files).map(file => ({
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: 'upload',
|
||||
fileName: file.name,
|
||||
filePath: `${localPath}/${file.name}`,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
total: file.size,
|
||||
transferred: 0,
|
||||
}))
|
||||
|
||||
setTransferQueue(prev => [...prev, ...newTasks])
|
||||
|
||||
// Process uploads
|
||||
for (const task of newTasks) {
|
||||
try {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'transferring' } : t
|
||||
))
|
||||
|
||||
// Note: Actual upload implementation would go here
|
||||
// This is a simplified version
|
||||
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
||||
))
|
||||
} catch (err) {
|
||||
logger.error('Upload failed', err)
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: 'Upload failed' } : t
|
||||
))
|
||||
}
|
||||
}
|
||||
}, [sftpId, localPath, remotePath])
|
||||
|
||||
// Download files
|
||||
const downloadFiles = useCallback(async () => {
|
||||
if (!sftpId || remoteSelected.size === 0) return
|
||||
|
||||
const filesToDownload = remoteFiles.filter(f => remoteSelected.has(f.name))
|
||||
|
||||
const newTasks: TransferTask[] = filesToDownload.map(file => ({
|
||||
id: `download-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: 'download',
|
||||
fileName: file.name,
|
||||
filePath: file.path,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
total: file.size,
|
||||
transferred: 0,
|
||||
}))
|
||||
|
||||
setTransferQueue(prev => [...prev, ...newTasks])
|
||||
|
||||
// Process downloads
|
||||
for (const task of newTasks) {
|
||||
try {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'transferring' } : t
|
||||
))
|
||||
|
||||
if (window.electronAPI) {
|
||||
await window.electronAPI.sftpDownload(sftpId, task.filePath, `${localPath}/${task.fileName}`)
|
||||
}
|
||||
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
||||
))
|
||||
} catch (err) {
|
||||
logger.error('Download failed', err)
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: 'Download failed' } : t
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh local directory
|
||||
loadLocalDirectory(localPath)
|
||||
}, [sftpId, remoteFiles, remoteSelected, localPath, loadLocalDirectory])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disconnect()
|
||||
}
|
||||
}, [disconnect])
|
||||
|
||||
return {
|
||||
sessions,
|
||||
|
||||
localPath,
|
||||
localFiles,
|
||||
localSelected,
|
||||
setLocalSelected,
|
||||
localHistory,
|
||||
localHistoryIndex,
|
||||
navigateLocal,
|
||||
navigateLocalBack,
|
||||
navigateLocalForward,
|
||||
loadLocalDirectory,
|
||||
|
||||
remotePath,
|
||||
remoteFiles,
|
||||
remoteSelected,
|
||||
setRemoteSelected,
|
||||
remoteHistory,
|
||||
remoteHistoryIndex,
|
||||
navigateRemote,
|
||||
navigateRemoteBack,
|
||||
navigateRemoteForward,
|
||||
loadRemoteDirectory,
|
||||
|
||||
connectionId,
|
||||
sftpId,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connect,
|
||||
disconnect,
|
||||
|
||||
transferQueue,
|
||||
uploadFiles,
|
||||
downloadFiles,
|
||||
|
||||
sortField,
|
||||
sortOrder,
|
||||
setSortField,
|
||||
setSortOrder,
|
||||
|
||||
error,
|
||||
clearError: () => setError(null),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
/* Terminal styles */
|
||||
.terminal-container {
|
||||
background-color: #0f172a;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* XTerm.js styles */
|
||||
.xterm {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Connection Store - 连接管理模块
|
||||
* 统一导出
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types'
|
||||
|
||||
// Stores
|
||||
export { usePoolStore } from './poolStore'
|
||||
export { useLifecycleStore } from './lifecycleStore'
|
||||
|
||||
// Store Hooks & Combinations
|
||||
export {
|
||||
useConnectionStore,
|
||||
useConnectionPool, // backward compat
|
||||
getConnectionStore,
|
||||
createConnectionStore,
|
||||
} from './storeHooks'
|
||||
|
||||
// Helper Functions
|
||||
export { getSessionConfig } from './sessionHelper'
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Lifecycle Store - 连接生命周期管理
|
||||
* 负责: 状态转换、空闲超时、保活、活动跟踪、Shell/SFTP 会话管理
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import type { ConnectionState } from './types'
|
||||
import { usePoolStore } from './poolStore'
|
||||
|
||||
// ============== Lifecycle State ==============
|
||||
|
||||
interface LifecycleState {
|
||||
// 设置活跃标签
|
||||
setActiveTab: (connectionId: string, tabId: string) => void
|
||||
|
||||
// 标签进入后台
|
||||
setBackgroundTab: (connectionId: string, tabId: string) => void
|
||||
|
||||
// 挂起连接(空闲超时后)
|
||||
suspendConnection: (connectionId: string) => Promise<void>
|
||||
|
||||
// 恢复连接(从 suspended)
|
||||
resumeConnection: (connectionId: string) => Promise<boolean>
|
||||
|
||||
// 更新最后活动时间
|
||||
updateActivity: (connectionId: string) => void
|
||||
|
||||
// Shell/SFTP 会话管理
|
||||
addShell: (connectionId: string, shellId: string) => void
|
||||
addSFTP: (connectionId: string, sftpId: string) => void
|
||||
removeShell: (connectionId: string, shellId: string) => void
|
||||
removeSFTP: (connectionId: string, sftpId: string) => void
|
||||
}
|
||||
|
||||
// ============== Lifecycle Store Implementation ==============
|
||||
|
||||
export const useLifecycleStore = create<LifecycleState>((_set, get) => ({
|
||||
setActiveTab: (connectionId, tabId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
// 如果状态没有变化,不需要更新
|
||||
if (conn.state === 'connected' && conn.activeTabId === tabId) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// 清除之前的空闲超时
|
||||
if (conn.idleTimeout) {
|
||||
clearTimeout(conn.idleTimeout)
|
||||
}
|
||||
|
||||
// 直接更新 pool store,不触发 lifecycle store 的 setState
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, {
|
||||
...conn,
|
||||
state: 'connected' as ConnectionState,
|
||||
activeTabId: tabId,
|
||||
stats: {
|
||||
...conn.stats,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
idleTimeout: undefined,
|
||||
})
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
setBackgroundTab: (connectionId, _tabId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn || conn.state === 'disconnected') return
|
||||
|
||||
// 如果状态没有变化,不需要更新
|
||||
if (conn.state === 'background' && conn.activeTabId === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const { idleTimeoutMs } = poolStore.config
|
||||
|
||||
// 设置空闲超时
|
||||
const idleTimeout = window.setTimeout(() => {
|
||||
get().suspendConnection(connectionId)
|
||||
}, idleTimeoutMs)
|
||||
|
||||
// 直接更新 pool store
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, {
|
||||
...conn,
|
||||
state: 'background' as ConnectionState,
|
||||
activeTabId: null,
|
||||
stats: {
|
||||
...conn.stats,
|
||||
lastBackgroundAt: now,
|
||||
},
|
||||
idleTimeout,
|
||||
})
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
suspendConnection: async (connectionId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn || conn.state === 'disconnected' || conn.state === 'suspended') return
|
||||
|
||||
// 清除保活
|
||||
if (conn.keepaliveInterval) {
|
||||
clearInterval(conn.keepaliveInterval)
|
||||
}
|
||||
|
||||
// 设置挂起超时(之后彻底断开)
|
||||
const { suspendTimeoutMs } = poolStore.config
|
||||
const idleTimeout = window.setTimeout(() => {
|
||||
poolStore.disconnectConnection(connectionId, 'suspend timeout')
|
||||
}, suspendTimeoutMs)
|
||||
|
||||
// 直接更新 pool store
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, {
|
||||
...conn,
|
||||
state: 'suspended' as ConnectionState,
|
||||
keepaliveInterval: undefined,
|
||||
idleTimeout,
|
||||
})
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
resumeConnection: async (connectionId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return false
|
||||
|
||||
if (conn.state === 'connected' || conn.state === 'background') {
|
||||
return true
|
||||
}
|
||||
|
||||
// 尝试重新连接
|
||||
try {
|
||||
const result = await window.electronAPI?.sshConnect(conn.config)
|
||||
if (!result?.success) return false
|
||||
|
||||
const newConnectionId = result.connectionId
|
||||
if (!newConnectionId) return false
|
||||
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.delete(connectionId)
|
||||
|
||||
const now = Date.now()
|
||||
const keepaliveInterval = window.setInterval(() => {
|
||||
window.electronAPI?.sshExec(newConnectionId, 'echo -n ""').catch(() => {})
|
||||
}, poolStore.config.keepaliveIntervalMs)
|
||||
|
||||
newConnections.set(newConnectionId, {
|
||||
...conn,
|
||||
connectionId: newConnectionId,
|
||||
state: 'connected' as ConnectionState,
|
||||
stats: {
|
||||
...conn.stats,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
keepaliveInterval,
|
||||
idleTimeout: undefined,
|
||||
reconnectAttempts: 0,
|
||||
})
|
||||
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
updateActivity: (connectionId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, {
|
||||
...conn,
|
||||
stats: {
|
||||
...conn.stats,
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
})
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
addShell: (connectionId, shellId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
conn.shellIds.add(shellId)
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, conn)
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
addSFTP: (connectionId, sftpId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
conn.sftpIds.add(sftpId)
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, conn)
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
removeShell: (connectionId, shellId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
conn.shellIds.delete(shellId)
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, conn)
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
removeSFTP: (connectionId, sftpId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
conn.sftpIds.delete(sftpId)
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, conn)
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Pool Store - 连接池核心管理
|
||||
* 负责: 连接 Map、LRU 淘汰、引用计数、创建/断开连接
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import type {
|
||||
PooledConnection,
|
||||
ConnectionConfig,
|
||||
ConnectionState,
|
||||
PoolClientConfig,
|
||||
} from './types'
|
||||
import type { CreateConnectionOptions, CreateConnectionResult, PoolStats } from './types'
|
||||
|
||||
// ============== Pool State ==============
|
||||
|
||||
interface PoolState {
|
||||
// 连接池: connectionId -> PooledConnection
|
||||
connections: Map<string, PooledConnection>
|
||||
|
||||
// 配置
|
||||
config: PoolClientConfig
|
||||
|
||||
// Actions
|
||||
setConfig: (config: Partial<PoolClientConfig>) => void
|
||||
|
||||
// 连接管理
|
||||
createConnection: (
|
||||
tabId: string,
|
||||
config: ConnectionConfig,
|
||||
options?: CreateConnectionOptions
|
||||
) => Promise<CreateConnectionResult>
|
||||
|
||||
acquireConnection: (
|
||||
tabId: string,
|
||||
sessionId: string,
|
||||
getSessionConfig: () => Promise<ConnectionConfig | null>
|
||||
) => Promise<{ success: boolean; connectionId?: string; error?: string }>
|
||||
|
||||
resumeConnection: (connectionId: string) => Promise<boolean>
|
||||
|
||||
disconnectConnection: (connectionId: string, reason?: string) => Promise<void>
|
||||
|
||||
releaseConnection: (connectionId: string, tabId: string) => Promise<void>
|
||||
|
||||
// 连接查询
|
||||
getConnection: (connectionId: string) => PooledConnection | undefined
|
||||
getConnectionState: (connectionId: string) => ConnectionState | undefined
|
||||
|
||||
// LRU 淘汰
|
||||
evictOldestBackgroundConnection: () => Promise<string | null>
|
||||
shouldEvict: () => boolean
|
||||
|
||||
// 统计
|
||||
getStats: () => PoolStats
|
||||
|
||||
// 断开所有
|
||||
disconnectAll: () => Promise<void>
|
||||
}
|
||||
|
||||
// ============== Pool Store Implementation ==============
|
||||
|
||||
export const usePoolStore = create<PoolState>((set, get) => ({
|
||||
connections: new Map(),
|
||||
config: {
|
||||
maxConnections: 10,
|
||||
maxBackgroundConnections: 5,
|
||||
idleTimeoutMs: 5 * 60 * 1000,
|
||||
suspendTimeoutMs: 30 * 60 * 1000,
|
||||
keepaliveIntervalMs: 60 * 1000,
|
||||
maxReconnectAttempts: 3,
|
||||
},
|
||||
|
||||
setConfig: (newConfig) => {
|
||||
set((state) => ({
|
||||
config: { ...state.config, ...newConfig },
|
||||
}))
|
||||
},
|
||||
|
||||
createConnection: async (tabId, config, _options = {}): Promise<CreateConnectionResult> => {
|
||||
console.log('=== POOL STORE createConnection ===')
|
||||
console.log(' tabId:', tabId)
|
||||
console.log(' config:', config.host, config.port, config.username)
|
||||
|
||||
const { connections, config: poolConfig } = get()
|
||||
const now = Date.now()
|
||||
|
||||
// 1. 检查是否已存在相同配置的连接 (连接复用)
|
||||
for (const [existingId, conn] of connections.entries()) {
|
||||
if (
|
||||
conn.config.host === config.host &&
|
||||
conn.config.port === config.port &&
|
||||
conn.config.username === config.username &&
|
||||
conn.state !== 'disconnected' &&
|
||||
conn.state !== 'error'
|
||||
) {
|
||||
// 复用已有连接
|
||||
conn.refCount++
|
||||
conn.tabIds.add(tabId)
|
||||
conn.activeTabId = tabId
|
||||
|
||||
if (conn.state === 'suspended') {
|
||||
// 需要恢复连接 - 简化处理:断开后重建
|
||||
const resumed = await get().resumeConnection(existingId)
|
||||
if (!resumed) {
|
||||
conn.refCount--
|
||||
conn.tabIds.delete(tabId)
|
||||
continue // 尝试创建新连接
|
||||
}
|
||||
} else {
|
||||
conn.state = 'connected'
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newConnections = new Map(state.connections)
|
||||
newConnections.set(existingId, conn)
|
||||
return { connections: newConnections }
|
||||
})
|
||||
|
||||
return { success: true, connectionId: existingId, isExisting: true }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查是否需要淘汰
|
||||
if (connections.size >= poolConfig.maxConnections) {
|
||||
const evictedId = await get().evictOldestBackgroundConnection()
|
||||
if (!evictedId) {
|
||||
return {
|
||||
success: false,
|
||||
error: `连接数达到上限 (${poolConfig.maxConnections}),请先关闭一些连接`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 创建新连接
|
||||
console.log('>>> Creating new SSH connection via electronAPI...')
|
||||
try {
|
||||
const result = await window.electronAPI?.sshConnect({
|
||||
host: config.host,
|
||||
port: config.port || 22,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
privateKey: config.privateKey,
|
||||
passphrase: config.passphrase,
|
||||
})
|
||||
console.log('>>> sshConnect result:', result)
|
||||
|
||||
if (!result?.success || !result.connectionId) {
|
||||
console.log('>>> sshConnect FAILED:', result?.error)
|
||||
return {
|
||||
success: false,
|
||||
error: result?.error || 'Failed to create connection',
|
||||
}
|
||||
}
|
||||
|
||||
const connectionId = result.connectionId
|
||||
console.log('>>> Connection created, connectionId:', connectionId)
|
||||
|
||||
// 设置保活定时器
|
||||
const keepaliveInterval = window.setInterval(() => {
|
||||
const conn = get().connections.get(connectionId)
|
||||
if (conn && (conn.state === 'connected' || conn.state === 'background')) {
|
||||
window.electronAPI?.sshExec(connectionId, 'echo -n ""').catch(() => {
|
||||
get().disconnectConnection(connectionId, 'keepalive failed')
|
||||
})
|
||||
}
|
||||
}, poolConfig.keepaliveIntervalMs)
|
||||
|
||||
// 4. 创建连接池项
|
||||
const pooledConnection: PooledConnection = {
|
||||
connectionId,
|
||||
config: {
|
||||
host: config.host,
|
||||
port: config.port || 22,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
privateKey: config.privateKey,
|
||||
passphrase: config.passphrase,
|
||||
},
|
||||
state: 'connected',
|
||||
refCount: 1,
|
||||
tabIds: new Set([tabId]),
|
||||
activeTabId: tabId,
|
||||
shellIds: new Set(),
|
||||
sftpIds: new Set(),
|
||||
stats: {
|
||||
connectedAt: now,
|
||||
lastActivityAt: now,
|
||||
bytesTransferred: 0,
|
||||
commandsExecuted: 0,
|
||||
},
|
||||
keepaliveInterval,
|
||||
reconnectAttempts: 0,
|
||||
maxReconnectAttempts: poolConfig.maxReconnectAttempts,
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newConnections = new Map(state.connections)
|
||||
newConnections.set(connectionId, pooledConnection)
|
||||
return { connections: newConnections }
|
||||
})
|
||||
|
||||
return { success: true, connectionId }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Unknown error creating connection',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 辅助: 恢复连接 (简化实现)
|
||||
resumeConnection: async (connectionId: string): Promise<boolean> => {
|
||||
const conn = get().connections.get(connectionId)
|
||||
if (!conn) return false
|
||||
|
||||
if (conn.state === 'connected' || conn.state === 'background') {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI?.sshConnect(conn.config)
|
||||
if (!result?.success) return false
|
||||
|
||||
const newConnectionId = result.connectionId
|
||||
if (!newConnectionId) return false
|
||||
|
||||
set((state) => {
|
||||
const newConnections = new Map(state.connections)
|
||||
newConnections.delete(connectionId)
|
||||
|
||||
const now = Date.now()
|
||||
const keepaliveInterval = window.setInterval(() => {
|
||||
window.electronAPI?.sshExec(newConnectionId, 'echo -n ""').catch(() => {})
|
||||
}, state.config.keepaliveIntervalMs)
|
||||
|
||||
newConnections.set(newConnectionId, {
|
||||
...conn,
|
||||
connectionId: newConnectionId,
|
||||
state: 'connected',
|
||||
stats: {
|
||||
...conn.stats,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
keepaliveInterval,
|
||||
idleTimeout: undefined,
|
||||
reconnectAttempts: 0,
|
||||
})
|
||||
|
||||
return { connections: newConnections }
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
acquireConnection: async (tabId, _sessionId, getSessionConfigFn) => {
|
||||
const { connections } = get()
|
||||
|
||||
// 检查是否已有该标签的连接
|
||||
for (const [connId, conn] of connections.entries()) {
|
||||
if (conn.tabIds.has(tabId) && conn.state !== 'disconnected') {
|
||||
return { success: true, connectionId: connId }
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 session 配置并创建连接
|
||||
const config = await getSessionConfigFn()
|
||||
if (!config) {
|
||||
return { success: false, error: '无法获取会话配置' }
|
||||
}
|
||||
|
||||
return get().createConnection(tabId, config)
|
||||
},
|
||||
|
||||
disconnectConnection: async (connectionId, _reason = 'manual') => {
|
||||
const conn = get().connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
// 清除定时器
|
||||
if (conn.keepaliveInterval) {
|
||||
clearInterval(conn.keepaliveInterval)
|
||||
}
|
||||
if (conn.idleTimeout) {
|
||||
clearTimeout(conn.idleTimeout)
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI?.sshDisconnect(connectionId)
|
||||
} catch {
|
||||
// 忽略断开错误
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newConnections = new Map(state.connections)
|
||||
newConnections.delete(connectionId)
|
||||
return { connections: newConnections }
|
||||
})
|
||||
},
|
||||
|
||||
releaseConnection: async (connectionId, tabId) => {
|
||||
const conn = get().connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
conn.tabIds.delete(tabId)
|
||||
conn.refCount = Math.max(0, conn.refCount - 1)
|
||||
|
||||
if (conn.activeTabId === tabId) {
|
||||
conn.activeTabId = null
|
||||
}
|
||||
|
||||
// 如果没有标签使用,断开连接
|
||||
if (conn.refCount === 0 || conn.tabIds.size === 0) {
|
||||
await get().disconnectConnection(connectionId, 'no refs')
|
||||
return
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newConnections = new Map(state.connections)
|
||||
newConnections.set(connectionId, conn)
|
||||
return { connections: newConnections }
|
||||
})
|
||||
},
|
||||
|
||||
getConnection: (connectionId) => {
|
||||
return get().connections.get(connectionId)
|
||||
},
|
||||
|
||||
getConnectionState: (connectionId) => {
|
||||
return get().connections.get(connectionId)?.state
|
||||
},
|
||||
|
||||
evictOldestBackgroundConnection: async () => {
|
||||
const { connections } = get()
|
||||
|
||||
let oldestId: string | null = null
|
||||
let oldestTime = Infinity
|
||||
|
||||
for (const [id, conn] of connections.entries()) {
|
||||
if (conn.state === 'background' || conn.state === 'suspended') {
|
||||
const lastActivity = conn.stats.lastBackgroundAt || conn.stats.lastActivityAt
|
||||
if (lastActivity < oldestTime) {
|
||||
oldestTime = lastActivity
|
||||
oldestId = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestId) {
|
||||
await get().disconnectConnection(oldestId, 'evicted')
|
||||
}
|
||||
|
||||
return oldestId
|
||||
},
|
||||
|
||||
shouldEvict: () => {
|
||||
const { connections, config } = get()
|
||||
return connections.size >= config.maxConnections
|
||||
},
|
||||
|
||||
getStats: (): PoolStats => {
|
||||
const { connections } = get()
|
||||
let connected = 0, background = 0, suspended = 0, connecting = 0, error = 0
|
||||
|
||||
connections.forEach((conn) => {
|
||||
switch (conn.state) {
|
||||
case 'connected': connected++; break
|
||||
case 'background': background++; break
|
||||
case 'suspended': suspended++; break
|
||||
case 'connecting': connecting++; break
|
||||
case 'error': error++; break
|
||||
}
|
||||
})
|
||||
|
||||
return { total: connections.size, connected, background, suspended, connecting, error }
|
||||
},
|
||||
|
||||
disconnectAll: async () => {
|
||||
const { connections } = get()
|
||||
const promises: Promise<void>[] = []
|
||||
|
||||
connections.forEach((conn) => {
|
||||
if (conn.keepaliveInterval) {
|
||||
clearInterval(conn.keepaliveInterval)
|
||||
}
|
||||
if (conn.idleTimeout) {
|
||||
clearTimeout(conn.idleTimeout)
|
||||
}
|
||||
|
||||
promises.push(
|
||||
window.electronAPI?.sshDisconnect(conn.connectionId)
|
||||
.then(() => {})
|
||||
.catch(() => {}) as Promise<void>
|
||||
)
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
set({ connections: new Map() })
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Session Helper - 会话配置辅助函数
|
||||
* 从 sessionId 获取连接配置
|
||||
*/
|
||||
|
||||
import type { ConnectionConfig } from './types'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
|
||||
const logger = createLogger('SessionHelper')
|
||||
|
||||
/**
|
||||
* 从 sessionId 获取连接配置
|
||||
*/
|
||||
export async function getSessionConfig(sessionId: string): Promise<ConnectionConfig | null> {
|
||||
try {
|
||||
const result = await window.electronAPI?.sessionGetById(sessionId)
|
||||
if (!result?.success || !result.session) {
|
||||
return null
|
||||
}
|
||||
|
||||
const session = result.session
|
||||
return {
|
||||
host: session.host,
|
||||
port: session.port || 22,
|
||||
username: session.username,
|
||||
password: session.password,
|
||||
privateKey: session.privateKey,
|
||||
passphrase: session.passphrase,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get session config:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Connection Store Hooks - 组合 Store 接口
|
||||
* 提供统一的连接管理接口
|
||||
*/
|
||||
|
||||
import { usePoolStore } from './poolStore'
|
||||
import { useLifecycleStore } from './lifecycleStore'
|
||||
|
||||
// ============== 组合 Store Hook ==============
|
||||
|
||||
export function useConnectionStore() {
|
||||
const pool = usePoolStore()
|
||||
const lifecycle = useLifecycleStore()
|
||||
|
||||
return {
|
||||
// 配置
|
||||
config: pool.config,
|
||||
setConfig: pool.setConfig,
|
||||
|
||||
// 连接生命周期
|
||||
createConnection: pool.createConnection,
|
||||
acquireConnection: pool.acquireConnection,
|
||||
disconnectConnection: pool.disconnectConnection,
|
||||
releaseConnection: pool.releaseConnection,
|
||||
|
||||
// 状态管理
|
||||
setActiveTab: lifecycle.setActiveTab,
|
||||
setBackgroundTab: lifecycle.setBackgroundTab,
|
||||
suspendConnection: lifecycle.suspendConnection,
|
||||
resumeConnection: lifecycle.resumeConnection,
|
||||
updateActivity: lifecycle.updateActivity,
|
||||
|
||||
// 会话管理
|
||||
addShell: lifecycle.addShell,
|
||||
addSFTP: lifecycle.addSFTP,
|
||||
removeShell: lifecycle.removeShell,
|
||||
removeSFTP: lifecycle.removeSFTP,
|
||||
|
||||
// 查询
|
||||
getConnection: pool.getConnection,
|
||||
getConnectionState: pool.getConnectionState,
|
||||
getStats: pool.getStats,
|
||||
|
||||
// LRU
|
||||
evictOldestBackgroundConnection: pool.evictOldestBackgroundConnection,
|
||||
shouldEvict: pool.shouldEvict,
|
||||
|
||||
// 全部断开
|
||||
disconnectAll: pool.disconnectAll,
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Backward Compatibility ==============
|
||||
// 保持原有 useConnectionPool 名称以便现有代码无需修改
|
||||
|
||||
/**
|
||||
* @deprecated 请使用 useConnectionStore()
|
||||
*/
|
||||
export const useConnectionPool = useConnectionStore
|
||||
|
||||
// ============== Static Store Access ==============
|
||||
// 用于非 React 上下文中访问 store
|
||||
|
||||
/**
|
||||
* 获取组合 store 的状态 (用于静态调用)
|
||||
*/
|
||||
export function getConnectionStore() {
|
||||
const pool = usePoolStore.getState()
|
||||
const lifecycle = useLifecycleStore.getState()
|
||||
|
||||
return {
|
||||
config: pool.config,
|
||||
setConfig: pool.setConfig,
|
||||
createConnection: pool.createConnection,
|
||||
acquireConnection: pool.acquireConnection,
|
||||
disconnectConnection: pool.disconnectConnection,
|
||||
releaseConnection: pool.releaseConnection,
|
||||
setActiveTab: lifecycle.setActiveTab,
|
||||
setBackgroundTab: lifecycle.setBackgroundTab,
|
||||
suspendConnection: lifecycle.suspendConnection,
|
||||
resumeConnection: lifecycle.resumeConnection,
|
||||
updateActivity: lifecycle.updateActivity,
|
||||
addShell: lifecycle.addShell,
|
||||
addSFTP: lifecycle.addSFTP,
|
||||
removeShell: lifecycle.removeShell,
|
||||
removeSFTP: lifecycle.removeSFTP,
|
||||
getConnection: pool.getConnection,
|
||||
getConnectionState: pool.getConnectionState,
|
||||
getStats: pool.getStats,
|
||||
evictOldestBackgroundConnection: pool.evictOldestBackgroundConnection,
|
||||
shouldEvict: pool.shouldEvict,
|
||||
disconnectAll: pool.disconnectAll,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 请使用 getConnectionStore()
|
||||
*/
|
||||
export const createConnectionStore = getConnectionStore
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Connection Types - 连接管理类型定义
|
||||
* 渲染进程专用 (客户端)
|
||||
*/
|
||||
|
||||
// Re-export shared types
|
||||
export type {
|
||||
ConnectionState,
|
||||
ConnectionStats,
|
||||
PoolConfig,
|
||||
PooledConnection,
|
||||
ConnectionResult,
|
||||
ShellInfo,
|
||||
ConnectionConfig,
|
||||
} from '../../../shared/types/connection.types'
|
||||
|
||||
export type { AuthType, Session } from '../../../shared/types/session.types'
|
||||
export type { SSHConnectionConfig } from '../../../shared/types/session.types'
|
||||
|
||||
// ============== Additional Client-side Types ==============
|
||||
|
||||
// 连接池配置 (客户端用)
|
||||
export interface PoolClientConfig {
|
||||
maxConnections: number // 最大连接数
|
||||
maxBackgroundConnections: number // 最大后台连接数
|
||||
idleTimeoutMs: number // 空闲超时(进入 suspended)
|
||||
suspendTimeoutMs: number // 挂起超时(断开连接)
|
||||
keepaliveIntervalMs: number // 保活间隔
|
||||
maxReconnectAttempts: number // 最大重连次数
|
||||
}
|
||||
|
||||
// 连接创建选项
|
||||
export interface CreateConnectionOptions {
|
||||
skipIfExists?: boolean // 如果存在则跳过
|
||||
}
|
||||
|
||||
// 连接创建结果
|
||||
export interface CreateConnectionResult {
|
||||
success: boolean
|
||||
connectionId?: string
|
||||
error?: string
|
||||
isExisting?: boolean
|
||||
}
|
||||
|
||||
// 获取连接结果
|
||||
export interface AcquireConnectionResult {
|
||||
success: boolean
|
||||
connectionId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 连接统计摘要
|
||||
export interface PoolStats {
|
||||
total: number
|
||||
connected: number
|
||||
background: number
|
||||
suspended: number
|
||||
connecting: number
|
||||
error: number
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Store 统一导出
|
||||
*/
|
||||
|
||||
export { useTabStore } from './tabStore'
|
||||
export { useSettingsStore } from './settingsStore'
|
||||
export { useTerminalSnapshotStore } from './terminalSnapshotStore'
|
||||
|
||||
// 会话管理
|
||||
export { useSessionStore, useConnectionStore as useSessionStoreCompat } from './sessionStore'
|
||||
export type { Session } from './sessionStore'
|
||||
|
||||
// 连接管理 (新架构)
|
||||
export {
|
||||
useConnectionStore as useConnectionManager,
|
||||
useConnectionPool,
|
||||
getConnectionStore,
|
||||
getSessionConfig,
|
||||
} from './connection'
|
||||
|
||||
// 类型导出
|
||||
export type { ConnectionState } from './connection'
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Session Store - 会话管理
|
||||
*
|
||||
* 管理保存的 SSH 会话配置
|
||||
* 与 connection store 分离,专注会话 CRUD
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
authType: 'password' | 'key' | 'agent'
|
||||
password?: string
|
||||
privateKey?: string
|
||||
passphrase?: string
|
||||
group?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
sessions: Session[]
|
||||
setSessions: (sessions: Session[]) => void
|
||||
addSession: (session: Session) => void
|
||||
removeSession: (id: string) => void
|
||||
updateSession: (id: string, updates: Partial<Session>) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>((set) => ({
|
||||
sessions: [],
|
||||
|
||||
setSessions: (sessions) => set({ sessions }),
|
||||
|
||||
addSession: (session) => {
|
||||
set((state) => ({
|
||||
sessions: [...state.sessions, session],
|
||||
}))
|
||||
},
|
||||
|
||||
removeSession: (id) => {
|
||||
set((state) => ({
|
||||
sessions: state.sessions.filter((s) => s.id !== id),
|
||||
}))
|
||||
// 同步删除数据库中的会话
|
||||
window.electronAPI?.sessionDelete(id)
|
||||
},
|
||||
|
||||
updateSession: async (id, updates) => {
|
||||
set((state) => ({
|
||||
sessions: state.sessions.map((s) =>
|
||||
s.id === id ? { ...s, ...updates } : s
|
||||
),
|
||||
}))
|
||||
// 同步更新数据库中的会话
|
||||
await window.electronAPI?.sessionUpdate(id, updates)
|
||||
},
|
||||
}))
|
||||
|
||||
// 向后兼容:导出旧名称
|
||||
export const useConnectionStore = useSessionStore
|
||||
@@ -0,0 +1,99 @@
|
||||
import { create } from 'zustand'
|
||||
import { createLogger } from '../utils/logger'
|
||||
|
||||
const logger = createLogger('SettingsStore')
|
||||
|
||||
export interface AppSettings {
|
||||
// 常规
|
||||
restoreOnStart: boolean
|
||||
autoSave: boolean
|
||||
showNotifications: boolean
|
||||
|
||||
// 外观
|
||||
theme: 'dark' | 'light' | 'system'
|
||||
language: 'zh-CN' | 'en-US'
|
||||
|
||||
// 终端
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
terminalTheme: 'dracula' | 'monokai' | 'solarized' | 'one-dark'
|
||||
|
||||
// SSH
|
||||
keepAlive: boolean
|
||||
defaultPort: number
|
||||
timeout: number
|
||||
|
||||
// 专业连接管理
|
||||
maxConnections: number
|
||||
maxBackgroundConnections: number
|
||||
idleTimeoutMs: number
|
||||
suspendTimeoutMs: number
|
||||
enableSnapshots: boolean
|
||||
autoReconnect: boolean
|
||||
|
||||
// 安全
|
||||
encryptPasswords: boolean
|
||||
lockOnSleep: boolean
|
||||
}
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
restoreOnStart: true,
|
||||
autoSave: true,
|
||||
showNotifications: false,
|
||||
theme: 'dark',
|
||||
language: 'zh-CN',
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 14,
|
||||
terminalTheme: 'dracula',
|
||||
keepAlive: true,
|
||||
defaultPort: 22,
|
||||
timeout: 30,
|
||||
// 专业连接管理默认值
|
||||
maxConnections: 10,
|
||||
maxBackgroundConnections: 5,
|
||||
idleTimeoutMs: 5 * 60 * 1000, // 5 分钟
|
||||
suspendTimeoutMs: 30 * 60 * 1000, // 30 分钟
|
||||
enableSnapshots: true,
|
||||
autoReconnect: true,
|
||||
encryptPasswords: true,
|
||||
lockOnSleep: false,
|
||||
}
|
||||
|
||||
interface SettingsState {
|
||||
settings: AppSettings
|
||||
isLoaded: boolean
|
||||
setSetting: <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => Promise<void>
|
||||
loadSettings: () => Promise<void>
|
||||
saveSettings: (settings: Partial<AppSettings>) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
settings: defaultSettings,
|
||||
isLoaded: false,
|
||||
|
||||
loadSettings: async () => {
|
||||
try {
|
||||
const result = await window.electronAPI?.settingsGet('app-settings')
|
||||
if (result?.success && result.value) {
|
||||
set({ settings: { ...defaultSettings, ...result.value }, isLoaded: true })
|
||||
} else {
|
||||
set({ isLoaded: true })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load settings:', error)
|
||||
set({ isLoaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
setSetting: async (key, value) => {
|
||||
const newSettings = { ...get().settings, [key]: value }
|
||||
set({ settings: newSettings })
|
||||
await window.electronAPI?.settingsSet('app-settings', newSettings)
|
||||
},
|
||||
|
||||
saveSettings: async (newSettings) => {
|
||||
const merged = { ...get().settings, ...newSettings }
|
||||
set({ settings: merged })
|
||||
await window.electronAPI?.settingsSet('app-settings', merged)
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,226 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type TabType = 'welcome' | 'terminal' | 'sftp' | 'portforward' | 'settings'
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
// SSH 连接配置(用于快速连接)
|
||||
export interface SSHConnectionConfig {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password?: string
|
||||
privateKey?: string
|
||||
passphrase?: string
|
||||
}
|
||||
|
||||
// 连接实例信息
|
||||
export interface ConnectionInstance {
|
||||
connectionId: string // 实际的 SSH 连接 ID
|
||||
shellId?: string // Shell 会话 ID(仅 terminal 类型)
|
||||
sftpId?: string // SFTP 会话 ID(仅 sftp 类型)
|
||||
}
|
||||
|
||||
export interface Tab {
|
||||
id: string
|
||||
title: string
|
||||
type: TabType
|
||||
sessionId?: string // 持久化会话 ID(用于获取配置)
|
||||
connectionConfig?: SSHConnectionConfig // 快速连接配置(临时)
|
||||
connectionInstance?: ConnectionInstance // 实际的连接实例(每个标签独立)
|
||||
isActive: boolean
|
||||
connectionStatus?: ConnectionStatus
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
tabs: Tab[]
|
||||
activeTabId: string | null
|
||||
|
||||
// Actions
|
||||
addTab: (tab: Omit<Tab, 'id' | 'isActive'>) => void
|
||||
updateTab: (tabId: string, updates: Partial<Tab>) => void
|
||||
updateTabConnectionInstance: (tabId: string, instance: ConnectionInstance) => void
|
||||
updateTabStatus: (tabId: string, status: ConnectionStatus) => void
|
||||
closeTab: (tabId: string) => Tab | null // 返回被关闭的标签(用于清理连接)
|
||||
closeOtherTabs: (tabId: string) => Tab[] // 返回被关闭的标签数组
|
||||
closeRightTabs: (tabId: string) => Tab[]
|
||||
closeLeftTabs: (tabId: string) => Tab[]
|
||||
closeAllTabs: () => Tab[]
|
||||
activateTab: (tabId: string) => void
|
||||
updateTabTitle: (tabId: string, title: string) => void
|
||||
getTabById: (tabId: string) => Tab | undefined
|
||||
}
|
||||
|
||||
export const useTabStore = create<TabState>((set, get) => ({
|
||||
tabs: [
|
||||
{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }
|
||||
],
|
||||
activeTabId: 'welcome',
|
||||
|
||||
addTab: (tab) => {
|
||||
const { tabs } = get()
|
||||
|
||||
// 检查是否是唯一类型的标签(settings, welcome),如果有则激活而不是创建新的
|
||||
if (tab.type === 'settings' || tab.type === 'welcome') {
|
||||
const existing = tabs.find(t => t.type === tab.type)
|
||||
if (existing) {
|
||||
// 激活已存在的标签
|
||||
const updatedTabs = tabs.map(t => ({
|
||||
...t,
|
||||
isActive: t.id === existing.id,
|
||||
}))
|
||||
set({ tabs: updatedTabs, activeTabId: existing.id })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const newTab: Tab = {
|
||||
...tab,
|
||||
id: `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// Deactivate all other tabs
|
||||
const updatedTabs = tabs.map(t => ({ ...t, isActive: false }))
|
||||
|
||||
set({
|
||||
tabs: [...updatedTabs, newTab],
|
||||
activeTabId: newTab.id,
|
||||
})
|
||||
},
|
||||
|
||||
updateTab: (tabId, updates) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, ...updates } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
updateTabConnectionInstance: (tabId, instance) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, connectionInstance: instance } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
updateTabStatus: (tabId, status) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, connectionStatus: status } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
closeTab: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
|
||||
if (tabs.length <= 1) return null
|
||||
|
||||
const closedTab = tabs.find(t => t.id === tabId) || null
|
||||
const newTabs = tabs.filter(t => t.id !== tabId)
|
||||
|
||||
// If closing active tab, activate another one
|
||||
let newActiveId = activeTabId
|
||||
if (activeTabId === tabId) {
|
||||
const closedTabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
const newActiveTab = newTabs[Math.min(closedTabIndex, newTabs.length - 1)]
|
||||
if (newActiveTab) {
|
||||
newActiveTab.isActive = true
|
||||
newActiveId = newActiveTab.id
|
||||
}
|
||||
}
|
||||
|
||||
set({ tabs: newTabs, activeTabId: newActiveId })
|
||||
return closedTab
|
||||
},
|
||||
|
||||
closeOtherTabs: (tabId) => {
|
||||
const { tabs } = get()
|
||||
const keepTab = tabs.find(t => t.id === tabId)
|
||||
if (!keepTab) return []
|
||||
|
||||
const closedTabs = tabs.filter(t => t.id !== tabId)
|
||||
keepTab.isActive = true
|
||||
set({
|
||||
tabs: [keepTab],
|
||||
activeTabId: tabId,
|
||||
})
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeRightTabs: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
if (tabIndex === -1) return []
|
||||
|
||||
// 保留当前标签及其左侧所有标签
|
||||
const closedTabs = tabs.slice(tabIndex + 1)
|
||||
const newTabs = tabs.slice(0, tabIndex + 1)
|
||||
// 如果关闭的是当前活动标签,激活最后一个
|
||||
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) > tabIndex) {
|
||||
newTabs[newTabs.length - 1].isActive = true
|
||||
set({ tabs: newTabs, activeTabId: newTabs[newTabs.length - 1].id })
|
||||
} else {
|
||||
set({ tabs: newTabs })
|
||||
}
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeLeftTabs: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
if (tabIndex === -1) return []
|
||||
|
||||
// 保留当前标签及其右侧所有标签
|
||||
const closedTabs = tabs.slice(0, tabIndex)
|
||||
const newTabs = tabs.slice(tabIndex)
|
||||
// 如果关闭的是当前活动标签,激活第一个
|
||||
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) < tabIndex) {
|
||||
newTabs[0].isActive = true
|
||||
set({ tabs: newTabs, activeTabId: newTabs[0].id })
|
||||
} else {
|
||||
set({ tabs: newTabs, activeTabId: tabId })
|
||||
}
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeAllTabs: () => {
|
||||
const { tabs } = get()
|
||||
const welcomeTab: Tab = {
|
||||
id: 'welcome',
|
||||
title: '欢迎',
|
||||
type: 'welcome',
|
||||
isActive: true,
|
||||
}
|
||||
set({
|
||||
tabs: [welcomeTab],
|
||||
activeTabId: 'welcome',
|
||||
})
|
||||
return tabs
|
||||
},
|
||||
|
||||
activateTab: (tabId) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t => ({
|
||||
...t,
|
||||
isActive: t.id === tabId,
|
||||
}))
|
||||
set({
|
||||
tabs: updatedTabs,
|
||||
activeTabId: tabId,
|
||||
})
|
||||
},
|
||||
|
||||
updateTabTitle: (tabId, title) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, title } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
getTabById: (tabId) => {
|
||||
return get().tabs.find(t => t.id === tabId)
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,348 @@
|
||||
import { create } from 'zustand'
|
||||
import { createLogger } from '../utils/logger'
|
||||
|
||||
const logger = createLogger('TerminalSnapshotStore')
|
||||
|
||||
// 终端缓冲区行
|
||||
export interface BufferLine {
|
||||
text: string
|
||||
styles: number[] // xterm 样式信息
|
||||
}
|
||||
|
||||
// 终端快照
|
||||
export interface TerminalSnapshot {
|
||||
tabId: string
|
||||
connectionId: string
|
||||
shellId: string | null
|
||||
|
||||
// 缓冲区内容(最后 N 行)
|
||||
buffer: string[]
|
||||
bufferLines: number // 总行数
|
||||
|
||||
// 光标位置
|
||||
cursorX: number
|
||||
cursorY: number
|
||||
|
||||
// 视口
|
||||
scrollTop: number
|
||||
scrollBottom: number
|
||||
|
||||
// 尺寸
|
||||
cols: number
|
||||
rows: number
|
||||
|
||||
// 元数据
|
||||
timestamp: number
|
||||
hasFocus: boolean
|
||||
|
||||
// 终端模式
|
||||
applicationCursorKeys: boolean
|
||||
applicationKeypad: boolean
|
||||
originMode: boolean
|
||||
insertMode: boolean
|
||||
wraparoundMode: boolean
|
||||
bracketedPasteMode: boolean
|
||||
}
|
||||
|
||||
// 快照配置
|
||||
interface SnapshotConfig {
|
||||
maxBufferLines: number // 保存的最大缓冲区行数
|
||||
snapshotOnBlur: boolean // 失去焦点时自动快照
|
||||
snapshotOnSwitch: boolean // 切换标签时自动快照
|
||||
restoreAnimation: boolean // 恢复时是否播放淡入动画
|
||||
}
|
||||
|
||||
interface TerminalSnapshotState {
|
||||
// 快照存储: tabId -> TerminalSnapshot
|
||||
snapshots: Map<string, TerminalSnapshot>
|
||||
|
||||
// 配置
|
||||
config: SnapshotConfig
|
||||
|
||||
// 当前活跃的标签
|
||||
activeTabId: string | null
|
||||
|
||||
// Actions
|
||||
setConfig: (config: Partial<SnapshotConfig>) => void
|
||||
|
||||
// 创建快照
|
||||
createSnapshot: (
|
||||
tabId: string,
|
||||
terminal: {
|
||||
buffer: { active: { getLine: (y: number) => { translateToString: (trimRight?: boolean) => string } | undefined; length: number; cursorX: number; cursorY: number } }
|
||||
cols: number
|
||||
rows: number
|
||||
modes: {
|
||||
applicationCursorKeysMode: boolean
|
||||
applicationKeypadMode: boolean
|
||||
originMode: boolean
|
||||
insertMode: boolean
|
||||
wraparoundMode: boolean
|
||||
bracketedPasteMode: boolean
|
||||
}
|
||||
},
|
||||
connectionInfo: {
|
||||
connectionId: string
|
||||
shellId: string | null
|
||||
}
|
||||
) => void
|
||||
|
||||
// 获取快照
|
||||
getSnapshot: (tabId: string) => TerminalSnapshot | undefined
|
||||
|
||||
// 检查是否有快照
|
||||
hasSnapshot: (tabId: string) => boolean
|
||||
|
||||
// 删除快照
|
||||
deleteSnapshot: (tabId: string) => void
|
||||
|
||||
// 恢复快照到终端
|
||||
restoreSnapshot: (
|
||||
tabId: string,
|
||||
terminal: {
|
||||
write: (data: string) => void
|
||||
resize: (cols: number, rows: number) => void
|
||||
clear: () => void
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
) => boolean
|
||||
|
||||
// 设置当前活跃标签
|
||||
setActiveTab: (tabId: string | null) => void
|
||||
|
||||
// 处理标签切换(自动快照/恢复)
|
||||
handleTabSwitch: (fromTabId: string | null, toTabId: string | null) => void
|
||||
|
||||
// 清空所有快照
|
||||
clearAll: () => void
|
||||
|
||||
// 获取快照统计
|
||||
getStats: () => {
|
||||
count: number
|
||||
totalSize: number // 估算的字节数
|
||||
oldestSnapshot: number | null
|
||||
}
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_CONFIG: SnapshotConfig = {
|
||||
maxBufferLines: 1000, // 保存最后 1000 行
|
||||
snapshotOnBlur: true,
|
||||
snapshotOnSwitch: true,
|
||||
restoreAnimation: true,
|
||||
}
|
||||
|
||||
// ANSI 转义序列工具
|
||||
const ANSI = {
|
||||
clear: '\x1b[2J\x1b[H', // 清屏并移动光标到左上角
|
||||
clearScrollback: '\x1b[3J', // 清除滚动缓冲区
|
||||
reset: '\x1b[c', // 重置终端
|
||||
saveCursor: '\x1b[s', // 保存光标位置
|
||||
restoreCursor: '\x1b[u', // 恢复光标位置
|
||||
hideCursor: '\x1b[?25l', // 隐藏光标
|
||||
showCursor: '\x1b[?25h', // 显示光标
|
||||
setCursor: (x: number, y: number) => `\x1b[${y + 1};${x + 1}H`, // 设置光标位置
|
||||
}
|
||||
|
||||
export const useTerminalSnapshotStore = create<TerminalSnapshotState>((set, get) => ({
|
||||
snapshots: new Map(),
|
||||
config: { ...DEFAULT_CONFIG },
|
||||
activeTabId: null,
|
||||
|
||||
setConfig: (newConfig) => {
|
||||
set((state) => ({
|
||||
config: { ...state.config, ...newConfig }
|
||||
}))
|
||||
},
|
||||
|
||||
createSnapshot: (tabId, terminal, connectionInfo) => {
|
||||
const { config } = get()
|
||||
|
||||
try {
|
||||
// 提取缓冲区内容
|
||||
const buffer: string[] = []
|
||||
const bufferLength = terminal.buffer.active.length
|
||||
const startLine = Math.max(0, bufferLength - config.maxBufferLines)
|
||||
|
||||
for (let i = startLine; i < bufferLength; i++) {
|
||||
const line = terminal.buffer.active.getLine(i)
|
||||
if (line) {
|
||||
buffer.push(line.translateToString(true))
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot: TerminalSnapshot = {
|
||||
tabId,
|
||||
connectionId: connectionInfo.connectionId,
|
||||
shellId: connectionInfo.shellId,
|
||||
buffer,
|
||||
bufferLines: bufferLength,
|
||||
cursorX: terminal.buffer.active.cursorX,
|
||||
cursorY: terminal.buffer.active.cursorY,
|
||||
scrollTop: 0, // xterm.js 不直接暴露这些,使用默认值
|
||||
scrollBottom: terminal.rows - 1,
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
timestamp: Date.now(),
|
||||
hasFocus: false,
|
||||
applicationCursorKeys: terminal.modes.applicationCursorKeysMode,
|
||||
applicationKeypad: terminal.modes.applicationKeypadMode,
|
||||
originMode: terminal.modes.originMode,
|
||||
insertMode: terminal.modes.insertMode,
|
||||
wraparoundMode: terminal.modes.wraparoundMode,
|
||||
bracketedPasteMode: terminal.modes.bracketedPasteMode,
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newSnapshots = new Map(state.snapshots)
|
||||
newSnapshots.set(tabId, snapshot)
|
||||
return { snapshots: newSnapshots }
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to create snapshot:', error)
|
||||
}
|
||||
},
|
||||
|
||||
getSnapshot: (tabId) => {
|
||||
return get().snapshots.get(tabId)
|
||||
},
|
||||
|
||||
hasSnapshot: (tabId) => {
|
||||
return get().snapshots.has(tabId)
|
||||
},
|
||||
|
||||
deleteSnapshot: (tabId) => {
|
||||
set((state) => {
|
||||
const newSnapshots = new Map(state.snapshots)
|
||||
newSnapshots.delete(tabId)
|
||||
return { snapshots: newSnapshots }
|
||||
})
|
||||
},
|
||||
|
||||
restoreSnapshot: (tabId, terminal) => {
|
||||
const snapshot = get().snapshots.get(tabId)
|
||||
if (!snapshot) return false
|
||||
|
||||
try {
|
||||
// 清屏
|
||||
terminal.clear()
|
||||
|
||||
// 调整尺寸(如果不同)
|
||||
if (snapshot.cols !== terminal.cols || snapshot.rows !== terminal.rows) {
|
||||
terminal.resize(snapshot.cols, snapshot.rows)
|
||||
}
|
||||
|
||||
// 写入缓冲区内容
|
||||
// 使用更高效的批量写入
|
||||
const content = snapshot.buffer.join('\r\n')
|
||||
if (content) {
|
||||
terminal.write(content)
|
||||
}
|
||||
|
||||
// 恢复光标位置
|
||||
terminal.write(ANSI.setCursor(snapshot.cursorX, snapshot.cursorY))
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to restore snapshot:', error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
setActiveTab: (tabId) => {
|
||||
set({ activeTabId: tabId })
|
||||
},
|
||||
|
||||
handleTabSwitch: (fromTabId, toTabId) => {
|
||||
const { config } = get()
|
||||
|
||||
// 在离开标签时创建快照
|
||||
if (fromTabId && config.snapshotOnSwitch) {
|
||||
// 注意:这里需要在组件中调用 createSnapshot
|
||||
// 因为需要访问 xterm 实例
|
||||
}
|
||||
|
||||
// 更新当前活跃标签
|
||||
set({ activeTabId: toTabId })
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ snapshots: new Map() })
|
||||
},
|
||||
|
||||
getStats: () => {
|
||||
const { snapshots } = get()
|
||||
let totalSize = 0
|
||||
let oldestSnapshot: number | null = null
|
||||
|
||||
snapshots.forEach((snapshot) => {
|
||||
// 估算大小:每个字符 2 bytes (UTF-16)
|
||||
const bufferSize = snapshot.buffer.reduce((sum, line) => sum + line.length * 2, 0)
|
||||
totalSize += bufferSize + 200 // 200 bytes 用于其他字段
|
||||
|
||||
if (!oldestSnapshot || snapshot.timestamp < oldestSnapshot) {
|
||||
oldestSnapshot = snapshot.timestamp
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
count: snapshots.size,
|
||||
totalSize,
|
||||
oldestSnapshot,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// 辅助 Hook:在组件中管理快照
|
||||
export function useTerminalSnapshot(
|
||||
tabId: string,
|
||||
terminal: {
|
||||
buffer: { active: { getLine: (y: number) => { translateToString: (trimRight?: boolean) => string } | undefined; length: number; cursorX: number; cursorY: number } }
|
||||
cols: number
|
||||
rows: number
|
||||
modes: {
|
||||
applicationCursorKeysMode: boolean
|
||||
applicationKeypadMode: boolean
|
||||
originMode: boolean
|
||||
insertMode: boolean
|
||||
wraparoundMode: boolean
|
||||
bracketedPasteMode: boolean
|
||||
}
|
||||
write: (data: string) => void
|
||||
resize: (cols: number, rows: number) => void
|
||||
clear: () => void
|
||||
} | null,
|
||||
connectionInfo: {
|
||||
connectionId: string
|
||||
shellId: string | null
|
||||
}
|
||||
) {
|
||||
const snapshotStore = useTerminalSnapshotStore()
|
||||
|
||||
return {
|
||||
// 创建快照
|
||||
snapshot: () => {
|
||||
if (terminal) {
|
||||
snapshotStore.createSnapshot(tabId, terminal, connectionInfo)
|
||||
}
|
||||
},
|
||||
|
||||
// 恢复快照
|
||||
restore: () => {
|
||||
if (terminal) {
|
||||
return snapshotStore.restoreSnapshot(tabId, terminal)
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
// 检查是否有快照
|
||||
hasSnapshot: () => snapshotStore.hasSnapshot(tabId),
|
||||
|
||||
// 删除快照
|
||||
delete: () => snapshotStore.deleteSnapshot(tabId),
|
||||
|
||||
// 获取快照
|
||||
get: () => snapshotStore.getSnapshot(tabId),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
export interface TerminalTheme {
|
||||
name: string
|
||||
background: string
|
||||
foreground: string
|
||||
cursor: string
|
||||
black: string
|
||||
red: string
|
||||
green: string
|
||||
yellow: string
|
||||
blue: string
|
||||
magenta: string
|
||||
cyan: string
|
||||
white: string
|
||||
brightBlack: string
|
||||
brightRed: string
|
||||
brightGreen: string
|
||||
brightYellow: string
|
||||
brightBlue: string
|
||||
brightMagenta: string
|
||||
brightCyan: string
|
||||
brightWhite: string
|
||||
}
|
||||
|
||||
export const themes: Record<string, TerminalTheme> = {
|
||||
'dracula': {
|
||||
name: 'Dracula',
|
||||
background: '#282a36',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f2',
|
||||
black: '#21222c',
|
||||
red: '#ff5555',
|
||||
green: '#50fa7b',
|
||||
yellow: '#f1fa8c',
|
||||
blue: '#bd93f9',
|
||||
magenta: '#ff79c6',
|
||||
cyan: '#8be9fd',
|
||||
white: '#f8f8f2',
|
||||
brightBlack: '#6272a4',
|
||||
brightRed: '#ff6e6e',
|
||||
brightGreen: '#69ff94',
|
||||
brightYellow: '#ffffa5',
|
||||
brightBlue: '#d6acff',
|
||||
brightMagenta: '#ff92df',
|
||||
brightCyan: '#a4ffff',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
'monokai': {
|
||||
name: 'Monokai',
|
||||
background: '#272822',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f2',
|
||||
black: '#272822',
|
||||
red: '#f92672',
|
||||
green: '#a6e22e',
|
||||
yellow: '#f4bf75',
|
||||
blue: '#66d9ef',
|
||||
magenta: '#ae81ff',
|
||||
cyan: '#a1efe4',
|
||||
white: '#f8f8f2',
|
||||
brightBlack: '#75715e',
|
||||
brightRed: '#f92672',
|
||||
brightGreen: '#a6e22e',
|
||||
brightYellow: '#f4bf75',
|
||||
brightBlue: '#66d9ef',
|
||||
brightMagenta: '#ae81ff',
|
||||
brightCyan: '#a1efe4',
|
||||
brightWhite: '#f9f8f5',
|
||||
},
|
||||
'solarized': {
|
||||
name: 'Solarized',
|
||||
background: '#002b36',
|
||||
foreground: '#839496',
|
||||
cursor: '#839496',
|
||||
black: '#073642',
|
||||
red: '#dc322f',
|
||||
green: '#859900',
|
||||
yellow: '#b58900',
|
||||
blue: '#268bd2',
|
||||
magenta: '#d33682',
|
||||
cyan: '#2aa198',
|
||||
white: '#eee8d5',
|
||||
brightBlack: '#586e75',
|
||||
brightRed: '#cb4b16',
|
||||
brightGreen: '#586e75',
|
||||
brightYellow: '#657b83',
|
||||
brightBlue: '#839496',
|
||||
brightMagenta: '#6c71c4',
|
||||
brightCyan: '#93a1a1',
|
||||
brightWhite: '#fdf6e3',
|
||||
},
|
||||
'one-dark': {
|
||||
name: 'One Dark',
|
||||
background: '#282c34',
|
||||
foreground: '#abb2bf',
|
||||
cursor: '#528bff',
|
||||
black: '#282c34',
|
||||
red: '#e06c75',
|
||||
green: '#98c379',
|
||||
yellow: '#e5c07b',
|
||||
blue: '#61afef',
|
||||
magenta: '#c678dd',
|
||||
cyan: '#56b6c2',
|
||||
white: '#abb2bf',
|
||||
brightBlack: '#5c6370',
|
||||
brightRed: '#e06c75',
|
||||
brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b',
|
||||
brightBlue: '#61afef',
|
||||
brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 统一错误处理系统
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 分层处理: 底层捕获 -> 中层转换 -> 上层展示
|
||||
* 2. 用户友好: 技术错误转业务语言
|
||||
* 3. 可恢复性: 明确错误是否可恢复
|
||||
* 4. 自动上报: 关键错误记录日志
|
||||
*/
|
||||
|
||||
import { logger } from './logger'
|
||||
|
||||
export enum ErrorCode {
|
||||
// 连接错误 (1000-1099)
|
||||
CONNECTION_FAILED = 1000,
|
||||
CONNECTION_TIMEOUT = 1001,
|
||||
AUTH_FAILED = 1002,
|
||||
HOST_UNREACHABLE = 1003,
|
||||
CONNECTION_CLOSED = 1004,
|
||||
|
||||
// SSH 错误 (1100-1199)
|
||||
SHELL_CREATE_FAILED = 1100,
|
||||
EXEC_FAILED = 1101,
|
||||
PORT_FORWARD_FAILED = 1102,
|
||||
|
||||
// SFTP 错误 (1200-1299)
|
||||
SFTP_CONNECT_FAILED = 1200,
|
||||
FILE_NOT_FOUND = 1201,
|
||||
PERMISSION_DENIED = 1202,
|
||||
TRANSFER_FAILED = 1203,
|
||||
DIRECTORY_LIST_FAILED = 1204,
|
||||
|
||||
// 会话错误 (1300-1399)
|
||||
SESSION_NOT_FOUND = 1300,
|
||||
SESSION_SAVE_FAILED = 1301,
|
||||
SESSION_LOAD_FAILED = 1302,
|
||||
|
||||
// 验证错误 (1400-1499)
|
||||
INVALID_HOST = 1400,
|
||||
INVALID_PORT = 1401,
|
||||
INVALID_CREDENTIALS = 1402,
|
||||
|
||||
// 系统错误 (9000-9099)
|
||||
UNKNOWN_ERROR = 9000,
|
||||
IPC_ERROR = 9001,
|
||||
FILE_SYSTEM_ERROR = 9002,
|
||||
NETWORK_ERROR = 9003,
|
||||
}
|
||||
|
||||
export interface AppError {
|
||||
code: ErrorCode
|
||||
message: string
|
||||
detail?: string
|
||||
recoverable: boolean
|
||||
action: ErrorAction
|
||||
originalError?: unknown
|
||||
}
|
||||
|
||||
export type ErrorAction = 'retry' | 'reconnect' | 'ignore' | 'exit' | 'none'
|
||||
|
||||
interface ErrorMapping {
|
||||
message: string
|
||||
recoverable: boolean
|
||||
action: ErrorAction
|
||||
}
|
||||
|
||||
// 错误码到用户友好消息的映射
|
||||
const ERROR_MAP: Record<ErrorCode, ErrorMapping> = {
|
||||
[ErrorCode.CONNECTION_FAILED]: {
|
||||
message: '连接失败,请检查网络或主机地址',
|
||||
recoverable: true,
|
||||
action: 'reconnect',
|
||||
},
|
||||
[ErrorCode.CONNECTION_TIMEOUT]: {
|
||||
message: '连接超时,请检查网络或稍后重试',
|
||||
recoverable: true,
|
||||
action: 'retry',
|
||||
},
|
||||
[ErrorCode.AUTH_FAILED]: {
|
||||
message: '认证失败,请检查用户名和密码',
|
||||
recoverable: true,
|
||||
action: 'reconnect',
|
||||
},
|
||||
[ErrorCode.HOST_UNREACHABLE]: {
|
||||
message: '主机不可达,请检查网络配置',
|
||||
recoverable: true,
|
||||
action: 'reconnect',
|
||||
},
|
||||
[ErrorCode.CONNECTION_CLOSED]: {
|
||||
message: '连接已关闭',
|
||||
recoverable: true,
|
||||
action: 'reconnect',
|
||||
},
|
||||
[ErrorCode.SHELL_CREATE_FAILED]: {
|
||||
message: '终端创建失败',
|
||||
recoverable: true,
|
||||
action: 'retry',
|
||||
},
|
||||
[ErrorCode.EXEC_FAILED]: {
|
||||
message: '命令执行失败',
|
||||
recoverable: true,
|
||||
action: 'ignore',
|
||||
},
|
||||
[ErrorCode.PORT_FORWARD_FAILED]: {
|
||||
message: '端口转发设置失败',
|
||||
recoverable: true,
|
||||
action: 'ignore',
|
||||
},
|
||||
[ErrorCode.SFTP_CONNECT_FAILED]: {
|
||||
message: 'SFTP 连接失败',
|
||||
recoverable: true,
|
||||
action: 'retry',
|
||||
},
|
||||
[ErrorCode.FILE_NOT_FOUND]: {
|
||||
message: '文件不存在',
|
||||
recoverable: false,
|
||||
action: 'none',
|
||||
},
|
||||
[ErrorCode.PERMISSION_DENIED]: {
|
||||
message: '权限不足,无法访问该文件或目录',
|
||||
recoverable: false,
|
||||
action: 'none',
|
||||
},
|
||||
[ErrorCode.TRANSFER_FAILED]: {
|
||||
message: '文件传输失败',
|
||||
recoverable: true,
|
||||
action: 'retry',
|
||||
},
|
||||
[ErrorCode.DIRECTORY_LIST_FAILED]: {
|
||||
message: '无法读取目录内容',
|
||||
recoverable: true,
|
||||
action: 'retry',
|
||||
},
|
||||
[ErrorCode.SESSION_NOT_FOUND]: {
|
||||
message: '会话不存在',
|
||||
recoverable: false,
|
||||
action: 'none',
|
||||
},
|
||||
[ErrorCode.SESSION_SAVE_FAILED]: {
|
||||
message: '保存会话失败',
|
||||
recoverable: true,
|
||||
action: 'retry',
|
||||
},
|
||||
[ErrorCode.SESSION_LOAD_FAILED]: {
|
||||
message: '加载会话失败',
|
||||
recoverable: true,
|
||||
action: 'retry',
|
||||
},
|
||||
[ErrorCode.INVALID_HOST]: {
|
||||
message: '主机地址格式不正确',
|
||||
recoverable: false,
|
||||
action: 'none',
|
||||
},
|
||||
[ErrorCode.INVALID_PORT]: {
|
||||
message: '端口号必须在 1-65535 之间',
|
||||
recoverable: false,
|
||||
action: 'none',
|
||||
},
|
||||
[ErrorCode.INVALID_CREDENTIALS]: {
|
||||
message: '凭据格式不正确',
|
||||
recoverable: false,
|
||||
action: 'none',
|
||||
},
|
||||
[ErrorCode.UNKNOWN_ERROR]: {
|
||||
message: '发生未知错误',
|
||||
recoverable: false,
|
||||
action: 'none',
|
||||
},
|
||||
[ErrorCode.IPC_ERROR]: {
|
||||
message: '内部通信错误',
|
||||
recoverable: true,
|
||||
action: 'retry',
|
||||
},
|
||||
[ErrorCode.FILE_SYSTEM_ERROR]: {
|
||||
message: '文件系统错误',
|
||||
recoverable: true,
|
||||
action: 'retry',
|
||||
},
|
||||
[ErrorCode.NETWORK_ERROR]: {
|
||||
message: '网络错误',
|
||||
recoverable: true,
|
||||
action: 'retry',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 将未知错误转换为 AppError
|
||||
*/
|
||||
export function handleError(error: unknown, context?: string): AppError {
|
||||
// 已经是 AppError,直接返回
|
||||
if (isAppError(error)) {
|
||||
return error
|
||||
}
|
||||
|
||||
// 从 Error 对象提取信息
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const contextStr = context ? `[${context}] ` : ''
|
||||
|
||||
// 根据错误消息推断错误码
|
||||
const code = inferErrorCode(errorMessage)
|
||||
const mapping = ERROR_MAP[code]
|
||||
|
||||
// 记录错误日志
|
||||
logger.error(`${contextStr}${mapping.message}`, error)
|
||||
|
||||
return {
|
||||
code,
|
||||
message: mapping.message,
|
||||
detail: errorMessage !== mapping.message ? errorMessage : undefined,
|
||||
recoverable: mapping.recoverable,
|
||||
action: mapping.action,
|
||||
originalError: error,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从错误消息推断错误码
|
||||
*/
|
||||
function inferErrorCode(message: string): ErrorCode {
|
||||
const lowerMsg = message.toLowerCase()
|
||||
|
||||
// 认证相关
|
||||
if (lowerMsg.includes('authentication') || lowerMsg.includes('auth fail') || lowerMsg.includes('password')) {
|
||||
return ErrorCode.AUTH_FAILED
|
||||
}
|
||||
|
||||
// 连接相关
|
||||
if (lowerMsg.includes('connect timeout') || lowerMsg.includes('etimedout')) {
|
||||
return ErrorCode.CONNECTION_TIMEOUT
|
||||
}
|
||||
if (lowerMsg.includes('econnrefused') || lowerMsg.includes('refused')) {
|
||||
return ErrorCode.CONNECTION_FAILED
|
||||
}
|
||||
if (lowerMsg.includes('enotfound') || lowerMsg.includes('dns') || lowerMsg.includes('getaddrinfo')) {
|
||||
return ErrorCode.HOST_UNREACHABLE
|
||||
}
|
||||
if (lowerMsg.includes('connection closed') || lowerMsg.includes('disconnected')) {
|
||||
return ErrorCode.CONNECTION_CLOSED
|
||||
}
|
||||
|
||||
// SFTP 相关
|
||||
if (lowerMsg.includes('sftp')) {
|
||||
if (lowerMsg.includes('no such file')) return ErrorCode.FILE_NOT_FOUND
|
||||
if (lowerMsg.includes('permission')) return ErrorCode.PERMISSION_DENIED
|
||||
return ErrorCode.SFTP_CONNECT_FAILED
|
||||
}
|
||||
|
||||
// 会话相关
|
||||
if (lowerMsg.includes('session')) {
|
||||
return ErrorCode.SESSION_LOAD_FAILED
|
||||
}
|
||||
|
||||
// 终端相关
|
||||
if (lowerMsg.includes('shell')) {
|
||||
return ErrorCode.SHELL_CREATE_FAILED
|
||||
}
|
||||
|
||||
// 网络相关
|
||||
if (lowerMsg.includes('network') || lowerMsg.includes('enetdown') || lowerMsg.includes('enetunreach')) {
|
||||
return ErrorCode.NETWORK_ERROR
|
||||
}
|
||||
|
||||
return ErrorCode.UNKNOWN_ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 AppError
|
||||
*/
|
||||
function isAppError(error: unknown): error is AppError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
'message' in error &&
|
||||
'recoverable' in error &&
|
||||
'action' in error
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建特定错误码的 AppError
|
||||
*/
|
||||
export function createError(
|
||||
code: ErrorCode,
|
||||
detail?: string,
|
||||
originalError?: unknown
|
||||
): AppError {
|
||||
const mapping = ERROR_MAP[code]
|
||||
return {
|
||||
code,
|
||||
message: mapping.message,
|
||||
detail,
|
||||
recoverable: mapping.recoverable,
|
||||
action: mapping.action,
|
||||
originalError,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误通知 (可扩展为 UI 通知)
|
||||
*/
|
||||
export function notifyError(error: AppError): void {
|
||||
// 这里可以集成 UI 通知系统
|
||||
// 例如: toast, modal, status bar 等
|
||||
logger.warn(`[Error Notification] ${error.message}`, {
|
||||
code: error.code,
|
||||
recoverable: error.recoverable,
|
||||
action: error.action,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装异步函数,自动错误处理
|
||||
*
|
||||
* @example
|
||||
* const safeConnect = withErrorHandling(connect, ErrorCode.CONNECTION_FAILED)
|
||||
*/
|
||||
export function withErrorHandling<T extends (...args: unknown[]) => Promise<unknown>>(
|
||||
fn: T,
|
||||
_defaultCode: ErrorCode = ErrorCode.UNKNOWN_ERROR,
|
||||
context?: string
|
||||
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
|
||||
return async (...args: Parameters<T>): Promise<ReturnType<T>> => {
|
||||
try {
|
||||
return await fn(...args) as ReturnType<T>
|
||||
} catch (error) {
|
||||
throw handleError(error, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 工具函数统一导出
|
||||
*/
|
||||
|
||||
export { createLogger, logger, LogLevel } from './logger'
|
||||
export { handleError, createError, ErrorCode, withErrorHandling } from './error-handler'
|
||||
// export { validators } from './validators'
|
||||
// export { formatters } from './formatters'
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 统一日志系统
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 分级: DEBUG < INFO < WARN < ERROR
|
||||
* 2. 模块: 自动添加模块前缀
|
||||
* 3. 生产: ERROR 写入文件, DEBUG 控制台
|
||||
* 4. 性能: 异步写入, 不影响主流程
|
||||
*/
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3,
|
||||
}
|
||||
|
||||
interface LogConfig {
|
||||
level: LogLevel
|
||||
enableConsole: boolean
|
||||
enableFile: boolean
|
||||
prefix: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: LogConfig = {
|
||||
level: process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.DEBUG,
|
||||
enableConsole: true,
|
||||
enableFile: false, // 可在生产环境启用
|
||||
prefix: '',
|
||||
}
|
||||
|
||||
class LoggerImpl {
|
||||
private config: LogConfig
|
||||
|
||||
constructor(config: Partial<LogConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config }
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return level >= this.config.level
|
||||
}
|
||||
|
||||
private formatMessage(level: string, message: string, args: unknown[]): string {
|
||||
const timestamp = new Date().toISOString()
|
||||
const prefix = this.config.prefix ? `[${this.config.prefix}]` : ''
|
||||
const argsStr = args.length > 0 ? ' ' + args.map(a =>
|
||||
typeof a === 'object' ? JSON.stringify(a) : String(a)
|
||||
).join(' ') : ''
|
||||
return `${timestamp} ${level}${prefix} ${message}${argsStr}`
|
||||
}
|
||||
|
||||
private log(level: LogLevel, levelStr: string, message: string, args: unknown[]): void {
|
||||
if (!this.shouldLog(level)) return
|
||||
|
||||
const formatted = this.formatMessage(levelStr, message, args)
|
||||
|
||||
if (this.config.enableConsole) {
|
||||
switch (level) {
|
||||
case LogLevel.DEBUG:
|
||||
console.debug(formatted)
|
||||
break
|
||||
case LogLevel.INFO:
|
||||
console.info(formatted)
|
||||
break
|
||||
case LogLevel.WARN:
|
||||
console.warn(formatted)
|
||||
break
|
||||
case LogLevel.ERROR:
|
||||
console.error(formatted)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 生产环境可写入文件
|
||||
if (this.config.enableFile && level >= LogLevel.ERROR) {
|
||||
// 可通过 IPC 发送到主进程写入日志文件
|
||||
// window.electronAPI?.logError?.(formatted).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, ...args: unknown[]): void {
|
||||
this.log(LogLevel.DEBUG, '[DEBUG]', message, args)
|
||||
}
|
||||
|
||||
info(message: string, ...args: unknown[]): void {
|
||||
this.log(LogLevel.INFO, '[INFO]', message, args)
|
||||
}
|
||||
|
||||
warn(message: string, ...args: unknown[]): void {
|
||||
this.log(LogLevel.WARN, '[WARN]', message, args)
|
||||
}
|
||||
|
||||
error(message: string, error?: unknown): void {
|
||||
const errorDetail = error instanceof Error
|
||||
? { message: error.message, stack: error.stack }
|
||||
: error
|
||||
this.log(LogLevel.ERROR, '[ERROR]', message, errorDetail ? [errorDetail] : [])
|
||||
}
|
||||
}
|
||||
|
||||
export type Logger = LoggerImpl
|
||||
|
||||
/**
|
||||
* 创建模块级 Logger
|
||||
*
|
||||
* @example
|
||||
* const logger = createLogger('TerminalView')
|
||||
* logger.info('连接成功', { host: '192.168.1.1' })
|
||||
*/
|
||||
export function createLogger(module: string): Logger {
|
||||
return new LoggerImpl({ prefix: module })
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局 Logger (无前缀)
|
||||
*/
|
||||
export const globalLogger = new LoggerImpl()
|
||||
|
||||
/**
|
||||
* 便捷导出
|
||||
*/
|
||||
export const logger = {
|
||||
debug: globalLogger.debug.bind(globalLogger),
|
||||
info: globalLogger.info.bind(globalLogger),
|
||||
warn: globalLogger.warn.bind(globalLogger),
|
||||
error: globalLogger.error.bind(globalLogger),
|
||||
}
|
||||
Reference in new issue
Block a user