Files
opsxshell/src/stores/tabStore.ts
T
OpsXShell Dev d344900118 feat(v1.1): Toast通知系统 + 自定义对话框 + 终端搜索 + SFTP真实实现 + 标签持久化
任务7: Toast通知系统 — ToastProvider+4类型(success/error/warning/info)+右上角堆叠动画
任务8: 替换alert/confirm/prompt — ConfirmDialog+PromptDialog+useToast替换7处原生对话框
任务9: 终端搜索 — xterm SearchAddon Ctrl+F搜索栏(上一个/下一个/关闭)
任务10: SFTP上传/删除 — useSFTP.ts upload/delete方法实现
任务11: 会话恢复 — tabStore持久化到localStorage,启动恢复tabs

14 files changed, 1218 insertions(+), 241 deletions(-)
2026-04-18 16:21:10 +08:00

342 lines
11 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'
const logger = createLogger('TabStore')
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
needsReconnect?: boolean // 从持久化恢复后需要重新连接
}
// 用于持久化的序列化 Tab 数据(排除敏感字段和运行时状态)
interface PersistedTab {
id: string
title: string
type: TabType
sessionId?: string
// 注意:不持久化 connectionConfig(可能包含密码/密钥)
// 注意:不持久化 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
updateTabTitle: (tabId: string, title: string) => 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()
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)
},
}),
{
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 === 'sftp' || tab.type === 'portforward'
})
.map(tab => ({
id: tab.id,
title: tab.title,
type: tab.type,
sessionId: tab.sessionId,
// 不保存 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: any) => ({
...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,
}
},
}
)
)