import { useEffect, useRef, useState, useCallback } from 'react' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { SearchAddon } from '@xterm/addon-search' import '@xterm/xterm/css/xterm.css' import { Search, ChevronUp, ChevronDown, X, Terminal as TerminalIcon } from 'lucide-react' import { themes } from '../themes/terminal' import { useSettingsStore } from '../stores/settingsStore' import { useTabStore, type ConnectionInstance } from '../stores/tabStore' import { getSessionConfig, getConnectionStore, type ConnectionState } from '../stores/connection' import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore' import { createLogger } from '../utils/logger' import type { TerminalViewProps } from './terminal/types' const logger = createLogger('TerminalView') export default function TerminalView({ tabId, isActive = true }: TerminalViewProps) { const terminalRef = useRef(null) const xtermRef = useRef(null) const fitAddonRef = useRef(null) const searchAddonRef = useRef(null) const shellIdRef = useRef(null) const connectionIdRef = useRef(null) const hasConnectedRef = useRef(false) const isConnectingRef = useRef(false) const handleShellDataRef = useRef<((_event: unknown, _connId: string, shellId: string, data: Buffer) => void) | null>(null) const listenerRegisteredRef = useRef(false) // 本地状态 const [connectionState, setConnectionState] = useState('disconnected') const [statusMessage, setStatusMessage] = useState('') const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false) // 搜索状态 const [showSearch, setShowSearch] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [searchResultCount, setSearchResultCount] = useState(undefined) const [searchResultIndex, setSearchResultIndex] = useState(undefined) const searchInputRef = useRef(null) // 全局状态 const { settings, loadSettings } = useSettingsStore() const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore() const snapshotStore = useTerminalSnapshotStore() const currentTheme = themes[settings.terminalTheme] || themes['dracula'] // 加载设置 useEffect(() => { loadSettings() }, [loadSettings]) // 搜索相关回调 const searchDecorations = { matchBackground: '#3b82f633', matchBorder: '#3b82f666', matchOverviewRuler: '#3b82f6', activeMatchBackground: '#3b82f6', activeMatchBorder: '#60a5fa', activeMatchColorOverviewRuler: '#60a5fa', } const handleSearchChange = useCallback((e: React.ChangeEvent) => { const query = e.target.value setSearchQuery(query) if (query) { searchAddonRef.current?.findNext(query, { decorations: searchDecorations, incremental: true }) } else { searchAddonRef.current?.clearDecorations() setSearchResultCount(undefined) setSearchResultIndex(undefined) } }, []) const handleSearchNext = useCallback(() => { if (searchAddonRef.current && searchQuery) { searchAddonRef.current.findNext(searchQuery, { decorations: searchDecorations }) } }, [searchQuery]) const handleSearchPrevious = useCallback(() => { if (searchAddonRef.current && searchQuery) { searchAddonRef.current.findPrevious(searchQuery, { decorations: searchDecorations }) } }, [searchQuery]) const handleCloseSearch = useCallback(() => { setShowSearch(false) searchAddonRef.current?.clearDecorations() setSearchQuery('') setSearchResultCount(undefined) setSearchResultIndex(undefined) xtermRef.current?.focus() }, []) // Ctrl+F / Cmd+F 打开搜索栏,Escape 关闭 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'f') { // 只在当前标签激活时拦截 if (!isActive) return e.preventDefault() e.stopPropagation() if (!showSearch) { setShowSearch(true) // 延迟聚焦输入框,等 DOM 更新 setTimeout(() => searchInputRef.current?.focus(), 50) } else { searchInputRef.current?.focus() searchInputRef.current?.select() } } if (e.key === 'Escape' && showSearch) { handleCloseSearch() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [isActive, showSearch, handleCloseSearch]) // 监听 SearchAddon 的 onDidChangeResults 事件 useEffect(() => { const addon = searchAddonRef.current if (!addon) return const disposable = addon.onDidChangeResults((e) => { setSearchResultCount(e.resultCount) setSearchResultIndex(e.resultIndex !== undefined ? e.resultIndex + 1 : undefined) }) return () => disposable.dispose() }, []) // 初始化 xterm useEffect(() => { // 修复:只检查 terminalRef,不检查 xtermRef // 因为在严格模式下,组件会 unmount/remount,xtermRef 可能还存在 // 但监听器需要重新注册 if (!terminalRef.current) { return } // 处理窗口大小调整 const handleResize = () => { fitAddonRef.current?.fit() if (shellIdRef.current && xtermRef.current) { const { cols, rows } = xtermRef.current window.electronAPI?.sshShellResize(shellIdRef.current, cols, rows) } } // 如果 xterm 不存在,创建它 if (!xtermRef.current) { const term = new Terminal({ cursorBlink: true, cursorStyle: 'block', fontSize: settings.fontSize, fontFamily: settings.fontFamily + ', Menlo, Monaco, "Courier New", monospace', theme: { background: currentTheme.background, foreground: currentTheme.foreground, cursor: currentTheme.cursor, black: currentTheme.black, red: currentTheme.red, green: currentTheme.green, yellow: currentTheme.yellow, blue: currentTheme.blue, magenta: currentTheme.magenta, cyan: currentTheme.cyan, white: currentTheme.white, brightBlack: currentTheme.brightBlack, brightRed: currentTheme.brightRed, brightGreen: currentTheme.brightGreen, brightYellow: currentTheme.brightYellow, brightBlue: currentTheme.brightBlue, brightMagenta: currentTheme.brightMagenta, brightCyan: currentTheme.brightCyan, brightWhite: currentTheme.brightWhite, }, scrollback: 10000, rows: 30 }) const fitAddon = new FitAddon() const searchAddon = new SearchAddon() term.loadAddon(fitAddon) term.loadAddon(searchAddon) term.open(terminalRef.current) fitAddon.fit() xtermRef.current = term fitAddonRef.current = fitAddon searchAddonRef.current = searchAddon // 处理终端输入 term.onData((data) => { if (shellIdRef.current && window.electronAPI) { // 更新活动状态 if (connectionIdRef.current) { getConnectionStore().updateActivity(connectionIdRef.current) } window.electronAPI.sshShellWrite(shellIdRef.current, data) } }) } window.addEventListener('resize', handleResize) // IP 地址高亮处理 - 将包含 "from" 或 "Last login" 行中的 IP 地址标记为绿色 const highlightIPAddresses = (text: string): string => { // 匹配 IPv4 地址格式 (xxx.xxx.xxx.xxx) const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g // 如果包含 Last login 或 from IP,将 IP 地址替换为绿色 if (text.includes('Last login') || text.includes(' from ')) { return text.replace(ipRegex, '\x1b[32m$1\x1b[0m') } return text } // Shell 数据处理器 - 直接处理,不依赖外部 ref // 使用 ref 存储 handler,避免闭包问题 handleShellDataRef.current = (_event: unknown, _connId: string, shellId: string, data: Buffer) => { const text = data.toString('utf-8') // 使用 ref 获取最新的 shellId,避免闭包问题 if (shellId === shellIdRef.current && xtermRef.current) { // 处理数据,高亮 IP 地址(Last login 或 from IP 行) let processedData = data // 如果包含 Last login 或 from IP,处理 IP 地址高亮 if (text.includes('Last login') || text.includes(' from ')) { logger.debug('[Terminal] Found IP address line, highlighting IP') const highlighted = highlightIPAddresses(text) processedData = Buffer.from(highlighted, 'utf-8') } // 写入 xterm xtermRef.current.write(processedData) // 更新传输统计 const connId = connectionIdRef.current if (connId) { const conn = getConnectionStore().getConnection(connId) if (conn) { conn.stats.bytesTransferred += data.length } } } } // 只注册一次监听器(防止严格模式下重复注册) if (!listenerRegisteredRef.current) { const handler = (event: any, connectionId: string, shellId: string, data: Buffer) => { handleShellDataRef.current?.(event, connectionId, shellId, data) } window.electronAPI?.onShellData(handler) listenerRegisteredRef.current = true } return () => { window.removeEventListener('resize', handleResize) // 不在这里移除监听器,避免严格模式下的问题 // 监听器会在组件真正卸载时通过 removeAllListeners 清理 // 注意:不要在严格模式下 dispose terminal } }, []) // 只在挂载时运行 // 保存快照 const saveSnapshot = useCallback(() => { if (!xtermRef.current || !connectionIdRef.current) return useTerminalSnapshotStore.getState().createSnapshot(tabId, xtermRef.current, { connectionId: connectionIdRef.current, shellId: shellIdRef.current, }) }, [tabId]) // 恢复快照 const restoreSnapshot = useCallback(() => { if (!xtermRef.current) return false const snapshot = useTerminalSnapshotStore.getState().getSnapshot(tabId) if (!snapshot) return false // 清屏 xtermRef.current.clear() // 恢复内容 const content = snapshot.buffer.join('\r\n') if (content) { xtermRef.current.write(content) } // 恢复光标 xtermRef.current.write(`\x1b[${snapshot.cursorY + 1};${snapshot.cursorX + 1}H`) return true }, [tabId]) // 连接管理 const connect = useCallback(async () => { if (isConnectingRef.current) { logger.debug('[Terminal] Connection already in progress, skipping') return } isConnectingRef.current = true // 直接使用 getTabById,避免 useCallback 循环问题 const currentTab = getTabById(tabId) if (!currentTab) { logger.debug('[Terminal] Tab not found:', tabId) isConnectingRef.current = false return } logger.debug('[Terminal] Starting connection for tab:', tabId, 'sessionId:', currentTab.sessionId) // 检查是否已有连接 if (connectionIdRef.current) { const conn = getConnectionStore().getConnection(connectionIdRef.current) if (conn) { if (conn.state === 'connected' || conn.state === 'background') { // 已有活跃连接,恢复显示 shellIdRef.current = currentTab.connectionInstance?.shellId || null setConnectionState(conn.state) updateTabStatus(tabId, conn.state === 'connected' ? 'connected' : 'disconnected') isConnectingRef.current = false return } else if (conn.state === 'suspended') { // 连接被挂起,需要恢复 setShowSuspendedOverlay(true) setConnectionState('suspended') updateTabStatus(tabId, 'disconnected') isConnectingRef.current = false return } } } // 开始连接 setConnectionState('connecting') updateTabStatus(tabId, 'connecting') try { // 获取配置 let config: { host: string port: number username: string password?: string privateKey?: string passphrase?: string } | null = null if (currentTab.connectionConfig) { config = { host: currentTab.connectionConfig.host, port: currentTab.connectionConfig.port || 22, username: currentTab.connectionConfig.username, password: currentTab.connectionConfig.password, privateKey: currentTab.connectionConfig.privateKey, passphrase: currentTab.connectionConfig.passphrase, } } else if (currentTab.sessionId) { config = await getSessionConfig(currentTab.sessionId) } if (!config) { throw new Error('无可用连接信息') } // 创建连接 const result = await getConnectionStore().createConnection(tabId, config) if (!result.success || !result.connectionId) { throw new Error(result.error || '连接失败') } const connectionId = result.connectionId connectionIdRef.current = connectionId // 创建 Shell - 先生成前端 shellId,确保数据不会丢失 const pendingShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` logger.debug('[Terminal] Pre-assigning shellId:', pendingShellId) shellIdRef.current = pendingShellId logger.debug('[Terminal] Creating shell for connection:', connectionId, 'with shellId:', pendingShellId) const shellResult = await window.electronAPI?.sshCreateShell(connectionId, { cols: xtermRef.current?.cols || 80, rows: xtermRef.current?.rows || 24, }, pendingShellId) if (!shellResult?.success || !shellResult.shellId) { logger.error('[Terminal] Shell creation failed:', shellResult?.error) shellIdRef.current = null await getConnectionStore().disconnectConnection(connectionId, 'shell creation failed') throw new Error(shellResult?.error || '创建终端失败') } // shellId 应该和 pendingShellId 相同(由后端确认) const confirmedShellId = shellResult.shellId logger.debug('[Terminal] Shell confirmed with ID:', confirmedShellId) // shellIdRef 已经是 confirmedShellId(等于 pendingShellId) getConnectionStore().addShell(connectionId, confirmedShellId) // 更新标签 const connectionInstance: ConnectionInstance = { connectionId, shellId: confirmedShellId, } updateTabConnectionInstance(tabId, connectionInstance) // 更新状态 setConnectionState('connected') updateTabStatus(tabId, 'connected') getConnectionStore().setActiveTab(connectionId, tabId) // 更新标题 if (currentTab.title === '新终端' || currentTab.title === 'Terminal') { updateTab(tabId, { title: `${config.username}@${config.host}`, }) } // 等待服务器返回真实的 Last login 信息和提示符 // 不手动写入任何内容,让 SSH 服务器的自然输出显示 xtermRef.current?.focus() } catch (error: any) { setConnectionState('error') updateTabStatus(tabId, 'error') setStatusMessage(`错误: ${error.message}`) xtermRef.current?.writeln(`\r\n\x1b[31m错误: ${error.message}\x1b[0m`) } finally { isConnectingRef.current = false hasConnectedRef.current = true } }, [tabId, updateTabConnectionInstance, updateTabStatus, getTabById]) // 注意:不再使用模拟的 Last login 信息 // 真实的 SSH 服务器会自动发送欢迎信息和提示符 // 恢复挂起的连接 const resumeConnection = useCallback(async () => { if (!connectionIdRef.current) return setConnectionState('reconnecting') const resumed = await getConnectionStore().resumeConnection(connectionIdRef.current) if (resumed) { // 获取新的连接 ID const currentTab = getTabById(tabId) if (currentTab?.connectionInstance?.connectionId) { connectionIdRef.current = currentTab.connectionInstance.connectionId // 创建新的 shell - 预分配 shellId 防止数据丢失 const newShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` shellIdRef.current = newShellId const shellResult = await window.electronAPI?.sshCreateShell(connectionIdRef.current, { cols: xtermRef.current?.cols || 80, rows: xtermRef.current?.rows || 24, }, newShellId) if (shellResult?.success && shellResult.shellId) { // shellId 应该和 newShellId 相同 getConnectionStore().addShell(connectionIdRef.current, shellResult.shellId) updateTabConnectionInstance(tabId, { connectionId: connectionIdRef.current, shellId: shellResult.shellId, }) setConnectionState('connected') updateTabStatus(tabId, 'connected') setShowSuspendedOverlay(false) // 恢复连接成功,等待服务器发送提示符 return } } } // 恢复失败,重新连接 setConnectionState('disconnected') hasConnectedRef.current = false setShowSuspendedOverlay(false) connect() }, [connect, updateTabConnectionInstance, updateTabStatus, getTabById, tabId]) // 初始连接 useEffect(() => { if (!hasConnectedRef.current && isActive) { // 检查标签是否从持久化恢复(需要手动重新连接) const currentTab = getTabById(tabId) if (currentTab?.needsReconnect) { // 从持久化恢复的标签,不自动连接,显示「重新连接」界面 logger.info('[Terminal] Tab restored from session, waiting for manual reconnect') setConnectionState('disconnected') setStatusMessage('上次会话已恢复,点击重新连接') return } // 检查是否有快照 const hasSnapshot = snapshotStore.hasSnapshot(tabId) if (hasSnapshot && isActive) { // 先恢复快照 restoreSnapshot() } connect() } }, []) // 只在挂载时执行 // 处理活跃状态变化 useEffect(() => { if (!hasConnectedRef.current) return const pool = getConnectionStore() if (isActive) { // 标签变为活跃 if (connectionIdRef.current) { const conn = pool.getConnection(connectionIdRef.current) if (conn?.state === 'suspended') { setShowSuspendedOverlay(true) setConnectionState('suspended') } else { pool.setActiveTab(connectionIdRef.current, tabId) // 同步本地状态 setConnectionState(conn?.state === 'connected' || conn?.state === 'background' ? conn.state : 'connected') // 不再恢复快照 - shell 是实时输出的,重复写入会导致内容重复 } } xtermRef.current?.focus() } else { // 标签进入后台 - 保存快照用于恢复 if (connectionIdRef.current) { pool.setBackgroundTab(connectionIdRef.current, tabId) saveSnapshot() } } }, [isActive, tabId, saveSnapshot]) // 重连功能 const handleReconnect = useCallback(() => { if (connectionState === 'error' || connectionState === 'disconnected' || connectionState === 'suspended') { if (connectionIdRef.current) { getConnectionStore().disconnectConnection(connectionIdRef.current, 'manual reconnect') } connectionIdRef.current = null shellIdRef.current = null hasConnectedRef.current = false setConnectionState('disconnected') setShowSuspendedOverlay(false) // 清除 needsReconnect 标志 updateTab(tabId, { needsReconnect: false }) xtermRef.current?.clear() connect() } }, [connectionState, connect, updateTab, tabId]) // 组件卸载时清理监听器 useEffect(() => { return () => { // 重置连接状态,防止严格模式下 remount 时状态混乱 isConnectingRef.current = false listenerRegisteredRef.current = false // 重置监听器注册标记 // 组件真正卸载时移除所有监听器 window.electronAPI?.removeAllListeners('ssh:shellData') } }, []) return (
{/* 终端容器 */}
{/* 搜索栏 - 终端顶部覆盖 */} {showSearch && (
{ if (e.key === 'Enter') { if (e.shiftKey) { handleSearchPrevious() } else { handleSearchNext() } } e.stopPropagation() }} placeholder="搜索..." className="flex-1 bg-dark-900 text-gray-200 text-sm px-2 py-1 rounded border border-dark-600 focus:border-accent-blue focus:outline-none placeholder-gray-500" /> {searchResultCount !== undefined && ( {searchResultIndex ?? 0}/{searchResultCount} )}
)} {/* 会话恢复 - 需要重新连接覆盖层 */} {connectionState === 'disconnected' && getTabById(tabId)?.needsReconnect && (
会话已恢复
上次关闭时的终端标签已恢复
点击下方按钮重新建立 SSH 连接
)} {/* 挂起状态覆盖层 */} {showSuspendedOverlay && (
连接已挂起
长时间未操作,连接已进入挂起状态
)} {/* 错误状态覆盖层 */} {connectionState === 'error' && (
连接失败
{statusMessage}
)} {/* 连接中状态 */} {connectionState === 'connecting' && (
连接中...
)} {/* 连接状态指示器 - 简洁版 */}
{/* 状态指示已移至 TabBar */}
) }