import { create } from 'zustand' import { persist } from 'zustand/middleware' import { createLogger } from '../utils/logger' import type { SSHConnectionConfig } from '../../shared/types/session.types' import type { SerialConfig } from '../../shared/types/serial.types' import type { RdpConfig, VncConfig } from '../../shared/types/desktop.types' const logger = createLogger('TabStore') export type TabType = | 'welcome' | 'terminal' | 'serial' | 'rdp' | 'vnc' | 'sftp' | 'portforward' | 'settings' | 'upgrade' | 'batch' export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' // 连接实例信息 export interface ConnectionInstance { connectionId: string // 实际的 SSH 连接 ID shellId?: string // Shell 会话 ID(仅 terminal 类型) sftpId?: string // SFTP 会话 ID(仅 sftp 类型) } /** Terminal split layout (persisted without shell ids) */ export interface SplitLayout { direction: 'horizontal' | 'vertical' /** Number of panes 1–4 */ paneCount: number /** Ratio used when paneCount === 2 (first pane share 0.2–0.8) */ ratio?: number } export interface Tab { id: string title: string type: TabType sessionId?: string // 持久化会话 ID(用于获取配置) connectionConfig?: SSHConnectionConfig // 快速连接配置(临时) connectionInstance?: ConnectionInstance // 实际的连接实例(每个标签独立) isActive: boolean connectionStatus?: ConnectionStatus needsReconnect?: boolean // 从持久化恢复后需要重新连接 /** Batch actions can establish inactive terminal tabs immediately. */ connectOnMount?: boolean /** Terminal multi-pane layout */ splitLayout?: SplitLayout /** Host local shell (node-pty), not SSH */ isLocal?: boolean /** Local shell kind for tab badge: zsh | bash | fish | ... */ localShellKind?: string /** Explicit shell binary path e.g. /bin/zsh */ localShellPath?: string /** Native serial terminal configuration. */ serialConfig?: SerialConfig /** RDP remote desktop configuration (password kept in-memory for the tab lifetime). */ rdpConfig?: RdpConfig /** VNC remote desktop configuration. */ vncConfig?: VncConfig /** Pro upgrade tab context */ upgradeFeature?: string upgradeComingSoon?: boolean /** Batch exec tab: preselected session ids */ batchSessionIds?: string[] } // 用于持久化的序列化 Tab 数据(排除敏感字段和运行时状态) interface PersistedTab { id: string title: string type: TabType sessionId?: string splitLayout?: SplitLayout isLocal?: boolean serialConfig?: SerialConfig rdpConfig?: RdpConfig vncConfig?: VncConfig // 注意:不持久化 connectionConfig(可能包含密码/密钥) // 注意:rdpConfig / vncConfig 含密码,持久化时剥离 // 注意:不持久化 connectionInstance(运行时状态) // 注意:不持久化 connectionStatus(运行时状态) // 注意:不持久化 isActive(由 activeTabId 决定) } interface TabState { tabs: Tab[] activeTabId: string | null // Actions addTab: (tab: Omit) => void updateTab: (tabId: string, updates: Partial) => 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 activateTabByIndex: (index: number) => void switchToNextTab: () => void switchToPrevTab: () => void updateTabTitle: (tabId: string, title: string) => void moveTab: (fromIndex: number, toIndex: number) => void getTabById: (tabId: string) => Tab | undefined } export const useTabStore = create()( persist( (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, needsReconnect: status === 'disconnected' ? false : t.needsReconnect } : t ) set({ tabs: updatedTabs }) }, closeTab: (tabId) => { const { tabs, activeTabId } = get() const closedTab = tabs.find(t => t.id === tabId) || null if (!closedTab) return null // Last remaining tab: welcome stays put; any other type resets to welcome // (e.g. after "close others", closing the sole terminal must work). if (tabs.length <= 1) { if (closedTab.type === 'welcome') return null const welcomeTab: Tab = { id: 'welcome', title: '欢迎', type: 'welcome', isActive: true, } set({ tabs: [welcomeTab], activeTabId: 'welcome' }) return closedTab } 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, }) }, activateTabByIndex: (index) => { const { tabs } = get() if (index < 0 || index >= tabs.length) return const targetTab = tabs[index] if (targetTab) { get().activateTab(targetTab.id) } }, switchToNextTab: () => { const { tabs, activeTabId } = get() if (tabs.length <= 1) return const currentIndex = tabs.findIndex(t => t.id === activeTabId) const nextIndex = (currentIndex + 1) % tabs.length get().activateTab(tabs[nextIndex].id) }, switchToPrevTab: () => { const { tabs, activeTabId } = get() if (tabs.length <= 1) return const currentIndex = tabs.findIndex(t => t.id === activeTabId) const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length get().activateTab(tabs[prevIndex].id) }, updateTabTitle: (tabId, title) => { const { tabs } = get() const updatedTabs = tabs.map(t => t.id === tabId ? { ...t, title } : t ) set({ tabs: updatedTabs }) }, moveTab: (fromIndex, toIndex) => { const { tabs, activeTabId } = get() if ( fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= tabs.length || toIndex >= tabs.length ) { return } // Immutable reorder const newTabs = [...tabs] const [moved] = newTabs.splice(fromIndex, 1) newTabs.splice(toIndex, 0, moved) // Re-apply isActive based on activeTabId const reordered = newTabs.map(t => ({ ...t, isActive: t.id === activeTabId, })) set({ tabs: reordered }) }, getTabById: (tabId) => { return get().tabs.find(t => t.id === tabId) }, }), { name: 'opsxshell-tabs', // 只持久化需要恢复的字段 partialize: (state) => { // 过滤掉 welcome 标签和 settings 标签(这些在启动时总会自动创建) // 只持久化有实际会话关联的标签(terminal, sftp, portforward) const tabsToPersist = state.tabs .filter(tab => { // 持久化有 sessionId 的标签或非 welcome/settings 类型 return tab.type === 'terminal' || tab.type === 'serial' || tab.type === 'sftp' || tab.type === 'portforward' }) .map(tab => ({ id: tab.id, title: tab.title, type: tab.type, sessionId: tab.sessionId, splitLayout: tab.splitLayout, isLocal: tab.isLocal, serialConfig: tab.serialConfig, // 不保存 connectionConfig(含密码等敏感信息) // 不保存 connectionInstance(运行时状态) // 不保存 connectionStatus(运行时状态) // 不保存 isActive(由 activeTabId 决定) })) as PersistedTab[] // 只持久化非 welcome 的 activeTabId const activeTab = state.tabs.find(t => t.id === state.activeTabId) let persistActiveTabId = state.activeTabId if (activeTab?.type === 'welcome') { persistActiveTabId = null } return { tabs: tabsToPersist, activeTabId: persistActiveTabId, } }, // 从持久化数据恢复时的合并策略 merge: (persistedState, currentState) => { const persisted = persistedState as Partial if (!persisted?.tabs || persisted.tabs.length === 0) { // 没有持久化数据,使用默认状态 return currentState } logger.info('Restoring tabs from persisted state:', persisted.tabs.length, 'tabs') // 将持久化的标签转换为运行时标签 const restoredTabs: Tab[] = persisted.tabs.map((tab: Tab) => ({ ...tab, isActive: false, connectionStatus: 'disconnected' as ConnectionStatus, needsReconnect: true, // 标记需要重新连接 })) // 确定活动标签 let activeTabId = persisted.activeTabId || null if (activeTabId) { const activeTab = restoredTabs.find(t => t.id === activeTabId) if (activeTab) { activeTab.isActive = true } else { // 活动标签不存在,选择第一个 if (restoredTabs.length > 0) { restoredTabs[0].isActive = true activeTabId = restoredTabs[0].id } } } else if (restoredTabs.length > 0) { restoredTabs[0].isActive = true activeTabId = restoredTabs[0].id } // 始终在开头添加 welcome 标签 const welcomeTab: Tab = { id: 'welcome', title: '欢迎', type: 'welcome', isActive: false, } const finalTabs = [welcomeTab, ...restoredTabs] // 如果活动标签不存在,默认激活 welcome if (!activeTabId) { welcomeTab.isActive = true activeTabId = 'welcome' } return { ...currentState, tabs: finalTabs, activeTabId, } }, } ) )