feat: release OpsXShell 2.1.0 native Tauri edition
This commit is contained in:
1 parent
e9a0d21758
commit
a3dba0c6ed
311 files changed
+31748
-14897
No files matched your search
@@ -1,424 +0,0 @@
|
||||
/**
|
||||
* 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'
|
||||
import { useConfirmDialog } from './common/ConfirmDialog'
|
||||
|
||||
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, updateTab } = useTabStore()
|
||||
const pool = useConnectionPool()
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||
|
||||
const currentTab = tabs.find(t => t.id === tabId)
|
||||
const needsReconnect = currentTab?.needsReconnect
|
||||
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: unknown) {
|
||||
setError((err instanceof Error ? err.message : String(err)) || '启动失败')
|
||||
} 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 = async (ruleId: string) => {
|
||||
const confirmed = await confirm({
|
||||
title: '确认删除',
|
||||
message: '确定删除此转发规则?',
|
||||
danger: true,
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (confirmed) {
|
||||
saveRules(rules.filter(r => r.id !== ruleId))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col bg-dark-900">
|
||||
{/* 会话恢复 - 需要重新连接覆盖层 */}
|
||||
{needsReconnect && currentTab?.sessionId && (
|
||||
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-50">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-surface-baseccent-blue/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<ArrowRightLeft className="w-8 h-8 text-accent-blue" />
|
||||
</div>
|
||||
<div className="text-content-primary mb-2 text-lg">会话已恢复</div>
|
||||
<div className="text-txt-muted text-sm mb-6">
|
||||
上次关闭时的端口转发标签已恢复<br />
|
||||
需要重新建立 SSH 连接才能使用
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateTab(tabId, { needsReconnect: false })
|
||||
// 刷新页面或触发连接逻辑
|
||||
}}
|
||||
className="px-6 py-2.5 bg-surface-baseccent-blue hover:bg-surface-baselue-500 text-content-primary rounded-lg transition-colors"
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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-content-primary">端口转发</h2>
|
||||
<p className="text-sm text-txt-muted">
|
||||
{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-surface-baseccent-blue hover:bg-surface-baselue-600 disabled:bg-dark-700 disabled:text-txt-muted 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-txt-muted">
|
||||
<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-surface-baselue-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-content-primary">{rule.name}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
rule.type === 'local' ? 'bg-surface-baselue-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-txt-muted 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-content-primary 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 'local' | 'remote' | 'dynamic' })}
|
||||
className={`p-3 rounded-lg border text-left transition-colors ${
|
||||
newRule.type === t.type
|
||||
? 'bg-surface-baseccent-blue/20 border-accent-blue text-content-primary'
|
||||
: 'bg-dark-700 border-dark-600 text-txt-muted 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-txt-muted 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-content-primary placeholder-ph focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-txt-muted 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-content-primary placeholder-ph focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newRule.type !== 'dynamic' && (
|
||||
<div>
|
||||
<label className="block text-sm text-txt-muted 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-content-primary placeholder-ph focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{newRule.type === 'local' && (
|
||||
<div>
|
||||
<label className="block text-sm text-txt-muted 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-content-primary placeholder-ph 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-surface-baseccent-blue hover:bg-surface-baselue-600 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
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: unknown) {
|
||||
setError((err instanceof Error ? err.message : String(err)) || '连接失败')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-surface-baselack/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-surface-baseccent-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-content-primary">快速连接</h2>
|
||||
<p className="text-sm text-txt-muted">临时连接到服务器</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-dark-700 rounded-lg text-txt-muted hover:text-content-primary 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-txt-muted 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-content-primary placeholder-ph focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-txt-muted 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-content-primary focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-txt-muted 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-content-primary placeholder-ph focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-txt-muted 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-content-primary placeholder-ph 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-txt-muted">
|
||||
快速连接不会保存会话信息。如需保存,请使用"新建会话"。
|
||||
</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-txt-muted hover:text-content-primary transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isConnecting}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-surface-baseccent-blue hover:bg-surface-baselue-600 disabled:opacity-50 rounded-lg text-content-primary font-medium transition-colors"
|
||||
>
|
||||
{isConnecting ? (
|
||||
'连接中...'
|
||||
) : (
|
||||
<>
|
||||
连接
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,502 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Server, Key, Lock, User, Globe, Tag, Folder, Shield } from 'lucide-react'
|
||||
import { useSessionStore, Session } from '../stores'
|
||||
import type { AuthType, ProxyType } 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: 'root',
|
||||
authType: 'password' as AuthType,
|
||||
password: '',
|
||||
privateKey: '~/.ssh/id_rsa',
|
||||
passphrase: '',
|
||||
proxy: {
|
||||
type: 'none' as ProxyType,
|
||||
host: '',
|
||||
port: 0,
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
group: 'default',
|
||||
tags: '',
|
||||
})
|
||||
const [showProxySettings, setShowProxySettings] = useState(false)
|
||||
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 || '',
|
||||
proxy: editSession.proxy
|
||||
? { type: editSession.proxy.type as ProxyType, host: editSession.proxy.host, port: editSession.proxy.port, username: editSession.proxy.username || '', password: editSession.proxy.password || '' }
|
||||
: { type: 'none' as ProxyType, host: '', port: 0, username: '', password: '' },
|
||||
group: editSession.group || 'default',
|
||||
tags: editSession.tags?.join(', ') || '',
|
||||
})
|
||||
if (editSession.proxy && editSession.proxy.type !== 'none') {
|
||||
setShowProxySettings(true)
|
||||
}
|
||||
} else {
|
||||
// 重置表单
|
||||
setFormData({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
password: '',
|
||||
privateKey: '~/.ssh/id_rsa',
|
||||
passphrase: '',
|
||||
proxy: { type: 'none', host: '', port: 0, username: '', password: '' },
|
||||
group: 'default',
|
||||
tags: '',
|
||||
})
|
||||
setShowProxySettings(false)
|
||||
}
|
||||
}, [editSession])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const isEditing = !!editSession
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const proxyConfig = formData.proxy.type !== 'none' && formData.proxy.host
|
||||
? {
|
||||
type: formData.proxy.type,
|
||||
host: formData.proxy.host,
|
||||
port: formData.proxy.port || (formData.proxy.type === 'http' ? 8080 : 1080),
|
||||
username: formData.proxy.username || undefined,
|
||||
password: formData.proxy.password || undefined,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const sessionData = {
|
||||
name: formData.name,
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
username: formData.username.trim() || 'root',
|
||||
authType: formData.authType,
|
||||
password: formData.password,
|
||||
privateKey: formData.authType === 'key' ? (formData.privateKey.trim() || '~/.ssh/id_rsa') : formData.privateKey,
|
||||
passphrase: formData.passphrase,
|
||||
proxy: proxyConfig,
|
||||
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: '',
|
||||
proxy: { type: 'none', host: '', port: 0, username: '', password: '' },
|
||||
group: 'default',
|
||||
tags: '',
|
||||
})
|
||||
setShowProxySettings(false)
|
||||
} 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-surface-baselack/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-surface-baseccent-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-content-primary">{isEditing ? '编辑会话' : '新建会话'}</h2>
|
||||
<p className="text-sm text-txt-muted">{isEditing ? '修改 SSH 连接配置' : '配置 SSH 连接信息'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-dark-700 rounded-lg text-txt-muted hover:text-content-primary 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-txt-muted 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-content-primary placeholder-ph 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-txt-muted mb-1">
|
||||
主机地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-txt-muted" />
|
||||
<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-content-primary placeholder-ph focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-txt-muted 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-content-primary focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-txt-muted mb-1">
|
||||
用户名
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-txt-muted" />
|
||||
<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-content-primary placeholder-ph 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-txt-muted 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-surface-baseccent-blue/20 text-accent-blue'
|
||||
: 'border-dark-700 hover:border-dark-600 text-txt-muted'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{type.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{formData.authType === 'password' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-txt-muted mb-1">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-txt-muted" />
|
||||
<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-content-primary placeholder-ph 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-txt-muted 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-content-primary placeholder-ph 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-content-secondary transition-colors"
|
||||
onClick={async () => {
|
||||
const result = await window.electronAPI?.showOpenDialog({
|
||||
title: '选择私钥文件',
|
||||
defaultPath: '~/.ssh',
|
||||
filters: [
|
||||
{ name: '所有文件', extensions: ['*'] },
|
||||
{ name: 'PEM 密钥', extensions: ['pem'] },
|
||||
{ name: 'SSH 密钥', extensions: ['rsa', 'ed25519', 'pem'] },
|
||||
],
|
||||
})
|
||||
if (result?.canceled) return
|
||||
if (result?.filePaths?.[0]) {
|
||||
setFormData({ ...formData, privateKey: result.filePaths[0] })
|
||||
}
|
||||
}}
|
||||
>
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-txt-muted 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-content-primary placeholder-ph focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Proxy Settings */}
|
||||
<div className="border-t border-dark-700 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowProxySettings(!showProxySettings)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-txt-muted hover:text-content-primary transition-colors"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
代理设置
|
||||
<span className="text-xs text-txt-faint">
|
||||
{formData.proxy.type !== 'none' && formData.proxy.host ? `(已配置 ${formData.proxy.type.toUpperCase()})` : '(未配置)'}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${showProxySettings ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showProxySettings && (
|
||||
<div className="mt-3 space-y-3 animate-fade-in">
|
||||
{/* Proxy Type */}
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ value: 'none', label: '不使用' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
].map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
proxy: {
|
||||
...formData.proxy,
|
||||
type: type.value as ProxyType,
|
||||
port: formData.proxy.port || (type.value === 'http' ? 8080 : 1080),
|
||||
}
|
||||
})}
|
||||
className={`flex-1 px-3 py-1.5 rounded-lg border text-xs transition-colors ${
|
||||
formData.proxy.type === type.value
|
||||
? 'border-accent-blue bg-surface-baseccent-blue/20 text-accent-blue'
|
||||
: 'border-dark-700 hover:border-dark-600 text-txt-muted'
|
||||
}`}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{formData.proxy.type !== 'none' && (
|
||||
<>
|
||||
{/* Proxy Host & Port */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-txt-muted mb-1">代理地址</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.proxy.host}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, host: e.target.value }
|
||||
})}
|
||||
placeholder="proxy.example.com"
|
||||
className="w-full px-3 py-1.5 bg-dark-900 border border-dark-700 rounded-lg text-content-primary text-sm placeholder-ph focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-txt-muted mb-1">端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.proxy.port || ''}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, port: parseInt(e.target.value) || 0 }
|
||||
})}
|
||||
placeholder={formData.proxy.type === 'http' ? '8080' : '1080'}
|
||||
className="w-full px-3 py-1.5 bg-dark-900 border border-dark-700 rounded-lg text-content-primary text-sm placeholder-ph focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proxy Auth */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-txt-muted mb-1">代理用户名 (可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.proxy.username}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, username: e.target.value }
|
||||
})}
|
||||
placeholder="username"
|
||||
className="w-full px-3 py-1.5 bg-dark-900 border border-dark-700 rounded-lg text-content-primary text-sm placeholder-ph focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-txt-muted mb-1">代理密码 (可选)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.proxy.password}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, password: e.target.value }
|
||||
})}
|
||||
placeholder="password"
|
||||
className="w-full px-3 py-1.5 bg-dark-900 border border-dark-700 rounded-lg text-content-primary text-sm placeholder-ph focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
</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-txt-muted 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-content-primary placeholder-ph focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-txt-muted 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-content-primary placeholder-ph 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-txt-muted hover:text-content-primary transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-2 bg-surface-baseccent-blue hover:bg-surface-baselue-600 disabled:opacity-50 rounded-lg text-content-primary font-medium transition-colors"
|
||||
>
|
||||
{isSubmitting ? (isEditing ? '保存中...' : '创建中...') : (isEditing ? '保存' : '创建会话')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,768 +0,0 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useKeybindingStore } from '../stores/keybindingStore'
|
||||
import {
|
||||
Monitor, Palette, Terminal, Keyboard, Shield, Database, Info, Search, X,
|
||||
RotateCcw, AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { themes } from '../themes/terminal'
|
||||
import {
|
||||
getKeybindingCategories,
|
||||
getKeybindingsByCategory,
|
||||
type KeybindingActionId,
|
||||
} from '../constants/keybindings'
|
||||
|
||||
// ─── Tab 类型 ───────────────────────────────────────────────
|
||||
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'shortcuts' | 'ssh' | 'security' | 'about'
|
||||
|
||||
interface TabMeta {
|
||||
id: SettingsTab
|
||||
label: string
|
||||
description: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
const TAB_LIST: TabMeta[] = [
|
||||
{ id: 'general', label: '常规', description: '应用启动与通知', icon: Monitor },
|
||||
{ id: 'appearance', label: '外观', description: '主题与语言偏好', icon: Palette },
|
||||
{ id: 'terminal', label: '终端', description: '字体与配色方案', icon: Terminal },
|
||||
{ id: 'shortcuts', label: '快捷键', description: '键盘快捷键管理', icon: Keyboard },
|
||||
{ id: 'ssh', label: 'SSH', description: '连接与认证设置', icon: Shield },
|
||||
{ id: 'security', label: '安全', description: '加密与隐私设置', icon: Database },
|
||||
{ id: 'about', label: '关于', description: '版本与许可信息', icon: Info },
|
||||
]
|
||||
|
||||
// ─── 快捷键录入 Hook ─────────────────────────────────────────
|
||||
// 监听键盘事件并格式化为键序列字符串 (如 "Ctrl+Shift+P")
|
||||
function useKeyRecording() {
|
||||
const [recordingId, setRecordingId] = useState<KeybindingActionId | null>(null)
|
||||
const [recordedKeys, setRecordedKeys] = useState<string>('')
|
||||
|
||||
const startRecording = useCallback((actionId: KeybindingActionId) => {
|
||||
setRecordingId(actionId)
|
||||
setRecordedKeys('')
|
||||
}, [])
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
setRecordingId(null)
|
||||
setRecordedKeys('')
|
||||
}, [])
|
||||
|
||||
// 全局键盘监听
|
||||
useEffect(() => {
|
||||
if (!recordingId) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Escape 取消录入
|
||||
if (e.key === 'Escape') {
|
||||
stopRecording()
|
||||
return
|
||||
}
|
||||
|
||||
// 忽略单独的修饰键
|
||||
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return
|
||||
|
||||
const parts: string[] = []
|
||||
if (e.ctrlKey || e.metaKey) parts.push('Ctrl')
|
||||
if (e.shiftKey) parts.push('Shift')
|
||||
if (e.altKey) parts.push('Alt')
|
||||
|
||||
// 主键
|
||||
let mainKey = e.key
|
||||
if (mainKey === ' ') mainKey = 'Space'
|
||||
else if (mainKey === 'Tab') mainKey = 'Tab'
|
||||
else if (mainKey.length === 1) mainKey = mainKey.toUpperCase()
|
||||
parts.push(mainKey)
|
||||
|
||||
const keySequence = parts.join('+')
|
||||
setRecordedKeys(keySequence)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [recordingId, stopRecording])
|
||||
|
||||
return { recordingId, recordedKeys, startRecording, stopRecording }
|
||||
}
|
||||
|
||||
// ─── 主组件 ─────────────────────────────────────────────────
|
||||
export default function SettingsView() {
|
||||
const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore()
|
||||
const keybindingState = useKeybindingStore()
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('general')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [appVersion, setAppVersion] = useState<string>('—')
|
||||
const { recordingId, recordedKeys, startRecording, stopRecording } = useKeyRecording()
|
||||
|
||||
useEffect(() => { loadSettings() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 获取版本号(参考 StatusBar 实现)
|
||||
useEffect(() => {
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.getVersion().then((v: string) => setAppVersion(v))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 当前 tab 元信息
|
||||
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
|
||||
|
||||
// ── 搜索过滤 ──────────────────────────────────────────────
|
||||
const filtered = useMemo((): SettingsTab[] | null => {
|
||||
if (!searchQuery.trim()) return null // null 表示不过滤,正常显示
|
||||
const q = searchQuery.toLowerCase()
|
||||
const keywords: Record<SettingsTab, string[]> = {
|
||||
general: ['启动', '恢复', '会话', '自动保存', '通知', 'restore', 'notification'],
|
||||
appearance: ['主题', '深色', '浅色', '语言', '中文', 'english', 'theme', 'language'],
|
||||
terminal: ['字体', 'font', '配色', 'terminal', 'monokai', 'dracula'],
|
||||
shortcuts: ['快捷键', '键盘', 'keyboard', 'shortcut', 'hotkey'],
|
||||
ssh: ['ssh', '连接', '端口', '超时', '心跳', '连接池', '重连', '快照'],
|
||||
security: ['加密', '安全', '密码', '锁屏', 'encrypt', 'lock'],
|
||||
about: ['版本', '关于', '许可', 'version', 'about', 'license'],
|
||||
}
|
||||
const matchIds = (Object.entries(keywords) as [SettingsTab, string[]][])
|
||||
.filter(([, kws]) => kws.some((k: string) => k.includes(q) || q.includes(k)))
|
||||
.map(([id]) => id)
|
||||
return matchIds.length > 0 ? matchIds : []
|
||||
}, [searchQuery])
|
||||
|
||||
// 如果搜索过滤后有结果,自动跳转到第一个匹配 tab
|
||||
useEffect(() => {
|
||||
if (filtered && filtered.length > 0 && !filtered.includes(activeTab)) {
|
||||
setActiveTab(filtered[0])
|
||||
}
|
||||
}, [filtered]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const visibleTabs = filtered ? TAB_LIST.filter((t) => filtered.includes(t.id)) : TAB_LIST
|
||||
|
||||
// ── 加载态 ────────────────────────────────────────────────
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-dark-900">
|
||||
<div className="text-txt-muted">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 渲染内容区 ────────────────────────────────────────────
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
// ──────── 常规 ─────────
|
||||
case 'general':
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SettingCard>
|
||||
<SettingToggle
|
||||
label="启动时恢复会话"
|
||||
description="应用启动时自动恢复上次的会话"
|
||||
checked={settings.restoreOnStart}
|
||||
onChange={(v) => setSetting('restoreOnStart', v)}
|
||||
/>
|
||||
</SettingCard>
|
||||
<SettingCard>
|
||||
<SettingToggle
|
||||
label="自动保存会话"
|
||||
description="自动保存会话配置更改"
|
||||
checked={settings.autoSave}
|
||||
onChange={(v) => setSetting('autoSave', v)}
|
||||
/>
|
||||
</SettingCard>
|
||||
<SettingCard>
|
||||
<SettingToggle
|
||||
label="显示通知"
|
||||
description="在桌面显示连接状态通知"
|
||||
checked={settings.showNotifications}
|
||||
onChange={(v) => setSetting('showNotifications', v)}
|
||||
/>
|
||||
</SettingCard>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ──────── 外观 ─────────
|
||||
case 'appearance':
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>主题</SettingSectionTitle>
|
||||
<div className="grid grid-cols-3 gap-3 mt-3">
|
||||
{[
|
||||
{ value: 'dark', label: '深色' },
|
||||
{ value: 'light', label: '浅色' },
|
||||
{ value: 'system', label: '跟随系统' },
|
||||
].map((theme) => (
|
||||
<button
|
||||
key={theme.value}
|
||||
onClick={() => setSetting('theme', theme.value as 'dark' | 'light' | 'system')}
|
||||
className={`p-3 rounded-lg border text-sm transition-colors ${
|
||||
settings.theme === theme.value
|
||||
? 'border-accent-blue bg-surface-baseccent-blue/20 text-accent-blue'
|
||||
: 'border-dark-700/30 hover:border-dark-600 text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{theme.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>语言</SettingSectionTitle>
|
||||
<select
|
||||
value={settings.language}
|
||||
onChange={(e) => setSetting('language', e.target.value as 'zh-CN' | 'en-US')}
|
||||
className="w-full mt-3 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en-US">English</option>
|
||||
</select>
|
||||
</SettingCard>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ──────── 终端 ─────────
|
||||
case 'terminal':
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>字体</SettingSectionTitle>
|
||||
<div className="grid grid-cols-2 gap-4 mt-3">
|
||||
<div>
|
||||
<label className="text-sm text-txt-muted">字体族</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/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option>JetBrains Mono</option>
|
||||
<option>Fira Code</option>
|
||||
<option>Consolas</option>
|
||||
<option>Monaco</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-txt-muted">字体大小</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/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
{[12, 13, 14, 15, 16, 18, 20].map((size) => (
|
||||
<option key={size} value={size}>{size}px</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>终端配色</SettingSectionTitle>
|
||||
<div className="grid grid-cols-4 gap-2 mt-3">
|
||||
{Object.entries(themes).map(([key, theme]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSetting('terminalTheme', key as 'dracula' | 'monokai' | 'solarized' | 'one-dark')}
|
||||
className={`p-3 rounded-lg border text-sm transition-colors ${
|
||||
settings.terminalTheme === key
|
||||
? 'border-accent-blue bg-surface-baseccent-blue/20 text-accent-blue'
|
||||
: 'border-dark-700/30 hover:border-dark-600 text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-full h-6 rounded mb-1"
|
||||
style={{ backgroundColor: theme.background }}
|
||||
/>
|
||||
{theme.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingCard>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ──────── 快捷键 ───────
|
||||
case 'shortcuts': {
|
||||
const allKeys = keybindingState.getAllEffective()
|
||||
const categories = getKeybindingCategories()
|
||||
const hasCustom = Object.keys(keybindingState.customKeybindings).length > 0
|
||||
|
||||
// 检查录入的键是否与当前录制匹配
|
||||
const recordedConflict = recordedKeys && recordingId
|
||||
? keybindingState.getConflicts(recordingId, recordedKeys)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 重置全部按钮 */}
|
||||
{hasCustom && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => keybindingState.resetAll()}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-txt-muted hover:text-accent-blue rounded-lg hover:bg-dark-700/50 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
重置全部为默认
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.map((category) => {
|
||||
const defs = getKeybindingsByCategory(category)
|
||||
return (
|
||||
<SettingCard key={category}>
|
||||
<h3 className="text-sm font-semibold text-content-secondary mb-3">{category}</h3>
|
||||
<div className="space-y-1">
|
||||
{defs.map((def) => {
|
||||
const currentKeys = allKeys[def.id]
|
||||
const isCustom = keybindingState.customKeybindings[def.id] !== undefined
|
||||
const isRecordingThis = recordingId === def.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={def.id}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${
|
||||
isRecordingThis
|
||||
? 'bg-surface-baseccent-blue/10 border border-accent-blue/30'
|
||||
: 'hover:bg-dark-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-content-primary">{def.label}</span>
|
||||
{isCustom && (
|
||||
<span className="ml-2 text-[10px] text-accent-blue bg-surface-baseccent-blue/10 px-1.5 py-0.5 rounded">
|
||||
自定义
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isRecordingThis ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<kbd className="inline-flex items-center px-3 py-1 rounded bg-surface-baseccent-blue/20 border border-accent-blue/40 text-xs text-accent-blue font-mono animate-pulse">
|
||||
{recordedKeys || '按下快捷键...'}
|
||||
</kbd>
|
||||
<button
|
||||
onClick={() => stopRecording()}
|
||||
className="text-xs text-txt-muted hover:text-content-secondary"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{recordedKeys && recordedConflict.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-yellow-500">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>
|
||||
与「{recordedConflict[0].label}」冲突
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
startRecording(def.id)
|
||||
}}
|
||||
className="group relative"
|
||||
>
|
||||
<kbd
|
||||
className={`inline-flex items-center px-2.5 py-1 rounded text-xs font-mono transition-colors ${
|
||||
isCustom
|
||||
? 'bg-surface-baseccent-blue/10 border border-accent-blue/30 text-accent-blue'
|
||||
: 'bg-dark-700 border border-dark-600 text-content-secondary'
|
||||
} group-hover:border-accent-blue/50`}
|
||||
>
|
||||
{currentKeys}
|
||||
</kbd>
|
||||
</button>
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={() => keybindingState.resetKeybinding(def.id)}
|
||||
className="text-txt-faint hover:text-txt-muted transition-colors"
|
||||
title="重置为默认"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SettingCard>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 录入确认弹层 */}
|
||||
{recordingId && recordedKeys && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 px-5 py-3 bg-dark-800 border border-dark-600 rounded-xl shadow-2xl">
|
||||
<span className="text-sm text-content-secondary">
|
||||
设置为 <kbd className="px-1.5 py-0.5 rounded bg-dark-700 text-accent-blue font-mono text-xs">{recordedKeys}</kbd>
|
||||
</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await keybindingState.setCustomKeybinding(recordingId, recordedKeys)
|
||||
stopRecording()
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-surface-baseccent-blue text-content-primary rounded-lg hover:bg-surface-baseccent-blue/80 transition-colors"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="px-3 py-1 text-xs text-txt-muted hover:text-content-primary rounded-lg hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-txt-faint">
|
||||
点击快捷键可重新绑定 · 按 Esc 取消录入
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ──────── SSH ──────────
|
||||
case 'ssh':
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SettingCard>
|
||||
<SettingToggle
|
||||
label="保持连接活跃"
|
||||
description="定期发送心跳包保持连接"
|
||||
checked={settings.keepAlive}
|
||||
onChange={(v) => setSetting('keepAlive', v)}
|
||||
/>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>默认端口</SettingSectionTitle>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.defaultPort}
|
||||
onChange={(e) => setSetting('defaultPort', parseInt(e.target.value) || 22)}
|
||||
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>超时设置 (秒)</SettingSectionTitle>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.timeout}
|
||||
onChange={(e) => setSetting('timeout', parseInt(e.target.value) || 30)}
|
||||
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</SettingCard>
|
||||
|
||||
{/* 专业连接管理 */}
|
||||
<SettingCard highlight>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-accent-blue text-xs font-semibold uppercase tracking-wider">Pro</span>
|
||||
<SettingSectionTitle className="mb-0">连接管理</SettingSectionTitle>
|
||||
</div>
|
||||
<p className="text-sm text-txt-muted mb-4">配置连接池行为,优化资源使用和性能</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-txt-muted">最大连接数</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/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
<div className="text-xs text-txt-faint mt-1">最大并发 SSH 连接数</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-txt-muted">最大后台连接</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/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
<div className="text-xs text-txt-faint mt-1">非活跃标签保持连接数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-txt-muted">空闲超时 (分钟)</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/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
<div className="text-xs text-txt-faint mt-1">无操作后进入挂起</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-txt-muted">挂起超时 (分钟)</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/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
<div className="text-xs text-txt-faint mt-1">挂起后自动断开</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 space-y-4">
|
||||
<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>
|
||||
</SettingCard>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ──────── 安全 ─────────
|
||||
case 'security':
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SettingCard>
|
||||
<SettingToggle
|
||||
label="加密存储密码"
|
||||
description="使用 AES-256-GCM 加密存储密码"
|
||||
checked={settings.encryptPasswords}
|
||||
onChange={(v) => setSetting('encryptPasswords', v)}
|
||||
/>
|
||||
</SettingCard>
|
||||
<SettingCard>
|
||||
<SettingToggle
|
||||
label="锁定屏幕时断开连接"
|
||||
description="系统锁屏时自动断开 SSH 连接"
|
||||
checked={settings.lockOnSleep}
|
||||
onChange={(v) => setSetting('lockOnSleep', v)}
|
||||
/>
|
||||
</SettingCard>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ──────── 关于 ─────────
|
||||
case 'about':
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SettingCard>
|
||||
<div className="flex flex-col items-center py-6">
|
||||
<div className="w-20 h-20 bg-dark-700 rounded-2xl flex items-center justify-center mb-4">
|
||||
<Terminal className="w-10 h-10 text-content-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-content-primary mb-1">OpsXShell</h3>
|
||||
<p className="text-sm text-txt-muted mb-4">版本 {appVersion}</p>
|
||||
<p className="text-sm text-txt-muted max-w-md text-center">
|
||||
跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · 操作便捷人性化
|
||||
</p>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>技术栈</SettingSectionTitle>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 mt-3 text-sm">
|
||||
{[
|
||||
['Electron', '28+'],
|
||||
['React', '18'],
|
||||
['TypeScript', '5'],
|
||||
['Vite', '5'],
|
||||
['Zustand', '4.4+'],
|
||||
['XTerm.js', '5.5+'],
|
||||
['Tailwind CSS', '3.3+'],
|
||||
['Lucide React', '0.294+'],
|
||||
].map(([name, ver]) => (
|
||||
<div key={name} className="flex justify-between py-1 border-b border-dark-700/20">
|
||||
<span className="text-txt-muted">{name}</span>
|
||||
<span className="text-txt-muted font-mono">{ver}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>开源许可</SettingSectionTitle>
|
||||
<p className="text-sm text-txt-muted mt-2">
|
||||
OpsXShell 基于 MIT 许可证开源。本项目使用了以下开源组件:
|
||||
</p>
|
||||
<ul className="mt-2 text-sm text-txt-muted list-disc list-inside space-y-1">
|
||||
<li>ssh2 — MIT License</li>
|
||||
<li>xterm.js — MIT License</li>
|
||||
<li>zmodem-ts — MIT License</li>
|
||||
<li>better-sqlite3 — MIT License</li>
|
||||
<li>lucide-react — ISC License</li>
|
||||
</ul>
|
||||
</SettingCard>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── 主布局 ────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="h-full flex bg-dark-900">
|
||||
{/* ── 左侧导航 ── */}
|
||||
<div className="w-56 flex-shrink-0 bg-dark-800 flex flex-col">
|
||||
{/* 搜索框 */}
|
||||
<div className="p-3 border-b border-dark-700/20">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-txt-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索设置..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-8 py-1.5 text-sm bg-dark-900 border border-dark-700/30 rounded-lg text-content-secondary placeholder-ph focus:outline-none focus:border-accent-blue/60"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-txt-muted hover:text-content-secondary"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab 列表 */}
|
||||
<nav className="flex-1 p-2 space-y-0.5 overflow-y-auto">
|
||||
{visibleTabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const active = activeTab === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all ${
|
||||
active
|
||||
? 'bg-surface-baseccent-blue text-content-primary shadow-sm'
|
||||
: 'text-txt-muted hover:bg-dark-700/50 hover:text-content-primary'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-content-primary' : ''}`} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{tab.label}</div>
|
||||
<div className={`text-xs truncate ${active ? 'text-content-primary/70' : 'text-txt-faint'}`}>
|
||||
{tab.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* ── 右侧内容区 ── */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header bar */}
|
||||
<div className="flex-shrink-0 px-8 pt-6 pb-4 border-b border-dark-700/20">
|
||||
<h1 className="text-xl font-semibold text-content-primary">{currentMeta.label}</h1>
|
||||
<p className="text-sm text-txt-muted mt-0.5">{currentMeta.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 px-8 py-6 overflow-y-auto">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 子组件 ─────────────────────────────────────────────────
|
||||
|
||||
/** 卡片容器 */
|
||||
function SettingCard({
|
||||
children,
|
||||
highlight = false,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
highlight?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl p-5 ${
|
||||
highlight
|
||||
? 'bg-dark-800/80 border border-accent-blue/20'
|
||||
: 'bg-dark-800/50 border border-dark-700/30'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 区域小标题 */
|
||||
function SettingSectionTitle({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`text-sm font-semibold text-content-secondary mb-1 ${className}`}>
|
||||
{children}
|
||||
</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">
|
||||
<div className="mr-4">
|
||||
<div className="font-medium text-content-primary text-sm">{label}</div>
|
||||
<div className="text-xs text-txt-muted mt-0.5">{description}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative flex-shrink-0 w-11 h-6 rounded-full transition-colors ${
|
||||
checked ? 'bg-surface-baseccent-blue' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
checked ? 'left-6' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* Sidebar - 侧边栏组件
|
||||
* 支持宽度拖拽调整
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Plus, Zap, Server, Settings } from 'lucide-react'
|
||||
import { useSessionStore, Session } from '../stores'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { SessionTree } from './sidebar/SessionTree'
|
||||
import { SearchBox } from './sidebar/SearchBox'
|
||||
import { useConfirmDialog } from './common/ConfirmDialog'
|
||||
|
||||
const MIN_SIDEBAR_WIDTH = 180
|
||||
const MAX_SIDEBAR_WIDTH = 400
|
||||
|
||||
interface SidebarProps {
|
||||
onNewSession: () => void
|
||||
onQuickConnect: () => void
|
||||
onEditSession: (session: Session) => void
|
||||
}
|
||||
|
||||
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) {
|
||||
const { sessions, setSessions, addSession, removeSession } = useSessionStore()
|
||||
const { settings, setSetting } = useSettingsStore()
|
||||
const { addTab } = useTabStore()
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||
|
||||
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 [isResizing, setIsResizing] = useState(false)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(settings.sidebarWidth || 256)
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Sync width from settings on load
|
||||
useEffect(() => {
|
||||
if (settings.sidebarWidth) {
|
||||
setSidebarWidth(settings.sidebarWidth)
|
||||
}
|
||||
}, [settings.sidebarWidth])
|
||||
|
||||
// Load sessions
|
||||
useEffect(() => {
|
||||
window.electronAPI?.sessionGetAll().then((result) => {
|
||||
if (result.success && result.sessions) {
|
||||
setSessions(result.sessions)
|
||||
}
|
||||
setSessionsLoading(false)
|
||||
})
|
||||
}, [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)
|
||||
}, [])
|
||||
|
||||
// Resize handler
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsResizing(true)
|
||||
|
||||
const startX = e.clientX
|
||||
const startWidth = sidebarWidth
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientX - startX
|
||||
const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta))
|
||||
setSidebarWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
// Save to settings on drag end
|
||||
setSetting('sidebarWidth', sidebarWidth)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
// Prevent text selection during drag
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [sidebarWidth, setSetting])
|
||||
|
||||
// Save final width on unmount or when resizing ends (we save in handleMouseUp)
|
||||
useEffect(() => {
|
||||
if (!isResizing && sidebarWidth !== settings.sidebarWidth) {
|
||||
setSetting('sidebarWidth', sidebarWidth)
|
||||
}
|
||||
}, [isResizing, sidebarWidth, settings.sidebarWidth, setSetting])
|
||||
|
||||
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 = async (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
|
||||
const { id: _id, ...sessionWithoutId } = session
|
||||
window.electronAPI?.sessionCreate({
|
||||
...sessionWithoutId,
|
||||
name: `${session.name} (副本)`,
|
||||
}).then((result) => {
|
||||
if (result?.success && result.session) {
|
||||
addSession(result.session as Session)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'delete': {
|
||||
const confirmed = await confirm({
|
||||
title: '确认删除',
|
||||
message: `确定删除会话 "${session.name}"?`,
|
||||
danger: true,
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (confirmed) {
|
||||
removeSession(session.id)
|
||||
window.electronAPI?.sessionDelete(session.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
setContextMenu(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className="relative flex flex-col h-full bg-dark-800"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-dark-700/20">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-8 h-8 bg-surface-baseccent-blue rounded-lg flex items-center justify-center">
|
||||
<Server className="w-5 h-5 text-content-primary" />
|
||||
</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-surface-baseccent-blue hover:bg-surface-baselue-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/20">
|
||||
<SearchBox
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="搜索会话..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Session Tree */}
|
||||
<SessionTree
|
||||
sessions={sessions}
|
||||
expandedGroups={expandedGroups}
|
||||
selectedSessionId={selectedSessionId}
|
||||
onToggleGroup={toggleGroup}
|
||||
onSelectSession={handleConnect}
|
||||
onContextMenu={handleContextMenu}
|
||||
searchTerm={searchTerm}
|
||||
loading={sessionsLoading}
|
||||
onNewSession={onNewSession}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-dark-700/20">
|
||||
<button
|
||||
onClick={() => addTab({ title: '设置', type: 'settings' })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-txt-muted 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-content-secondary hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
onMouseDown={handleResizeStart}
|
||||
className={`
|
||||
absolute top-0 right-0 bottom-0 w-1 cursor-col-resize
|
||||
hover:bg-surface-baseccent-blue/50 active:bg-surface-baseccent-blue/70
|
||||
hover:w-1.5 hover:shadow-[0_0_6px_rgba(59,130,246,0.4)]
|
||||
transition-all duration-150
|
||||
${isResizing ? 'bg-surface-baseccent-blue/70 w-1.5 shadow-[0_0_6px_rgba(59,130,246,0.5)]' : ''}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* TabBar - 标签栏组件(支持拖拽排序)
|
||||
*/
|
||||
|
||||
import { Plus } from 'lucide-react'
|
||||
import React, { useState, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useConnectionPool } from '../stores/connection'
|
||||
import { TabItem } from './tabbar/TabItem'
|
||||
import { TabContextMenu } from './tabbar/TabContextMenu'
|
||||
|
||||
function TabBar() {
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activateTab,
|
||||
closeTab,
|
||||
addTab,
|
||||
closeOtherTabs,
|
||||
closeRightTabs,
|
||||
closeLeftTabs,
|
||||
closeAllTabs,
|
||||
moveTab,
|
||||
} = useTabStore()
|
||||
|
||||
const { disconnectConnection } = useConnectionPool()
|
||||
|
||||
// Drag state
|
||||
const [dragFromIndex, setDragFromIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const [menuState, setMenuState] = useState({
|
||||
isOpen: false,
|
||||
tabId: null as string | null,
|
||||
position: { x: 0, y: 0 }
|
||||
})
|
||||
|
||||
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 }
|
||||
})
|
||||
}
|
||||
|
||||
// --- Drag handlers ---
|
||||
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
|
||||
setDragFromIndex(index)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
// Set a transparent drag image so we rely on the ghost
|
||||
if (e.currentTarget instanceof HTMLDivElement) {
|
||||
dragNodeRef.current = e.currentTarget
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragFromIndex === null || dragFromIndex === index) {
|
||||
setDragOverIndex(null)
|
||||
return
|
||||
}
|
||||
// Determine insert position: if dragging from lower index, place after; otherwise before
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const midX = rect.left + rect.width / 2
|
||||
const insertAt = e.clientX < midX ? index : index + 1
|
||||
// Don't show indicator if it would result in no-op
|
||||
if (
|
||||
insertAt === dragFromIndex ||
|
||||
insertAt === dragFromIndex + 1
|
||||
) {
|
||||
setDragOverIndex(null)
|
||||
} else {
|
||||
setDragOverIndex(insertAt)
|
||||
}
|
||||
}, [dragFromIndex])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, index: number) => {
|
||||
e.preventDefault()
|
||||
if (dragFromIndex === null) return
|
||||
// Calculate actual toIndex based on mouse position
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const midX = rect.left + rect.width / 2
|
||||
let toIndex = e.clientX < midX ? index : index + 1
|
||||
// Adjust for the removal of the dragged item shifting indices
|
||||
if (toIndex > dragFromIndex) {
|
||||
toIndex -= 1
|
||||
}
|
||||
if (dragFromIndex !== toIndex) {
|
||||
moveTab(dragFromIndex, toIndex)
|
||||
}
|
||||
setDragFromIndex(null)
|
||||
setDragOverIndex(null)
|
||||
}, [dragFromIndex, moveTab])
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragFromIndex(null)
|
||||
setDragOverIndex(null)
|
||||
}, [])
|
||||
|
||||
const currentTab = menuState.tabId ? tabs.find(t => t.id === menuState.tabId) : null
|
||||
|
||||
return (
|
||||
<div className="flex items-center bg-dark-800 overflow-x-auto">
|
||||
{/* Tab List */}
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
index={index}
|
||||
isActive={tab.id === activeTabId}
|
||||
isDragging={dragFromIndex === index}
|
||||
dragOverIndex={dragOverIndex}
|
||||
onActivate={() => activateTab(tab.id)}
|
||||
onClose={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCloseTab(tab.id)
|
||||
}}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add Tab Button */}
|
||||
<button
|
||||
onClick={() => addTab({ title: '新标签', type: 'welcome' })}
|
||||
className="flex items-center justify-center w-10 h-10 text-txt-muted hover:text-content-secondary 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>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TabBar)
|
||||
@@ -1,130 +0,0 @@
|
||||
import { Terminal, FolderSync, ArrowRightLeft, Globe, Plus, Zap, Clock } from 'lucide-react'
|
||||
import { useSessionStore } from '../stores'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import EmptyState from './common/EmptyState'
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onNewSession?: () => void
|
||||
onQuickConnect?: () => void
|
||||
}
|
||||
|
||||
export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeViewProps) {
|
||||
const { addTab } = useTabStore()
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
|
||||
// 取最近 5 个会话作为最近连接
|
||||
const recentSessions = sessions.slice(-5).reverse()
|
||||
|
||||
const handleSessionClick = (session: typeof sessions[number]) => {
|
||||
addTab({
|
||||
title: session.name || session.host,
|
||||
type: 'terminal',
|
||||
sessionId: session.id,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-start bg-dark-900 p-8 overflow-y-auto">
|
||||
<div className="text-center mb-12 pt-8">
|
||||
<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-content-primary" />
|
||||
</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/30 hover:border-accent-blue transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-surface-baseccent-blue/20 rounded-lg flex items-center justify-center group-hover:bg-surface-baseccent-blue/30 transition-colors">
|
||||
<Zap className="w-6 h-6 text-accent-blue" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold text-content-primary">快速连接</div>
|
||||
<div className="text-sm text-txt-muted">输入地址快速建立连接</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/30 hover:border-accent-green transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-surface-baseccent-green/20 rounded-lg flex items-center justify-center group-hover:bg-surface-baseccent-green/30 transition-colors">
|
||||
<Plus className="w-6 h-6 text-content-primary" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold text-content-primary">新建会话</div>
|
||||
<div className="text-sm text-txt-muted">创建并保存新的会话</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/30 hover:border-accent-purple transition-all group"
|
||||
>
|
||||
<div className="w-12 h-12 bg-surface-baseccent-purple/20 rounded-lg flex items-center justify-center group-hover:bg-surface-baseccent-purple/30 transition-colors">
|
||||
<FolderSync className="w-6 h-6 text-accent-purple" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold text-content-primary">SFTP 传输</div>
|
||||
<div className="text-sm text-txt-muted">可视化文件管理</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/30 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-content-primary">端口转发</div>
|
||||
<div className="text-sm text-txt-muted">本地/远程/SOCKS5 代理</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recent connections */}
|
||||
<div className="w-full max-w-2xl">
|
||||
<h3 className="text-sm font-medium text-txt-muted mb-4">最近连接</h3>
|
||||
{recentSessions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Clock}
|
||||
title="暂无最近连接"
|
||||
description="开始你的第一次连接吧"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSessionClick(session)}
|
||||
className="w-full flex items-center justify-between p-4 bg-dark-800 hover:bg-dark-700 rounded-lg border border-dark-700/30 hover:border-accent-blue/50 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-content-primary">{session.name}</div>
|
||||
<div className="text-sm text-txt-muted">
|
||||
{session.username}@{session.host}{session.port !== 22 ? `:${session.port}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-txt-muted">--</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shortcuts hint */}
|
||||
<div className="mt-12 flex items-center gap-6 text-sm text-txt-muted">
|
||||
<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,313 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, Clipboard, CornerDownLeft, Loader2, Play, Server, ShieldAlert, Sparkles, X } from 'lucide-react'
|
||||
import type { AiAssistantAction } from '../../../shared/types/ai.types'
|
||||
import { useAiProviderStore } from '../../stores/aiProviderStore'
|
||||
import { extractBashBlocks, singleLineTerminalInsert, terminalContextLineCount } from '../../utils/aiResponse'
|
||||
import { writeClipboardText } from '../../platform/clipboard'
|
||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||
|
||||
export interface TerminalAiContext {
|
||||
selection: string
|
||||
viewport: string
|
||||
}
|
||||
|
||||
interface AiTerminalPanelProps {
|
||||
capture: () => TerminalAiContext
|
||||
insert: (command: string) => void
|
||||
onClose: () => void
|
||||
connectionId?: string | null
|
||||
targetLabel?: string
|
||||
isLocal?: boolean
|
||||
}
|
||||
|
||||
const ACTIONS: Array<{ id: AiAssistantAction; label: string }> = [
|
||||
{ id: 'explain', label: '解释' },
|
||||
{ id: 'command', label: '生成命令' },
|
||||
{ id: 'diagnose', label: '排障' },
|
||||
{ id: 'execute', label: '执行任务' },
|
||||
]
|
||||
|
||||
function commandMayChangeSystem(command: string): boolean {
|
||||
return /(^|[;&|]\s*)(rm|mv|cp|chmod|chown|kill|pkill|reboot|shutdown|mkfs|dd|systemctl|service|apt|yum|dnf|apk|brew)\b|\bsed\s+-i\b|(^|[^<])>{1,2}[^=]|\bkubectl\s+(delete|apply|patch|replace|scale|rollout)\b/i.test(command)
|
||||
}
|
||||
|
||||
export default function AiTerminalPanel({
|
||||
capture,
|
||||
insert,
|
||||
onClose,
|
||||
connectionId = null,
|
||||
targetLabel = '当前 SSH 会话',
|
||||
isLocal = false,
|
||||
}: AiTerminalPanelProps) {
|
||||
const store = useAiProviderStore()
|
||||
const [action, setAction] = useState<AiAssistantAction>('diagnose')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [includeViewport, setIncludeViewport] = useState(false)
|
||||
const [result, setResult] = useState('')
|
||||
const [contextNote, setContextNote] = useState('不会自动发送终端内容')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [toolOutput, setToolOutput] = useState('')
|
||||
const [pendingCommand, setPendingCommand] = useState('')
|
||||
const [executing, setExecuting] = useState(false)
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||
|
||||
useEffect(() => {
|
||||
void store.load()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const provider = store.providers.find((item) => item.id === store.activeProviderId && item.enabled)
|
||||
const model = provider?.models.find((item) => item.id === provider.defaultModelId)
|
||||
const blocks = useMemo(() => extractBashBlocks(result), [result])
|
||||
const insertable = blocks.length === 1 ? singleLineTerminalInsert(blocks[0]) : null
|
||||
|
||||
const submit = async () => {
|
||||
setError('')
|
||||
setResult('')
|
||||
if (!provider || !model) {
|
||||
setError('请先在“设置 → 大模型”添加 Provider 并选择默认模型')
|
||||
return
|
||||
}
|
||||
if (!provider.hasApiKey && provider.authStyle !== 'none') {
|
||||
setError(`Provider「${provider.name}」尚未保存 API Key`)
|
||||
return
|
||||
}
|
||||
if (!prompt.trim()) {
|
||||
setError('请输入问题或任务')
|
||||
return
|
||||
}
|
||||
if (action === 'execute' && (!connectionId || isLocal)) {
|
||||
setError('ssh-exec 只作用于当前已连接的远程 SSH 会话')
|
||||
return
|
||||
}
|
||||
const captured = capture()
|
||||
const selection = captured.selection.trim()
|
||||
const context = selection || (includeViewport ? captured.viewport.trim() : '')
|
||||
setContextNote(
|
||||
selection
|
||||
? `已发送选中内容 · ${terminalContextLineCount(context)} 行`
|
||||
: context
|
||||
? `已发送当前屏幕 · ${terminalContextLineCount(context)} 行`
|
||||
: '本次未发送终端内容'
|
||||
)
|
||||
setLoading(true)
|
||||
setToolOutput('')
|
||||
setPendingCommand('')
|
||||
try {
|
||||
const executionScope = action === 'execute'
|
||||
? `\n\n执行目标:当前 SSH 会话「${targetLabel}」。只生成远程 shell 内要执行的命令。远程已有的 kubectl、helm、docker 等工具可像普通命令一样使用。`
|
||||
: ''
|
||||
const response = await window.opsxApi.aiComplete({
|
||||
provider,
|
||||
model,
|
||||
action,
|
||||
prompt: `${prompt.trim()}${executionScope}`,
|
||||
context,
|
||||
})
|
||||
if (!response.success || !response.text) throw new Error(response.error || '模型未返回内容')
|
||||
setResult(response.text)
|
||||
if (action === 'execute') {
|
||||
const commands = extractBashBlocks(response.text)
|
||||
const command = commands.length === 1 ? singleLineTerminalInsert(commands[0]) : null
|
||||
if (!command) {
|
||||
setError('模型没有返回唯一的单行命令,已拒绝进入执行流程')
|
||||
} else {
|
||||
setPendingCommand(command)
|
||||
}
|
||||
}
|
||||
if (response.redactedCount) setContextNote((current) => `${current} · 本地脱敏 ${response.redactedCount} 处`)
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const executeOnce = async () => {
|
||||
if (!provider || !model || !pendingCommand || action !== 'execute') return
|
||||
const target = `SSH · ${targetLabel}`
|
||||
const highRisk = commandMayChangeSystem(pendingCommand)
|
||||
const approved = await confirm({
|
||||
title: highRisk ? '确认执行可能修改系统的命令' : '确认执行一次',
|
||||
message: `目标:${target}\n\n命令:${pendingCommand}\n\n该操作只执行一次,不会保存到命令历史。`,
|
||||
danger: highRisk,
|
||||
confirmText: highRisk ? '确认风险并执行' : '执行一次',
|
||||
})
|
||||
if (!approved) return
|
||||
|
||||
setExecuting(true)
|
||||
setError('')
|
||||
setToolOutput('')
|
||||
try {
|
||||
const response = await window.opsxApi.sshExec(connectionId || '', pendingCommand)
|
||||
if (!response.success) throw new Error(response.error || '工具执行失败')
|
||||
const output = response.result?.trim() || '命令执行成功,无输出'
|
||||
setToolOutput(output)
|
||||
const executedCommand = pendingCommand
|
||||
setPendingCommand('')
|
||||
|
||||
const analysis = await window.opsxApi.aiComplete({
|
||||
provider,
|
||||
model,
|
||||
action: 'diagnose',
|
||||
prompt: `用户原任务:${prompt.trim()}\n已通过 ssh-exec 执行一次命令。根据真实结果给出简短结论;不要声称执行了其他操作。`,
|
||||
context: `$ ${executedCommand}\n${output}`,
|
||||
})
|
||||
if (analysis.success && analysis.text) setResult(analysis.text)
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught))
|
||||
} finally {
|
||||
setExecuting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyResult = async () => {
|
||||
await writeClipboardText(result)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1200)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="border-dark-700/50 bg-dark-850 flex w-[370px] flex-shrink-0 flex-col border-l">
|
||||
<header className="border-dark-700/50 flex items-center gap-2 border-b px-3 py-2.5">
|
||||
<Sparkles className="h-4 w-4 text-accent-blue" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-content-primary">终端助手</div>
|
||||
<div className="truncate text-[10px] text-txt-faint">
|
||||
{provider && model ? `${provider.name} · ${model.label}` : '尚未配置模型'}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" title="关闭" onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto p-3">
|
||||
<div className="bg-dark-900/60 flex gap-1 rounded-lg p-1">
|
||||
{ACTIONS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAction(item.id)
|
||||
setResult('')
|
||||
setToolOutput('')
|
||||
setPendingCommand('')
|
||||
setError('')
|
||||
}}
|
||||
className={`flex-1 rounded-md px-2 py-1.5 text-xs ${action === item.id ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-muted hover:text-content-secondary'}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{action === 'execute' && (
|
||||
<div className="border-dark-700/40 bg-dark-900/30 space-y-2 rounded-lg border p-2.5">
|
||||
<div className="flex items-center gap-1.5 truncate text-[11px] text-content-secondary">
|
||||
<Server className="h-3.5 w-3.5 flex-shrink-0" /> 目标:{targetLabel}
|
||||
</div>
|
||||
<p className="text-[10px] leading-relaxed text-txt-faint">AI 只准备一条远程命令;固定复用当前 SSH 连接,每次真实执行都需要你确认。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
rows={4}
|
||||
placeholder="例如:分析这段报错,并给出最少的只读检查命令"
|
||||
className="ui-input-sm resize-none leading-relaxed"
|
||||
/>
|
||||
|
||||
<label className="border-dark-700/40 bg-dark-900/30 flex items-start gap-2 rounded-lg border p-2.5 text-xs text-txt-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeViewport}
|
||||
onChange={(event) => setIncludeViewport(event.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-content-secondary">无选区时附带当前屏幕</span>
|
||||
<span className="mt-0.5 block text-[10px] text-txt-faint">
|
||||
有选中内容时始终只发送选区;默认不读取屏幕。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={loading}
|
||||
className="ui-btn-primary flex w-full items-center justify-center gap-1.5"
|
||||
>
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}{' '}
|
||||
{loading ? '处理中...' : action === 'execute' ? '准备执行方案' : '生成建议'}
|
||||
</button>
|
||||
<div className="text-[10px] text-txt-faint">{contextNote}</div>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-xs text-red-300"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<div className="border-dark-700/50 bg-dark-900/55 whitespace-pre-wrap break-words rounded-lg border p-3 font-mono text-xs leading-relaxed text-content-secondary">
|
||||
{result}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyResult()}
|
||||
className="ui-btn-secondary flex items-center gap-1.5"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Clipboard className="h-3.5 w-3.5" />}{' '}
|
||||
{copied ? '已复制' : '复制结果'}
|
||||
</button>
|
||||
{action !== 'execute' && insertable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insert(insertable)}
|
||||
className="ui-btn-primary flex items-center gap-1.5"
|
||||
>
|
||||
<CornerDownLeft className="h-3.5 w-3.5" />
|
||||
填入终端
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{action !== 'execute' && blocks.length > 0 && !insertable && (
|
||||
<p className="text-[10px] text-amber-300">多行脚本仅允许复制,不提供填入按钮。</p>
|
||||
)}
|
||||
{action === 'execute' && pendingCommand && (
|
||||
<div className={`rounded-lg border p-2.5 ${commandMayChangeSystem(pendingCommand) ? 'border-amber-500/30 bg-amber-500/10' : 'border-dark-700/50 bg-dark-900/40'}`}>
|
||||
<div className="mb-1 text-[10px] text-txt-faint">待执行 · SSH {targetLabel}</div>
|
||||
<code className="block break-all text-xs text-content-secondary">{pendingCommand}</code>
|
||||
<button type="button" onClick={() => void executeOnce()} disabled={executing} className="ui-btn-primary mt-2 flex items-center gap-1.5">
|
||||
{executing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||
{executing ? '执行并分析中...' : '执行一次'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolOutput && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] text-emerald-400">真实工具输出</div>
|
||||
<pre className="border-dark-700/50 bg-black/25 max-h-52 overflow-auto whitespace-pre-wrap break-words rounded-lg border p-3 text-[11px] text-content-secondary">{toolOutput}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="border-dark-700/40 flex items-start gap-2 border-t px-3 py-2 text-[10px] leading-relaxed text-txt-faint">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
ssh-exec 只复用当前连接;每次执行均需确认,关闭面板即清空内容,不保存命令历史。
|
||||
</footer>
|
||||
{ConfirmDialogComponent}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +1,36 @@
|
||||
/**
|
||||
* 按钮组件
|
||||
*
|
||||
* 统一按钮样式,支持多种变体和尺寸
|
||||
* Unified button — professional remote-tool chrome
|
||||
*/
|
||||
|
||||
import { type ReactNode, type ButtonHTMLAttributes } from 'react'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** 按钮变体 */
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
/** 尺寸 */
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
/** 加载状态 */
|
||||
loading?: boolean
|
||||
/** 图标 */
|
||||
icon?: ReactNode
|
||||
/** 子元素 */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
primary: 'bg-surface-baseccent-blue hover:bg-surface-baselue-600 text-content-primary',
|
||||
secondary: 'bg-dark-700 hover:bg-dark-600 text-content-primary',
|
||||
danger: 'bg-red-600 hover:bg-red-500 text-content-primary',
|
||||
ghost: 'hover:bg-dark-700 text-txt-muted hover:text-content-primary',
|
||||
primary:
|
||||
'bg-accent-blue hover:bg-accent-blue-hover text-white shadow-sm shadow-accent-blue/20 border border-transparent',
|
||||
secondary:
|
||||
'bg-dark-700 hover:bg-dark-600 text-content-primary border border-dark-600/80',
|
||||
danger:
|
||||
'bg-red-600 hover:bg-red-500 text-white border border-transparent shadow-sm shadow-red-900/30',
|
||||
ghost:
|
||||
'bg-transparent hover:bg-dark-700 text-txt-muted hover:text-content-primary border border-transparent',
|
||||
outline:
|
||||
'bg-transparent hover:bg-dark-700/60 text-content-secondary border border-dark-600 hover:border-dark-500',
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-3 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
sm: 'px-3 py-1.5 text-xs rounded-lg gap-1.5',
|
||||
md: 'px-4 py-2 text-sm rounded-lg gap-2',
|
||||
lg: 'px-5 py-2.5 text-base rounded-xl gap-2',
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用按钮组件
|
||||
*
|
||||
* @example
|
||||
* <Button>保存</Button>
|
||||
* <Button variant="danger" onClick={handleDelete}>删除</Button>
|
||||
* <Button loading>保存中...</Button>
|
||||
* <Button icon={<Plus />}>新建</Button>
|
||||
*/
|
||||
export default function Button({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
@@ -54,9 +44,10 @@ export default function Button({
|
||||
return (
|
||||
<button
|
||||
className={`
|
||||
inline-flex items-center justify-center gap-2
|
||||
rounded-lg font-medium transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
inline-flex items-center justify-center font-medium
|
||||
transition-colors duration-150
|
||||
disabled:opacity-45 disabled:cursor-not-allowed disabled:pointer-events-none
|
||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-blue
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
${className}
|
||||
@@ -65,9 +56,13 @@ export default function Button({
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24" aria-hidden>
|
||||
<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" />
|
||||
<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}
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
Zap,
|
||||
Plus,
|
||||
X,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
Settings,
|
||||
Laptop,
|
||||
} from 'lucide-react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSessionStore, useSnippetStore, useCommandPaletteStore, type Session } from '../../stores'
|
||||
@@ -45,9 +45,9 @@ interface CommandPaletteProps {
|
||||
// ─── 类型 badge 颜色映射 ──────────────────────────────────────────────
|
||||
|
||||
const TYPE_BADGE: Record<CommandPaletteItemType, { label: string; className: string }> = {
|
||||
command: { label: '命令', className: 'bg-surface-baseccent-blue/20 text-accent-blue' },
|
||||
session: { label: '会话', className: 'bg-surface-baseccent-green/20 text-accent-green' },
|
||||
snippet: { label: '片段', className: 'bg-surface-baseccent-purple/20 text-accent-purple' },
|
||||
command: { label: '命令', className: 'bg-accent-blue/20 text-accent-blue' },
|
||||
session: { label: '会话', className: 'bg-accent-green/20 text-accent-green' },
|
||||
snippet: { label: '片段', className: 'bg-accent-purple/20 text-accent-purple' },
|
||||
}
|
||||
|
||||
// ─── 组件 ──────────────────────────────────────────────────────────────
|
||||
@@ -91,6 +91,15 @@ export function CommandPalette({
|
||||
icon: <Zap size={16} />,
|
||||
action: () => onQuickConnect(),
|
||||
},
|
||||
{
|
||||
id: 'cmd:local-terminal',
|
||||
type: 'command',
|
||||
label: '本地终端',
|
||||
description: '打开本机 Shell',
|
||||
shortcut: 'Ctrl+Shift+T',
|
||||
icon: <Laptop size={16} />,
|
||||
action: () => addTab({ title: 'Local', type: 'terminal', isLocal: true }),
|
||||
},
|
||||
{
|
||||
id: 'cmd:open-settings',
|
||||
type: 'command',
|
||||
@@ -129,14 +138,6 @@ export function CommandPalette({
|
||||
icon: <X size={16} />,
|
||||
action: () => useTabStore.getState().closeAllTabs(),
|
||||
},
|
||||
{
|
||||
id: 'cmd:new-sftp',
|
||||
type: 'command',
|
||||
label: '新建 SFTP 浏览器',
|
||||
description: '打开 SFTP 文件管理标签',
|
||||
icon: <FolderOpen size={16} />,
|
||||
action: () => addTab({ title: 'SFTP', type: 'sftp' }),
|
||||
},
|
||||
{
|
||||
id: 'cmd:new-portforward',
|
||||
type: 'command',
|
||||
@@ -305,7 +306,7 @@ export function CommandPalette({
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-surface-baselack/60 backdrop-blur-sm animate-fade-in" />
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in" />
|
||||
|
||||
{/* Palette container */}
|
||||
<div
|
||||
@@ -369,7 +370,7 @@ export function CommandPalette({
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-2 cursor-pointer transition-colors duration-75
|
||||
${isSelected
|
||||
? 'bg-surface-baseccent-blue/15 text-content-primary'
|
||||
? 'bg-accent-blue/15 text-content-primary'
|
||||
: 'text-content-secondary hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -89,13 +89,14 @@ export function useConfirmDialog() {
|
||||
confirmText={state.confirmText}
|
||||
cancelText={state.cancelText}
|
||||
isDanger={state.danger}
|
||||
size="sm"
|
||||
icon={
|
||||
state.danger ? (
|
||||
<AlertTriangle className="w-5 h-5 text-red-400" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{state.danger && (
|
||||
<AlertTriangle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<p className="text-content-secondary">{state.message}</p>
|
||||
</div>
|
||||
<p className="text-sm text-content-secondary leading-relaxed">{state.message}</p>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function DefaultFallback({ error, onReset }: DefaultFallbackProps
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex-1 px-4 py-2 bg-surface-baseccent-blue hover:bg-surface-baselue-600 text-content-primary rounded-lg transition-colors text-sm font-medium"
|
||||
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 text-content-primary rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
|
||||
+102
-65
@@ -1,116 +1,153 @@
|
||||
/**
|
||||
* 模态框组件
|
||||
*
|
||||
* 统一的对话框 UI
|
||||
* Professional modal shell — shared by dialogs across OpsXShell
|
||||
*/
|
||||
|
||||
import { type ReactNode, useEffect } from 'react'
|
||||
import { type ReactNode, useEffect, useId } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import Button from './Button'
|
||||
|
||||
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
|
||||
interface ModalProps {
|
||||
/** 是否显示 */
|
||||
isOpen: boolean
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 内容 */
|
||||
description?: string
|
||||
children: ReactNode
|
||||
/** 确认按钮文本 */
|
||||
/** Optional icon in header */
|
||||
icon?: ReactNode
|
||||
confirmText?: string
|
||||
/** 取消按钮文本 */
|
||||
cancelText?: string
|
||||
/** 确认回调 */
|
||||
onConfirm?: () => void
|
||||
/** 关闭回调 */
|
||||
onClose: () => void
|
||||
/** 是否显示确认按钮 */
|
||||
showConfirm?: boolean
|
||||
/** 是否危险操作 */
|
||||
showFooter?: boolean
|
||||
isDanger?: boolean
|
||||
/** 加载状态 */
|
||||
loading?: boolean
|
||||
size?: ModalSize
|
||||
/** Prevent closing by clicking backdrop */
|
||||
disableBackdropClose?: boolean
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizeClass: Record<ModalSize, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-2xl',
|
||||
full: 'max-w-4xl',
|
||||
}
|
||||
|
||||
/**
|
||||
* 模态框组件
|
||||
*
|
||||
* @example
|
||||
* <Modal
|
||||
* isOpen={isOpen}
|
||||
* title="确认删除"
|
||||
* onConfirm={handleDelete}
|
||||
* onClose={() => setIsOpen(false)}
|
||||
* isDanger
|
||||
* >
|
||||
* 确定要删除此会话吗?
|
||||
* </Modal>
|
||||
*/
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
icon,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
onConfirm,
|
||||
onClose,
|
||||
showConfirm = true,
|
||||
showFooter = true,
|
||||
isDanger = false,
|
||||
loading = false,
|
||||
size = 'md',
|
||||
disableBackdropClose = false,
|
||||
footer,
|
||||
className = '',
|
||||
}: ModalProps) {
|
||||
// ESC 键关闭
|
||||
const titleId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
// Lock body scroll while open
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleEsc)
|
||||
return () => window.removeEventListener('keydown', handleEsc)
|
||||
return () => {
|
||||
document.body.style.overflow = prev
|
||||
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-surface-baselack/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/55 backdrop-blur-[2px] animate-fade-in"
|
||||
onClick={disableBackdropClose ? undefined : 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-content-primary">{title}</h3>
|
||||
|
||||
<div
|
||||
className={`
|
||||
relative w-full ${sizeClass[size]} max-h-[min(90vh,880px)]
|
||||
flex flex-col
|
||||
bg-dark-800 border border-dark-700/80 rounded-2xl
|
||||
shadow-[0_25px_50px_-12px_rgba(0,0,0,0.55)]
|
||||
animate-scale-in
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 px-5 sm:px-6 py-4 border-b border-dark-700/60 flex-shrink-0">
|
||||
{icon && (
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-blue/15 border border-accent-blue/20 flex items-center justify-center flex-shrink-0 text-accent-blue">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<h3 id={titleId} className="text-base sm:text-lg font-semibold text-content-primary tracking-tight">
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-xs sm:text-sm text-txt-muted mt-0.5 leading-relaxed">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-dark-700 rounded-lg transition-colors"
|
||||
className="p-1.5 -mr-1 rounded-lg text-txt-muted hover:text-content-primary hover:bg-dark-700 transition-colors flex-shrink-0"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="w-5 h-5 text-txt-muted" />
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className="px-5 sm:px-6 py-4 overflow-y-auto flex-1 min-h-0">{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>
|
||||
{/* Footer */}
|
||||
{showFooter && (
|
||||
<div className="flex items-center justify-end gap-2.5 px-5 sm:px-6 py-3.5 border-t border-dark-700/60 bg-dark-900/30 rounded-b-2xl flex-shrink-0">
|
||||
{footer ?? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} disabled={loading}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
{showConfirm && (
|
||||
<Button
|
||||
variant={isDanger ? 'danger' : 'primary'}
|
||||
size="sm"
|
||||
onClick={onConfirm}
|
||||
loading={loading}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ const colorMap: Record<ToastType, { bg: string; border: string; icon: string; te
|
||||
text: 'text-amber-200',
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-surface-baselue-900/40',
|
||||
bg: 'bg-blue-900/40',
|
||||
border: 'border-blue-700/60',
|
||||
icon: 'text-blue-400',
|
||||
text: 'text-blue-200',
|
||||
|
||||
@@ -18,7 +18,7 @@ const iconMap: Record<ToastType, React.ReactNode> = {
|
||||
}
|
||||
|
||||
const bgMap: Record<ToastType, string> = {
|
||||
info: 'bg-surface-baselue-900/80 border-blue-700',
|
||||
info: 'bg-blue-900/80 border-blue-700',
|
||||
success: 'bg-green-900/80 border-green-700',
|
||||
error: 'bg-red-900/80 border-red-700',
|
||||
warning: 'bg-yellow-900/80 border-yellow-700',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Common — 通用 UI 原语
|
||||
*/
|
||||
export { default as ErrorBoundary } from './ErrorBoundary'
|
||||
export { default as Loading, ButtonLoading } from './Loading'
|
||||
export {
|
||||
Skeleton,
|
||||
SkeletonLine,
|
||||
SkeletonCircle,
|
||||
SkeletonBlock,
|
||||
FileListSkeleton,
|
||||
SessionTreeSkeleton,
|
||||
TabContentSkeleton,
|
||||
} from './Skeleton'
|
||||
export { default as Button } from './Button'
|
||||
export { default as Modal } from './Modal'
|
||||
export { default as EmptyState } from './EmptyState'
|
||||
export { default as ToastProvider } from './Toast'
|
||||
export { ToastContainer } from './ToastContainer'
|
||||
export { CommandPalette } from './CommandPalette'
|
||||
export { useConfirmDialog } from './ConfirmDialog'
|
||||
export { usePromptDialog } from './PromptDialog'
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
HardDrive,
|
||||
Activity,
|
||||
} from 'lucide-react'
|
||||
import { useConnectionStore } from '../stores/connection'
|
||||
import { usePoolStore } from '../stores/connection/poolStore'
|
||||
import { useConnectionPanelStore } from '../stores/connectionPanelStore'
|
||||
import type { PooledConnection, ConnectionState } from '../stores/connection/types'
|
||||
import EmptyState from './common/EmptyState'
|
||||
import { useConnectionStore } from '../../stores/connection'
|
||||
import { usePoolStore } from '../../stores/connection/poolStore'
|
||||
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
|
||||
import type { PooledConnection, ConnectionState } from '../../stores/connection/types'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
|
||||
// ============== Helpers ==============
|
||||
|
||||
@@ -167,7 +167,7 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
|
||||
onClick={() => onReconnect(conn.connectionId)}
|
||||
disabled={isReconnecting}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs
|
||||
bg-surface-baseccent-blue/10 text-accent-blue hover:bg-surface-baseccent-blue/20
|
||||
bg-accent-blue/10 text-accent-blue hover:bg-accent-blue/20
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
|
||||
@@ -274,7 +274,7 @@ export default function ConnectionPanel() {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-surface-baselack/30 z-40 animate-fade-in"
|
||||
className="fixed inset-0 bg-black/30 z-40 animate-fade-in"
|
||||
onClick={close}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
/**
|
||||
* PortForwardView - 端口转发管理视图
|
||||
*
|
||||
* 支持本地端口转发、远程端口转发和动态 SOCKS5 代理
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useConnectionPool } from '../../stores/connection'
|
||||
import { ArrowRightLeft, Copy, CopyPlus, Globe, Pencil, Server, Plus, Trash2, Play, Square } from 'lucide-react'
|
||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
|
||||
import {
|
||||
formatTransferBytes,
|
||||
duplicateForwardRule,
|
||||
parseForwardRules,
|
||||
portForwardStorageKey,
|
||||
runtimeMap,
|
||||
type ForwardRule,
|
||||
} from './forwardRules'
|
||||
|
||||
interface PortForwardViewProps {
|
||||
tabId: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export default function PortForwardView({ tabId, isActive = true }: PortForwardViewProps) {
|
||||
const { tabs, updateTab } = useTabStore()
|
||||
const pool = useConnectionPool()
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||
|
||||
const currentTab = tabs.find((t) => t.id === tabId)
|
||||
const needsReconnect = currentTab?.needsReconnect
|
||||
const connectionId = currentTab?.connectionInstance?.connectionId
|
||||
const connection = connectionId ? pool.getConnection(connectionId) : null
|
||||
const storageKey = portForwardStorageKey(currentTab?.sessionId, tabId)
|
||||
|
||||
const [rules, setRules] = useState<ForwardRule[]>([])
|
||||
const [tunnels, setTunnels] = useState<SSHForwardStatus[]>([])
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [editingRuleId, setEditingRuleId] = useState<string | null>(null)
|
||||
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)
|
||||
|
||||
const runtimes = useMemo(() => runtimeMap(tunnels), [tunnels])
|
||||
const socksRunning = rules.some((rule) => rule.type === 'dynamic' && runtimes.has(rule.id))
|
||||
|
||||
const refreshTunnels = useCallback(async () => {
|
||||
if (!connectionId) {
|
||||
setTunnels([])
|
||||
return
|
||||
}
|
||||
const result = await window.opsxApi?.sshForwardList(connectionId)
|
||||
if (result?.success) setTunnels(result.tunnels || [])
|
||||
}, [connectionId])
|
||||
|
||||
// Load stable saved rules and migrate the old connection-id based key once.
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
const load = async () => {
|
||||
const result = await window.opsxApi?.settingsGet(storageKey)
|
||||
let loaded = parseForwardRules(result?.value)
|
||||
if (loaded.length === 0 && connectionId) {
|
||||
const legacy = await window.opsxApi?.settingsGet(`portForwards_${connectionId}`)
|
||||
loaded = parseForwardRules(legacy?.value)
|
||||
if (loaded.length > 0) await window.opsxApi?.settingsSet(storageKey, loaded)
|
||||
}
|
||||
if (!disposed) setRules(loaded)
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [connectionId, storageKey])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTunnels()
|
||||
if (!isActive || !connectionId) return
|
||||
const timer = window.setInterval(() => void refreshTunnels(), 2000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [connectionId, isActive, refreshTunnels])
|
||||
|
||||
// Save configuration only. Runtime status is owned by Rust and never persisted.
|
||||
const saveRules = (newRules: ForwardRule[]) => {
|
||||
setRules(newRules)
|
||||
void window.opsxApi?.settingsSet(storageKey, newRules)
|
||||
}
|
||||
|
||||
const validPort = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 65535
|
||||
|
||||
const resetRuleForm = () => {
|
||||
setEditingRuleId(null)
|
||||
setNewRule({ type: 'local', name: '', localPort: 0, remoteHost: '', remotePort: 0 })
|
||||
}
|
||||
|
||||
const openAddRule = () => {
|
||||
resetRuleForm()
|
||||
setError(null)
|
||||
setShowAddModal(true)
|
||||
}
|
||||
|
||||
// Persist a new rule or update an existing stopped rule.
|
||||
const handleSaveRule = async () => {
|
||||
if (!newRule.name?.trim() || !validPort(newRule.localPort)) {
|
||||
setError('请填写规则名称和 1-65535 范围内的端口')
|
||||
return
|
||||
}
|
||||
|
||||
if (newRule.type !== 'dynamic' && !validPort(newRule.remotePort)) {
|
||||
setError('请填写 1-65535 范围内的目标端口')
|
||||
return
|
||||
}
|
||||
if (newRule.type === 'local' && !newRule.remoteHost?.trim()) {
|
||||
setError('请填写远程主机')
|
||||
return
|
||||
}
|
||||
|
||||
const rule: ForwardRule = {
|
||||
id: editingRuleId || `pf-${crypto.randomUUID()}`,
|
||||
type: newRule.type as 'local' | 'remote' | 'dynamic',
|
||||
name: newRule.name.trim(),
|
||||
localPort: newRule.localPort,
|
||||
remoteHost: newRule.remoteHost?.trim(),
|
||||
remotePort: newRule.remotePort,
|
||||
}
|
||||
|
||||
saveRules(editingRuleId ? rules.map((item) => (item.id === editingRuleId ? rule : item)) : [...rules, rule])
|
||||
setShowAddModal(false)
|
||||
resetRuleForm()
|
||||
setError(null)
|
||||
}
|
||||
|
||||
// Start port forward
|
||||
const startForward = async (rule: ForwardRule) => {
|
||||
if (!connectionId) {
|
||||
setError('未连接到服务器')
|
||||
return false
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
let result
|
||||
if (rule.type === 'local') {
|
||||
result = await window.opsxApi?.sshForwardLocal(
|
||||
connectionId,
|
||||
rule.localPort,
|
||||
rule.remoteHost || '',
|
||||
rule.remotePort || 0,
|
||||
rule.id
|
||||
)
|
||||
} else if (rule.type === 'remote') {
|
||||
result = await window.opsxApi?.sshForwardRemote(
|
||||
connectionId,
|
||||
rule.remotePort || 0,
|
||||
'127.0.0.1',
|
||||
rule.localPort,
|
||||
rule.id
|
||||
)
|
||||
} else {
|
||||
result = await window.opsxApi?.sshForwardDynamic(connectionId, rule.localPort, rule.id)
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
await refreshTunnels()
|
||||
return true
|
||||
}
|
||||
setError(result?.error || '启动失败')
|
||||
} catch (err: unknown) {
|
||||
setError((err instanceof Error ? err.message : String(err)) || '启动失败')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const quickStartSocks = async () => {
|
||||
const existing = rules.find((rule) => rule.type === 'dynamic')
|
||||
if (existing) {
|
||||
if (runtimes.has(existing.id)) {
|
||||
setError('SOCKS5 代理已在运行')
|
||||
return
|
||||
}
|
||||
await startForward(existing)
|
||||
return
|
||||
}
|
||||
const rule: ForwardRule = {
|
||||
id: `pf-${crypto.randomUUID()}`,
|
||||
type: 'dynamic',
|
||||
name: 'SOCKS5 代理',
|
||||
localPort: 1080,
|
||||
}
|
||||
saveRules([...rules, rule])
|
||||
await startForward(rule)
|
||||
}
|
||||
|
||||
// Stop the real native listener before changing UI state.
|
||||
const stopForward = async (rule: ForwardRule) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.opsxApi?.sshForwardStop(rule.id)
|
||||
if (!result?.success) {
|
||||
setError(result?.error || '停止失败')
|
||||
return false
|
||||
}
|
||||
await refreshTunnels()
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
setError((err instanceof Error ? err.message : String(err)) || '停止失败')
|
||||
return false
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyProxyValue = async (value: string) => {
|
||||
await window.opsxApi?.clipboardWriteText(value)
|
||||
}
|
||||
|
||||
const editRule = async (rule: ForwardRule) => {
|
||||
if (runtimes.has(rule.id)) {
|
||||
const confirmed = await confirm({
|
||||
title: '停止后编辑',
|
||||
message: '该转发正在运行。编辑前需要停止真实监听,是否继续?',
|
||||
confirmText: '停止并编辑',
|
||||
})
|
||||
if (!confirmed || !(await stopForward(rule))) return
|
||||
}
|
||||
setEditingRuleId(rule.id)
|
||||
setNewRule({ ...rule })
|
||||
setError(null)
|
||||
setShowAddModal(true)
|
||||
}
|
||||
|
||||
const duplicateRule = (rule: ForwardRule) => {
|
||||
const copy = duplicateForwardRule(rule, rules, `pf-${crypto.randomUUID()}`)
|
||||
saveRules([...rules, copy])
|
||||
}
|
||||
|
||||
// Delete rule
|
||||
const deleteRule = async (rule: ForwardRule) => {
|
||||
const confirmed = await confirm({
|
||||
title: '确认删除',
|
||||
message: runtimes.has(rule.id) ? '该转发正在运行,将先停止再删除。' : '确定删除此转发规则?',
|
||||
danger: true,
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (!confirmed) return
|
||||
if (runtimes.has(rule.id) && !(await stopForward(rule))) return
|
||||
saveRules(rules.filter((item) => item.id !== rule.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col bg-dark-900">
|
||||
{/* 会话恢复 - 需要重新连接覆盖层 */}
|
||||
{needsReconnect && currentTab?.sessionId && (
|
||||
<div className="bg-dark-900/90 absolute inset-0 z-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="bg-accent-blue/20 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl">
|
||||
<ArrowRightLeft className="h-8 w-8 text-accent-blue" />
|
||||
</div>
|
||||
<div className="mb-2 text-lg text-content-primary">会话已恢复</div>
|
||||
<div className="mb-6 text-sm text-txt-muted">
|
||||
上次关闭时的端口转发标签已恢复
|
||||
<br />
|
||||
需要重新建立 SSH 连接才能使用
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateTab(tabId, { needsReconnect: false })
|
||||
// 刷新页面或触发连接逻辑
|
||||
}}
|
||||
className="rounded-lg bg-accent-blue px-6 py-2.5 text-content-primary transition-colors hover:bg-blue-500"
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-dark-700 px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowRightLeft className="h-6 w-6 text-accent-blue" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-content-primary">端口转发</h2>
|
||||
<p className="text-sm text-txt-muted">
|
||||
{connection
|
||||
? `已连接: ${connection.config.username}@${connection.config.host}`
|
||||
: '未连接 - 请先建立 SSH 连接'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => void quickStartSocks()}
|
||||
disabled={!connection || isLoading || socksRunning}
|
||||
className="flex items-center gap-2 rounded-lg bg-orange-500/15 px-3 py-2 text-sm font-medium text-orange-300 transition-colors hover:bg-orange-500/25 disabled:opacity-40"
|
||||
title="默认仅监听 127.0.0.1:1080"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
{socksRunning ? 'SOCKS5 已启动' : '启动 SOCKS5'}
|
||||
</button>
|
||||
<button
|
||||
onClick={openAddRule}
|
||||
disabled={!connection}
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-blue px-4 py-2 text-sm font-medium transition-colors hover:bg-blue-600 disabled:bg-dark-700 disabled:text-txt-muted"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加规则
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rules List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{rules.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-txt-muted">
|
||||
<Globe className="mb-4 h-16 w-16 opacity-50" />
|
||||
<p className="text-lg">暂无端口转发规则</p>
|
||||
<p className="mb-4 mt-1 text-sm">可以一键启动本机 SOCKS5 代理</p>
|
||||
<button
|
||||
onClick={() => void quickStartSocks()}
|
||||
disabled={!connection || isLoading}
|
||||
className="rounded-lg bg-orange-500/15 px-4 py-2 text-sm text-orange-300 hover:bg-orange-500/25 disabled:opacity-40"
|
||||
>
|
||||
启动 127.0.0.1:1080
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{rules.map((rule) => {
|
||||
const runtime = runtimes.get(rule.id)
|
||||
const running = Boolean(runtime?.running)
|
||||
const boundPort = runtime?.boundPort || rule.localPort
|
||||
return (
|
||||
<div
|
||||
key={rule.id}
|
||||
className={`rounded-lg border p-4 transition-all ${
|
||||
running ? 'border-green-500/30 bg-green-500/10' : 'border-dark-700 bg-dark-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${
|
||||
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="h-5 w-5" />}
|
||||
{rule.type === 'remote' && <Server className="h-5 w-5" />}
|
||||
{rule.type === 'dynamic' && <Globe className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-content-primary">{rule.name}</span>
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${
|
||||
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>
|
||||
{running && (
|
||||
<span className="rounded bg-green-500/20 px-2 py-0.5 text-xs text-green-400">运行中</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-txt-muted">
|
||||
{rule.type === 'dynamic' ? (
|
||||
<span>SOCKS5 代理: 127.0.0.1:{boundPort}</span>
|
||||
) : rule.type === 'local' ? (
|
||||
<span>
|
||||
127.0.0.1:{boundPort} → {rule.remoteHost}:{rule.remotePort}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
远程 {runtime?.boundPort || rule.remotePort} → 127.0.0.1:{rule.localPort}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{runtime && (
|
||||
<div className="mt-1 text-xs text-txt-muted">
|
||||
{runtime.activeConnections} 个连接 · 上行 {formatTransferBytes(runtime.bytesUp)} · 下行{' '}
|
||||
{formatTransferBytes(runtime.bytesDown)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{rule.type === 'dynamic' && (
|
||||
<button
|
||||
onClick={() => void copyProxyValue(`socks5://127.0.0.1:${boundPort}`)}
|
||||
className="rounded p-2 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
title="复制代理地址"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => duplicateRule(rule)}
|
||||
className="rounded p-2 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
title="复制规则"
|
||||
>
|
||||
<CopyPlus className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void editRule(rule)}
|
||||
disabled={isLoading}
|
||||
className="rounded p-2 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary disabled:opacity-50"
|
||||
title="编辑规则"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
{running ? (
|
||||
<button
|
||||
onClick={() => void stopForward(rule)}
|
||||
disabled={isLoading}
|
||||
className="rounded p-2 text-yellow-400 transition-colors hover:bg-yellow-400/10 disabled:opacity-50"
|
||||
title="停止真实监听"
|
||||
>
|
||||
<Square className="h-5 w-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void startForward(rule)}
|
||||
disabled={isLoading || !connection}
|
||||
className="rounded p-2 text-green-400 transition-colors hover:bg-green-400/10 disabled:opacity-50"
|
||||
title="启动"
|
||||
>
|
||||
<Play className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void deleteRule(rule)}
|
||||
className="rounded p-2 text-red-400 transition-colors hover:bg-red-400/10"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="border-t border-red-500/20 bg-red-500/10 px-6 py-3">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Rule Modal */}
|
||||
{showAddModal && (
|
||||
<div className="bg-dark-900/90 absolute inset-0 z-50 flex items-center justify-center">
|
||||
<div className="w-full max-w-md rounded-xl border border-dark-700 bg-dark-800 p-6 shadow-2xl">
|
||||
<h3 className="mb-4 text-lg font-semibold text-content-primary">
|
||||
{editingRuleId ? '编辑端口转发规则' : '添加端口转发规则'}
|
||||
</h3>
|
||||
|
||||
{/* Type Selection */}
|
||||
<div className="mb-4 grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ 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 'local' | 'remote' | 'dynamic' })}
|
||||
className={`rounded-lg border p-3 text-left transition-colors ${
|
||||
newRule.type === t.type
|
||||
? 'bg-accent-blue/20 border-accent-blue text-content-primary'
|
||||
: 'border-dark-600 bg-dark-700 text-txt-muted 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="mb-1 block text-sm text-txt-muted">规则名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRule.name}
|
||||
onChange={(e) => setNewRule({ ...newRule, name: e.target.value })}
|
||||
placeholder="例如: MySQL 转发"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-content-primary placeholder-ph focus:border-accent-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-txt-muted">
|
||||
{newRule.type === 'remote' ? '本地目标端口' : '本地监听端口'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={newRule.localPort || ''}
|
||||
onChange={(e) => setNewRule({ ...newRule, localPort: Number(e.target.value) })}
|
||||
placeholder="例如: 3306"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-content-primary placeholder-ph focus:border-accent-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newRule.type !== 'dynamic' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-txt-muted">
|
||||
{newRule.type === 'remote' ? '远程监听端口' : '远程目标端口'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={newRule.remotePort || ''}
|
||||
onChange={(e) => setNewRule({ ...newRule, remotePort: Number(e.target.value) })}
|
||||
placeholder="例如: 3306"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-content-primary placeholder-ph focus:border-accent-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{newRule.type === 'local' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-txt-muted">远程主机</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRule.remoteHost}
|
||||
onChange={(e) => setNewRule({ ...newRule, remoteHost: e.target.value })}
|
||||
placeholder="例如: localhost 或 192.168.1.100"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-content-primary placeholder-ph focus:border-accent-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddModal(false)
|
||||
resetRuleForm()
|
||||
}}
|
||||
className="flex-1 rounded-lg bg-dark-700 px-4 py-2 text-sm font-medium transition-colors hover:bg-dark-600"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveRule}
|
||||
className="flex-1 rounded-lg bg-accent-blue px-4 py-2 text-sm font-medium transition-colors hover:bg-blue-600"
|
||||
>
|
||||
{editingRuleId ? '保存' : '添加'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
|
||||
|
||||
export type ForwardRuleType = 'local' | 'remote' | 'dynamic'
|
||||
|
||||
export interface ForwardRule {
|
||||
id: string
|
||||
type: ForwardRuleType
|
||||
name: string
|
||||
localPort: number
|
||||
remoteHost?: string
|
||||
remotePort?: number
|
||||
}
|
||||
|
||||
export function portForwardStorageKey(sessionId: string | undefined, tabId: string): string {
|
||||
return `portForwards_${sessionId ? `session_${sessionId}` : `tab_${tabId}`}`
|
||||
}
|
||||
|
||||
/** Migrates the old JSON-string format and deliberately drops persisted isActive. */
|
||||
export function parseForwardRules(value: unknown): ForwardRule[] {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return parseForwardRules(JSON.parse(value))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
return value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== 'object') return []
|
||||
const rule = candidate as Record<string, unknown>
|
||||
const type = rule.type
|
||||
const localPort = Number(rule.localPort)
|
||||
if (
|
||||
typeof rule.id !== 'string' ||
|
||||
typeof rule.name !== 'string' ||
|
||||
!['local', 'remote', 'dynamic'].includes(String(type)) ||
|
||||
!Number.isInteger(localPort) ||
|
||||
localPort < 1 ||
|
||||
localPort > 65535
|
||||
) {
|
||||
return []
|
||||
}
|
||||
const parsed: ForwardRule = {
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
type: type as ForwardRuleType,
|
||||
localPort,
|
||||
}
|
||||
if (typeof rule.remoteHost === 'string' && rule.remoteHost.trim()) {
|
||||
parsed.remoteHost = rule.remoteHost.trim()
|
||||
}
|
||||
const remotePort = Number(rule.remotePort)
|
||||
if (Number.isInteger(remotePort) && remotePort >= 1 && remotePort <= 65535) {
|
||||
parsed.remotePort = remotePort
|
||||
}
|
||||
return [parsed]
|
||||
})
|
||||
}
|
||||
|
||||
export function runtimeMap(tunnels: SSHForwardStatus[] | undefined): Map<string, SSHForwardStatus> {
|
||||
return new Map((tunnels || []).map((tunnel) => [tunnel.forwardId, tunnel]))
|
||||
}
|
||||
|
||||
/** Create a persisted rule copy without carrying over any process-local state. */
|
||||
export function duplicateForwardRule(rule: ForwardRule, rules: ForwardRule[], id: string): ForwardRule {
|
||||
const baseName = `${rule.name} 副本`
|
||||
let name = baseName
|
||||
let suffix = 2
|
||||
const names = new Set(rules.map((item) => item.name))
|
||||
while (names.has(name)) {
|
||||
name = `${baseName} ${suffix}`
|
||||
suffix += 1
|
||||
}
|
||||
return { ...rule, id, name }
|
||||
}
|
||||
|
||||
export function formatTransferBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const value = bytes / 1024 ** unit
|
||||
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Connection — 连接管理 / 端口转发
|
||||
*/
|
||||
export { default as ConnectionPanel } from './ConnectionPanel'
|
||||
export { default as PortForwardView } from './PortForwardView'
|
||||
+37
-24
@@ -1,29 +1,40 @@
|
||||
/**
|
||||
* 组件统一导出
|
||||
*
|
||||
* 使用方式:
|
||||
* import { Sidebar, TabBar, TerminalView } from '../components'
|
||||
* 组件统一导出(按功能域)
|
||||
*
|
||||
* 推荐按子路径导入以利于 tree-shaking:
|
||||
* import { Sidebar } from '@/components/layout'
|
||||
* import { TerminalView } from '@/components/terminal'
|
||||
*
|
||||
* 根 barrel 保留兼容:
|
||||
* import { Sidebar, 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 ConnectionPanel } from './ConnectionPanel'
|
||||
// Layout
|
||||
export { Sidebar, TabBar, TabContent, StatusBar } from './layout'
|
||||
|
||||
// 功能组件 (当前在根目录,将来移到对应目录)
|
||||
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'
|
||||
// Sessions
|
||||
export { SessionDialog, QuickConnectDialog, QuickLauncher, WelcomeView } from './sessions'
|
||||
|
||||
// 通用组件
|
||||
export { default as ErrorBoundary } from './common/ErrorBoundary'
|
||||
export { default as Loading, ButtonLoading } from './common/Loading'
|
||||
// Serial
|
||||
export { SerialConnectDialog, SerialTerminalView } from './serial'
|
||||
|
||||
// Settings
|
||||
export { SettingsView } from './settings'
|
||||
|
||||
// Connection
|
||||
export { ConnectionPanel, PortForwardView } from './connection'
|
||||
|
||||
// Terminal
|
||||
export { TerminalView, TerminalWorkspace, HostKeyDialog } from './terminal'
|
||||
|
||||
// SFTP
|
||||
export { SFTPView } from './sftp'
|
||||
|
||||
// Common
|
||||
export {
|
||||
ErrorBoundary,
|
||||
Loading,
|
||||
ButtonLoading,
|
||||
Skeleton,
|
||||
SkeletonLine,
|
||||
SkeletonCircle,
|
||||
@@ -31,10 +42,12 @@ export {
|
||||
FileListSkeleton,
|
||||
SessionTreeSkeleton,
|
||||
TabContentSkeleton,
|
||||
} from './common/Skeleton'
|
||||
export { default as Button } from './common/Button'
|
||||
export { default as Modal } from './common/Modal'
|
||||
export { default as EmptyState } from './common/EmptyState'
|
||||
Button,
|
||||
Modal,
|
||||
EmptyState,
|
||||
ToastContainer,
|
||||
CommandPalette,
|
||||
} from './common'
|
||||
|
||||
// Snippets
|
||||
export { default as SnippetPanel } from './snippets/SnippetPanel'
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Sidebar - 侧边栏组件
|
||||
* 支持宽度拖拽调整
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Plus, Zap, Server, Settings, ListChecks, Terminal, FolderSync, X, TerminalSquare } from 'lucide-react'
|
||||
import { useSessionStore, Session } from '../../stores'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { SessionTree } from './sidebar/SessionTree'
|
||||
import { SearchBox } from './sidebar/SearchBox'
|
||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||
import BatchCommandDialog from '../sessions/BatchCommandDialog'
|
||||
|
||||
const MIN_SIDEBAR_WIDTH = 180
|
||||
const MAX_SIDEBAR_WIDTH = 400
|
||||
|
||||
interface SidebarProps {
|
||||
onNewSession: () => void
|
||||
onQuickConnect: () => void
|
||||
onEditSession: (session: Session) => void
|
||||
}
|
||||
|
||||
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) {
|
||||
const { sessions, setSessions, addSession, removeSession } = useSessionStore()
|
||||
const { settings, setSetting } = useSettingsStore()
|
||||
const { addTab } = useTabStore()
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||
|
||||
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 [isResizing, setIsResizing] = useState(false)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(settings.sidebarWidth || 256)
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
const [batchMode, setBatchMode] = useState(false)
|
||||
const [batchSelection, setBatchSelection] = useState<Set<string>>(new Set())
|
||||
const [batchCommandSessions, setBatchCommandSessions] = useState<Session[]>([])
|
||||
const [batchCommandOpen, setBatchCommandOpen] = useState(false)
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Sync width from settings on load
|
||||
useEffect(() => {
|
||||
if (settings.sidebarWidth) {
|
||||
setSidebarWidth(settings.sidebarWidth)
|
||||
}
|
||||
}, [settings.sidebarWidth])
|
||||
|
||||
// Load sessions
|
||||
useEffect(() => {
|
||||
window.opsxApi?.sessionGetAll()
|
||||
.then((result) => {
|
||||
if (result.success && result.sessions) setSessions(result.sessions)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setSessionsLoading(false))
|
||||
}, [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)
|
||||
}, [])
|
||||
|
||||
// Resize handler
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsResizing(true)
|
||||
|
||||
const startX = e.clientX
|
||||
const startWidth = sidebarWidth
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientX - startX
|
||||
const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta))
|
||||
setSidebarWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
// Save to settings on drag end
|
||||
setSetting('sidebarWidth', sidebarWidth)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
// Prevent text selection during drag
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [sidebarWidth, setSetting])
|
||||
|
||||
// Save final width on unmount or when resizing ends (we save in handleMouseUp)
|
||||
useEffect(() => {
|
||||
if (!isResizing && sidebarWidth !== settings.sidebarWidth) {
|
||||
setSetting('sidebarWidth', sidebarWidth)
|
||||
}
|
||||
}, [isResizing, sidebarWidth, settings.sidebarWidth, setSetting])
|
||||
|
||||
const toggleGroup = (group: string) => {
|
||||
const newExpanded = new Set(expandedGroups)
|
||||
if (newExpanded.has(group)) newExpanded.delete(group)
|
||||
else newExpanded.add(group)
|
||||
setExpandedGroups(newExpanded)
|
||||
}
|
||||
|
||||
const handleSelect = (session: Session) => {
|
||||
setSelectedSessionId(session.id)
|
||||
}
|
||||
|
||||
const handleConnect = (session: Session) => {
|
||||
setSelectedSessionId(session.id)
|
||||
addTab({
|
||||
title: session.name,
|
||||
type: 'terminal',
|
||||
sessionId: session.id,
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenSftp = (session: Session) => {
|
||||
setSelectedSessionId(session.id)
|
||||
addTab({
|
||||
title: `SFTP: ${session.name}`,
|
||||
type: 'sftp',
|
||||
sessionId: session.id,
|
||||
})
|
||||
}
|
||||
|
||||
const toggleBatchSelection = useCallback((session: Session) => {
|
||||
setBatchSelection((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(session.id)) next.delete(session.id)
|
||||
else next.add(session.id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const visibleSessions = sessions.filter((session) => {
|
||||
const query = searchTerm.trim().toLowerCase()
|
||||
return !query || session.name.toLowerCase().includes(query) ||
|
||||
session.host.toLowerCase().includes(query) ||
|
||||
session.username?.toLowerCase().includes(query)
|
||||
})
|
||||
|
||||
const toggleSelectVisible = useCallback(() => {
|
||||
setBatchSelection((current) => {
|
||||
const visibleIds = visibleSessions.map((session) => session.id)
|
||||
const allSelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id))
|
||||
const next = new Set(current)
|
||||
for (const id of visibleIds) {
|
||||
if (allSelected) next.delete(id)
|
||||
else next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [visibleSessions])
|
||||
|
||||
const selectedSessions = sessions.filter((session) => batchSelection.has(session.id))
|
||||
|
||||
const finishBatchMode = useCallback(() => {
|
||||
setBatchMode(false)
|
||||
setBatchSelection(new Set())
|
||||
}, [])
|
||||
|
||||
const handleBatchTerminal = useCallback(() => {
|
||||
for (const session of selectedSessions) {
|
||||
addTab({
|
||||
title: session.name,
|
||||
type: 'terminal',
|
||||
sessionId: session.id,
|
||||
connectOnMount: true,
|
||||
})
|
||||
}
|
||||
finishBatchMode()
|
||||
}, [addTab, finishBatchMode, selectedSessions])
|
||||
|
||||
const handleBatchSftp = useCallback(() => {
|
||||
for (const session of selectedSessions) {
|
||||
addTab({ title: `SFTP: ${session.name}`, type: 'sftp', sessionId: session.id })
|
||||
}
|
||||
finishBatchMode()
|
||||
}, [addTab, finishBatchMode, selectedSessions])
|
||||
|
||||
const handleBatchCommand = useCallback(() => {
|
||||
setBatchCommandSessions(selectedSessions)
|
||||
setBatchCommandOpen(true)
|
||||
finishBatchMode()
|
||||
}, [finishBatchMode, selectedSessions])
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, session: Session) => {
|
||||
e.preventDefault()
|
||||
setSelectedSessionId(session.id)
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, session })
|
||||
}
|
||||
|
||||
const handleContextMenuAction = async (action: string) => {
|
||||
if (!contextMenu) return
|
||||
const session = contextMenu.session
|
||||
|
||||
switch (action) {
|
||||
case 'connect':
|
||||
handleConnect(session)
|
||||
break
|
||||
case 'sftp':
|
||||
handleOpenSftp(session)
|
||||
break
|
||||
case 'edit':
|
||||
onEditSession(session)
|
||||
break
|
||||
case 'duplicate': {
|
||||
const { id: _id, ...sessionWithoutId } = session
|
||||
window.opsxApi?.sessionCreate({
|
||||
...sessionWithoutId,
|
||||
name: `${session.name} (副本)`,
|
||||
}).then((result) => {
|
||||
if (result?.success && result.session) {
|
||||
addSession(result.session as Session)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'delete': {
|
||||
const confirmed = await confirm({
|
||||
title: '确认删除',
|
||||
message: `确定删除会话 "${session.name}"?`,
|
||||
danger: true,
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (confirmed) {
|
||||
removeSession(session.id)
|
||||
window.opsxApi?.sessionDelete(session.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
setContextMenu(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className="relative flex flex-col h-full bg-dark-800/95 border-r border-dark-700/40"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-3.5 border-b border-dark-700/30">
|
||||
<div className="flex items-center gap-2.5 mb-3.5 px-0.5">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-accent-blue to-accent-blue/70 rounded-lg flex items-center justify-center shadow-sm shadow-accent-blue/25">
|
||||
<Server className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-sm text-content-primary tracking-tight leading-none">
|
||||
OpsXShell
|
||||
</div>
|
||||
<div className="text-[10px] text-txt-faint mt-1">远程运维工作台</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={onQuickConnect}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-blue hover:bg-accent-blue-hover rounded-lg text-xs font-medium text-white transition-colors shadow-sm shadow-accent-blue/20"
|
||||
>
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
快速连接
|
||||
</button>
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
title="新建会话"
|
||||
className="flex items-center justify-center px-2.5 py-2 bg-dark-700 hover:bg-dark-600 border border-dark-600/60 rounded-lg text-sm font-medium transition-colors text-content-primary"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<div className="p-3 border-b border-dark-700/20 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchBox
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="搜索会话..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title={batchMode ? '退出批量操作' : '批量操作'}
|
||||
aria-label={batchMode ? '退出批量操作' : '批量操作'}
|
||||
onClick={() => batchMode ? finishBatchMode() : setBatchMode(true)}
|
||||
className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded border transition-colors ${
|
||||
batchMode
|
||||
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
|
||||
: 'border-dark-700 bg-dark-900 text-txt-muted hover:text-content-primary'
|
||||
}`}
|
||||
>
|
||||
{batchMode ? <X className="h-4 w-4" /> : <ListChecks className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{batchMode && (
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-txt-muted">已选 {batchSelection.size} 个会话</span>
|
||||
<button type="button" onClick={toggleSelectVisible} className="text-accent-blue hover:underline">
|
||||
{visibleSessions.length > 0 && visibleSessions.every((s) => batchSelection.has(s.id))
|
||||
? '取消当前结果'
|
||||
: '全选当前结果'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SessionTree
|
||||
sessions={sessions}
|
||||
expandedGroups={expandedGroups}
|
||||
selectedSessionId={selectedSessionId}
|
||||
onToggleGroup={toggleGroup}
|
||||
onSelectSession={handleSelect}
|
||||
onConnectSession={handleConnect}
|
||||
onContextMenu={handleContextMenu}
|
||||
searchTerm={searchTerm}
|
||||
loading={sessionsLoading}
|
||||
onNewSession={onNewSession}
|
||||
multiSelect={batchMode}
|
||||
selectedSessionIds={batchSelection}
|
||||
onToggleSelection={toggleBatchSelection}
|
||||
/>
|
||||
{batchMode && (
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-dark-700/30 p-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedSessions.length === 0}
|
||||
onClick={handleBatchCommand}
|
||||
className="col-span-2 flex h-9 items-center justify-center gap-1.5 rounded bg-accent-blue text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<TerminalSquare className="h-3.5 w-3.5" />
|
||||
批量命令
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedSessions.length === 0}
|
||||
onClick={handleBatchTerminal}
|
||||
className="flex h-9 items-center justify-center gap-1.5 rounded bg-accent-blue text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
打开终端
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedSessions.length === 0}
|
||||
onClick={handleBatchSftp}
|
||||
className="flex h-9 items-center justify-center gap-1.5 rounded border border-dark-600 bg-dark-700 text-xs font-medium text-content-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<FolderSync className="h-3.5 w-3.5" />
|
||||
打开 SFTP
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-dark-700/20">
|
||||
<button
|
||||
onClick={() => addTab({ title: '设置', type: 'settings' })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-txt-muted 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: 'SFTP 文件', action: 'sftp' },
|
||||
{ label: '编辑', action: 'edit' },
|
||||
{ label: '克隆', action: 'duplicate' },
|
||||
{ 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-content-secondary hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
onMouseDown={handleResizeStart}
|
||||
className={`
|
||||
absolute top-0 right-0 bottom-0 w-1 cursor-col-resize
|
||||
hover:bg-accent-blue/50 active:bg-accent-blue/70
|
||||
hover:w-1.5 hover:shadow-[0_0_6px_rgba(59,130,246,0.4)]
|
||||
transition-all duration-150
|
||||
${isResizing ? 'bg-accent-blue/70 w-1.5 shadow-[0_0_6px_rgba(59,130,246,0.5)]' : ''}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
<BatchCommandDialog
|
||||
isOpen={batchCommandOpen}
|
||||
sessions={batchCommandSessions}
|
||||
onClose={() => setBatchCommandOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Wifi, WifiOff, AlertCircle, Loader2, HardDrive, Clock, ChevronUp } from 'lucide-react'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useTabStore, type ConnectionStatus } from '../stores/tabStore'
|
||||
import { usePoolStore } from '../stores/connection/poolStore'
|
||||
import { useConnectionStore, type ConnectionState } from '../stores/connection'
|
||||
import { useConnectionPanelStore } from '../stores/connectionPanelStore'
|
||||
import { useTabStore, type ConnectionStatus } from '../../stores/tabStore'
|
||||
import { usePoolStore } from '../../stores/connection/poolStore'
|
||||
import type { ConnectionState } from '../../stores/connection'
|
||||
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
|
||||
import { useSessionStore } from '../../stores'
|
||||
|
||||
// ============== Status Display Config ==============
|
||||
|
||||
@@ -92,7 +93,7 @@ export default function StatusBar() {
|
||||
|
||||
// Connection store (pool) — subscribe to connections to reactively update
|
||||
const connections = usePoolStore((s) => s.connections)
|
||||
const poolStore = useConnectionStore()
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
|
||||
// Total connection count for the panel badge
|
||||
const totalConnections = connections.size
|
||||
@@ -104,38 +105,72 @@ export default function StatusBar() {
|
||||
)
|
||||
|
||||
// Determine the connection state and info for the active tab
|
||||
const { connectionState, hostLabel, stats } = useMemo(() => {
|
||||
const { connectionState, hostLabel, stats, tabKind } = useMemo(() => {
|
||||
if (!activeTab || activeTab.type === 'welcome' || activeTab.type === 'settings') {
|
||||
return { connectionState: undefined, hostLabel: undefined, stats: undefined }
|
||||
return {
|
||||
connectionState: undefined,
|
||||
hostLabel: undefined,
|
||||
stats: undefined,
|
||||
tabKind: activeTab?.type as string | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTab.type === 'serial') {
|
||||
return {
|
||||
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
|
||||
hostLabel: activeTab.serialConfig
|
||||
? `${activeTab.serialConfig.path} · ${activeTab.serialConfig.baudRate}`
|
||||
: activeTab.title,
|
||||
stats: undefined,
|
||||
tabKind: activeTab.type,
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Try to get connection from pool via connectionInstance
|
||||
const instance = activeTab.connectionInstance
|
||||
if (instance?.connectionId) {
|
||||
const conn = poolStore.getConnection(instance.connectionId)
|
||||
const conn = connections.get(instance.connectionId)
|
||||
if (conn) {
|
||||
return {
|
||||
connectionState: conn.state,
|
||||
hostLabel: `${conn.config.username}@${conn.config.host}`,
|
||||
hostLabel: `${conn.config.username}@${conn.config.host}:${conn.config.port || 22}`,
|
||||
stats: conn.stats,
|
||||
tabKind: activeTab.type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to tab's own connectionStatus
|
||||
// 2. Session id → session store
|
||||
if (activeTab.sessionId) {
|
||||
const session = sessions.find((s) => s.id === activeTab.sessionId)
|
||||
if (session) {
|
||||
return {
|
||||
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
|
||||
hostLabel: `${session.username}@${session.host}:${session.port || 22}`,
|
||||
stats: undefined,
|
||||
tabKind: activeTab.type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fall back to tab's own connectionConfig
|
||||
const state = mapTabStatusToConnectionState(activeTab.connectionStatus)
|
||||
const config = activeTab.connectionConfig
|
||||
const label = config ? `${config.username}@${config.host}` : undefined
|
||||
const label = config
|
||||
? `${config.username}@${config.host}:${config.port || 22}`
|
||||
: activeTab.isLocal
|
||||
? '本地终端'
|
||||
: undefined
|
||||
|
||||
return { connectionState: state, hostLabel: label, stats: undefined }
|
||||
}, [activeTab, poolStore, connections]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return { connectionState: state, hostLabel: label, stats: undefined, tabKind: activeTab.type }
|
||||
}, [activeTab, connections, sessions])
|
||||
|
||||
// Clock timer + version
|
||||
useEffect(() => {
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.getVersion().then((v: string) => {
|
||||
if (window.opsxApi) {
|
||||
window.opsxApi.getVersion().then((v: string) => {
|
||||
setVersion(v)
|
||||
})
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
@@ -163,52 +198,57 @@ export default function StatusBar() {
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="h-7 bg-dark-800 flex items-center px-3 text-xs text-content-primary select-none">
|
||||
<div className="h-7 bg-dark-800/95 border-t border-dark-700/40 flex items-center px-3 text-[11px] text-content-primary select-none">
|
||||
{/* Left: Connection status area — clickable to toggle ConnectionPanel */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePanel}
|
||||
className={`flex items-center gap-1.5 rounded px-1 py-0.5 -ml-1
|
||||
hover:bg-dark-700/60 transition-colors cursor-pointer group
|
||||
className={`flex items-center gap-1.5 rounded-md px-1.5 py-0.5 -ml-1
|
||||
hover:bg-dark-700/70 transition-colors cursor-pointer group
|
||||
${statusCfg.colorClass} ${statusCfg.animateClass}`}
|
||||
title="点击打开连接管理面板"
|
||||
>
|
||||
{statusCfg.icon && (
|
||||
<span className="transition-colors duration-300">{statusCfg.icon}</span>
|
||||
)}
|
||||
<span className="transition-colors duration-300">{statusCfg.label}</span>
|
||||
<span className="transition-colors duration-300 font-medium">{statusCfg.label}</span>
|
||||
{hostLabel && (
|
||||
<>
|
||||
<span className="text-txt-muted mx-0.5">·</span>
|
||||
<span className="text-content-secondary">{hostLabel}</span>
|
||||
<span className="text-txt-faint mx-0.5">·</span>
|
||||
<span className="text-content-secondary font-mono truncate max-w-[280px]" title={hostLabel}>
|
||||
{hostLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{tabKind && tabKind !== 'welcome' && tabKind !== 'settings' && (
|
||||
<span className="ml-1.5 px-1.5 py-px rounded text-[10px] text-txt-faint bg-dark-700/80 capitalize">
|
||||
{tabKind === 'terminal' ? '终端' : tabKind === 'sftp' ? 'SFTP' : tabKind === 'portforward' ? '转发' : tabKind}
|
||||
</span>
|
||||
)}
|
||||
{totalConnections > 0 && (
|
||||
<span className="ml-1 px-1.5 py-px rounded-full bg-surface-baseccent-blue/20 text-accent-blue text-[10px] font-medium">
|
||||
<span className="ml-1 px-1.5 py-px rounded-full bg-accent-blue/20 text-accent-blue text-[10px] font-medium tabular-nums">
|
||||
{totalConnections}
|
||||
</span>
|
||||
)}
|
||||
<ChevronUp className="w-3 h-3 text-txt-muted transition-transform duration-200 group-hover:rotate-180" />
|
||||
<ChevronUp className="w-3 h-3 text-txt-faint transition-transform duration-200 group-hover:rotate-180" />
|
||||
</button>
|
||||
|
||||
{/* Center: Transfer stats */}
|
||||
{transferLabel && (
|
||||
<div className="flex items-center gap-1 ml-4 text-txt-muted">
|
||||
<div className="flex items-center gap-1 ml-3 text-txt-muted tabular-nums">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
<span>{transferLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right: Clock + version */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1 text-txt-muted tabular-nums">
|
||||
<div className="flex items-center gap-3 text-txt-faint">
|
||||
<div className="flex items-center gap-1 tabular-nums">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
<span className="text-txt-muted">OpsXShell v{version}</span>
|
||||
<span className="hidden sm:inline opacity-80">OpsXShell v{version}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* TabBar - 标签栏组件(支持拖拽排序)
|
||||
*/
|
||||
|
||||
import { Plus } from 'lucide-react'
|
||||
import React, { useState, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useConnectionPool } from '../../stores/connection'
|
||||
import { TabItem } from './tabbar/TabItem'
|
||||
import { TabContextMenu } from './tabbar/TabContextMenu'
|
||||
|
||||
interface TabBarProps {
|
||||
onOpenLauncher?: () => void
|
||||
}
|
||||
|
||||
function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activateTab,
|
||||
closeTab,
|
||||
addTab,
|
||||
closeOtherTabs,
|
||||
closeRightTabs,
|
||||
closeLeftTabs,
|
||||
closeAllTabs,
|
||||
moveTab,
|
||||
} = useTabStore()
|
||||
|
||||
const { disconnectConnection } = useConnectionPool()
|
||||
|
||||
// Drag state
|
||||
const [dragFromIndex, setDragFromIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const [menuState, setMenuState] = useState({
|
||||
isOpen: false,
|
||||
tabId: null as string | null,
|
||||
position: { x: 0, y: 0 },
|
||||
})
|
||||
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
// --- Drag handlers ---
|
||||
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
|
||||
setDragFromIndex(index)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
// Set a transparent drag image so we rely on the ghost
|
||||
if (e.currentTarget instanceof HTMLDivElement) {
|
||||
dragNodeRef.current = e.currentTarget
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, index: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragFromIndex === null || dragFromIndex === index) {
|
||||
setDragOverIndex(null)
|
||||
return
|
||||
}
|
||||
// Determine insert position: if dragging from lower index, place after; otherwise before
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const midX = rect.left + rect.width / 2
|
||||
const insertAt = e.clientX < midX ? index : index + 1
|
||||
// Don't show indicator if it would result in no-op
|
||||
if (insertAt === dragFromIndex || insertAt === dragFromIndex + 1) {
|
||||
setDragOverIndex(null)
|
||||
} else {
|
||||
setDragOverIndex(insertAt)
|
||||
}
|
||||
},
|
||||
[dragFromIndex]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, index: number) => {
|
||||
e.preventDefault()
|
||||
if (dragFromIndex === null) return
|
||||
// Calculate actual toIndex based on mouse position
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const midX = rect.left + rect.width / 2
|
||||
let toIndex = e.clientX < midX ? index : index + 1
|
||||
// Adjust for the removal of the dragged item shifting indices
|
||||
if (toIndex > dragFromIndex) {
|
||||
toIndex -= 1
|
||||
}
|
||||
if (dragFromIndex !== toIndex) {
|
||||
moveTab(dragFromIndex, toIndex)
|
||||
}
|
||||
setDragFromIndex(null)
|
||||
setDragOverIndex(null)
|
||||
},
|
||||
[dragFromIndex, moveTab]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragFromIndex(null)
|
||||
setDragOverIndex(null)
|
||||
}, [])
|
||||
|
||||
const currentTab = menuState.tabId ? tabs.find((t) => t.id === menuState.tabId) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="bg-dark-800/90 border-dark-700/40 flex items-stretch border-b"
|
||||
>
|
||||
{/* Tab List — 可点击,禁止窗口拖拽 */}
|
||||
<div className="titlebar-no-drag flex min-w-0 items-stretch overflow-x-auto">
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
index={index}
|
||||
isActive={tab.id === activeTabId}
|
||||
isDragging={dragFromIndex === index}
|
||||
dragOverIndex={dragOverIndex}
|
||||
onActivate={() => activateTab(tab.id)}
|
||||
onClose={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCloseTab(tab.id)
|
||||
}}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenLauncher}
|
||||
className="bg-accent-blue/90 shadow-accent-blue/20 mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md text-white shadow-sm transition-colors hover:bg-accent-blue"
|
||||
title="快速启动"
|
||||
aria-label="快速启动"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 右侧空白:拖动窗口 */}
|
||||
<div data-tauri-drag-region className="min-w-[48px] flex-1" aria-hidden />
|
||||
|
||||
<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,
|
||||
serialConfig: currentTab.serialConfig,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TabBar)
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
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'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import WelcomeView from '../sessions/WelcomeView'
|
||||
import TerminalWorkspace from '../terminal/TerminalWorkspace'
|
||||
import SFTPView from '../sftp/SFTPView'
|
||||
import PortForwardView from '../connection/PortForwardView'
|
||||
import SettingsView from '../settings/SettingsView'
|
||||
import SerialTerminalView from '../serial/SerialTerminalView'
|
||||
|
||||
interface TabContentProps {
|
||||
activeTabId: string | null
|
||||
@@ -25,11 +26,14 @@ function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentPro
|
||||
|
||||
switch (tab.type) {
|
||||
case 'terminal':
|
||||
content = <TerminalView tabId={tab.id} isActive={isActive} />
|
||||
content = <TerminalWorkspace tabId={tab.id} isActive={isActive} />
|
||||
break
|
||||
case 'sftp':
|
||||
content = <SFTPView tabId={tab.id} isActive={isActive} sessionId={tab.sessionId} />
|
||||
break
|
||||
case 'serial':
|
||||
content = <SerialTerminalView tabId={tab.id} isActive={isActive} />
|
||||
break
|
||||
case 'portforward':
|
||||
content = <PortForwardView tabId={tab.id} isActive={isActive} />
|
||||
break
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 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'
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Local Shell picker — choose Zsh / Bash (default marked)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { Laptop, ChevronDown } from 'lucide-react'
|
||||
import { useTabStore } from '../../../stores/tabStore'
|
||||
import { shellDisplayFromPath } from '../../../utils/shellName'
|
||||
|
||||
export interface ShellOption {
|
||||
path: string
|
||||
name: string
|
||||
kind: string
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
interface LocalShellMenuProps {
|
||||
/** Compact icon-only trigger (sidebar) */
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LocalShellMenu({ compact = false, className = '' }: LocalShellMenuProps) {
|
||||
const { addTab } = useTabStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [shells, setShells] = useState<ShellOption[]>([])
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fallback = () => setShells([
|
||||
{ path: '/bin/zsh', name: 'Zsh', kind: 'zsh', isDefault: true },
|
||||
{ path: '/bin/bash', name: 'Bash', kind: 'bash', isDefault: false },
|
||||
])
|
||||
|
||||
void window.opsxApi?.localListShells?.().then((res) => {
|
||||
if (res?.success && res.shells?.length) {
|
||||
setShells(res.shells)
|
||||
} else {
|
||||
fallback()
|
||||
}
|
||||
}).catch(fallback)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [open])
|
||||
|
||||
const openShell = useCallback(
|
||||
(opt?: ShellOption) => {
|
||||
const chosen =
|
||||
opt ||
|
||||
shells.find((s) => s.isDefault) ||
|
||||
shells[0] || {
|
||||
path: '',
|
||||
name: 'Local',
|
||||
kind: 'unknown',
|
||||
isDefault: true,
|
||||
}
|
||||
const display = shellDisplayFromPath(chosen.path || chosen.name)
|
||||
addTab({
|
||||
title: display.title,
|
||||
type: 'terminal',
|
||||
isLocal: true,
|
||||
localShellPath: chosen.path || undefined,
|
||||
localShellKind: display.kind,
|
||||
})
|
||||
setOpen(false)
|
||||
},
|
||||
[addTab, shells]
|
||||
)
|
||||
|
||||
const badgeClass = (kind: string) => {
|
||||
if (kind === 'zsh') return 'bg-teal-700 text-teal-100'
|
||||
if (kind === 'bash') return 'bg-dark-600 text-emerald-300'
|
||||
if (kind === 'fish') return 'bg-violet-700 text-violet-100'
|
||||
return 'bg-dark-600 text-content-secondary'
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`relative ${className}`}>
|
||||
{compact ? (
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openShell()}
|
||||
title="打开默认本地 Shell"
|
||||
className="flex items-center justify-center px-2.5 py-2 bg-dark-700 hover:bg-dark-600 border border-dark-600/60 rounded-l-lg text-sm font-medium transition-colors text-content-primary border-r-0"
|
||||
>
|
||||
<Laptop className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="选择 Shell"
|
||||
className="flex items-center justify-center px-1.5 py-2 bg-dark-700 hover:bg-dark-600 border border-dark-600/60 rounded-r-lg text-content-secondary"
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-3 p-4 rounded-2xl border border-dark-700/50 bg-dark-800/70 hover:bg-dark-800 hover:border-accent-blue/40 transition-all text-left"
|
||||
>
|
||||
<div className="w-11 h-11 rounded-xl bg-accent-blue/15 border border-accent-blue/20 flex items-center justify-center">
|
||||
<Laptop className="w-5 h-5 text-accent-blue" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-content-primary">本地 Shell</div>
|
||||
<div className="text-xs text-txt-muted mt-0.5">选择 Zsh / Bash 打开本机终端</div>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-txt-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={`
|
||||
absolute z-50 mt-1 py-1.5 rounded-xl border border-dark-600/80 bg-dark-800 shadow-xl
|
||||
min-w-[200px]
|
||||
${compact ? 'right-0' : 'left-0 right-0'}
|
||||
`}
|
||||
>
|
||||
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
本地 Shell
|
||||
</div>
|
||||
{shells.map((s) => (
|
||||
<button
|
||||
key={s.path}
|
||||
type="button"
|
||||
onClick={() => openShell(s)}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 hover:bg-dark-700/70 transition-colors text-left"
|
||||
>
|
||||
<span
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center text-[10px] font-bold flex-shrink-0 ${badgeClass(s.kind)}`}
|
||||
>
|
||||
{s.kind === 'bash' ? '$' : s.kind === 'zsh' ? 'zsh' : s.kind.slice(0, 2)}
|
||||
</span>
|
||||
<span className="text-sm text-content-primary flex-1">{s.name}</span>
|
||||
{s.isDefault && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-dark-700 text-txt-muted border border-dark-600">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Programmatic open helper for menu / palette */
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function openLocalShellTab(shell?: { path?: string; kind?: string; name?: string }) {
|
||||
const { addTab } = useTabStore.getState()
|
||||
const display = shellDisplayFromPath(shell?.path || shell?.name)
|
||||
addTab({
|
||||
title: display.title,
|
||||
type: 'terminal',
|
||||
isLocal: true,
|
||||
localShellPath: shell?.path,
|
||||
localShellKind: shell?.kind || display.kind,
|
||||
})
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* SessionTree — 分组会话列表
|
||||
* 单击选中 · 双击打开终端 · 右键更多操作
|
||||
*/
|
||||
|
||||
import { Check, ChevronRight, Folder, Server, ServerOff } from 'lucide-react'
|
||||
import { SessionTreeSkeleton } from '../../common/Skeleton'
|
||||
import type { Session } from '../../../stores/sessionStore'
|
||||
import EmptyState from '../../common/EmptyState'
|
||||
|
||||
interface SessionTreeProps {
|
||||
sessions: Session[]
|
||||
expandedGroups: Set<string>
|
||||
selectedSessionId: string | null
|
||||
onToggleGroup: (group: string) => void
|
||||
onSelectSession: (session: Session) => void
|
||||
onConnectSession: (session: Session) => void
|
||||
onContextMenu: (e: React.MouseEvent, session: Session) => void
|
||||
searchTerm: string
|
||||
loading?: boolean
|
||||
onNewSession?: () => void
|
||||
multiSelect?: boolean
|
||||
selectedSessionIds?: Set<string>
|
||||
onToggleSelection?: (session: Session) => void
|
||||
}
|
||||
|
||||
export function SessionTree({
|
||||
sessions,
|
||||
expandedGroups,
|
||||
selectedSessionId,
|
||||
onToggleGroup,
|
||||
onSelectSession,
|
||||
onConnectSession,
|
||||
onContextMenu,
|
||||
searchTerm,
|
||||
loading = false,
|
||||
onNewSession,
|
||||
multiSelect = false,
|
||||
selectedSessionIds = new Set(),
|
||||
onToggleSelection,
|
||||
}: SessionTreeProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<SessionTreeSkeleton groups={2} sessionsPerGroup={[3, 4]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const filteredSessions = sessions.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
s.host.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
s.username?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
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]) => {
|
||||
const isExpanded = expandedGroups.has(group)
|
||||
return (
|
||||
<div key={group} className="border-b border-dark-700/20 last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleGroup(group)}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-txt-muted hover:bg-dark-700/50 transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className="w-4 h-4 transition-transform duration-200 ease-in-out"
|
||||
style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
<Folder className="w-4 h-4" />
|
||||
<span className="flex-1 text-left">
|
||||
{group === 'default' ? '默认分组' : group}
|
||||
</span>
|
||||
<span className="text-xs text-txt-faint tabular-nums">{groupSessions.length}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
|
||||
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{groupSessions.map((session) => {
|
||||
const selected = multiSelect
|
||||
? selectedSessionIds.has(session.id)
|
||||
: selectedSessionId === session.id
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (multiSelect) onToggleSelection?.(session)
|
||||
else onSelectSession(session)
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (!multiSelect) onConnectSession(session)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || (multiSelect && e.key === ' ')) {
|
||||
e.preventDefault()
|
||||
if (multiSelect) onToggleSelection?.(session)
|
||||
else onConnectSession(session)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => onContextMenu(e, session)}
|
||||
title={multiSelect ? '选择会话' : '双击打开终端 · 右键更多操作'}
|
||||
className={`
|
||||
group flex items-center gap-2 px-3 py-2 text-sm cursor-pointer
|
||||
transition-colors duration-150 pl-9 pr-2
|
||||
${selected
|
||||
? 'bg-accent-blue/10 border-l-2 border-accent-blue'
|
||||
: 'border-l-2 border-transparent hover:bg-dark-700/40'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{multiSelect ? (
|
||||
<span
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border ${
|
||||
selected
|
||||
? 'border-accent-blue bg-accent-blue text-white'
|
||||
: 'border-dark-500 bg-dark-900'
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
{selected && <Check className="h-3 w-3" />}
|
||||
</span>
|
||||
) : (
|
||||
<Server
|
||||
className={`w-4 h-4 flex-shrink-0 ${
|
||||
selected ? 'text-accent-blue' : 'text-accent-green'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-content-primary truncate text-[13px]">
|
||||
{session.name}
|
||||
</div>
|
||||
<div className="text-[11px] text-txt-faint font-mono truncate">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{filteredSessions.length === 0 && (
|
||||
<EmptyState
|
||||
icon={ServerOff}
|
||||
title={searchTerm ? '未找到匹配的会话' : '暂无会话'}
|
||||
description={
|
||||
searchTerm
|
||||
? '尝试其他关键词'
|
||||
: '快速连接或新建会话,双击即可进终端'
|
||||
}
|
||||
action={
|
||||
searchTerm
|
||||
? undefined
|
||||
: onNewSession
|
||||
? { label: '新建会话', onClick: onNewSession }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* TabItem — local shell shows zsh/bash badge like professional terminal UIs
|
||||
*/
|
||||
|
||||
import { X, Terminal, FolderSync, Cable } from 'lucide-react'
|
||||
import type { Tab } from '../../../stores/tabStore'
|
||||
|
||||
interface TabItemProps {
|
||||
tab: Tab
|
||||
index: number
|
||||
isActive: boolean
|
||||
isDragging: boolean
|
||||
dragOverIndex: number | null
|
||||
onActivate: () => void
|
||||
onClose: (e: React.MouseEvent) => void
|
||||
onContextMenu: (e: React.MouseEvent) => void
|
||||
onDragStart: (e: React.DragEvent, index: number) => void
|
||||
onDragOver: (e: React.DragEvent, index: number) => void
|
||||
onDrop: (e: React.DragEvent, index: number) => void
|
||||
onDragEnd: () => void
|
||||
}
|
||||
|
||||
function LocalShellBadge({ kind }: { kind?: string }) {
|
||||
const k = (kind || 'unknown').toLowerCase()
|
||||
if (k === 'zsh') {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-teal-800/90 text-[8px] font-bold text-teal-100 flex-shrink-0 border border-teal-600/40"
|
||||
title="Zsh"
|
||||
>
|
||||
zsh
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (k === 'bash') {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-dark-600 text-emerald-400 flex-shrink-0 border border-dark-500"
|
||||
title="Bash"
|
||||
>
|
||||
<span className="text-[11px] font-bold leading-none">$_</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (k === 'fish') {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-violet-800 text-[8px] font-bold text-violet-100 flex-shrink-0"
|
||||
title="Fish"
|
||||
>
|
||||
fi
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-dark-600 text-[9px] font-bold text-content-secondary flex-shrink-0">
|
||||
sh
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function TabItem({
|
||||
tab,
|
||||
index,
|
||||
isActive,
|
||||
isDragging,
|
||||
dragOverIndex,
|
||||
onActivate,
|
||||
onClose,
|
||||
onContextMenu,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
}: TabItemProps) {
|
||||
const isLocalTerm = tab.type === 'terminal' && tab.isLocal
|
||||
|
||||
const getIcon = () => {
|
||||
if (isLocalTerm) {
|
||||
return <LocalShellBadge kind={tab.localShellKind} />
|
||||
}
|
||||
switch (tab.type) {
|
||||
case 'terminal':
|
||||
return <Terminal className={`w-3.5 h-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
|
||||
case 'sftp':
|
||||
return <FolderSync className={`w-3.5 h-3.5 ${isActive ? 'text-accent-purple' : ''}`} />
|
||||
case 'serial':
|
||||
return <Cable className={`w-3.5 h-3.5 ${isActive ? 'text-accent-green' : ''}`} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const showLeftIndicator = dragOverIndex === index && !isDragging
|
||||
const showRightIndicator = dragOverIndex === index + 1 && !isDragging
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tab-item
|
||||
draggable
|
||||
onClick={onActivate}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragStart={(e) => onDragStart(e, index)}
|
||||
onDragOver={(e) => onDragOver(e, index)}
|
||||
onDrop={(e) => onDrop(e, index)}
|
||||
onDragEnd={onDragEnd}
|
||||
className={`
|
||||
group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[220px]
|
||||
border-r border-dark-700/20 cursor-pointer select-none
|
||||
transition-all duration-150 relative titlebar-no-drag
|
||||
${isActive
|
||||
? 'bg-dark-900 text-content-primary'
|
||||
: 'bg-dark-800 text-txt-muted hover:bg-dark-700 hover:text-txt-muted'
|
||||
}
|
||||
${isDragging ? 'opacity-50' : 'opacity-100'}
|
||||
`}
|
||||
>
|
||||
{showLeftIndicator && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-accent-blue z-10" />
|
||||
)}
|
||||
{showRightIndicator && (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-0.5 bg-accent-blue z-10" />
|
||||
)}
|
||||
|
||||
{isLocalTerm ? (
|
||||
<>
|
||||
{getIcon()}
|
||||
{tab.connectionStatus === 'connected' && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0" />
|
||||
)}
|
||||
{tab.connectionStatus === 'connecting' && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse flex-shrink-0" />
|
||||
)}
|
||||
{tab.connectionStatus === 'error' && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{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" />
|
||||
)}
|
||||
{getIcon()}
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="flex-1 truncate text-sm font-medium">{tab.title}</span>
|
||||
|
||||
<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
|
||||
className={`
|
||||
absolute bottom-0 left-0 right-0 h-0.5 bg-accent-blue
|
||||
transition-[opacity,transform] duration-150 origin-left
|
||||
${isActive ? 'opacity-100 scale-x-100' : 'opacity-0 scale-x-0'}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Cable, RefreshCw } from 'lucide-react'
|
||||
import Modal from '../common/Modal'
|
||||
import { listSerialPorts } from '../../platform/serial'
|
||||
import type {
|
||||
SerialConfig,
|
||||
SerialFlowControl,
|
||||
SerialParity,
|
||||
SerialPortSummary,
|
||||
} from '../../../shared/types/serial.types'
|
||||
|
||||
interface SerialConnectDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConnect: (config: SerialConfig) => void
|
||||
}
|
||||
|
||||
const BAUD_RATES = [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600]
|
||||
|
||||
export default function SerialConnectDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConnect,
|
||||
}: SerialConnectDialogProps) {
|
||||
const [ports, setPorts] = useState<SerialPortSummary[]>([])
|
||||
const [path, setPath] = useState('/dev/tty.debug-console')
|
||||
const [baudRate, setBaudRate] = useState(115200)
|
||||
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8)
|
||||
const [stopBits, setStopBits] = useState<1 | 2>(1)
|
||||
const [parity, setParity] = useState<SerialParity>('none')
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none')
|
||||
const [loadingPorts, setLoadingPorts] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const refreshPorts = useCallback(async () => {
|
||||
setLoadingPorts(true)
|
||||
setError(null)
|
||||
try {
|
||||
const detected = await listSerialPorts()
|
||||
setPorts(detected)
|
||||
setPath((currentPath) =>
|
||||
detected.length > 0 && !detected.some((port) => port.path === currentPath)
|
||||
? detected[0].path
|
||||
: currentPath
|
||||
)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
} finally {
|
||||
setLoadingPorts(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) void refreshPorts()
|
||||
}, [isOpen, refreshPorts])
|
||||
|
||||
const handleConnect = () => {
|
||||
if (!path.trim()) {
|
||||
setError('请输入串口设备路径')
|
||||
return
|
||||
}
|
||||
onConnect({
|
||||
path: path.trim(),
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
parity,
|
||||
flowControl,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onConfirm={handleConnect}
|
||||
title="连接串口"
|
||||
description="原生串口终端 · 适用于路由器、交换机、开发板与调试控制台"
|
||||
icon={<Cable className="w-5 h-5" />}
|
||||
confirmText="打开串口"
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-xl border border-dark-700/60 bg-dark-900/30 p-3.5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="serial-path" className="ui-label mb-0">设备</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshPorts()}
|
||||
className="inline-flex items-center gap-1 text-[11px] text-txt-muted hover:text-content-primary"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${loadingPorts ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
id="serial-path"
|
||||
value={path}
|
||||
onChange={(event) => setPath(event.target.value)}
|
||||
placeholder="/dev/tty.debug-console"
|
||||
className="ui-input font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{ports.length > 0 ? (
|
||||
<div className="grid gap-1.5 max-h-32 overflow-y-auto">
|
||||
{ports.map((port) => (
|
||||
<button
|
||||
key={port.path}
|
||||
type="button"
|
||||
onClick={() => setPath(port.path)}
|
||||
className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg border text-left transition-colors ${
|
||||
path === port.path
|
||||
? 'border-accent-blue/60 bg-accent-blue/10 text-content-primary'
|
||||
: 'border-dark-700/50 hover:bg-dark-700/40 text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-xs truncate">{port.path}</span>
|
||||
<span className="text-[10px] text-txt-faint flex-shrink-0">{port.portType}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-txt-faint">
|
||||
未枚举到设备时可直接输入路径,例如 /dev/tty.debug-console 或
|
||||
/dev/tty.Bluetooth-Incoming-Port。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-2 sm:grid-cols-3 gap-3 rounded-xl border border-dark-700/60 bg-dark-900/30 p-3.5">
|
||||
<div>
|
||||
<label htmlFor="serial-baud" className="ui-label">波特率</label>
|
||||
<select
|
||||
id="serial-baud"
|
||||
value={baudRate}
|
||||
onChange={(event) => setBaudRate(Number(event.target.value))}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
{BAUD_RATES.map((rate) => <option key={rate} value={rate}>{rate}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="serial-data-bits" className="ui-label">数据位</label>
|
||||
<select
|
||||
id="serial-data-bits"
|
||||
value={dataBits}
|
||||
onChange={(event) => setDataBits(Number(event.target.value) as 5 | 6 | 7 | 8)}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
{[5, 6, 7, 8].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="serial-stop-bits" className="ui-label">停止位</label>
|
||||
<select
|
||||
id="serial-stop-bits"
|
||||
value={stopBits}
|
||||
onChange={(event) => setStopBits(Number(event.target.value) as 1 | 2)}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
<option value={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="serial-parity" className="ui-label">校验</label>
|
||||
<select
|
||||
id="serial-parity"
|
||||
value={parity}
|
||||
onChange={(event) => setParity(event.target.value as SerialParity)}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
<option value="none">无</option>
|
||||
<option value="odd">奇校验</option>
|
||||
<option value="even">偶校验</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="serial-flow-control" className="ui-label">流控</label>
|
||||
<select
|
||||
id="serial-flow-control"
|
||||
value={flowControl}
|
||||
onChange={(event) => setFlowControl(event.target.value as SerialFlowControl)}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
<option value="none">无</option>
|
||||
<option value="software">软件 XON/XOFF</option>
|
||||
<option value="hardware">硬件 RTS/CTS</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { Cable, RefreshCw } from 'lucide-react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { themes } from '../../themes/terminal'
|
||||
import { buildTerminalFontFamily } from '../../utils/terminalFont'
|
||||
import {
|
||||
closeSerial,
|
||||
onSerialData,
|
||||
onSerialError,
|
||||
openSerial,
|
||||
writeSerial,
|
||||
} from '../../platform/serial'
|
||||
|
||||
interface SerialTerminalViewProps {
|
||||
tabId: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export default function SerialTerminalView({ tabId, isActive }: SerialTerminalViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const fitRef = useRef<FitAddon | null>(null)
|
||||
const sessionIdRef = useRef<string | null>(null)
|
||||
const { settings } = useSettingsStore()
|
||||
const tab = useTabStore((state) => state.tabs.find((item) => item.id === tabId))
|
||||
const updateTabStatus = useTabStore((state) => state.updateTabStatus)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const config = tab?.serialConfig
|
||||
if (!container || !config) return
|
||||
|
||||
let disposed = false
|
||||
let unlistenData: (() => void) | undefined
|
||||
let unlistenError: (() => void) | undefined
|
||||
const currentTheme = themes[settings.terminalTheme] || themes.dracula
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: buildTerminalFontFamily(settings.fontFamily),
|
||||
fontSize: settings.fontSize,
|
||||
scrollback: settings.scrollback,
|
||||
theme: currentTheme,
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
terminal.loadAddon(fit)
|
||||
terminal.open(container)
|
||||
terminalRef.current = terminal
|
||||
fitRef.current = fit
|
||||
requestAnimationFrame(() => fit.fit())
|
||||
|
||||
const dataDisposable = terminal.onData((data) => {
|
||||
const sessionId = sessionIdRef.current
|
||||
if (sessionId) void writeSerial(sessionId, data)
|
||||
})
|
||||
|
||||
const handleResize = () => fit.fit()
|
||||
window.addEventListener('resize', handleResize)
|
||||
updateTabStatus(tabId, 'connecting')
|
||||
terminal.writeln(`\x1b[36m正在打开 ${config.path} @ ${config.baudRate}...\x1b[0m`)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
unlistenData = await onSerialData((payload) => {
|
||||
if (payload.sessionId === sessionIdRef.current) {
|
||||
terminal.write(new Uint8Array(payload.data))
|
||||
}
|
||||
})
|
||||
unlistenError = await onSerialError((payload) => {
|
||||
if (payload.sessionId === sessionIdRef.current) {
|
||||
setError(payload.message)
|
||||
terminal.writeln(`\r\n\x1b[31m串口错误: ${payload.message}\x1b[0m`)
|
||||
updateTabStatus(tabId, 'error')
|
||||
}
|
||||
})
|
||||
const sessionId = await openSerial(config)
|
||||
if (disposed) {
|
||||
await closeSerial(sessionId)
|
||||
return
|
||||
}
|
||||
sessionIdRef.current = sessionId
|
||||
updateTabStatus(tabId, 'connected')
|
||||
terminal.writeln(`\x1b[32m已连接 ${config.path} · ${config.baudRate} ${config.dataBits}${config.parity === 'none' ? 'N' : config.parity === 'odd' ? 'O' : 'E'}${config.stopBits}\x1b[0m\r\n`)
|
||||
terminal.focus()
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error ? reason.message : String(reason)
|
||||
setError(message)
|
||||
updateTabStatus(tabId, 'error')
|
||||
terminal.writeln(`\r\n\x1b[31m打开串口失败: ${message}\x1b[0m`)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
window.removeEventListener('resize', handleResize)
|
||||
dataDisposable.dispose()
|
||||
unlistenData?.()
|
||||
unlistenError?.()
|
||||
const sessionId = sessionIdRef.current
|
||||
sessionIdRef.current = null
|
||||
if (sessionId) void closeSerial(sessionId)
|
||||
terminal.dispose()
|
||||
terminalRef.current = null
|
||||
fitRef.current = null
|
||||
}
|
||||
}, [settings.fontFamily, settings.fontSize, settings.scrollback, settings.terminalTheme, tab?.serialConfig, tabId, updateTabStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
fitRef.current?.fit()
|
||||
terminalRef.current?.focus()
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-900">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-dark-700/50 bg-dark-800/70 text-xs">
|
||||
<Cable className="w-3.5 h-3.5 text-accent-blue" />
|
||||
<span className="font-mono text-content-secondary truncate">{tab?.serialConfig?.path}</span>
|
||||
<span className="text-txt-faint">{tab?.serialConfig?.baudRate} baud</span>
|
||||
{tab?.connectionStatus === 'connecting' && <RefreshCw className="w-3 h-3 animate-spin text-yellow-400" />}
|
||||
{error && <span className="ml-auto text-red-400 truncate">{error}</span>}
|
||||
</div>
|
||||
<div ref={containerRef} className="flex-1 min-h-0 p-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as SerialConnectDialog } from './SerialConnectDialog'
|
||||
export { default as SerialTerminalView } from './SerialTerminalView'
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircle, CheckCircle2, Clock3, Loader2, Play, TerminalSquare } from 'lucide-react'
|
||||
import type { Session } from '../../stores/sessionStore'
|
||||
import { getSessionConfig } from '../../stores/connection'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import Modal from '../common/Modal'
|
||||
import Button from '../common/Button'
|
||||
|
||||
type BatchCommandStatus = 'idle' | 'running' | 'success' | 'error'
|
||||
|
||||
interface BatchCommandResult {
|
||||
sessionId: string
|
||||
status: BatchCommandStatus
|
||||
output?: string
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface BatchCommandDialogProps {
|
||||
isOpen: boolean
|
||||
sessions: Session[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const MAX_CONCURRENCY = 5
|
||||
|
||||
export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchCommandDialogProps) {
|
||||
const [command, setCommand] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [results, setResults] = useState<BatchCommandResult[]>([])
|
||||
const timeout = useSettingsStore((state) => state.settings.timeout)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setResults(sessions.map((session) => ({ sessionId: session.id, status: 'idle' })))
|
||||
}, [isOpen, sessions])
|
||||
|
||||
const updateResult = useCallback((sessionId: string, updates: Partial<BatchCommandResult>) => {
|
||||
setResults((current) => current.map((result) =>
|
||||
result.sessionId === sessionId ? { ...result, ...updates } : result
|
||||
))
|
||||
}, [])
|
||||
|
||||
const executeForSession = useCallback(async (session: Session, commandText: string) => {
|
||||
const startedAt = Date.now()
|
||||
let connectionId: string | undefined
|
||||
updateResult(session.id, { status: 'running', output: undefined, error: undefined })
|
||||
|
||||
try {
|
||||
const config = await getSessionConfig(session.id)
|
||||
if (!config) throw new Error('无法读取会话配置')
|
||||
|
||||
const connected = await window.opsxApi.sshConnect({
|
||||
...config,
|
||||
timeoutSeconds: config.timeoutSeconds ?? timeout,
|
||||
keepAlive: false,
|
||||
})
|
||||
if (!connected.success || !connected.connectionId) {
|
||||
throw new Error(connected.error || '连接失败')
|
||||
}
|
||||
connectionId = connected.connectionId
|
||||
|
||||
const executed = await window.opsxApi.sshExec(connectionId, commandText)
|
||||
if (!executed.success) throw new Error(executed.error || '命令执行失败')
|
||||
|
||||
updateResult(session.id, {
|
||||
status: 'success',
|
||||
output: executed.result?.trimEnd() || '(无输出)',
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
} catch (error) {
|
||||
updateResult(session.id, {
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
} finally {
|
||||
if (connectionId) {
|
||||
await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}, [timeout, updateResult])
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
const commandText = command.trim()
|
||||
if (!commandText || running || sessions.length === 0) return
|
||||
|
||||
setRunning(true)
|
||||
setResults(sessions.map((session) => ({ sessionId: session.id, status: 'idle' })))
|
||||
|
||||
let nextIndex = 0
|
||||
const worker = async () => {
|
||||
while (nextIndex < sessions.length) {
|
||||
const session = sessions[nextIndex]
|
||||
nextIndex += 1
|
||||
await executeForSession(session, commandText)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(MAX_CONCURRENCY, sessions.length) }, () => worker())
|
||||
)
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}, [command, executeForSession, running, sessions])
|
||||
|
||||
const summary = useMemo(() => ({
|
||||
success: results.filter((result) => result.status === 'success').length,
|
||||
error: results.filter((result) => result.status === 'error').length,
|
||||
}), [results])
|
||||
|
||||
const handleClose = () => {
|
||||
if (!running) onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
title="批量命令"
|
||||
description={`${sessions.length} 台主机 · 最大并发 ${MAX_CONCURRENCY}`}
|
||||
icon={<TerminalSquare className="h-5 w-5" />}
|
||||
onClose={handleClose}
|
||||
showFooter
|
||||
showConfirm={false}
|
||||
disableBackdropClose={running}
|
||||
size="full"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-xs text-txt-muted">
|
||||
{summary.success > 0 && <span className="text-accent-green">成功 {summary.success}</span>}
|
||||
{summary.success > 0 && summary.error > 0 && ' · '}
|
||||
{summary.error > 0 && <span className="text-red-400">失败 {summary.error}</span>}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleClose} disabled={running}>关闭</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Play className="h-3.5 w-3.5" />}
|
||||
onClick={() => void execute()}
|
||||
disabled={!command.trim()}
|
||||
loading={running}
|
||||
>
|
||||
{results.some((result) => result.status === 'success' || result.status === 'error')
|
||||
? '再次执行'
|
||||
: '执行命令'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="batch-command" className="mb-1.5 block text-xs font-medium text-content-secondary">
|
||||
远程命令
|
||||
</label>
|
||||
<textarea
|
||||
id="batch-command"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void execute()
|
||||
}
|
||||
}}
|
||||
disabled={running}
|
||||
rows={3}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder="例如:date"
|
||||
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-sm text-content-primary outline-none transition-colors focus:border-accent-blue disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[48vh] space-y-2 overflow-y-auto pr-1">
|
||||
{sessions.map((session) => {
|
||||
const result = results.find((item) => item.sessionId === session.id)
|
||||
const status = result?.status ?? 'idle'
|
||||
return (
|
||||
<div key={session.id} className="overflow-hidden rounded-lg border border-dark-700 bg-dark-900/65">
|
||||
<div className="flex min-h-10 items-center gap-2 border-b border-dark-700/70 px-3 py-2">
|
||||
{status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-accent-blue" />}
|
||||
{status === 'success' && <CheckCircle2 className="h-4 w-4 text-accent-green" />}
|
||||
{status === 'error' && <AlertCircle className="h-4 w-4 text-red-400" />}
|
||||
{status === 'idle' && <Clock3 className="h-4 w-4 text-txt-faint" />}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content-primary">{session.name}</span>
|
||||
<span className="truncate font-mono text-[11px] text-txt-muted">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
</span>
|
||||
{result?.durationMs !== undefined && (
|
||||
<span className="text-[10px] tabular-nums text-txt-faint">{result.durationMs} ms</span>
|
||||
)}
|
||||
</div>
|
||||
{(result?.output || result?.error) && (
|
||||
<pre className={`max-h-40 overflow-auto whitespace-pre-wrap break-words px-3 py-2 text-xs leading-relaxed ${
|
||||
result.error ? 'text-red-300' : 'text-content-secondary'
|
||||
}`}>
|
||||
{result.error || result.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 快速连接 — 尽量像 ssh 命令一样简单
|
||||
* 支持:host | user@host | user@host:port | host:port
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Zap, ArrowRight, AlertCircle } from 'lucide-react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import Modal from '../common/Modal'
|
||||
import Button from '../common/Button'
|
||||
|
||||
interface QuickConnectDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
initialTarget?: string
|
||||
}
|
||||
|
||||
/** 解析 user@host:port / host:port / host */
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function parseSshTarget(raw: string): {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
} | null {
|
||||
const s = raw.trim()
|
||||
if (!s) return null
|
||||
|
||||
// strip optional ssh://
|
||||
const cleaned = s.replace(/^ssh:\/\//i, '')
|
||||
|
||||
let username = 'root'
|
||||
let hostPort = cleaned
|
||||
|
||||
const at = cleaned.lastIndexOf('@')
|
||||
if (at > 0) {
|
||||
username = cleaned.slice(0, at).trim() || 'root'
|
||||
hostPort = cleaned.slice(at + 1).trim()
|
||||
}
|
||||
|
||||
// IPv6 in brackets [2001:db8::1]:22
|
||||
let host = hostPort
|
||||
let port = 22
|
||||
const bracket = /^\[([^\]]+)\](?::(\d+))?$/.exec(hostPort)
|
||||
if (bracket) {
|
||||
host = bracket[1]
|
||||
if (bracket[2]) port = parseInt(bracket[2], 10) || 22
|
||||
} else {
|
||||
// last :port — avoid splitting bare IPv6 without brackets
|
||||
const colon = hostPort.lastIndexOf(':')
|
||||
if (colon > 0 && hostPort.indexOf(':') === colon) {
|
||||
const maybePort = hostPort.slice(colon + 1)
|
||||
if (/^\d+$/.test(maybePort)) {
|
||||
host = hostPort.slice(0, colon)
|
||||
port = parseInt(maybePort, 10) || 22
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
host = host.trim()
|
||||
if (!host || !username) return null
|
||||
return { host, port, username }
|
||||
}
|
||||
|
||||
export default function QuickConnectDialog({ isOpen, onClose, initialTarget = '' }: QuickConnectDialogProps) {
|
||||
const { addTab } = useTabStore()
|
||||
const [target, setTarget] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && initialTarget) {
|
||||
setTarget(initialTarget)
|
||||
setError(null)
|
||||
}
|
||||
}, [initialTarget, isOpen])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const parsed = parseSshTarget(target)
|
||||
if (!parsed) {
|
||||
setError('请输入主机,例如 root@192.168.1.10 或 opsbase.cn:5555')
|
||||
return
|
||||
}
|
||||
setIsConnecting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
addTab({
|
||||
title: `${parsed.username}@${parsed.host}`,
|
||||
type: 'terminal',
|
||||
connectionConfig: {
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
username: parsed.username,
|
||||
password: password || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
onClose()
|
||||
setTarget('')
|
||||
setPassword('')
|
||||
} catch (err: unknown) {
|
||||
setError((err instanceof Error ? err.message : String(err)) || '连接失败')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="快速连接"
|
||||
description="粘贴 user@host:port 即可,不必拆成多个框"
|
||||
icon={<Zap className="h-5 w-5" />}
|
||||
size="md"
|
||||
showFooter={false}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void handleSubmit()
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="ui-label">目标</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder="root@192.168.1.10:22"
|
||||
className="ui-input font-mono"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-txt-faint">支持 host、user@host、user@host:port</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ui-label">
|
||||
密码 <span className="font-normal text-txt-faint">可选 · 用密钥可留空</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="ui-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2.5 text-sm text-red-300">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-dark-700/50 flex justify-end gap-2.5 border-t pt-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose} disabled={isConnecting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={isConnecting}
|
||||
icon={<ArrowRight className="h-3.5 w-3.5" />}
|
||||
>
|
||||
连接
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Cable, Plus, Search, Server, Terminal } from 'lucide-react'
|
||||
import Modal from '../common/Modal'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import {
|
||||
listLocalShells,
|
||||
type AvailableLocalShell,
|
||||
} from '../../platform/localTerminal'
|
||||
|
||||
interface QuickLauncherProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onNewSession: () => void
|
||||
onQuickConnect: () => void
|
||||
onSerialConnect: () => void
|
||||
}
|
||||
|
||||
export default function QuickLauncher({
|
||||
isOpen,
|
||||
onClose,
|
||||
onNewSession,
|
||||
onQuickConnect,
|
||||
onSerialConnect,
|
||||
}: QuickLauncherProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [localShells, setLocalShells] = useState<AvailableLocalShell[]>([])
|
||||
const sessions = useSessionStore((state) => state.sessions)
|
||||
const addTab = useTabStore((state) => state.addTab)
|
||||
const filteredSessions = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (!normalized) return sessions.slice(0, 8)
|
||||
return sessions
|
||||
.filter((session) =>
|
||||
[session.name, session.host, session.username, session.group, ...(session.tags ?? [])]
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.some((value) => value.toLowerCase().includes(normalized))
|
||||
)
|
||||
.slice(0, 8)
|
||||
}, [query, sessions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
let disposed = false
|
||||
void listLocalShells().then((shells) => {
|
||||
if (!disposed) setLocalShells(shells)
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const run = (action: () => void) => {
|
||||
action()
|
||||
onClose()
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="快速启动"
|
||||
description="会话、本地 Shell 与设备控制台统一入口"
|
||||
showFooter={false}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-txt-faint" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索主机、分组或标签页"
|
||||
className="ui-input pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button type="button" onClick={() => run(onQuickConnect)} className="launcher-card">
|
||||
<Server className="w-4 h-4 text-accent-blue" />
|
||||
<span><strong>快速连接</strong><small>user@host:port</small></span>
|
||||
</button>
|
||||
<button type="button" onClick={() => run(onNewSession)} className="launcher-card">
|
||||
<Plus className="w-4 h-4 text-accent-green" />
|
||||
<span><strong>保存会话</strong><small>凭据、分组、代理</small></span>
|
||||
</button>
|
||||
<button type="button" onClick={() => run(onSerialConnect)} className="launcher-card">
|
||||
<Cable className="w-4 h-4 text-accent-purple" />
|
||||
<span><strong>串口终端</strong><small>波特率与串口参数</small></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className="px-1 mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-txt-faint">本地 Shell</div>
|
||||
<div className="space-y-1">
|
||||
{(localShells.length > 0
|
||||
? localShells.slice(0, 4)
|
||||
: [{ path: '/bin/zsh', name: 'Zsh', kind: 'zsh', isDefault: true }]
|
||||
).map((shell) => (
|
||||
<button
|
||||
key={shell.path}
|
||||
type="button"
|
||||
onClick={() => run(() => addTab({
|
||||
title: shell.name,
|
||||
type: 'terminal',
|
||||
isLocal: true,
|
||||
localShellPath: shell.path,
|
||||
localShellKind: shell.kind,
|
||||
}))}
|
||||
className="launcher-row"
|
||||
>
|
||||
<Terminal className="w-4 h-4 text-emerald-400" />
|
||||
<span className="flex-1 text-left"><strong>{shell.name}</strong><small>{shell.path}</small></span>
|
||||
{shell.isDefault && <span className="launcher-badge">系统默认</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{filteredSessions.length > 0 && (
|
||||
<section>
|
||||
<div className="px-1 mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-txt-faint">保存会话</div>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1">
|
||||
{filteredSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => run(() => addTab({ title: session.name, type: 'terminal', sessionId: session.id }))}
|
||||
className="launcher-row"
|
||||
>
|
||||
<Server className="w-4 h-4 text-accent-blue" />
|
||||
<span className="flex-1 min-w-0 text-left">
|
||||
<strong className="truncate">{session.name}</strong>
|
||||
<small className="truncate">{session.username}@{session.host}:{session.port || 22}</small>
|
||||
</span>
|
||||
<span className="launcher-badge">{session.group || 'default'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
/**
|
||||
* Session editor — right drawer (basic fields first, advanced collapsed)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, type ComponentType } from 'react'
|
||||
import {
|
||||
X, Server, Key, Lock, Tag, Folder, ChevronDown, Check,
|
||||
} from 'lucide-react'
|
||||
import { useSessionStore, Session } from '../../stores'
|
||||
import type { AuthType, ProxyType } from '../../../shared/types/session.types'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
import { parseSshTarget } from './QuickConnectDialog'
|
||||
|
||||
const logger = createLogger('SessionDialog')
|
||||
|
||||
function targetIncludesPort(raw: string): boolean {
|
||||
const cleaned = raw.trim().replace(/^ssh:\/\//i, '')
|
||||
const hostPort = cleaned.slice(cleaned.lastIndexOf('@') + 1)
|
||||
if (/^\[[^\]]+\]:\d+$/.test(hostPort)) return true
|
||||
const firstColon = hostPort.indexOf(':')
|
||||
return firstColon > 0 && firstColon === hostPort.lastIndexOf(':') && /:\d+$/.test(hostPort)
|
||||
}
|
||||
|
||||
interface SessionDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
editSession?: Session | null
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password' as AuthType,
|
||||
password: '',
|
||||
privateKey: '~/.ssh/id_rsa',
|
||||
passphrase: '',
|
||||
proxy: {
|
||||
type: 'none' as ProxyType,
|
||||
host: '',
|
||||
port: 0,
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
group: 'default',
|
||||
tags: '',
|
||||
startupCommand: '',
|
||||
jumpSessionIds: [] as string[],
|
||||
}
|
||||
|
||||
export default function SessionDialog({ isOpen, onClose, editSession }: SessionDialogProps) {
|
||||
const { addSession, updateSession, sessions } = useSessionStore()
|
||||
const [formData, setFormData] = useState(emptyForm)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const isEditing = !!editSession
|
||||
const jumpCandidates = sessions.filter((session) => session.id !== editSession?.id)
|
||||
/** 一行目标:user@host:port */
|
||||
const [target, setTarget] = useState('root@')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (editSession) {
|
||||
const hasAdvanced =
|
||||
!!(editSession.proxy && editSession.proxy.type !== 'none') ||
|
||||
!!(editSession.jumpSessionId || (editSession.jumpSessionIds && editSession.jumpSessionIds.length)) ||
|
||||
!!editSession.startupCommand ||
|
||||
(!!editSession.group && editSession.group !== 'default') ||
|
||||
!!(editSession.tags && editSession.tags.length)
|
||||
const u = editSession.username || 'root'
|
||||
const h = editSession.host || ''
|
||||
setTarget(`${u}@${h}`)
|
||||
setFormData({
|
||||
name: editSession.name || '',
|
||||
host: editSession.host || '',
|
||||
port: editSession.port || 22,
|
||||
username: editSession.username || '',
|
||||
authType: editSession.authType === 'agent' ? 'key' : editSession.authType || 'password',
|
||||
password: editSession.password || '',
|
||||
privateKey: editSession.privateKey || '~/.ssh/id_rsa',
|
||||
passphrase: editSession.passphrase || '',
|
||||
proxy: editSession.proxy
|
||||
? {
|
||||
type: editSession.proxy.type as ProxyType,
|
||||
host: editSession.proxy.host,
|
||||
port: editSession.proxy.port,
|
||||
username: editSession.proxy.username || '',
|
||||
password: editSession.proxy.password || '',
|
||||
}
|
||||
: { type: 'none', host: '', port: 0, username: '', password: '' },
|
||||
group: editSession.group || 'default',
|
||||
tags: editSession.tags?.join(', ') || '',
|
||||
startupCommand: editSession.startupCommand || '',
|
||||
jumpSessionIds:
|
||||
editSession.jumpSessionIds && editSession.jumpSessionIds.length > 0
|
||||
? [...editSession.jumpSessionIds]
|
||||
: editSession.jumpSessionId
|
||||
? [editSession.jumpSessionId]
|
||||
: [],
|
||||
})
|
||||
setShowAdvanced(hasAdvanced)
|
||||
} else {
|
||||
setFormData({ ...emptyForm, username: 'root', privateKey: '~/.ssh/id_rsa' })
|
||||
setTarget('root@')
|
||||
setShowAdvanced(false)
|
||||
}
|
||||
}, [editSession, isOpen])
|
||||
|
||||
const applyTarget = (raw: string) => {
|
||||
setTarget(raw)
|
||||
const parsed = parseSshTarget(raw)
|
||||
if (parsed) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
host: parsed.host,
|
||||
port: targetIncludesPort(raw) ? parsed.port : prev.port,
|
||||
username: parsed.username,
|
||||
name:
|
||||
prev.name.trim() && !prev.name.includes('@')
|
||||
? prev.name
|
||||
: `${parsed.username}@${parsed.host}`,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ESC close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const proxyReady =
|
||||
formData.proxy.type === 'none' ||
|
||||
(formData.proxy.host.trim().length > 0 &&
|
||||
formData.proxy.port > 0 &&
|
||||
formData.proxy.port <= 65535)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const parsed = parseSshTarget(target)
|
||||
if (!parsed?.host) {
|
||||
logger.error('Invalid SSH target')
|
||||
return
|
||||
}
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const proxyConfig =
|
||||
formData.proxy.type !== 'none' && formData.proxy.host
|
||||
? {
|
||||
type: formData.proxy.type,
|
||||
host: formData.proxy.host,
|
||||
port: formData.proxy.port || (formData.proxy.type === 'http' ? 8080 : 1080),
|
||||
username: formData.proxy.username || undefined,
|
||||
password: formData.proxy.password || undefined,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const sessionData = {
|
||||
name: formData.name.trim() || `${parsed.username}@${parsed.host}`,
|
||||
host: parsed.host,
|
||||
port: formData.port || 22,
|
||||
username: parsed.username || 'root',
|
||||
authType: formData.authType,
|
||||
password: formData.authType === 'password' ? formData.password || undefined : undefined,
|
||||
privateKey:
|
||||
formData.authType === 'key'
|
||||
? formData.privateKey.trim() || '~/.ssh/id_rsa'
|
||||
: undefined,
|
||||
passphrase:
|
||||
formData.authType === 'key' ? formData.passphrase || undefined : undefined,
|
||||
proxy: proxyConfig,
|
||||
startupCommand: formData.startupCommand.trim() || undefined,
|
||||
jumpSessionIds:
|
||||
formData.jumpSessionIds.filter(Boolean).length > 0
|
||||
? formData.jumpSessionIds.filter(Boolean)
|
||||
: undefined,
|
||||
jumpSessionId: formData.jumpSessionIds.filter(Boolean)[0] || undefined,
|
||||
group: formData.group || 'default',
|
||||
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).slice(2, 9)}`,
|
||||
} as Session)
|
||||
}
|
||||
onClose()
|
||||
} catch (error) {
|
||||
logger.error(isEditing ? 'Failed to update session:' : 'Failed to create session:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const authTypes: {
|
||||
value: AuthType
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}[] = [
|
||||
{ value: 'password', label: '密码', icon: Lock },
|
||||
{ value: 'key', label: '密钥', icon: Key },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-end">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/45 backdrop-blur-[1px] animate-fade-in"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<aside
|
||||
className="relative w-full max-w-[400px] h-full bg-dark-800 border-l border-dark-700/70 shadow-2xl flex flex-col animate-slide-in-right"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="session-drawer-title"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-dark-700/50 flex-shrink-0">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent-blue/15 border border-accent-blue/20 flex items-center justify-center text-accent-blue">
|
||||
<Server className="w-4.5 h-4.5 w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="session-drawer-title" className="text-sm font-semibold text-content-primary">
|
||||
{isEditing ? '编辑会话' : '新建会话'}
|
||||
</h2>
|
||||
<p className="text-[11px] text-txt-faint mt-0.5">目标一行搞定 · 高级可折叠</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-txt-muted hover:text-content-primary hover:bg-dark-700"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
{/* 基本:名称 + 目标 */}
|
||||
<section className="rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5 space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
基本
|
||||
</div>
|
||||
<div>
|
||||
<label className="ui-label">显示名称(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus={!isEditing}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="自动用 user@host"
|
||||
className="ui-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_88px] gap-2">
|
||||
<div>
|
||||
<label htmlFor="session-target" className="ui-label">目标</label>
|
||||
<input
|
||||
id="session-target"
|
||||
type="text"
|
||||
required
|
||||
value={target}
|
||||
onChange={(e) => applyTarget(e.target.value)}
|
||||
placeholder="root@192.168.1.10"
|
||||
className="ui-input font-mono"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="session-port" className="ui-label">端口</label>
|
||||
<input
|
||||
id="session-port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
required
|
||||
value={formData.port || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
port: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
onBlur={() => {
|
||||
if (formData.port < 1 || formData.port > 65535) {
|
||||
setFormData({ ...formData, port: 22 })
|
||||
}
|
||||
}}
|
||||
className="ui-input font-mono"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-txt-faint">
|
||||
支持 user@host · host;目标中填写端口时会自动同步
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 凭据 */}
|
||||
<section className="rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5 space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
登录
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5 p-1 rounded-xl bg-dark-900/60 border border-dark-700/40">
|
||||
{authTypes.map((type) => {
|
||||
const Icon = type.icon
|
||||
const active = formData.authType === type.value
|
||||
return (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, authType: type.value })}
|
||||
className={`flex items-center justify-center gap-1 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-dark-700 text-content-primary shadow-sm'
|
||||
: 'text-txt-muted hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{type.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{formData.authType === 'password' && (
|
||||
<div>
|
||||
<label htmlFor="session-password" className="ui-label">密码</label>
|
||||
<input
|
||||
id="session-password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="ui-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.authType === 'key' && (
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<label htmlFor="session-private-key" className="ui-label">私钥路径</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
id="session-private-key"
|
||||
type="text"
|
||||
value={formData.privateKey}
|
||||
onChange={(e) => setFormData({ ...formData, privateKey: e.target.value })}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
className="ui-input flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2.5 text-xs rounded-lg bg-dark-700 border border-dark-600 text-content-secondary hover:bg-dark-600"
|
||||
onClick={async () => {
|
||||
const result = await window.opsxApi?.showOpenDialog({
|
||||
title: '选择私钥文件',
|
||||
filters: [{ name: '所有文件', extensions: ['*'] }],
|
||||
})
|
||||
if (!result?.canceled && result?.filePaths?.[0]) {
|
||||
setFormData({ ...formData, privateKey: result.filePaths[0] })
|
||||
}
|
||||
}}
|
||||
>
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="session-passphrase" className="ui-label">密钥口令(可选)</label>
|
||||
<input
|
||||
id="session-passphrase"
|
||||
type="password"
|
||||
value={formData.passphrase}
|
||||
onChange={(e) => setFormData({ ...formData, passphrase: e.target.value })}
|
||||
placeholder="可选"
|
||||
className="ui-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</section>
|
||||
|
||||
{/* 高级 */}
|
||||
<div className="rounded-xl border border-dark-700/50 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-3.5 py-3 text-left bg-dark-900/20 hover:bg-dark-900/40 transition-colors"
|
||||
>
|
||||
<span className="text-xs font-semibold text-content-secondary">高级选项</span>
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-txt-faint">
|
||||
{showAdvanced ? '收起' : '代理 · 跳板 · 分组'}
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-transform ${showAdvanced ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="px-3.5 pb-3.5 space-y-3 border-t border-dark-700/40 bg-dark-900/20">
|
||||
<div className="grid grid-cols-2 gap-2 pt-3">
|
||||
<div>
|
||||
<label className="ui-label">
|
||||
<Folder className="inline w-3 h-3 mr-0.5" />
|
||||
分组
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.group}
|
||||
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
|
||||
placeholder="default"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ui-label">
|
||||
<Tag className="inline w-3 h-3 mr-0.5" />
|
||||
标签
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.tags}
|
||||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||||
placeholder="生产, web"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ui-label">启动命令(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.startupCommand}
|
||||
onChange={(e) => setFormData({ ...formData, startupCommand: e.target.value })}
|
||||
placeholder="连接后自动执行,如 cd /var/log"
|
||||
className="ui-input-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ui-label">代理</label>
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(
|
||||
[
|
||||
{ value: 'none', label: '无' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
] as const
|
||||
).map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: {
|
||||
...formData.proxy,
|
||||
type: t.value,
|
||||
port:
|
||||
formData.proxy.port ||
|
||||
(t.value === 'http' ? 8080 : t.value === 'socks5' ? 1080 : 0),
|
||||
},
|
||||
jumpSessionIds: t.value === 'none' ? formData.jumpSessionIds : [],
|
||||
})
|
||||
}
|
||||
className={`flex-1 py-1.5 rounded-lg text-[11px] border transition-colors ${
|
||||
formData.proxy.type === t.value
|
||||
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
|
||||
: 'border-dark-600 text-txt-muted hover:border-dark-500'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{formData.proxy.type !== 'none' && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.proxy.host}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, host: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="代理地址"
|
||||
className="ui-input-sm col-span-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.proxy.port || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: {
|
||||
...formData.proxy,
|
||||
port: parseInt(e.target.value) || 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="端口"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.proxy.username}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, username: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="用户名(可选)"
|
||||
autoComplete="off"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.proxy.password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, password: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="密码(可选)"
|
||||
autoComplete="new-password"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="session-jump-host" className="ui-label">跳板机(可选)</label>
|
||||
<select
|
||||
id="session-jump-host"
|
||||
value={formData.jumpSessionIds[0] || ''}
|
||||
onChange={(event) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
jumpSessionIds: event.target.value ? [event.target.value] : [],
|
||||
proxy: event.target.value
|
||||
? { type: 'none', host: '', port: 0, username: '', password: '' }
|
||||
: formData.proxy,
|
||||
})
|
||||
}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
<option value="">不使用跳板</option>
|
||||
{jumpCandidates.map((session) => (
|
||||
<option key={session.id} value={session.id}>
|
||||
{session.name} ({session.username}@{session.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-[10px] text-txt-faint">当前支持一个已保存会话作为跳板</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-dark-700/50 flex-shrink-0 bg-dark-800">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !parseSshTarget(target)?.host || !proxyReady}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-accent-blue hover:bg-accent-blue-hover disabled:opacity-45 text-white text-sm font-medium transition-colors shadow-sm shadow-accent-blue/25"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
'保存中…'
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
{isEditing ? '保存' : '创建并保存'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Terminal, Plus, Zap, Clock, Server, ChevronRight } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
import LocalShellMenu from '../layout/sidebar/LocalShellMenu'
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onNewSession?: () => void
|
||||
onQuickConnect?: () => void
|
||||
}
|
||||
|
||||
export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeViewProps) {
|
||||
const { addTab } = useTabStore()
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const recentSessions = sessions.slice(-6).reverse()
|
||||
|
||||
const handleSessionClick = (session: (typeof sessions)[number]) => {
|
||||
addTab({
|
||||
title: session.name || session.host,
|
||||
type: 'terminal',
|
||||
sessionId: session.id,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center bg-dark-900 overflow-y-auto">
|
||||
<div className="w-full max-w-3xl px-6 py-10 sm:py-12">
|
||||
{/* Hero — 短文案 */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-accent-blue/30 to-accent-purple/20 border border-dark-600/60 flex items-center justify-center shadow-lg shadow-black/20 flex-shrink-0">
|
||||
<Terminal className="w-7 h-7 text-content-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-content-primary tracking-tight">OpsXShell</h1>
|
||||
<p className="text-sm text-txt-muted mt-0.5">SSH / SFTP 跨平台运维工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主路径:快速连接 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQuickConnect?.()}
|
||||
className="
|
||||
group w-full flex items-center gap-4 p-5 mb-3 rounded-2xl text-left
|
||||
bg-accent-blue hover:bg-blue-500 border border-blue-400/30
|
||||
shadow-lg shadow-accent-blue/10 transition-all duration-150
|
||||
"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-white/15 flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-white text-base">快速连接</div>
|
||||
<div className="text-sm text-blue-100/80 mt-0.5">粘贴 user@host:port,一步连上</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-white/70 group-hover:text-white group-hover:translate-x-0.5 transition-all flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{/* 次路径:保存常用会话 */}
|
||||
<div className="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewSession?.()}
|
||||
className="
|
||||
group flex items-center gap-3 p-4 rounded-2xl text-left
|
||||
bg-dark-800/70 border border-dark-700/50 hover:border-accent-green/40
|
||||
hover:bg-dark-800 transition-all duration-150
|
||||
"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-green/15 border border-accent-green/20 flex items-center justify-center flex-shrink-0 group-hover:bg-accent-green/25">
|
||||
<Plus className="w-5 h-5 text-accent-green" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-content-primary text-sm">新建会话</div>
|
||||
<div className="text-xs text-txt-muted mt-0.5">保存后侧栏双击即开终端</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 本地 Shell — 降级为次要入口 */}
|
||||
<div className="mb-10 opacity-90">
|
||||
<LocalShellMenu />
|
||||
</div>
|
||||
|
||||
{/* 最近会话 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5 text-txt-faint" />
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-txt-faint">最近会话</h3>
|
||||
</div>
|
||||
{recentSessions.length > 0 && (
|
||||
<span className="text-[11px] text-txt-faint">{recentSessions.length} 个</span>
|
||||
)}
|
||||
</div>
|
||||
{recentSessions.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-dark-600/60 bg-dark-800/30 p-6">
|
||||
<EmptyState icon={Server} title="暂无会话" description="创建会话或快速连接开始运维" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/40 overflow-hidden divide-y divide-dark-700/40">
|
||||
{recentSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => handleSessionClick(session)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-700/40 transition-colors text-left group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-dark-700 flex items-center justify-center flex-shrink-0 group-hover:bg-accent-blue/20">
|
||||
<Server className="w-4 h-4 text-accent-blue" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-content-primary truncate">
|
||||
{session.name}
|
||||
</div>
|
||||
<div className="text-xs text-txt-faint font-mono truncate">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
</div>
|
||||
</div>
|
||||
{session.group && (
|
||||
<span className="ui-chip flex-shrink-0">{session.group}</span>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-txt-faint opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Sessions — 会话管理相关 UI
|
||||
*/
|
||||
export { default as SessionDialog } from './SessionDialog'
|
||||
export { default as QuickConnectDialog } from './QuickConnectDialog'
|
||||
export { default as WelcomeView } from './WelcomeView'
|
||||
export { default as QuickLauncher } from './QuickLauncher'
|
||||
@@ -0,0 +1,391 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, CheckCircle2, ChevronDown, KeyRound, Loader2, Plus, Save, Trash2, Wifi } from 'lucide-react'
|
||||
import type { AiModelConfig, AiProviderConfig } from '../../../shared/types/ai.types'
|
||||
import {
|
||||
AI_PROVIDER_PRESETS,
|
||||
createCustomProvider,
|
||||
createProviderFromPreset,
|
||||
} from '../../ai/providerPresets'
|
||||
import { useAiProviderStore } from '../../stores/aiProviderStore'
|
||||
|
||||
type TestState = 'idle' | 'testing' | 'success' | 'error'
|
||||
|
||||
function selectedModel(provider: AiProviderConfig | null): AiModelConfig | null {
|
||||
if (!provider) return null
|
||||
return provider.models.find((model) => model.id === provider.defaultModelId) || null
|
||||
}
|
||||
|
||||
function connectionSnapshot(provider: AiProviderConfig, apiKey: string): string {
|
||||
const model = selectedModel(provider)
|
||||
return JSON.stringify({
|
||||
id: provider.id,
|
||||
name: provider.name.trim(),
|
||||
protocol: provider.protocol,
|
||||
authStyle: provider.authStyle,
|
||||
baseUrl: provider.baseUrl.trim(),
|
||||
allowInsecureHttp: provider.allowInsecureHttp,
|
||||
model,
|
||||
credential: apiKey.trim() ? `entered:${apiKey.trim()}` : provider.hasApiKey ? 'stored' : 'missing',
|
||||
})
|
||||
}
|
||||
|
||||
function validDraft(provider: AiProviderConfig | null, apiKey: string): boolean {
|
||||
if (!provider?.name.trim() || !provider.baseUrl.trim() || !selectedModel(provider)?.model.trim()) return false
|
||||
return provider.authStyle === 'none' || provider.hasApiKey === true || apiKey.trim().length > 0
|
||||
}
|
||||
|
||||
export default function AiProviderSettings() {
|
||||
const store = useAiProviderStore()
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [presetId, setPresetId] = useState('deepseek')
|
||||
const [draft, setDraft] = useState<AiProviderConfig | null>(null)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [manualModel, setManualModel] = useState(false)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [testState, setTestState] = useState<TestState>('idle')
|
||||
const [verifiedSnapshot, setVerifiedSnapshot] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState('')
|
||||
const [testAnswer, setTestAnswer] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void store.load()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId && store.providers[0]) setSelectedId(store.providers[0].id)
|
||||
}, [selectedId, store.providers])
|
||||
|
||||
useEffect(() => {
|
||||
const provider = store.providers.find((item) => item.id === selectedId)
|
||||
if (!provider) return
|
||||
setDraft(structuredClone(provider))
|
||||
setApiKey('')
|
||||
setManualModel(provider.models.length === 0)
|
||||
setTestState('idle')
|
||||
setVerifiedSnapshot(null)
|
||||
setMessage('')
|
||||
setTestAnswer('')
|
||||
}, [selectedId, store.providers])
|
||||
|
||||
const currentSnapshot = useMemo(() => (draft ? connectionSnapshot(draft, apiKey) : ''), [apiKey, draft])
|
||||
const verified = !!currentSnapshot && verifiedSnapshot === currentSnapshot && testState === 'success'
|
||||
const currentModel = selectedModel(draft)
|
||||
const isStored = !!draft && store.providers.some((provider) => provider.id === draft.id)
|
||||
const canTest = validDraft(draft, apiKey) && testState !== 'testing'
|
||||
|
||||
const chooseDraft = (provider: AiProviderConfig) => {
|
||||
setSelectedId(provider.id)
|
||||
setDraft(provider)
|
||||
setApiKey('')
|
||||
setManualModel(provider.models.length === 0)
|
||||
setAdvancedOpen(false)
|
||||
setTestState('idle')
|
||||
setVerifiedSnapshot(null)
|
||||
setMessage('请填写配置并测试连接;测试通过前不会保存任何内容')
|
||||
setTestAnswer('')
|
||||
}
|
||||
|
||||
const addPreset = () => chooseDraft(createProviderFromPreset(presetId))
|
||||
const addCustom = () => chooseDraft(createCustomProvider())
|
||||
|
||||
const updateDraft = (changes: Partial<AiProviderConfig>) => {
|
||||
if (!draft) return
|
||||
setDraft({ ...draft, ...changes })
|
||||
if (testState !== 'idle') setMessage('连接字段已变更,请重新测试')
|
||||
}
|
||||
|
||||
const chooseModel = (modelId: string) => {
|
||||
if (!draft) return
|
||||
if (modelId === '__manual') {
|
||||
setManualModel(true)
|
||||
return
|
||||
}
|
||||
setManualModel(false)
|
||||
updateDraft({ defaultModelId: modelId })
|
||||
}
|
||||
|
||||
const updateManualModel = (modelId: string) => {
|
||||
if (!draft) return
|
||||
const active = selectedModel(draft)
|
||||
if (active) {
|
||||
const models = draft.models.map((model) =>
|
||||
model.id === active.id ? { ...model, model: modelId, label: modelId || '自定义模型' } : model
|
||||
)
|
||||
updateDraft({ models })
|
||||
return
|
||||
}
|
||||
const id = `manual-${Date.now().toString(36)}`
|
||||
updateDraft({
|
||||
models: [{ id, model: modelId, label: modelId || '自定义模型', capability: 'chat', billing: 'payg' }],
|
||||
defaultModelId: id,
|
||||
})
|
||||
}
|
||||
|
||||
const testConnection = async () => {
|
||||
if (!draft || !currentModel || !canTest) return
|
||||
setTestState('testing')
|
||||
setVerifiedSnapshot(null)
|
||||
setMessage('正在发送最小请求验证 Endpoint、模型和凭据...')
|
||||
setTestAnswer('')
|
||||
const snapshot = currentSnapshot
|
||||
try {
|
||||
const result = await window.opsxApi.aiTestProvider({
|
||||
provider: draft,
|
||||
model: currentModel,
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
})
|
||||
if (!result.success) throw new Error(result.error || '连接测试失败')
|
||||
setTestState('success')
|
||||
setVerifiedSnapshot(snapshot)
|
||||
setTestAnswer(result.answer?.trim() || '')
|
||||
setMessage(`测试成功 · HTTP ${result.status ?? 200} · ${result.model || currentModel.model} · ${result.latencyMs ?? '-'} ms`)
|
||||
} catch (error) {
|
||||
setTestState('error')
|
||||
setMessage(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!draft || !verified) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await store.saveProvider(draft, apiKey)
|
||||
if (!store.activeProviderId || !isStored) await store.setActiveProvider(draft.id)
|
||||
setApiKey('')
|
||||
setMessage('配置已安全保存,并可用于终端助手')
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error))
|
||||
setTestState('error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
if (!draft) return
|
||||
if (isStored) await store.deleteProvider(draft.id)
|
||||
setDraft(null)
|
||||
setSelectedId('')
|
||||
setApiKey('')
|
||||
setVerifiedSnapshot(null)
|
||||
setMessage('')
|
||||
setTestAnswer('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="border-dark-700/50 bg-dark-800/45 rounded-xl border p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
aria-label="供应商预设"
|
||||
value={presetId}
|
||||
onChange={(event) => setPresetId(event.target.value)}
|
||||
className="ui-input-sm min-w-52 flex-1"
|
||||
>
|
||||
{AI_PROVIDER_PRESETS.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>
|
||||
{preset.name} · {preset.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={addPreset} className="ui-btn-primary flex items-center gap-1.5">
|
||||
<Plus className="h-3.5 w-3.5" /> 添加
|
||||
</button>
|
||||
<button type="button" onClick={addCustom} className="ui-btn-secondary">
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-txt-muted">
|
||||
新配置只在当前表单中暂存。真实请求测试成功后才可保存;API Key 仅由 Rust 加密存储。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="border-dark-700/50 bg-dark-800/45 grid min-h-[440px] grid-cols-[180px_minmax(0,1fr)] overflow-hidden rounded-xl border">
|
||||
<aside className="border-dark-700/50 border-r p-2">
|
||||
<div className="px-2 pb-2 text-[11px] font-medium text-txt-faint">已配置</div>
|
||||
{store.providers.length === 0 ? (
|
||||
<p className="px-2 py-4 text-xs leading-relaxed text-txt-faint">尚无配置,从上方选择供应商添加。</p>
|
||||
) : (
|
||||
store.providers.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(provider.id)}
|
||||
className={`mb-1 w-full rounded-lg px-3 py-2 text-left ${selectedId === provider.id ? 'bg-accent-blue/15 text-accent-blue' : 'text-content-secondary hover:bg-dark-700/60'}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{store.activeProviderId === provider.id && <Check className="h-3 w-3 flex-shrink-0" />}
|
||||
<span className="truncate text-sm font-medium">{provider.name}</span>
|
||||
</div>
|
||||
<div className={`mt-0.5 truncate text-[10px] ${provider.hasApiKey || provider.authStyle === 'none' ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
{provider.models.find((model) => model.id === provider.defaultModelId)?.label || '未选择模型'}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 p-5">
|
||||
{!draft ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-faint">选择已有配置,或添加新供应商</div>
|
||||
) : (
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="供应商">
|
||||
<input value={draft.name} onChange={(event) => updateDraft({ name: event.target.value })} className="ui-input-sm" />
|
||||
</Field>
|
||||
<Field label="模型">
|
||||
{manualModel || draft.models.length === 0 ? (
|
||||
<input
|
||||
aria-label="模型"
|
||||
value={currentModel?.model || ''}
|
||||
onChange={(event) => updateManualModel(event.target.value)}
|
||||
placeholder="例如 deepseek-chat"
|
||||
className="ui-input-sm font-mono"
|
||||
/>
|
||||
) : (
|
||||
<select value={draft.defaultModelId} onChange={(event) => chooseModel(event.target.value)} className="ui-input-sm">
|
||||
{draft.models.map((model) => (
|
||||
<option key={model.id} value={model.id}>{model.label || model.model}</option>
|
||||
))}
|
||||
<option value="__manual">手动输入模型...</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{manualModel && draft.models.length > 0 && (
|
||||
<button type="button" className="text-xs text-accent-blue hover:underline" onClick={() => setManualModel(false)}>
|
||||
返回预设模型列表
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Field label="Endpoint">
|
||||
<input
|
||||
value={draft.baseUrl}
|
||||
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="ui-input-sm font-mono"
|
||||
/>
|
||||
</Field>
|
||||
{currentModel?.baseUrl && (
|
||||
<p className="-mt-2 text-[10px] text-txt-faint">当前模型使用专用 Endpoint:{currentModel.baseUrl}</p>
|
||||
)}
|
||||
|
||||
<Field label={draft.authStyle === 'none' ? 'API Key(无需填写)' : 'API Key'}>
|
||||
<div className="relative">
|
||||
<KeyRound className="absolute left-2.5 top-2 h-3.5 w-3.5 text-txt-faint" />
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
disabled={draft.authStyle === 'none'}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={draft.hasApiKey ? '已保存;留空使用现有密钥' : draft.authStyle === 'none' ? '本地服务无需认证' : 'sk-...'}
|
||||
className="ui-input-sm !pl-8"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
className="flex items-center gap-1 text-xs text-txt-muted hover:text-content-secondary"
|
||||
>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
|
||||
高级设置
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="border-dark-700/40 bg-dark-900/30 grid grid-cols-2 gap-3 rounded-lg border p-3">
|
||||
<Field label="接口协议">
|
||||
<select
|
||||
value={draft.protocol}
|
||||
onChange={(event) => updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
<option value="openai-chat">OpenAI Chat</option>
|
||||
<option value="openai-responses">OpenAI Responses</option>
|
||||
<option value="anthropic">Anthropic Messages</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="认证方式">
|
||||
<select
|
||||
value={draft.authStyle}
|
||||
onChange={(event) => updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
<option value="bearer">Bearer Token</option>
|
||||
<option value="x-api-key">x-api-key</option>
|
||||
<option value="both">Bearer + x-api-key</option>
|
||||
<option value="none">无需认证</option>
|
||||
</select>
|
||||
</Field>
|
||||
<label className="col-span-2 flex items-start gap-2 text-xs text-txt-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.allowInsecureHttp}
|
||||
onChange={(event) => updateDraft({ allowInsecureHttp: event.target.checked })}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
允许 HTTP(只用于可信本机或内网模型服务)
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-dark-700/40 flex flex-wrap items-center gap-2 border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void testConnection()}
|
||||
disabled={!canTest}
|
||||
className="ui-btn-secondary flex items-center gap-1.5"
|
||||
>
|
||||
{testState === 'testing' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wifi className="h-3.5 w-3.5" />}
|
||||
{testState === 'testing' ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={!verified || saving}
|
||||
className="ui-btn-primary flex items-center gap-1.5"
|
||||
>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
保存
|
||||
</button>
|
||||
{isStored && store.activeProviderId !== draft.id && (
|
||||
<button type="button" onClick={() => void store.setActiveProvider(draft.id)} className="ui-btn-secondary">
|
||||
设为当前
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button type="button" title="删除配置" onClick={() => void remove()} className="ui-btn-secondary text-red-300">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`flex min-h-5 items-center gap-1.5 text-xs ${verified ? 'text-emerald-400' : testState === 'error' ? 'text-red-300' : 'text-txt-muted'}`} role={testState === 'error' ? 'alert' : 'status'}>
|
||||
{verified && <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
<span>{message || '修改连接字段后需要重新测试'}</span>
|
||||
</div>
|
||||
{verified && testAnswer && (
|
||||
<div className="border-emerald-500/20 bg-emerald-500/5 rounded-lg border px-3 py-2 text-xs text-content-secondary">
|
||||
<span className="text-txt-faint">模型回答:</span>{testAnswer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label>
|
||||
<span className="mb-1 block text-[11px] text-txt-muted">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useKeybindingStore } from '../../stores/keybindingStore'
|
||||
import {
|
||||
Monitor,
|
||||
Palette,
|
||||
Terminal,
|
||||
Keyboard,
|
||||
Shield,
|
||||
Database,
|
||||
Info,
|
||||
Search,
|
||||
X,
|
||||
RotateCcw,
|
||||
AlertTriangle,
|
||||
Copy,
|
||||
Check,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import AiProviderSettings from './AiProviderSettings'
|
||||
import { themes } from '../../themes/terminal'
|
||||
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
|
||||
|
||||
// ─── Tab 类型 ───────────────────────────────────────────────
|
||||
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
|
||||
|
||||
interface TabMeta {
|
||||
id: SettingsTab
|
||||
label: string
|
||||
description: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
interface RuntimeInfo {
|
||||
appVersion: string
|
||||
os: string
|
||||
arch: string
|
||||
nativeBackend: string
|
||||
sshBackend: string
|
||||
dataPath: string
|
||||
credentialPath: string
|
||||
}
|
||||
|
||||
const TAB_LIST: TabMeta[] = [
|
||||
{ id: 'general', label: '工作区', description: '布局与资源', icon: Monitor },
|
||||
{ id: 'appearance', label: '外观', description: '主题与终端配色', icon: Palette },
|
||||
{ id: 'terminal', label: '终端', description: '字体与配色', icon: Terminal },
|
||||
{ id: 'ai', label: '大模型', description: 'Provider 与模型', icon: Sparkles },
|
||||
{ id: 'shortcuts', label: '快捷键', description: '键盘快捷键', icon: Keyboard },
|
||||
{ id: 'ssh', label: 'SSH', description: '连接(高级)', icon: Shield },
|
||||
{ id: 'security', label: '安全', description: '加密与隐私', icon: Database },
|
||||
{ id: 'about', label: '关于', description: '版本与支持', icon: Info },
|
||||
]
|
||||
|
||||
// ─── 快捷键录入 Hook ─────────────────────────────────────────
|
||||
// 监听键盘事件并格式化为键序列字符串 (如 "Ctrl+Shift+P")
|
||||
function useKeyRecording() {
|
||||
const [recordingId, setRecordingId] = useState<KeybindingActionId | null>(null)
|
||||
const [recordedKeys, setRecordedKeys] = useState<string>('')
|
||||
|
||||
const startRecording = useCallback((actionId: KeybindingActionId) => {
|
||||
setRecordingId(actionId)
|
||||
setRecordedKeys('')
|
||||
}, [])
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
setRecordingId(null)
|
||||
setRecordedKeys('')
|
||||
}, [])
|
||||
|
||||
// 全局键盘监听
|
||||
useEffect(() => {
|
||||
if (!recordingId) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Escape 取消录入
|
||||
if (e.key === 'Escape') {
|
||||
stopRecording()
|
||||
return
|
||||
}
|
||||
|
||||
// 忽略单独的修饰键
|
||||
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return
|
||||
|
||||
const parts: string[] = []
|
||||
if (e.ctrlKey || e.metaKey) parts.push('Ctrl')
|
||||
if (e.shiftKey) parts.push('Shift')
|
||||
if (e.altKey) parts.push('Alt')
|
||||
|
||||
// 主键
|
||||
let mainKey = e.key
|
||||
if (mainKey === ' ') mainKey = 'Space'
|
||||
else if (mainKey === 'Tab') mainKey = 'Tab'
|
||||
else if (mainKey.length === 1) mainKey = mainKey.toUpperCase()
|
||||
parts.push(mainKey)
|
||||
|
||||
const keySequence = parts.join('+')
|
||||
setRecordedKeys(keySequence)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [recordingId, stopRecording])
|
||||
|
||||
return { recordingId, recordedKeys, startRecording, stopRecording }
|
||||
}
|
||||
|
||||
// ─── 主组件 ─────────────────────────────────────────────────
|
||||
export default function SettingsView() {
|
||||
const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore()
|
||||
const keybindingState = useKeybindingStore()
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('general')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [runtimeInfo, setRuntimeInfo] = useState<RuntimeInfo | null>(null)
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||
const { recordingId, recordedKeys, startRecording, stopRecording } = useKeyRecording()
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Runtime facts come from Rust so diagnostics match the installed build.
|
||||
useEffect(() => {
|
||||
if (window.opsxApi) {
|
||||
window.opsxApi
|
||||
.appRuntimeInfo()
|
||||
.then(setRuntimeInfo)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copyText = useCallback(async (label: string, text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedField(label)
|
||||
setTimeout(() => setCopiedField(null), 1500)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 当前 tab 元信息
|
||||
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
|
||||
|
||||
// ── 搜索过滤 ──────────────────────────────────────────────
|
||||
const filtered = useMemo((): SettingsTab[] | null => {
|
||||
if (!searchQuery.trim()) return null // null 表示不过滤,正常显示
|
||||
const q = searchQuery.toLowerCase()
|
||||
const keywords: Record<SettingsTab, string[]> = {
|
||||
general: ['工作区', '侧栏', '宽度', '资源', 'workspace', 'sidebar'],
|
||||
appearance: ['主题', '深色', '浅色', 'theme'],
|
||||
terminal: ['字体', 'font', '配色', 'terminal', 'monokai', 'dracula'],
|
||||
ai: ['ai', '大模型', 'provider', '模型', 'deepseek', 'glm', 'minimax', 'new-api'],
|
||||
shortcuts: ['快捷键', '键盘', 'keyboard', 'shortcut', 'hotkey'],
|
||||
ssh: ['ssh', '连接', '端口', '超时', '心跳', '连接池', '重连', '快照'],
|
||||
security: ['加密', '安全', '密码', '主密钥', 'known hosts', 'encrypt'],
|
||||
about: ['版本', '关于', '许可', 'version', 'about', 'license'],
|
||||
}
|
||||
const matchIds = (Object.entries(keywords) as [SettingsTab, string[]][])
|
||||
.filter(([, kws]) => kws.some((k: string) => k.includes(q) || q.includes(k)))
|
||||
.map(([id]) => id)
|
||||
return matchIds.length > 0 ? matchIds : []
|
||||
}, [searchQuery])
|
||||
|
||||
// 如果搜索过滤后有结果,自动跳转到第一个匹配 tab
|
||||
useEffect(() => {
|
||||
if (filtered && filtered.length > 0 && !filtered.includes(activeTab)) {
|
||||
setActiveTab(filtered[0])
|
||||
}
|
||||
}, [filtered]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const visibleTabs = filtered ? TAB_LIST.filter((t) => filtered.includes(t.id)) : TAB_LIST
|
||||
const appVersion = runtimeInfo?.appVersion ?? '—'
|
||||
const diagnosticText = runtimeInfo
|
||||
? [
|
||||
`OpsXShell v${runtimeInfo.appVersion}`,
|
||||
`${runtimeInfo.os} ${runtimeInfo.arch}`,
|
||||
runtimeInfo.nativeBackend,
|
||||
runtimeInfo.sshBackend,
|
||||
`Data: ${runtimeInfo.dataPath}`,
|
||||
].join('\n')
|
||||
: `OpsXShell v${appVersion}`
|
||||
|
||||
// ── 加载态 ────────────────────────────────────────────────
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-dark-900">
|
||||
<div className="text-txt-muted">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 渲染内容区 ────────────────────────────────────────────
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
// ──────── 常规 ─────────
|
||||
case 'general':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title="工作区布局">
|
||||
<SettingRow label="侧栏宽度" description="会话侧栏的默认宽度">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={180}
|
||||
max={400}
|
||||
step={8}
|
||||
value={settings.sidebarWidth}
|
||||
onChange={(e) => setSetting('sidebarWidth', Number(e.target.value))}
|
||||
className="w-28 accent-blue-500"
|
||||
aria-label="侧栏宽度"
|
||||
/>
|
||||
<span className="w-12 text-right text-xs tabular-nums text-txt-muted">{settings.sidebarWidth}px</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ──────── 外观 ─────────
|
||||
case 'appearance':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title="外观">
|
||||
<SettingRow label="主题" description="应用界面配色">
|
||||
<select
|
||||
value={settings.theme}
|
||||
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
|
||||
className="ui-select-pill"
|
||||
>
|
||||
<option value="dark">深色</option>
|
||||
<option value="light">浅色</option>
|
||||
<option value="system">跟随系统</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ──────── 终端 ─────────
|
||||
case 'terminal':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title="字体">
|
||||
<SettingRow label="字体" description="需 Nerd Font 才能正确显示 Zsh 提示符图标">
|
||||
<select
|
||||
value={settings.fontFamily}
|
||||
onChange={(e) => setSetting('fontFamily', e.target.value)}
|
||||
className="ui-select-pill"
|
||||
>
|
||||
<option>MesloLGS NF</option>
|
||||
<option>JetBrainsMono Nerd Font</option>
|
||||
<option>Hack Nerd Font</option>
|
||||
<option>FiraCode Nerd Font</option>
|
||||
<option>JetBrains Mono</option>
|
||||
<option>Menlo</option>
|
||||
<option>Fira Code</option>
|
||||
<option>Monaco</option>
|
||||
<option>Consolas</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="字号" description="终端文字大小">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 rounded-lg border border-dark-600 bg-dark-700 text-content-secondary hover:bg-dark-600"
|
||||
onClick={() => setSetting('fontSize', Math.max(10, (settings.fontSize || 14) - 1))}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-12 text-center text-sm tabular-nums text-content-primary">
|
||||
{settings.fontSize || 14}px
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 rounded-lg border border-dark-600 bg-dark-700 text-content-secondary hover:bg-dark-600"
|
||||
onClick={() => setSetting('fontSize', Math.min(28, (settings.fontSize || 14) + 1))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="回滚缓冲" description="每个终端保留的历史行数">
|
||||
<input
|
||||
type="number"
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
value={settings.scrollback}
|
||||
onChange={(e) => setSetting('scrollback', Math.max(1000, Number(e.target.value) || 10000))}
|
||||
className="ui-input-sm w-24 text-center"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingToggle
|
||||
label="关键词高亮"
|
||||
description="突出显示错误、警告、IP 地址和成功状态"
|
||||
checked={settings.keywordHighlightEnabled}
|
||||
onChange={(value) => setSetting('keywordHighlightEnabled', value)}
|
||||
/>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title="配色">
|
||||
<div className="px-1 py-1">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{Object.entries(themes).map(([key, theme]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSetting('terminalTheme', key as 'dracula' | 'monokai' | 'solarized' | 'one-dark')
|
||||
}
|
||||
className={`rounded-xl border p-2.5 text-left text-xs transition-colors ${
|
||||
settings.terminalTheme === key
|
||||
? 'bg-accent-blue/10 ring-accent-blue/30 border-accent-blue ring-1'
|
||||
: 'border-dark-600/60 hover:border-dark-500'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="mb-1.5 h-7 w-full rounded-md border border-black/20"
|
||||
style={{ backgroundColor: theme.background }}
|
||||
/>
|
||||
<span className="text-content-secondary">{theme.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingGroup>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'ai':
|
||||
return <AiProviderSettings />
|
||||
|
||||
// ──────── 快捷键 ───────
|
||||
case 'shortcuts': {
|
||||
const allKeys = keybindingState.getAllEffective()
|
||||
const categories = getKeybindingCategories()
|
||||
const hasCustom = Object.keys(keybindingState.customKeybindings).length > 0
|
||||
|
||||
// 检查录入的键是否与当前录制匹配
|
||||
const recordedConflict =
|
||||
recordedKeys && recordingId ? keybindingState.getConflicts(recordingId, recordedKeys) : []
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 重置全部按钮 */}
|
||||
{hasCustom && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => keybindingState.resetAll()}
|
||||
className="hover:bg-dark-700/50 flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs text-txt-muted transition-colors hover:text-accent-blue"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
重置全部为默认
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.map((category) => {
|
||||
const defs = getKeybindingsByCategory(category)
|
||||
return (
|
||||
<SettingCard key={category}>
|
||||
<h3 className="mb-3 text-sm font-semibold text-content-secondary">{category}</h3>
|
||||
<div className="space-y-1">
|
||||
{defs.map((def) => {
|
||||
const currentKeys = allKeys[def.id]
|
||||
const isCustom = keybindingState.customKeybindings[def.id] !== undefined
|
||||
const isRecordingThis = recordingId === def.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={def.id}
|
||||
className={`flex items-center justify-between rounded-lg px-3 py-2 transition-colors ${
|
||||
isRecordingThis ? 'bg-accent-blue/10 border-accent-blue/30 border' : 'hover:bg-dark-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm text-content-primary">{def.label}</span>
|
||||
{isCustom && (
|
||||
<span className="bg-accent-blue/10 ml-2 rounded px-1.5 py-0.5 text-[10px] text-accent-blue">
|
||||
自定义
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isRecordingThis ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<kbd className="bg-accent-blue/20 border-accent-blue/40 inline-flex animate-pulse items-center rounded border px-3 py-1 font-mono text-xs text-accent-blue">
|
||||
{recordedKeys || '按下快捷键...'}
|
||||
</kbd>
|
||||
<button
|
||||
onClick={() => stopRecording()}
|
||||
className="text-xs text-txt-muted hover:text-content-secondary"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{recordedKeys && recordedConflict.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-yellow-500">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span>与「{recordedConflict[0].label}」冲突</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
startRecording(def.id)
|
||||
}}
|
||||
className="group relative"
|
||||
>
|
||||
<kbd
|
||||
className={`inline-flex items-center rounded px-2.5 py-1 font-mono text-xs transition-colors ${
|
||||
isCustom
|
||||
? 'bg-accent-blue/10 border-accent-blue/30 border text-accent-blue'
|
||||
: 'border border-dark-600 bg-dark-700 text-content-secondary'
|
||||
} group-hover:border-accent-blue/50`}
|
||||
>
|
||||
{currentKeys}
|
||||
</kbd>
|
||||
</button>
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={() => keybindingState.resetKeybinding(def.id)}
|
||||
className="text-txt-faint transition-colors hover:text-txt-muted"
|
||||
title="重置为默认"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SettingCard>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 录入确认弹层 */}
|
||||
{recordingId && recordedKeys && (
|
||||
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-dark-600 bg-dark-800 px-5 py-3 shadow-2xl">
|
||||
<span className="text-sm text-content-secondary">
|
||||
设置为{' '}
|
||||
<kbd className="rounded bg-dark-700 px-1.5 py-0.5 font-mono text-xs text-accent-blue">
|
||||
{recordedKeys}
|
||||
</kbd>
|
||||
</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await keybindingState.setCustomKeybinding(recordingId, recordedKeys)
|
||||
stopRecording()
|
||||
}}
|
||||
className="hover:bg-accent-blue/80 rounded-lg bg-accent-blue px-3 py-1 text-xs text-content-primary transition-colors"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="rounded-lg px-3 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-txt-faint">点击快捷键可重新绑定 · 按 Esc 取消录入</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ──────── SSH ──────────
|
||||
case 'ssh':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title="连接">
|
||||
<SettingToggle
|
||||
label="保持心跳"
|
||||
description="空闲时发送心跳,减少断线"
|
||||
checked={settings.keepAlive}
|
||||
onChange={(v) => setSetting('keepAlive', v)}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="自动重连"
|
||||
description="意外断开后自动尝试重连"
|
||||
checked={settings.autoReconnect !== false}
|
||||
onChange={(v) => setSetting('autoReconnect', v)}
|
||||
/>
|
||||
<SettingRow label="连接超时" description="建立 SSH 连接的最长等待时间(秒)">
|
||||
<input
|
||||
type="number"
|
||||
min={5}
|
||||
max={120}
|
||||
value={settings.timeout}
|
||||
onChange={(e) => setSetting('timeout', parseInt(e.target.value) || 30)}
|
||||
className="ui-input-sm w-20 text-center"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="自动重连次数" description="意外断线后的最大尝试次数">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={settings.maxReconnectAttempts}
|
||||
onChange={(e) => setSetting('maxReconnectAttempts', parseInt(e.target.value) || 5)}
|
||||
className="ui-input-sm w-20 text-center"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title="连接池">
|
||||
<SettingRow label="最大连接数" description="SSH、SFTP 与批量连接共享此上限">
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={50}
|
||||
value={settings.maxConnections}
|
||||
onChange={(e) => setSetting('maxConnections', parseInt(e.target.value) || 10)}
|
||||
className="ui-input-sm w-20 text-center"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="后台连接上限" description="超过上限时优先回收最久未使用的连接">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={30}
|
||||
value={settings.maxBackgroundConnections}
|
||||
onChange={(e) => setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)}
|
||||
className="ui-input-sm w-20 text-center"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="后台挂起" description="标签进入后台后多少分钟释放活跃通道">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={Math.round(settings.idleTimeoutMs / 60000)}
|
||||
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)}
|
||||
className="ui-input-sm w-20 text-center"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ──────── 安全 ─────────
|
||||
case 'security':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title="凭据保护">
|
||||
<SettingRow label="会话密码与密钥口令" description="保存前由 Rust 使用 AES-256-GCM 加密,无法关闭">
|
||||
<span className="border-accent-green/25 bg-accent-green/10 rounded-md border px-2 py-1 text-xs font-medium text-accent-green">
|
||||
已启用
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="本地主密钥" description="仅当前系统用户可读取">
|
||||
<code className="text-xs text-txt-muted">~/.opsxshsell/master.key</code>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
<KnownHostsSettingsCard />
|
||||
</div>
|
||||
)
|
||||
|
||||
// ──────── 关于 ─────────
|
||||
case 'about':
|
||||
return (
|
||||
<div className="max-w-2xl space-y-5">
|
||||
<SettingCard>
|
||||
<div className="flex items-start gap-4 py-2">
|
||||
<div className="border-dark-600/50 flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl border bg-dark-700">
|
||||
<Terminal className="h-8 w-8 text-content-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold text-content-primary">OpsXShell</h3>
|
||||
<p className="mt-0.5 text-sm text-txt-muted">轻量原生的 SSH / SFTP 运维终端</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="bg-accent-blue/15 border-accent-blue/20 inline-flex items-center rounded-md border px-2.5 py-1 font-mono text-xs font-medium text-accent-blue">
|
||||
应用 v{appVersion}
|
||||
</span>
|
||||
<span className="border-dark-600/50 inline-flex items-center rounded-md border bg-dark-700 px-2.5 py-1 font-mono text-xs text-txt-muted">
|
||||
Tauri 2 · Rust
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText('version', diagnosticText)}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
title="复制版本信息"
|
||||
>
|
||||
{copiedField === 'version' ? (
|
||||
<Check className="h-3.5 w-3.5 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{copiedField === 'version' ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>运行环境</SettingSectionTitle>
|
||||
<div className="divide-dark-700/30 mt-3 divide-y text-sm">
|
||||
{[
|
||||
['系统', runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : '读取中...'],
|
||||
['原生运行时', runtimeInfo?.nativeBackend ?? '读取中...'],
|
||||
['SSH 后端', runtimeInfo?.sshBackend ?? '读取中...'],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="flex items-center justify-between gap-4 py-2">
|
||||
<span className="text-txt-muted">{label}</span>
|
||||
<code className="text-right text-xs text-content-secondary">{value}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>本地数据</SettingSectionTitle>
|
||||
<div className="mt-3 space-y-3 text-xs">
|
||||
<div>
|
||||
<div className="mb-1 text-txt-muted">会话与设置</div>
|
||||
<code className="block break-all text-content-secondary">{runtimeInfo?.dataPath ?? '读取中...'}</code>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-txt-muted">加密主密钥</div>
|
||||
<code className="block break-all text-content-secondary">
|
||||
{runtimeInfo?.credentialPath ?? '~/.opsxshsell/master.key'}
|
||||
</code>
|
||||
</div>
|
||||
<p className="leading-relaxed text-txt-faint">
|
||||
密码与密钥口令由 Rust 使用 AES-256-GCM 加密。OpsXShell 不保存终端命令历史。
|
||||
</p>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<p className="px-1 text-[11px] text-txt-faint">MIT License · Tauri 2 · Rust · React · XTerm.js</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── 主布局 ────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex h-full bg-dark-900">
|
||||
{/* ── 左侧导航 ── */}
|
||||
<aside className="bg-dark-800/80 border-dark-700/40 flex w-60 flex-shrink-0 flex-col border-r">
|
||||
<div className="px-4 pb-3 pt-5">
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-txt-faint">设置</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-txt-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索设置..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="ui-input-sm !pl-8 !pr-8"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-txt-muted hover:text-content-secondary"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
|
||||
{visibleTabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const active = activeTab === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''}`}
|
||||
>
|
||||
<Icon className={`h-4 w-4 flex-shrink-0 ${active ? 'text-accent-blue' : ''}`} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{tab.label}</div>
|
||||
<div className={`truncate text-[11px] ${active ? 'text-txt-muted' : 'text-txt-faint'}`}>
|
||||
{tab.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* ── 右侧内容区 ── */}
|
||||
<div className="to-dark-900/95 flex min-w-0 flex-1 flex-col bg-gradient-to-b from-dark-900">
|
||||
<div className="border-dark-700/30 flex-shrink-0 border-b px-8 pb-4 pt-6">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-content-primary">{currentMeta.label}</h1>
|
||||
<p className="mt-1 text-sm text-txt-muted">{currentMeta.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl flex-1 overflow-y-auto px-8 py-6">{renderContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 子组件 ─────────────────────────────────────────────────
|
||||
|
||||
function KnownHostsSettingsCard() {
|
||||
const [entries, setEntries] = useState<Array<{ host: string; port: number; keyType: string; fingerprint: string }>>(
|
||||
[]
|
||||
)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.opsxApi?.knownHostsList?.()
|
||||
if (result?.success && result.entries) {
|
||||
setEntries(result.entries)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const handleRemove = async (host: string, port: number) => {
|
||||
await window.opsxApi?.knownHostsRemove?.(host, port)
|
||||
await load()
|
||||
}
|
||||
|
||||
const handleClear = async () => {
|
||||
if (!confirm('确定清空全部已知主机密钥?下次连接将重新确认。')) return
|
||||
await window.opsxApi?.knownHostsClear?.()
|
||||
await load()
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingGroup title="已知主机">
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-3">
|
||||
<p className="flex-1 text-xs text-txt-muted">首次连接会确认主机指纹。此处可管理已信任记录。</p>
|
||||
<div className="flex flex-shrink-0 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
className="rounded-lg bg-dark-700 px-2 py-1 text-[11px] text-txt-muted hover:bg-dark-600"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleClear()}
|
||||
className="rounded-lg bg-dark-700 px-2 py-1 text-[11px] text-red-300 hover:bg-red-900/40"
|
||||
disabled={entries.length === 0}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="px-4 pb-3 text-xs text-txt-muted">加载中...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="px-4 pb-3 text-xs text-txt-faint">暂无记录</div>
|
||||
) : (
|
||||
<div className="divide-dark-700/30 max-h-48 divide-y overflow-y-auto">
|
||||
{entries.map((e) => (
|
||||
<div key={`${e.host}:${e.port}`} className="flex items-start justify-between gap-2 px-4 py-2.5">
|
||||
<div className="min-w-0 font-mono text-[11px]">
|
||||
<div className="truncate text-content-primary">
|
||||
{e.host}:{e.port} <span className="text-txt-faint">({e.keyType})</span>
|
||||
</div>
|
||||
<div className="mt-0.5 break-all text-txt-muted opacity-80">SHA256:{e.fingerprint}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove(e.host, e.port)}
|
||||
className="flex-shrink-0 px-1 text-[11px] text-red-300 hover:text-red-200"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
/** 分组卡片:多行设置 */
|
||||
function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
{title && <h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">{title}</h3>}
|
||||
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 卡片容器(快捷键等复用) */
|
||||
function SettingCard({ children, highlight = false }: { children: React.ReactNode; highlight?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl p-4 transition-colors ${
|
||||
highlight ? 'bg-accent-blue/5 border-accent-blue/25 border' : 'bg-dark-800/50 border-dark-700/45 border'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 区域小标题 */
|
||||
function SettingSectionTitle({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||
return <div className={`mb-1 text-sm font-semibold text-content-secondary ${className}`}>{children}</div>
|
||||
}
|
||||
|
||||
/** 左文右控行 */
|
||||
function SettingRow({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3.5">
|
||||
<div className="mr-2 min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-content-primary">{label}</div>
|
||||
{description && <div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div>}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{children}</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 gap-4 px-4 py-3.5">
|
||||
<div className="mr-2 min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-content-primary">{label}</div>
|
||||
<div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative h-6 w-11 flex-shrink-0 rounded-full transition-colors ${
|
||||
checked ? 'bg-accent-blue' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
checked ? 'left-6' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Settings — 设置模块
|
||||
*/
|
||||
export { default as SettingsView } from './SettingsView'
|
||||
@@ -42,7 +42,7 @@ export function ConnectionPanel({
|
||||
// Connecting State
|
||||
if (isConnecting) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-surface-baseccent-blue/10 border-b border-accent-blue/20">
|
||||
<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>
|
||||
@@ -74,7 +74,7 @@ export function ConnectionPanel({
|
||||
<span className="text-sm text-txt-muted">未连接</span>
|
||||
<button
|
||||
onClick={onShowSelector}
|
||||
className="px-3 py-1.5 bg-surface-baseccent-blue hover:bg-surface-baselue-600 rounded text-sm font-medium transition-colors"
|
||||
className="px-3 py-1.5 bg-accent-blue hover:bg-blue-600 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
选择服务器
|
||||
</button>
|
||||
|
||||
@@ -3,17 +3,28 @@
|
||||
*
|
||||
* 本地面板或远程面板的文件列表组件
|
||||
* 远程面板支持拖拽上传
|
||||
* 支持当前文件夹内文件名搜索
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, FolderOpen } from 'lucide-react'
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, FolderOpen,
|
||||
Search, X, Eye, EyeOff,
|
||||
} from 'lucide-react'
|
||||
import { Breadcrumb } from './Breadcrumb'
|
||||
import { SortHeader } from './SortHeader'
|
||||
import { FileRow } from './FileRow'
|
||||
import { ContextMenu, type MenuItem } from './ContextMenu'
|
||||
import { FileListSkeleton } from '../common/Skeleton'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
import { filterFilesByName, filterHiddenFiles, isHiddenFileName } from './utils'
|
||||
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
||||
import {
|
||||
hasSftpDragEntry,
|
||||
readSftpDragEntry,
|
||||
writeSftpDragEntry,
|
||||
type SftpDragEntry,
|
||||
} from './dragDrop'
|
||||
|
||||
interface FileListPanelProps {
|
||||
title: string
|
||||
@@ -35,8 +46,10 @@ interface FileListPanelProps {
|
||||
onSort: (field: SortField) => void
|
||||
contextMenu: { x: number; y: number; items: MenuItem[] } | null
|
||||
onCloseContextMenu: () => void
|
||||
/** 远程面板拖拽上传回调 */
|
||||
onDropFiles?: (files: FileList) => void
|
||||
/** 操作系统文件拖入远程面板。 */
|
||||
onDropFiles?: (files: FileList, targetPath: string) => void
|
||||
/** OpsXShell 本地/远程栏之间的内部拖拽。 */
|
||||
onDropEntry?: (entry: SftpDragEntry, targetPath: string) => void
|
||||
/** 是否正在加载 */
|
||||
loading?: boolean
|
||||
}
|
||||
@@ -62,75 +75,193 @@ export function FileListPanel({
|
||||
contextMenu,
|
||||
onCloseContextMenu,
|
||||
onDropFiles,
|
||||
onDropEntry,
|
||||
loading = false,
|
||||
}: FileListPanelProps) {
|
||||
const canGoBack = historyIndex > 0
|
||||
const canGoForward = historyIndex < history.length - 1
|
||||
|
||||
// Current-folder filename filter + hidden files toggle
|
||||
const [nameFilter, setNameFilter] = useState('')
|
||||
const [showHidden, setShowHidden] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Clear name filter when directory changes (keep showHidden preference)
|
||||
useEffect(() => {
|
||||
setNameFilter('')
|
||||
}, [path])
|
||||
|
||||
const hiddenCount = useMemo(
|
||||
() => files.filter((f) => isHiddenFileName(f.name) && f.name !== '.' && f.name !== '..').length,
|
||||
[files]
|
||||
)
|
||||
|
||||
const visibleFiles = useMemo(
|
||||
() => filterHiddenFiles(files, showHidden),
|
||||
[files, showHidden]
|
||||
)
|
||||
|
||||
const filteredFiles = useMemo(
|
||||
() => filterFilesByName(visibleFiles, nameFilter),
|
||||
[visibleFiles, nameFilter]
|
||||
)
|
||||
const isFiltering = nameFilter.trim().length > 0
|
||||
|
||||
// Drag state for remote panel upload
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [dropTargetPath, setDropTargetPath] = useState<string | null>(null)
|
||||
const [dragSource, setDragSource] = useState<SftpDragEntry['source'] | null>(null)
|
||||
const dragCounterRef = useRef(0)
|
||||
|
||||
const acceptsDrag = useCallback((dataTransfer: DataTransfer) => {
|
||||
const internal = readSftpDragEntry(dataTransfer)
|
||||
if (internal) return internal.source !== (isLocal ? 'local' : 'remote') && !!onDropEntry
|
||||
if (hasSftpDragEntry(dataTransfer)) return !!onDropEntry
|
||||
return !isLocal && !!onDropFiles && Array.from(dataTransfer.types || []).includes('Files')
|
||||
}, [isLocal, onDropEntry, onDropFiles])
|
||||
|
||||
const resetDragState = useCallback(() => {
|
||||
dragCounterRef.current = 0
|
||||
setIsDragOver(false)
|
||||
setDropTargetPath(null)
|
||||
setDragSource(null)
|
||||
}, [])
|
||||
|
||||
const clearFilter = useCallback(() => {
|
||||
setNameFilter('')
|
||||
searchInputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (nameFilter) {
|
||||
setNameFilter('')
|
||||
} else {
|
||||
searchInputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
}, [nameFilter])
|
||||
|
||||
// Panel-level shortcut: focus search (Ctrl/Cmd+F)
|
||||
const handlePanelKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
if (isLocal || !onDropFiles) return
|
||||
if (!acceptsDrag(e.dataTransfer)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragSource(readSftpDragEntry(e.dataTransfer)?.source || null)
|
||||
dragCounterRef.current++
|
||||
if (dragCounterRef.current === 1) {
|
||||
setIsDragOver(true)
|
||||
}
|
||||
}, [isLocal, onDropFiles])
|
||||
}, [acceptsDrag])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (isLocal || !onDropFiles) return
|
||||
if (!acceptsDrag(e.dataTransfer)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragCounterRef.current--
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragOver(false)
|
||||
}
|
||||
}, [isLocal, onDropFiles])
|
||||
}, [acceptsDrag])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (isLocal || !onDropFiles) return
|
||||
if (!acceptsDrag(e.dataTransfer)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [isLocal, onDropFiles])
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}, [acceptsDrag])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
if (isLocal || !onDropFiles) return
|
||||
if (!acceptsDrag(e.dataTransfer)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragCounterRef.current = 0
|
||||
setIsDragOver(false)
|
||||
const entry = readSftpDragEntry(e.dataTransfer)
|
||||
resetDragState()
|
||||
|
||||
if (entry && entry.source !== (isLocal ? 'local' : 'remote')) {
|
||||
onDropEntry?.(entry, path)
|
||||
return
|
||||
}
|
||||
|
||||
const droppedFiles = e.dataTransfer.files
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
onDropFiles(droppedFiles)
|
||||
onDropFiles?.(droppedFiles, path)
|
||||
}
|
||||
}, [isLocal, onDropFiles])
|
||||
}, [acceptsDrag, isLocal, onDropEntry, onDropFiles, path, resetDragState])
|
||||
|
||||
const dropOnDirectory = useCallback((event: React.DragEvent, directory: FileItem) => {
|
||||
if (!acceptsDrag(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const entry = readSftpDragEntry(event.dataTransfer)
|
||||
resetDragState()
|
||||
if (entry && entry.source !== (isLocal ? 'local' : 'remote')) {
|
||||
onDropEntry?.(entry, directory.path)
|
||||
return
|
||||
}
|
||||
if (!isLocal && event.dataTransfer.files?.length) {
|
||||
onDropFiles?.(event.dataTransfer.files, directory.path)
|
||||
}
|
||||
}, [acceptsDrag, isLocal, onDropEntry, onDropFiles, resetDragState])
|
||||
|
||||
const statusText = (() => {
|
||||
if (loading) return '加载中...'
|
||||
if (selected.size > 0) return `已选择 ${selected.size} 项`
|
||||
if (isFiltering) return `显示 ${filteredFiles.length} / ${visibleFiles.length} 项`
|
||||
if (!showHidden && hiddenCount > 0) return `共 ${visibleFiles.length} 项(已隐藏 ${hiddenCount})`
|
||||
return `共 ${visibleFiles.length} 项`
|
||||
})()
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={`${title}文件面板`}
|
||||
className={`flex-1 flex flex-col bg-dark-800 rounded-lg border overflow-hidden transition-colors ${
|
||||
isDragOver ? 'border-accent-blue shadow-lg shadow-accent-blue/20' : 'border-dark-700'
|
||||
isDragOver ? 'border-accent-blue shadow-lg shadow-accent-blue/20' : 'border-dark-700/40'
|
||||
}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onKeyDown={handlePanelKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600/30">
|
||||
<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 className="flex items-center gap-2 min-w-0">
|
||||
{isLocal ? <HardDrive className="w-4 h-4 text-accent-blue flex-shrink-0" /> : <Folder className="w-4 h-4 text-accent-green flex-shrink-0" />}
|
||||
<span className="text-sm font-medium truncate">{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHidden((v) => !v)}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
showHidden
|
||||
? 'bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30'
|
||||
: 'text-txt-muted hover:bg-dark-600 hover:text-content-primary'
|
||||
}`}
|
||||
title={showHidden ? '隐藏点文件(.xxx)' : '显示隐藏文件(.xxx)'}
|
||||
aria-label={showHidden ? '隐藏点文件' : '显示隐藏文件'}
|
||||
aria-pressed={showHidden}
|
||||
>
|
||||
{showHidden ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
disabled={!canGoBack}
|
||||
className="p-1.5 hover:bg-dark-600 rounded disabled:opacity-30"
|
||||
title="后退"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -138,25 +269,53 @@ export function FileListPanel({
|
||||
onClick={onNavigateForward}
|
||||
disabled={!canGoForward}
|
||||
className="p-1.5 hover:bg-dark-600 rounded disabled:opacity-30"
|
||||
title="前进"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateUp}
|
||||
className="p-1.5 hover:bg-dark-600 rounded"
|
||||
title="上级目录"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="px-4 py-2 border-b border-dark-700/20">
|
||||
<Breadcrumb path={path} onNavigate={onNavigate} onHome={() => onNavigate(isLocal ? '/' : '/')} />
|
||||
{/* Breadcrumb + search */}
|
||||
<div className="px-3 py-2 border-b border-dark-700/20 flex items-center gap-2 min-w-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Breadcrumb path={path} onNavigate={onNavigate} onHome={() => onNavigate(isLocal ? '/' : '/')} />
|
||||
</div>
|
||||
<div className="relative flex-shrink-0 w-[140px] sm:w-[168px]">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-txt-muted pointer-events-none" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
value={nameFilter}
|
||||
onChange={(e) => setNameFilter(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="搜索文件名"
|
||||
aria-label={`${title} 当前文件夹搜索`}
|
||||
className="w-full h-7 pl-7 pr-7 text-xs bg-dark-900 border border-dark-600 rounded-md text-content-primary placeholder:text-txt-faint focus:outline-none focus:border-accent-blue/70 focus:ring-1 focus:ring-accent-blue/30"
|
||||
/>
|
||||
{isFiltering && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilter}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-txt-muted hover:text-content-primary hover:bg-dark-700"
|
||||
title="清除搜索"
|
||||
aria-label="清除搜索"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column Headers */}
|
||||
<div className="flex items-center px-4 py-2 border-b border-dark-700/20 text-xs text-txt-muted">
|
||||
{/* Column Headers — 与 FileRow 共用 px-4 对齐 */}
|
||||
<div className="px-4 border-b border-dark-700/20 bg-dark-800/80">
|
||||
<SortHeader sortField={sortField} sortOrder={sortOrder} onSort={onSort} />
|
||||
</div>
|
||||
|
||||
@@ -168,10 +327,32 @@ export function FileListPanel({
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title="目录为空"
|
||||
description="此目录下没有文件"
|
||||
description={isLocal ? '此目录下没有文件' : '可尝试返回上级或刷新'}
|
||||
action={
|
||||
path !== '/'
|
||||
? { label: '返回上级', onClick: onNavigateUp }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : visibleFiles.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={EyeOff}
|
||||
title="仅有隐藏文件"
|
||||
description="点击标题栏眼睛图标显示点文件"
|
||||
action={{
|
||||
label: '显示隐藏文件',
|
||||
onClick: () => setShowHidden(true),
|
||||
}}
|
||||
/>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="无匹配文件"
|
||||
description={`没有包含「${nameFilter.trim()}」的文件`}
|
||||
action={{ label: '清除搜索', onClick: clearFilter }}
|
||||
/>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
filteredFiles.map((file) => (
|
||||
<FileRow
|
||||
key={file.name}
|
||||
file={file}
|
||||
@@ -179,24 +360,60 @@ export function FileListPanel({
|
||||
onSelect={() => onSelect(file.name)}
|
||||
onOpen={() => onOpen(file)}
|
||||
onContextMenu={(e) => onContextMenu(e, file)}
|
||||
onDragStart={(event) => {
|
||||
writeSftpDragEntry(event.dataTransfer, {
|
||||
source: isLocal ? 'local' : 'remote',
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
})
|
||||
}}
|
||||
onDragEnd={resetDragState}
|
||||
onDragEnter={file.type === 'directory' ? (event) => {
|
||||
if (!acceptsDrag(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setDragSource(readSftpDragEntry(event.dataTransfer)?.source || null)
|
||||
setIsDragOver(true)
|
||||
setDropTargetPath(file.path)
|
||||
} : undefined}
|
||||
onDragLeave={file.type === 'directory' ? (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setDropTargetPath((current) => current === file.path ? null : current)
|
||||
} : undefined}
|
||||
onDragOver={file.type === 'directory' ? (event) => {
|
||||
if (!acceptsDrag(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
} : undefined}
|
||||
onDrop={file.type === 'directory' ? (event) => dropOnDirectory(event, file) : undefined}
|
||||
isDropTarget={dropTargetPath === file.path}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Drag overlay - pointer-events-none so it doesn't interfere with drop events */}
|
||||
{isDragOver && (
|
||||
<div className="absolute inset-0 bg-surface-baseccent-blue/10 border-2 border-dashed border-accent-blue rounded-lg flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="absolute inset-0 bg-accent-blue/10 border-2 border-dashed border-accent-blue rounded-lg flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="flex flex-col items-center gap-2 text-accent-blue">
|
||||
<Upload className="w-10 h-10" />
|
||||
<span className="text-sm font-medium">拖拽文件到此处上传</span>
|
||||
<span className="text-sm font-medium">
|
||||
{isLocal || dragSource === 'remote' ? '下载到' : '上传到'} {dropTargetPath || path}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="px-4 py-2 border-t border-dark-700/20 text-xs text-txt-muted">
|
||||
{loading ? '加载中...' : selected.size > 0 ? `已选择 ${selected.size} 项` : `共 ${files.length} 项`}
|
||||
<div className="px-4 py-2 border-t border-dark-700/20 text-xs text-txt-muted flex items-center justify-between gap-2">
|
||||
<span>{statusText}</span>
|
||||
{isFiltering && (
|
||||
<span className="text-txt-faint truncate">筛选: {nameFilter.trim()}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
|
||||
@@ -13,18 +13,46 @@ interface FileRowProps {
|
||||
onOpen: () => void
|
||||
onContextMenu: (e: React.MouseEvent) => void
|
||||
onDragStart?: (e: React.DragEvent) => void
|
||||
onDragEnd?: (e: React.DragEvent) => void
|
||||
onDragEnter?: (e: React.DragEvent) => void
|
||||
onDragLeave?: (e: React.DragEvent) => void
|
||||
onDragOver?: (e: React.DragEvent) => void
|
||||
onDrop?: (e: React.DragEvent) => void
|
||||
isDropTarget?: boolean
|
||||
}
|
||||
|
||||
export function FileRow({ file, isSelected, onSelect, onOpen, onContextMenu, onDragStart }: FileRowProps) {
|
||||
export function FileRow({
|
||||
file,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onOpen,
|
||||
onContextMenu,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
isDropTarget = false,
|
||||
}: FileRowProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
onDoubleClick={file.type === 'directory' || file.type === 'symlink' ? onOpen : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
draggable={file.type === 'file'}
|
||||
draggable={!!onDragStart}
|
||||
onDragStart={onDragStart}
|
||||
className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/20 transition-colors group ${
|
||||
isSelected ? 'bg-surface-baseccent-blue/20 hover:bg-surface-baseccent-blue/25' : 'hover:bg-dark-700/50'
|
||||
onDragEnd={onDragEnd}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/20 transition-colors group last:border-b-0 ${
|
||||
isDropTarget
|
||||
? 'bg-accent-blue/25 ring-1 ring-inset ring-accent-blue'
|
||||
: isSelected
|
||||
? 'bg-accent-blue/20 hover:bg-accent-blue/25'
|
||||
: 'hover:bg-dark-700/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Remote text file editor (SFTP) with Monaco
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo, lazy, Suspense } from 'react'
|
||||
import { X, Save, FileCode } from 'lucide-react'
|
||||
import { guessLanguageFromPath } from '../../utils/fileLanguage'
|
||||
import { readRemoteTextFile, remotePath as toRemotePath, writeRemoteTextFile } from '../../platform/sftp'
|
||||
|
||||
const MonacoEditor = lazy(() => import('@monaco-editor/react'))
|
||||
|
||||
export interface RemoteTextEditorProps {
|
||||
isOpen: boolean
|
||||
sftpId: string
|
||||
remotePath: string
|
||||
fileName: string
|
||||
onClose: () => void
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
export default function RemoteTextEditor({
|
||||
isOpen,
|
||||
sftpId,
|
||||
remotePath,
|
||||
fileName,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: RemoteTextEditorProps) {
|
||||
const [content, setContent] = useState('')
|
||||
const [original, setOriginal] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const dirty = content !== original
|
||||
const language = useMemo(() => guessLanguageFromPath(remotePath || fileName), [remotePath, fileName])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!isOpen || !sftpId || !remotePath) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await readRemoteTextFile({ sftpId, remotePath: toRemotePath(remotePath) })
|
||||
if (!result.success || result.content === undefined) {
|
||||
throw new Error(result?.error || '读取失败')
|
||||
}
|
||||
setContent(result.content)
|
||||
setOriginal(result.content)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isOpen, sftpId, remotePath])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// Cmd/Ctrl+S
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
if (dirty && !saving && !loading) {
|
||||
void handleSave()
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, dirty, saving, loading, content])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await writeRemoteTextFile({
|
||||
sftpId,
|
||||
remotePath: toRemotePath(remotePath),
|
||||
content,
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error(result?.error || '保存失败')
|
||||
}
|
||||
setOriginal(content)
|
||||
onSaved?.()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (dirty && !confirm('有未保存的修改,确定关闭?')) return
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="flex h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-dark-600 bg-dark-800 shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-dark-700 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileCode className="h-4 w-4 flex-shrink-0 text-accent-blue" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-content-primary">
|
||||
{fileName}
|
||||
{dirty && <span className="ml-1 text-yellow-400">•</span>}
|
||||
<span className="ml-2 text-xs font-normal text-txt-faint">{language}</span>
|
||||
</div>
|
||||
<div className="truncate font-mono text-xs text-txt-faint">{remotePath}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving || loading}
|
||||
onClick={() => void handleSave()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent-blue px-3 py-1.5 text-sm text-white hover:bg-blue-500 disabled:opacity-40"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
<button type="button" onClick={handleClose} className="rounded-lg p-2 text-txt-muted hover:bg-dark-700">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-300">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-muted">加载中...</div>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-muted">加载编辑器...</div>
|
||||
}
|
||||
>
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
theme="vs-dark"
|
||||
value={content}
|
||||
onChange={(v) => setContent(v ?? '')}
|
||||
options={{
|
||||
fontSize: 13,
|
||||
fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace',
|
||||
minimap: { enabled: true, scale: 0.75 },
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
tabSize: 2,
|
||||
renderWhitespace: 'selection',
|
||||
smoothScrolling: true,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t border-dark-700 px-4 py-2 text-xs text-txt-faint">
|
||||
<span>UTF-8 · Monaco · ⌘/Ctrl+S 保存 · 最大约 2MB</span>
|
||||
<span>
|
||||
{content.length.toLocaleString()} 字符
|
||||
{dirty ? ' · 未保存' : ' · 已保存'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,22 +8,24 @@ import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
FolderOpen, Upload, Download, Trash2,
|
||||
Edit3, Copy, FileText, ShieldCheck,
|
||||
Unplug,
|
||||
Unplug, FileCode,
|
||||
} from 'lucide-react'
|
||||
import { useSFTP } from '../hooks'
|
||||
import { useToast } from '../hooks/useToast'
|
||||
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 { ToastContainer } from './common/ToastContainer'
|
||||
import EmptyState from './common/EmptyState'
|
||||
import { sortFiles } from './sftp/utils'
|
||||
import { usePromptDialog } from './common/PromptDialog'
|
||||
import FilePropertiesDialog from './sftp/FilePropertiesDialog'
|
||||
import ChmodDialog from './sftp/ChmodDialog'
|
||||
import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.types'
|
||||
import { useSFTP } from '../../hooks'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import { FileListPanel } from './FileListPanel'
|
||||
import { TransferPanel } from './TransferPanel'
|
||||
import { Toolbar } from './Toolbar'
|
||||
import { ConnectionPanel } from './ConnectionPanel'
|
||||
import { ContextMenu, type MenuItem } from './ContextMenu'
|
||||
import { ToastContainer } from '../common/ToastContainer'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
import { sortFiles } from './utils'
|
||||
import { usePromptDialog } from '../common/PromptDialog'
|
||||
import FilePropertiesDialog from './FilePropertiesDialog'
|
||||
import ChmodDialog from './ChmodDialog'
|
||||
import RemoteTextEditor from './RemoteTextEditor'
|
||||
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
||||
import type { SftpDragEntry } from './dragDrop'
|
||||
|
||||
interface SFTPViewProps {
|
||||
tabId: string
|
||||
@@ -60,11 +62,13 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
transferQueue,
|
||||
uploadFiles,
|
||||
downloadFiles,
|
||||
removeTransfer,
|
||||
deleteRemote,
|
||||
renameRemote,
|
||||
getRemoteStat,
|
||||
chmodRemote,
|
||||
error,
|
||||
sftpId,
|
||||
} = useSFTP({ tabId })
|
||||
|
||||
const { toasts, toast, removeToast } = useToast()
|
||||
@@ -84,6 +88,9 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
const [chmodFile, setChmodFile] = useState<FileItem | null>(null)
|
||||
const [chmodInitialMode, setChmodInitialMode] = useState(0o644)
|
||||
|
||||
// Remote text editor
|
||||
const [editFile, setEditFile] = useState<FileItem | null>(null)
|
||||
|
||||
// Auto-connect when sessionId is provided and not connected
|
||||
const autoConnectAttempted = useRef(false)
|
||||
const connectRef = useRef(connect)
|
||||
@@ -129,6 +136,25 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
})
|
||||
}, [setRemoteSelected])
|
||||
|
||||
const reportTransfer = useCallback((action: '上传' | '下载', result: { completed: number; failed: number; errors: string[] }) => {
|
||||
if (result.failed > 0) {
|
||||
const detail = result.errors[0] || `${action}失败`
|
||||
toast.error(`${action}完成 ${result.completed} 个,失败 ${result.failed} 个:${detail}`)
|
||||
} else if (result.completed > 0) {
|
||||
toast.success(`${action}完成 ${result.completed} 个文件`)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
const performUpload = useCallback(async (sources: Array<{ path: string; name?: string; size?: number }>, targetPath?: string) => {
|
||||
const result = await uploadFiles(sources, targetPath)
|
||||
reportTransfer('上传', result)
|
||||
}, [reportTransfer, uploadFiles])
|
||||
|
||||
const performDownload = useCallback(async (files?: FileItem[], targetPath?: string) => {
|
||||
const result = await downloadFiles(files, targetPath)
|
||||
reportTransfer('下载', result)
|
||||
}, [downloadFiles, reportTransfer])
|
||||
|
||||
// ===== Context menu handlers =====
|
||||
|
||||
// Rename handler
|
||||
@@ -193,11 +219,13 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
label: '打开',
|
||||
action: () => file.type === 'directory' && navigateLocal(file.path),
|
||||
},
|
||||
{
|
||||
icon: <Upload className="w-4 h-4" />,
|
||||
label: '上传',
|
||||
action: () => {},
|
||||
},
|
||||
...(file.type !== 'directory'
|
||||
? [{
|
||||
icon: <Upload className="w-4 h-4" />,
|
||||
label: '上传',
|
||||
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
|
||||
} as MenuItem]
|
||||
: []),
|
||||
{ icon: <Copy className="w-4 h-4" />, label: '复制路径', separator: true, action: () => handleCopyPath(file.path) },
|
||||
{
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
@@ -206,7 +234,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
},
|
||||
]
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||
}, [navigateLocal, handleCopyPath, handleShowProperties])
|
||||
}, [navigateLocal, handleCopyPath, handleShowProperties, performUpload])
|
||||
|
||||
// Remote context menu
|
||||
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
||||
@@ -220,11 +248,17 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
{
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
label: '下载',
|
||||
action: () => {
|
||||
setRemoteSelected(new Set([file.name]))
|
||||
downloadFiles()
|
||||
},
|
||||
action: () => void performDownload([file]),
|
||||
},
|
||||
...(file.type === 'file'
|
||||
? [
|
||||
{
|
||||
icon: <FileCode className="w-4 h-4" />,
|
||||
label: '编辑',
|
||||
action: () => setEditFile(file),
|
||||
} as MenuItem,
|
||||
]
|
||||
: []),
|
||||
{ icon: <Edit3 className="w-4 h-4" />, label: '重命名', separator: true, action: () => handleRename(file) },
|
||||
{ icon: <Copy className="w-4 h-4" />, label: '复制路径', action: () => handleCopyPath(file.path) },
|
||||
{
|
||||
@@ -240,7 +274,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
{ icon: <Trash2 className="w-4 h-4" />, label: '删除', separator: true, danger: true, action: () => deleteRemote([file]) },
|
||||
]
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||
}, [navigateRemote, downloadFiles, deleteRemote, setRemoteSelected, handleRename, handleCopyPath, handleShowProperties, handleChmod])
|
||||
}, [navigateRemote, performDownload, deleteRemote, handleRename, handleCopyPath, handleShowProperties, handleChmod])
|
||||
|
||||
// Navigate up
|
||||
const navigateLocalUp = useCallback(() => {
|
||||
@@ -266,30 +300,71 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
}
|
||||
}, [navigateRemote])
|
||||
|
||||
// Handle upload (via file picker)
|
||||
const handleUpload = useCallback(() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
input.onchange = (e) => {
|
||||
uploadFiles((e.target as HTMLInputElement).files)
|
||||
// Upload selected local rows first; otherwise use the native picker so Rust
|
||||
// receives real absolute paths instead of browser-only File objects.
|
||||
const handleUpload = useCallback(async () => {
|
||||
const selectedFiles = localFiles.filter(
|
||||
(file) => localSelected.has(file.name) && file.type !== 'directory'
|
||||
)
|
||||
if (selectedFiles.length > 0) {
|
||||
await performUpload(selectedFiles.map((file) => ({
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
})))
|
||||
return
|
||||
}
|
||||
input.click()
|
||||
}, [uploadFiles])
|
||||
|
||||
if (localSelected.size > 0) {
|
||||
toast.warning('暂不支持上传文件夹,请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await window.opsxApi?.showOpenDialog({
|
||||
title: '选择要上传的文件',
|
||||
defaultPath: localPath,
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
})
|
||||
if (!result || result.canceled) return
|
||||
const paths = result.filePaths ?? (result.filePath ? [result.filePath] : [])
|
||||
if (paths.length > 0) {
|
||||
await performUpload(paths.map((path) => ({ path })))
|
||||
}
|
||||
}, [localFiles, localPath, localSelected, performUpload, toast])
|
||||
|
||||
// Handle drag-and-drop upload on remote panel
|
||||
const handleDropFiles = useCallback((files: FileList) => {
|
||||
const handleDropFiles = useCallback((files: FileList, targetPath: string) => {
|
||||
if (!isConnected) return
|
||||
const count = files.length
|
||||
toast.info(`正在上传 ${count} 个文件...`)
|
||||
uploadFiles(files)
|
||||
.then(() => {
|
||||
toast.success(`${count} 个文件上传完成`)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(`上传失败: ${err?.message || '未知错误'}`)
|
||||
})
|
||||
}, [isConnected, uploadFiles, toast])
|
||||
const sources = Array.from(files).flatMap((file) => {
|
||||
const path = (file as File & { path?: string }).path
|
||||
return path ? [{ path, name: file.name, size: file.size }] : []
|
||||
})
|
||||
if (sources.length === 0) {
|
||||
toast.warning('当前系统未提供拖拽文件路径,请使用“上传”按钮选择文件')
|
||||
return
|
||||
}
|
||||
void performUpload(sources, targetPath)
|
||||
}, [isConnected, performUpload, toast])
|
||||
|
||||
const handleDropEntry = useCallback((entry: SftpDragEntry, targetPath: string) => {
|
||||
if (!isConnected) return
|
||||
if (entry.type !== 'file') {
|
||||
toast.warning('暂不支持文件夹递归传输,请拖拽单个文件')
|
||||
return
|
||||
}
|
||||
if (entry.source === 'local') {
|
||||
void performUpload([{ path: entry.path, name: entry.name, size: entry.size }], targetPath)
|
||||
} else {
|
||||
void performDownload([{
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: entry.type,
|
||||
size: entry.size,
|
||||
modified: 0,
|
||||
permissions: '',
|
||||
}], targetPath)
|
||||
}
|
||||
}, [isConnected, performDownload, performUpload, toast])
|
||||
|
||||
// Handle new folder
|
||||
const handleNewFolder = useCallback(async () => {
|
||||
@@ -298,8 +373,8 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
message: '输入文件夹名称:',
|
||||
placeholder: '文件夹名称',
|
||||
})
|
||||
if (name && window.electronAPI?.localMkdir) {
|
||||
window.electronAPI.localMkdir(`${localPath}/${name}`).then(() => {
|
||||
if (name && window.opsxApi?.localMkdir) {
|
||||
window.opsxApi.localMkdir(`${localPath}/${name}`).then(() => {
|
||||
loadLocalDirectory(localPath)
|
||||
})
|
||||
}
|
||||
@@ -323,7 +398,10 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
<Toolbar
|
||||
isConnected={isConnected}
|
||||
onUpload={handleUpload}
|
||||
onDownload={downloadFiles}
|
||||
canDownload={remoteFiles.some(
|
||||
(file) => remoteSelected.has(file.name) && file.type !== 'directory'
|
||||
)}
|
||||
onDownload={() => void performDownload()}
|
||||
onRefresh={() => {
|
||||
loadLocalDirectory(localPath)
|
||||
if (isConnected) loadRemoteDirectory(remotePath)
|
||||
@@ -363,6 +441,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
onSort={handleSort}
|
||||
contextMenu={contextMenu}
|
||||
onCloseContextMenu={() => setContextMenu(null)}
|
||||
onDropEntry={handleDropEntry}
|
||||
/>
|
||||
|
||||
{/* Remote Panel */}
|
||||
@@ -387,6 +466,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
contextMenu={contextMenu}
|
||||
onCloseContextMenu={() => setContextMenu(null)}
|
||||
onDropFiles={isConnected ? handleDropFiles : undefined}
|
||||
onDropEntry={handleDropEntry}
|
||||
loading={isConnecting}
|
||||
/>
|
||||
</div>
|
||||
@@ -397,8 +477,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
tasks={transferQueue}
|
||||
isOpen={isTransferPanelOpen}
|
||||
onToggle={() => setIsTransferPanelOpen(!isTransferPanelOpen)}
|
||||
onCancel={() => {}}
|
||||
onRemove={() => {}}
|
||||
onRemove={removeTransfer}
|
||||
/>
|
||||
|
||||
{/* Global Context Menu */}
|
||||
@@ -439,6 +518,18 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Remote text editor */}
|
||||
{editFile && sftpId && (
|
||||
<RemoteTextEditor
|
||||
isOpen={!!editFile}
|
||||
sftpId={sftpId}
|
||||
remotePath={editFile.path}
|
||||
fileName={editFile.name}
|
||||
onClose={() => setEditFile(null)}
|
||||
onSaved={() => toast.success('文件已保存')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
@@ -11,26 +11,37 @@ interface SortHeaderProps {
|
||||
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' },
|
||||
]
|
||||
const FIELDS: { key: SortField; label: string; className: string }[] = [
|
||||
{ key: 'name', label: '名称', className: 'flex-1 min-w-0 justify-start' },
|
||||
{ key: 'modified', label: '修改时间', className: 'w-32 justify-end' },
|
||||
{ key: 'size', label: '大小', className: 'w-24 justify-end' },
|
||||
{ key: 'type', label: '类型', className: 'w-24 justify-end' },
|
||||
]
|
||||
|
||||
export function SortHeader({ onSort, sortField, sortOrder }: SortHeaderProps) {
|
||||
return (
|
||||
<div className="h-9 flex items-center px-4 bg-dark-700/50 text-xs text-txt-muted border-b border-dark-700/30 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-content-primary transition-colors`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{sortField === key && (sortOrder === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />)}
|
||||
</button>
|
||||
))}
|
||||
<div className="h-8 flex items-center w-full text-xs text-txt-muted select-none">
|
||||
{FIELDS.map(({ key, label, className }) => {
|
||||
const active = sortField === key
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onSort(key)}
|
||||
className={`${className} flex items-center gap-1 hover:text-content-primary transition-colors ${
|
||||
active ? 'text-content-primary' : ''
|
||||
}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{active &&
|
||||
(sortOrder === 'asc' ? (
|
||||
<ArrowUp className="w-3 h-3 flex-shrink-0" />
|
||||
) : (
|
||||
<ArrowDown className="w-3 h-3 flex-shrink-0" />
|
||||
))}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,27 +8,29 @@ import { Upload, Download, RefreshCw, FolderPlus } from 'lucide-react'
|
||||
|
||||
interface ToolbarProps {
|
||||
isConnected: boolean
|
||||
canDownload: boolean
|
||||
onUpload: () => void
|
||||
onDownload: () => void
|
||||
onRefresh: () => void
|
||||
onNewFolder: () => void
|
||||
}
|
||||
|
||||
export function Toolbar({ isConnected, onUpload, onDownload, onRefresh, onNewFolder }: ToolbarProps) {
|
||||
export function Toolbar({ isConnected, canDownload, 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/30">
|
||||
<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-surface-baseccent-blue hover:bg-surface-baselue-600 disabled:bg-dark-700 disabled:text-txt-muted rounded text-sm font-medium transition-colors"
|
||||
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-txt-muted rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
上传
|
||||
</button>
|
||||
<button
|
||||
onClick={onDownload}
|
||||
disabled={!isConnected}
|
||||
disabled={!isConnected || !canDownload}
|
||||
title={canDownload ? '下载选中的远程文件' : '请先选择远程文件'}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 disabled:text-txt-muted rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
|
||||
@@ -8,7 +8,7 @@ import { formatSpeed } from './utils'
|
||||
|
||||
interface TransferItemProps {
|
||||
task: TransferTask
|
||||
onCancel: () => void
|
||||
onCancel?: () => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
|
||||
{isActive && (
|
||||
<>
|
||||
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden max-w-[120px]">
|
||||
<div className="h-full bg-surface-baseccent-blue transition-all duration-300" style={{ width: `${task.progress}%` }} />
|
||||
<div className="h-full bg-accent-blue transition-all duration-300" style={{ width: `${task.progress}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-txt-muted">{task.progress}%</span>
|
||||
{task.speed !== undefined && <span className="text-xs text-txt-muted">{formatSpeed(task.speed)}</span>}
|
||||
@@ -59,7 +59,7 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{task.status === 'transferring' && (
|
||||
{task.status === 'transferring' && onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1.5 hover:bg-dark-700 rounded text-txt-muted hover:text-red-400"
|
||||
|
||||
@@ -12,7 +12,7 @@ interface TransferPanelProps {
|
||||
tasks: TransferTask[]
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
onCancel: (id: string) => void
|
||||
onCancel?: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">传输队列</span>
|
||||
{activeCount > 0 && (
|
||||
<span className="px-2 py-0.5 bg-surface-baseccent-blue/20 text-accent-blue text-xs rounded-full">
|
||||
<span className="px-2 py-0.5 bg-accent-blue/20 text-accent-blue text-xs rounded-full">
|
||||
{activeCount} 进行中
|
||||
</span>
|
||||
)}
|
||||
@@ -53,7 +53,7 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
|
||||
<TransferItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
onCancel={() => onCancel(task.id)}
|
||||
onCancel={onCancel ? () => onCancel(task.id) : undefined}
|
||||
onRemove={() => onRemove(task.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FileItem } from '../../../shared/types/sftp.types'
|
||||
|
||||
export const SFTP_ENTRY_MIME = 'application/x-opsxshell-sftp-entry'
|
||||
|
||||
export interface SftpDragEntry extends Pick<FileItem, 'name' | 'path' | 'type' | 'size'> {
|
||||
source: 'local' | 'remote'
|
||||
}
|
||||
|
||||
export function writeSftpDragEntry(dataTransfer: DataTransfer, entry: SftpDragEntry): void {
|
||||
dataTransfer.effectAllowed = 'copy'
|
||||
dataTransfer.setData(SFTP_ENTRY_MIME, JSON.stringify(entry))
|
||||
dataTransfer.setData('text/plain', entry.path)
|
||||
}
|
||||
|
||||
export function hasSftpDragEntry(dataTransfer: DataTransfer): boolean {
|
||||
return Array.from(dataTransfer.types || []).includes(SFTP_ENTRY_MIME)
|
||||
}
|
||||
|
||||
export function readSftpDragEntry(dataTransfer: DataTransfer): SftpDragEntry | null {
|
||||
try {
|
||||
const raw = dataTransfer.getData(SFTP_ENTRY_MIME)
|
||||
if (!raw) return null
|
||||
const value = JSON.parse(raw) as Partial<SftpDragEntry>
|
||||
if (
|
||||
(value.source !== 'local' && value.source !== 'remote') ||
|
||||
typeof value.path !== 'string' ||
|
||||
typeof value.name !== 'string' ||
|
||||
!['file', 'directory', 'symlink'].includes(value.type || '')
|
||||
) return null
|
||||
return {
|
||||
source: value.source,
|
||||
path: value.path,
|
||||
name: value.name,
|
||||
type: value.type as FileItem['type'],
|
||||
size: typeof value.size === 'number' ? value.size : 0,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* SFTP Components - SFTP 模块组件导出
|
||||
* SFTP — 文件传输模块
|
||||
*/
|
||||
|
||||
export { default as SFTPView } from './SFTPView'
|
||||
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'
|
||||
export { FileListPanel } from './FileListPanel'
|
||||
export { TransferPanel } from './TransferPanel'
|
||||
export { Toolbar } from './Toolbar'
|
||||
@@ -36,7 +36,7 @@ export interface ContextMenuProps {
|
||||
|
||||
export interface TransferItemProps {
|
||||
task: SFTPTransferTask
|
||||
onCancel: () => void
|
||||
onCancel?: () => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
|
||||
@@ -84,3 +84,34 @@ export function sortFiles(files: FileItem[], sortField: SortField, sortOrder: So
|
||||
return sortOrder === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 按文件名过滤当前目录列表(不区分大小写,子串匹配)
|
||||
* query 为空时返回原数组引用
|
||||
*/
|
||||
export function filterFilesByName(files: FileItem[], query: string): FileItem[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return files
|
||||
return files.filter((f) => f.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为隐藏项(Unix 风格:以 . 开头;始终视为隐藏的 . / ..)
|
||||
*/
|
||||
export function isHiddenFileName(name: string): boolean {
|
||||
if (!name) return false
|
||||
if (name === '.' || name === '..') return true
|
||||
return name.startsWith('.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 按是否显示隐藏文件过滤列表
|
||||
* showHidden=true 时仍排除 . 与 ..
|
||||
*/
|
||||
export function filterHiddenFiles(files: FileItem[], showHidden: boolean): FileItem[] {
|
||||
return files.filter((f) => {
|
||||
if (f.name === '.' || f.name === '..') return false
|
||||
if (showHidden) return true
|
||||
return !isHiddenFileName(f.name)
|
||||
})
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* SessionTree - 会话树组件
|
||||
*
|
||||
* 显示分组和会话列表,带折叠动画
|
||||
*/
|
||||
|
||||
import { ChevronRight, Folder, Server, ServerOff } from 'lucide-react'
|
||||
import { SessionTreeSkeleton } from '../common/Skeleton'
|
||||
import type { Session } from '../../stores/sessionStore'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
|
||||
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
|
||||
/** 是否正在加载 */
|
||||
loading?: boolean
|
||||
/** 新建会话回调,用于空状态操作按钮 */
|
||||
onNewSession?: () => void
|
||||
}
|
||||
|
||||
export function SessionTree({
|
||||
sessions,
|
||||
expandedGroups,
|
||||
selectedSessionId,
|
||||
onToggleGroup,
|
||||
onSelectSession,
|
||||
onContextMenu,
|
||||
searchTerm,
|
||||
loading = false,
|
||||
onNewSession,
|
||||
}: SessionTreeProps) {
|
||||
// 加载中时显示骨架屏
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<SessionTreeSkeleton groups={2} sessionsPerGroup={[3, 4]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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]) => {
|
||||
const isExpanded = expandedGroups.has(group)
|
||||
return (
|
||||
<div key={group} className="border-b border-dark-700/20 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-txt-muted hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className="w-4 h-4 transition-transform duration-200 ease-in-out"
|
||||
style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
<Folder className="w-4 h-4" />
|
||||
<span className="flex-1 text-left">{group === 'default' ? '默认分组' : group}</span>
|
||||
<span className="text-xs text-txt-muted">{groupSessions.length}</span>
|
||||
</button>
|
||||
|
||||
{/* Sessions - CSS Grid collapse animation */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
|
||||
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div>
|
||||
{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 duration-150 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-content-primary truncate">{session.name}</div>
|
||||
<div className="text-xs text-txt-muted truncate">{session.host}:{session.port}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{filteredSessions.length === 0 && (
|
||||
<EmptyState
|
||||
icon={ServerOff}
|
||||
title={searchTerm ? '未找到匹配的会话' : '暂无会话'}
|
||||
description={searchTerm ? '尝试其他关键词搜索' : '点击 + 按钮添加你的第一个 SSH 连接'}
|
||||
action={searchTerm ? undefined : onNewSession ? { label: '新建会话', onClick: onNewSession } : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { Code2, Plus, Search, Trash2, Copy, Pencil, X } from 'lucide-react'
|
||||
import { useSnippetStore, type Snippet } from '../../stores/snippetStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { expandSnippetVariables, SNIPPET_VARIABLE_HINTS } from '../../utils/snippetVariables'
|
||||
import Button from '../common/Button'
|
||||
import Modal from '../common/Modal'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
@@ -70,10 +71,13 @@ function SnippetFormModal({ isOpen, initial, title, confirmText, onConfirm, onCl
|
||||
<textarea
|
||||
value={form.content}
|
||||
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
|
||||
placeholder="sudo systemctl restart nginx"
|
||||
placeholder={"sudo systemctl restart nginx\n# 支持 {{host}} {{username}} {{date}}"}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-content-primary placeholder-ph focus:outline-none focus:border-accent-blue text-sm font-mono resize-none"
|
||||
/>
|
||||
<p className="text-xs text-txt-faint mt-1">
|
||||
变量:{SNIPPET_VARIABLE_HINTS.slice(0, 6).join(' ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
@@ -128,17 +132,28 @@ export default function SnippetPanel() {
|
||||
return activeTab?.connectionInstance?.shellId ?? null
|
||||
}, [tabs, activeTabId])
|
||||
|
||||
// 插入命令到终端
|
||||
// 插入命令到终端(展开 {{host}} 等变量)
|
||||
const handleInsert = useCallback(async (content: string) => {
|
||||
const shellId = getActiveShellId()
|
||||
if (!shellId) return
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId)
|
||||
const cfg = activeTab?.connectionConfig
|
||||
const expanded = expandSnippetVariables(content, {
|
||||
host: cfg?.host,
|
||||
port: cfg?.port,
|
||||
username: cfg?.username,
|
||||
user: cfg?.username,
|
||||
name: activeTab?.title,
|
||||
session: activeTab?.title,
|
||||
})
|
||||
|
||||
try {
|
||||
await window.electronAPI?.sshShellWrite(shellId, content + '\n')
|
||||
await window.opsxApi?.sshShellWrite(shellId, expanded + '\n')
|
||||
} catch (err) {
|
||||
console.error('Failed to insert snippet:', err)
|
||||
}
|
||||
}, [getActiveShellId])
|
||||
}, [getActiveShellId, tabs, activeTabId])
|
||||
|
||||
// 复制到剪贴板
|
||||
const handleCopy = useCallback(async (snippet: Snippet) => {
|
||||
@@ -316,7 +331,7 @@ function SnippetItem({ snippet, copied, canInsert, onInsert, onCopy, onEdit, onD
|
||||
onClick={() => onInsert(snippet.content)}
|
||||
disabled={!canInsert}
|
||||
title={canInsert ? '插入到终端' : '无活跃终端'}
|
||||
className="p-1 rounded hover:bg-surface-baseccent-blue/20 text-accent-blue disabled:text-txt-faint disabled:hover:bg-transparent transition-colors"
|
||||
className="p-1 rounded hover:bg-accent-blue/20 text-accent-blue disabled:text-txt-faint disabled:hover:bg-transparent transition-colors"
|
||||
>
|
||||
<Code2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* TabItem - 单个标签项(支持拖拽排序)
|
||||
*/
|
||||
|
||||
import { X, Terminal, FolderSync } from 'lucide-react'
|
||||
import type { Tab } from '../../stores/tabStore'
|
||||
|
||||
interface TabItemProps {
|
||||
tab: Tab
|
||||
index: number
|
||||
isActive: boolean
|
||||
isDragging: boolean
|
||||
dragOverIndex: number | null
|
||||
onActivate: () => void
|
||||
onClose: (e: React.MouseEvent) => void
|
||||
onContextMenu: (e: React.MouseEvent) => void
|
||||
onDragStart: (e: React.DragEvent, index: number) => void
|
||||
onDragOver: (e: React.DragEvent, index: number) => void
|
||||
onDrop: (e: React.DragEvent, index: number) => void
|
||||
onDragEnd: () => void
|
||||
}
|
||||
|
||||
export function TabItem({
|
||||
tab,
|
||||
index,
|
||||
isActive,
|
||||
isDragging,
|
||||
dragOverIndex,
|
||||
onActivate,
|
||||
onClose,
|
||||
onContextMenu,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
}: 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
|
||||
}
|
||||
}
|
||||
|
||||
// Determine drop indicator position
|
||||
const showLeftIndicator = dragOverIndex === index && !isDragging
|
||||
const showRightIndicator = dragOverIndex === index + 1 && !isDragging
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onClick={onActivate}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragStart={(e) => onDragStart(e, index)}
|
||||
onDragOver={(e) => onDragOver(e, index)}
|
||||
onDrop={(e) => onDrop(e, index)}
|
||||
onDragEnd={onDragEnd}
|
||||
className={`
|
||||
group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[200px]
|
||||
border-r border-dark-700/20 cursor-pointer select-none
|
||||
transition-all duration-150 relative
|
||||
${isActive
|
||||
? 'bg-dark-900 text-content-primary'
|
||||
: 'bg-dark-800 text-txt-muted hover:bg-dark-700 hover:text-txt-muted'
|
||||
}
|
||||
${isDragging ? 'opacity-50' : 'opacity-100'}
|
||||
`}
|
||||
>
|
||||
{/* Left drop indicator */}
|
||||
{showLeftIndicator && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-surface-baseccent-blue z-10 transition-all duration-150" />
|
||||
)}
|
||||
{/* Right drop indicator */}
|
||||
{showRightIndicator && (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-0.5 bg-surface-baseccent-blue z-10 transition-all duration-150" />
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Active tab bottom indicator — always rendered for width transition */}
|
||||
<div
|
||||
className={`
|
||||
absolute bottom-0 left-0 right-0 h-0.5 bg-surface-baseccent-blue
|
||||
transition-[opacity,transform] duration-150 origin-left
|
||||
${isActive ? 'opacity-100 scale-x-100' : 'opacity-0 scale-x-0'}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Host Key confirmation — security-critical professional dialog
|
||||
*/
|
||||
|
||||
import { memo } from 'react'
|
||||
import { ShieldAlert, ShieldCheck } from 'lucide-react'
|
||||
import Modal from '../common/Modal'
|
||||
import Button from '../common/Button'
|
||||
|
||||
export interface HostKeyDialogProps {
|
||||
open: boolean
|
||||
host: string
|
||||
port: number
|
||||
keyType: string
|
||||
fingerprint: string
|
||||
isChanged: boolean
|
||||
previousFingerprint?: string
|
||||
onDecision: (decision: 'accept-once' | 'accept-always' | 'reject') => void
|
||||
}
|
||||
|
||||
function HostKeyDialog({
|
||||
open,
|
||||
host,
|
||||
port,
|
||||
keyType,
|
||||
fingerprint,
|
||||
isChanged,
|
||||
previousFingerprint,
|
||||
onDecision,
|
||||
}: HostKeyDialogProps) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={() => onDecision('reject')}
|
||||
title={isChanged ? '主机密钥已变更' : '确认主机密钥'}
|
||||
description={
|
||||
isChanged
|
||||
? '该主机的 SSH 密钥与本地记录不一致,可能存在中间人攻击风险。'
|
||||
: '首次连接此主机,请核对指纹后信任。'
|
||||
}
|
||||
icon={
|
||||
isChanged ? (
|
||||
<ShieldAlert className="w-5 h-5 text-red-400" />
|
||||
) : (
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
)
|
||||
}
|
||||
size="md"
|
||||
disableBackdropClose
|
||||
showFooter={false}
|
||||
className={isChanged ? 'border-red-500/30' : undefined}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-dark-900/80 border border-dark-600/70 p-3.5 space-y-2.5 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-txt-muted text-xs">主机</span>
|
||||
<span className="text-content-primary text-right break-all font-mono text-xs">
|
||||
{host}:{port}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-txt-muted text-xs">类型</span>
|
||||
<span className="ui-chip font-mono">{keyType || 'unknown'}</span>
|
||||
</div>
|
||||
<div className="ui-divider pt-2">
|
||||
<div className="text-txt-muted text-xs mb-1.5">SHA256 指纹</div>
|
||||
<div className="text-accent-green break-all text-[11px] leading-relaxed font-mono select-all">
|
||||
SHA256:{fingerprint}
|
||||
</div>
|
||||
</div>
|
||||
{isChanged && previousFingerprint && (
|
||||
<div>
|
||||
<div className="text-txt-muted text-xs mb-1.5">先前指纹</div>
|
||||
<div className="text-red-300/80 break-all text-[11px] leading-relaxed font-mono select-all">
|
||||
SHA256:{previousFingerprint}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button variant="ghost" size="sm" className="flex-1" onClick={() => onDecision('reject')}>
|
||||
拒绝
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="flex-1" onClick={() => onDecision('accept-once')}>
|
||||
仅本次
|
||||
</Button>
|
||||
<Button
|
||||
variant={isChanged ? 'danger' : 'primary'}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => onDecision('accept-always')}
|
||||
>
|
||||
{isChanged ? '仍要信任并更新' : '始终信任'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(HostKeyDialog)
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo } from 'react'
|
||||
import { Terminal as TerminalIcon } from 'lucide-react'
|
||||
import { Terminal as TerminalIcon, WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import type { ConnectionState } from '../../stores/connection'
|
||||
|
||||
export interface TerminalOverlayProps {
|
||||
@@ -7,6 +7,7 @@ export interface TerminalOverlayProps {
|
||||
statusMessage: string
|
||||
showSuspendedOverlay: boolean
|
||||
needsReconnect: boolean
|
||||
reconnectAttempt?: number
|
||||
onReconnect: () => void
|
||||
onResume: () => void
|
||||
}
|
||||
@@ -16,84 +17,103 @@ function TerminalOverlay({
|
||||
statusMessage,
|
||||
showSuspendedOverlay,
|
||||
needsReconnect,
|
||||
reconnectAttempt = 0,
|
||||
onReconnect,
|
||||
onResume,
|
||||
}: TerminalOverlayProps) {
|
||||
const showDisconnectBanner =
|
||||
connectionState === 'disconnected' && needsReconnect
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 会话恢复 - 需要重新连接覆盖层 */}
|
||||
{connectionState === 'disconnected' && needsReconnect && (
|
||||
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-surface-baseccent-blue/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<TerminalIcon className="w-8 h-8 text-accent-blue" />
|
||||
{showDisconnectBanner && (
|
||||
<div className="absolute inset-0 bg-dark-900/92 backdrop-blur-[2px] flex items-center justify-center z-10">
|
||||
<div className="text-center max-w-sm px-6">
|
||||
<div className="w-14 h-14 bg-accent-blue/15 border border-accent-blue/25 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<WifiOff className="w-7 h-7 text-accent-blue" />
|
||||
</div>
|
||||
<div className="text-content-primary mb-2 text-lg">会话已恢复</div>
|
||||
<div className="text-txt-muted text-sm mb-6">
|
||||
上次关闭时的终端标签已恢复<br />
|
||||
点击下方按钮重新建立 SSH 连接
|
||||
<div className="text-content-primary mb-1.5 text-base font-medium">连接已断开</div>
|
||||
<div className="text-txt-muted text-sm mb-6 leading-relaxed">
|
||||
{statusMessage?.trim()
|
||||
? statusMessage
|
||||
: 'SSH 会话已结束或网络中断。点击下方一键重新连接。'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReconnect}
|
||||
className="px-6 py-2.5 bg-surface-baseccent-blue hover:bg-surface-baselue-500 text-content-primary rounded-lg transition-colors"
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 bg-accent-blue hover:bg-accent-blue-hover text-white rounded-xl transition-colors text-sm font-medium shadow-sm shadow-accent-blue/20"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
重新连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 挂起状态覆盖层 */}
|
||||
{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-txt-muted text-sm mb-4">
|
||||
长时间未操作,连接已进入挂起状态
|
||||
<div className="text-center max-w-sm px-6">
|
||||
<div className="text-yellow-400 mb-2 text-base font-medium">连接已挂起</div>
|
||||
<div className="text-txt-muted text-sm mb-5">
|
||||
长时间未操作,连接已进入挂起以节省资源
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResume}
|
||||
className="px-4 py-2 bg-accent-blue hover:bg-accent-blue-hover text-white rounded-lg text-sm transition-colors"
|
||||
>
|
||||
恢复连接
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReconnect}
|
||||
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-content-primary rounded-lg text-sm transition-colors"
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionState === 'error' && (
|
||||
<div className="absolute inset-0 bg-dark-900/88 flex items-center justify-center z-10">
|
||||
<div className="text-center max-w-md px-6">
|
||||
<div className="w-12 h-12 rounded-2xl bg-red-500/15 border border-red-500/25 flex items-center justify-center mx-auto mb-3">
|
||||
<AlertTriangle className="w-6 h-6 text-red-400" />
|
||||
</div>
|
||||
<div className="text-red-300 mb-1.5 font-medium">连接失败</div>
|
||||
<div className="text-txt-muted text-sm mb-5 leading-relaxed">
|
||||
{statusMessage || '请检查主机、端口、用户名与认证方式'}
|
||||
</div>
|
||||
<button
|
||||
onClick={onResume}
|
||||
className="px-4 py-2 bg-surface-baseccent-blue hover:bg-surface-baselue-500 text-content-primary rounded transition-colors mr-2"
|
||||
>
|
||||
恢复连接
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReconnect}
|
||||
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-content-primary rounded transition-colors"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-accent-blue hover:bg-accent-blue-hover text-white rounded-xl text-sm font-medium transition-colors"
|
||||
>
|
||||
重新连接
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
重试连接
|
||||
</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-txt-muted text-sm mb-4">{statusMessage}</div>
|
||||
<button
|
||||
onClick={onReconnect}
|
||||
className="px-4 py-2 bg-surface-baseccent-blue hover:bg-surface-baselue-500 text-content-primary rounded transition-colors"
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
{(connectionState === 'connecting' || connectionState === 'reconnecting') && (
|
||||
<div className="absolute inset-0 bg-dark-900/45 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="text-center px-4">
|
||||
<div className="inline-flex items-center gap-2 text-accent-blue animate-pulse text-sm font-medium">
|
||||
<TerminalIcon className="w-4 h-4" />
|
||||
{connectionState === 'reconnecting'
|
||||
? `自动重连中${reconnectAttempt ? ` (${reconnectAttempt})` : ''}…`
|
||||
: '正在连接…'}
|
||||
</div>
|
||||
{statusMessage && connectionState === 'reconnecting' && (
|
||||
<div className="text-txt-muted text-xs mt-2 max-w-sm">{statusMessage}</div>
|
||||
)}
|
||||
</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-accent-blue animate-pulse">连接中...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 连接状态指示器 - 简洁版 */}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-2 z-20">
|
||||
{/* 状态指示已移至 TabBar */}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,18 +2,22 @@ import { useEffect, useRef } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { themes } from '../themes/terminal'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { getConnectionStore } from '../stores/connection'
|
||||
import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import { useTerminalConnection } from '../hooks/useTerminalConnection'
|
||||
import { useTerminalSearch } from '../hooks/useTerminalSearch'
|
||||
import TerminalOverlay from './terminal/TerminalOverlay'
|
||||
import TerminalSearchBar from './terminal/TerminalSearchBar'
|
||||
import type { TerminalViewProps } from './terminal/types'
|
||||
import { themes } from '../../themes/terminal'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { getConnectionStore } from '../../stores/connection'
|
||||
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
|
||||
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
||||
import { buildTerminalFontFamily } from '../../utils/terminalFont'
|
||||
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
|
||||
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
|
||||
import TerminalOverlay from './TerminalOverlay'
|
||||
import TerminalSearchBar from './TerminalSearchBar'
|
||||
import type { TerminalViewProps } from './types'
|
||||
|
||||
const logger = createLogger('TerminalView')
|
||||
|
||||
@@ -22,20 +26,29 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
const xtermRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
const searchAddonRef = useRef<SearchAddon | null>(null)
|
||||
const handleShellDataRef = useRef<((_event: unknown, _connId: string, shellId: string, data: Buffer) => void) | null>(null)
|
||||
const handleShellDataRef = useRef<
|
||||
((_event: unknown, _connId: string, shellId: string, data: Uint8Array) => void) | null
|
||||
>(null)
|
||||
const registeredShellHandlerRef = useRef<
|
||||
((_event: unknown, _connId: string, shellId: string, data: Uint8Array) => void) | null
|
||||
>(null)
|
||||
const pipelineRef = useRef<TerminalPipeline | null>(null)
|
||||
const listenerRegisteredRef = useRef(false)
|
||||
const settingsRef = useRef(useSettingsStore.getState().settings)
|
||||
|
||||
const { settings, loadSettings } = useSettingsStore()
|
||||
const { getTabById } = useTabStore()
|
||||
const snapshotStore = useTerminalSnapshotStore()
|
||||
|
||||
settingsRef.current = settings
|
||||
|
||||
const currentTheme = themes[settings.terminalTheme] || themes['dracula']
|
||||
|
||||
// 连接管理 hook
|
||||
const {
|
||||
connectionState,
|
||||
statusMessage,
|
||||
showSuspendedOverlay,
|
||||
reconnectAttempt,
|
||||
connectionIdRef,
|
||||
shellIdRef,
|
||||
hasConnectedRef,
|
||||
@@ -51,7 +64,6 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
xtermRef,
|
||||
})
|
||||
|
||||
// 搜索功能 hook
|
||||
const {
|
||||
showSearch,
|
||||
searchQuery,
|
||||
@@ -68,7 +80,6 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
isActive,
|
||||
})
|
||||
|
||||
// 加载设置
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [loadSettings])
|
||||
@@ -79,22 +90,20 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
return
|
||||
}
|
||||
|
||||
// 处理窗口大小调整
|
||||
const handleResize = () => {
|
||||
fitAddonRef.current?.fit()
|
||||
if (shellIdRef.current && xtermRef.current) {
|
||||
const { cols, rows } = xtermRef.current
|
||||
window.electronAPI?.sshShellResize(shellIdRef.current, cols, rows)
|
||||
window.opsxApi?.sshShellResize(shellIdRef.current, cols, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 xterm 不存在,创建它
|
||||
if (!xtermRef.current) {
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
fontSize: settings.fontSize,
|
||||
fontFamily: settings.fontFamily + ', Menlo, Monaco, "Courier New", monospace',
|
||||
fontFamily: buildTerminalFontFamily(settings.fontFamily),
|
||||
theme: {
|
||||
background: currentTheme.background,
|
||||
foreground: currentTheme.foreground,
|
||||
@@ -116,14 +125,21 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
brightCyan: currentTheme.brightCyan,
|
||||
brightWhite: currentTheme.brightWhite,
|
||||
},
|
||||
scrollback: 10000,
|
||||
rows: 30
|
||||
scrollback: settings.scrollback || 10000,
|
||||
rows: 30,
|
||||
allowProposedApi: true,
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
const searchAddon = new SearchAddon()
|
||||
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
||||
void window.opsxApi?.openExternal?.(uri).catch(() => {
|
||||
logger.warn('Failed to open link', uri)
|
||||
})
|
||||
})
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(searchAddon)
|
||||
term.loadAddon(webLinksAddon)
|
||||
term.open(terminalRef.current)
|
||||
|
||||
fitAddon.fit()
|
||||
@@ -131,69 +147,60 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
xtermRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
searchAddonRef.current = searchAddon
|
||||
|
||||
// 处理终端输入
|
||||
term.onData((data) => {
|
||||
if (shellIdRef.current && window.electronAPI) {
|
||||
// 更新活动状态
|
||||
pipelineRef.current = new TerminalPipeline({
|
||||
input: (data) => {
|
||||
if (!shellIdRef.current || !window.opsxApi) return
|
||||
return window.opsxApi.sshShellWrite(shellIdRef.current, data)
|
||||
},
|
||||
onInput: () => {
|
||||
if (connectionIdRef.current) {
|
||||
getConnectionStore().updateActivity(connectionIdRef.current)
|
||||
}
|
||||
window.electronAPI.sshShellWrite(shellIdRef.current, data)
|
||||
}
|
||||
},
|
||||
output: {
|
||||
write: (data) => term.write(data),
|
||||
transform: (data) => {
|
||||
const currentSettings = settingsRef.current
|
||||
return currentSettings.keywordHighlightEnabled && currentSettings.keywordHighlightRules?.length
|
||||
? applyKeywordHighlights(data, currentSettings.keywordHighlightRules)
|
||||
: data
|
||||
},
|
||||
},
|
||||
onOutputBytes: (bytes) => {
|
||||
const connId = connectionIdRef.current
|
||||
if (!connId) return
|
||||
const conn = getConnectionStore().getConnection(connId)
|
||||
if (conn) conn.stats.bytesTransferred += bytes
|
||||
},
|
||||
onError: (error) => logger.warn('终端输入失败', error),
|
||||
})
|
||||
|
||||
term.onData((data) => pipelineRef.current?.input(data))
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
// IP 地址高亮处理 - 将包含 "from" 或 "Last login" 行中的 IP 地址标记为绿色
|
||||
const highlightIPAddresses = (text: string): string => {
|
||||
const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g
|
||||
if (text.includes('Last login') || text.includes(' from ')) {
|
||||
return text.replace(ipRegex, '\x1b[32m$1\x1b[0m')
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// Shell 数据处理器
|
||||
handleShellDataRef.current = (_event: unknown, _connId: string, shellId: string, data: Buffer) => {
|
||||
const text = data.toString('utf-8')
|
||||
|
||||
handleShellDataRef.current = (_event: unknown, _connId: string, shellId: string, data: Uint8Array) => {
|
||||
// IPC may deliver Buffer as number[] — Array#toString would print "27,91,..."
|
||||
if (shellId === shellIdRef.current && xtermRef.current) {
|
||||
let processedData = data
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
xtermRef.current.write(processedData)
|
||||
|
||||
const connId = connectionIdRef.current
|
||||
if (connId) {
|
||||
const conn = getConnectionStore().getConnection(connId)
|
||||
if (conn) {
|
||||
conn.stats.bytesTransferred += data.length
|
||||
}
|
||||
}
|
||||
pipelineRef.current?.output(data)
|
||||
}
|
||||
}
|
||||
|
||||
// 只注册一次监听器(防止严格模式下重复注册)
|
||||
if (!listenerRegisteredRef.current) {
|
||||
const handler = (event: unknown, connectionId: string, shellId: string, data: Buffer) => {
|
||||
const handler = (event: unknown, connectionId: string, shellId: string, data: Uint8Array) => {
|
||||
handleShellDataRef.current?.(event, connectionId, shellId, data)
|
||||
}
|
||||
window.electronAPI?.onShellData(handler)
|
||||
window.opsxApi?.onShellData(handler)
|
||||
registeredShellHandlerRef.current = handler
|
||||
listenerRegisteredRef.current = true
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []) // 只在挂载时运行
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 初始连接
|
||||
useEffect(() => {
|
||||
@@ -211,8 +218,8 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
|
||||
connect()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []) // 只在挂载时执行
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 处理活跃状态变化
|
||||
useEffect(() => {
|
||||
@@ -243,26 +250,26 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
return () => {
|
||||
isConnectingRef.current = false
|
||||
listenerRegisteredRef.current = false
|
||||
// 精确移除当前组件注册的监听器,避免影响其他终端
|
||||
if (handleShellDataRef.current) {
|
||||
window.electronAPI?.removeListener('ssh:shellData', handleShellDataRef.current as (...args: unknown[]) => void)
|
||||
pipelineRef.current?.dispose()
|
||||
pipelineRef.current = null
|
||||
if (registeredShellHandlerRef.current) {
|
||||
window.opsxApi?.removeListener(
|
||||
'ssh:shell-data',
|
||||
registeredShellHandlerRef.current as (...args: unknown[]) => void
|
||||
)
|
||||
registeredShellHandlerRef.current = null
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const currentTab = getTabById(tabId)
|
||||
|
||||
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"
|
||||
/>
|
||||
<div className="flex h-full flex-col bg-dark-900">
|
||||
<div className="relative flex-1 overflow-hidden p-2">
|
||||
<div ref={terminalRef} className="h-full w-full rounded bg-dark-900" />
|
||||
|
||||
{/* 搜索栏 */}
|
||||
{showSearch && (
|
||||
<TerminalSearchBar
|
||||
searchQuery={searchQuery}
|
||||
@@ -276,12 +283,14 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 连接状态覆盖层 */}
|
||||
<TerminalOverlay
|
||||
connectionState={connectionState}
|
||||
statusMessage={statusMessage}
|
||||
showSuspendedOverlay={showSuspendedOverlay}
|
||||
needsReconnect={!!currentTab?.needsReconnect}
|
||||
needsReconnect={
|
||||
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
|
||||
}
|
||||
reconnectAttempt={reconnectAttempt}
|
||||
onReconnect={handleReconnect}
|
||||
onResume={resumeConnection}
|
||||
/>
|
||||
@@ -0,0 +1,772 @@
|
||||
/**
|
||||
* Terminal workspace with optional split panes (H/V).
|
||||
* One SSH connection per tab; each pane has its own shell + xterm.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import {
|
||||
SplitSquareHorizontal,
|
||||
SeparatorHorizontal,
|
||||
X,
|
||||
Square,
|
||||
Plus,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Radio,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { themes } from '../../themes/terminal'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { getConnectionStore } from '../../stores/connection'
|
||||
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
|
||||
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
|
||||
import { shellPayloadByteLength } from '../../utils/shellData'
|
||||
import { shellWrite, shellResize, shellCreate, shellKill } from '../../utils/shellIO'
|
||||
import { buildTerminalFontFamily } from '../../utils/terminalFont'
|
||||
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
|
||||
import TerminalOverlay from './TerminalOverlay'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
import type { TerminalViewProps } from './types'
|
||||
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
|
||||
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
|
||||
import { resolveTerminalInputTargets } from '../../utils/terminalPanes'
|
||||
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
||||
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
|
||||
import { singleLineTerminalInsert } from '../../utils/aiResponse'
|
||||
|
||||
const logger = createLogger('TerminalWorkspace')
|
||||
const MAX_PANES = 4
|
||||
|
||||
interface PaneState {
|
||||
id: string
|
||||
shellId: string | null
|
||||
}
|
||||
|
||||
interface PaneRuntime {
|
||||
capture: () => TerminalAiContext
|
||||
insert: (command: string) => void
|
||||
}
|
||||
|
||||
function captureTerminal(term: Terminal): TerminalAiContext {
|
||||
const buffer = term.buffer.active
|
||||
const lines: string[] = []
|
||||
const end = Math.min(buffer.length, buffer.viewportY + term.rows)
|
||||
for (let row = buffer.viewportY; row < end; row += 1) {
|
||||
lines.push(buffer.getLine(row)?.translateToString(true) || '')
|
||||
}
|
||||
return { selection: term.getSelection(), viewport: lines.join('\n').replace(/\n+$/g, '') }
|
||||
}
|
||||
|
||||
function newPaneId() {
|
||||
return `pane-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
}
|
||||
|
||||
interface PaneTerminalProps {
|
||||
paneId: string
|
||||
shellId: string | null
|
||||
connectionId: string | null
|
||||
tabId: string
|
||||
isLocal?: boolean
|
||||
/** When true, pane will create its own shell if shellId is null */
|
||||
autoCreateShell: boolean
|
||||
isFocused: boolean
|
||||
isActive: boolean
|
||||
onFocus: () => void
|
||||
onInput: (paneId: string, data: string) => void
|
||||
onShellReady: (paneId: string, shellId: string) => void
|
||||
registerShellHandler: (paneId: string, handler: (shellId: string, data: unknown) => void) => void
|
||||
unregisterShellHandler: (paneId: string) => void
|
||||
registerRuntime: (paneId: string, runtime: PaneRuntime) => void
|
||||
unregisterRuntime: (paneId: string) => void
|
||||
}
|
||||
|
||||
function PaneTerminal({
|
||||
paneId,
|
||||
shellId,
|
||||
connectionId,
|
||||
tabId,
|
||||
isLocal = false,
|
||||
autoCreateShell,
|
||||
isFocused,
|
||||
isActive,
|
||||
onFocus,
|
||||
onInput,
|
||||
onShellReady,
|
||||
registerShellHandler,
|
||||
unregisterShellHandler,
|
||||
registerRuntime,
|
||||
unregisterRuntime,
|
||||
}: PaneTerminalProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const xtermRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
const shellIdRef = useRef(shellId)
|
||||
const onInputRef = useRef(onInput)
|
||||
const creatingRef = useRef(false)
|
||||
const { settings } = useSettingsStore()
|
||||
const settingsRef = useRef(settings)
|
||||
settingsRef.current = settings
|
||||
shellIdRef.current = shellId
|
||||
onInputRef.current = onInput
|
||||
|
||||
const currentTheme = themes[settings.terminalTheme] || themes['dracula']
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || xtermRef.current) return
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
fontSize: settings.fontSize,
|
||||
// Nerd Font stack so Powerline / Oh-My-Zsh / p10k glyphs render (not □)
|
||||
fontFamily: buildTerminalFontFamily(settings.fontFamily),
|
||||
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: settings.scrollback || 10000,
|
||||
allowProposedApi: true,
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
const searchAddon = new SearchAddon()
|
||||
const webLinks = new WebLinksAddon((_e, uri) => {
|
||||
void window.opsxApi?.openExternal?.(uri)
|
||||
})
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(searchAddon)
|
||||
term.loadAddon(webLinks)
|
||||
const container = containerRef.current
|
||||
term.open(container)
|
||||
fitAddon.fit()
|
||||
|
||||
const pasteClipboard = async () => {
|
||||
try {
|
||||
const text = await readClipboardText()
|
||||
if (text) term.paste(text)
|
||||
} catch (error) {
|
||||
logger.warn('读取剪贴板失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type !== 'keydown') return true
|
||||
if (isTerminalCopyShortcut(event)) {
|
||||
event.preventDefault()
|
||||
const selection = term.getSelection()
|
||||
if (selection) {
|
||||
void writeClipboardText(selection).catch((error) => {
|
||||
logger.warn('写入剪贴板失败', error)
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (isTerminalPasteShortcut(event)) {
|
||||
event.preventDefault()
|
||||
void pasteClipboard()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
const selection = term.getSelection()
|
||||
if (selection) {
|
||||
void writeClipboardText(selection).catch((error) => {
|
||||
logger.warn('写入剪贴板失败', error)
|
||||
})
|
||||
} else {
|
||||
void pasteClipboard()
|
||||
}
|
||||
}
|
||||
container.addEventListener('contextmenu', handleContextMenu)
|
||||
|
||||
xtermRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
const pipeline = new TerminalPipeline({
|
||||
input: (data) => {
|
||||
if (shellIdRef.current) onInputRef.current(paneId, data)
|
||||
},
|
||||
output: {
|
||||
write: (text) => term.write(text),
|
||||
transform: (text) => {
|
||||
const currentSettings = settingsRef.current
|
||||
return currentSettings.keywordHighlightEnabled && currentSettings.keywordHighlightRules?.length
|
||||
? applyKeywordHighlights(text, currentSettings.keywordHighlightRules)
|
||||
: text
|
||||
},
|
||||
},
|
||||
onError: (error) => logger.warn('终端输入失败', error),
|
||||
})
|
||||
term.onData((data) => pipeline.input(data))
|
||||
|
||||
const onResize = () => {
|
||||
fitAddon.fit()
|
||||
const sid = shellIdRef.current
|
||||
if (sid && term) {
|
||||
void shellResize(sid, term.cols, term.rows)
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
registerShellHandler(paneId, (sid, data) => {
|
||||
if (sid !== shellIdRef.current || !xtermRef.current) return
|
||||
pipeline.output(data)
|
||||
})
|
||||
registerRuntime(paneId, {
|
||||
capture: () => captureTerminal(term),
|
||||
insert: (value) => {
|
||||
const command = singleLineTerminalInsert(value)
|
||||
if (command) term.paste(command)
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
container.removeEventListener('contextmenu', handleContextMenu)
|
||||
unregisterShellHandler(paneId)
|
||||
unregisterRuntime(paneId)
|
||||
pipeline.dispose()
|
||||
term.dispose()
|
||||
xtermRef.current = null
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Resize when shell id attaches
|
||||
useEffect(() => {
|
||||
if (!shellId || !xtermRef.current) return
|
||||
shellIdRef.current = shellId
|
||||
fitAddonRef.current?.fit()
|
||||
void shellResize(shellId, xtermRef.current.cols, xtermRef.current.rows)
|
||||
}, [shellId])
|
||||
|
||||
// Auto-create shell for secondary panes
|
||||
useEffect(() => {
|
||||
if (!autoCreateShell) return
|
||||
if (!connectionId || shellId || creatingRef.current || !xtermRef.current) return
|
||||
creatingRef.current = true
|
||||
|
||||
const pendingId = isLocal
|
||||
? `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
: `shell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
void (async () => {
|
||||
try {
|
||||
const cols = xtermRef.current?.cols || 80
|
||||
const rows = xtermRef.current?.rows || 24
|
||||
const tabMeta = useTabStore.getState().getTabById(tabId)
|
||||
const result = await shellCreate(
|
||||
isLocal,
|
||||
isLocal ? null : connectionId,
|
||||
{
|
||||
cols,
|
||||
rows,
|
||||
shell: isLocal ? tabMeta?.localShellPath : undefined,
|
||||
},
|
||||
pendingId
|
||||
)
|
||||
if (result?.success && result.shellId) {
|
||||
if (!isLocal && connectionId) {
|
||||
getConnectionStore().addShell(connectionId, result.shellId)
|
||||
}
|
||||
shellIdRef.current = result.shellId
|
||||
onShellReady(paneId, result.shellId)
|
||||
} else {
|
||||
xtermRef.current?.writeln(`\r\n\x1b[31m创建 Shell 失败: ${result?.error || 'unknown'}\x1b[0m`)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('create shell failed', e)
|
||||
} finally {
|
||||
creatingRef.current = false
|
||||
}
|
||||
})()
|
||||
}, [autoCreateShell, connectionId, shellId, paneId, onShellReady, isLocal, tabId])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && isFocused) {
|
||||
fitAddonRef.current?.fit()
|
||||
xtermRef.current?.focus()
|
||||
}
|
||||
}, [isActive, isFocused])
|
||||
|
||||
return (
|
||||
<div className={`relative h-full w-full ${isFocused ? 'ring-accent-blue/40 ring-1' : ''}`} onMouseDown={onFocus}>
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TerminalWorkspace({ tabId, isActive = true }: TerminalViewProps) {
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { getTabById, updateTab, updateTabConnectionInstance } = useTabStore()
|
||||
const snapshotStore = useTerminalSnapshotStore()
|
||||
const currentTab = getTabById(tabId)
|
||||
const savedLayout = currentTab?.splitLayout
|
||||
|
||||
// useTerminalConnection needs an xterm ref for initial shell size; panes own real terminals
|
||||
const dummyXtermRef = useRef<Terminal | null>(null)
|
||||
|
||||
const initialCount = Math.min(MAX_PANES, Math.max(1, savedLayout?.paneCount || 1))
|
||||
const [panes, setPanes] = useState<PaneState[]>(() =>
|
||||
Array.from({ length: initialCount }, () => ({ id: newPaneId(), shellId: null }))
|
||||
)
|
||||
const panesRef = useRef(panes)
|
||||
panesRef.current = panes
|
||||
const [focusedPaneId, setFocusedPaneId] = useState(() => panes[0]?.id || '')
|
||||
const [direction, setDirection] = useState<'horizontal' | 'vertical'>(savedLayout?.direction || 'horizontal')
|
||||
const [ratio, setRatio] = useState(savedLayout?.ratio ?? 0.5)
|
||||
const [maximizedPaneId, setMaximizedPaneId] = useState<string | null>(null)
|
||||
const [broadcastInput, setBroadcastInput] = useState(false)
|
||||
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
||||
const draggingRef = useRef(false)
|
||||
const layoutHydrated = useRef(false)
|
||||
|
||||
const shellHandlersRef = useRef<Map<string, (shellId: string, data: unknown) => void>>(new Map())
|
||||
const paneRuntimesRef = useRef<Map<string, PaneRuntime>>(new Map())
|
||||
|
||||
const {
|
||||
connectionState,
|
||||
statusMessage,
|
||||
showSuspendedOverlay,
|
||||
reconnectAttempt,
|
||||
connectionIdRef,
|
||||
shellIdRef,
|
||||
hasConnectedRef,
|
||||
connect,
|
||||
resumeConnection,
|
||||
handleReconnect,
|
||||
saveSnapshot,
|
||||
restoreSnapshot,
|
||||
setShowSuspendedOverlay,
|
||||
} = useTerminalConnection({ tabId, xtermRef: dummyXtermRef })
|
||||
|
||||
// Persist layout to tab store
|
||||
useEffect(() => {
|
||||
if (!layoutHydrated.current) {
|
||||
layoutHydrated.current = true
|
||||
return
|
||||
}
|
||||
updateTab(tabId, {
|
||||
splitLayout: {
|
||||
direction,
|
||||
paneCount: panes.length,
|
||||
ratio: panes.length === 2 ? ratio : undefined,
|
||||
},
|
||||
})
|
||||
}, [tabId, direction, panes.length, ratio, updateTab])
|
||||
|
||||
// Sync primary shell from connection hook → first pane
|
||||
useEffect(() => {
|
||||
if (connectionState === 'connected' && shellIdRef.current) {
|
||||
setPanes((prev) => {
|
||||
if (prev.length === 0) return prev
|
||||
if (prev[0].shellId === shellIdRef.current) return prev
|
||||
return prev.map((p, i) => (i === 0 ? { ...p, shellId: shellIdRef.current } : p))
|
||||
})
|
||||
}
|
||||
}, [connectionState, shellIdRef])
|
||||
|
||||
// Fan-out shell data to panes (SSH + local)
|
||||
useEffect(() => {
|
||||
const handler = (_e: unknown, _connId: string, shellId: string, data: unknown) => {
|
||||
for (const h of shellHandlersRef.current.values()) {
|
||||
h(shellId, data)
|
||||
}
|
||||
const connId = connectionIdRef.current
|
||||
if (connId && connId !== 'local') {
|
||||
const conn = getConnectionStore().getConnection(connId)
|
||||
if (conn) conn.stats.bytesTransferred += shellPayloadByteLength(data)
|
||||
}
|
||||
}
|
||||
window.opsxApi?.onShellData(handler)
|
||||
window.opsxApi?.onLocalData?.(handler)
|
||||
return () => {
|
||||
window.opsxApi?.removeListener('ssh:shell-data', handler as (...args: unknown[]) => void)
|
||||
window.opsxApi?.removeListener('local:data', handler as (...args: unknown[]) => void)
|
||||
}
|
||||
}, [connectionIdRef])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!currentTab?.isLocal) return
|
||||
for (const pane of panesRef.current) {
|
||||
if (pane.shellId) void shellKill(pane.shellId)
|
||||
}
|
||||
}
|
||||
}, [currentTab?.isLocal])
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
const currentTab = getTabById(tabId)
|
||||
if (!hasConnectedRef.current && (isActive || currentTab?.connectOnMount)) {
|
||||
if (currentTab?.needsReconnect) return
|
||||
if (snapshotStore.hasSnapshot(tabId) && isActive) {
|
||||
restoreSnapshot()
|
||||
}
|
||||
if (currentTab?.connectOnMount) {
|
||||
updateTab(tabId, { connectOnMount: undefined })
|
||||
}
|
||||
connect()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
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)
|
||||
else pool.setActiveTab(connectionIdRef.current, tabId)
|
||||
}
|
||||
} else if (connectionIdRef.current) {
|
||||
pool.setBackgroundTab(connectionIdRef.current, tabId)
|
||||
saveSnapshot()
|
||||
}
|
||||
}, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay, connectionIdRef, hasConnectedRef])
|
||||
|
||||
const registerShellHandler = useCallback((paneId: string, handler: (shellId: string, data: unknown) => void) => {
|
||||
shellHandlersRef.current.set(paneId, handler)
|
||||
}, [])
|
||||
const unregisterShellHandler = useCallback((paneId: string) => {
|
||||
shellHandlersRef.current.delete(paneId)
|
||||
}, [])
|
||||
const registerRuntime = useCallback((paneId: string, runtime: PaneRuntime) => {
|
||||
paneRuntimesRef.current.set(paneId, runtime)
|
||||
}, [])
|
||||
const unregisterRuntime = useCallback((paneId: string) => {
|
||||
paneRuntimesRef.current.delete(paneId)
|
||||
}, [])
|
||||
|
||||
const onShellReady = useCallback(
|
||||
(paneId: string, shellId: string) => {
|
||||
setPanes((prev) => prev.map((p) => (p.id === paneId ? { ...p, shellId } : p)))
|
||||
if (connectionIdRef.current) {
|
||||
updateTabConnectionInstance(tabId, {
|
||||
connectionId: connectionIdRef.current,
|
||||
shellId,
|
||||
})
|
||||
}
|
||||
},
|
||||
[connectionIdRef, tabId, updateTabConnectionInstance]
|
||||
)
|
||||
|
||||
const handlePaneInput = useCallback(
|
||||
(sourcePaneId: string, data: string) => {
|
||||
const targets = resolveTerminalInputTargets(panesRef.current, sourcePaneId, broadcastInput)
|
||||
for (const targetShellId of targets) {
|
||||
void shellWrite(targetShellId, data)
|
||||
}
|
||||
|
||||
const connectionId = connectionIdRef.current
|
||||
if (targets.length > 0 && connectionId && connectionId !== 'local') {
|
||||
getConnectionStore().updateActivity(connectionId)
|
||||
}
|
||||
},
|
||||
[broadcastInput, connectionIdRef]
|
||||
)
|
||||
|
||||
const split = useCallback(
|
||||
(dir: 'horizontal' | 'vertical') => {
|
||||
if (!connectionIdRef.current || connectionState !== 'connected') return
|
||||
setDirection(dir)
|
||||
setPanes((prev) => {
|
||||
if (prev.length >= MAX_PANES) return prev
|
||||
const id = newPaneId()
|
||||
setFocusedPaneId(id)
|
||||
if (prev.length === 1) setRatio(0.5)
|
||||
return [...prev, { id, shellId: null }]
|
||||
})
|
||||
},
|
||||
[connectionIdRef, connectionState]
|
||||
)
|
||||
|
||||
const addPane = useCallback(() => split(direction), [direction, split])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const handleSplitShortcut = (event: KeyboardEvent) => {
|
||||
if (isTerminalSplitShortcut(event)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
split('horizontal')
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleSplitShortcut, true)
|
||||
return () => window.removeEventListener('keydown', handleSplitShortcut, true)
|
||||
}, [isActive, split])
|
||||
|
||||
const closePane = (paneId: string) => {
|
||||
if (panes.length <= 1) return
|
||||
const pane = panes.find((item) => item.id === paneId)
|
||||
if (currentTab?.isLocal && pane?.shellId) void shellKill(pane.shellId)
|
||||
if (maximizedPaneId === paneId) setMaximizedPaneId(null)
|
||||
setPanes((prev) => {
|
||||
const next = prev.filter((p) => p.id !== paneId)
|
||||
if (!next.find((p) => p.id === focusedPaneId) && next[0]) {
|
||||
setFocusedPaneId(next[0].id)
|
||||
if (next[0].shellId) shellIdRef.current = next[0].shellId
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (panes.length <= 1 && broadcastInput) setBroadcastInput(false)
|
||||
if (maximizedPaneId && !panes.some((pane) => pane.id === maximizedPaneId)) {
|
||||
setMaximizedPaneId(null)
|
||||
}
|
||||
}, [broadcastInput, maximizedPaneId, panes])
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [maximizedPaneId, aiPanelOpen])
|
||||
|
||||
const onDividerMouseDown = (e: React.MouseEvent) => {
|
||||
if (panes.length !== 2) return
|
||||
e.preventDefault()
|
||||
draggingRef.current = true
|
||||
const startPos = direction === 'horizontal' ? e.clientX : e.clientY
|
||||
const startRatio = ratio
|
||||
const parent = (e.target as HTMLElement).parentElement
|
||||
if (!parent) return
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!draggingRef.current || !parent) return
|
||||
const rect = parent.getBoundingClientRect()
|
||||
const delta =
|
||||
direction === 'horizontal' ? (ev.clientX - startPos) / rect.width : (ev.clientY - startPos) / rect.height
|
||||
setRatio(Math.min(0.8, Math.max(0.2, startRatio + delta)))
|
||||
}
|
||||
const onUp = () => {
|
||||
draggingRef.current = false
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
const multi = panes.length > 1
|
||||
const isLocal = !!currentTab?.isLocal
|
||||
|
||||
const paneStyle = (index: number, paneId: string): CSSProperties => {
|
||||
if (maximizedPaneId === paneId) {
|
||||
return { flex: 1, width: '100%', height: '100%', minWidth: 0, minHeight: 0 }
|
||||
}
|
||||
if (!multi) return { flex: 1 }
|
||||
if (panes.length === 2) {
|
||||
const share = index === 0 ? ratio : 1 - ratio
|
||||
return direction === 'horizontal'
|
||||
? { width: `${share * 100}%`, flexShrink: 0 }
|
||||
: { height: `${share * 100}%`, flexShrink: 0 }
|
||||
}
|
||||
// 3–4 panes: equal flex
|
||||
return { flex: 1, minWidth: 0, minHeight: 0 }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-dark-900">
|
||||
<div className="border-dark-700/35 bg-dark-800/40 flex items-center gap-0.5 border-b px-2 py-1">
|
||||
<button
|
||||
type="button"
|
||||
title="左右分屏"
|
||||
disabled={connectionState !== 'connected' || panes.length >= MAX_PANES}
|
||||
onClick={() => split('horizontal')}
|
||||
className="rounded-md p-1.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary disabled:opacity-30"
|
||||
>
|
||||
<SplitSquareHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="上下分屏"
|
||||
disabled={connectionState !== 'connected' || panes.length >= MAX_PANES}
|
||||
onClick={() => split('vertical')}
|
||||
className="rounded-md p-1.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary disabled:opacity-30"
|
||||
>
|
||||
<SeparatorHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="再加一屏"
|
||||
disabled={connectionState !== 'connected' || panes.length >= MAX_PANES}
|
||||
onClick={addPane}
|
||||
className="rounded-md p-1.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary disabled:opacity-30"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{multi && (
|
||||
<button
|
||||
type="button"
|
||||
title="关闭当前分屏"
|
||||
onClick={() => closePane(focusedPaneId)}
|
||||
className="rounded-md p-1.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{multi && (
|
||||
<button
|
||||
type="button"
|
||||
title={maximizedPaneId ? '恢复分屏布局' : '最大化当前分屏'}
|
||||
aria-pressed={!!maximizedPaneId}
|
||||
onClick={() => setMaximizedPaneId((current) => (current ? null : focusedPaneId))}
|
||||
className={`rounded-md p-1.5 transition-colors ${
|
||||
maximizedPaneId
|
||||
? 'bg-accent-blue/15 text-accent-blue'
|
||||
: 'text-txt-muted hover:bg-dark-700 hover:text-content-primary'
|
||||
}`}
|
||||
>
|
||||
{maximizedPaneId ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
<div className="bg-dark-600/70 mx-1 h-4 w-px" />
|
||||
<button
|
||||
type="button"
|
||||
title={broadcastInput ? '广播输入已开启:键盘输入将发送到所有分屏' : '广播输入到所有分屏'}
|
||||
aria-label="广播输入"
|
||||
aria-pressed={broadcastInput}
|
||||
disabled={!multi || connectionState !== 'connected'}
|
||||
onClick={() => setBroadcastInput((enabled) => !enabled)}
|
||||
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-30 ${
|
||||
broadcastInput
|
||||
? 'bg-accent-orange/15 text-accent-orange'
|
||||
: 'text-txt-muted hover:bg-dark-700 hover:text-content-primary'
|
||||
}`}
|
||||
>
|
||||
<Radio className="h-3.5 w-3.5" />
|
||||
广播
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
title="终端 AI 助手"
|
||||
aria-pressed={aiPanelOpen}
|
||||
onClick={() => setAiPanelOpen((open) => !open)}
|
||||
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors ${aiPanelOpen ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-muted hover:bg-dark-700 hover:text-content-primary'}`}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" /> AI
|
||||
</button>
|
||||
{broadcastInput && <div className="ui-chip border-accent-orange/30 text-accent-orange">广播中</div>}
|
||||
<div className="ui-chip tabular-nums">
|
||||
<Square className="h-2.5 w-2.5" />
|
||||
{panes.length}/{MAX_PANES}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden p-1">
|
||||
<div
|
||||
className={`h-full min-h-0 w-full ${
|
||||
multi ? (direction === 'horizontal' ? 'flex flex-row' : 'flex flex-col') : 'flex'
|
||||
}`}
|
||||
>
|
||||
{panes.map((pane, index) => (
|
||||
<div
|
||||
key={pane.id}
|
||||
className={`relative min-h-0 min-w-0 ${maximizedPaneId && maximizedPaneId !== pane.id ? 'hidden' : ''}`}
|
||||
style={paneStyle(index, pane.id)}
|
||||
>
|
||||
<PaneTerminal
|
||||
paneId={pane.id}
|
||||
shellId={pane.shellId}
|
||||
connectionId={connectionState === 'connected' ? connectionIdRef.current : null}
|
||||
tabId={tabId}
|
||||
isLocal={isLocal}
|
||||
autoCreateShell={index > 0}
|
||||
isFocused={focusedPaneId === pane.id}
|
||||
isActive={isActive}
|
||||
onInput={handlePaneInput}
|
||||
onFocus={() => {
|
||||
setFocusedPaneId(pane.id)
|
||||
if (pane.shellId && connectionIdRef.current) {
|
||||
shellIdRef.current = pane.shellId
|
||||
updateTabConnectionInstance(tabId, {
|
||||
connectionId: connectionIdRef.current,
|
||||
shellId: pane.shellId,
|
||||
})
|
||||
}
|
||||
}}
|
||||
onShellReady={onShellReady}
|
||||
registerShellHandler={registerShellHandler}
|
||||
unregisterShellHandler={unregisterShellHandler}
|
||||
registerRuntime={registerRuntime}
|
||||
unregisterRuntime={unregisterRuntime}
|
||||
/>
|
||||
{multi && panes.length === 2 && index === 0 && (
|
||||
<div
|
||||
onMouseDown={onDividerMouseDown}
|
||||
className={
|
||||
direction === 'horizontal'
|
||||
? 'absolute right-0 top-0 z-10 h-full w-1 cursor-col-resize bg-dark-600 hover:bg-accent-blue'
|
||||
: 'absolute bottom-0 left-0 z-10 h-1 w-full cursor-row-resize bg-dark-600 hover:bg-accent-blue'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<TerminalOverlay
|
||||
connectionState={connectionState}
|
||||
statusMessage={statusMessage}
|
||||
showSuspendedOverlay={showSuspendedOverlay}
|
||||
needsReconnect={
|
||||
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
|
||||
}
|
||||
reconnectAttempt={reconnectAttempt}
|
||||
onReconnect={() => {
|
||||
const count = Math.min(MAX_PANES, Math.max(1, panes.length))
|
||||
setPanes(Array.from({ length: count }, () => ({ id: newPaneId(), shellId: null })))
|
||||
handleReconnect()
|
||||
}}
|
||||
onResume={resumeConnection}
|
||||
/>
|
||||
</div>
|
||||
{aiPanelOpen && (
|
||||
<AiTerminalPanel
|
||||
capture={() => paneRuntimesRef.current.get(focusedPaneId)?.capture() || { selection: '', viewport: '' }}
|
||||
insert={(command) => paneRuntimesRef.current.get(focusedPaneId)?.insert(command)}
|
||||
onClose={() => setAiPanelOpen(false)}
|
||||
connectionId={connectionState === 'connected' && !isLocal ? connectionIdRef.current : null}
|
||||
targetLabel={currentTab?.title || '当前 SSH 会话'}
|
||||
isLocal={isLocal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Terminal — 终端仿真模块
|
||||
*/
|
||||
export { default as TerminalView } from './TerminalView'
|
||||
export { default as TerminalWorkspace } from './TerminalWorkspace'
|
||||
export { default as HostKeyDialog } from './HostKeyDialog'
|
||||
export { default as TerminalOverlay } from './TerminalOverlay'
|
||||
export { default as TerminalSearchBar } from './TerminalSearchBar'
|
||||
export type { TerminalViewProps } from './types'
|
||||
Reference in new issue
Block a user