feat: v0.3.0 版本 - SSH终端、SFTP、端口转发功能实现

核心功能:
- SSH终端: 密码/密钥认证、XTerm.js终端、多主题配色、IP高亮
- SFTP管理: 双栏文件管理、上传下载、传输队列
- 端口转发: 本地/远程转发、SOCKS5动态代理、规则管理

架构改进:
- 拆分大型组件 (SFTPView/TabBar/Sidebar)
- 添加统一日志和错误处理
- 修复React严格模式下的终端黑屏问题

UI更新:
- 新增端口转发视图
- 更新UI预览文档
- 左侧栏右键菜单支持SFTP快捷打开
This commit is contained in:
Logan.Li committed 2026-04-06 01:20:47 +08:00
1 parent cf1c054381
commit 484d850abd
105 files changed
+25330 -64

No files matched your search

+645
View File
@@ -0,0 +1,645 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
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')
// 调试用 - 大量日志
const DEBUG = true
function debug(...args: any[]) {
if (DEBUG) {
console.log('[TerminalView]', ...args)
}
}
// 彩色高亮日志
function logHeader(msg: string) {
console.log(
`%c${msg}`,
'background: #41b883; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;'
)
}
function logInfo(label: string, value: any) {
console.log(
`%c${label}:%c ${value}`,
'background: #35495e; color: #fff; padding: 2px 6px; border-radius: 3px;',
'color: #41b883; font-weight: bold;'
)
}
function logSuccess(msg: string) {
console.log(
`%c✓ ${msg}`,
'color: #41b883; font-weight: bold;'
)
}
function logError(msg: string) {
console.log(
`%c✗ ${msg}`,
'color: #ff4757; font-weight: bold;'
)
}
// 使用 logError 避免 TS 错误
if (false) logError('')
function logWarning(msg: string) {
console.log(
`%c⚠ ${msg}`,
'color: #ffa502; font-weight: bold;'
)
}
export default function TerminalView({ tabId, isActive = true }: TerminalViewProps) {
debug('>>> RENDER tabId:', tabId, 'isActive:', isActive)
const terminalRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | 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 { settings, loadSettings } = useSettingsStore()
const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore()
const snapshotStore = useTerminalSnapshotStore()
const currentTheme = themes[settings.terminalTheme] || themes['dracula']
// 加载设置
useEffect(() => {
loadSettings()
}, [loadSettings])
// 初始化 xterm
useEffect(() => {
debug('>>> useEffect[init] START - terminalRef.current:', !!terminalRef.current, 'xtermRef.current:', !!xtermRef.current)
// 修复:只检查 terminalRef,不检查 xtermRef
// 因为在严格模式下,组件会 unmount/remount,xtermRef 可能还存在
// 但监听器需要重新注册
if (!terminalRef.current) {
debug('>>> useEffect[init] EARLY RETURN - terminalRef is null')
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) {
debug('>>> useEffect[init] Creating Terminal...')
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()
term.loadAddon(fitAddon)
term.open(terminalRef.current)
fitAddon.fit()
debug('>>> Terminal opened, cols:', term.cols, 'rows:', term.rows)
xtermRef.current = term
fitAddonRef.current = fitAddon
debug('>>> xtermRef set:', !!xtermRef.current)
// 处理终端输入
term.onData((data) => {
if (shellIdRef.current && window.electronAPI) {
// 更新活动状态
if (connectionIdRef.current) {
getConnectionStore().updateActivity(connectionIdRef.current)
}
window.electronAPI.sshShellWrite(shellIdRef.current, data)
}
})
} else {
debug('>>> useEffect[init] xterm already exists, skipping creation')
}
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')
// ===== 大量调试日志 =====
logHeader('📝 SHELL DATA RECEIVED')
logInfo('shellId (received)', shellId)
logInfo('shellId (expected)', shellIdRef.current)
logInfo('xterm exists', !!xtermRef.current)
logInfo('data length', data.length)
logInfo('MATCH', shellId === shellIdRef.current)
console.log(' Data preview:', text.substring(0, 200).replace(/\n/g, '\\n'))
// 使用 ref 获取最新的 shellId,避免闭包问题
if (shellId === shellIdRef.current && xtermRef.current) {
logSuccess('WRITING TO XTERM')
// 处理数据,高亮 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)
console.log('>>> DATA WRITTEN TO XTERM <<<')
// 更新传输统计
const connId = connectionIdRef.current
if (connId) {
const conn = getConnectionStore().getConnection(connId)
if (conn) {
conn.stats.bytesTransferred += data.length
}
}
} else {
logWarning('SKIPPED: shellId mismatch or xterm not ready')
logInfo('Expected', shellIdRef.current)
logInfo('Received', shellId)
logInfo('xterm exists', !!xtermRef.current)
}
}
// 只注册一次监听器(防止严格模式下重复注册)
if (!listenerRegisteredRef.current) {
debug('>>> Registering onShellData listener')
const handler = (event: any, connectionId: string, shellId: string, data: Buffer) => {
console.log('=== IPC onShellData RAW received ===')
console.log(' connectionId:', connectionId)
console.log(' shellId:', shellId)
console.log(' data type:', typeof data)
console.log(' data length:', data?.length)
handleShellDataRef.current?.(event, connectionId, shellId, data)
}
window.electronAPI?.onShellData(handler)
listenerRegisteredRef.current = true
debug('>>> onShellData listener registered')
} else {
debug('>>> onShellData listener already registered, skipping')
}
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 () => {
logHeader('CONNECT() CALLED')
logInfo('tabId', tabId)
logInfo('isConnectingRef', isConnectingRef.current)
logInfo('hasConnectedRef', hasConnectedRef.current)
logInfo('xtermRef', !!xtermRef.current)
if (isConnectingRef.current) {
console.log('>>> CONNECT: Already connecting, skipping <<<')
logger.debug('[Terminal] Connection already in progress, skipping')
return
}
isConnectingRef.current = true
console.log('>>> isConnectingRef set to true <<<')
// 直接使用 getTabById,避免 useCallback 循环问题
const currentTab = getTabById(tabId)
console.log(' currentTab:', currentTab ? 'found' : 'NOT FOUND')
if (!currentTab) {
logger.debug('[Terminal] Tab not found:', tabId)
isConnectingRef.current = false
return
}
logger.debug('[Terminal] Starting connection for tab:', tabId, 'sessionId:', currentTab.sessionId)
console.log('>>> 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('无可用连接信息')
}
logSuccess(`Config ready: ${config.host}:${config.port} (${config.username})`)
// 创建连接
console.log('>>> Calling createConnection...')
const result = await getConnectionStore().createConnection(tabId, config)
console.log('>>> createConnection result:', result)
if (!result.success || !result.connectionId) {
throw new Error(result.error || '连接失败')
}
const connectionId = result.connectionId
connectionIdRef.current = connectionId
logSuccess(`connectionId set: ${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
logSuccess(`shellIdRef set to: ${pendingShellId}`)
logger.debug('[Terminal] Creating shell for connection:', connectionId, 'with shellId:', pendingShellId)
console.log('>>> Calling sshCreateShell...')
const shellResult = await window.electronAPI?.sshCreateShell(connectionId, {
cols: xtermRef.current?.cols || 80,
rows: xtermRef.current?.rows || 24,
}, pendingShellId)
console.log('>>> sshCreateShell result:', shellResult)
if (!shellResult?.success || !shellResult.shellId) {
logger.error('[Terminal] Shell creation failed:', shellResult?.error)
console.error('>>> 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)
logSuccess(`Shell confirmed: ${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(() => {
logHeader('INITIAL CONNECT useEffect')
logInfo('hasConnectedRef', hasConnectedRef.current)
logInfo('isActive', isActive)
if (!hasConnectedRef.current && isActive) {
// 检查是否有快照
const hasSnapshot = snapshotStore.hasSnapshot(tabId)
logInfo('hasSnapshot', hasSnapshot)
if (hasSnapshot && isActive) {
// 先恢复快照
restoreSnapshot()
}
logSuccess('Calling connect() from initial useEffect')
connect()
} else {
logWarning('SKIPPED: hasConnectedRef=true or isActive=false')
}
}, []) // 只在挂载时执行
// 处理活跃状态变化
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)
xtermRef.current?.clear()
connect()
}
}, [connectionState, connect])
// 组件卸载时清理监听器
useEffect(() => {
logHeader('📺 TerminalView MOUNT')
logInfo('tabId', tabId)
return () => {
logHeader('📺 TerminalView UNMOUNT')
logInfo('tabId', tabId)
// 重置连接状态,防止严格模式下 remount 时状态混乱
isConnectingRef.current = false
listenerRegisteredRef.current = false // 重置监听器注册标记
logSuccess('Reset isConnectingRef and listenerRegisteredRef to false')
// 组件真正卸载时移除所有监听器
window.electronAPI?.removeAllListeners('ssh:shellData')
logSuccess('Removed all ssh:shellData listeners')
}
}, [])
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"
/>
{/* 挂起状态覆盖层 */}
{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-cyan-600 hover:bg-cyan-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-cyan-600 hover:bg-cyan-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-cyan-400 animate-pulse">连接中...</div>
</div>
)}
{/* 连接状态指示器 - 简洁版 */}
<div className="absolute top-3 right-3 flex items-center gap-2 z-20">
{/* 状态指示已移至 TabBar */}
</div>
</div>
</div>
)
}