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
+1224
-247
No files matched your search
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user