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(-)
This commit is contained in:
1 parent
53274e2291
commit
d344900118
14 files changed
+1027
-50
No files matched your search
Generated
+7
@@ -10,6 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -3167,6 +3168,12 @@
|
|||||||
"@xterm/xterm": "^5.0.0"
|
"@xterm/xterm": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-search": {
|
||||||
|
"version": "0.16.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||||
|
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@xterm/addon-webgl": {
|
"node_modules/@xterm/addon-webgl": {
|
||||||
"version": "0.19.0",
|
"version": "0.19.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
+12
-8
@@ -5,9 +5,11 @@ import TabContent from './components/TabContent'
|
|||||||
import StatusBar from './components/StatusBar'
|
import StatusBar from './components/StatusBar'
|
||||||
import SessionDialog from './components/SessionDialog'
|
import SessionDialog from './components/SessionDialog'
|
||||||
import QuickConnectDialog from './components/QuickConnectDialog'
|
import QuickConnectDialog from './components/QuickConnectDialog'
|
||||||
|
import { ToastProvider } from './components/common/Toast'
|
||||||
import { useTabStore } from './stores/tabStore'
|
import { useTabStore } from './stores/tabStore'
|
||||||
import { useSessionStore, type Session } from './stores'
|
import { useSessionStore, type Session } from './stores'
|
||||||
import { createLogger } from './utils/logger'
|
import { createLogger } from './utils/logger'
|
||||||
|
import { useToast } from './hooks/useToast'
|
||||||
|
|
||||||
const logger = createLogger('App')
|
const logger = createLogger('App')
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ function App() {
|
|||||||
const [editSession, setEditSession] = useState<Session | null>(null)
|
const [editSession, setEditSession] = useState<Session | null>(null)
|
||||||
const { activeTabId } = useTabStore()
|
const { activeTabId } = useTabStore()
|
||||||
const { sessions } = useSessionStore()
|
const { sessions } = useSessionStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
// 导出会话
|
// 导出会话
|
||||||
const handleExportSessions = async () => {
|
const handleExportSessions = async () => {
|
||||||
@@ -42,11 +45,10 @@ function App() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// TODO: 使用 Node.js fs 写入文件(通过 preload 暴露的方法)
|
// TODO: 使用 Node.js fs 写入文件(通过 preload 暴露的方法)
|
||||||
// 简化处理:直接 alert 成功信息
|
toast.success(`已导出 ${exportData.length} 个会话到:\n${result.filePath}`)
|
||||||
alert(`已导出 ${exportData.length} 个会话到:\n${result.filePath}`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Export failed:', error)
|
logger.error('Export failed:', error)
|
||||||
alert('导出会话失败')
|
toast.error('导出会话失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ function App() {
|
|||||||
const fileResult = await window.electronAPI?.readFile(filePath)
|
const fileResult = await window.electronAPI?.readFile(filePath)
|
||||||
|
|
||||||
if (!fileResult?.success || !fileResult.data) {
|
if (!fileResult?.success || !fileResult.data) {
|
||||||
alert('读取文件失败')
|
toast.error('读取文件失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,14 +127,14 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (importSessions.length === 0) {
|
if (importSessions.length === 0) {
|
||||||
alert('未找到可导入的会话')
|
toast.warning('未找到可导入的会话')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤必填字段
|
// 过滤必填字段
|
||||||
const validSessions = importSessions.filter(s => s.host && s.username)
|
const validSessions = importSessions.filter(s => s.host && s.username)
|
||||||
if (validSessions.length === 0) {
|
if (validSessions.length === 0) {
|
||||||
alert('文件中没有有效的会话配置')
|
toast.warning('文件中没有有效的会话配置')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +158,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
alert(`成功导入 ${created}/${validSessions.length} 个会话`)
|
toast.success(`成功导入 ${created}/${validSessions.length} 个会话`)
|
||||||
|
|
||||||
// 刷新会话列表
|
// 刷新会话列表
|
||||||
const refreshResult = await window.electronAPI?.sessionGetAll()
|
const refreshResult = await window.electronAPI?.sessionGetAll()
|
||||||
@@ -165,7 +167,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Import failed:', error)
|
logger.error('Import failed:', error)
|
||||||
alert('导入会话失败')
|
toast.error('导入会话失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +198,7 @@ function App() {
|
|||||||
}, [sessions])
|
}, [sessions])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ToastProvider>
|
||||||
<div className="flex flex-col h-screen bg-dark-900 text-gray-200">
|
<div className="flex flex-col h-screen bg-dark-900 text-gray-200">
|
||||||
{/* Title bar spacer for macOS */}
|
{/* Title bar spacer for macOS */}
|
||||||
<div className="h-8 bg-dark-900 drag"></div>
|
<div className="h-8 bg-dark-900 drag"></div>
|
||||||
@@ -252,6 +255,7 @@ function App() {
|
|||||||
onClose={() => setIsQuickConnectOpen(false)}
|
onClose={() => setIsQuickConnectOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
import { useConnectionPool } from '../stores/connection'
|
import { useConnectionPool } from '../stores/connection'
|
||||||
import { ArrowRightLeft, Globe, Server, Plus, Trash2, Play, Square } from 'lucide-react'
|
import { ArrowRightLeft, Globe, Server, Plus, Trash2, Play, Square } from 'lucide-react'
|
||||||
|
import { useConfirmDialog } from './common/ConfirmDialog'
|
||||||
|
|
||||||
interface PortForwardViewProps {
|
interface PortForwardViewProps {
|
||||||
tabId: string
|
tabId: string
|
||||||
@@ -25,10 +26,12 @@ interface ForwardRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PortForwardView({ tabId, isActive: _isActive = true }: PortForwardViewProps) {
|
export default function PortForwardView({ tabId, isActive: _isActive = true }: PortForwardViewProps) {
|
||||||
const { tabs } = useTabStore()
|
const { tabs, updateTab } = useTabStore()
|
||||||
const pool = useConnectionPool()
|
const pool = useConnectionPool()
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||||
|
|
||||||
const currentTab = tabs.find(t => t.id === tabId)
|
const currentTab = tabs.find(t => t.id === tabId)
|
||||||
|
const needsReconnect = currentTab?.needsReconnect
|
||||||
const connectionId = currentTab?.connectionInstance?.connectionId
|
const connectionId = currentTab?.connectionInstance?.connectionId
|
||||||
const connection = connectionId ? pool.getConnection(connectionId) : null
|
const connection = connectionId ? pool.getConnection(connectionId) : null
|
||||||
|
|
||||||
@@ -146,14 +149,46 @@ export default function PortForwardView({ tabId, isActive: _isActive = true }: P
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete rule
|
// Delete rule
|
||||||
const deleteRule = (ruleId: string) => {
|
const deleteRule = async (ruleId: string) => {
|
||||||
if (confirm('确定删除此转发规则?')) {
|
const confirmed = await confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
message: '确定删除此转发规则?',
|
||||||
|
danger: true,
|
||||||
|
confirmText: '删除',
|
||||||
|
})
|
||||||
|
if (confirmed) {
|
||||||
saveRules(rules.filter(r => r.id !== ruleId))
|
saveRules(rules.filter(r => r.id !== ruleId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="h-full flex flex-col bg-dark-900">
|
<div className="h-full flex flex-col bg-dark-900">
|
||||||
|
{/* 会话恢复 - 需要重新连接覆盖层 */}
|
||||||
|
{needsReconnect && currentTab?.sessionId && (
|
||||||
|
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-accent-blue/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<ArrowRightLeft className="w-8 h-8 text-accent-blue" />
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-200 mb-2 text-lg">会话已恢复</div>
|
||||||
|
<div className="text-gray-400 text-sm mb-6">
|
||||||
|
上次关闭时的端口转发标签已恢复<br />
|
||||||
|
需要重新建立 SSH 连接才能使用
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
updateTab(tabId, { needsReconnect: false })
|
||||||
|
// 刷新页面或触发连接逻辑
|
||||||
|
}}
|
||||||
|
className="px-6 py-2.5 bg-accent-blue hover:bg-blue-500 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
重新连接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -383,5 +418,7 @@ export default function PortForwardView({ tabId, isActive: _isActive = true }: P
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ import { Toolbar } from './sftp/Toolbar'
|
|||||||
import { ConnectionPanel } from './sftp/ConnectionPanel'
|
import { ConnectionPanel } from './sftp/ConnectionPanel'
|
||||||
import { ContextMenu, type MenuItem } from './sftp/ContextMenu'
|
import { ContextMenu, type MenuItem } from './sftp/ContextMenu'
|
||||||
import { sortFiles } from './sftp/utils'
|
import { sortFiles } from './sftp/utils'
|
||||||
|
import { usePromptDialog } from './common/PromptDialog'
|
||||||
import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.types'
|
import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.types'
|
||||||
|
|
||||||
interface SFTPViewProps {
|
interface SFTPViewProps {
|
||||||
@@ -49,9 +50,12 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
transferQueue,
|
transferQueue,
|
||||||
uploadFiles,
|
uploadFiles,
|
||||||
downloadFiles,
|
downloadFiles,
|
||||||
|
deleteRemote,
|
||||||
error,
|
error,
|
||||||
} = useSFTP({ tabId })
|
} = useSFTP({ tabId })
|
||||||
|
|
||||||
|
const { prompt, PromptDialogComponent } = usePromptDialog()
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
const [sortField, setSortField] = useState<SortField>('name')
|
const [sortField, setSortField] = useState<SortField>('name')
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||||
@@ -128,11 +132,13 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
icon: <Trash2 className="w-4 h-4" />,
|
icon: <Trash2 className="w-4 h-4" />,
|
||||||
label: '删除',
|
label: '删除',
|
||||||
danger: true,
|
danger: true,
|
||||||
action: () => {},
|
action: () => {
|
||||||
|
deleteRemote([file])
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||||
}, [navigateRemote, downloadFiles, setRemoteSelected])
|
}, [navigateRemote, downloadFiles, deleteRemote, setRemoteSelected])
|
||||||
|
|
||||||
// Navigate up
|
// Navigate up
|
||||||
const navigateLocalUp = useCallback(() => {
|
const navigateLocalUp = useCallback(() => {
|
||||||
@@ -170,14 +176,18 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
}, [uploadFiles])
|
}, [uploadFiles])
|
||||||
|
|
||||||
// Handle new folder
|
// Handle new folder
|
||||||
const handleNewFolder = useCallback(() => {
|
const handleNewFolder = useCallback(async () => {
|
||||||
const name = prompt('输入文件夹名称:')
|
const name = await prompt({
|
||||||
|
title: '新建文件夹',
|
||||||
|
message: '输入文件夹名称:',
|
||||||
|
placeholder: '文件夹名称',
|
||||||
|
})
|
||||||
if (name && window.electronAPI?.localMkdir) {
|
if (name && window.electronAPI?.localMkdir) {
|
||||||
window.electronAPI.localMkdir(`${localPath}/${name}`).then(() => {
|
window.electronAPI.localMkdir(`${localPath}/${name}`).then(() => {
|
||||||
loadLocalDirectory(localPath)
|
loadLocalDirectory(localPath)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [localPath, loadLocalDirectory])
|
}, [localPath, loadLocalDirectory, prompt])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-dark-900">
|
<div className="h-full flex flex-col bg-dark-900">
|
||||||
@@ -272,6 +282,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
onClose={() => setContextMenu(null)}
|
onClose={() => setContextMenu(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{PromptDialogComponent}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import { useSessionStore, Session } from '../stores'
|
|||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
import { SessionTree } from './sidebar/SessionTree'
|
import { SessionTree } from './sidebar/SessionTree'
|
||||||
import { SearchBox } from './sidebar/SearchBox'
|
import { SearchBox } from './sidebar/SearchBox'
|
||||||
|
import { useConfirmDialog } from './common/ConfirmDialog'
|
||||||
// Sidebar Component
|
// Sidebar Component
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -19,6 +20,7 @@ interface SidebarProps {
|
|||||||
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) {
|
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) {
|
||||||
const { sessions, setSessions, removeSession } = useSessionStore()
|
const { sessions, setSessions, removeSession } = useSessionStore()
|
||||||
const { addTab } = useTabStore()
|
const { addTab } = useTabStore()
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['default']))
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['default']))
|
||||||
@@ -68,7 +70,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
|||||||
setContextMenu({ x: e.clientX, y: e.clientY, session })
|
setContextMenu({ x: e.clientX, y: e.clientY, session })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleContextMenuAction = (action: string) => {
|
const handleContextMenuAction = async (action: string) => {
|
||||||
if (!contextMenu) return
|
if (!contextMenu) return
|
||||||
const session = contextMenu.session
|
const session = contextMenu.session
|
||||||
|
|
||||||
@@ -99,17 +101,25 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case 'delete':
|
case 'delete': {
|
||||||
if (confirm(`确定删除会话 "${session.name}"?`)) {
|
const confirmed = await confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
message: `确定删除会话 "${session.name}"?`,
|
||||||
|
danger: true,
|
||||||
|
confirmText: '删除',
|
||||||
|
})
|
||||||
|
if (confirmed) {
|
||||||
removeSession(session.id)
|
removeSession(session.id)
|
||||||
window.electronAPI?.sessionDelete(session.id)
|
window.electronAPI?.sessionDelete(session.id)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setContextMenu(null)
|
setContextMenu(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="w-64 bg-dark-800 border-r border-dark-700 flex flex-col">
|
<div className="w-64 bg-dark-800 border-r border-dark-700 flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-dark-700">
|
<div className="p-4 border-b border-dark-700">
|
||||||
@@ -200,5 +210,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
|
import { SearchAddon } from '@xterm/addon-search'
|
||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
|
import { Search, ChevronUp, ChevronDown, X, Terminal as TerminalIcon } from 'lucide-react'
|
||||||
import { themes } from '../themes/terminal'
|
import { themes } from '../themes/terminal'
|
||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
|
import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
|
||||||
@@ -16,6 +18,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
|||||||
const terminalRef = useRef<HTMLDivElement>(null)
|
const terminalRef = useRef<HTMLDivElement>(null)
|
||||||
const xtermRef = useRef<Terminal | null>(null)
|
const xtermRef = useRef<Terminal | null>(null)
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||||
|
const searchAddonRef = useRef<SearchAddon | null>(null)
|
||||||
const shellIdRef = useRef<string | null>(null)
|
const shellIdRef = useRef<string | null>(null)
|
||||||
const connectionIdRef = useRef<string | null>(null)
|
const connectionIdRef = useRef<string | null>(null)
|
||||||
const hasConnectedRef = useRef(false)
|
const hasConnectedRef = useRef(false)
|
||||||
@@ -28,6 +31,13 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
|||||||
const [statusMessage, setStatusMessage] = useState<string>('')
|
const [statusMessage, setStatusMessage] = useState<string>('')
|
||||||
const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false)
|
const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false)
|
||||||
|
|
||||||
|
// 搜索状态
|
||||||
|
const [showSearch, setShowSearch] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchResultCount, setSearchResultCount] = useState<number | undefined>(undefined)
|
||||||
|
const [searchResultIndex, setSearchResultIndex] = useState<number | undefined>(undefined)
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// 全局状态
|
// 全局状态
|
||||||
const { settings, loadSettings } = useSettingsStore()
|
const { settings, loadSettings } = useSettingsStore()
|
||||||
const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore()
|
const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore()
|
||||||
@@ -40,6 +50,85 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
|||||||
loadSettings()
|
loadSettings()
|
||||||
}, [loadSettings])
|
}, [loadSettings])
|
||||||
|
|
||||||
|
// 搜索相关回调
|
||||||
|
const searchDecorations = {
|
||||||
|
matchBackground: '#3b82f633',
|
||||||
|
matchBorder: '#3b82f666',
|
||||||
|
matchOverviewRuler: '#3b82f6',
|
||||||
|
activeMatchBackground: '#3b82f6',
|
||||||
|
activeMatchBorder: '#60a5fa',
|
||||||
|
activeMatchColorOverviewRuler: '#60a5fa',
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const query = e.target.value
|
||||||
|
setSearchQuery(query)
|
||||||
|
if (query) {
|
||||||
|
searchAddonRef.current?.findNext(query, { decorations: searchDecorations, incremental: true })
|
||||||
|
} else {
|
||||||
|
searchAddonRef.current?.clearDecorations()
|
||||||
|
setSearchResultCount(undefined)
|
||||||
|
setSearchResultIndex(undefined)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSearchNext = useCallback(() => {
|
||||||
|
if (searchAddonRef.current && searchQuery) {
|
||||||
|
searchAddonRef.current.findNext(searchQuery, { decorations: searchDecorations })
|
||||||
|
}
|
||||||
|
}, [searchQuery])
|
||||||
|
|
||||||
|
const handleSearchPrevious = useCallback(() => {
|
||||||
|
if (searchAddonRef.current && searchQuery) {
|
||||||
|
searchAddonRef.current.findPrevious(searchQuery, { decorations: searchDecorations })
|
||||||
|
}
|
||||||
|
}, [searchQuery])
|
||||||
|
|
||||||
|
const handleCloseSearch = useCallback(() => {
|
||||||
|
setShowSearch(false)
|
||||||
|
searchAddonRef.current?.clearDecorations()
|
||||||
|
setSearchQuery('')
|
||||||
|
setSearchResultCount(undefined)
|
||||||
|
setSearchResultIndex(undefined)
|
||||||
|
xtermRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Ctrl+F / Cmd+F 打开搜索栏,Escape 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||||
|
// 只在当前标签激活时拦截
|
||||||
|
if (!isActive) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!showSearch) {
|
||||||
|
setShowSearch(true)
|
||||||
|
// 延迟聚焦输入框,等 DOM 更新
|
||||||
|
setTimeout(() => searchInputRef.current?.focus(), 50)
|
||||||
|
} else {
|
||||||
|
searchInputRef.current?.focus()
|
||||||
|
searchInputRef.current?.select()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape' && showSearch) {
|
||||||
|
handleCloseSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [isActive, showSearch, handleCloseSearch])
|
||||||
|
|
||||||
|
// 监听 SearchAddon 的 onDidChangeResults 事件
|
||||||
|
useEffect(() => {
|
||||||
|
const addon = searchAddonRef.current
|
||||||
|
if (!addon) return
|
||||||
|
const disposable = addon.onDidChangeResults((e) => {
|
||||||
|
setSearchResultCount(e.resultCount)
|
||||||
|
setSearchResultIndex(e.resultIndex !== undefined ? e.resultIndex + 1 : undefined)
|
||||||
|
})
|
||||||
|
return () => disposable.dispose()
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 初始化 xterm
|
// 初始化 xterm
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 修复:只检查 terminalRef,不检查 xtermRef
|
// 修复:只检查 terminalRef,不检查 xtermRef
|
||||||
@@ -91,13 +180,16 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
|||||||
})
|
})
|
||||||
|
|
||||||
const fitAddon = new FitAddon()
|
const fitAddon = new FitAddon()
|
||||||
|
const searchAddon = new SearchAddon()
|
||||||
term.loadAddon(fitAddon)
|
term.loadAddon(fitAddon)
|
||||||
|
term.loadAddon(searchAddon)
|
||||||
term.open(terminalRef.current)
|
term.open(terminalRef.current)
|
||||||
|
|
||||||
fitAddon.fit()
|
fitAddon.fit()
|
||||||
|
|
||||||
xtermRef.current = term
|
xtermRef.current = term
|
||||||
fitAddonRef.current = fitAddon
|
fitAddonRef.current = fitAddon
|
||||||
|
searchAddonRef.current = searchAddon
|
||||||
|
|
||||||
// 处理终端输入
|
// 处理终端输入
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
@@ -400,6 +492,16 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
|||||||
// 初始连接
|
// 初始连接
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasConnectedRef.current && isActive) {
|
if (!hasConnectedRef.current && isActive) {
|
||||||
|
// 检查标签是否从持久化恢复(需要手动重新连接)
|
||||||
|
const currentTab = getTabById(tabId)
|
||||||
|
if (currentTab?.needsReconnect) {
|
||||||
|
// 从持久化恢复的标签,不自动连接,显示「重新连接」界面
|
||||||
|
logger.info('[Terminal] Tab restored from session, waiting for manual reconnect')
|
||||||
|
setConnectionState('disconnected')
|
||||||
|
setStatusMessage('上次会话已恢复,点击重新连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否有快照
|
// 检查是否有快照
|
||||||
const hasSnapshot = snapshotStore.hasSnapshot(tabId)
|
const hasSnapshot = snapshotStore.hasSnapshot(tabId)
|
||||||
if (hasSnapshot && isActive) {
|
if (hasSnapshot && isActive) {
|
||||||
@@ -452,10 +554,12 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
|||||||
hasConnectedRef.current = false
|
hasConnectedRef.current = false
|
||||||
setConnectionState('disconnected')
|
setConnectionState('disconnected')
|
||||||
setShowSuspendedOverlay(false)
|
setShowSuspendedOverlay(false)
|
||||||
|
// 清除 needsReconnect 标志
|
||||||
|
updateTab(tabId, { needsReconnect: false })
|
||||||
xtermRef.current?.clear()
|
xtermRef.current?.clear()
|
||||||
connect()
|
connect()
|
||||||
}
|
}
|
||||||
}, [connectionState, connect])
|
}, [connectionState, connect, updateTab, tabId])
|
||||||
|
|
||||||
// 组件卸载时清理监听器
|
// 组件卸载时清理监听器
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -477,6 +581,79 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
|||||||
className="w-full h-full bg-dark-900 rounded"
|
className="w-full h-full bg-dark-900 rounded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 搜索栏 - 终端顶部覆盖 */}
|
||||||
|
{showSearch && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-30 flex items-center gap-2 px-3 py-2 bg-dark-800/95 border-b border-dark-600 backdrop-blur-sm">
|
||||||
|
<Search size={14} className="text-gray-400 shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
handleSearchPrevious()
|
||||||
|
} else {
|
||||||
|
handleSearchNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
placeholder="搜索..."
|
||||||
|
className="flex-1 bg-dark-900 text-gray-200 text-sm px-2 py-1 rounded border border-dark-600 focus:border-accent-blue focus:outline-none placeholder-gray-500"
|
||||||
|
/>
|
||||||
|
{searchResultCount !== undefined && (
|
||||||
|
<span className="text-xs text-gray-400 whitespace-nowrap min-w-[60px] text-center">
|
||||||
|
{searchResultIndex ?? 0}/{searchResultCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSearchPrevious}
|
||||||
|
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
title="上一个 (Shift+Enter)"
|
||||||
|
>
|
||||||
|
<ChevronUp size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSearchNext}
|
||||||
|
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
title="下一个 (Enter)"
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseSearch}
|
||||||
|
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
title="关闭 (Escape)"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 会话恢复 - 需要重新连接覆盖层 */}
|
||||||
|
{connectionState === 'disconnected' && getTabById(tabId)?.needsReconnect && (
|
||||||
|
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-accent-blue/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<TerminalIcon className="w-8 h-8 text-accent-blue" />
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-200 mb-2 text-lg">会话已恢复</div>
|
||||||
|
<div className="text-gray-400 text-sm mb-6">
|
||||||
|
上次关闭时的终端标签已恢复<br />
|
||||||
|
点击下方按钮重新建立 SSH 连接
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleReconnect}
|
||||||
|
className="px-6 py-2.5 bg-accent-blue hover:bg-blue-500 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
重新连接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 挂起状态覆盖层 */}
|
{/* 挂起状态覆盖层 */}
|
||||||
{showSuspendedOverlay && (
|
{showSuspendedOverlay && (
|
||||||
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
|
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* ConfirmDialog — 基于 Promise 的确认对话框
|
||||||
|
*
|
||||||
|
* 用于替代 window.confirm(),支持异步等待结果
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||||
|
* const confirmed = await confirm({ title: '确认删除', message: '确定删除?', danger: true })
|
||||||
|
*
|
||||||
|
* // 在 JSX 中渲染:
|
||||||
|
* <ConfirmDialogComponent />
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from 'react'
|
||||||
|
import Modal from './Modal'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface ConfirmOptions {
|
||||||
|
/** 对话框标题 */
|
||||||
|
title: string
|
||||||
|
/** 对话框内容 */
|
||||||
|
message: string
|
||||||
|
/** 是否为危险操作(红色确认按钮) */
|
||||||
|
danger?: boolean
|
||||||
|
/** 确认按钮文本 */
|
||||||
|
confirmText?: string
|
||||||
|
/** 取消按钮文本 */
|
||||||
|
cancelText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmState extends ConfirmOptions {
|
||||||
|
/** 是否显示 */
|
||||||
|
isOpen: boolean
|
||||||
|
/** resolve 回调 */
|
||||||
|
resolver: ((value: boolean) => void) | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ConfirmState = {
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
danger: false,
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
resolver: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConfirmDialog 组件 + Hook
|
||||||
|
*
|
||||||
|
* 返回 confirm 函数和需要渲染的组件
|
||||||
|
*/
|
||||||
|
export function useConfirmDialog() {
|
||||||
|
const [state, setState] = useState<ConfirmState>(initialState)
|
||||||
|
const resolverRef = useRef<((value: boolean) => void) | null>(null)
|
||||||
|
|
||||||
|
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
resolverRef.current = resolve
|
||||||
|
setState({
|
||||||
|
...options,
|
||||||
|
isOpen: true,
|
||||||
|
danger: options.danger ?? false,
|
||||||
|
confirmText: options.confirmText ?? '确认',
|
||||||
|
cancelText: options.cancelText ?? '取消',
|
||||||
|
resolver: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, isOpen: false }))
|
||||||
|
resolverRef.current?.(false)
|
||||||
|
resolverRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, isOpen: false }))
|
||||||
|
resolverRef.current?.(true)
|
||||||
|
resolverRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const ConfirmDialogComponent = (
|
||||||
|
<Modal
|
||||||
|
isOpen={state.isOpen}
|
||||||
|
title={state.title}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onClose={handleClose}
|
||||||
|
confirmText={state.confirmText}
|
||||||
|
cancelText={state.cancelText}
|
||||||
|
isDanger={state.danger}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{state.danger && (
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<p className="text-gray-300">{state.message}</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
|
||||||
|
return { confirm, ConfirmDialogComponent }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useConfirmDialog
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* PromptDialog — 基于 Promise 的输入对话框
|
||||||
|
*
|
||||||
|
* 用于替代 window.prompt(),支持异步等待用户输入
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { prompt, PromptDialogComponent } = usePromptDialog()
|
||||||
|
* const name = await prompt({ title: '新建文件夹', message: '输入文件夹名称:', placeholder: '文件夹名称' })
|
||||||
|
* if (name !== null) { ... 用户输入了内容 }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
|
import Modal from './Modal'
|
||||||
|
|
||||||
|
export interface PromptOptions {
|
||||||
|
/** 对话框标题 */
|
||||||
|
title: string
|
||||||
|
/** 提示信息 */
|
||||||
|
message: string
|
||||||
|
/** 输入框占位符 */
|
||||||
|
placeholder?: string
|
||||||
|
/** 默认值 */
|
||||||
|
defaultValue?: string
|
||||||
|
/** 确认按钮文本 */
|
||||||
|
confirmText?: string
|
||||||
|
/** 取消按钮文本 */
|
||||||
|
cancelText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PromptState extends PromptOptions {
|
||||||
|
/** 是否显示 */
|
||||||
|
isOpen: boolean
|
||||||
|
/** 当前输入值 */
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: PromptState = {
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
placeholder: '',
|
||||||
|
defaultValue: '',
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
value: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PromptDialog 组件 + Hook
|
||||||
|
*/
|
||||||
|
export function usePromptDialog() {
|
||||||
|
const [state, setState] = useState<PromptState>(initialState)
|
||||||
|
const resolverRef = useRef<((value: string | null) => void) | null>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const prompt = useCallback((options: PromptOptions): Promise<string | null> => {
|
||||||
|
return new Promise<string | null>((resolve) => {
|
||||||
|
resolverRef.current = resolve
|
||||||
|
setState({
|
||||||
|
...options,
|
||||||
|
isOpen: true,
|
||||||
|
placeholder: options.placeholder ?? '',
|
||||||
|
defaultValue: options.defaultValue ?? '',
|
||||||
|
confirmText: options.confirmText ?? '确认',
|
||||||
|
cancelText: options.cancelText ?? '取消',
|
||||||
|
value: options.defaultValue ?? '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 自动聚焦输入框
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.isOpen) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50)
|
||||||
|
}
|
||||||
|
}, [state.isOpen])
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, isOpen: false }))
|
||||||
|
resolverRef.current?.(null)
|
||||||
|
resolverRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
const value = state.value.trim()
|
||||||
|
setState((prev) => ({ ...prev, isOpen: false }))
|
||||||
|
resolverRef.current?.(value || null)
|
||||||
|
resolverRef.current = null
|
||||||
|
}, [state.value])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleConfirm()
|
||||||
|
}
|
||||||
|
}, [handleConfirm])
|
||||||
|
|
||||||
|
const PromptDialogComponent = (
|
||||||
|
<Modal
|
||||||
|
isOpen={state.isOpen}
|
||||||
|
title={state.title}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onClose={handleClose}
|
||||||
|
confirmText={state.confirmText}
|
||||||
|
cancelText={state.cancelText}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-300 mb-3">{state.message}</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={state.value}
|
||||||
|
onChange={(e) => setState((prev) => ({ ...prev, value: e.target.value }))}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={state.placeholder}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
|
||||||
|
return { prompt, PromptDialogComponent }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePromptDialog
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Toast 通知组件
|
||||||
|
*
|
||||||
|
* 包含单个 Toast 条目渲染 + ToastProvider 全局容器
|
||||||
|
* 右上角堆叠显示,支持入场/退场动画
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react'
|
||||||
|
import { useToastStore, type ToastItem, type ToastType } from '../../stores/toastStore'
|
||||||
|
|
||||||
|
/** 类型 → 图标映射 */
|
||||||
|
const iconMap: Record<ToastType, typeof CheckCircle> = {
|
||||||
|
success: CheckCircle,
|
||||||
|
error: XCircle,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
info: Info,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 类型 → 颜色样式映射 */
|
||||||
|
const colorMap: Record<ToastType, { bg: string; border: string; icon: string; text: string }> = {
|
||||||
|
success: {
|
||||||
|
bg: 'bg-green-900/40',
|
||||||
|
border: 'border-green-700/60',
|
||||||
|
icon: 'text-green-400',
|
||||||
|
text: 'text-green-200',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bg: 'bg-red-900/40',
|
||||||
|
border: 'border-red-700/60',
|
||||||
|
icon: 'text-red-400',
|
||||||
|
text: 'text-red-200',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: 'bg-amber-900/40',
|
||||||
|
border: 'border-amber-700/60',
|
||||||
|
icon: 'text-amber-400',
|
||||||
|
text: 'text-amber-200',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
bg: 'bg-blue-900/40',
|
||||||
|
border: 'border-blue-700/60',
|
||||||
|
icon: 'text-blue-400',
|
||||||
|
text: 'text-blue-200',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastItemProps {
|
||||||
|
toast: ToastItem
|
||||||
|
onClose: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个 Toast 条目
|
||||||
|
*
|
||||||
|
* 支持自动消失和手动关闭,带入场/退场动画
|
||||||
|
*/
|
||||||
|
function ToastItemView({ toast, onClose }: ToastItemProps) {
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const Icon = iconMap[toast.type]
|
||||||
|
const colors = colorMap[toast.type]
|
||||||
|
|
||||||
|
// 入场动画:挂载后下一帧设为可见
|
||||||
|
useEffect(() => {
|
||||||
|
const raf = requestAnimationFrame(() => setVisible(true))
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 自动关闭
|
||||||
|
useEffect(() => {
|
||||||
|
if (toast.duration <= 0) return
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
handleClose()
|
||||||
|
}, toast.duration)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [toast.duration])
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setVisible(false)
|
||||||
|
// 等待退场动画结束后再移除
|
||||||
|
setTimeout(() => onClose(toast.id), 200)
|
||||||
|
}, [onClose, toast.id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex items-start gap-3 w-80 px-4 py-3 rounded-lg border
|
||||||
|
shadow-lg backdrop-blur-sm
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
${colors.bg} ${colors.border}
|
||||||
|
${visible
|
||||||
|
? 'translate-x-0 opacity-100'
|
||||||
|
: 'translate-x-4 opacity-0'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{/* 图标 */}
|
||||||
|
<Icon className={`w-5 h-5 mt-0.5 shrink-0 ${colors.icon}`} />
|
||||||
|
|
||||||
|
{/* 消息 */}
|
||||||
|
<p className={`flex-1 text-sm font-medium ${colors.text}`}>
|
||||||
|
{toast.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 关闭按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-0.5 rounded hover:bg-white/10 transition-colors shrink-0"
|
||||||
|
aria-label="关闭通知"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-gray-400 hover:text-gray-200" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast 通知容器(Provider)
|
||||||
|
*
|
||||||
|
* 在应用根节点中使用,渲染右上角通知堆叠
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 在 App.tsx 中
|
||||||
|
* <ToastProvider>
|
||||||
|
* <Router />
|
||||||
|
* </ToastProvider>
|
||||||
|
*/
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const toasts = useToastStore((s) => s.toasts)
|
||||||
|
const removeToast = useToastStore((s) => s.removeToast)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
className="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div key={toast.id} className="pointer-events-auto">
|
||||||
|
<ToastItemView toast={toast} onClose={removeToast} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ToastProvider
|
||||||
+135
-28
@@ -75,6 +75,9 @@ export interface UseSFTPReturn {
|
|||||||
uploadFiles: (files: FileList | null) => Promise<void>
|
uploadFiles: (files: FileList | null) => Promise<void>
|
||||||
downloadFiles: () => Promise<void>
|
downloadFiles: () => Promise<void>
|
||||||
|
|
||||||
|
// 远程文件操作
|
||||||
|
deleteRemote: (files: FileItem[]) => Promise<boolean>
|
||||||
|
|
||||||
// 排序
|
// 排序
|
||||||
sortField: SortField
|
sortField: SortField
|
||||||
sortOrder: SortOrder
|
sortOrder: SortOrder
|
||||||
@@ -372,16 +375,19 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
setRemoteFiles([])
|
setRemoteFiles([])
|
||||||
}, [connectionId, sftpId, getConnection])
|
}, [connectionId, sftpId, getConnection])
|
||||||
|
|
||||||
// Upload files
|
// Upload files (real implementation via IPC)
|
||||||
const uploadFiles = useCallback(async (files: FileList | null) => {
|
const uploadFiles = useCallback(async (files: FileList | null) => {
|
||||||
if (!files || !sftpId) return
|
if (!files || files.length === 0 || !sftpId || !window.electronAPI) {
|
||||||
|
logWarning('uploadFiles: missing files, sftpId, or electronAPI')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const newTasks: TransferTask[] = Array.from(files).map(file => ({
|
const newTasks: TransferTask[] = Array.from(files).map(file => ({
|
||||||
id: `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
id: `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
type: 'upload',
|
type: 'upload' as const,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
filePath: `${localPath}/${file.name}`,
|
filePath: `${localPath}/${file.name}`,
|
||||||
status: 'pending',
|
status: 'pending' as const,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
total: file.size,
|
total: file.size,
|
||||||
transferred: 0,
|
transferred: 0,
|
||||||
@@ -389,40 +395,74 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
|
|
||||||
setTransferQueue(prev => [...prev, ...newTasks])
|
setTransferQueue(prev => [...prev, ...newTasks])
|
||||||
|
|
||||||
// Process uploads
|
// Set up progress listener scoped to this batch
|
||||||
for (const task of newTasks) {
|
const progressHandler = (_event: any, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => {
|
||||||
|
// Update the first task that is currently transferring
|
||||||
|
setTransferQueue(prev => {
|
||||||
|
const idx = prev.findIndex(t => t.type === 'upload' && t.status === 'transferring')
|
||||||
|
if (idx === -1) return prev
|
||||||
|
const t = prev[idx]
|
||||||
|
return [
|
||||||
|
...prev.slice(0, idx),
|
||||||
|
{ ...t, transferred: progress.transferred, total: progress.total, progress: progress.percentage },
|
||||||
|
...prev.slice(idx + 1),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
window.electronAPI.onSFTPProgress(progressHandler)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
for (const task of newTasks) {
|
||||||
|
const remoteFilePath = `${remotePath}/${task.fileName}`.replace(/\/+/g, '/')
|
||||||
|
logInfo('Uploading', `${task.filePath} -> ${remoteFilePath}`)
|
||||||
|
|
||||||
|
// Mark as transferring
|
||||||
setTransferQueue(prev => prev.map(t =>
|
setTransferQueue(prev => prev.map(t =>
|
||||||
t.id === task.id ? { ...t, status: 'transferring' } : t
|
t.id === task.id ? { ...t, status: 'transferring', startTime: Date.now() } : t
|
||||||
))
|
))
|
||||||
|
|
||||||
// Note: Actual upload implementation would go here
|
try {
|
||||||
// This is a simplified version
|
const result = await window.electronAPI.sftpUpload(sftpId, task.filePath, remoteFilePath)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
setTransferQueue(prev => prev.map(t =>
|
setTransferQueue(prev => prev.map(t =>
|
||||||
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
||||||
))
|
))
|
||||||
} catch (err) {
|
logSuccess(`Upload complete: ${task.fileName}`)
|
||||||
|
} else {
|
||||||
|
setTransferQueue(prev => prev.map(t =>
|
||||||
|
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Upload failed' } : t
|
||||||
|
))
|
||||||
|
logError(`Upload failed: ${task.fileName} - ${result.error}`)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
logger.error('Upload failed', err)
|
logger.error('Upload failed', err)
|
||||||
setTransferQueue(prev => prev.map(t =>
|
setTransferQueue(prev => prev.map(t =>
|
||||||
t.id === task.id ? { ...t, status: 'failed', error: 'Upload failed' } : t
|
t.id === task.id ? { ...t, status: 'failed', error: err?.message || 'Upload failed' } : t
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [sftpId, localPath, remotePath])
|
|
||||||
|
|
||||||
// Download files
|
// Refresh remote directory after all uploads
|
||||||
|
loadRemoteDirectory(remotePath)
|
||||||
|
} finally {
|
||||||
|
window.electronAPI.removeListener?.('sftp:progress', progressHandler)
|
||||||
|
}
|
||||||
|
}, [sftpId, localPath, remotePath, loadRemoteDirectory])
|
||||||
|
|
||||||
|
// Download files (real implementation via IPC)
|
||||||
const downloadFiles = useCallback(async () => {
|
const downloadFiles = useCallback(async () => {
|
||||||
if (!sftpId || remoteSelected.size === 0) return
|
if (!sftpId || remoteSelected.size === 0 || !window.electronAPI) return
|
||||||
|
|
||||||
const filesToDownload = remoteFiles.filter(f => remoteSelected.has(f.name))
|
const filesToDownload = remoteFiles.filter(f => remoteSelected.has(f.name))
|
||||||
|
|
||||||
const newTasks: TransferTask[] = filesToDownload.map(file => ({
|
const newTasks: TransferTask[] = filesToDownload.map(file => ({
|
||||||
id: `download-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
id: `download-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
type: 'download',
|
type: 'download' as const,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
filePath: file.path,
|
filePath: file.path,
|
||||||
status: 'pending',
|
status: 'pending' as const,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
total: file.size,
|
total: file.size,
|
||||||
transferred: 0,
|
transferred: 0,
|
||||||
@@ -430,32 +470,96 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
|
|
||||||
setTransferQueue(prev => [...prev, ...newTasks])
|
setTransferQueue(prev => [...prev, ...newTasks])
|
||||||
|
|
||||||
// Process downloads
|
// Set up progress listener
|
||||||
for (const task of newTasks) {
|
const progressHandler = (_event: any, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => {
|
||||||
try {
|
setTransferQueue(prev => {
|
||||||
setTransferQueue(prev => prev.map(t =>
|
const idx = prev.findIndex(t => t.type === 'download' && t.status === 'transferring')
|
||||||
t.id === task.id ? { ...t, status: 'transferring' } : t
|
if (idx === -1) return prev
|
||||||
))
|
const t = prev[idx]
|
||||||
|
return [
|
||||||
if (window.electronAPI) {
|
...prev.slice(0, idx),
|
||||||
await window.electronAPI.sftpDownload(sftpId, task.filePath, `${localPath}/${task.fileName}`)
|
{ ...t, transferred: progress.transferred, total: progress.total, progress: progress.percentage },
|
||||||
|
...prev.slice(idx + 1),
|
||||||
|
]
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.electronAPI.onSFTPProgress(progressHandler)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const task of newTasks) {
|
||||||
|
const localFilePath = `${localPath}/${task.fileName}`
|
||||||
|
logInfo('Downloading', `${task.filePath} -> ${localFilePath}`)
|
||||||
|
|
||||||
|
setTransferQueue(prev => prev.map(t =>
|
||||||
|
t.id === task.id ? { ...t, status: 'transferring', startTime: Date.now() } : t
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.sftpDownload(sftpId, task.filePath, localFilePath)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
setTransferQueue(prev => prev.map(t =>
|
setTransferQueue(prev => prev.map(t =>
|
||||||
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
||||||
))
|
))
|
||||||
} catch (err) {
|
logSuccess(`Download complete: ${task.fileName}`)
|
||||||
|
} else {
|
||||||
|
setTransferQueue(prev => prev.map(t =>
|
||||||
|
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Download failed' } : t
|
||||||
|
))
|
||||||
|
logError(`Download failed: ${task.fileName} - ${result.error}`)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
logger.error('Download failed', err)
|
logger.error('Download failed', err)
|
||||||
setTransferQueue(prev => prev.map(t =>
|
setTransferQueue(prev => prev.map(t =>
|
||||||
t.id === task.id ? { ...t, status: 'failed', error: 'Download failed' } : t
|
t.id === task.id ? { ...t, status: 'failed', error: err?.message || 'Download failed' } : t
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh local directory
|
// Refresh local directory after all downloads
|
||||||
loadLocalDirectory(localPath)
|
loadLocalDirectory(localPath)
|
||||||
|
} finally {
|
||||||
|
window.electronAPI.removeListener?.('sftp:progress', progressHandler)
|
||||||
|
}
|
||||||
}, [sftpId, remoteFiles, remoteSelected, localPath, loadLocalDirectory])
|
}, [sftpId, remoteFiles, remoteSelected, localPath, loadLocalDirectory])
|
||||||
|
|
||||||
|
// Delete remote files/directories
|
||||||
|
const deleteRemote = useCallback(async (files: FileItem[]): Promise<boolean> => {
|
||||||
|
if (!sftpId || files.length === 0 || !window.electronAPI) {
|
||||||
|
logWarning('deleteRemote: missing sftpId, files, or electronAPI')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let allSuccess = true
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
logInfo('Deleting remote', `${file.path} (isDir: ${file.type === 'directory'})`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.sftpDelete(sftpId, file.path, file.type === 'directory')
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logSuccess(`Deleted: ${file.name}`)
|
||||||
|
} else {
|
||||||
|
logError(`Delete failed: ${file.name} - ${result.error}`)
|
||||||
|
allSuccess = false
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error('Delete failed', err)
|
||||||
|
logError(`Delete error: ${file.name} - ${err?.message}`)
|
||||||
|
allSuccess = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh remote directory after deletion
|
||||||
|
if (allSuccess || files.length > 0) {
|
||||||
|
loadRemoteDirectory(remotePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allSuccess
|
||||||
|
}, [sftpId, remotePath, loadRemoteDirectory])
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -499,6 +603,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
uploadFiles,
|
uploadFiles,
|
||||||
downloadFiles,
|
downloadFiles,
|
||||||
|
|
||||||
|
// Remote file operations
|
||||||
|
deleteRemote,
|
||||||
|
|
||||||
sortField,
|
sortField,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortField,
|
setSortField,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* useToast — 全局 Toast 通知 Hook
|
||||||
|
*
|
||||||
|
* 提供便捷的通知调用方法,可在任何组件中使用
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const toast = useToast()
|
||||||
|
* toast.success('保存成功')
|
||||||
|
* toast.error('连接失败', { duration: 5000 })
|
||||||
|
* toast.warning('磁盘空间不足')
|
||||||
|
* toast.info('正在同步...')
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useToastStore, type ToastType, type ToastOptions } from '../stores/toastStore'
|
||||||
|
|
||||||
|
/** Toast 操作接口 */
|
||||||
|
export interface ToastApi {
|
||||||
|
/** 显示成功通知(绿色) */
|
||||||
|
success: (message: string, options?: ToastOptions) => string
|
||||||
|
/** 显示错误通知(红色) */
|
||||||
|
error: (message: string, options?: ToastOptions) => string
|
||||||
|
/** 显示警告通知(琥珀色) */
|
||||||
|
warning: (message: string, options?: ToastOptions) => string
|
||||||
|
/** 显示信息通知(蓝色) */
|
||||||
|
info: (message: string, options?: ToastOptions) => string
|
||||||
|
/** 移除指定通知 */
|
||||||
|
remove: (id: string) => void
|
||||||
|
/** 清空所有通知 */
|
||||||
|
clearAll: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局 Toast 通知 Hook
|
||||||
|
*/
|
||||||
|
export function useToast(): ToastApi {
|
||||||
|
const addToast = useToastStore((s) => s.addToast)
|
||||||
|
const removeToast = useToastStore((s) => s.removeToast)
|
||||||
|
const clearAll = useToastStore((s) => s.clearAll)
|
||||||
|
|
||||||
|
const create = (type: ToastType) => (message: string, options?: ToastOptions) =>
|
||||||
|
addToast(type, message, options)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: create('success'),
|
||||||
|
error: create('error'),
|
||||||
|
warning: create('warning'),
|
||||||
|
info: create('info'),
|
||||||
|
remove: removeToast,
|
||||||
|
clearAll,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useToast
|
||||||
+117
-2
@@ -1,4 +1,8 @@
|
|||||||
import { create } from 'zustand'
|
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 TabType = 'welcome' | 'terminal' | 'sftp' | 'portforward' | 'settings'
|
||||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||||
@@ -29,6 +33,19 @@ export interface Tab {
|
|||||||
connectionInstance?: ConnectionInstance // 实际的连接实例(每个标签独立)
|
connectionInstance?: ConnectionInstance // 实际的连接实例(每个标签独立)
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
connectionStatus?: ConnectionStatus
|
connectionStatus?: ConnectionStatus
|
||||||
|
needsReconnect?: boolean // 从持久化恢复后需要重新连接
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用于持久化的序列化 Tab 数据(排除敏感字段和运行时状态)
|
||||||
|
interface PersistedTab {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: TabType
|
||||||
|
sessionId?: string
|
||||||
|
// 注意:不持久化 connectionConfig(可能包含密码/密钥)
|
||||||
|
// 注意:不持久化 connectionInstance(运行时状态)
|
||||||
|
// 注意:不持久化 connectionStatus(运行时状态)
|
||||||
|
// 注意:不持久化 isActive(由 activeTabId 决定)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabState {
|
interface TabState {
|
||||||
@@ -50,7 +67,9 @@ interface TabState {
|
|||||||
getTabById: (tabId: string) => Tab | undefined
|
getTabById: (tabId: string) => Tab | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTabStore = create<TabState>((set, get) => ({
|
export const useTabStore = create<TabState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
tabs: [
|
tabs: [
|
||||||
{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }
|
{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }
|
||||||
],
|
],
|
||||||
@@ -107,7 +126,7 @@ export const useTabStore = create<TabState>((set, get) => ({
|
|||||||
updateTabStatus: (tabId, status) => {
|
updateTabStatus: (tabId, status) => {
|
||||||
const { tabs } = get()
|
const { tabs } = get()
|
||||||
const updatedTabs = tabs.map(t =>
|
const updatedTabs = tabs.map(t =>
|
||||||
t.id === tabId ? { ...t, connectionStatus: status } : t
|
t.id === tabId ? { ...t, connectionStatus: status, needsReconnect: status === 'disconnected' ? false : t.needsReconnect } : t
|
||||||
)
|
)
|
||||||
set({ tabs: updatedTabs })
|
set({ tabs: updatedTabs })
|
||||||
},
|
},
|
||||||
@@ -223,4 +242,100 @@ export const useTabStore = create<TabState>((set, get) => ({
|
|||||||
getTabById: (tabId) => {
|
getTabById: (tabId) => {
|
||||||
return get().tabs.find(t => t.id === 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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Toast 通知状态管理
|
||||||
|
*
|
||||||
|
* 管理全局通知队列,支持 success / error / warning / info 四种类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
/** Toast 类型 */
|
||||||
|
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||||
|
|
||||||
|
/** 单条 Toast 数据 */
|
||||||
|
export interface ToastItem {
|
||||||
|
/** 唯一 ID */
|
||||||
|
id: string
|
||||||
|
/** 类型 */
|
||||||
|
type: ToastType
|
||||||
|
/** 消息内容 */
|
||||||
|
message: string
|
||||||
|
/** 自动关闭时间(毫秒),0 表示不自动关闭 */
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toast 创建选项 */
|
||||||
|
export interface ToastOptions {
|
||||||
|
/** 自动关闭时间(毫秒),默认 3000 */
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastState {
|
||||||
|
/** 通知队列 */
|
||||||
|
toasts: ToastItem[]
|
||||||
|
/** 添加一条通知 */
|
||||||
|
addToast: (type: ToastType, message: string, options?: ToastOptions) => string
|
||||||
|
/** 移除一条通知 */
|
||||||
|
removeToast: (id: string) => void
|
||||||
|
/** 清空所有通知 */
|
||||||
|
clearAll: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastCounter = 0
|
||||||
|
|
||||||
|
/** 生成唯一 ID */
|
||||||
|
function generateId(): string {
|
||||||
|
toastCounter += 1
|
||||||
|
return `toast-${Date.now()}-${toastCounter}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useToastStore = create<ToastState>((set) => ({
|
||||||
|
toasts: [],
|
||||||
|
|
||||||
|
addToast: (type, message, options) => {
|
||||||
|
const id = generateId()
|
||||||
|
const duration = options?.duration ?? 3000
|
||||||
|
|
||||||
|
const toast: ToastItem = { id, type, message, duration }
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
toasts: [...state.toasts, toast],
|
||||||
|
}))
|
||||||
|
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
|
||||||
|
removeToast: (id) => {
|
||||||
|
set((state) => ({
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== id),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAll: () => {
|
||||||
|
set({ toasts: [] })
|
||||||
|
},
|
||||||
|
}))
|
||||||
Reference in new issue
Block a user