feat(v1.2): 体验升级 — 快捷键系统 + 标签拖拽 + SFTP拖拽上传 + 组件拆分 + 响应式
任务1: 全局快捷键管理器 — useKeyboardShortcuts hook Ctrl+Tab/Shift+Tab标签切换, Ctrl+1~9跳转, F11全屏, Ctrl+Shift+N新建标签 任务2: 标签拖拽排序 — tabStore.moveTab + TabBar HTML5 DnD 不可变更新, 拖拽视觉反馈(opacity+指示线), 活跃标签底部accent-blue指示线 任务3: SFTP拖拽上传 + useSFTP调试清理 FileListPanel拖拽覆盖层(Upload图标+虚线边框), 5个调试函数→createLogger, disconnect()正确调用IPC, 删除mock data 任务4: SFTP右键菜单完善 — 重命名/复制路径/属性/权限 FilePropertiesDialog+ChmodDialog, sftpStat/sftpChmod IPC全栈实现 任务5-6: 侧边栏分组折叠动画 + 宽度可拖拽调整 Grid 0fr→1fr折叠动画, ChevronRight旋转, resize handle 180~400px, settings记忆 任务7: 响应式布局 + 视觉优化V1~V5 useMediaQuery hook, <800px侧边栏overlay模式, SFTP垂直堆叠, WelcomeView响应式grid 任务8: Loading/Skeleton骨架屏 Skeleton/SkeletonLine/SkeletonCircle/SkeletonBlock + FileListSkeleton/SessionTreeSkeleton/TabContentSkeleton 任务9: 空状态设计 EmptyState通用组件, SessionTree/FileListPanel/WelcomeView/SFTPView空状态引导 任务10: TerminalView组件拆分重构 711行→285行 useTerminalConnection(300行) + useTerminalSearch(123行) + TerminalOverlay(98行) + TerminalSearchBar(74行) 33 files changed, 2682 insertions(+), 872 deletions(-)
This commit is contained in:
1 parent
d344900118
commit
caba5f4c0d
33 files changed
+2531
-721
No files matched your search
+21
-20
@@ -12,34 +12,35 @@ v1.5.0 安全升级 ─── 1 周 ──→ 安全 55→85, 代码 80→85
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.1.0 基础可用 (2 周) — 当前阶段
|
## v1.1.0 基础可用 (2 周) — ✅ 已完成
|
||||||
|
|
||||||
> **目标**: 解决所有 Critical UI/UX 问题,建立基础反馈系统,产品具备日常使用价值
|
> **目标**: 解决所有 Critical UI/UX 问题,建立基础反馈系统,产品具备日常使用价值
|
||||||
|
|
||||||
### 🔴 P0 — 必须完成
|
### ✅ 全部完成 (提交: 53274e2 + d344900)
|
||||||
|
|
||||||
| # | 任务 | 类型 | 预计 | 验收标准 |
|
| # | 任务 | 状态 | 提交 |
|
||||||
|---|------|------|------|----------|
|
|---|------|------|------|
|
||||||
| 1 | Toast 通知系统 | 新增 | 2天 | `useToast()` hook + `ToastContainer`,支持 success/error/warning/info 四种类型,自动消失+手动关闭 |
|
| 1 | Toast 通知系统 | ✅ | d344900 |
|
||||||
| 2 | 替换所有 alert/prompt/confirm | 修复 | 2天 | `App.tsx` 5处 alert → Toast,`SFTPView.tsx` prompt → 内联输入,`PortForwardView.tsx` confirm → ConfirmDialog |
|
| 2 | 替换所有 alert/prompt/confirm | ✅ | d344900 |
|
||||||
| 3 | SFTP 上传/删除真实实现 | 修复 | 3天 | 上传通过 ssh2 sftp fastPut 实现,删除通过 rimraf/unlink 实现,右键菜单 action 绑定真实函数 |
|
| 3 | SFTP 上传/删除真实实现 | ✅ | d344900 |
|
||||||
| 4 | 终端搜索 (SearchAddon) | 新增 | 1天 | Ctrl+Shift+F 打开搜索栏,支持正则/大小写/全词匹配,高亮所有匹配 |
|
| 4 | 终端搜索 (SearchAddon) | ✅ | d344900 |
|
||||||
| 5 | 会话恢复 | 新增 | 3天 | 关闭前持久化标签状态到 SQLite,启动时恢复标签+自动重连 |
|
| 5 | 会话恢复 (标签持久化) | ✅ | d344900 |
|
||||||
| 6 | 修复私钥浏览按钮 | 修复 | 0.5天 | dialog.showOpenDialog() 绑定到 onClick,过滤 `.pem/.key/id_*` |
|
| 6 | 修复私钥浏览按钮 | ✅ | 53274e2 |
|
||||||
| 7 | 清理生产调试日志 | 修复 | 1天 | 移除 `DEBUG=true`,所有 console.log 用 logger 替代或删除 |
|
| 7 | 清理生产调试日志 | ✅ | 53274e2 |
|
||||||
| 8 | 设计系统统一颜色 | 修复 | 1天 | cyan→accent-blue, sky→accent-blue, slate→dark-*,新增 accent-orange |
|
| 8 | 设计系统统一颜色 | ✅ | 53274e2 |
|
||||||
| 9 | StatusBar 接入实际状态 | 修复 | 0.5天 | 显示当前连接 host、延迟 ms、标签数 |
|
| 9 | StatusBar 接入实际状态 | ✅ | 53274e2 |
|
||||||
| 10 | WelcomeView 真实最近连接 | 修复 | 1天 | 从 sessionStore 获取最近 5 条连接记录,空状态显示引导 |
|
| 10 | WelcomeView 真实最近连接 | ✅ | 53274e2 |
|
||||||
|
| 11 | better-sqlite3 → sql.js 迁移 | ✅ | 53274e2 |
|
||||||
|
|
||||||
### 里程碑推送
|
### 里程碑
|
||||||
- [ ] SFTP 功能可用 (上传+删除+右键菜单)
|
- [x] SFTP 功能可用 (上传+删除+右键菜单)
|
||||||
- [ ] Toast 系统上线 (零 alert)
|
- [x] Toast 系统上线 (零 alert)
|
||||||
- [ ] 终端搜索可用
|
- [x] 终端搜索可用
|
||||||
- [ ] 会话恢复可用
|
- [x] 会话恢复可用
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.2.0 体验升级 (2 周)
|
## v1.2.0 体验升级 (2 周) — 🚧 进行中
|
||||||
|
|
||||||
> **目标**: 快捷键补全、视觉打磨、交互优化,接近 Termius 体验水平
|
> **目标**: 快捷键补全、视觉打磨、交互优化,接近 Termius 体验水平
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const SFTP_CHANNELS = {
|
|||||||
RENAME: 'sftp:rename',
|
RENAME: 'sftp:rename',
|
||||||
DELETE: 'sftp:delete',
|
DELETE: 'sftp:delete',
|
||||||
MKDIR: 'sftp:mkdir',
|
MKDIR: 'sftp:mkdir',
|
||||||
|
STAT: 'sftp:stat',
|
||||||
|
CHMOD: 'sftp:chmod',
|
||||||
PROGRESS: 'sftp:progress',
|
PROGRESS: 'sftp:progress',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -193,4 +195,46 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
|
|||||||
return { success: false, error: error.message }
|
return { success: false, error: error.message }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// SFTP stat
|
||||||
|
ipcMain.handle(SFTP_CHANNELS.STAT, async (
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
sftpId: string,
|
||||||
|
filePath: string
|
||||||
|
) => {
|
||||||
|
logHeader('📋 SFTP IPC STAT')
|
||||||
|
logInfo('sftpId', sftpId)
|
||||||
|
logInfo('filePath', filePath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await sshService.sftpStat(sftpId, filePath)
|
||||||
|
logSuccess(`Stat complete: ${stats.size} bytes`)
|
||||||
|
return { success: true, stats }
|
||||||
|
} catch (error: any) {
|
||||||
|
logError(`Stat failed: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// SFTP chmod
|
||||||
|
ipcMain.handle(SFTP_CHANNELS.CHMOD, async (
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
sftpId: string,
|
||||||
|
filePath: string,
|
||||||
|
mode: number
|
||||||
|
) => {
|
||||||
|
logHeader('🔒 SFTP IPC CHMOD')
|
||||||
|
logInfo('sftpId', sftpId)
|
||||||
|
logInfo('filePath', filePath)
|
||||||
|
logInfo('mode', mode.toString(8))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sshService.sftpChmod(sftpId, filePath, mode)
|
||||||
|
logSuccess('Chmod complete')
|
||||||
|
return { success: true }
|
||||||
|
} catch (error: any) {
|
||||||
|
logError(`Chmod failed: ${error.message}`)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,8 @@ const api = {
|
|||||||
sftpRename: (sftpId: string, oldPath: string, newPath: string) => ipcRenderer.invoke('sftp:rename', sftpId, oldPath, newPath),
|
sftpRename: (sftpId: string, oldPath: string, newPath: string) => ipcRenderer.invoke('sftp:rename', sftpId, oldPath, newPath),
|
||||||
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => ipcRenderer.invoke('sftp:delete', sftpId, path, isDirectory),
|
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => ipcRenderer.invoke('sftp:delete', sftpId, path, isDirectory),
|
||||||
sftpMkdir: (sftpId: string, path: string) => ipcRenderer.invoke('sftp:mkdir', sftpId, path),
|
sftpMkdir: (sftpId: string, path: string) => ipcRenderer.invoke('sftp:mkdir', sftpId, path),
|
||||||
|
sftpStat: (sftpId: string, filePath: string) => ipcRenderer.invoke('sftp:stat', sftpId, filePath),
|
||||||
|
sftpChmod: (sftpId: string, filePath: string, mode: number) => ipcRenderer.invoke('sftp:chmod', sftpId, filePath, mode),
|
||||||
|
|
||||||
// 端口转发
|
// 端口转发
|
||||||
sshForwardLocal: (connectionId: string, localPort: number, remoteHost: string, remotePort: number) =>
|
sshForwardLocal: (connectionId: string, localPort: number, remoteHost: string, remotePort: number) =>
|
||||||
|
|||||||
@@ -480,6 +480,56 @@ export class SSHService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sftpStat(sftpId: string, filePath: string): Promise<{
|
||||||
|
size: number
|
||||||
|
mode: number
|
||||||
|
mtime: number
|
||||||
|
atime: number
|
||||||
|
isDirectory: boolean
|
||||||
|
isFile: boolean
|
||||||
|
isSymbolicLink: boolean
|
||||||
|
}> {
|
||||||
|
const session = this.sftps.get(sftpId)
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('SFTP session not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
session.sftp.stat(filePath, (err, stats) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
size: stats.size,
|
||||||
|
mode: stats.mode,
|
||||||
|
mtime: stats.mtime,
|
||||||
|
atime: stats.atime,
|
||||||
|
isDirectory: stats.isDirectory(),
|
||||||
|
isFile: stats.isFile(),
|
||||||
|
isSymbolicLink: stats.isSymbolicLink(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async sftpChmod(sftpId: string, filePath: string, mode: number): Promise<void> {
|
||||||
|
const session = this.sftps.get(sftpId)
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('SFTP session not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
session.sftp.chmod(filePath, mode, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 端口转发
|
// 端口转发
|
||||||
// ===== 本地端口转发 =====
|
// ===== 本地端口转发 =====
|
||||||
async forwardLocal(
|
async forwardLocal(
|
||||||
|
|||||||
+75
-8
@@ -1,15 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import Sidebar from './components/Sidebar'
|
import Sidebar from './components/Sidebar'
|
||||||
import TabBar from './components/TabBar'
|
import TabBar from './components/TabBar'
|
||||||
import TabContent from './components/TabContent'
|
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'
|
import { useToast } from './hooks/useToast'
|
||||||
|
import { ToastContainer } from './components/common/ToastContainer'
|
||||||
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
|
import { useIsMobile } from './hooks/useMediaQuery'
|
||||||
|
|
||||||
const logger = createLogger('App')
|
const logger = createLogger('App')
|
||||||
|
|
||||||
@@ -19,7 +21,33 @@ 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 { toasts, toast, removeToast } = useToast()
|
||||||
|
useKeyboardShortcuts()
|
||||||
|
|
||||||
|
// Responsive: detect mobile
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
// When viewport changes to mobile, auto-collapse sidebar
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarCollapsed(true)
|
||||||
|
} else {
|
||||||
|
setSidebarCollapsed(false)
|
||||||
|
setMobileSidebarOpen(false)
|
||||||
|
}
|
||||||
|
}, [isMobile])
|
||||||
|
|
||||||
|
// Toggle sidebar (for mobile overlay)
|
||||||
|
const toggleMobileSidebar = useCallback(() => {
|
||||||
|
setMobileSidebarOpen((prev) => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Close mobile sidebar overlay
|
||||||
|
const closeMobileSidebar = useCallback(() => {
|
||||||
|
setMobileSidebarOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 导出会话
|
// 导出会话
|
||||||
const handleExportSessions = async () => {
|
const handleExportSessions = async () => {
|
||||||
@@ -198,30 +226,67 @@ 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>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Mobile overlay backdrop */}
|
||||||
|
{isMobile && mobileSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-40 transition-opacity duration-300"
|
||||||
|
onClick={closeMobileSidebar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar container with transition */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
transition-all duration-300 ease-in-out flex-shrink-0
|
||||||
|
${isMobile
|
||||||
|
? `fixed inset-y-0 left-0 z-50 top-8 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
|
||||||
|
: sidebarCollapsed ? 'w-0 overflow-hidden' : 'w-64'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
onNewSession={() => {
|
onNewSession={() => {
|
||||||
setEditSession(null)
|
setEditSession(null)
|
||||||
setIsSessionDialogOpen(true)
|
setIsSessionDialogOpen(true)
|
||||||
|
if (isMobile) closeMobileSidebar()
|
||||||
|
}}
|
||||||
|
onQuickConnect={() => {
|
||||||
|
setIsQuickConnectOpen(true)
|
||||||
|
if (isMobile) closeMobileSidebar()
|
||||||
}}
|
}}
|
||||||
onQuickConnect={() => setIsQuickConnectOpen(true)}
|
|
||||||
onEditSession={(session) => {
|
onEditSession={(session) => {
|
||||||
setEditSession(session)
|
setEditSession(session)
|
||||||
setIsSessionDialogOpen(true)
|
setIsSessionDialogOpen(true)
|
||||||
|
if (isMobile) closeMobileSidebar()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main area */}
|
{/* Main area */}
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
{/* Tab bar */}
|
{/* Tab bar with mobile sidebar toggle */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={toggleMobileSidebar}
|
||||||
|
className="flex items-center justify-center w-10 h-10 text-gray-400 hover:text-gray-200 hover:bg-dark-800 transition-colors"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
<TabBar />
|
<TabBar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
@@ -254,8 +319,10 @@ function App() {
|
|||||||
isOpen={isQuickConnectOpen}
|
isOpen={isQuickConnectOpen}
|
||||||
onClose={() => setIsQuickConnectOpen(false)}
|
onClose={() => setIsQuickConnectOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Toast Notifications */}
|
||||||
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</div>
|
</div>
|
||||||
</ToastProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+151
-9
@@ -5,15 +5,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { FolderOpen, Upload, Download, Trash2 } from 'lucide-react'
|
import {
|
||||||
|
FolderOpen, Upload, Download, Trash2,
|
||||||
|
Edit3, Copy, FileText, ShieldCheck,
|
||||||
|
Unplug,
|
||||||
|
} from 'lucide-react'
|
||||||
import { useSFTP } from '../hooks'
|
import { useSFTP } from '../hooks'
|
||||||
|
import { useToast } from '../hooks/useToast'
|
||||||
import { FileListPanel } from './sftp/FileListPanel'
|
import { FileListPanel } from './sftp/FileListPanel'
|
||||||
import { TransferPanel } from './sftp/TransferPanel'
|
import { TransferPanel } from './sftp/TransferPanel'
|
||||||
import { Toolbar } from './sftp/Toolbar'
|
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 { ToastContainer } from './common/ToastContainer'
|
||||||
|
import EmptyState from './common/EmptyState'
|
||||||
import { sortFiles } from './sftp/utils'
|
import { sortFiles } from './sftp/utils'
|
||||||
import { usePromptDialog } from './common/PromptDialog'
|
import { usePromptDialog } from './common/PromptDialog'
|
||||||
|
import FilePropertiesDialog from './sftp/FilePropertiesDialog'
|
||||||
|
import ChmodDialog from './sftp/ChmodDialog'
|
||||||
import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.types'
|
import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.types'
|
||||||
|
|
||||||
interface SFTPViewProps {
|
interface SFTPViewProps {
|
||||||
@@ -51,9 +60,13 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
uploadFiles,
|
uploadFiles,
|
||||||
downloadFiles,
|
downloadFiles,
|
||||||
deleteRemote,
|
deleteRemote,
|
||||||
|
renameRemote,
|
||||||
|
getRemoteStat,
|
||||||
|
chmodRemote,
|
||||||
error,
|
error,
|
||||||
} = useSFTP({ tabId })
|
} = useSFTP({ tabId })
|
||||||
|
|
||||||
|
const { toasts, toast, removeToast } = useToast()
|
||||||
const { prompt, PromptDialogComponent } = usePromptDialog()
|
const { prompt, PromptDialogComponent } = usePromptDialog()
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
@@ -63,6 +76,13 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
const [showConnectionSelector, setShowConnectionSelector] = useState(false)
|
const [showConnectionSelector, setShowConnectionSelector] = useState(false)
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null)
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null)
|
||||||
|
|
||||||
|
// Properties dialog state
|
||||||
|
const [propertiesFile, setPropertiesFile] = useState<FileItem | null>(null)
|
||||||
|
|
||||||
|
// Chmod dialog state
|
||||||
|
const [chmodFile, setChmodFile] = useState<FileItem | null>(null)
|
||||||
|
const [chmodInitialMode, setChmodInitialMode] = useState(0o644)
|
||||||
|
|
||||||
// Sort handler
|
// Sort handler
|
||||||
const handleSort = useCallback((field: SortField) => {
|
const handleSort = useCallback((field: SortField) => {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
@@ -93,6 +113,61 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
})
|
})
|
||||||
}, [setRemoteSelected])
|
}, [setRemoteSelected])
|
||||||
|
|
||||||
|
// ===== Context menu handlers =====
|
||||||
|
|
||||||
|
// Rename handler
|
||||||
|
const handleRename = useCallback(async (file: FileItem) => {
|
||||||
|
const newName = await prompt({
|
||||||
|
title: '重命名',
|
||||||
|
message: '输入新名称:',
|
||||||
|
placeholder: '新名称',
|
||||||
|
defaultValue: file.name,
|
||||||
|
})
|
||||||
|
if (newName && newName !== file.name) {
|
||||||
|
const ok = await renameRemote(file, newName)
|
||||||
|
if (ok) {
|
||||||
|
toast.success(`已重命名为 ${newName}`)
|
||||||
|
} else {
|
||||||
|
toast.error('重命名失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [prompt, renameRemote, toast])
|
||||||
|
|
||||||
|
// Copy path handler
|
||||||
|
const handleCopyPath = useCallback(async (filePath: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(filePath)
|
||||||
|
toast.success('路径已复制到剪贴板')
|
||||||
|
} catch {
|
||||||
|
toast.error('复制路径失败')
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
// Properties handler
|
||||||
|
const handleShowProperties = useCallback((file: FileItem) => {
|
||||||
|
setPropertiesFile(file)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Permissions (chmod) handler — fetch stat first, then show dialog
|
||||||
|
const handleChmod = useCallback(async (file: FileItem) => {
|
||||||
|
const stats = await getRemoteStat(file.path)
|
||||||
|
const mode = stats?.mode ?? 0o644
|
||||||
|
setChmodFile(file)
|
||||||
|
setChmodInitialMode(mode & 0o7777)
|
||||||
|
}, [getRemoteStat])
|
||||||
|
|
||||||
|
// Chmod confirm callback
|
||||||
|
const handleChmodConfirm = useCallback(async (filePath: string, mode: number) => {
|
||||||
|
const ok = await chmodRemote(filePath, mode)
|
||||||
|
if (ok) {
|
||||||
|
toast.success(`权限已更新为 ${mode.toString(8)}`)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
toast.error('权限修改失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [chmodRemote, toast])
|
||||||
|
|
||||||
// Local context menu
|
// Local context menu
|
||||||
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -107,9 +182,15 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
label: '上传',
|
label: '上传',
|
||||||
action: () => {},
|
action: () => {},
|
||||||
},
|
},
|
||||||
|
{ icon: <Copy className="w-4 h-4" />, label: '复制路径', separator: true, action: () => handleCopyPath(file.path) },
|
||||||
|
{
|
||||||
|
icon: <FileText className="w-4 h-4" />,
|
||||||
|
label: '属性',
|
||||||
|
action: () => handleShowProperties(file),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||||
}, [navigateLocal])
|
}, [navigateLocal, handleCopyPath, handleShowProperties])
|
||||||
|
|
||||||
// Remote context menu
|
// Remote context menu
|
||||||
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
||||||
@@ -128,17 +209,22 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
downloadFiles()
|
downloadFiles()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ icon: <Edit3 className="w-4 h-4" />, label: '重命名', separator: true, action: () => handleRename(file) },
|
||||||
|
{ icon: <Copy className="w-4 h-4" />, label: '复制路径', action: () => handleCopyPath(file.path) },
|
||||||
{
|
{
|
||||||
icon: <Trash2 className="w-4 h-4" />,
|
icon: <FileText className="w-4 h-4" />,
|
||||||
label: '删除',
|
label: '属性',
|
||||||
danger: true,
|
action: () => handleShowProperties(file),
|
||||||
action: () => {
|
|
||||||
deleteRemote([file])
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <ShieldCheck className="w-4 h-4" />,
|
||||||
|
label: '权限',
|
||||||
|
action: () => handleChmod(file),
|
||||||
},
|
},
|
||||||
|
{ icon: <Trash2 className="w-4 h-4" />, label: '删除', separator: true, danger: true, action: () => deleteRemote([file]) },
|
||||||
]
|
]
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||||
}, [navigateRemote, downloadFiles, deleteRemote, setRemoteSelected])
|
}, [navigateRemote, downloadFiles, deleteRemote, setRemoteSelected, handleRename, handleCopyPath, handleShowProperties, handleChmod])
|
||||||
|
|
||||||
// Navigate up
|
// Navigate up
|
||||||
const navigateLocalUp = useCallback(() => {
|
const navigateLocalUp = useCallback(() => {
|
||||||
@@ -164,7 +250,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
}
|
}
|
||||||
}, [navigateRemote])
|
}, [navigateRemote])
|
||||||
|
|
||||||
// Handle upload
|
// Handle upload (via file picker)
|
||||||
const handleUpload = useCallback(() => {
|
const handleUpload = useCallback(() => {
|
||||||
const input = document.createElement('input')
|
const input = document.createElement('input')
|
||||||
input.type = 'file'
|
input.type = 'file'
|
||||||
@@ -175,6 +261,20 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
input.click()
|
input.click()
|
||||||
}, [uploadFiles])
|
}, [uploadFiles])
|
||||||
|
|
||||||
|
// Handle drag-and-drop upload on remote panel
|
||||||
|
const handleDropFiles = useCallback((files: FileList) => {
|
||||||
|
if (!isConnected) return
|
||||||
|
const count = files.length
|
||||||
|
toast.info(`正在上传 ${count} 个文件...`)
|
||||||
|
uploadFiles(files)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(`${count} 个文件上传完成`)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(`上传失败: ${err?.message || '未知错误'}`)
|
||||||
|
})
|
||||||
|
}, [isConnected, uploadFiles, toast])
|
||||||
|
|
||||||
// Handle new folder
|
// Handle new folder
|
||||||
const handleNewFolder = useCallback(async () => {
|
const handleNewFolder = useCallback(async () => {
|
||||||
const name = await prompt({
|
const name = await prompt({
|
||||||
@@ -216,6 +316,15 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* File Panels */}
|
{/* File Panels */}
|
||||||
|
{!isConnected ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<EmptyState
|
||||||
|
icon={Unplug}
|
||||||
|
title="未连接到远程服务器"
|
||||||
|
description="请先连接 SSH 会话以使用 SFTP"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex-1 flex gap-4 p-4 min-h-0">
|
<div className="flex-1 flex gap-4 p-4 min-h-0">
|
||||||
{/* Local Panel */}
|
{/* Local Panel */}
|
||||||
<FileListPanel
|
<FileListPanel
|
||||||
@@ -261,8 +370,11 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
contextMenu={contextMenu}
|
contextMenu={contextMenu}
|
||||||
onCloseContextMenu={() => setContextMenu(null)}
|
onCloseContextMenu={() => setContextMenu(null)}
|
||||||
|
onDropFiles={isConnected ? handleDropFiles : undefined}
|
||||||
|
loading={isConnecting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Transfer Panel */}
|
{/* Transfer Panel */}
|
||||||
<TransferPanel
|
<TransferPanel
|
||||||
@@ -283,6 +395,36 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{PromptDialogComponent}
|
{PromptDialogComponent}
|
||||||
|
|
||||||
|
{/* File Properties Dialog */}
|
||||||
|
{propertiesFile && (
|
||||||
|
<FilePropertiesDialog
|
||||||
|
isOpen={!!propertiesFile}
|
||||||
|
onClose={() => setPropertiesFile(null)}
|
||||||
|
fileName={propertiesFile.name}
|
||||||
|
filePath={propertiesFile.path}
|
||||||
|
fileType={propertiesFile.type}
|
||||||
|
permissions={propertiesFile.permissions}
|
||||||
|
initialSize={propertiesFile.size}
|
||||||
|
initialModified={propertiesFile.modified}
|
||||||
|
onFetchStat={isConnected ? getRemoteStat : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chmod Dialog */}
|
||||||
|
{chmodFile && (
|
||||||
|
<ChmodDialog
|
||||||
|
isOpen={!!chmodFile}
|
||||||
|
onClose={() => setChmodFile(null)}
|
||||||
|
fileName={chmodFile.name}
|
||||||
|
filePath={chmodFile.path}
|
||||||
|
initialMode={chmodInitialMode}
|
||||||
|
onConfirm={handleChmodConfirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toast Notifications */}
|
||||||
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Sidebar - 侧边栏组件 (简化版)
|
* Sidebar - 侧边栏组件
|
||||||
|
* 支持宽度拖拽调整
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { Plus, Zap, Server, Settings } from 'lucide-react'
|
import { Plus, Zap, Server, Settings } from 'lucide-react'
|
||||||
import { useSessionStore, Session } from '../stores'
|
import { useSessionStore, Session } from '../stores'
|
||||||
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
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'
|
import { useConfirmDialog } from './common/ConfirmDialog'
|
||||||
// Sidebar Component
|
|
||||||
|
const MIN_SIDEBAR_WIDTH = 180
|
||||||
|
const MAX_SIDEBAR_WIDTH = 400
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
onNewSession: () => void
|
onNewSession: () => void
|
||||||
@@ -19,6 +23,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 { settings, setSetting } = useSettingsStore()
|
||||||
const { addTab } = useTabStore()
|
const { addTab } = useTabStore()
|
||||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||||
|
|
||||||
@@ -26,8 +31,19 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
|||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['default']))
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['default']))
|
||||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; session: Session } | null>(null)
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; session: Session } | null>(null)
|
||||||
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(settings.sidebarWidth || 256)
|
||||||
|
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||||
|
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Sync width from settings on load
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings.sidebarWidth && settings.sidebarWidth !== sidebarWidth) {
|
||||||
|
setSidebarWidth(settings.sidebarWidth)
|
||||||
|
}
|
||||||
|
}, [settings.sidebarWidth])
|
||||||
|
|
||||||
// Load sessions
|
// Load sessions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,6 +51,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
|||||||
if (result.success && result.sessions) {
|
if (result.success && result.sessions) {
|
||||||
setSessions(result.sessions)
|
setSessions(result.sessions)
|
||||||
}
|
}
|
||||||
|
setSessionsLoading(false)
|
||||||
})
|
})
|
||||||
}, [setSessions])
|
}, [setSessions])
|
||||||
|
|
||||||
@@ -49,6 +66,44 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Resize handler
|
||||||
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsResizing(true)
|
||||||
|
|
||||||
|
const startX = e.clientX
|
||||||
|
const startWidth = sidebarWidth
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
const delta = moveEvent.clientX - startX
|
||||||
|
const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta))
|
||||||
|
setSidebarWidth(newWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsResizing(false)
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
// Save to settings on drag end
|
||||||
|
setSetting('sidebarWidth', sidebarWidth)
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent text selection during drag
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
}, [sidebarWidth, setSetting])
|
||||||
|
|
||||||
|
// Save final width on unmount or when resizing ends (we save in handleMouseUp)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isResizing && sidebarWidth !== settings.sidebarWidth) {
|
||||||
|
setSetting('sidebarWidth', sidebarWidth)
|
||||||
|
}
|
||||||
|
}, [isResizing, sidebarWidth, settings.sidebarWidth, setSetting])
|
||||||
|
|
||||||
const toggleGroup = (group: string) => {
|
const toggleGroup = (group: string) => {
|
||||||
const newExpanded = new Set(expandedGroups)
|
const newExpanded = new Set(expandedGroups)
|
||||||
if (newExpanded.has(group)) newExpanded.delete(group)
|
if (newExpanded.has(group)) newExpanded.delete(group)
|
||||||
@@ -120,7 +175,12 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-64 bg-dark-800 border-r border-dark-700 flex flex-col">
|
<div
|
||||||
|
ref={sidebarRef}
|
||||||
|
className="relative flex flex-shrink-0 bg-dark-800 border-r border-dark-700"
|
||||||
|
style={{ width: sidebarWidth }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-dark-700">
|
<div className="p-4 border-b border-dark-700">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
@@ -166,6 +226,8 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
|||||||
onSelectSession={handleConnect}
|
onSelectSession={handleConnect}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
|
loading={sessionsLoading}
|
||||||
|
onNewSession={onNewSession}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@@ -210,6 +272,18 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Resize Handle */}
|
||||||
|
<div
|
||||||
|
onMouseDown={handleResizeStart}
|
||||||
|
className={`
|
||||||
|
absolute top-0 right-0 bottom-0 w-1 cursor-col-resize
|
||||||
|
hover:bg-accent-blue/50 active:bg-accent-blue/70
|
||||||
|
transition-colors duration-150
|
||||||
|
${isResizing ? 'bg-accent-blue/70' : ''}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{ConfirmDialogComponent}
|
{ConfirmDialogComponent}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
+72
-17
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* TabBar - 标签栏组件 (简化版)
|
* TabBar - 标签栏组件(支持拖拽排序)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useCallback, useRef } from 'react'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
import { useConnectionPool } from '../stores/connection'
|
import { useConnectionPool } from '../stores/connection'
|
||||||
import { TabItem } from './tabbar/TabItem'
|
import { TabItem } from './tabbar/TabItem'
|
||||||
@@ -20,30 +20,22 @@ export default function TabBar() {
|
|||||||
closeRightTabs,
|
closeRightTabs,
|
||||||
closeLeftTabs,
|
closeLeftTabs,
|
||||||
closeAllTabs,
|
closeAllTabs,
|
||||||
|
moveTab,
|
||||||
} = useTabStore()
|
} = useTabStore()
|
||||||
|
|
||||||
const { disconnectConnection } = useConnectionPool()
|
const { disconnectConnection } = useConnectionPool()
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
const [dragFromIndex, setDragFromIndex] = useState<number | null>(null)
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||||
|
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const [menuState, setMenuState] = useState({
|
const [menuState, setMenuState] = useState({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
tabId: null as string | null,
|
tabId: null as string | null,
|
||||||
position: { x: 0, y: 0 }
|
position: { x: 0, y: 0 }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 快捷键
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'w' && activeTabId) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (tabs.length > 1) {
|
|
||||||
handleCloseTab(activeTabId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [activeTabId, tabs.length])
|
|
||||||
|
|
||||||
const handleCloseTab = (tabId: string) => {
|
const handleCloseTab = (tabId: string) => {
|
||||||
const tab = tabs.find(t => t.id === tabId)
|
const tab = tabs.find(t => t.id === tabId)
|
||||||
if (tab?.connectionInstance?.connectionId) {
|
if (tab?.connectionInstance?.connectionId) {
|
||||||
@@ -61,22 +53,85 @@ export default function TabBar() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Drag handlers ---
|
||||||
|
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
|
||||||
|
setDragFromIndex(index)
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
// Set a transparent drag image so we rely on the ghost
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if (e.currentTarget instanceof HTMLDivElement) {
|
||||||
|
dragNodeRef.current = e.currentTarget
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
if (dragFromIndex === null || dragFromIndex === index) {
|
||||||
|
setDragOverIndex(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Determine insert position: if dragging from lower index, place after; otherwise before
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const midX = rect.left + rect.width / 2
|
||||||
|
const insertAt = e.clientX < midX ? index : index + 1
|
||||||
|
// Don't show indicator if it would result in no-op
|
||||||
|
if (
|
||||||
|
insertAt === dragFromIndex ||
|
||||||
|
insertAt === dragFromIndex + 1
|
||||||
|
) {
|
||||||
|
setDragOverIndex(null)
|
||||||
|
} else {
|
||||||
|
setDragOverIndex(insertAt)
|
||||||
|
}
|
||||||
|
}, [dragFromIndex])
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (dragFromIndex === null) return
|
||||||
|
// Calculate actual toIndex based on mouse position
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const midX = rect.left + rect.width / 2
|
||||||
|
let toIndex = e.clientX < midX ? index : index + 1
|
||||||
|
// Adjust for the removal of the dragged item shifting indices
|
||||||
|
if (toIndex > dragFromIndex) {
|
||||||
|
toIndex -= 1
|
||||||
|
}
|
||||||
|
if (dragFromIndex !== toIndex) {
|
||||||
|
moveTab(dragFromIndex, toIndex)
|
||||||
|
}
|
||||||
|
setDragFromIndex(null)
|
||||||
|
setDragOverIndex(null)
|
||||||
|
}, [dragFromIndex, moveTab])
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDragFromIndex(null)
|
||||||
|
setDragOverIndex(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const currentTab = menuState.tabId ? tabs.find(t => t.id === menuState.tabId) : null
|
const currentTab = menuState.tabId ? tabs.find(t => t.id === menuState.tabId) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center bg-dark-900 border-b border-dark-700 overflow-x-auto">
|
<div className="flex items-center bg-dark-900 border-b border-dark-700 overflow-x-auto">
|
||||||
{/* Tab List */}
|
{/* Tab List */}
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab, index) => (
|
||||||
<TabItem
|
<TabItem
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
|
index={index}
|
||||||
isActive={tab.id === activeTabId}
|
isActive={tab.id === activeTabId}
|
||||||
|
isDragging={dragFromIndex === index}
|
||||||
|
dragOverIndex={dragOverIndex}
|
||||||
onActivate={() => activateTab(tab.id)}
|
onActivate={() => activateTab(tab.id)}
|
||||||
onClose={(e) => {
|
onClose={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleCloseTab(tab.id)
|
handleCloseTab(tab.id)
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
+69
-495
@@ -1,15 +1,18 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef } 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 { 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 } from '../stores/tabStore'
|
||||||
import { getSessionConfig, getConnectionStore, type ConnectionState } from '../stores/connection'
|
import { getConnectionStore } from '../stores/connection'
|
||||||
import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore'
|
import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore'
|
||||||
import { createLogger } from '../utils/logger'
|
import { createLogger } from '../utils/logger'
|
||||||
|
import { useTerminalConnection } from '../hooks/useTerminalConnection'
|
||||||
|
import { useTerminalSearch } from '../hooks/useTerminalSearch'
|
||||||
|
import TerminalOverlay from './terminal/TerminalOverlay'
|
||||||
|
import TerminalSearchBar from './terminal/TerminalSearchBar'
|
||||||
import type { TerminalViewProps } from './terminal/types'
|
import type { TerminalViewProps } from './terminal/types'
|
||||||
|
|
||||||
const logger = createLogger('TerminalView')
|
const logger = createLogger('TerminalView')
|
||||||
@@ -19,121 +22,59 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
|||||||
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 searchAddonRef = useRef<SearchAddon | null>(null)
|
||||||
const shellIdRef = useRef<string | null>(null)
|
|
||||||
const connectionIdRef = useRef<string | null>(null)
|
|
||||||
const hasConnectedRef = useRef(false)
|
|
||||||
const isConnectingRef = useRef(false)
|
|
||||||
const handleShellDataRef = useRef<((_event: unknown, _connId: string, shellId: string, data: Buffer) => void) | null>(null)
|
const handleShellDataRef = useRef<((_event: unknown, _connId: string, shellId: string, data: Buffer) => void) | null>(null)
|
||||||
const listenerRegisteredRef = useRef(false)
|
const listenerRegisteredRef = useRef(false)
|
||||||
|
|
||||||
// 本地状态
|
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
|
||||||
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 { settings, loadSettings } = useSettingsStore()
|
||||||
const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore()
|
const { getTabById } = useTabStore()
|
||||||
const snapshotStore = useTerminalSnapshotStore()
|
const snapshotStore = useTerminalSnapshotStore()
|
||||||
|
|
||||||
const currentTheme = themes[settings.terminalTheme] || themes['dracula']
|
const currentTheme = themes[settings.terminalTheme] || themes['dracula']
|
||||||
|
|
||||||
|
// 连接管理 hook
|
||||||
|
const {
|
||||||
|
connectionState,
|
||||||
|
statusMessage,
|
||||||
|
showSuspendedOverlay,
|
||||||
|
connectionIdRef,
|
||||||
|
shellIdRef,
|
||||||
|
hasConnectedRef,
|
||||||
|
isConnectingRef,
|
||||||
|
connect,
|
||||||
|
resumeConnection,
|
||||||
|
handleReconnect,
|
||||||
|
saveSnapshot,
|
||||||
|
restoreSnapshot,
|
||||||
|
setShowSuspendedOverlay,
|
||||||
|
} = useTerminalConnection({
|
||||||
|
tabId,
|
||||||
|
xtermRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索功能 hook
|
||||||
|
const {
|
||||||
|
showSearch,
|
||||||
|
searchQuery,
|
||||||
|
searchResultCount,
|
||||||
|
searchResultIndex,
|
||||||
|
searchInputRef,
|
||||||
|
handleSearchChange,
|
||||||
|
handleSearchNext,
|
||||||
|
handleSearchPrevious,
|
||||||
|
handleCloseSearch,
|
||||||
|
} = useTerminalSearch({
|
||||||
|
xtermRef,
|
||||||
|
searchAddonRef,
|
||||||
|
isActive,
|
||||||
|
})
|
||||||
|
|
||||||
// 加载设置
|
// 加载设置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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
|
|
||||||
// 因为在严格模式下,组件会 unmount/remount,xtermRef 可能还存在
|
|
||||||
// 但监听器需要重新注册
|
|
||||||
if (!terminalRef.current) {
|
if (!terminalRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -207,36 +148,28 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
|||||||
|
|
||||||
// IP 地址高亮处理 - 将包含 "from" 或 "Last login" 行中的 IP 地址标记为绿色
|
// IP 地址高亮处理 - 将包含 "from" 或 "Last login" 行中的 IP 地址标记为绿色
|
||||||
const highlightIPAddresses = (text: string): string => {
|
const highlightIPAddresses = (text: string): string => {
|
||||||
// 匹配 IPv4 地址格式 (xxx.xxx.xxx.xxx)
|
|
||||||
const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g
|
const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g
|
||||||
// 如果包含 Last login 或 from IP,将 IP 地址替换为绿色
|
|
||||||
if (text.includes('Last login') || text.includes(' from ')) {
|
if (text.includes('Last login') || text.includes(' from ')) {
|
||||||
return text.replace(ipRegex, '\x1b[32m$1\x1b[0m')
|
return text.replace(ipRegex, '\x1b[32m$1\x1b[0m')
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shell 数据处理器 - 直接处理,不依赖外部 ref
|
// Shell 数据处理器
|
||||||
// 使用 ref 存储 handler,避免闭包问题
|
|
||||||
handleShellDataRef.current = (_event: unknown, _connId: string, shellId: string, data: Buffer) => {
|
handleShellDataRef.current = (_event: unknown, _connId: string, shellId: string, data: Buffer) => {
|
||||||
const text = data.toString('utf-8')
|
const text = data.toString('utf-8')
|
||||||
|
|
||||||
// 使用 ref 获取最新的 shellId,避免闭包问题
|
|
||||||
if (shellId === shellIdRef.current && xtermRef.current) {
|
if (shellId === shellIdRef.current && xtermRef.current) {
|
||||||
// 处理数据,高亮 IP 地址(Last login 或 from IP 行)
|
|
||||||
let processedData = data
|
let processedData = data
|
||||||
|
|
||||||
// 如果包含 Last login 或 from IP,处理 IP 地址高亮
|
|
||||||
if (text.includes('Last login') || text.includes(' from ')) {
|
if (text.includes('Last login') || text.includes(' from ')) {
|
||||||
logger.debug('[Terminal] Found IP address line, highlighting IP')
|
logger.debug('[Terminal] Found IP address line, highlighting IP')
|
||||||
const highlighted = highlightIPAddresses(text)
|
const highlighted = highlightIPAddresses(text)
|
||||||
processedData = Buffer.from(highlighted, 'utf-8')
|
processedData = Buffer.from(highlighted, 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入 xterm
|
|
||||||
xtermRef.current.write(processedData)
|
xtermRef.current.write(processedData)
|
||||||
|
|
||||||
// 更新传输统计
|
|
||||||
const connId = connectionIdRef.current
|
const connId = connectionIdRef.current
|
||||||
if (connId) {
|
if (connId) {
|
||||||
const conn = getConnectionStore().getConnection(connId)
|
const conn = getConnectionStore().getConnection(connId)
|
||||||
@@ -258,254 +191,20 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
// 不在这里移除监听器,避免严格模式下的问题
|
|
||||||
// 监听器会在组件真正卸载时通过 removeAllListeners 清理
|
|
||||||
|
|
||||||
// 注意:不要在严格模式下 dispose terminal
|
|
||||||
}
|
}
|
||||||
}, []) // 只在挂载时运行
|
}, []) // 只在挂载时运行
|
||||||
|
|
||||||
// 保存快照
|
|
||||||
const saveSnapshot = useCallback(() => {
|
|
||||||
if (!xtermRef.current || !connectionIdRef.current) return
|
|
||||||
|
|
||||||
useTerminalSnapshotStore.getState().createSnapshot(tabId, xtermRef.current, {
|
|
||||||
connectionId: connectionIdRef.current,
|
|
||||||
shellId: shellIdRef.current,
|
|
||||||
})
|
|
||||||
}, [tabId])
|
|
||||||
|
|
||||||
// 恢复快照
|
|
||||||
const restoreSnapshot = useCallback(() => {
|
|
||||||
if (!xtermRef.current) return false
|
|
||||||
|
|
||||||
const snapshot = useTerminalSnapshotStore.getState().getSnapshot(tabId)
|
|
||||||
if (!snapshot) return false
|
|
||||||
|
|
||||||
// 清屏
|
|
||||||
xtermRef.current.clear()
|
|
||||||
|
|
||||||
// 恢复内容
|
|
||||||
const content = snapshot.buffer.join('\r\n')
|
|
||||||
if (content) {
|
|
||||||
xtermRef.current.write(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复光标
|
|
||||||
xtermRef.current.write(`\x1b[${snapshot.cursorY + 1};${snapshot.cursorX + 1}H`)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}, [tabId])
|
|
||||||
|
|
||||||
// 连接管理
|
|
||||||
const connect = useCallback(async () => {
|
|
||||||
if (isConnectingRef.current) {
|
|
||||||
logger.debug('[Terminal] Connection already in progress, skipping')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isConnectingRef.current = true
|
|
||||||
|
|
||||||
// 直接使用 getTabById,避免 useCallback 循环问题
|
|
||||||
const currentTab = getTabById(tabId)
|
|
||||||
if (!currentTab) {
|
|
||||||
logger.debug('[Terminal] Tab not found:', tabId)
|
|
||||||
isConnectingRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('[Terminal] Starting connection for tab:', tabId, 'sessionId:', currentTab.sessionId)
|
|
||||||
|
|
||||||
// 检查是否已有连接
|
|
||||||
if (connectionIdRef.current) {
|
|
||||||
const conn = getConnectionStore().getConnection(connectionIdRef.current)
|
|
||||||
if (conn) {
|
|
||||||
if (conn.state === 'connected' || conn.state === 'background') {
|
|
||||||
// 已有活跃连接,恢复显示
|
|
||||||
shellIdRef.current = currentTab.connectionInstance?.shellId || null
|
|
||||||
setConnectionState(conn.state)
|
|
||||||
updateTabStatus(tabId, conn.state === 'connected' ? 'connected' : 'disconnected')
|
|
||||||
isConnectingRef.current = false
|
|
||||||
return
|
|
||||||
} else if (conn.state === 'suspended') {
|
|
||||||
// 连接被挂起,需要恢复
|
|
||||||
setShowSuspendedOverlay(true)
|
|
||||||
setConnectionState('suspended')
|
|
||||||
updateTabStatus(tabId, 'disconnected')
|
|
||||||
isConnectingRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始连接
|
|
||||||
setConnectionState('connecting')
|
|
||||||
updateTabStatus(tabId, 'connecting')
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 获取配置
|
|
||||||
let config: {
|
|
||||||
host: string
|
|
||||||
port: number
|
|
||||||
username: string
|
|
||||||
password?: string
|
|
||||||
privateKey?: string
|
|
||||||
passphrase?: string
|
|
||||||
} | null = null
|
|
||||||
|
|
||||||
if (currentTab.connectionConfig) {
|
|
||||||
config = {
|
|
||||||
host: currentTab.connectionConfig.host,
|
|
||||||
port: currentTab.connectionConfig.port || 22,
|
|
||||||
username: currentTab.connectionConfig.username,
|
|
||||||
password: currentTab.connectionConfig.password,
|
|
||||||
privateKey: currentTab.connectionConfig.privateKey,
|
|
||||||
passphrase: currentTab.connectionConfig.passphrase,
|
|
||||||
}
|
|
||||||
} else if (currentTab.sessionId) {
|
|
||||||
config = await getSessionConfig(currentTab.sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
throw new Error('无可用连接信息')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建连接
|
|
||||||
const result = await getConnectionStore().createConnection(tabId, config)
|
|
||||||
|
|
||||||
if (!result.success || !result.connectionId) {
|
|
||||||
throw new Error(result.error || '连接失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectionId = result.connectionId
|
|
||||||
connectionIdRef.current = connectionId
|
|
||||||
|
|
||||||
// 创建 Shell - 先生成前端 shellId,确保数据不会丢失
|
|
||||||
const pendingShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
||||||
logger.debug('[Terminal] Pre-assigning shellId:', pendingShellId)
|
|
||||||
shellIdRef.current = pendingShellId
|
|
||||||
|
|
||||||
logger.debug('[Terminal] Creating shell for connection:', connectionId, 'with shellId:', pendingShellId)
|
|
||||||
const shellResult = await window.electronAPI?.sshCreateShell(connectionId, {
|
|
||||||
cols: xtermRef.current?.cols || 80,
|
|
||||||
rows: xtermRef.current?.rows || 24,
|
|
||||||
}, pendingShellId)
|
|
||||||
|
|
||||||
if (!shellResult?.success || !shellResult.shellId) {
|
|
||||||
logger.error('[Terminal] Shell creation failed:', shellResult?.error)
|
|
||||||
shellIdRef.current = null
|
|
||||||
await getConnectionStore().disconnectConnection(connectionId, 'shell creation failed')
|
|
||||||
throw new Error(shellResult?.error || '创建终端失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
// shellId 应该和 pendingShellId 相同(由后端确认)
|
|
||||||
const confirmedShellId = shellResult.shellId
|
|
||||||
logger.debug('[Terminal] Shell confirmed with ID:', confirmedShellId)
|
|
||||||
// shellIdRef 已经是 confirmedShellId(等于 pendingShellId)
|
|
||||||
getConnectionStore().addShell(connectionId, confirmedShellId)
|
|
||||||
|
|
||||||
// 更新标签
|
|
||||||
const connectionInstance: ConnectionInstance = {
|
|
||||||
connectionId,
|
|
||||||
shellId: confirmedShellId,
|
|
||||||
}
|
|
||||||
updateTabConnectionInstance(tabId, connectionInstance)
|
|
||||||
|
|
||||||
// 更新状态
|
|
||||||
setConnectionState('connected')
|
|
||||||
updateTabStatus(tabId, 'connected')
|
|
||||||
getConnectionStore().setActiveTab(connectionId, tabId)
|
|
||||||
|
|
||||||
// 更新标题
|
|
||||||
if (currentTab.title === '新终端' || currentTab.title === 'Terminal') {
|
|
||||||
updateTab(tabId, {
|
|
||||||
title: `${config.username}@${config.host}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 等待服务器返回真实的 Last login 信息和提示符
|
|
||||||
// 不手动写入任何内容,让 SSH 服务器的自然输出显示
|
|
||||||
|
|
||||||
xtermRef.current?.focus()
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
setConnectionState('error')
|
|
||||||
updateTabStatus(tabId, 'error')
|
|
||||||
setStatusMessage(`错误: ${error.message}`)
|
|
||||||
xtermRef.current?.writeln(`\r\n\x1b[31m错误: ${error.message}\x1b[0m`)
|
|
||||||
} finally {
|
|
||||||
isConnectingRef.current = false
|
|
||||||
hasConnectedRef.current = true
|
|
||||||
}
|
|
||||||
}, [tabId, updateTabConnectionInstance, updateTabStatus, getTabById])
|
|
||||||
|
|
||||||
// 注意:不再使用模拟的 Last login 信息
|
|
||||||
// 真实的 SSH 服务器会自动发送欢迎信息和提示符
|
|
||||||
|
|
||||||
// 恢复挂起的连接
|
|
||||||
const resumeConnection = useCallback(async () => {
|
|
||||||
if (!connectionIdRef.current) return
|
|
||||||
|
|
||||||
setConnectionState('reconnecting')
|
|
||||||
const resumed = await getConnectionStore().resumeConnection(connectionIdRef.current)
|
|
||||||
|
|
||||||
if (resumed) {
|
|
||||||
// 获取新的连接 ID
|
|
||||||
const currentTab = getTabById(tabId)
|
|
||||||
if (currentTab?.connectionInstance?.connectionId) {
|
|
||||||
connectionIdRef.current = currentTab.connectionInstance.connectionId
|
|
||||||
|
|
||||||
// 创建新的 shell - 预分配 shellId 防止数据丢失
|
|
||||||
const newShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
||||||
shellIdRef.current = newShellId
|
|
||||||
|
|
||||||
const shellResult = await window.electronAPI?.sshCreateShell(connectionIdRef.current, {
|
|
||||||
cols: xtermRef.current?.cols || 80,
|
|
||||||
rows: xtermRef.current?.rows || 24,
|
|
||||||
}, newShellId)
|
|
||||||
|
|
||||||
if (shellResult?.success && shellResult.shellId) {
|
|
||||||
// shellId 应该和 newShellId 相同
|
|
||||||
getConnectionStore().addShell(connectionIdRef.current, shellResult.shellId)
|
|
||||||
|
|
||||||
updateTabConnectionInstance(tabId, {
|
|
||||||
connectionId: connectionIdRef.current,
|
|
||||||
shellId: shellResult.shellId,
|
|
||||||
})
|
|
||||||
|
|
||||||
setConnectionState('connected')
|
|
||||||
updateTabStatus(tabId, 'connected')
|
|
||||||
setShowSuspendedOverlay(false)
|
|
||||||
|
|
||||||
// 恢复连接成功,等待服务器发送提示符
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复失败,重新连接
|
|
||||||
setConnectionState('disconnected')
|
|
||||||
hasConnectedRef.current = false
|
|
||||||
setShowSuspendedOverlay(false)
|
|
||||||
connect()
|
|
||||||
}, [connect, updateTabConnectionInstance, updateTabStatus, getTabById, tabId])
|
|
||||||
|
|
||||||
// 初始连接
|
// 初始连接
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasConnectedRef.current && isActive) {
|
if (!hasConnectedRef.current && isActive) {
|
||||||
// 检查标签是否从持久化恢复(需要手动重新连接)
|
|
||||||
const currentTab = getTabById(tabId)
|
const currentTab = getTabById(tabId)
|
||||||
if (currentTab?.needsReconnect) {
|
if (currentTab?.needsReconnect) {
|
||||||
// 从持久化恢复的标签,不自动连接,显示「重新连接」界面
|
|
||||||
logger.info('[Terminal] Tab restored from session, waiting for manual reconnect')
|
logger.info('[Terminal] Tab restored from session, waiting for manual reconnect')
|
||||||
setConnectionState('disconnected')
|
|
||||||
setStatusMessage('上次会话已恢复,点击重新连接')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有快照
|
|
||||||
const hasSnapshot = snapshotStore.hasSnapshot(tabId)
|
const hasSnapshot = snapshotStore.hasSnapshot(tabId)
|
||||||
if (hasSnapshot && isActive) {
|
if (hasSnapshot && isActive) {
|
||||||
// 先恢复快照
|
|
||||||
restoreSnapshot()
|
restoreSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,58 +219,34 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
|||||||
const pool = getConnectionStore()
|
const pool = getConnectionStore()
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
// 标签变为活跃
|
|
||||||
if (connectionIdRef.current) {
|
if (connectionIdRef.current) {
|
||||||
const conn = pool.getConnection(connectionIdRef.current)
|
const conn = pool.getConnection(connectionIdRef.current)
|
||||||
if (conn?.state === 'suspended') {
|
if (conn?.state === 'suspended') {
|
||||||
setShowSuspendedOverlay(true)
|
setShowSuspendedOverlay(true)
|
||||||
setConnectionState('suspended')
|
|
||||||
} else {
|
} else {
|
||||||
pool.setActiveTab(connectionIdRef.current, tabId)
|
pool.setActiveTab(connectionIdRef.current, tabId)
|
||||||
// 同步本地状态
|
|
||||||
setConnectionState(conn?.state === 'connected' || conn?.state === 'background' ? conn.state : 'connected')
|
|
||||||
// 不再恢复快照 - shell 是实时输出的,重复写入会导致内容重复
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xtermRef.current?.focus()
|
xtermRef.current?.focus()
|
||||||
} else {
|
} else {
|
||||||
// 标签进入后台 - 保存快照用于恢复
|
|
||||||
if (connectionIdRef.current) {
|
if (connectionIdRef.current) {
|
||||||
pool.setBackgroundTab(connectionIdRef.current, tabId)
|
pool.setBackgroundTab(connectionIdRef.current, tabId)
|
||||||
saveSnapshot()
|
saveSnapshot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isActive, tabId, saveSnapshot])
|
}, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay])
|
||||||
|
|
||||||
// 重连功能
|
|
||||||
const handleReconnect = useCallback(() => {
|
|
||||||
if (connectionState === 'error' || connectionState === 'disconnected' || connectionState === 'suspended') {
|
|
||||||
if (connectionIdRef.current) {
|
|
||||||
getConnectionStore().disconnectConnection(connectionIdRef.current, 'manual reconnect')
|
|
||||||
}
|
|
||||||
connectionIdRef.current = null
|
|
||||||
shellIdRef.current = null
|
|
||||||
hasConnectedRef.current = false
|
|
||||||
setConnectionState('disconnected')
|
|
||||||
setShowSuspendedOverlay(false)
|
|
||||||
// 清除 needsReconnect 标志
|
|
||||||
updateTab(tabId, { needsReconnect: false })
|
|
||||||
xtermRef.current?.clear()
|
|
||||||
connect()
|
|
||||||
}
|
|
||||||
}, [connectionState, connect, updateTab, tabId])
|
|
||||||
|
|
||||||
// 组件卸载时清理监听器
|
// 组件卸载时清理监听器
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
// 重置连接状态,防止严格模式下 remount 时状态混乱
|
|
||||||
isConnectingRef.current = false
|
isConnectingRef.current = false
|
||||||
listenerRegisteredRef.current = false // 重置监听器注册标记
|
listenerRegisteredRef.current = false
|
||||||
// 组件真正卸载时移除所有监听器
|
|
||||||
window.electronAPI?.removeAllListeners('ssh:shellData')
|
window.electronAPI?.removeAllListeners('ssh:shellData')
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const currentTab = getTabById(tabId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-dark-900">
|
<div className="h-full flex flex-col bg-dark-900">
|
||||||
{/* 终端容器 */}
|
{/* 终端容器 */}
|
||||||
@@ -581,130 +256,29 @@ 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 && (
|
{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">
|
<TerminalSearchBar
|
||||||
<Search size={14} className="text-gray-400 shrink-0" />
|
searchQuery={searchQuery}
|
||||||
<input
|
searchResultCount={searchResultCount}
|
||||||
ref={searchInputRef}
|
searchResultIndex={searchResultIndex}
|
||||||
type="text"
|
searchInputRef={searchInputRef}
|
||||||
value={searchQuery}
|
onSearchChange={handleSearchChange}
|
||||||
onChange={handleSearchChange}
|
onSearchNext={handleSearchNext}
|
||||||
onKeyDown={(e) => {
|
onSearchPrevious={handleSearchPrevious}
|
||||||
if (e.key === 'Enter') {
|
onClose={handleCloseSearch}
|
||||||
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 && (
|
<TerminalOverlay
|
||||||
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
|
connectionState={connectionState}
|
||||||
<div className="text-center">
|
statusMessage={statusMessage}
|
||||||
<div className="w-16 h-16 bg-accent-blue/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
showSuspendedOverlay={showSuspendedOverlay}
|
||||||
<TerminalIcon className="w-8 h-8 text-accent-blue" />
|
needsReconnect={!!currentTab?.needsReconnect}
|
||||||
</div>
|
onReconnect={handleReconnect}
|
||||||
<div className="text-gray-200 mb-2 text-lg">会话已恢复</div>
|
onResume={resumeConnection}
|
||||||
<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">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-yellow-400 mb-2 text-lg">连接已挂起</div>
|
|
||||||
<div className="text-gray-400 text-sm mb-4">
|
|
||||||
长时间未操作,连接已进入挂起状态
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={resumeConnection}
|
|
||||||
className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors mr-2"
|
|
||||||
>
|
|
||||||
恢复连接
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleReconnect}
|
|
||||||
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-gray-200 rounded transition-colors"
|
|
||||||
>
|
|
||||||
重新连接
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 错误状态覆盖层 */}
|
|
||||||
{connectionState === 'error' && (
|
|
||||||
<div className="absolute inset-0 bg-dark-900/80 flex items-center justify-center z-10">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-red-400 mb-2">连接失败</div>
|
|
||||||
<div className="text-gray-400 text-sm mb-4">{statusMessage}</div>
|
|
||||||
<button
|
|
||||||
onClick={handleReconnect}
|
|
||||||
className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors"
|
|
||||||
>
|
|
||||||
重新连接
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 连接中状态 */}
|
|
||||||
{connectionState === 'connecting' && (
|
|
||||||
<div className="absolute inset-0 bg-dark-900/50 flex items-center justify-center pointer-events-none z-10">
|
|
||||||
<div className="text-accent-blue animate-pulse">连接中...</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 连接状态指示器 - 简洁版 */}
|
|
||||||
<div className="absolute top-3 right-3 flex items-center gap-2 z-20">
|
|
||||||
{/* 状态指示已移至 TabBar */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap, Inbox } from 'lucide-react'
|
import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap, Clock } from 'lucide-react'
|
||||||
import { useSessionStore } from '../stores'
|
import { useSessionStore } from '../stores'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
|
import EmptyState from './common/EmptyState'
|
||||||
|
|
||||||
interface WelcomeViewProps {
|
interface WelcomeViewProps {
|
||||||
onNewSession?: () => void
|
onNewSession?: () => void
|
||||||
@@ -30,7 +31,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl mb-12">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 w-full max-w-2xl mb-12">
|
||||||
<button
|
<button
|
||||||
onClick={onQuickConnect}
|
onClick={onQuickConnect}
|
||||||
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-blue transition-all group"
|
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-blue transition-all group"
|
||||||
@@ -101,11 +102,11 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
|||||||
<div className="w-full max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<h3 className="text-sm font-medium text-gray-400 mb-4">最近连接</h3>
|
<h3 className="text-sm font-medium text-gray-400 mb-4">最近连接</h3>
|
||||||
{recentSessions.length === 0 ? (
|
{recentSessions.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-10 text-gray-500">
|
<EmptyState
|
||||||
<Inbox className="w-10 h-10 mb-3 text-gray-600" />
|
icon={Clock}
|
||||||
<p className="text-sm">暂无会话记录</p>
|
title="暂无最近连接"
|
||||||
<p className="text-xs mt-1">点击上方「新建会话」创建第一个连接</p>
|
description="开始你的第一次连接吧"
|
||||||
</div>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{recentSessions.map((session) => (
|
{recentSessions.map((session) => (
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* EmptyState - 通用空状态组件
|
||||||
|
*
|
||||||
|
* 居中布局,大图标 + 标题 + 描述 + 可选操作按钮
|
||||||
|
* 用于各组件的空状态引导
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type LucideIcon } from 'lucide-react'
|
||||||
|
import Button from './Button'
|
||||||
|
|
||||||
|
interface EmptyStateAction {
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
/** Lucide 图标组件 */
|
||||||
|
icon: LucideIcon
|
||||||
|
/** 标题文字 */
|
||||||
|
title: string
|
||||||
|
/** 描述文字 */
|
||||||
|
description: string
|
||||||
|
/** 可选操作按钮 */
|
||||||
|
action?: EmptyStateAction
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyState({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 px-4">
|
||||||
|
{/* 图标容器 */}
|
||||||
|
<div className="w-16 h-16 rounded-full bg-dark-700 flex items-center justify-center mb-4">
|
||||||
|
<Icon className="w-8 h-8 text-accent-blue" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标题 */}
|
||||||
|
<h3 className="text-dark-300 font-medium mb-1">{title}</h3>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<p className="text-dark-500 text-sm mb-4 text-center max-w-xs">{description}</p>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
{action && (
|
||||||
|
<Button variant="primary" size="sm" onClick={action.onClick}>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -84,14 +84,26 @@ export function ButtonLoading({ text = '处理中...' }: { text?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 骨架屏组件
|
* 骨架屏组件 (向后兼容)
|
||||||
|
*
|
||||||
|
* 新代码请使用 @/components/common/Skeleton 中的增强版本
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* <Skeleton className="h-4 w-32" />
|
* <Skeleton className="h-4 w-32" />
|
||||||
* <Skeleton className="h-20 w-full" />
|
|
||||||
*/
|
*/
|
||||||
export function Skeleton({ className = '' }: { className?: string }) {
|
export function Skeleton({ className = '' }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={`bg-dark-700 animate-pulse rounded ${className}`} />
|
<div className={`bg-dark-700 animate-pulse rounded ${className}`} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重新导出增强版骨架屏组件,方便统一引用
|
||||||
|
export {
|
||||||
|
Skeleton as SkeletonBase,
|
||||||
|
SkeletonLine,
|
||||||
|
SkeletonCircle,
|
||||||
|
SkeletonBlock,
|
||||||
|
FileListSkeleton,
|
||||||
|
SessionTreeSkeleton,
|
||||||
|
TabContentSkeleton,
|
||||||
|
} from './Skeleton'
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* Skeleton - 骨架屏加载态组件
|
||||||
|
*
|
||||||
|
* 提供通用骨架屏基础组件和专用骨架屏
|
||||||
|
* - 基础变体: SkeletonLine, SkeletonCircle, SkeletonBlock
|
||||||
|
* - 专用组件: FileListSkeleton, SessionTreeSkeleton, TabContentSkeleton
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── 工具函数 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并类名
|
||||||
|
*/
|
||||||
|
function mergeClass(...classes: (string | undefined | false)[]): string {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 基础 Skeleton Props ─────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SkeletonBaseProps {
|
||||||
|
/** 自定义宽度 */
|
||||||
|
width?: string | number
|
||||||
|
/** 自定义高度 */
|
||||||
|
height?: string | number
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Skeleton (基础骨架块) ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础骨架屏组件 — 圆角矩形 + 脉冲动画
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <Skeleton width="100%" height={20} />
|
||||||
|
* <Skeleton className="h-4 w-32" />
|
||||||
|
*/
|
||||||
|
export function Skeleton({ width, height, className }: SkeletonBaseProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={mergeClass('bg-dark-600/60 animate-pulse rounded', className)}
|
||||||
|
style={{
|
||||||
|
width: typeof width === 'number' ? `${width}px` : width,
|
||||||
|
height: typeof height === 'number' ? `${height}px` : height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SkeletonLine (行骨架) ─────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SkeletonLineProps extends SkeletonBaseProps {
|
||||||
|
/** 行数,默认 1 */
|
||||||
|
lines?: number
|
||||||
|
/** 行间距 */
|
||||||
|
gap?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行骨架 — 模拟文本行
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <SkeletonLine lines={3} width="80%" height={14} />
|
||||||
|
*/
|
||||||
|
export function SkeletonLine({ lines = 1, gap = 8, width, height = 14, className }: SkeletonLineProps) {
|
||||||
|
if (lines <= 1) {
|
||||||
|
return <Skeleton width={width} height={height} className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col" style={{ gap }}>
|
||||||
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
width={i === lines - 1 ? '60%' : width || '100%'}
|
||||||
|
height={height}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SkeletonCircle (圆形骨架) ─────────────────────────────────
|
||||||
|
|
||||||
|
export interface SkeletonCircleProps extends SkeletonBaseProps {
|
||||||
|
/** 圆形尺寸(宽高相同),默认 40 */
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 圆形骨架 — 模拟头像/图标
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <SkeletonCircle size={40} />
|
||||||
|
*/
|
||||||
|
export function SkeletonCircle({ size = 40, className }: SkeletonCircleProps) {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className={mergeClass('rounded-full', className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SkeletonBlock (卡片/区块骨架) ─────────────────────────────
|
||||||
|
|
||||||
|
export interface SkeletonBlockProps extends SkeletonBaseProps {
|
||||||
|
/** 圆角大小 */
|
||||||
|
rounded?: 'sm' | 'md' | 'lg'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 区块骨架 — 模拟卡片/面板
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <SkeletonBlock width="100%" height={120} />
|
||||||
|
*/
|
||||||
|
export function SkeletonBlock({ rounded = 'lg', className, ...rest }: SkeletonBlockProps) {
|
||||||
|
const roundedMap = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg' }
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
className={mergeClass(roundedMap[rounded], className)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FileListSkeleton (文件列表骨架屏) ─────────────────────────
|
||||||
|
|
||||||
|
export interface FileListSkeletonProps {
|
||||||
|
/** 模拟行数,默认 6 */
|
||||||
|
rows?: number
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件列表骨架屏
|
||||||
|
* 模拟文件列表 (图标 + 文件名 + 大小 + 日期)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <FileListSkeleton rows={6} />
|
||||||
|
*/
|
||||||
|
export function FileListSkeleton({ rows = 6, className }: FileListSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div className={mergeClass('flex flex-col', className)}>
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-3 px-4 py-2 border-b border-dark-700/50"
|
||||||
|
>
|
||||||
|
{/* 图标占位 */}
|
||||||
|
<Skeleton width={16} height={16} className="rounded-sm flex-shrink-0" />
|
||||||
|
|
||||||
|
{/* 文件名 — 宽度随机模拟不同文件名长度 */}
|
||||||
|
<Skeleton
|
||||||
|
height={13}
|
||||||
|
width={`${40 + ((i * 17) % 30)}%`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 弹性间隔 */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* 大小占位 */}
|
||||||
|
<Skeleton width={48} height={12} className="flex-shrink-0" />
|
||||||
|
|
||||||
|
{/* 日期占位 */}
|
||||||
|
<Skeleton width={80} height={12} className="flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SessionTreeSkeleton (会话树骨架屏) ────────────────────────
|
||||||
|
|
||||||
|
export interface SessionTreeSkeletonProps {
|
||||||
|
/** 分组数量,默认 2 */
|
||||||
|
groups?: number
|
||||||
|
/** 每组会话数量(范围 [min, max]),默认 [3, 4] */
|
||||||
|
sessionsPerGroup?: [number, number]
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话树骨架屏
|
||||||
|
* 模拟分组头 + 会话项 (2 组, 每组 3-4 项)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <SessionTreeSkeleton />
|
||||||
|
*/
|
||||||
|
export function SessionTreeSkeleton({
|
||||||
|
groups = 2,
|
||||||
|
sessionsPerGroup = [3, 4],
|
||||||
|
className,
|
||||||
|
}: SessionTreeSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div className={mergeClass('flex flex-col', className)}>
|
||||||
|
{Array.from({ length: groups }).map((_, gIdx) => {
|
||||||
|
const count = sessionsPerGroup[0] + (gIdx % (sessionsPerGroup[1] - sessionsPerGroup[0] + 1))
|
||||||
|
return (
|
||||||
|
<div key={gIdx} className="border-b border-dark-700 last:border-b-0">
|
||||||
|
{/* 分组头 */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2">
|
||||||
|
<Skeleton width={14} height={14} className="rounded-sm" />
|
||||||
|
<Skeleton width={14} height={14} className="rounded-sm" />
|
||||||
|
<Skeleton width={72} height={14} />
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Skeleton width={18} height={12} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 会话项 */}
|
||||||
|
<div className="pl-10">
|
||||||
|
{Array.from({ length: count }).map((_, sIdx) => (
|
||||||
|
<div
|
||||||
|
key={sIdx}
|
||||||
|
className="flex items-center gap-2 px-4 py-2"
|
||||||
|
>
|
||||||
|
{/* 服务器图标占位 */}
|
||||||
|
<Skeleton width={16} height={16} className="rounded-sm flex-shrink-0" />
|
||||||
|
{/* 名称 + host */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||||
|
<Skeleton
|
||||||
|
height={13}
|
||||||
|
width={`${50 + ((sIdx * 13) % 30)}%`}
|
||||||
|
/>
|
||||||
|
<Skeleton height={11} width={`${40 + ((sIdx * 11) % 25)}%`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TabContentSkeleton (终端内容骨架屏) ────────────────────────
|
||||||
|
|
||||||
|
export interface TabContentSkeletonProps {
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 终端内容骨架屏
|
||||||
|
* 黑色背景 + 模拟终端行 + 光标闪烁
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <TabContentSkeleton />
|
||||||
|
*/
|
||||||
|
export function TabContentSkeleton({ className }: TabContentSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={mergeClass(
|
||||||
|
'flex-1 bg-[#1a1b26] p-4 flex flex-col gap-1.5 font-mono text-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 模拟终端提示行 */}
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
{/* 提示符 */}
|
||||||
|
<span className="text-accent-green/40 select-none">$</span>
|
||||||
|
{/* 命令行占位 */}
|
||||||
|
{i < 3 ? (
|
||||||
|
<Skeleton
|
||||||
|
height={13}
|
||||||
|
width={`${30 + ((i * 19) % 50)}%`}
|
||||||
|
className="rounded-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Skeleton
|
||||||
|
height={13}
|
||||||
|
width={`${15 + ((i * 7) % 45)}%`}
|
||||||
|
className="rounded-sm opacity-50"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 闪烁光标行 */}
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-accent-green/40 select-none">$</span>
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-4 bg-gray-400 animate-pulse"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 默认导出 (兼容旧代码中 import Skeleton from ...) ──────────
|
||||||
|
|
||||||
|
export default Skeleton
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* ToastContainer - Toast 通知显示容器
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CheckCircle, Info, AlertTriangle, XCircle, X } from 'lucide-react'
|
||||||
|
import type { Toast, ToastType } from '../../hooks/useToast'
|
||||||
|
|
||||||
|
interface ToastContainerProps {
|
||||||
|
toasts: Toast[]
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap: Record<ToastType, React.ReactNode> = {
|
||||||
|
info: <Info className="w-4 h-4 text-blue-400" />,
|
||||||
|
success: <CheckCircle className="w-4 h-4 text-green-400" />,
|
||||||
|
error: <XCircle className="w-4 h-4 text-red-400" />,
|
||||||
|
warning: <AlertTriangle className="w-4 h-4 text-yellow-400" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgMap: Record<ToastType, string> = {
|
||||||
|
info: 'bg-blue-900/80 border-blue-700',
|
||||||
|
success: 'bg-green-900/80 border-green-700',
|
||||||
|
error: 'bg-red-900/80 border-red-700',
|
||||||
|
warning: 'bg-yellow-900/80 border-yellow-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
|
||||||
|
if (toasts.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`pointer-events-auto flex items-center gap-2 px-4 py-3 rounded-lg border shadow-lg backdrop-blur-sm text-sm text-white min-w-[280px] max-w-[400px] animate-fade-in ${bgMap[toast.type]}`}
|
||||||
|
>
|
||||||
|
{iconMap[toast.type]}
|
||||||
|
<span className="flex-1">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(toast.id)}
|
||||||
|
className="p-0.5 hover:bg-white/10 rounded"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5 text-gray-300" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+11
-1
@@ -21,6 +21,16 @@ export { default as WelcomeView } from './WelcomeView'
|
|||||||
|
|
||||||
// 通用组件
|
// 通用组件
|
||||||
export { default as ErrorBoundary } from './common/ErrorBoundary'
|
export { default as ErrorBoundary } from './common/ErrorBoundary'
|
||||||
export { default as Loading, ButtonLoading, Skeleton } from './common/Loading'
|
export { default as Loading, ButtonLoading } from './common/Loading'
|
||||||
|
export {
|
||||||
|
Skeleton,
|
||||||
|
SkeletonLine,
|
||||||
|
SkeletonCircle,
|
||||||
|
SkeletonBlock,
|
||||||
|
FileListSkeleton,
|
||||||
|
SessionTreeSkeleton,
|
||||||
|
TabContentSkeleton,
|
||||||
|
} from './common/Skeleton'
|
||||||
export { default as Button } from './common/Button'
|
export { default as Button } from './common/Button'
|
||||||
export { default as Modal } from './common/Modal'
|
export { default as Modal } from './common/Modal'
|
||||||
|
export { default as EmptyState } from './common/EmptyState'
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* ChmodDialog — 权限编辑对话框
|
||||||
|
*
|
||||||
|
* 使用复选框 r/w/x 三组权限来修改文件权限
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { Shield } from 'lucide-react'
|
||||||
|
import Modal from '../common/Modal'
|
||||||
|
|
||||||
|
interface ChmodDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
fileName: string
|
||||||
|
filePath: string
|
||||||
|
/** Initial mode as octal number (e.g. 0o755). If not provided, defaults to 0o644 */
|
||||||
|
initialMode?: number
|
||||||
|
/** Callback when user confirms */
|
||||||
|
onConfirm: (filePath: string, mode: number) => Promise<boolean> | boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermGroup {
|
||||||
|
read: boolean
|
||||||
|
write: boolean
|
||||||
|
execute: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeToPermGroups(mode: number): { owner: PermGroup; group: PermGroup; other: PermGroup } {
|
||||||
|
return {
|
||||||
|
owner: {
|
||||||
|
read: !!(mode & 0o400),
|
||||||
|
write: !!(mode & 0o200),
|
||||||
|
execute: !!(mode & 0o100),
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
read: !!(mode & 0o040),
|
||||||
|
write: !!(mode & 0o020),
|
||||||
|
execute: !!(mode & 0o010),
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
read: !!(mode & 0o004),
|
||||||
|
write: !!(mode & 0o002),
|
||||||
|
execute: !!(mode & 0o001),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function permGroupsToMode(groups: { owner: PermGroup; group: PermGroup; other: PermGroup }): number {
|
||||||
|
let mode = 0
|
||||||
|
if (groups.owner.read) mode |= 0o400
|
||||||
|
if (groups.owner.write) mode |= 0o200
|
||||||
|
if (groups.owner.execute) mode |= 0o100
|
||||||
|
if (groups.group.read) mode |= 0o040
|
||||||
|
if (groups.group.write) mode |= 0o020
|
||||||
|
if (groups.group.execute) mode |= 0o010
|
||||||
|
if (groups.other.read) mode |= 0o004
|
||||||
|
if (groups.other.write) mode |= 0o002
|
||||||
|
if (groups.other.execute) mode |= 0o001
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChmodDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
fileName,
|
||||||
|
filePath,
|
||||||
|
initialMode = 0o644,
|
||||||
|
onConfirm,
|
||||||
|
}: ChmodDialogProps) {
|
||||||
|
const [groups, setGroups] = useState(modeToPermGroups(initialMode))
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const currentMode = permGroupsToMode(groups)
|
||||||
|
const octalString = currentMode.toString(8).padStart(3, '0')
|
||||||
|
|
||||||
|
const togglePerm = useCallback((scope: 'owner' | 'group' | 'other', perm: 'read' | 'write' | 'execute') => {
|
||||||
|
setGroups((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[scope]: {
|
||||||
|
...prev[scope],
|
||||||
|
[perm]: !prev[scope][perm],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(async () => {
|
||||||
|
setSaving(true)
|
||||||
|
const ok = await onConfirm(filePath, currentMode)
|
||||||
|
setSaving(false)
|
||||||
|
if (ok) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}, [filePath, currentMode, onConfirm, onClose])
|
||||||
|
|
||||||
|
const PermCheckbox = ({
|
||||||
|
scope,
|
||||||
|
perm,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
scope: 'owner' | 'group' | 'other'
|
||||||
|
perm: 'read' | 'write' | 'execute'
|
||||||
|
label: string
|
||||||
|
}) => (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={groups[scope][perm]}
|
||||||
|
onChange={() => togglePerm(scope, perm)}
|
||||||
|
className="w-4 h-4 rounded border-dark-500 bg-dark-700 text-accent-blue focus:ring-accent-blue focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-300 text-sm">{label}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
|
||||||
|
const PermRow = ({
|
||||||
|
scope,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
scope: 'owner' | 'group' | 'other'
|
||||||
|
label: string
|
||||||
|
}) => (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-gray-400 w-16 text-sm">{label}</span>
|
||||||
|
<PermCheckbox scope={scope} perm="read" label="读取" />
|
||||||
|
<PermCheckbox scope={scope} perm="write" label="写入" />
|
||||||
|
<PermCheckbox scope={scope} perm="execute" label="执行" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
title="权限编辑"
|
||||||
|
onClose={onClose}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
confirmText="应用"
|
||||||
|
loading={saving}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 min-w-[360px]">
|
||||||
|
{/* File info */}
|
||||||
|
<div className="flex items-center gap-3 pb-3 border-b border-dark-600">
|
||||||
|
<Shield className="w-5 h-5 text-accent-blue" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-white font-medium truncate">{fileName}</p>
|
||||||
|
<p className="text-gray-500 text-xs font-mono truncate">{filePath}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current octal mode display */}
|
||||||
|
<div className="flex items-center justify-center gap-2 py-2">
|
||||||
|
<span className="text-gray-400 text-sm">当前权限:</span>
|
||||||
|
<span className="text-accent-blue font-mono font-bold text-lg">{octalString}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permission matrix */}
|
||||||
|
<div className="space-y-3 bg-dark-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="w-16" />
|
||||||
|
<span className="text-gray-500 text-xs w-16">读取 (r)</span>
|
||||||
|
<span className="text-gray-500 text-xs w-16">写入 (w)</span>
|
||||||
|
<span className="text-gray-500 text-xs w-16">执行 (x)</span>
|
||||||
|
</div>
|
||||||
|
<PermRow scope="owner" label="所有者" />
|
||||||
|
<PermRow scope="group" label="所属组" />
|
||||||
|
<PermRow scope="other" label="其他人" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export interface MenuItem {
|
|||||||
label: string
|
label: string
|
||||||
action: () => void
|
action: () => void
|
||||||
danger?: boolean
|
danger?: boolean
|
||||||
|
separator?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
@@ -45,8 +46,11 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
|||||||
style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }}
|
style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }}
|
||||||
>
|
>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
{item.separator && index > 0 && (
|
||||||
|
<div className="my-1 border-t border-dark-600" />
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
key={index}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
item.action()
|
item.action()
|
||||||
onClose()
|
onClose()
|
||||||
@@ -58,6 +62,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
|||||||
{item.icon}
|
{item.icon}
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
* FileListPanel - 文件列表面板
|
* FileListPanel - 文件列表面板
|
||||||
*
|
*
|
||||||
* 本地面板或远程面板的文件列表组件
|
* 本地面板或远程面板的文件列表组件
|
||||||
|
* 远程面板支持拖拽上传
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown } from 'lucide-react'
|
import { useState, useCallback, useRef } from 'react'
|
||||||
|
import { Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, FolderOpen } from 'lucide-react'
|
||||||
import { Breadcrumb } from './Breadcrumb'
|
import { Breadcrumb } from './Breadcrumb'
|
||||||
import { SortHeader } from './SortHeader'
|
import { SortHeader } from './SortHeader'
|
||||||
import { FileRow } from './FileRow'
|
import { FileRow } from './FileRow'
|
||||||
import { ContextMenu, type MenuItem } from './ContextMenu'
|
import { ContextMenu, type MenuItem } from './ContextMenu'
|
||||||
|
import { FileListSkeleton } from '../common/Skeleton'
|
||||||
|
import EmptyState from '../common/EmptyState'
|
||||||
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
||||||
|
|
||||||
interface FileListPanelProps {
|
interface FileListPanelProps {
|
||||||
@@ -31,6 +35,10 @@ interface FileListPanelProps {
|
|||||||
onSort: (field: SortField) => void
|
onSort: (field: SortField) => void
|
||||||
contextMenu: { x: number; y: number; items: MenuItem[] } | null
|
contextMenu: { x: number; y: number; items: MenuItem[] } | null
|
||||||
onCloseContextMenu: () => void
|
onCloseContextMenu: () => void
|
||||||
|
/** 远程面板拖拽上传回调 */
|
||||||
|
onDropFiles?: (files: FileList) => void
|
||||||
|
/** 是否正在加载 */
|
||||||
|
loading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileListPanel({
|
export function FileListPanel({
|
||||||
@@ -53,12 +61,65 @@ export function FileListPanel({
|
|||||||
onSort,
|
onSort,
|
||||||
contextMenu,
|
contextMenu,
|
||||||
onCloseContextMenu,
|
onCloseContextMenu,
|
||||||
|
onDropFiles,
|
||||||
|
loading = false,
|
||||||
}: FileListPanelProps) {
|
}: FileListPanelProps) {
|
||||||
const canGoBack = historyIndex > 0
|
const canGoBack = historyIndex > 0
|
||||||
const canGoForward = historyIndex < history.length - 1
|
const canGoForward = historyIndex < history.length - 1
|
||||||
|
|
||||||
|
// Drag state for remote panel upload
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
const dragCounterRef = useRef(0)
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
if (isLocal || !onDropFiles) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
dragCounterRef.current++
|
||||||
|
if (dragCounterRef.current === 1) {
|
||||||
|
setIsDragOver(true)
|
||||||
|
}
|
||||||
|
}, [isLocal, onDropFiles])
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
if (isLocal || !onDropFiles) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
dragCounterRef.current--
|
||||||
|
if (dragCounterRef.current === 0) {
|
||||||
|
setIsDragOver(false)
|
||||||
|
}
|
||||||
|
}, [isLocal, onDropFiles])
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
if (isLocal || !onDropFiles) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}, [isLocal, onDropFiles])
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
if (isLocal || !onDropFiles) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
dragCounterRef.current = 0
|
||||||
|
setIsDragOver(false)
|
||||||
|
|
||||||
|
const droppedFiles = e.dataTransfer.files
|
||||||
|
if (droppedFiles && droppedFiles.length > 0) {
|
||||||
|
onDropFiles(droppedFiles)
|
||||||
|
}
|
||||||
|
}, [isLocal, onDropFiles])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col bg-dark-800 rounded-lg border border-dark-700 overflow-hidden">
|
<div
|
||||||
|
className={`flex-1 flex flex-col bg-dark-800 rounded-lg border overflow-hidden transition-colors ${
|
||||||
|
isDragOver ? 'border-accent-blue shadow-lg shadow-accent-blue/20' : 'border-dark-700'
|
||||||
|
}`}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600">
|
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -100,11 +161,15 @@ export function FileListPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File List */}
|
{/* File List */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto relative">
|
||||||
{files.length === 0 ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-32 text-gray-500 text-sm">
|
<FileListSkeleton rows={6} />
|
||||||
空文件夹
|
) : files.length === 0 ? (
|
||||||
</div>
|
<EmptyState
|
||||||
|
icon={FolderOpen}
|
||||||
|
title="目录为空"
|
||||||
|
description="此目录下没有文件"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
files.map((file) => (
|
files.map((file) => (
|
||||||
<FileRow
|
<FileRow
|
||||||
@@ -117,11 +182,21 @@ export function FileListPanel({
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Drag overlay - pointer-events-none so it doesn't interfere with drop events */}
|
||||||
|
{isDragOver && (
|
||||||
|
<div className="absolute inset-0 bg-accent-blue/10 border-2 border-dashed border-accent-blue rounded-lg flex items-center justify-center pointer-events-none z-10">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-accent-blue">
|
||||||
|
<Upload className="w-10 h-10" />
|
||||||
|
<span className="text-sm font-medium">拖拽文件到此处上传</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Bar */}
|
{/* Status Bar */}
|
||||||
<div className="px-4 py-2 border-t border-dark-700 text-xs text-gray-400">
|
<div className="px-4 py-2 border-t border-dark-700 text-xs text-gray-400">
|
||||||
{selected.size > 0 ? `已选择 ${selected.size} 项` : `共 ${files.length} 项`}
|
{loading ? '加载中...' : selected.size > 0 ? `已选择 ${selected.size} 项` : `共 ${files.length} 项`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context Menu */}
|
{/* Context Menu */}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* FilePropertiesDialog — 文件属性查看对话框
|
||||||
|
*
|
||||||
|
* 显示文件名、路径、大小、权限、修改时间、类型等信息
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { File, Folder, Link2, HardDrive, Clock, Shield, Info } from 'lucide-react'
|
||||||
|
import Modal from '../common/Modal'
|
||||||
|
|
||||||
|
interface FileStats {
|
||||||
|
size: number
|
||||||
|
mode: number
|
||||||
|
mtime: number
|
||||||
|
atime: number
|
||||||
|
isDirectory: boolean
|
||||||
|
isFile: boolean
|
||||||
|
isSymbolicLink: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePropertiesDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
fileName: string
|
||||||
|
filePath: string
|
||||||
|
fileType: 'file' | 'directory' | 'symlink'
|
||||||
|
/** Initial permissions string e.g. "-rwxr-xr-x" */
|
||||||
|
permissions?: string
|
||||||
|
/** Initial size from file listing */
|
||||||
|
initialSize?: number
|
||||||
|
/** Initial modified timestamp from file listing */
|
||||||
|
initialModified?: number
|
||||||
|
/** Async function to fetch fresh stat from the server */
|
||||||
|
onFetchStat?: (filePath: string) => Promise<FileStats | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const k = 1024
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts: number): string {
|
||||||
|
if (!ts) return '-'
|
||||||
|
const date = new Date(ts * 1000)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeToOctal(mode: number): string {
|
||||||
|
// Lower 9 bits = rwxrwxrwx
|
||||||
|
return (mode & 0o7777).toString(8).padStart(4, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeToString(mode: number): string {
|
||||||
|
const perms = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']
|
||||||
|
const owner = perms[(mode >> 6) & 7]
|
||||||
|
const group = perms[(mode >> 3) & 7]
|
||||||
|
const other = perms[mode & 7]
|
||||||
|
let prefix = '-'
|
||||||
|
if (mode & 0o40000) prefix = 'd'
|
||||||
|
else if (mode & 0o120000) prefix = 'l'
|
||||||
|
return `${prefix}${owner}${group}${other}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilePropertiesDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
fileName,
|
||||||
|
filePath,
|
||||||
|
fileType,
|
||||||
|
permissions: initialPermissions,
|
||||||
|
initialSize,
|
||||||
|
initialModified,
|
||||||
|
onFetchStat,
|
||||||
|
}: FilePropertiesDialogProps) {
|
||||||
|
const [stats, setStats] = useState<FileStats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && onFetchStat) {
|
||||||
|
setLoading(true)
|
||||||
|
onFetchStat(filePath)
|
||||||
|
.then((result) => {
|
||||||
|
if (result) setStats(result)
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
}, [isOpen, filePath, onFetchStat])
|
||||||
|
|
||||||
|
const size = stats?.size ?? initialSize ?? 0
|
||||||
|
const mtime = stats?.mtime ?? initialModified ?? 0
|
||||||
|
const atime = stats?.atime ?? 0
|
||||||
|
const octalMode = stats ? modeToOctal(stats.mode) : null
|
||||||
|
const stringMode = stats ? modeToString(stats.mode) : (initialPermissions ?? '-')
|
||||||
|
const typeLabel = fileType === 'directory'
|
||||||
|
? '文件夹'
|
||||||
|
: fileType === 'symlink'
|
||||||
|
? '符号链接'
|
||||||
|
: '文件'
|
||||||
|
|
||||||
|
const TypeIcon = fileType === 'directory'
|
||||||
|
? Folder
|
||||||
|
: fileType === 'symlink'
|
||||||
|
? Link2
|
||||||
|
: File
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
title="文件属性"
|
||||||
|
onClose={onClose}
|
||||||
|
showConfirm={false}
|
||||||
|
cancelText="关闭"
|
||||||
|
>
|
||||||
|
<div className="space-y-4 min-w-[320px]">
|
||||||
|
{/* File icon + name header */}
|
||||||
|
<div className="flex items-center gap-3 pb-3 border-b border-dark-600">
|
||||||
|
<div className="p-2 bg-dark-700 rounded-lg">
|
||||||
|
<TypeIcon className="w-8 h-8 text-accent-blue" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-white font-semibold truncate">{fileName}</p>
|
||||||
|
<p className="text-gray-400 text-xs">{typeLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||||
|
<div className="animate-spin h-4 w-4 border-2 border-accent-blue border-t-transparent rounded-full" />
|
||||||
|
<span>加载详细信息...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Properties table */}
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<PropertyRow icon={<Info className="w-4 h-4" />} label="名称" value={fileName} />
|
||||||
|
<PropertyRow icon={<HardDrive className="w-4 h-4" />} label="路径" value={filePath} monospace />
|
||||||
|
<PropertyRow icon={<HardDrive className="w-4 h-4" />} label="大小" value={formatFileSize(size)} />
|
||||||
|
<PropertyRow icon={<Shield className="w-4 h-4" />} label="权限" value={stringMode + (octalMode ? ` (${octalMode})` : '')} monospace />
|
||||||
|
<PropertyRow icon={<Clock className="w-4 h-4" />} label="修改时间" value={formatTimestamp(mtime)} />
|
||||||
|
{stats && (
|
||||||
|
<PropertyRow icon={<Clock className="w-4 h-4" />} label="访问时间" value={formatTimestamp(atime)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropertyRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
monospace = false,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
monospace?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-gray-500 mt-0.5 shrink-0">{icon}</span>
|
||||||
|
<span className="text-gray-400 w-20 shrink-0">{label}</span>
|
||||||
|
<span
|
||||||
|
className={`text-gray-200 break-all ${monospace ? 'font-mono text-xs' : ''}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* SessionTree - 会话树组件
|
* SessionTree - 会话树组件
|
||||||
*
|
*
|
||||||
* 显示分组和会话列表
|
* 显示分组和会话列表,带折叠动画
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ChevronRight, ChevronDown, Folder, Server } from 'lucide-react'
|
import { ChevronRight, Folder, Server, ServerOff } from 'lucide-react'
|
||||||
|
import { SessionTreeSkeleton } from '../common/Skeleton'
|
||||||
import type { Session } from '../../stores/sessionStore'
|
import type { Session } from '../../stores/sessionStore'
|
||||||
|
import EmptyState from '../common/EmptyState'
|
||||||
|
|
||||||
interface SessionTreeProps {
|
interface SessionTreeProps {
|
||||||
sessions: Session[]
|
sessions: Session[]
|
||||||
@@ -15,6 +17,10 @@ interface SessionTreeProps {
|
|||||||
onSelectSession: (session: Session) => void
|
onSelectSession: (session: Session) => void
|
||||||
onContextMenu: (e: React.MouseEvent, session: Session) => void
|
onContextMenu: (e: React.MouseEvent, session: Session) => void
|
||||||
searchTerm: string
|
searchTerm: string
|
||||||
|
/** 是否正在加载 */
|
||||||
|
loading?: boolean
|
||||||
|
/** 新建会话回调,用于空状态操作按钮 */
|
||||||
|
onNewSession?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionTree({
|
export function SessionTree({
|
||||||
@@ -25,7 +31,18 @@ export function SessionTree({
|
|||||||
onSelectSession,
|
onSelectSession,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
|
loading = false,
|
||||||
|
onNewSession,
|
||||||
}: SessionTreeProps) {
|
}: SessionTreeProps) {
|
||||||
|
// 加载中时显示骨架屏
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<SessionTreeSkeleton groups={2} sessionsPerGroup={[3, 4]} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Filter sessions by search
|
// Filter sessions by search
|
||||||
const filteredSessions = sessions.filter(s =>
|
const filteredSessions = sessions.filter(s =>
|
||||||
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
@@ -42,26 +59,31 @@ export function SessionTree({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{Object.entries(grouped).map(([group, groupSessions]) => (
|
{Object.entries(grouped).map(([group, groupSessions]) => {
|
||||||
|
const isExpanded = expandedGroups.has(group)
|
||||||
|
return (
|
||||||
<div key={group} className="border-b border-dark-700 last:border-b-0">
|
<div key={group} className="border-b border-dark-700 last:border-b-0">
|
||||||
{/* Group Header */}
|
{/* Group Header */}
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleGroup(group)}
|
onClick={() => onToggleGroup(group)}
|
||||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-400 hover:bg-dark-700 transition-colors"
|
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-400 hover:bg-dark-700 transition-colors"
|
||||||
>
|
>
|
||||||
{expandedGroups.has(group) ? (
|
<ChevronRight
|
||||||
<ChevronDown className="w-4 h-4" />
|
className="w-4 h-4 transition-transform duration-200 ease-in-out"
|
||||||
) : (
|
style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||||
<ChevronRight className="w-4 h-4" />
|
/>
|
||||||
)}
|
|
||||||
<Folder className="w-4 h-4" />
|
<Folder className="w-4 h-4" />
|
||||||
<span className="flex-1 text-left">{group === 'default' ? '默认分组' : group}</span>
|
<span className="flex-1 text-left">{group === 'default' ? '默认分组' : group}</span>
|
||||||
<span className="text-xs text-gray-500">{groupSessions.length}</span>
|
<span className="text-xs text-gray-500">{groupSessions.length}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Sessions */}
|
{/* Sessions - CSS Grid collapse animation */}
|
||||||
{expandedGroups.has(group) && (
|
<div
|
||||||
<div className="animate-fade-in">
|
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
|
||||||
|
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div>
|
||||||
{groupSessions.map((session) => (
|
{groupSessions.map((session) => (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
@@ -84,14 +106,19 @@ export function SessionTree({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{filteredSessions.length === 0 && (
|
{filteredSessions.length === 0 && (
|
||||||
<div className="p-8 text-center text-gray-500 text-sm">
|
<EmptyState
|
||||||
{searchTerm ? '未找到匹配的会话' : '暂无会话'}
|
icon={ServerOff}
|
||||||
</div>
|
title={searchTerm ? '未找到匹配的会话' : '暂无会话'}
|
||||||
|
description={searchTerm ? '尝试其他关键词搜索' : '点击 + 按钮添加你的第一个 SSH 连接'}
|
||||||
|
action={searchTerm ? undefined : onNewSession ? { label: '新建会话', onClick: onNewSession } : undefined}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* TabItem - 单个标签项
|
* TabItem - 单个标签项(支持拖拽排序)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { X, Terminal, FolderSync } from 'lucide-react'
|
import { X, Terminal, FolderSync } from 'lucide-react'
|
||||||
@@ -7,13 +7,33 @@ import type { Tab } from '../../stores/tabStore'
|
|||||||
|
|
||||||
interface TabItemProps {
|
interface TabItemProps {
|
||||||
tab: Tab
|
tab: Tab
|
||||||
|
index: number
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
|
isDragging: boolean
|
||||||
|
dragOverIndex: number | null
|
||||||
onActivate: () => void
|
onActivate: () => void
|
||||||
onClose: (e: React.MouseEvent) => void
|
onClose: (e: React.MouseEvent) => void
|
||||||
onContextMenu: (e: React.MouseEvent) => void
|
onContextMenu: (e: React.MouseEvent) => void
|
||||||
|
onDragStart: (e: React.DragEvent, index: number) => void
|
||||||
|
onDragOver: (e: React.DragEvent, index: number) => void
|
||||||
|
onDrop: (e: React.DragEvent, index: number) => void
|
||||||
|
onDragEnd: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabItem({ tab, isActive, onActivate, onClose, onContextMenu }: TabItemProps) {
|
export function TabItem({
|
||||||
|
tab,
|
||||||
|
index,
|
||||||
|
isActive,
|
||||||
|
isDragging,
|
||||||
|
dragOverIndex,
|
||||||
|
onActivate,
|
||||||
|
onClose,
|
||||||
|
onContextMenu,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onDragEnd,
|
||||||
|
}: TabItemProps) {
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
switch (tab.type) {
|
switch (tab.type) {
|
||||||
case 'terminal':
|
case 'terminal':
|
||||||
@@ -25,20 +45,39 @@ export function TabItem({ tab, isActive, onActivate, onClose, onContextMenu }: T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine drop indicator position
|
||||||
|
const showLeftIndicator = dragOverIndex === index && !isDragging
|
||||||
|
const showRightIndicator = dragOverIndex === index + 1 && !isDragging
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
draggable
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
|
onDragStart={(e) => onDragStart(e, index)}
|
||||||
|
onDragOver={(e) => onDragOver(e, index)}
|
||||||
|
onDrop={(e) => onDrop(e, index)}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
className={`
|
className={`
|
||||||
group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[200px]
|
group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[200px]
|
||||||
border-r border-dark-700 cursor-pointer select-none
|
border-r border-dark-700 cursor-pointer select-none
|
||||||
transition-colors relative
|
transition-all duration-150 relative
|
||||||
${isActive
|
${isActive
|
||||||
? 'bg-dark-800 text-gray-200'
|
? 'bg-dark-800 text-gray-200'
|
||||||
: 'bg-dark-900 text-gray-500 hover:bg-dark-800 hover:text-gray-400'
|
: 'bg-dark-900 text-gray-500 hover:bg-dark-800 hover:text-gray-400'
|
||||||
}
|
}
|
||||||
|
${isDragging ? 'opacity-50' : 'opacity-100'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
{/* Left drop indicator */}
|
||||||
|
{showLeftIndicator && (
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-accent-blue z-10 transition-all duration-150" />
|
||||||
|
)}
|
||||||
|
{/* Right drop indicator */}
|
||||||
|
{showRightIndicator && (
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-0.5 bg-accent-blue z-10 transition-all duration-150" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
{tab.connectionStatus === 'connecting' && (
|
{tab.connectionStatus === 'connecting' && (
|
||||||
<span className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
|
||||||
@@ -67,6 +106,11 @@ export function TabItem({ tab, isActive, onActivate, onClose, onContextMenu }: T
|
|||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Active tab bottom indicator */}
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent-blue" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Terminal as TerminalIcon } from 'lucide-react'
|
||||||
|
import type { ConnectionState } from '../../stores/connection'
|
||||||
|
|
||||||
|
export interface TerminalOverlayProps {
|
||||||
|
connectionState: ConnectionState
|
||||||
|
statusMessage: string
|
||||||
|
showSuspendedOverlay: boolean
|
||||||
|
needsReconnect: boolean
|
||||||
|
onReconnect: () => void
|
||||||
|
onResume: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TerminalOverlay({
|
||||||
|
connectionState,
|
||||||
|
statusMessage,
|
||||||
|
showSuspendedOverlay,
|
||||||
|
needsReconnect,
|
||||||
|
onReconnect,
|
||||||
|
onResume,
|
||||||
|
}: TerminalOverlayProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 会话恢复 - 需要重新连接覆盖层 */}
|
||||||
|
{connectionState === 'disconnected' && 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={onReconnect}
|
||||||
|
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">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-yellow-400 mb-2 text-lg">连接已挂起</div>
|
||||||
|
<div className="text-gray-400 text-sm mb-4">
|
||||||
|
长时间未操作,连接已进入挂起状态
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onResume}
|
||||||
|
className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors mr-2"
|
||||||
|
>
|
||||||
|
恢复连接
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onReconnect}
|
||||||
|
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-gray-200 rounded transition-colors"
|
||||||
|
>
|
||||||
|
重新连接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误状态覆盖层 */}
|
||||||
|
{connectionState === 'error' && (
|
||||||
|
<div className="absolute inset-0 bg-dark-900/80 flex items-center justify-center z-10">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-red-400 mb-2">连接失败</div>
|
||||||
|
<div className="text-gray-400 text-sm mb-4">{statusMessage}</div>
|
||||||
|
<button
|
||||||
|
onClick={onReconnect}
|
||||||
|
className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors"
|
||||||
|
>
|
||||||
|
重新连接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 连接中状态 */}
|
||||||
|
{connectionState === 'connecting' && (
|
||||||
|
<div className="absolute inset-0 bg-dark-900/50 flex items-center justify-center pointer-events-none z-10">
|
||||||
|
<div className="text-accent-blue animate-pulse">连接中...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 连接状态指示器 - 简洁版 */}
|
||||||
|
<div className="absolute top-3 right-3 flex items-center gap-2 z-20">
|
||||||
|
{/* 状态指示已移至 TabBar */}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Search, ChevronUp, ChevronDown, X } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface TerminalSearchBarProps {
|
||||||
|
searchQuery: string
|
||||||
|
searchResultCount: number | undefined
|
||||||
|
searchResultIndex: number | undefined
|
||||||
|
searchInputRef: React.MutableRefObject<HTMLInputElement | null>
|
||||||
|
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
onSearchNext: () => void
|
||||||
|
onSearchPrevious: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TerminalSearchBar({
|
||||||
|
searchQuery,
|
||||||
|
searchResultCount,
|
||||||
|
searchResultIndex,
|
||||||
|
searchInputRef,
|
||||||
|
onSearchChange,
|
||||||
|
onSearchNext,
|
||||||
|
onSearchPrevious,
|
||||||
|
onClose,
|
||||||
|
}: TerminalSearchBarProps) {
|
||||||
|
return (
|
||||||
|
<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={onSearchChange}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
onSearchPrevious()
|
||||||
|
} else {
|
||||||
|
onSearchNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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={onSearchPrevious}
|
||||||
|
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={onSearchNext}
|
||||||
|
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={onClose}
|
||||||
|
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
title="关闭 (Escape)"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Vendored
+12
@@ -68,6 +68,18 @@ interface ElectronAPI {
|
|||||||
sftpRename: (sftpId: string, oldPath: string, newPath: string) => Promise<IPCResponse>
|
sftpRename: (sftpId: string, oldPath: string, newPath: string) => Promise<IPCResponse>
|
||||||
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => Promise<IPCResponse>
|
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => Promise<IPCResponse>
|
||||||
sftpMkdir: (sftpId: string, path: string) => Promise<IPCResponse>
|
sftpMkdir: (sftpId: string, path: string) => Promise<IPCResponse>
|
||||||
|
sftpStat: (sftpId: string, filePath: string) => Promise<IPCResponse & {
|
||||||
|
stats?: {
|
||||||
|
size: number
|
||||||
|
mode: number
|
||||||
|
mtime: number
|
||||||
|
atime: number
|
||||||
|
isDirectory: boolean
|
||||||
|
isFile: boolean
|
||||||
|
isSymbolicLink: boolean
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
sftpChmod: (sftpId: string, filePath: string, mode: number) => Promise<IPCResponse>
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
settingsGet: (key: string) => Promise<IPCResponse>
|
settingsGet: (key: string) => Promise<IPCResponse>
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
|
|
||||||
export { useSFTP } from './useSFTP'
|
export { useSFTP } from './useSFTP'
|
||||||
export type { UseSFTPOptions, UseSFTPReturn } from './useSFTP'
|
export type { UseSFTPOptions, UseSFTPReturn } from './useSFTP'
|
||||||
|
export { useKeyboardShortcuts } from './useKeyboardShortcuts'
|
||||||
|
export { useToast } from './useToast'
|
||||||
|
export type { Toast, ToastType } from './useToast'
|
||||||
|
|
||||||
|
export { useMediaQuery, useIsMobile } from './useMediaQuery'
|
||||||
|
|
||||||
// 待实现
|
// 待实现
|
||||||
// export { useTerminal } from './useTerminal'
|
// export { useTerminal } from './useTerminal'
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* useKeyboardShortcuts - 全局快捷键管理 Hook
|
||||||
|
*
|
||||||
|
* 在 App.tsx 挂载,处理全局键盘快捷键:
|
||||||
|
* - Ctrl+Tab / Ctrl+Shift+Tab: 切换标签
|
||||||
|
* - Ctrl+1~9: 跳转到指定索引标签
|
||||||
|
* - Ctrl+W: 关闭当前标签
|
||||||
|
* - F11: 全屏切换
|
||||||
|
* - Ctrl+Shift+N: 新建标签
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useTabStore } from '../stores/tabStore'
|
||||||
|
import { createLogger } from '../utils/logger'
|
||||||
|
|
||||||
|
const logger = createLogger('KeyboardShortcuts')
|
||||||
|
|
||||||
|
/** 检查事件目标是否为输入元素 */
|
||||||
|
function isInputElement(e: KeyboardEvent): boolean {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target) return false
|
||||||
|
const tagName = target.tagName.toLowerCase()
|
||||||
|
return (
|
||||||
|
tagName === 'input' ||
|
||||||
|
tagName === 'textarea' ||
|
||||||
|
tagName === 'select' ||
|
||||||
|
target.isContentEditable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断快捷键是否应该在输入框中触发
|
||||||
|
* Ctrl+Tab/Ctrl+Shift+Tab 等导航类快捷键始终生效
|
||||||
|
*/
|
||||||
|
function shouldSkipInInput(e: KeyboardEvent): boolean {
|
||||||
|
// Ctrl+Tab 系列:始终生效(浏览器级别标签切换)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') return false
|
||||||
|
// F11:始终生效
|
||||||
|
if ( e.key === 'F11') return false
|
||||||
|
// 其他快捷键在输入框中不触发
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts(): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const ctrlOrMeta = e.ctrlKey || e.metaKey
|
||||||
|
|
||||||
|
// ---- Ctrl+Tab: 切换到下一个标签 ----
|
||||||
|
if (ctrlOrMeta && e.key === 'Tab' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
const { switchToNextTab } = useTabStore.getState()
|
||||||
|
switchToNextTab()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ctrl+Shift+Tab: 切换到上一个标签 ----
|
||||||
|
if (ctrlOrMeta && e.key === 'Tab' && e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
const { switchToPrevTab } = useTabStore.getState()
|
||||||
|
switchToPrevTab()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ctrl+1~9: 跳转到指定索引标签 ----
|
||||||
|
if (ctrlOrMeta && !e.shiftKey && !e.altKey) {
|
||||||
|
const num = parseInt(e.key, 10)
|
||||||
|
if (num >= 1 && num <= 9) {
|
||||||
|
e.preventDefault()
|
||||||
|
const { activateTabByIndex } = useTabStore.getState()
|
||||||
|
activateTabByIndex(num - 1) // 0-indexed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- F11: 全屏切换 ----
|
||||||
|
if (e.key === 'F11') {
|
||||||
|
e.preventDefault()
|
||||||
|
// Electron 渲染进程通过 IPC 请求全屏切换
|
||||||
|
if (window.electronAPI?.settingsSet) {
|
||||||
|
window.electronAPI.settingsGet('fullscreen').then((res: any) => {
|
||||||
|
const isFullscreen = res?.value === true
|
||||||
|
window.electronAPI?.settingsSet('fullscreen', !isFullscreen)
|
||||||
|
}).catch(() => {
|
||||||
|
logger.warn('Failed to toggle fullscreen')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 以下快捷键在输入框中不触发 ----
|
||||||
|
if (isInputElement(e) && shouldSkipInInput(e)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ctrl+W: 关闭当前标签 ----
|
||||||
|
if (ctrlOrMeta && e.key === 'w') {
|
||||||
|
e.preventDefault()
|
||||||
|
const { tabs, activeTabId, closeTab } = useTabStore.getState()
|
||||||
|
if (activeTabId && tabs.length > 1) {
|
||||||
|
logger.debug('Ctrl+W: closing tab', activeTabId)
|
||||||
|
closeTab(activeTabId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ctrl+Shift+N: 新建标签 ----
|
||||||
|
if (ctrlOrMeta && e.shiftKey && (e.key === 'n' || e.key === 'N')) {
|
||||||
|
e.preventDefault()
|
||||||
|
const { addTab } = useTabStore.getState()
|
||||||
|
addTab({ title: '新标签', type: 'welcome' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useKeyboardShortcuts
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* useMediaQuery - 响应式媒体查询 Hook
|
||||||
|
*
|
||||||
|
* 监听窗口宽度变化,返回是否为移动端视图。
|
||||||
|
* SSR 安全:初始值返回 false(非移动端),避免 hydration 不匹配。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(() => {
|
||||||
|
// SSR safe: check if window exists
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
return window.matchMedia(query).matches
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia(query)
|
||||||
|
const handler = (event: MediaQueryListEvent) => {
|
||||||
|
setMatches(event.matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial value in effect to avoid hydration mismatch
|
||||||
|
setMatches(mediaQuery.matches)
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', handler)
|
||||||
|
return () => mediaQuery.removeEventListener('change', handler)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useIsMobile - 便捷 Hook
|
||||||
|
*
|
||||||
|
* 当窗口宽度 < 800px 时返回 true (isMobile = true)
|
||||||
|
*/
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
// matches = true means width >= 800px, so isMobile = !matches
|
||||||
|
return !useMediaQuery('(min-width: 800px)')
|
||||||
|
}
|
||||||
+137
-88
@@ -13,23 +13,6 @@ import type { FileItem, TransferTask, SortField, SortOrder } from '../../shared/
|
|||||||
|
|
||||||
const logger = createLogger('useSFTP')
|
const logger = createLogger('useSFTP')
|
||||||
|
|
||||||
// ===== 彩色调试日志 =====
|
|
||||||
function logHeader(msg: string) {
|
|
||||||
console.log(`%c${msg}`, 'background: #16a085; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;')
|
|
||||||
}
|
|
||||||
function logInfo(label: string, value: any) {
|
|
||||||
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #1abc9c; font-weight: bold;')
|
|
||||||
}
|
|
||||||
function logSuccess(msg: string) {
|
|
||||||
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;')
|
|
||||||
}
|
|
||||||
function logError(msg: string) {
|
|
||||||
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;')
|
|
||||||
}
|
|
||||||
function logWarning(msg: string) {
|
|
||||||
console.log(`%c⚠ ${msg}`, 'color: #f39c12; font-weight: bold;')
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseSFTPOptions {
|
export interface UseSFTPOptions {
|
||||||
tabId: string
|
tabId: string
|
||||||
}
|
}
|
||||||
@@ -77,6 +60,17 @@ export interface UseSFTPReturn {
|
|||||||
|
|
||||||
// 远程文件操作
|
// 远程文件操作
|
||||||
deleteRemote: (files: FileItem[]) => Promise<boolean>
|
deleteRemote: (files: FileItem[]) => Promise<boolean>
|
||||||
|
renameRemote: (file: FileItem, newName: string) => Promise<boolean>
|
||||||
|
getRemoteStat: (filePath: string) => Promise<{
|
||||||
|
size: number
|
||||||
|
mode: number
|
||||||
|
mtime: number
|
||||||
|
atime: number
|
||||||
|
isDirectory: boolean
|
||||||
|
isFile: boolean
|
||||||
|
isSymbolicLink: boolean
|
||||||
|
} | null>
|
||||||
|
chmodRemote: (filePath: string, mode: number) => Promise<boolean>
|
||||||
|
|
||||||
// 排序
|
// 排序
|
||||||
sortField: SortField
|
sortField: SortField
|
||||||
@@ -92,7 +86,7 @@ export interface UseSFTPReturn {
|
|||||||
export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||||
const { sessions } = useSessionStore()
|
const { sessions } = useSessionStore()
|
||||||
const { updateTabConnectionInstance, updateTab } = useTabStore()
|
const { updateTabConnectionInstance, updateTab } = useTabStore()
|
||||||
const { createConnection, getConnection } = useConnectionPool()
|
const { createConnection } = useConnectionPool()
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const [localPath, setLocalPath] = useState('')
|
const [localPath, setLocalPath] = useState('')
|
||||||
@@ -124,50 +118,40 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
|
|
||||||
// Initialize home path
|
// Initialize home path
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logHeader('📁 SFTP Initializing home path')
|
logger.info('SFTP Initializing home path')
|
||||||
if (window.electronAPI) {
|
if (window.electronAPI) {
|
||||||
window.electronAPI.getHomePath?.().then((result: { success: boolean; path?: string }) => {
|
window.electronAPI.getHomePath?.().then((result: { success: boolean; path?: string }) => {
|
||||||
logInfo('Home path result', result)
|
logger.debug('Home path result', result)
|
||||||
if (result.success && result.path) {
|
if (result.success && result.path) {
|
||||||
setLocalPath(result.path)
|
setLocalPath(result.path)
|
||||||
setLocalHistory([result.path])
|
setLocalHistory([result.path])
|
||||||
setLocalHistoryIndex(0)
|
setLocalHistoryIndex(0)
|
||||||
logSuccess(`Home path set: ${result.path}`)
|
logger.info(`Home path set: ${result.path}`)
|
||||||
// Load local directory
|
// Load local directory
|
||||||
loadLocalDirectory(result.path)
|
loadLocalDirectory(result.path)
|
||||||
} else {
|
} else {
|
||||||
logError('Failed to get home path')
|
logger.error('Failed to get home path')
|
||||||
}
|
}
|
||||||
}).catch((err: any) => {
|
}).catch((err: any) => {
|
||||||
logError(`getHomePath error: ${err?.message || err}`)
|
logger.error(`getHomePath error: ${err?.message || err}`)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
logWarning('electronAPI not available')
|
logger.warn('electronAPI not available')
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load local directory
|
// Load local directory
|
||||||
const loadLocalDirectory = useCallback(async (path: string) => {
|
const loadLocalDirectory = useCallback(async (path: string) => {
|
||||||
logHeader('📁 LOAD LOCAL DIRECTORY')
|
logger.info('Loading local directory', path)
|
||||||
logInfo('path', path)
|
|
||||||
|
|
||||||
if (!window.electronAPI?.localReadDir) {
|
if (!window.electronAPI?.localReadDir) {
|
||||||
logWarning('localReadDir not available, using mock data')
|
logger.warn('localReadDir not available')
|
||||||
// Mock files for development
|
|
||||||
const mockFiles: FileItem[] = [
|
|
||||||
{ name: 'Desktop', path: `${path}/Desktop`, type: 'directory', size: 0, modified: Date.now() / 1000 - 86400, permissions: 'drwxr-xr-x' },
|
|
||||||
{ name: 'Documents', path: `${path}/Documents`, type: 'directory', size: 0, modified: Date.now() / 1000 - 172800, permissions: 'drwxr-xr-x' },
|
|
||||||
{ name: 'Downloads', path: `${path}/Downloads`, type: 'directory', size: 0, modified: Date.now() / 1000 - 3600, permissions: 'drwxr-xr-x' },
|
|
||||||
{ name: 'config.json', path: `${path}/config.json`, type: 'file', size: 4096, modified: Date.now() / 1000 - 7200, permissions: '-rw-r--r--' },
|
|
||||||
]
|
|
||||||
setLocalFiles(mockFiles)
|
|
||||||
logSuccess(`Loaded ${mockFiles.length} mock files`)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.localReadDir(path)
|
const result = await window.electronAPI.localReadDir(path)
|
||||||
logInfo('localReadDir result', result)
|
logger.debug('localReadDir result', result)
|
||||||
|
|
||||||
if (result.success && result.files) {
|
if (result.success && result.files) {
|
||||||
const files: FileItem[] = result.files.map((f: any) => ({
|
const files: FileItem[] = result.files.map((f: any) => ({
|
||||||
@@ -179,30 +163,27 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
permissions: f.isDirectory ? 'drwxr-xr-x' : '-rw-r--r--',
|
permissions: f.isDirectory ? 'drwxr-xr-x' : '-rw-r--r--',
|
||||||
}))
|
}))
|
||||||
setLocalFiles(files)
|
setLocalFiles(files)
|
||||||
logSuccess(`Loaded ${files.length} local files`)
|
logger.info(`Loaded ${files.length} local files`)
|
||||||
} else {
|
} else {
|
||||||
logError(`Failed to load local directory: ${result.error}`)
|
logger.error(`Failed to load local directory: ${result.error}`)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logError(`loadLocalDirectory error: ${err?.message}`)
|
logger.error(`loadLocalDirectory error: ${err?.message}`, err)
|
||||||
logger.error('Failed to load local directory', err)
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load remote directory
|
// Load remote directory
|
||||||
const loadRemoteDirectory = useCallback(async (path: string) => {
|
const loadRemoteDirectory = useCallback(async (path: string) => {
|
||||||
logHeader('📁 LOAD REMOTE DIRECTORY')
|
logger.info('Loading remote directory', { path, sftpId })
|
||||||
logInfo('path', path)
|
|
||||||
logInfo('sftpId', sftpId)
|
|
||||||
|
|
||||||
if (!sftpId || !window.electronAPI) {
|
if (!sftpId || !window.electronAPI) {
|
||||||
logError('sftpId or electronAPI not available')
|
logger.error('sftpId or electronAPI not available')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.sftpListDirectory(sftpId, path)
|
const result = await window.electronAPI.sftpListDirectory(sftpId, path)
|
||||||
logInfo('sftpListDirectory result', result)
|
logger.debug('sftpListDirectory result', result)
|
||||||
|
|
||||||
if (result.success && result.files) {
|
if (result.success && result.files) {
|
||||||
const files: FileItem[] = result.files.map((f: any) => ({
|
const files: FileItem[] = result.files.map((f: any) => ({
|
||||||
@@ -214,13 +195,12 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
permissions: f.longname?.substring(0, 10) || '-rw-r--r--',
|
permissions: f.longname?.substring(0, 10) || '-rw-r--r--',
|
||||||
}))
|
}))
|
||||||
setRemoteFiles(files)
|
setRemoteFiles(files)
|
||||||
logSuccess(`Loaded ${files.length} remote files`)
|
logger.info(`Loaded ${files.length} remote files`)
|
||||||
} else {
|
} else {
|
||||||
logError(`Failed to load remote directory: ${result.error}`)
|
logger.error(`Failed to load remote directory: ${result.error}`)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logError(`loadRemoteDirectory error: ${err?.message}`)
|
logger.error(`loadRemoteDirectory error: ${err?.message}`, err)
|
||||||
logger.error('Failed to load remote directory', err)
|
|
||||||
setError('Failed to load remote directory')
|
setError('Failed to load remote directory')
|
||||||
}
|
}
|
||||||
}, [sftpId])
|
}, [sftpId])
|
||||||
@@ -284,11 +264,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
|
|
||||||
// Connect to SFTP
|
// Connect to SFTP
|
||||||
const connect = useCallback(async (sessionId: string) => {
|
const connect = useCallback(async (sessionId: string) => {
|
||||||
logHeader('📁 SFTP CONNECT')
|
logger.info('SFTP CONNECT', { sessionId })
|
||||||
logInfo('sessionId', sessionId)
|
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
logError('No sessionId provided')
|
logger.error('No sessionId provided')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,43 +276,43 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await getSessionConfig(sessionId)
|
const config = await getSessionConfig(sessionId)
|
||||||
logInfo('Config', `${config?.username}@${config?.host}:${config?.port}`)
|
logger.debug('Config', `${config?.username}@${config?.host}:${config?.port}`)
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
setError('Session not found')
|
setError('Session not found')
|
||||||
logError('Session config not found')
|
logger.error('Session config not found')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create connection
|
// Create connection
|
||||||
logInfo('Creating connection...', tabId)
|
logger.debug('Creating connection...', tabId)
|
||||||
const result = await createConnection(tabId, config)
|
const result = await createConnection(tabId, config)
|
||||||
logInfo('Connection result', result)
|
logger.debug('Connection result', result)
|
||||||
|
|
||||||
if (!result.success || !result.connectionId) {
|
if (!result.success || !result.connectionId) {
|
||||||
setError(result.error || 'Failed to create connection')
|
setError(result.error || 'Failed to create connection')
|
||||||
logError(`Connection failed: ${result.error}`)
|
logger.error(`Connection failed: ${result.error}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const connId = result.connectionId
|
const connId = result.connectionId
|
||||||
setConnectionId(connId)
|
setConnectionId(connId)
|
||||||
logSuccess(`Connection created: ${connId}`)
|
logger.info(`Connection created: ${connId}`)
|
||||||
|
|
||||||
// Create SFTP
|
// Create SFTP
|
||||||
logInfo('Creating SFTP session...', connId)
|
logger.debug('Creating SFTP session...', connId)
|
||||||
const sftpResult = await window.electronAPI?.sftpConnect(connId)
|
const sftpResult = await window.electronAPI?.sftpConnect(connId)
|
||||||
logInfo('SFTP result', sftpResult)
|
logger.debug('SFTP result', sftpResult)
|
||||||
|
|
||||||
if (!sftpResult?.success || !sftpResult.sftpId) {
|
if (!sftpResult?.success || !sftpResult.sftpId) {
|
||||||
setError(sftpResult?.error || 'Failed to create SFTP session')
|
setError(sftpResult?.error || 'Failed to create SFTP session')
|
||||||
logError(`SFTP creation failed: ${sftpResult?.error}`)
|
logger.error(`SFTP creation failed: ${sftpResult?.error}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSftpId(sftpResult.sftpId)
|
setSftpId(sftpResult.sftpId)
|
||||||
setIsConnected(true)
|
setIsConnected(true)
|
||||||
logSuccess(`SFTP connected: ${sftpResult.sftpId}`)
|
logger.info(`SFTP connected: ${sftpResult.sftpId}`)
|
||||||
|
|
||||||
// Update tab
|
// Update tab
|
||||||
const connectionInstance: ConnectionInstance = {
|
const connectionInstance: ConnectionInstance = {
|
||||||
@@ -344,12 +323,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
updateTab(tabId, { title: `SFTP: ${config.username}@${config.host}` })
|
updateTab(tabId, { title: `SFTP: ${config.username}@${config.host}` })
|
||||||
|
|
||||||
// Load initial remote directory
|
// Load initial remote directory
|
||||||
logInfo('Loading remote home directory...', '/')
|
logger.debug('Loading remote home directory...', '/')
|
||||||
loadRemoteDirectory('/')
|
loadRemoteDirectory('/')
|
||||||
|
|
||||||
logSuccess('SFTP initialization complete')
|
logger.info('SFTP initialization complete')
|
||||||
|
|
||||||
logger.info('SFTP connected', { host: config.host, connectionId: connId })
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('SFTP connection failed', err)
|
logger.error('SFTP connection failed', err)
|
||||||
setError('Connection failed')
|
setError('Connection failed')
|
||||||
@@ -360,25 +337,25 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
|
|
||||||
// Disconnect
|
// Disconnect
|
||||||
const disconnect = useCallback(async () => {
|
const disconnect = useCallback(async () => {
|
||||||
if (sftpId && window.electronAPI) {
|
// Close SFTP session via SSH disconnect (SFTP is cleaned up when connection closes)
|
||||||
// Note: SFTP disconnect is handled automatically when connection closes
|
if (connectionId && window.electronAPI) {
|
||||||
}
|
try {
|
||||||
if (connectionId) {
|
await window.electronAPI.sshDisconnect(connectionId)
|
||||||
const conn = getConnection(connectionId)
|
logger.info('SFTP connection closed', { connectionId, sftpId })
|
||||||
if (conn) {
|
} catch (err) {
|
||||||
// Cleanup
|
logger.error('Error closing SFTP connection', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsConnected(false)
|
setIsConnected(false)
|
||||||
setSftpId(null)
|
setSftpId(null)
|
||||||
setConnectionId(null)
|
setConnectionId(null)
|
||||||
setRemoteFiles([])
|
setRemoteFiles([])
|
||||||
}, [connectionId, sftpId, getConnection])
|
}, [connectionId, sftpId])
|
||||||
|
|
||||||
// Upload files (real implementation via IPC)
|
// Upload files (real implementation via IPC)
|
||||||
const uploadFiles = useCallback(async (files: FileList | null) => {
|
const uploadFiles = useCallback(async (files: FileList | null) => {
|
||||||
if (!files || files.length === 0 || !sftpId || !window.electronAPI) {
|
if (!files || files.length === 0 || !sftpId || !window.electronAPI) {
|
||||||
logWarning('uploadFiles: missing files, sftpId, or electronAPI')
|
logger.warn('uploadFiles: missing files, sftpId, or electronAPI')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +392,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
try {
|
try {
|
||||||
for (const task of newTasks) {
|
for (const task of newTasks) {
|
||||||
const remoteFilePath = `${remotePath}/${task.fileName}`.replace(/\/+/g, '/')
|
const remoteFilePath = `${remotePath}/${task.fileName}`.replace(/\/+/g, '/')
|
||||||
logInfo('Uploading', `${task.filePath} -> ${remoteFilePath}`)
|
logger.debug('Uploading', `${task.filePath} -> ${remoteFilePath}`)
|
||||||
|
|
||||||
// Mark as transferring
|
// Mark as transferring
|
||||||
setTransferQueue(prev => prev.map(t =>
|
setTransferQueue(prev => prev.map(t =>
|
||||||
@@ -429,12 +406,12 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
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
|
||||||
))
|
))
|
||||||
logSuccess(`Upload complete: ${task.fileName}`)
|
logger.info(`Upload complete: ${task.fileName}`)
|
||||||
} else {
|
} else {
|
||||||
setTransferQueue(prev => prev.map(t =>
|
setTransferQueue(prev => prev.map(t =>
|
||||||
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Upload failed' } : t
|
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Upload failed' } : t
|
||||||
))
|
))
|
||||||
logError(`Upload failed: ${task.fileName} - ${result.error}`)
|
logger.error(`Upload failed: ${task.fileName} - ${result.error}`)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error('Upload failed', err)
|
logger.error('Upload failed', err)
|
||||||
@@ -489,7 +466,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
try {
|
try {
|
||||||
for (const task of newTasks) {
|
for (const task of newTasks) {
|
||||||
const localFilePath = `${localPath}/${task.fileName}`
|
const localFilePath = `${localPath}/${task.fileName}`
|
||||||
logInfo('Downloading', `${task.filePath} -> ${localFilePath}`)
|
logger.debug('Downloading', `${task.filePath} -> ${localFilePath}`)
|
||||||
|
|
||||||
setTransferQueue(prev => prev.map(t =>
|
setTransferQueue(prev => prev.map(t =>
|
||||||
t.id === task.id ? { ...t, status: 'transferring', startTime: Date.now() } : t
|
t.id === task.id ? { ...t, status: 'transferring', startTime: Date.now() } : t
|
||||||
@@ -502,12 +479,12 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
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
|
||||||
))
|
))
|
||||||
logSuccess(`Download complete: ${task.fileName}`)
|
logger.info(`Download complete: ${task.fileName}`)
|
||||||
} else {
|
} else {
|
||||||
setTransferQueue(prev => prev.map(t =>
|
setTransferQueue(prev => prev.map(t =>
|
||||||
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Download failed' } : t
|
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Download failed' } : t
|
||||||
))
|
))
|
||||||
logError(`Download failed: ${task.fileName} - ${result.error}`)
|
logger.error(`Download failed: ${task.fileName} - ${result.error}`)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error('Download failed', err)
|
logger.error('Download failed', err)
|
||||||
@@ -527,27 +504,26 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
// Delete remote files/directories
|
// Delete remote files/directories
|
||||||
const deleteRemote = useCallback(async (files: FileItem[]): Promise<boolean> => {
|
const deleteRemote = useCallback(async (files: FileItem[]): Promise<boolean> => {
|
||||||
if (!sftpId || files.length === 0 || !window.electronAPI) {
|
if (!sftpId || files.length === 0 || !window.electronAPI) {
|
||||||
logWarning('deleteRemote: missing sftpId, files, or electronAPI')
|
logger.warn('deleteRemote: missing sftpId, files, or electronAPI')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let allSuccess = true
|
let allSuccess = true
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
logInfo('Deleting remote', `${file.path} (isDir: ${file.type === 'directory'})`)
|
logger.debug('Deleting remote', `${file.path} (isDir: ${file.type === 'directory'})`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.sftpDelete(sftpId, file.path, file.type === 'directory')
|
const result = await window.electronAPI.sftpDelete(sftpId, file.path, file.type === 'directory')
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
logSuccess(`Deleted: ${file.name}`)
|
logger.info(`Deleted: ${file.name}`)
|
||||||
} else {
|
} else {
|
||||||
logError(`Delete failed: ${file.name} - ${result.error}`)
|
logger.error(`Delete failed: ${file.name} - ${result.error}`)
|
||||||
allSuccess = false
|
allSuccess = false
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error('Delete failed', err)
|
logger.error(`Delete error: ${file.name} - ${err?.message}`, err)
|
||||||
logError(`Delete error: ${file.name} - ${err?.message}`)
|
|
||||||
allSuccess = false
|
allSuccess = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -560,6 +536,76 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
return allSuccess
|
return allSuccess
|
||||||
}, [sftpId, remotePath, loadRemoteDirectory])
|
}, [sftpId, remotePath, loadRemoteDirectory])
|
||||||
|
|
||||||
|
// Rename remote file/directory
|
||||||
|
const renameRemote = useCallback(async (file: FileItem, newName: string): Promise<boolean> => {
|
||||||
|
if (!sftpId || !window.electronAPI) {
|
||||||
|
logger.warn('renameRemote: missing sftpId or electronAPI')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirPath = file.path.substring(0, file.path.lastIndexOf('/'))
|
||||||
|
const newPath = dirPath ? `${dirPath}/${newName}` : `/${newName}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.sftpRename(sftpId, file.path, newPath)
|
||||||
|
if (result.success) {
|
||||||
|
logger.info(`Renamed: ${file.name} -> ${newName}`)
|
||||||
|
loadRemoteDirectory(remotePath)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
logger.error(`Rename failed: ${result.error}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`Rename error: ${err?.message}`, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [sftpId, remotePath, loadRemoteDirectory])
|
||||||
|
|
||||||
|
// Get remote file stat
|
||||||
|
const getRemoteStat = useCallback(async (filePath: string) => {
|
||||||
|
if (!sftpId || !window.electronAPI) {
|
||||||
|
logger.warn('getRemoteStat: missing sftpId or electronAPI')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.sftpStat(sftpId, filePath)
|
||||||
|
if (result.success && result.stats) {
|
||||||
|
return result.stats
|
||||||
|
} else {
|
||||||
|
logger.error(`Stat failed: ${result.error}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`Stat error: ${err?.message}`, err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [sftpId])
|
||||||
|
|
||||||
|
// Chmod remote file
|
||||||
|
const chmodRemote = useCallback(async (filePath: string, mode: number): Promise<boolean> => {
|
||||||
|
if (!sftpId || !window.electronAPI) {
|
||||||
|
logger.warn('chmodRemote: missing sftpId or electronAPI')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.sftpChmod(sftpId, filePath, mode)
|
||||||
|
if (result.success) {
|
||||||
|
logger.info(`Chmod success: ${filePath} -> ${mode.toString(8)}`)
|
||||||
|
loadRemoteDirectory(remotePath)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
logger.error(`Chmod failed: ${result.error}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`Chmod error: ${err?.message}`, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [sftpId, remotePath, loadRemoteDirectory])
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -605,6 +651,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
|||||||
|
|
||||||
// Remote file operations
|
// Remote file operations
|
||||||
deleteRemote,
|
deleteRemote,
|
||||||
|
renameRemote,
|
||||||
|
getRemoteStat,
|
||||||
|
chmodRemote,
|
||||||
|
|
||||||
sortField,
|
sortField,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
|
||||||
|
import { getSessionConfig, getConnectionStore, type ConnectionState } from '../stores/connection'
|
||||||
|
import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore'
|
||||||
|
import { createLogger } from '../utils/logger'
|
||||||
|
|
||||||
|
const logger = createLogger('useTerminalConnection')
|
||||||
|
|
||||||
|
export interface UseTerminalConnectionParams {
|
||||||
|
tabId: string
|
||||||
|
xtermRef: React.MutableRefObject<Terminal | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTerminalConnectionReturn {
|
||||||
|
connectionState: ConnectionState
|
||||||
|
statusMessage: string
|
||||||
|
showSuspendedOverlay: boolean
|
||||||
|
connectionIdRef: React.MutableRefObject<string | null>
|
||||||
|
shellIdRef: React.MutableRefObject<string | null>
|
||||||
|
hasConnectedRef: React.MutableRefObject<boolean>
|
||||||
|
isConnectingRef: React.MutableRefObject<boolean>
|
||||||
|
connect: () => Promise<void>
|
||||||
|
resumeConnection: () => Promise<void>
|
||||||
|
handleReconnect: () => void
|
||||||
|
saveSnapshot: () => void
|
||||||
|
restoreSnapshot: () => boolean
|
||||||
|
setShowSuspendedOverlay: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTerminalConnection({
|
||||||
|
tabId,
|
||||||
|
xtermRef,
|
||||||
|
}: UseTerminalConnectionParams): UseTerminalConnectionReturn {
|
||||||
|
const connectionIdRef = useRef<string | null>(null)
|
||||||
|
const shellIdRef = useRef<string | null>(null)
|
||||||
|
const hasConnectedRef = useRef(false)
|
||||||
|
const isConnectingRef = useRef(false)
|
||||||
|
|
||||||
|
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
||||||
|
const [statusMessage, setStatusMessage] = useState<string>('')
|
||||||
|
const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false)
|
||||||
|
|
||||||
|
const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore()
|
||||||
|
|
||||||
|
// 保存快照
|
||||||
|
const saveSnapshot = useCallback(() => {
|
||||||
|
if (!xtermRef.current || !connectionIdRef.current) return
|
||||||
|
|
||||||
|
useTerminalSnapshotStore.getState().createSnapshot(tabId, xtermRef.current, {
|
||||||
|
connectionId: connectionIdRef.current,
|
||||||
|
shellId: shellIdRef.current,
|
||||||
|
})
|
||||||
|
}, [tabId, xtermRef])
|
||||||
|
|
||||||
|
// 恢复快照
|
||||||
|
const restoreSnapshot = useCallback(() => {
|
||||||
|
if (!xtermRef.current) return false
|
||||||
|
|
||||||
|
const snapshot = useTerminalSnapshotStore.getState().getSnapshot(tabId)
|
||||||
|
if (!snapshot) return false
|
||||||
|
|
||||||
|
// 清屏
|
||||||
|
xtermRef.current.clear()
|
||||||
|
|
||||||
|
// 恢复内容
|
||||||
|
const content = snapshot.buffer.join('\r\n')
|
||||||
|
if (content) {
|
||||||
|
xtermRef.current.write(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复光标
|
||||||
|
xtermRef.current.write(`\x1b[${snapshot.cursorY + 1};${snapshot.cursorX + 1}H`)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}, [tabId, xtermRef])
|
||||||
|
|
||||||
|
// 连接管理
|
||||||
|
const connect = useCallback(async () => {
|
||||||
|
if (isConnectingRef.current) {
|
||||||
|
logger.debug('[Terminal] Connection already in progress, skipping')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isConnectingRef.current = true
|
||||||
|
|
||||||
|
// 直接使用 getTabById,避免 useCallback 循环问题
|
||||||
|
const currentTab = getTabById(tabId)
|
||||||
|
if (!currentTab) {
|
||||||
|
logger.debug('[Terminal] Tab not found:', tabId)
|
||||||
|
isConnectingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('[Terminal] Starting connection for tab:', tabId, 'sessionId:', currentTab.sessionId)
|
||||||
|
|
||||||
|
// 检查是否已有连接
|
||||||
|
if (connectionIdRef.current) {
|
||||||
|
const conn = getConnectionStore().getConnection(connectionIdRef.current)
|
||||||
|
if (conn) {
|
||||||
|
if (conn.state === 'connected' || conn.state === 'background') {
|
||||||
|
// 已有活跃连接,恢复显示
|
||||||
|
shellIdRef.current = currentTab.connectionInstance?.shellId || null
|
||||||
|
setConnectionState(conn.state)
|
||||||
|
updateTabStatus(tabId, conn.state === 'connected' ? 'connected' : 'disconnected')
|
||||||
|
isConnectingRef.current = false
|
||||||
|
return
|
||||||
|
} else if (conn.state === 'suspended') {
|
||||||
|
// 连接被挂起,需要恢复
|
||||||
|
setShowSuspendedOverlay(true)
|
||||||
|
setConnectionState('suspended')
|
||||||
|
updateTabStatus(tabId, 'disconnected')
|
||||||
|
isConnectingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始连接
|
||||||
|
setConnectionState('connecting')
|
||||||
|
updateTabStatus(tabId, 'connecting')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取配置
|
||||||
|
let config: {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
username: string
|
||||||
|
password?: string
|
||||||
|
privateKey?: string
|
||||||
|
passphrase?: string
|
||||||
|
} | null = null
|
||||||
|
|
||||||
|
if (currentTab.connectionConfig) {
|
||||||
|
config = {
|
||||||
|
host: currentTab.connectionConfig.host,
|
||||||
|
port: currentTab.connectionConfig.port || 22,
|
||||||
|
username: currentTab.connectionConfig.username,
|
||||||
|
password: currentTab.connectionConfig.password,
|
||||||
|
privateKey: currentTab.connectionConfig.privateKey,
|
||||||
|
passphrase: currentTab.connectionConfig.passphrase,
|
||||||
|
}
|
||||||
|
} else if (currentTab.sessionId) {
|
||||||
|
config = await getSessionConfig(currentTab.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('无可用连接信息')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建连接
|
||||||
|
const result = await getConnectionStore().createConnection(tabId, config)
|
||||||
|
|
||||||
|
if (!result.success || !result.connectionId) {
|
||||||
|
throw new Error(result.error || '连接失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = result.connectionId
|
||||||
|
connectionIdRef.current = connectionId
|
||||||
|
|
||||||
|
// 创建 Shell - 先生成前端 shellId,确保数据不会丢失
|
||||||
|
const pendingShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
logger.debug('[Terminal] Pre-assigning shellId:', pendingShellId)
|
||||||
|
shellIdRef.current = pendingShellId
|
||||||
|
|
||||||
|
logger.debug('[Terminal] Creating shell for connection:', connectionId, 'with shellId:', pendingShellId)
|
||||||
|
const shellResult = await window.electronAPI?.sshCreateShell(connectionId, {
|
||||||
|
cols: xtermRef.current?.cols || 80,
|
||||||
|
rows: xtermRef.current?.rows || 24,
|
||||||
|
}, pendingShellId)
|
||||||
|
|
||||||
|
if (!shellResult?.success || !shellResult.shellId) {
|
||||||
|
logger.error('[Terminal] Shell creation failed:', shellResult?.error)
|
||||||
|
shellIdRef.current = null
|
||||||
|
await getConnectionStore().disconnectConnection(connectionId, 'shell creation failed')
|
||||||
|
throw new Error(shellResult?.error || '创建终端失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellId 应该和 pendingShellId 相同(由后端确认)
|
||||||
|
const confirmedShellId = shellResult.shellId
|
||||||
|
logger.debug('[Terminal] Shell confirmed with ID:', confirmedShellId)
|
||||||
|
// shellIdRef 已经是 confirmedShellId(等于 pendingShellId)
|
||||||
|
getConnectionStore().addShell(connectionId, confirmedShellId)
|
||||||
|
|
||||||
|
// 更新标签
|
||||||
|
const connectionInstance: ConnectionInstance = {
|
||||||
|
connectionId,
|
||||||
|
shellId: confirmedShellId,
|
||||||
|
}
|
||||||
|
updateTabConnectionInstance(tabId, connectionInstance)
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
setConnectionState('connected')
|
||||||
|
updateTabStatus(tabId, 'connected')
|
||||||
|
getConnectionStore().setActiveTab(connectionId, tabId)
|
||||||
|
|
||||||
|
// 更新标题
|
||||||
|
if (currentTab.title === '新终端' || currentTab.title === 'Terminal') {
|
||||||
|
updateTab(tabId, {
|
||||||
|
title: `${config.username}@${config.host}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待服务器返回真实的 Last login 信息和提示符
|
||||||
|
// 不手动写入任何内容,让 SSH 服务器的自然输出显示
|
||||||
|
|
||||||
|
xtermRef.current?.focus()
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
setConnectionState('error')
|
||||||
|
updateTabStatus(tabId, 'error')
|
||||||
|
setStatusMessage(`错误: ${error.message}`)
|
||||||
|
xtermRef.current?.writeln(`\r\n\x1b[31m错误: ${error.message}\x1b[0m`)
|
||||||
|
} finally {
|
||||||
|
isConnectingRef.current = false
|
||||||
|
hasConnectedRef.current = true
|
||||||
|
}
|
||||||
|
}, [tabId, updateTabConnectionInstance, updateTabStatus, getTabById, xtermRef])
|
||||||
|
|
||||||
|
// 恢复挂起的连接
|
||||||
|
const resumeConnection = useCallback(async () => {
|
||||||
|
if (!connectionIdRef.current) return
|
||||||
|
|
||||||
|
setConnectionState('reconnecting')
|
||||||
|
const resumed = await getConnectionStore().resumeConnection(connectionIdRef.current)
|
||||||
|
|
||||||
|
if (resumed) {
|
||||||
|
// 获取新的连接 ID
|
||||||
|
const currentTab = getTabById(tabId)
|
||||||
|
if (currentTab?.connectionInstance?.connectionId) {
|
||||||
|
connectionIdRef.current = currentTab.connectionInstance.connectionId
|
||||||
|
|
||||||
|
// 创建新的 shell - 预分配 shellId 防止数据丢失
|
||||||
|
const newShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
shellIdRef.current = newShellId
|
||||||
|
|
||||||
|
const shellResult = await window.electronAPI?.sshCreateShell(connectionIdRef.current, {
|
||||||
|
cols: xtermRef.current?.cols || 80,
|
||||||
|
rows: xtermRef.current?.rows || 24,
|
||||||
|
}, newShellId)
|
||||||
|
|
||||||
|
if (shellResult?.success && shellResult.shellId) {
|
||||||
|
// shellId 应该和 newShellId 相同
|
||||||
|
getConnectionStore().addShell(connectionIdRef.current, shellResult.shellId)
|
||||||
|
|
||||||
|
updateTabConnectionInstance(tabId, {
|
||||||
|
connectionId: connectionIdRef.current,
|
||||||
|
shellId: shellResult.shellId,
|
||||||
|
})
|
||||||
|
|
||||||
|
setConnectionState('connected')
|
||||||
|
updateTabStatus(tabId, 'connected')
|
||||||
|
setShowSuspendedOverlay(false)
|
||||||
|
|
||||||
|
// 恢复连接成功,等待服务器发送提示符
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复失败,重新连接
|
||||||
|
setConnectionState('disconnected')
|
||||||
|
hasConnectedRef.current = false
|
||||||
|
setShowSuspendedOverlay(false)
|
||||||
|
connect()
|
||||||
|
}, [connect, updateTabConnectionInstance, updateTabStatus, getTabById, tabId, xtermRef])
|
||||||
|
|
||||||
|
// 重连功能
|
||||||
|
const handleReconnect = useCallback(() => {
|
||||||
|
if (connectionState === 'error' || connectionState === 'disconnected' || connectionState === 'suspended') {
|
||||||
|
if (connectionIdRef.current) {
|
||||||
|
getConnectionStore().disconnectConnection(connectionIdRef.current, 'manual reconnect')
|
||||||
|
}
|
||||||
|
connectionIdRef.current = null
|
||||||
|
shellIdRef.current = null
|
||||||
|
hasConnectedRef.current = false
|
||||||
|
setConnectionState('disconnected')
|
||||||
|
setShowSuspendedOverlay(false)
|
||||||
|
// 清除 needsReconnect 标志
|
||||||
|
updateTab(tabId, { needsReconnect: false })
|
||||||
|
xtermRef.current?.clear()
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
}, [connectionState, connect, updateTab, tabId, xtermRef])
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionState,
|
||||||
|
statusMessage,
|
||||||
|
showSuspendedOverlay,
|
||||||
|
connectionIdRef,
|
||||||
|
shellIdRef,
|
||||||
|
hasConnectedRef,
|
||||||
|
isConnectingRef,
|
||||||
|
connect,
|
||||||
|
resumeConnection,
|
||||||
|
handleReconnect,
|
||||||
|
saveSnapshot,
|
||||||
|
restoreSnapshot,
|
||||||
|
setShowSuspendedOverlay,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
import { SearchAddon } from '@xterm/addon-search'
|
||||||
|
|
||||||
|
const searchDecorations = {
|
||||||
|
matchBackground: '#3b82f633',
|
||||||
|
matchBorder: '#3b82f666',
|
||||||
|
matchOverviewRuler: '#3b82f6',
|
||||||
|
activeMatchBackground: '#3b82f6',
|
||||||
|
activeMatchBorder: '#60a5fa',
|
||||||
|
activeMatchColorOverviewRuler: '#60a5fa',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTerminalSearchParams {
|
||||||
|
xtermRef: React.MutableRefObject<Terminal | null>
|
||||||
|
searchAddonRef: React.MutableRefObject<SearchAddon | null>
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTerminalSearchReturn {
|
||||||
|
showSearch: boolean
|
||||||
|
searchQuery: string
|
||||||
|
searchResultCount: number | undefined
|
||||||
|
searchResultIndex: number | undefined
|
||||||
|
searchInputRef: React.MutableRefObject<HTMLInputElement | null>
|
||||||
|
handleSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
handleSearchNext: () => void
|
||||||
|
handleSearchPrevious: () => void
|
||||||
|
handleCloseSearch: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTerminalSearch({
|
||||||
|
xtermRef,
|
||||||
|
searchAddonRef,
|
||||||
|
isActive,
|
||||||
|
}: UseTerminalSearchParams): UseTerminalSearchReturn {
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
}, [searchAddonRef])
|
||||||
|
|
||||||
|
const handleSearchNext = useCallback(() => {
|
||||||
|
if (searchAddonRef.current && searchQuery) {
|
||||||
|
searchAddonRef.current.findNext(searchQuery, { decorations: searchDecorations })
|
||||||
|
}
|
||||||
|
}, [searchQuery, searchAddonRef])
|
||||||
|
|
||||||
|
const handleSearchPrevious = useCallback(() => {
|
||||||
|
if (searchAddonRef.current && searchQuery) {
|
||||||
|
searchAddonRef.current.findPrevious(searchQuery, { decorations: searchDecorations })
|
||||||
|
}
|
||||||
|
}, [searchQuery, searchAddonRef])
|
||||||
|
|
||||||
|
const handleCloseSearch = useCallback(() => {
|
||||||
|
setShowSearch(false)
|
||||||
|
searchAddonRef.current?.clearDecorations()
|
||||||
|
setSearchQuery('')
|
||||||
|
setSearchResultCount(undefined)
|
||||||
|
setSearchResultIndex(undefined)
|
||||||
|
xtermRef.current?.focus()
|
||||||
|
}, [xtermRef, searchAddonRef])
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}, [searchAddonRef])
|
||||||
|
|
||||||
|
return {
|
||||||
|
showSearch,
|
||||||
|
searchQuery,
|
||||||
|
searchResultCount,
|
||||||
|
searchResultIndex,
|
||||||
|
searchInputRef,
|
||||||
|
handleSearchChange,
|
||||||
|
handleSearchNext,
|
||||||
|
handleSearchPrevious,
|
||||||
|
handleCloseSearch,
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-42
@@ -1,53 +1,48 @@
|
|||||||
/**
|
/**
|
||||||
* useToast — 全局 Toast 通知 Hook
|
* useToast - Toast 通知系统
|
||||||
*
|
*
|
||||||
* 提供便捷的通知调用方法,可在任何组件中使用
|
* 轻量级通知提示,用于操作反馈
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const toast = useToast()
|
|
||||||
* toast.success('保存成功')
|
|
||||||
* toast.error('连接失败', { duration: 5000 })
|
|
||||||
* toast.warning('磁盘空间不足')
|
|
||||||
* toast.info('正在同步...')
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useToastStore, type ToastType, type ToastOptions } from '../stores/toastStore'
|
import { useState, useCallback } from 'react'
|
||||||
|
|
||||||
/** Toast 操作接口 */
|
export type ToastType = 'info' | 'success' | 'error' | 'warning'
|
||||||
export interface ToastApi {
|
|
||||||
/** 显示成功通知(绿色) */
|
export interface Toast {
|
||||||
success: (message: string, options?: ToastOptions) => string
|
id: string
|
||||||
/** 显示错误通知(红色) */
|
type: ToastType
|
||||||
error: (message: string, options?: ToastOptions) => string
|
message: string
|
||||||
/** 显示警告通知(琥珀色) */
|
duration?: number
|
||||||
warning: (message: string, options?: ToastOptions) => string
|
|
||||||
/** 显示信息通知(蓝色) */
|
|
||||||
info: (message: string, options?: ToastOptions) => string
|
|
||||||
/** 移除指定通知 */
|
|
||||||
remove: (id: string) => void
|
|
||||||
/** 清空所有通知 */
|
|
||||||
clearAll: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const DEFAULT_DURATION = 3000
|
||||||
* 全局 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) =>
|
export function useToast() {
|
||||||
addToast(type, message, options)
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
|
|
||||||
return {
|
const addToast = useCallback((type: ToastType, message: string, duration?: number) => {
|
||||||
success: create('success'),
|
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`
|
||||||
error: create('error'),
|
const toast: Toast = { id, type, message, duration: duration ?? DEFAULT_DURATION }
|
||||||
warning: create('warning'),
|
setToasts(prev => [...prev, toast])
|
||||||
info: create('info'),
|
|
||||||
remove: removeToast,
|
// Auto-remove after duration
|
||||||
clearAll,
|
setTimeout(() => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id))
|
||||||
|
}, toast.duration)
|
||||||
|
|
||||||
|
return id
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toast = {
|
||||||
|
info: (message: string, duration?: number) => addToast('info', message, duration),
|
||||||
|
success: (message: string, duration?: number) => addToast('success', message, duration),
|
||||||
|
error: (message: string, duration?: number) => addToast('error', message, duration ?? 5000),
|
||||||
|
warning: (message: string, duration?: number) => addToast('warning', message, duration),
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export default useToast
|
return { toasts, toast, removeToast }
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export interface AppSettings {
|
|||||||
// 外观
|
// 外观
|
||||||
theme: 'dark' | 'light' | 'system'
|
theme: 'dark' | 'light' | 'system'
|
||||||
language: 'zh-CN' | 'en-US'
|
language: 'zh-CN' | 'en-US'
|
||||||
|
sidebarWidth: number
|
||||||
|
|
||||||
// 终端
|
// 终端
|
||||||
fontFamily: string
|
fontFamily: string
|
||||||
@@ -42,6 +43,7 @@ const defaultSettings: AppSettings = {
|
|||||||
showNotifications: false,
|
showNotifications: false,
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
|
sidebarWidth: 256,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
terminalTheme: 'dracula',
|
terminalTheme: 'dracula',
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ interface TabState {
|
|||||||
closeLeftTabs: (tabId: string) => Tab[]
|
closeLeftTabs: (tabId: string) => Tab[]
|
||||||
closeAllTabs: () => Tab[]
|
closeAllTabs: () => Tab[]
|
||||||
activateTab: (tabId: string) => void
|
activateTab: (tabId: string) => void
|
||||||
|
activateTabByIndex: (index: number) => void
|
||||||
|
switchToNextTab: () => void
|
||||||
|
switchToPrevTab: () => void
|
||||||
updateTabTitle: (tabId: string, title: string) => void
|
updateTabTitle: (tabId: string, title: string) => void
|
||||||
|
moveTab: (fromIndex: number, toIndex: number) => void
|
||||||
getTabById: (tabId: string) => Tab | undefined
|
getTabById: (tabId: string) => Tab | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +235,31 @@ export const useTabStore = create<TabState>()(
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
activateTabByIndex: (index) => {
|
||||||
|
const { tabs } = get()
|
||||||
|
if (index < 0 || index >= tabs.length) return
|
||||||
|
const targetTab = tabs[index]
|
||||||
|
if (targetTab) {
|
||||||
|
get().activateTab(targetTab.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
switchToNextTab: () => {
|
||||||
|
const { tabs, activeTabId } = get()
|
||||||
|
if (tabs.length <= 1) return
|
||||||
|
const currentIndex = tabs.findIndex(t => t.id === activeTabId)
|
||||||
|
const nextIndex = (currentIndex + 1) % tabs.length
|
||||||
|
get().activateTab(tabs[nextIndex].id)
|
||||||
|
},
|
||||||
|
|
||||||
|
switchToPrevTab: () => {
|
||||||
|
const { tabs, activeTabId } = get()
|
||||||
|
if (tabs.length <= 1) return
|
||||||
|
const currentIndex = tabs.findIndex(t => t.id === activeTabId)
|
||||||
|
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length
|
||||||
|
get().activateTab(tabs[prevIndex].id)
|
||||||
|
},
|
||||||
|
|
||||||
updateTabTitle: (tabId, title) => {
|
updateTabTitle: (tabId, title) => {
|
||||||
const { tabs } = get()
|
const { tabs } = get()
|
||||||
const updatedTabs = tabs.map(t =>
|
const updatedTabs = tabs.map(t =>
|
||||||
@@ -239,6 +268,29 @@ export const useTabStore = create<TabState>()(
|
|||||||
set({ tabs: updatedTabs })
|
set({ tabs: updatedTabs })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
moveTab: (fromIndex, toIndex) => {
|
||||||
|
const { tabs, activeTabId } = get()
|
||||||
|
if (
|
||||||
|
fromIndex === toIndex ||
|
||||||
|
fromIndex < 0 ||
|
||||||
|
toIndex < 0 ||
|
||||||
|
fromIndex >= tabs.length ||
|
||||||
|
toIndex >= tabs.length
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Immutable reorder
|
||||||
|
const newTabs = [...tabs]
|
||||||
|
const [moved] = newTabs.splice(fromIndex, 1)
|
||||||
|
newTabs.splice(toIndex, 0, moved)
|
||||||
|
// Re-apply isActive based on activeTabId
|
||||||
|
const reordered = newTabs.map(t => ({
|
||||||
|
...t,
|
||||||
|
isActive: t.id === activeTabId,
|
||||||
|
}))
|
||||||
|
set({ tabs: reordered })
|
||||||
|
},
|
||||||
|
|
||||||
getTabById: (tabId) => {
|
getTabById: (tabId) => {
|
||||||
return get().tabs.find(t => t.id === tabId)
|
return get().tabs.find(t => t.id === tabId)
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user