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:
OpsXShell Dev committed 2026-04-18 16:21:10 +08:00
1 parent 53274e2291
commit d344900118
14 files changed
+1224 -247

No files matched your search

+178 -1
View File
@@ -1,7 +1,9 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css'
import { Search, ChevronUp, ChevronDown, X, Terminal as TerminalIcon } from 'lucide-react'
import { themes } from '../themes/terminal'
import { useSettingsStore } from '../stores/settingsStore'
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 xtermRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const searchAddonRef = useRef<SearchAddon | null>(null)
const shellIdRef = useRef<string | null>(null)
const connectionIdRef = useRef<string | null>(null)
const hasConnectedRef = useRef(false)
@@ -28,6 +31,13 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
const [statusMessage, setStatusMessage] = useState<string>('')
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 { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore()
@@ -40,6 +50,85 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
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
useEffect(() => {
// 修复:只检查 terminalRef,不检查 xtermRef
@@ -91,13 +180,16 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
})
const fitAddon = new FitAddon()
const searchAddon = new SearchAddon()
term.loadAddon(fitAddon)
term.loadAddon(searchAddon)
term.open(terminalRef.current)
fitAddon.fit()
xtermRef.current = term
fitAddonRef.current = fitAddon
searchAddonRef.current = searchAddon
// 处理终端输入
term.onData((data) => {
@@ -400,6 +492,16 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
// 初始连接
useEffect(() => {
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)
if (hasSnapshot && isActive) {
@@ -452,10 +554,12 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
hasConnectedRef.current = false
setConnectionState('disconnected')
setShowSuspendedOverlay(false)
// 清除 needsReconnect 标志
updateTab(tabId, { needsReconnect: false })
xtermRef.current?.clear()
connect()
}
}, [connectionState, connect])
}, [connectionState, connect, updateTab, tabId])
// 组件卸载时清理监听器
useEffect(() => {
@@ -477,6 +581,79 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
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 && (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">