任务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(-)
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
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,
|
||
}
|
||
},
|
||
}
|
||
)
|
||
)
|