Files
opsxshell/src/components/TerminalView.tsx
T
OpsXShell Dev d344900118 feat(v1.1): Toast通知系统 + 自定义对话框 + 终端搜索 + SFTP真实实现 + 标签持久化
任务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(-)
2026-04-18 16:21:10 +08:00

712 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}