Files
opsxshell/src/stores/tabStore.ts
T
Logan.LiandCursor 188f3556ed fix: allow closing last session tab and restore DevTools shortcut
Closing the sole remaining non-welcome tab now resets to welcome; enable Tauri devtools, menu/shortcut toggle, and copy release artifacts from CARGO_TARGET_DIR.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 01:31:20 +08:00

449 lines
15 KiB
TypeScript
Raw Blame History

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