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>
449 lines
15 KiB
TypeScript
449 lines
15 KiB
TypeScript
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,
|
||
}
|
||
},
|
||
}
|
||
)
|
||
)
|