Files
opsxshell/src/components/layout/StatusBar.tsx
T
Logan.LiandCursor 9ce71795b8 feat: add terminal broadcast, context menu, and shell close
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>
2026-08-05 19:53:39 +08:00

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>
)
}