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

+22
View File
@@ -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'
+235
View File
@@ -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 })
},
}))
+402
View File
@@ -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() })
},
}))
+34
View File
@@ -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
}
}
+99
View File
@@ -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
+60
View File
@@ -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
}
+22
View File
@@ -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'
+63
View File
@@ -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
+99
View File
@@ -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)
},
}))
+226
View File
@@ -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)
},
}))
+348
View File
@@ -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),
}
}