任务7: Toast通知系统 — ToastProvider+4类型(success/error/warning/info)+右上角堆叠动画 任务8: 替换alert/confirm/prompt — ConfirmDialog+PromptDialog+useToast替换7处原生对话框 任务9: 终端搜索 — xterm SearchAddon Ctrl+F搜索栏(上一个/下一个/关闭) 任务10: SFTP上传/删除 — useSFTP.ts upload/delete方法实现 任务11: 会话恢复 — tabStore持久化到localStorage,启动恢复tabs 14 files changed, 1218 insertions(+), 241 deletions(-)
712 lines
26 KiB
TypeScript
712 lines
26 KiB
TypeScript
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<HTMLDivElement>(null)
|
||
const xtermRef = useRef<Terminal | null>(null)
|
||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||
const searchAddonRef = useRef<SearchAddon | null>(null)
|
||
const shellIdRef = useRef<string | null>(null)
|
||
const connectionIdRef = useRef<string | null>(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<ConnectionState>('disconnected')
|
||
const [statusMessage, setStatusMessage] = useState<string>('')
|
||
const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false)
|
||
|
||
// 搜索状态
|
||
const [showSearch, setShowSearch] = useState(false)
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [searchResultCount, setSearchResultCount] = useState<number | undefined>(undefined)
|
||
const [searchResultIndex, setSearchResultIndex] = useState<number | undefined>(undefined)
|
||
const searchInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||
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 (
|
||
<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"
|
||
/>
|
||
|
||
{/* 搜索栏 - 终端顶部覆盖 */}
|
||
{showSearch && (
|
||
<div className="absolute top-0 left-0 right-0 z-30 flex items-center gap-2 px-3 py-2 bg-dark-800/95 border-b border-dark-600 backdrop-blur-sm">
|
||
<Search size={14} className="text-gray-400 shrink-0" />
|
||
<input
|
||
ref={searchInputRef}
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={handleSearchChange}
|
||
onKeyDown={(e) => {
|
||
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 && (
|
||
<span className="text-xs text-gray-400 whitespace-nowrap min-w-[60px] text-center">
|
||
{searchResultIndex ?? 0}/{searchResultCount}
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={handleSearchPrevious}
|
||
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
|
||
title="上一个 (Shift+Enter)"
|
||
>
|
||
<ChevronUp size={16} />
|
||
</button>
|
||
<button
|
||
onClick={handleSearchNext}
|
||
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
|
||
title="下一个 (Enter)"
|
||
>
|
||
<ChevronDown size={16} />
|
||
</button>
|
||
<button
|
||
onClick={handleCloseSearch}
|
||
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
|
||
title="关闭 (Escape)"
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 会话恢复 - 需要重新连接覆盖层 */}
|
||
{connectionState === 'disconnected' && getTabById(tabId)?.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-accent-blue/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||
<TerminalIcon className="w-8 h-8 text-accent-blue" />
|
||
</div>
|
||
<div className="text-gray-200 mb-2 text-lg">会话已恢复</div>
|
||
<div className="text-gray-400 text-sm mb-6">
|
||
上次关闭时的终端标签已恢复<br />
|
||
点击下方按钮重新建立 SSH 连接
|
||
</div>
|
||
<button
|
||
onClick={handleReconnect}
|
||
className="px-6 py-2.5 bg-accent-blue hover:bg-blue-500 text-white rounded-lg transition-colors"
|
||
>
|
||
重新连接
|
||
</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-gray-400 text-sm mb-4">
|
||
长时间未操作,连接已进入挂起状态
|
||
</div>
|
||
<button
|
||
onClick={resumeConnection}
|
||
className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors mr-2"
|
||
>
|
||
恢复连接
|
||
</button>
|
||
<button
|
||
onClick={handleReconnect}
|
||
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-gray-200 rounded transition-colors"
|
||
>
|
||
重新连接
|
||
</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-gray-400 text-sm mb-4">{statusMessage}</div>
|
||
<button
|
||
onClick={handleReconnect}
|
||
className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors"
|
||
>
|
||
重新连接
|
||
</button>
|
||
</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>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|