feat: release OpsXShell 2.1.0 native Tauri edition

This commit is contained in:
Logan.Li committed 2026-07-30 23:32:51 +08:00
1 parent e9a0d21758
commit a3dba0c6ed
311 files changed
+31748 -14897

No files matched your search

-424
View File
@@ -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}
</>
)
}
-191
View File
@@ -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>
)
}
-502
View File
@@ -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>
)
}
-768
View File
@@ -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>
)
}
-292
View File
@@ -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}
</>
)
}
-170
View File
@@ -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)
-130
View File
@@ -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>
)
}
+313
View File
@@ -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>
)
}
+25 -30
View File
@@ -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}
+15 -14
View File
@@ -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'
}
`}
+7 -6
View File
@@ -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>
)
+1 -1
View File
@@ -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
View File
@@ -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>
)
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+22
View File
@@ -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}
</>
)
}
+84
View File
@@ -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]}`
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Connection — 连接管理 / 端口转发
*/
export { default as ConnectionPanel } from './ConnectionPanel'
export { default as PortForwardView } from './PortForwardView'
+37 -24
View File
@@ -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'
+437
View File
@@ -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>
)
+187
View File
@@ -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
+7
View File
@@ -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,
})
}
@@ -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>
)
}
+175
View File
@@ -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>
)
}
+2
View File
@@ -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>
)
}
+150
View File
@@ -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>
)
}
+615
View File
@@ -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>
)
}
+133
View File
@@ -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>
)
}
+7
View File
@@ -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>
)
}
+886
View File
@@ -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>
)
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Settings — 设置模块
*/
export { default as SettingsView } from './SettingsView'
+2 -2
View File
@@ -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>
+248 -31
View File
@@ -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 */}
+32 -4
View File
@@ -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">
+180
View File
@@ -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>
+29 -18
View File
@@ -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>
)
}
+5 -3
View File
@@ -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" />
+3 -3
View File
@@ -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"
+3 -3
View File
@@ -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)}
/>
))}
+40
View File
@@ -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
}
}
+5 -2
View File
@@ -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'
+1 -1
View File
@@ -36,7 +36,7 @@ export interface ContextMenuProps {
export interface TransferItemProps {
task: SFTPTransferTask
onCancel: () => void
onCancel?: () => void
onRemove: () => void
}
+31
View File
@@ -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)
})
}
-125
View File
@@ -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>
)
}
+20 -5
View File
@@ -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>
-120
View File
@@ -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>
)
}
+102
View File
@@ -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)
+69 -49
View File
@@ -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>
)
}
+9
View File
@@ -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'