Complete multi-pane broadcast, standard terminal right-click actions, batch host selection with progress, and native ssh_close_shell so closing a pane tears down the remote shell. Co-authored-by: Cursor <cursoragent@cursor.com>
257 lines
8.5 KiB
TypeScript
257 lines
8.5 KiB
TypeScript
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 type { ConnectionState } from '../../stores/connection'
|
|
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
|
|
import { useSessionStore } from '../../stores'
|
|
import packageJson from '../../../package.json'
|
|
|
|
// ============== Status Display Config ==============
|
|
|
|
interface StatusDisplay {
|
|
icon: React.ReactNode
|
|
label: string
|
|
colorClass: string
|
|
animateClass: string
|
|
}
|
|
|
|
const STATUS_CONFIG: Record<string, StatusDisplay> = {
|
|
connected: {
|
|
icon: <Wifi className="w-3 h-3" />,
|
|
label: '已连接',
|
|
colorClass: 'text-green-400',
|
|
animateClass: '',
|
|
},
|
|
connecting: {
|
|
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
|
label: '连接中',
|
|
colorClass: 'text-yellow-400',
|
|
animateClass: 'animate-pulse',
|
|
},
|
|
background: {
|
|
icon: <Wifi className="w-3 h-3" />,
|
|
label: '后台',
|
|
colorClass: 'text-green-400',
|
|
animateClass: '',
|
|
},
|
|
suspended: {
|
|
icon: <WifiOff className="w-3 h-3" />,
|
|
label: '已挂起',
|
|
colorClass: 'text-yellow-400',
|
|
animateClass: '',
|
|
},
|
|
reconnecting: {
|
|
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
|
label: '重连中',
|
|
colorClass: 'text-yellow-400',
|
|
animateClass: 'animate-pulse',
|
|
},
|
|
error: {
|
|
icon: <AlertCircle className="w-3 h-3" />,
|
|
label: '连接错误',
|
|
colorClass: 'text-red-400',
|
|
animateClass: '',
|
|
},
|
|
disconnected: {
|
|
icon: <WifiOff className="w-3 h-3" />,
|
|
label: '未连接',
|
|
colorClass: 'text-txt-muted',
|
|
animateClass: '',
|
|
},
|
|
}
|
|
|
|
// ============== Helper: format bytes ==============
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B'
|
|
const units = ['B', 'KB', 'MB', 'GB']
|
|
const k = 1024
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
const value = bytes / Math.pow(k, i)
|
|
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
|
}
|
|
|
|
// ============== Helper: map tab connectionStatus to ConnectionState ==============
|
|
|
|
function mapTabStatusToConnectionState(status?: ConnectionStatus): ConnectionState | undefined {
|
|
if (!status) return undefined
|
|
// tabStore's ConnectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error'
|
|
// maps directly to ConnectionState values
|
|
return status as ConnectionState
|
|
}
|
|
|
|
// ============== Component ==============
|
|
|
|
export default function StatusBar() {
|
|
const togglePanel = useConnectionPanelStore((s) => s.toggle)
|
|
const [version, setVersion] = useState(packageJson.version)
|
|
const [currentTime, setCurrentTime] = useState(new Date())
|
|
|
|
// Tab store
|
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
|
const tabs = useTabStore((s) => s.tabs)
|
|
|
|
// Connection store (pool) — subscribe to connections to reactively update
|
|
const connections = usePoolStore((s) => s.connections)
|
|
const sessions = useSessionStore((s) => s.sessions)
|
|
|
|
// Total connection count for the panel badge
|
|
const totalConnections = connections.size
|
|
|
|
// Derive the active tab
|
|
const activeTab = useMemo(
|
|
() => tabs.find((t) => t.id === activeTabId),
|
|
[tabs, activeTabId]
|
|
)
|
|
|
|
// Determine the connection state and info for the active tab
|
|
const { connectionState, hostLabel, stats, tabKind } = useMemo(() => {
|
|
if (!activeTab || activeTab.type === 'welcome' || activeTab.type === 'settings') {
|
|
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 = connections.get(instance.connectionId)
|
|
if (conn) {
|
|
return {
|
|
connectionState: conn.state,
|
|
hostLabel: `${conn.config.username}@${conn.config.host}:${conn.config.port || 22}`,
|
|
stats: conn.stats,
|
|
tabKind: activeTab.type,
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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}:${config.port || 22}`
|
|
: activeTab.isLocal
|
|
? '本地终端'
|
|
: undefined
|
|
|
|
return { connectionState: state, hostLabel: label, stats: undefined, tabKind: activeTab.type }
|
|
}, [activeTab, connections, sessions])
|
|
|
|
// Clock timer + version
|
|
useEffect(() => {
|
|
if (window.opsxApi) {
|
|
window.opsxApi.getVersion().then((v: string) => {
|
|
setVersion(v)
|
|
}).catch(() => undefined)
|
|
}
|
|
|
|
const timer = setInterval(() => {
|
|
setCurrentTime(new Date())
|
|
}, 1000)
|
|
|
|
return () => clearInterval(timer)
|
|
}, [])
|
|
|
|
const formatTime = (date: Date) => {
|
|
return date.toLocaleTimeString('zh-CN', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
})
|
|
}
|
|
|
|
// Resolve status display
|
|
const statusKey = connectionState ?? 'disconnected'
|
|
const statusCfg = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.disconnected
|
|
|
|
// Format transfer info if available
|
|
const transferLabel = stats && stats.bytesTransferred > 0
|
|
? `↑↓ ${formatBytes(stats.bytesTransferred)}`
|
|
: null
|
|
|
|
return (
|
|
<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-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 font-medium">{statusCfg.label}</span>
|
|
{hostLabel && (
|
|
<>
|
|
<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-accent-blue/20 text-accent-blue text-[10px] font-medium tabular-nums">
|
|
{totalConnections}
|
|
</span>
|
|
)}
|
|
<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-3 text-txt-muted tabular-nums">
|
|
<HardDrive className="w-3 h-3" />
|
|
<span>{transferLabel}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1" />
|
|
|
|
<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="hidden sm:inline opacity-80">OpsXShell v{version}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|