feat: v0.3.0 版本 - SSH终端、SFTP、端口转发功能实现
核心功能: - SSH终端: 密码/密钥认证、XTerm.js终端、多主题配色、IP高亮 - SFTP管理: 双栏文件管理、上传下载、传输队列 - 端口转发: 本地/远程转发、SOCKS5动态代理、规则管理 架构改进: - 拆分大型组件 (SFTPView/TabBar/Sidebar) - 添加统一日志和错误处理 - 修复React严格模式下的终端黑屏问题 UI更新: - 新增端口转发视图 - 更新UI预览文档 - 左侧栏右键菜单支持SFTP快捷打开
This commit is contained in:
1 parent
cf1c054381
commit
484d850abd
105 files changed
+25330
-64
No files matched your search
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Connection Store - 连接管理模块
|
||||
* 统一导出
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types'
|
||||
|
||||
// Stores
|
||||
export { usePoolStore } from './poolStore'
|
||||
export { useLifecycleStore } from './lifecycleStore'
|
||||
|
||||
// Store Hooks & Combinations
|
||||
export {
|
||||
useConnectionStore,
|
||||
useConnectionPool, // backward compat
|
||||
getConnectionStore,
|
||||
createConnectionStore,
|
||||
} from './storeHooks'
|
||||
|
||||
// Helper Functions
|
||||
export { getSessionConfig } from './sessionHelper'
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Lifecycle Store - 连接生命周期管理
|
||||
* 负责: 状态转换、空闲超时、保活、活动跟踪、Shell/SFTP 会话管理
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import type { ConnectionState } from './types'
|
||||
import { usePoolStore } from './poolStore'
|
||||
|
||||
// ============== Lifecycle State ==============
|
||||
|
||||
interface LifecycleState {
|
||||
// 设置活跃标签
|
||||
setActiveTab: (connectionId: string, tabId: string) => void
|
||||
|
||||
// 标签进入后台
|
||||
setBackgroundTab: (connectionId: string, tabId: string) => void
|
||||
|
||||
// 挂起连接(空闲超时后)
|
||||
suspendConnection: (connectionId: string) => Promise<void>
|
||||
|
||||
// 恢复连接(从 suspended)
|
||||
resumeConnection: (connectionId: string) => Promise<boolean>
|
||||
|
||||
// 更新最后活动时间
|
||||
updateActivity: (connectionId: string) => void
|
||||
|
||||
// Shell/SFTP 会话管理
|
||||
addShell: (connectionId: string, shellId: string) => void
|
||||
addSFTP: (connectionId: string, sftpId: string) => void
|
||||
removeShell: (connectionId: string, shellId: string) => void
|
||||
removeSFTP: (connectionId: string, sftpId: string) => void
|
||||
}
|
||||
|
||||
// ============== Lifecycle Store Implementation ==============
|
||||
|
||||
export const useLifecycleStore = create<LifecycleState>((_set, get) => ({
|
||||
setActiveTab: (connectionId, tabId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
// 如果状态没有变化,不需要更新
|
||||
if (conn.state === 'connected' && conn.activeTabId === tabId) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// 清除之前的空闲超时
|
||||
if (conn.idleTimeout) {
|
||||
clearTimeout(conn.idleTimeout)
|
||||
}
|
||||
|
||||
// 直接更新 pool store,不触发 lifecycle store 的 setState
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, {
|
||||
...conn,
|
||||
state: 'connected' as ConnectionState,
|
||||
activeTabId: tabId,
|
||||
stats: {
|
||||
...conn.stats,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
idleTimeout: undefined,
|
||||
})
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
setBackgroundTab: (connectionId, _tabId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn || conn.state === 'disconnected') return
|
||||
|
||||
// 如果状态没有变化,不需要更新
|
||||
if (conn.state === 'background' && conn.activeTabId === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const { idleTimeoutMs } = poolStore.config
|
||||
|
||||
// 设置空闲超时
|
||||
const idleTimeout = window.setTimeout(() => {
|
||||
get().suspendConnection(connectionId)
|
||||
}, idleTimeoutMs)
|
||||
|
||||
// 直接更新 pool store
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, {
|
||||
...conn,
|
||||
state: 'background' as ConnectionState,
|
||||
activeTabId: null,
|
||||
stats: {
|
||||
...conn.stats,
|
||||
lastBackgroundAt: now,
|
||||
},
|
||||
idleTimeout,
|
||||
})
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
suspendConnection: async (connectionId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn || conn.state === 'disconnected' || conn.state === 'suspended') return
|
||||
|
||||
// 清除保活
|
||||
if (conn.keepaliveInterval) {
|
||||
clearInterval(conn.keepaliveInterval)
|
||||
}
|
||||
|
||||
// 设置挂起超时(之后彻底断开)
|
||||
const { suspendTimeoutMs } = poolStore.config
|
||||
const idleTimeout = window.setTimeout(() => {
|
||||
poolStore.disconnectConnection(connectionId, 'suspend timeout')
|
||||
}, suspendTimeoutMs)
|
||||
|
||||
// 直接更新 pool store
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, {
|
||||
...conn,
|
||||
state: 'suspended' as ConnectionState,
|
||||
keepaliveInterval: undefined,
|
||||
idleTimeout,
|
||||
})
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
resumeConnection: async (connectionId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return false
|
||||
|
||||
if (conn.state === 'connected' || conn.state === 'background') {
|
||||
return true
|
||||
}
|
||||
|
||||
// 尝试重新连接
|
||||
try {
|
||||
const result = await window.electronAPI?.sshConnect(conn.config)
|
||||
if (!result?.success) return false
|
||||
|
||||
const newConnectionId = result.connectionId
|
||||
if (!newConnectionId) return false
|
||||
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.delete(connectionId)
|
||||
|
||||
const now = Date.now()
|
||||
const keepaliveInterval = window.setInterval(() => {
|
||||
window.electronAPI?.sshExec(newConnectionId, 'echo -n ""').catch(() => {})
|
||||
}, poolStore.config.keepaliveIntervalMs)
|
||||
|
||||
newConnections.set(newConnectionId, {
|
||||
...conn,
|
||||
connectionId: newConnectionId,
|
||||
state: 'connected' as ConnectionState,
|
||||
stats: {
|
||||
...conn.stats,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
keepaliveInterval,
|
||||
idleTimeout: undefined,
|
||||
reconnectAttempts: 0,
|
||||
})
|
||||
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
updateActivity: (connectionId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, {
|
||||
...conn,
|
||||
stats: {
|
||||
...conn.stats,
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
})
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
addShell: (connectionId, shellId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
conn.shellIds.add(shellId)
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, conn)
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
addSFTP: (connectionId, sftpId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
conn.sftpIds.add(sftpId)
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, conn)
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
removeShell: (connectionId, shellId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
conn.shellIds.delete(shellId)
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, conn)
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
|
||||
removeSFTP: (connectionId, sftpId) => {
|
||||
const poolStore = usePoolStore.getState()
|
||||
const conn = poolStore.connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
conn.sftpIds.delete(sftpId)
|
||||
const newConnections = new Map(poolStore.connections)
|
||||
newConnections.set(connectionId, conn)
|
||||
usePoolStore.setState({ connections: newConnections })
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Pool Store - 连接池核心管理
|
||||
* 负责: 连接 Map、LRU 淘汰、引用计数、创建/断开连接
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import type {
|
||||
PooledConnection,
|
||||
ConnectionConfig,
|
||||
ConnectionState,
|
||||
PoolClientConfig,
|
||||
} from './types'
|
||||
import type { CreateConnectionOptions, CreateConnectionResult, PoolStats } from './types'
|
||||
|
||||
// ============== Pool State ==============
|
||||
|
||||
interface PoolState {
|
||||
// 连接池: connectionId -> PooledConnection
|
||||
connections: Map<string, PooledConnection>
|
||||
|
||||
// 配置
|
||||
config: PoolClientConfig
|
||||
|
||||
// Actions
|
||||
setConfig: (config: Partial<PoolClientConfig>) => void
|
||||
|
||||
// 连接管理
|
||||
createConnection: (
|
||||
tabId: string,
|
||||
config: ConnectionConfig,
|
||||
options?: CreateConnectionOptions
|
||||
) => Promise<CreateConnectionResult>
|
||||
|
||||
acquireConnection: (
|
||||
tabId: string,
|
||||
sessionId: string,
|
||||
getSessionConfig: () => Promise<ConnectionConfig | null>
|
||||
) => Promise<{ success: boolean; connectionId?: string; error?: string }>
|
||||
|
||||
resumeConnection: (connectionId: string) => Promise<boolean>
|
||||
|
||||
disconnectConnection: (connectionId: string, reason?: string) => Promise<void>
|
||||
|
||||
releaseConnection: (connectionId: string, tabId: string) => Promise<void>
|
||||
|
||||
// 连接查询
|
||||
getConnection: (connectionId: string) => PooledConnection | undefined
|
||||
getConnectionState: (connectionId: string) => ConnectionState | undefined
|
||||
|
||||
// LRU 淘汰
|
||||
evictOldestBackgroundConnection: () => Promise<string | null>
|
||||
shouldEvict: () => boolean
|
||||
|
||||
// 统计
|
||||
getStats: () => PoolStats
|
||||
|
||||
// 断开所有
|
||||
disconnectAll: () => Promise<void>
|
||||
}
|
||||
|
||||
// ============== Pool Store Implementation ==============
|
||||
|
||||
export const usePoolStore = create<PoolState>((set, get) => ({
|
||||
connections: new Map(),
|
||||
config: {
|
||||
maxConnections: 10,
|
||||
maxBackgroundConnections: 5,
|
||||
idleTimeoutMs: 5 * 60 * 1000,
|
||||
suspendTimeoutMs: 30 * 60 * 1000,
|
||||
keepaliveIntervalMs: 60 * 1000,
|
||||
maxReconnectAttempts: 3,
|
||||
},
|
||||
|
||||
setConfig: (newConfig) => {
|
||||
set((state) => ({
|
||||
config: { ...state.config, ...newConfig },
|
||||
}))
|
||||
},
|
||||
|
||||
createConnection: async (tabId, config, _options = {}): Promise<CreateConnectionResult> => {
|
||||
console.log('=== POOL STORE createConnection ===')
|
||||
console.log(' tabId:', tabId)
|
||||
console.log(' config:', config.host, config.port, config.username)
|
||||
|
||||
const { connections, config: poolConfig } = get()
|
||||
const now = Date.now()
|
||||
|
||||
// 1. 检查是否已存在相同配置的连接 (连接复用)
|
||||
for (const [existingId, conn] of connections.entries()) {
|
||||
if (
|
||||
conn.config.host === config.host &&
|
||||
conn.config.port === config.port &&
|
||||
conn.config.username === config.username &&
|
||||
conn.state !== 'disconnected' &&
|
||||
conn.state !== 'error'
|
||||
) {
|
||||
// 复用已有连接
|
||||
conn.refCount++
|
||||
conn.tabIds.add(tabId)
|
||||
conn.activeTabId = tabId
|
||||
|
||||
if (conn.state === 'suspended') {
|
||||
// 需要恢复连接 - 简化处理:断开后重建
|
||||
const resumed = await get().resumeConnection(existingId)
|
||||
if (!resumed) {
|
||||
conn.refCount--
|
||||
conn.tabIds.delete(tabId)
|
||||
continue // 尝试创建新连接
|
||||
}
|
||||
} else {
|
||||
conn.state = 'connected'
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newConnections = new Map(state.connections)
|
||||
newConnections.set(existingId, conn)
|
||||
return { connections: newConnections }
|
||||
})
|
||||
|
||||
return { success: true, connectionId: existingId, isExisting: true }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查是否需要淘汰
|
||||
if (connections.size >= poolConfig.maxConnections) {
|
||||
const evictedId = await get().evictOldestBackgroundConnection()
|
||||
if (!evictedId) {
|
||||
return {
|
||||
success: false,
|
||||
error: `连接数达到上限 (${poolConfig.maxConnections}),请先关闭一些连接`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 创建新连接
|
||||
console.log('>>> Creating new SSH connection via electronAPI...')
|
||||
try {
|
||||
const result = await window.electronAPI?.sshConnect({
|
||||
host: config.host,
|
||||
port: config.port || 22,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
privateKey: config.privateKey,
|
||||
passphrase: config.passphrase,
|
||||
})
|
||||
console.log('>>> sshConnect result:', result)
|
||||
|
||||
if (!result?.success || !result.connectionId) {
|
||||
console.log('>>> sshConnect FAILED:', result?.error)
|
||||
return {
|
||||
success: false,
|
||||
error: result?.error || 'Failed to create connection',
|
||||
}
|
||||
}
|
||||
|
||||
const connectionId = result.connectionId
|
||||
console.log('>>> Connection created, connectionId:', connectionId)
|
||||
|
||||
// 设置保活定时器
|
||||
const keepaliveInterval = window.setInterval(() => {
|
||||
const conn = get().connections.get(connectionId)
|
||||
if (conn && (conn.state === 'connected' || conn.state === 'background')) {
|
||||
window.electronAPI?.sshExec(connectionId, 'echo -n ""').catch(() => {
|
||||
get().disconnectConnection(connectionId, 'keepalive failed')
|
||||
})
|
||||
}
|
||||
}, poolConfig.keepaliveIntervalMs)
|
||||
|
||||
// 4. 创建连接池项
|
||||
const pooledConnection: PooledConnection = {
|
||||
connectionId,
|
||||
config: {
|
||||
host: config.host,
|
||||
port: config.port || 22,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
privateKey: config.privateKey,
|
||||
passphrase: config.passphrase,
|
||||
},
|
||||
state: 'connected',
|
||||
refCount: 1,
|
||||
tabIds: new Set([tabId]),
|
||||
activeTabId: tabId,
|
||||
shellIds: new Set(),
|
||||
sftpIds: new Set(),
|
||||
stats: {
|
||||
connectedAt: now,
|
||||
lastActivityAt: now,
|
||||
bytesTransferred: 0,
|
||||
commandsExecuted: 0,
|
||||
},
|
||||
keepaliveInterval,
|
||||
reconnectAttempts: 0,
|
||||
maxReconnectAttempts: poolConfig.maxReconnectAttempts,
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newConnections = new Map(state.connections)
|
||||
newConnections.set(connectionId, pooledConnection)
|
||||
return { connections: newConnections }
|
||||
})
|
||||
|
||||
return { success: true, connectionId }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Unknown error creating connection',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 辅助: 恢复连接 (简化实现)
|
||||
resumeConnection: async (connectionId: string): Promise<boolean> => {
|
||||
const conn = get().connections.get(connectionId)
|
||||
if (!conn) return false
|
||||
|
||||
if (conn.state === 'connected' || conn.state === 'background') {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI?.sshConnect(conn.config)
|
||||
if (!result?.success) return false
|
||||
|
||||
const newConnectionId = result.connectionId
|
||||
if (!newConnectionId) return false
|
||||
|
||||
set((state) => {
|
||||
const newConnections = new Map(state.connections)
|
||||
newConnections.delete(connectionId)
|
||||
|
||||
const now = Date.now()
|
||||
const keepaliveInterval = window.setInterval(() => {
|
||||
window.electronAPI?.sshExec(newConnectionId, 'echo -n ""').catch(() => {})
|
||||
}, state.config.keepaliveIntervalMs)
|
||||
|
||||
newConnections.set(newConnectionId, {
|
||||
...conn,
|
||||
connectionId: newConnectionId,
|
||||
state: 'connected',
|
||||
stats: {
|
||||
...conn.stats,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
keepaliveInterval,
|
||||
idleTimeout: undefined,
|
||||
reconnectAttempts: 0,
|
||||
})
|
||||
|
||||
return { connections: newConnections }
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
acquireConnection: async (tabId, _sessionId, getSessionConfigFn) => {
|
||||
const { connections } = get()
|
||||
|
||||
// 检查是否已有该标签的连接
|
||||
for (const [connId, conn] of connections.entries()) {
|
||||
if (conn.tabIds.has(tabId) && conn.state !== 'disconnected') {
|
||||
return { success: true, connectionId: connId }
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 session 配置并创建连接
|
||||
const config = await getSessionConfigFn()
|
||||
if (!config) {
|
||||
return { success: false, error: '无法获取会话配置' }
|
||||
}
|
||||
|
||||
return get().createConnection(tabId, config)
|
||||
},
|
||||
|
||||
disconnectConnection: async (connectionId, _reason = 'manual') => {
|
||||
const conn = get().connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
// 清除定时器
|
||||
if (conn.keepaliveInterval) {
|
||||
clearInterval(conn.keepaliveInterval)
|
||||
}
|
||||
if (conn.idleTimeout) {
|
||||
clearTimeout(conn.idleTimeout)
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI?.sshDisconnect(connectionId)
|
||||
} catch {
|
||||
// 忽略断开错误
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newConnections = new Map(state.connections)
|
||||
newConnections.delete(connectionId)
|
||||
return { connections: newConnections }
|
||||
})
|
||||
},
|
||||
|
||||
releaseConnection: async (connectionId, tabId) => {
|
||||
const conn = get().connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
conn.tabIds.delete(tabId)
|
||||
conn.refCount = Math.max(0, conn.refCount - 1)
|
||||
|
||||
if (conn.activeTabId === tabId) {
|
||||
conn.activeTabId = null
|
||||
}
|
||||
|
||||
// 如果没有标签使用,断开连接
|
||||
if (conn.refCount === 0 || conn.tabIds.size === 0) {
|
||||
await get().disconnectConnection(connectionId, 'no refs')
|
||||
return
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newConnections = new Map(state.connections)
|
||||
newConnections.set(connectionId, conn)
|
||||
return { connections: newConnections }
|
||||
})
|
||||
},
|
||||
|
||||
getConnection: (connectionId) => {
|
||||
return get().connections.get(connectionId)
|
||||
},
|
||||
|
||||
getConnectionState: (connectionId) => {
|
||||
return get().connections.get(connectionId)?.state
|
||||
},
|
||||
|
||||
evictOldestBackgroundConnection: async () => {
|
||||
const { connections } = get()
|
||||
|
||||
let oldestId: string | null = null
|
||||
let oldestTime = Infinity
|
||||
|
||||
for (const [id, conn] of connections.entries()) {
|
||||
if (conn.state === 'background' || conn.state === 'suspended') {
|
||||
const lastActivity = conn.stats.lastBackgroundAt || conn.stats.lastActivityAt
|
||||
if (lastActivity < oldestTime) {
|
||||
oldestTime = lastActivity
|
||||
oldestId = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestId) {
|
||||
await get().disconnectConnection(oldestId, 'evicted')
|
||||
}
|
||||
|
||||
return oldestId
|
||||
},
|
||||
|
||||
shouldEvict: () => {
|
||||
const { connections, config } = get()
|
||||
return connections.size >= config.maxConnections
|
||||
},
|
||||
|
||||
getStats: (): PoolStats => {
|
||||
const { connections } = get()
|
||||
let connected = 0, background = 0, suspended = 0, connecting = 0, error = 0
|
||||
|
||||
connections.forEach((conn) => {
|
||||
switch (conn.state) {
|
||||
case 'connected': connected++; break
|
||||
case 'background': background++; break
|
||||
case 'suspended': suspended++; break
|
||||
case 'connecting': connecting++; break
|
||||
case 'error': error++; break
|
||||
}
|
||||
})
|
||||
|
||||
return { total: connections.size, connected, background, suspended, connecting, error }
|
||||
},
|
||||
|
||||
disconnectAll: async () => {
|
||||
const { connections } = get()
|
||||
const promises: Promise<void>[] = []
|
||||
|
||||
connections.forEach((conn) => {
|
||||
if (conn.keepaliveInterval) {
|
||||
clearInterval(conn.keepaliveInterval)
|
||||
}
|
||||
if (conn.idleTimeout) {
|
||||
clearTimeout(conn.idleTimeout)
|
||||
}
|
||||
|
||||
promises.push(
|
||||
window.electronAPI?.sshDisconnect(conn.connectionId)
|
||||
.then(() => {})
|
||||
.catch(() => {}) as Promise<void>
|
||||
)
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
set({ connections: new Map() })
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Session Helper - 会话配置辅助函数
|
||||
* 从 sessionId 获取连接配置
|
||||
*/
|
||||
|
||||
import type { ConnectionConfig } from './types'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
|
||||
const logger = createLogger('SessionHelper')
|
||||
|
||||
/**
|
||||
* 从 sessionId 获取连接配置
|
||||
*/
|
||||
export async function getSessionConfig(sessionId: string): Promise<ConnectionConfig | null> {
|
||||
try {
|
||||
const result = await window.electronAPI?.sessionGetById(sessionId)
|
||||
if (!result?.success || !result.session) {
|
||||
return null
|
||||
}
|
||||
|
||||
const session = result.session
|
||||
return {
|
||||
host: session.host,
|
||||
port: session.port || 22,
|
||||
username: session.username,
|
||||
password: session.password,
|
||||
privateKey: session.privateKey,
|
||||
passphrase: session.passphrase,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get session config:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Connection Store Hooks - 组合 Store 接口
|
||||
* 提供统一的连接管理接口
|
||||
*/
|
||||
|
||||
import { usePoolStore } from './poolStore'
|
||||
import { useLifecycleStore } from './lifecycleStore'
|
||||
|
||||
// ============== 组合 Store Hook ==============
|
||||
|
||||
export function useConnectionStore() {
|
||||
const pool = usePoolStore()
|
||||
const lifecycle = useLifecycleStore()
|
||||
|
||||
return {
|
||||
// 配置
|
||||
config: pool.config,
|
||||
setConfig: pool.setConfig,
|
||||
|
||||
// 连接生命周期
|
||||
createConnection: pool.createConnection,
|
||||
acquireConnection: pool.acquireConnection,
|
||||
disconnectConnection: pool.disconnectConnection,
|
||||
releaseConnection: pool.releaseConnection,
|
||||
|
||||
// 状态管理
|
||||
setActiveTab: lifecycle.setActiveTab,
|
||||
setBackgroundTab: lifecycle.setBackgroundTab,
|
||||
suspendConnection: lifecycle.suspendConnection,
|
||||
resumeConnection: lifecycle.resumeConnection,
|
||||
updateActivity: lifecycle.updateActivity,
|
||||
|
||||
// 会话管理
|
||||
addShell: lifecycle.addShell,
|
||||
addSFTP: lifecycle.addSFTP,
|
||||
removeShell: lifecycle.removeShell,
|
||||
removeSFTP: lifecycle.removeSFTP,
|
||||
|
||||
// 查询
|
||||
getConnection: pool.getConnection,
|
||||
getConnectionState: pool.getConnectionState,
|
||||
getStats: pool.getStats,
|
||||
|
||||
// LRU
|
||||
evictOldestBackgroundConnection: pool.evictOldestBackgroundConnection,
|
||||
shouldEvict: pool.shouldEvict,
|
||||
|
||||
// 全部断开
|
||||
disconnectAll: pool.disconnectAll,
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Backward Compatibility ==============
|
||||
// 保持原有 useConnectionPool 名称以便现有代码无需修改
|
||||
|
||||
/**
|
||||
* @deprecated 请使用 useConnectionStore()
|
||||
*/
|
||||
export const useConnectionPool = useConnectionStore
|
||||
|
||||
// ============== Static Store Access ==============
|
||||
// 用于非 React 上下文中访问 store
|
||||
|
||||
/**
|
||||
* 获取组合 store 的状态 (用于静态调用)
|
||||
*/
|
||||
export function getConnectionStore() {
|
||||
const pool = usePoolStore.getState()
|
||||
const lifecycle = useLifecycleStore.getState()
|
||||
|
||||
return {
|
||||
config: pool.config,
|
||||
setConfig: pool.setConfig,
|
||||
createConnection: pool.createConnection,
|
||||
acquireConnection: pool.acquireConnection,
|
||||
disconnectConnection: pool.disconnectConnection,
|
||||
releaseConnection: pool.releaseConnection,
|
||||
setActiveTab: lifecycle.setActiveTab,
|
||||
setBackgroundTab: lifecycle.setBackgroundTab,
|
||||
suspendConnection: lifecycle.suspendConnection,
|
||||
resumeConnection: lifecycle.resumeConnection,
|
||||
updateActivity: lifecycle.updateActivity,
|
||||
addShell: lifecycle.addShell,
|
||||
addSFTP: lifecycle.addSFTP,
|
||||
removeShell: lifecycle.removeShell,
|
||||
removeSFTP: lifecycle.removeSFTP,
|
||||
getConnection: pool.getConnection,
|
||||
getConnectionState: pool.getConnectionState,
|
||||
getStats: pool.getStats,
|
||||
evictOldestBackgroundConnection: pool.evictOldestBackgroundConnection,
|
||||
shouldEvict: pool.shouldEvict,
|
||||
disconnectAll: pool.disconnectAll,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 请使用 getConnectionStore()
|
||||
*/
|
||||
export const createConnectionStore = getConnectionStore
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Connection Types - 连接管理类型定义
|
||||
* 渲染进程专用 (客户端)
|
||||
*/
|
||||
|
||||
// Re-export shared types
|
||||
export type {
|
||||
ConnectionState,
|
||||
ConnectionStats,
|
||||
PoolConfig,
|
||||
PooledConnection,
|
||||
ConnectionResult,
|
||||
ShellInfo,
|
||||
ConnectionConfig,
|
||||
} from '../../../shared/types/connection.types'
|
||||
|
||||
export type { AuthType, Session } from '../../../shared/types/session.types'
|
||||
export type { SSHConnectionConfig } from '../../../shared/types/session.types'
|
||||
|
||||
// ============== Additional Client-side Types ==============
|
||||
|
||||
// 连接池配置 (客户端用)
|
||||
export interface PoolClientConfig {
|
||||
maxConnections: number // 最大连接数
|
||||
maxBackgroundConnections: number // 最大后台连接数
|
||||
idleTimeoutMs: number // 空闲超时(进入 suspended)
|
||||
suspendTimeoutMs: number // 挂起超时(断开连接)
|
||||
keepaliveIntervalMs: number // 保活间隔
|
||||
maxReconnectAttempts: number // 最大重连次数
|
||||
}
|
||||
|
||||
// 连接创建选项
|
||||
export interface CreateConnectionOptions {
|
||||
skipIfExists?: boolean // 如果存在则跳过
|
||||
}
|
||||
|
||||
// 连接创建结果
|
||||
export interface CreateConnectionResult {
|
||||
success: boolean
|
||||
connectionId?: string
|
||||
error?: string
|
||||
isExisting?: boolean
|
||||
}
|
||||
|
||||
// 获取连接结果
|
||||
export interface AcquireConnectionResult {
|
||||
success: boolean
|
||||
connectionId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 连接统计摘要
|
||||
export interface PoolStats {
|
||||
total: number
|
||||
connected: number
|
||||
background: number
|
||||
suspended: number
|
||||
connecting: number
|
||||
error: number
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Store 统一导出
|
||||
*/
|
||||
|
||||
export { useTabStore } from './tabStore'
|
||||
export { useSettingsStore } from './settingsStore'
|
||||
export { useTerminalSnapshotStore } from './terminalSnapshotStore'
|
||||
|
||||
// 会话管理
|
||||
export { useSessionStore, useConnectionStore as useSessionStoreCompat } from './sessionStore'
|
||||
export type { Session } from './sessionStore'
|
||||
|
||||
// 连接管理 (新架构)
|
||||
export {
|
||||
useConnectionStore as useConnectionManager,
|
||||
useConnectionPool,
|
||||
getConnectionStore,
|
||||
getSessionConfig,
|
||||
} from './connection'
|
||||
|
||||
// 类型导出
|
||||
export type { ConnectionState } from './connection'
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Session Store - 会话管理
|
||||
*
|
||||
* 管理保存的 SSH 会话配置
|
||||
* 与 connection store 分离,专注会话 CRUD
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
authType: 'password' | 'key' | 'agent'
|
||||
password?: string
|
||||
privateKey?: string
|
||||
passphrase?: string
|
||||
group?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
sessions: Session[]
|
||||
setSessions: (sessions: Session[]) => void
|
||||
addSession: (session: Session) => void
|
||||
removeSession: (id: string) => void
|
||||
updateSession: (id: string, updates: Partial<Session>) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>((set) => ({
|
||||
sessions: [],
|
||||
|
||||
setSessions: (sessions) => set({ sessions }),
|
||||
|
||||
addSession: (session) => {
|
||||
set((state) => ({
|
||||
sessions: [...state.sessions, session],
|
||||
}))
|
||||
},
|
||||
|
||||
removeSession: (id) => {
|
||||
set((state) => ({
|
||||
sessions: state.sessions.filter((s) => s.id !== id),
|
||||
}))
|
||||
// 同步删除数据库中的会话
|
||||
window.electronAPI?.sessionDelete(id)
|
||||
},
|
||||
|
||||
updateSession: async (id, updates) => {
|
||||
set((state) => ({
|
||||
sessions: state.sessions.map((s) =>
|
||||
s.id === id ? { ...s, ...updates } : s
|
||||
),
|
||||
}))
|
||||
// 同步更新数据库中的会话
|
||||
await window.electronAPI?.sessionUpdate(id, updates)
|
||||
},
|
||||
}))
|
||||
|
||||
// 向后兼容:导出旧名称
|
||||
export const useConnectionStore = useSessionStore
|
||||
@@ -0,0 +1,99 @@
|
||||
import { create } from 'zustand'
|
||||
import { createLogger } from '../utils/logger'
|
||||
|
||||
const logger = createLogger('SettingsStore')
|
||||
|
||||
export interface AppSettings {
|
||||
// 常规
|
||||
restoreOnStart: boolean
|
||||
autoSave: boolean
|
||||
showNotifications: boolean
|
||||
|
||||
// 外观
|
||||
theme: 'dark' | 'light' | 'system'
|
||||
language: 'zh-CN' | 'en-US'
|
||||
|
||||
// 终端
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
terminalTheme: 'dracula' | 'monokai' | 'solarized' | 'one-dark'
|
||||
|
||||
// SSH
|
||||
keepAlive: boolean
|
||||
defaultPort: number
|
||||
timeout: number
|
||||
|
||||
// 专业连接管理
|
||||
maxConnections: number
|
||||
maxBackgroundConnections: number
|
||||
idleTimeoutMs: number
|
||||
suspendTimeoutMs: number
|
||||
enableSnapshots: boolean
|
||||
autoReconnect: boolean
|
||||
|
||||
// 安全
|
||||
encryptPasswords: boolean
|
||||
lockOnSleep: boolean
|
||||
}
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
restoreOnStart: true,
|
||||
autoSave: true,
|
||||
showNotifications: false,
|
||||
theme: 'dark',
|
||||
language: 'zh-CN',
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 14,
|
||||
terminalTheme: 'dracula',
|
||||
keepAlive: true,
|
||||
defaultPort: 22,
|
||||
timeout: 30,
|
||||
// 专业连接管理默认值
|
||||
maxConnections: 10,
|
||||
maxBackgroundConnections: 5,
|
||||
idleTimeoutMs: 5 * 60 * 1000, // 5 分钟
|
||||
suspendTimeoutMs: 30 * 60 * 1000, // 30 分钟
|
||||
enableSnapshots: true,
|
||||
autoReconnect: true,
|
||||
encryptPasswords: true,
|
||||
lockOnSleep: false,
|
||||
}
|
||||
|
||||
interface SettingsState {
|
||||
settings: AppSettings
|
||||
isLoaded: boolean
|
||||
setSetting: <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => Promise<void>
|
||||
loadSettings: () => Promise<void>
|
||||
saveSettings: (settings: Partial<AppSettings>) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
settings: defaultSettings,
|
||||
isLoaded: false,
|
||||
|
||||
loadSettings: async () => {
|
||||
try {
|
||||
const result = await window.electronAPI?.settingsGet('app-settings')
|
||||
if (result?.success && result.value) {
|
||||
set({ settings: { ...defaultSettings, ...result.value }, isLoaded: true })
|
||||
} else {
|
||||
set({ isLoaded: true })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load settings:', error)
|
||||
set({ isLoaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
setSetting: async (key, value) => {
|
||||
const newSettings = { ...get().settings, [key]: value }
|
||||
set({ settings: newSettings })
|
||||
await window.electronAPI?.settingsSet('app-settings', newSettings)
|
||||
},
|
||||
|
||||
saveSettings: async (newSettings) => {
|
||||
const merged = { ...get().settings, ...newSettings }
|
||||
set({ settings: merged })
|
||||
await window.electronAPI?.settingsSet('app-settings', merged)
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,226 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type TabType = 'welcome' | 'terminal' | 'sftp' | 'portforward' | 'settings'
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
// SSH 连接配置(用于快速连接)
|
||||
export interface SSHConnectionConfig {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password?: string
|
||||
privateKey?: string
|
||||
passphrase?: string
|
||||
}
|
||||
|
||||
// 连接实例信息
|
||||
export interface ConnectionInstance {
|
||||
connectionId: string // 实际的 SSH 连接 ID
|
||||
shellId?: string // Shell 会话 ID(仅 terminal 类型)
|
||||
sftpId?: string // SFTP 会话 ID(仅 sftp 类型)
|
||||
}
|
||||
|
||||
export interface Tab {
|
||||
id: string
|
||||
title: string
|
||||
type: TabType
|
||||
sessionId?: string // 持久化会话 ID(用于获取配置)
|
||||
connectionConfig?: SSHConnectionConfig // 快速连接配置(临时)
|
||||
connectionInstance?: ConnectionInstance // 实际的连接实例(每个标签独立)
|
||||
isActive: boolean
|
||||
connectionStatus?: ConnectionStatus
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
tabs: Tab[]
|
||||
activeTabId: string | null
|
||||
|
||||
// Actions
|
||||
addTab: (tab: Omit<Tab, 'id' | 'isActive'>) => void
|
||||
updateTab: (tabId: string, updates: Partial<Tab>) => void
|
||||
updateTabConnectionInstance: (tabId: string, instance: ConnectionInstance) => void
|
||||
updateTabStatus: (tabId: string, status: ConnectionStatus) => void
|
||||
closeTab: (tabId: string) => Tab | null // 返回被关闭的标签(用于清理连接)
|
||||
closeOtherTabs: (tabId: string) => Tab[] // 返回被关闭的标签数组
|
||||
closeRightTabs: (tabId: string) => Tab[]
|
||||
closeLeftTabs: (tabId: string) => Tab[]
|
||||
closeAllTabs: () => Tab[]
|
||||
activateTab: (tabId: string) => void
|
||||
updateTabTitle: (tabId: string, title: string) => void
|
||||
getTabById: (tabId: string) => Tab | undefined
|
||||
}
|
||||
|
||||
export const useTabStore = create<TabState>((set, get) => ({
|
||||
tabs: [
|
||||
{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }
|
||||
],
|
||||
activeTabId: 'welcome',
|
||||
|
||||
addTab: (tab) => {
|
||||
const { tabs } = get()
|
||||
|
||||
// 检查是否是唯一类型的标签(settings, welcome),如果有则激活而不是创建新的
|
||||
if (tab.type === 'settings' || tab.type === 'welcome') {
|
||||
const existing = tabs.find(t => t.type === tab.type)
|
||||
if (existing) {
|
||||
// 激活已存在的标签
|
||||
const updatedTabs = tabs.map(t => ({
|
||||
...t,
|
||||
isActive: t.id === existing.id,
|
||||
}))
|
||||
set({ tabs: updatedTabs, activeTabId: existing.id })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const newTab: Tab = {
|
||||
...tab,
|
||||
id: `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// Deactivate all other tabs
|
||||
const updatedTabs = tabs.map(t => ({ ...t, isActive: false }))
|
||||
|
||||
set({
|
||||
tabs: [...updatedTabs, newTab],
|
||||
activeTabId: newTab.id,
|
||||
})
|
||||
},
|
||||
|
||||
updateTab: (tabId, updates) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, ...updates } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
updateTabConnectionInstance: (tabId, instance) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, connectionInstance: instance } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
updateTabStatus: (tabId, status) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, connectionStatus: status } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
closeTab: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
|
||||
if (tabs.length <= 1) return null
|
||||
|
||||
const closedTab = tabs.find(t => t.id === tabId) || null
|
||||
const newTabs = tabs.filter(t => t.id !== tabId)
|
||||
|
||||
// If closing active tab, activate another one
|
||||
let newActiveId = activeTabId
|
||||
if (activeTabId === tabId) {
|
||||
const closedTabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
const newActiveTab = newTabs[Math.min(closedTabIndex, newTabs.length - 1)]
|
||||
if (newActiveTab) {
|
||||
newActiveTab.isActive = true
|
||||
newActiveId = newActiveTab.id
|
||||
}
|
||||
}
|
||||
|
||||
set({ tabs: newTabs, activeTabId: newActiveId })
|
||||
return closedTab
|
||||
},
|
||||
|
||||
closeOtherTabs: (tabId) => {
|
||||
const { tabs } = get()
|
||||
const keepTab = tabs.find(t => t.id === tabId)
|
||||
if (!keepTab) return []
|
||||
|
||||
const closedTabs = tabs.filter(t => t.id !== tabId)
|
||||
keepTab.isActive = true
|
||||
set({
|
||||
tabs: [keepTab],
|
||||
activeTabId: tabId,
|
||||
})
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeRightTabs: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
if (tabIndex === -1) return []
|
||||
|
||||
// 保留当前标签及其左侧所有标签
|
||||
const closedTabs = tabs.slice(tabIndex + 1)
|
||||
const newTabs = tabs.slice(0, tabIndex + 1)
|
||||
// 如果关闭的是当前活动标签,激活最后一个
|
||||
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) > tabIndex) {
|
||||
newTabs[newTabs.length - 1].isActive = true
|
||||
set({ tabs: newTabs, activeTabId: newTabs[newTabs.length - 1].id })
|
||||
} else {
|
||||
set({ tabs: newTabs })
|
||||
}
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeLeftTabs: (tabId) => {
|
||||
const { tabs, activeTabId } = get()
|
||||
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
||||
if (tabIndex === -1) return []
|
||||
|
||||
// 保留当前标签及其右侧所有标签
|
||||
const closedTabs = tabs.slice(0, tabIndex)
|
||||
const newTabs = tabs.slice(tabIndex)
|
||||
// 如果关闭的是当前活动标签,激活第一个
|
||||
if (activeTabId && tabs.findIndex(t => t.id === activeTabId) < tabIndex) {
|
||||
newTabs[0].isActive = true
|
||||
set({ tabs: newTabs, activeTabId: newTabs[0].id })
|
||||
} else {
|
||||
set({ tabs: newTabs, activeTabId: tabId })
|
||||
}
|
||||
return closedTabs
|
||||
},
|
||||
|
||||
closeAllTabs: () => {
|
||||
const { tabs } = get()
|
||||
const welcomeTab: Tab = {
|
||||
id: 'welcome',
|
||||
title: '欢迎',
|
||||
type: 'welcome',
|
||||
isActive: true,
|
||||
}
|
||||
set({
|
||||
tabs: [welcomeTab],
|
||||
activeTabId: 'welcome',
|
||||
})
|
||||
return tabs
|
||||
},
|
||||
|
||||
activateTab: (tabId) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t => ({
|
||||
...t,
|
||||
isActive: t.id === tabId,
|
||||
}))
|
||||
set({
|
||||
tabs: updatedTabs,
|
||||
activeTabId: tabId,
|
||||
})
|
||||
},
|
||||
|
||||
updateTabTitle: (tabId, title) => {
|
||||
const { tabs } = get()
|
||||
const updatedTabs = tabs.map(t =>
|
||||
t.id === tabId ? { ...t, title } : t
|
||||
)
|
||||
set({ tabs: updatedTabs })
|
||||
},
|
||||
|
||||
getTabById: (tabId) => {
|
||||
return get().tabs.find(t => t.id === tabId)
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,348 @@
|
||||
import { create } from 'zustand'
|
||||
import { createLogger } from '../utils/logger'
|
||||
|
||||
const logger = createLogger('TerminalSnapshotStore')
|
||||
|
||||
// 终端缓冲区行
|
||||
export interface BufferLine {
|
||||
text: string
|
||||
styles: number[] // xterm 样式信息
|
||||
}
|
||||
|
||||
// 终端快照
|
||||
export interface TerminalSnapshot {
|
||||
tabId: string
|
||||
connectionId: string
|
||||
shellId: string | null
|
||||
|
||||
// 缓冲区内容(最后 N 行)
|
||||
buffer: string[]
|
||||
bufferLines: number // 总行数
|
||||
|
||||
// 光标位置
|
||||
cursorX: number
|
||||
cursorY: number
|
||||
|
||||
// 视口
|
||||
scrollTop: number
|
||||
scrollBottom: number
|
||||
|
||||
// 尺寸
|
||||
cols: number
|
||||
rows: number
|
||||
|
||||
// 元数据
|
||||
timestamp: number
|
||||
hasFocus: boolean
|
||||
|
||||
// 终端模式
|
||||
applicationCursorKeys: boolean
|
||||
applicationKeypad: boolean
|
||||
originMode: boolean
|
||||
insertMode: boolean
|
||||
wraparoundMode: boolean
|
||||
bracketedPasteMode: boolean
|
||||
}
|
||||
|
||||
// 快照配置
|
||||
interface SnapshotConfig {
|
||||
maxBufferLines: number // 保存的最大缓冲区行数
|
||||
snapshotOnBlur: boolean // 失去焦点时自动快照
|
||||
snapshotOnSwitch: boolean // 切换标签时自动快照
|
||||
restoreAnimation: boolean // 恢复时是否播放淡入动画
|
||||
}
|
||||
|
||||
interface TerminalSnapshotState {
|
||||
// 快照存储: tabId -> TerminalSnapshot
|
||||
snapshots: Map<string, TerminalSnapshot>
|
||||
|
||||
// 配置
|
||||
config: SnapshotConfig
|
||||
|
||||
// 当前活跃的标签
|
||||
activeTabId: string | null
|
||||
|
||||
// Actions
|
||||
setConfig: (config: Partial<SnapshotConfig>) => void
|
||||
|
||||
// 创建快照
|
||||
createSnapshot: (
|
||||
tabId: string,
|
||||
terminal: {
|
||||
buffer: { active: { getLine: (y: number) => { translateToString: (trimRight?: boolean) => string } | undefined; length: number; cursorX: number; cursorY: number } }
|
||||
cols: number
|
||||
rows: number
|
||||
modes: {
|
||||
applicationCursorKeysMode: boolean
|
||||
applicationKeypadMode: boolean
|
||||
originMode: boolean
|
||||
insertMode: boolean
|
||||
wraparoundMode: boolean
|
||||
bracketedPasteMode: boolean
|
||||
}
|
||||
},
|
||||
connectionInfo: {
|
||||
connectionId: string
|
||||
shellId: string | null
|
||||
}
|
||||
) => void
|
||||
|
||||
// 获取快照
|
||||
getSnapshot: (tabId: string) => TerminalSnapshot | undefined
|
||||
|
||||
// 检查是否有快照
|
||||
hasSnapshot: (tabId: string) => boolean
|
||||
|
||||
// 删除快照
|
||||
deleteSnapshot: (tabId: string) => void
|
||||
|
||||
// 恢复快照到终端
|
||||
restoreSnapshot: (
|
||||
tabId: string,
|
||||
terminal: {
|
||||
write: (data: string) => void
|
||||
resize: (cols: number, rows: number) => void
|
||||
clear: () => void
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
) => boolean
|
||||
|
||||
// 设置当前活跃标签
|
||||
setActiveTab: (tabId: string | null) => void
|
||||
|
||||
// 处理标签切换(自动快照/恢复)
|
||||
handleTabSwitch: (fromTabId: string | null, toTabId: string | null) => void
|
||||
|
||||
// 清空所有快照
|
||||
clearAll: () => void
|
||||
|
||||
// 获取快照统计
|
||||
getStats: () => {
|
||||
count: number
|
||||
totalSize: number // 估算的字节数
|
||||
oldestSnapshot: number | null
|
||||
}
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_CONFIG: SnapshotConfig = {
|
||||
maxBufferLines: 1000, // 保存最后 1000 行
|
||||
snapshotOnBlur: true,
|
||||
snapshotOnSwitch: true,
|
||||
restoreAnimation: true,
|
||||
}
|
||||
|
||||
// ANSI 转义序列工具
|
||||
const ANSI = {
|
||||
clear: '\x1b[2J\x1b[H', // 清屏并移动光标到左上角
|
||||
clearScrollback: '\x1b[3J', // 清除滚动缓冲区
|
||||
reset: '\x1b[c', // 重置终端
|
||||
saveCursor: '\x1b[s', // 保存光标位置
|
||||
restoreCursor: '\x1b[u', // 恢复光标位置
|
||||
hideCursor: '\x1b[?25l', // 隐藏光标
|
||||
showCursor: '\x1b[?25h', // 显示光标
|
||||
setCursor: (x: number, y: number) => `\x1b[${y + 1};${x + 1}H`, // 设置光标位置
|
||||
}
|
||||
|
||||
export const useTerminalSnapshotStore = create<TerminalSnapshotState>((set, get) => ({
|
||||
snapshots: new Map(),
|
||||
config: { ...DEFAULT_CONFIG },
|
||||
activeTabId: null,
|
||||
|
||||
setConfig: (newConfig) => {
|
||||
set((state) => ({
|
||||
config: { ...state.config, ...newConfig }
|
||||
}))
|
||||
},
|
||||
|
||||
createSnapshot: (tabId, terminal, connectionInfo) => {
|
||||
const { config } = get()
|
||||
|
||||
try {
|
||||
// 提取缓冲区内容
|
||||
const buffer: string[] = []
|
||||
const bufferLength = terminal.buffer.active.length
|
||||
const startLine = Math.max(0, bufferLength - config.maxBufferLines)
|
||||
|
||||
for (let i = startLine; i < bufferLength; i++) {
|
||||
const line = terminal.buffer.active.getLine(i)
|
||||
if (line) {
|
||||
buffer.push(line.translateToString(true))
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot: TerminalSnapshot = {
|
||||
tabId,
|
||||
connectionId: connectionInfo.connectionId,
|
||||
shellId: connectionInfo.shellId,
|
||||
buffer,
|
||||
bufferLines: bufferLength,
|
||||
cursorX: terminal.buffer.active.cursorX,
|
||||
cursorY: terminal.buffer.active.cursorY,
|
||||
scrollTop: 0, // xterm.js 不直接暴露这些,使用默认值
|
||||
scrollBottom: terminal.rows - 1,
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
timestamp: Date.now(),
|
||||
hasFocus: false,
|
||||
applicationCursorKeys: terminal.modes.applicationCursorKeysMode,
|
||||
applicationKeypad: terminal.modes.applicationKeypadMode,
|
||||
originMode: terminal.modes.originMode,
|
||||
insertMode: terminal.modes.insertMode,
|
||||
wraparoundMode: terminal.modes.wraparoundMode,
|
||||
bracketedPasteMode: terminal.modes.bracketedPasteMode,
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newSnapshots = new Map(state.snapshots)
|
||||
newSnapshots.set(tabId, snapshot)
|
||||
return { snapshots: newSnapshots }
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to create snapshot:', error)
|
||||
}
|
||||
},
|
||||
|
||||
getSnapshot: (tabId) => {
|
||||
return get().snapshots.get(tabId)
|
||||
},
|
||||
|
||||
hasSnapshot: (tabId) => {
|
||||
return get().snapshots.has(tabId)
|
||||
},
|
||||
|
||||
deleteSnapshot: (tabId) => {
|
||||
set((state) => {
|
||||
const newSnapshots = new Map(state.snapshots)
|
||||
newSnapshots.delete(tabId)
|
||||
return { snapshots: newSnapshots }
|
||||
})
|
||||
},
|
||||
|
||||
restoreSnapshot: (tabId, terminal) => {
|
||||
const snapshot = get().snapshots.get(tabId)
|
||||
if (!snapshot) return false
|
||||
|
||||
try {
|
||||
// 清屏
|
||||
terminal.clear()
|
||||
|
||||
// 调整尺寸(如果不同)
|
||||
if (snapshot.cols !== terminal.cols || snapshot.rows !== terminal.rows) {
|
||||
terminal.resize(snapshot.cols, snapshot.rows)
|
||||
}
|
||||
|
||||
// 写入缓冲区内容
|
||||
// 使用更高效的批量写入
|
||||
const content = snapshot.buffer.join('\r\n')
|
||||
if (content) {
|
||||
terminal.write(content)
|
||||
}
|
||||
|
||||
// 恢复光标位置
|
||||
terminal.write(ANSI.setCursor(snapshot.cursorX, snapshot.cursorY))
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to restore snapshot:', error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
setActiveTab: (tabId) => {
|
||||
set({ activeTabId: tabId })
|
||||
},
|
||||
|
||||
handleTabSwitch: (fromTabId, toTabId) => {
|
||||
const { config } = get()
|
||||
|
||||
// 在离开标签时创建快照
|
||||
if (fromTabId && config.snapshotOnSwitch) {
|
||||
// 注意:这里需要在组件中调用 createSnapshot
|
||||
// 因为需要访问 xterm 实例
|
||||
}
|
||||
|
||||
// 更新当前活跃标签
|
||||
set({ activeTabId: toTabId })
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ snapshots: new Map() })
|
||||
},
|
||||
|
||||
getStats: () => {
|
||||
const { snapshots } = get()
|
||||
let totalSize = 0
|
||||
let oldestSnapshot: number | null = null
|
||||
|
||||
snapshots.forEach((snapshot) => {
|
||||
// 估算大小:每个字符 2 bytes (UTF-16)
|
||||
const bufferSize = snapshot.buffer.reduce((sum, line) => sum + line.length * 2, 0)
|
||||
totalSize += bufferSize + 200 // 200 bytes 用于其他字段
|
||||
|
||||
if (!oldestSnapshot || snapshot.timestamp < oldestSnapshot) {
|
||||
oldestSnapshot = snapshot.timestamp
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
count: snapshots.size,
|
||||
totalSize,
|
||||
oldestSnapshot,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// 辅助 Hook:在组件中管理快照
|
||||
export function useTerminalSnapshot(
|
||||
tabId: string,
|
||||
terminal: {
|
||||
buffer: { active: { getLine: (y: number) => { translateToString: (trimRight?: boolean) => string } | undefined; length: number; cursorX: number; cursorY: number } }
|
||||
cols: number
|
||||
rows: number
|
||||
modes: {
|
||||
applicationCursorKeysMode: boolean
|
||||
applicationKeypadMode: boolean
|
||||
originMode: boolean
|
||||
insertMode: boolean
|
||||
wraparoundMode: boolean
|
||||
bracketedPasteMode: boolean
|
||||
}
|
||||
write: (data: string) => void
|
||||
resize: (cols: number, rows: number) => void
|
||||
clear: () => void
|
||||
} | null,
|
||||
connectionInfo: {
|
||||
connectionId: string
|
||||
shellId: string | null
|
||||
}
|
||||
) {
|
||||
const snapshotStore = useTerminalSnapshotStore()
|
||||
|
||||
return {
|
||||
// 创建快照
|
||||
snapshot: () => {
|
||||
if (terminal) {
|
||||
snapshotStore.createSnapshot(tabId, terminal, connectionInfo)
|
||||
}
|
||||
},
|
||||
|
||||
// 恢复快照
|
||||
restore: () => {
|
||||
if (terminal) {
|
||||
return snapshotStore.restoreSnapshot(tabId, terminal)
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
// 检查是否有快照
|
||||
hasSnapshot: () => snapshotStore.hasSnapshot(tabId),
|
||||
|
||||
// 删除快照
|
||||
delete: () => snapshotStore.deleteSnapshot(tabId),
|
||||
|
||||
// 获取快照
|
||||
get: () => snapshotStore.getSnapshot(tabId),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user