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
+2683
-873
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 问题,建立基础反馈系统,产品具备日常使用价值
|
||||
|
||||
### 🔴 P0 — 必须完成
|
||||
### ✅ 全部完成 (提交: 53274e2 + d344900)
|
||||
|
||||
| # | 任务 | 类型 | 预计 | 验收标准 |
|
||||
|---|------|------|------|----------|
|
||||
| 1 | Toast 通知系统 | 新增 | 2天 | `useToast()` hook + `ToastContainer`,支持 success/error/warning/info 四种类型,自动消失+手动关闭 |
|
||||
| 2 | 替换所有 alert/prompt/confirm | 修复 | 2天 | `App.tsx` 5处 alert → Toast,`SFTPView.tsx` prompt → 内联输入,`PortForwardView.tsx` confirm → ConfirmDialog |
|
||||
| 3 | SFTP 上传/删除真实实现 | 修复 | 3天 | 上传通过 ssh2 sftp fastPut 实现,删除通过 rimraf/unlink 实现,右键菜单 action 绑定真实函数 |
|
||||
| 4 | 终端搜索 (SearchAddon) | 新增 | 1天 | Ctrl+Shift+F 打开搜索栏,支持正则/大小写/全词匹配,高亮所有匹配 |
|
||||
| 5 | 会话恢复 | 新增 | 3天 | 关闭前持久化标签状态到 SQLite,启动时恢复标签+自动重连 |
|
||||
| 6 | 修复私钥浏览按钮 | 修复 | 0.5天 | dialog.showOpenDialog() 绑定到 onClick,过滤 `.pem/.key/id_*` |
|
||||
| 7 | 清理生产调试日志 | 修复 | 1天 | 移除 `DEBUG=true`,所有 console.log 用 logger 替代或删除 |
|
||||
| 8 | 设计系统统一颜色 | 修复 | 1天 | cyan→accent-blue, sky→accent-blue, slate→dark-*,新增 accent-orange |
|
||||
| 9 | StatusBar 接入实际状态 | 修复 | 0.5天 | 显示当前连接 host、延迟 ms、标签数 |
|
||||
| 10 | WelcomeView 真实最近连接 | 修复 | 1天 | 从 sessionStore 获取最近 5 条连接记录,空状态显示引导 |
|
||||
| # | 任务 | 状态 | 提交 |
|
||||
|---|------|------|------|
|
||||
| 1 | Toast 通知系统 | ✅ | d344900 |
|
||||
| 2 | 替换所有 alert/prompt/confirm | ✅ | d344900 |
|
||||
| 3 | SFTP 上传/删除真实实现 | ✅ | d344900 |
|
||||
| 4 | 终端搜索 (SearchAddon) | ✅ | d344900 |
|
||||
| 5 | 会话恢复 (标签持久化) | ✅ | d344900 |
|
||||
| 6 | 修复私钥浏览按钮 | ✅ | 53274e2 |
|
||||
| 7 | 清理生产调试日志 | ✅ | 53274e2 |
|
||||
| 8 | 设计系统统一颜色 | ✅ | 53274e2 |
|
||||
| 9 | StatusBar 接入实际状态 | ✅ | 53274e2 |
|
||||
| 10 | WelcomeView 真实最近连接 | ✅ | 53274e2 |
|
||||
| 11 | better-sqlite3 → sql.js 迁移 | ✅ | 53274e2 |
|
||||
|
||||
### 里程碑推送
|
||||
- [ ] SFTP 功能可用 (上传+删除+右键菜单)
|
||||
- [ ] Toast 系统上线 (零 alert)
|
||||
- [ ] 终端搜索可用
|
||||
- [ ] 会话恢复可用
|
||||
### 里程碑
|
||||
- [x] SFTP 功能可用 (上传+删除+右键菜单)
|
||||
- [x] Toast 系统上线 (零 alert)
|
||||
- [x] 终端搜索可用
|
||||
- [x] 会话恢复可用
|
||||
|
||||
---
|
||||
|
||||
## v1.2.0 体验升级 (2 周)
|
||||
## v1.2.0 体验升级 (2 周) — 🚧 进行中
|
||||
|
||||
> **目标**: 快捷键补全、视觉打磨、交互优化,接近 Termius 体验水平
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export const SFTP_CHANNELS = {
|
||||
RENAME: 'sftp:rename',
|
||||
DELETE: 'sftp:delete',
|
||||
MKDIR: 'sftp:mkdir',
|
||||
STAT: 'sftp:stat',
|
||||
CHMOD: 'sftp:chmod',
|
||||
PROGRESS: 'sftp:progress',
|
||||
} as const
|
||||
|
||||
@@ -193,4 +195,46 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
|
||||
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),
|
||||
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),
|
||||
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) =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
+86
-19
@@ -1,15 +1,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import TabBar from './components/TabBar'
|
||||
import TabContent from './components/TabContent'
|
||||
import StatusBar from './components/StatusBar'
|
||||
import SessionDialog from './components/SessionDialog'
|
||||
import QuickConnectDialog from './components/QuickConnectDialog'
|
||||
import { ToastProvider } from './components/common/Toast'
|
||||
import { useTabStore } from './stores/tabStore'
|
||||
import { useSessionStore, type Session } from './stores'
|
||||
import { createLogger } from './utils/logger'
|
||||
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')
|
||||
|
||||
@@ -19,7 +21,33 @@ function App() {
|
||||
const [editSession, setEditSession] = useState<Session | null>(null)
|
||||
const { activeTabId } = useTabStore()
|
||||
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 () => {
|
||||
@@ -198,30 +226,67 @@ function App() {
|
||||
}, [sessions])
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="flex flex-col h-screen bg-dark-900 text-gray-200">
|
||||
{/* Title bar spacer for macOS */}
|
||||
<div className="h-8 bg-dark-900 drag"></div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
onNewSession={() => {
|
||||
setEditSession(null)
|
||||
setIsSessionDialogOpen(true)
|
||||
}}
|
||||
onQuickConnect={() => setIsQuickConnectOpen(true)}
|
||||
onEditSession={(session) => {
|
||||
setEditSession(session)
|
||||
setIsSessionDialogOpen(true)
|
||||
}}
|
||||
/>
|
||||
{/* 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
|
||||
onNewSession={() => {
|
||||
setEditSession(null)
|
||||
setIsSessionDialogOpen(true)
|
||||
if (isMobile) closeMobileSidebar()
|
||||
}}
|
||||
onQuickConnect={() => {
|
||||
setIsQuickConnectOpen(true)
|
||||
if (isMobile) closeMobileSidebar()
|
||||
}}
|
||||
onEditSession={(session) => {
|
||||
setEditSession(session)
|
||||
setIsSessionDialogOpen(true)
|
||||
if (isMobile) closeMobileSidebar()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Tab bar */}
|
||||
<TabBar />
|
||||
{/* 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 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
@@ -254,8 +319,10 @@ function App() {
|
||||
isOpen={isQuickConnectOpen}
|
||||
onClose={() => setIsQuickConnectOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+152
-10
@@ -5,15 +5,24 @@
|
||||
*/
|
||||
|
||||
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 { useToast } from '../hooks/useToast'
|
||||
import { FileListPanel } from './sftp/FileListPanel'
|
||||
import { TransferPanel } from './sftp/TransferPanel'
|
||||
import { Toolbar } from './sftp/Toolbar'
|
||||
import { ConnectionPanel } from './sftp/ConnectionPanel'
|
||||
import { ContextMenu, type MenuItem } from './sftp/ContextMenu'
|
||||
import { ToastContainer } from './common/ToastContainer'
|
||||
import EmptyState from './common/EmptyState'
|
||||
import { sortFiles } from './sftp/utils'
|
||||
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'
|
||||
|
||||
interface SFTPViewProps {
|
||||
@@ -51,9 +60,13 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
uploadFiles,
|
||||
downloadFiles,
|
||||
deleteRemote,
|
||||
renameRemote,
|
||||
getRemoteStat,
|
||||
chmodRemote,
|
||||
error,
|
||||
} = useSFTP({ tabId })
|
||||
|
||||
const { toasts, toast, removeToast } = useToast()
|
||||
const { prompt, PromptDialogComponent } = usePromptDialog()
|
||||
|
||||
// UI State
|
||||
@@ -63,6 +76,13 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
const [showConnectionSelector, setShowConnectionSelector] = useState(false)
|
||||
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
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (sortField === field) {
|
||||
@@ -93,6 +113,61 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
})
|
||||
}, [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
|
||||
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
||||
e.preventDefault()
|
||||
@@ -107,9 +182,15 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
label: '上传',
|
||||
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 })
|
||||
}, [navigateLocal])
|
||||
}, [navigateLocal, handleCopyPath, handleShowProperties])
|
||||
|
||||
// Remote context menu
|
||||
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
||||
@@ -128,17 +209,22 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
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" />,
|
||||
label: '删除',
|
||||
danger: true,
|
||||
action: () => {
|
||||
deleteRemote([file])
|
||||
},
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: '属性',
|
||||
action: () => handleShowProperties(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 })
|
||||
}, [navigateRemote, downloadFiles, deleteRemote, setRemoteSelected])
|
||||
}, [navigateRemote, downloadFiles, deleteRemote, setRemoteSelected, handleRename, handleCopyPath, handleShowProperties, handleChmod])
|
||||
|
||||
// Navigate up
|
||||
const navigateLocalUp = useCallback(() => {
|
||||
@@ -164,7 +250,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
}
|
||||
}, [navigateRemote])
|
||||
|
||||
// Handle upload
|
||||
// Handle upload (via file picker)
|
||||
const handleUpload = useCallback(() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
@@ -175,6 +261,20 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
input.click()
|
||||
}, [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
|
||||
const handleNewFolder = useCallback(async () => {
|
||||
const name = await prompt({
|
||||
@@ -216,6 +316,15 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
{/* Local Panel */}
|
||||
<FileListPanel
|
||||
@@ -261,8 +370,11 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
onSort={handleSort}
|
||||
contextMenu={contextMenu}
|
||||
onCloseContextMenu={() => setContextMenu(null)}
|
||||
onDropFiles={isConnected ? handleDropFiles : undefined}
|
||||
loading={isConnecting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transfer Panel */}
|
||||
<TransferPanel
|
||||
@@ -283,6 +395,36 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
+161
-87
@@ -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 { useSessionStore, Session } from '../stores'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { SessionTree } from './sidebar/SessionTree'
|
||||
import { SearchBox } from './sidebar/SearchBox'
|
||||
import { useConfirmDialog } from './common/ConfirmDialog'
|
||||
// Sidebar Component
|
||||
|
||||
const MIN_SIDEBAR_WIDTH = 180
|
||||
const MAX_SIDEBAR_WIDTH = 400
|
||||
|
||||
interface SidebarProps {
|
||||
onNewSession: () => void
|
||||
@@ -19,6 +23,7 @@ interface SidebarProps {
|
||||
|
||||
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) {
|
||||
const { sessions, setSessions, removeSession } = useSessionStore()
|
||||
const { settings, setSetting } = useSettingsStore()
|
||||
const { addTab } = useTabStore()
|
||||
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 [selectedSessionId, setSelectedSessionId] = useState<string | 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 sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Sync width from settings on load
|
||||
useEffect(() => {
|
||||
if (settings.sidebarWidth && settings.sidebarWidth !== sidebarWidth) {
|
||||
setSidebarWidth(settings.sidebarWidth)
|
||||
}
|
||||
}, [settings.sidebarWidth])
|
||||
|
||||
// Load sessions
|
||||
useEffect(() => {
|
||||
@@ -35,6 +51,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
if (result.success && result.sessions) {
|
||||
setSessions(result.sessions)
|
||||
}
|
||||
setSessionsLoading(false)
|
||||
})
|
||||
}, [setSessions])
|
||||
|
||||
@@ -49,6 +66,44 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
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 newExpanded = new Set(expandedGroups)
|
||||
if (newExpanded.has(group)) newExpanded.delete(group)
|
||||
@@ -120,95 +175,114 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-64 bg-dark-800 border-r border-dark-700 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center">
|
||||
<Server className="w-5 h-5 text-white" />
|
||||
<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 */}
|
||||
<div className="p-4 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center">
|
||||
<Server className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold text-lg">OpsXShell</span>
|
||||
</div>
|
||||
<span className="font-semibold text-lg">OpsXShell</span>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onQuickConnect}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-accent-blue hover:bg-blue-600 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
快速连接
|
||||
</button>
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
className="flex items-center justify-center px-3 py-2 bg-dark-700 hover:bg-dark-600 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-3 border-b border-dark-700">
|
||||
<SearchBox
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="搜索会话..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Session Tree */}
|
||||
<SessionTree
|
||||
sessions={sessions}
|
||||
expandedGroups={expandedGroups}
|
||||
selectedSessionId={selectedSessionId}
|
||||
onToggleGroup={toggleGroup}
|
||||
onSelectSession={handleConnect}
|
||||
onContextMenu={handleContextMenu}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => addTab({ title: '设置', type: 'settings' })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>设置</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[140px]"
|
||||
>
|
||||
{[
|
||||
{ label: '连接', action: 'connect' },
|
||||
{ label: '编辑', action: 'edit' },
|
||||
{ label: '克隆', action: 'duplicate' },
|
||||
{ label: 'SFTP 传输', action: 'sftp' },
|
||||
{ label: '删除', action: 'delete', danger: true },
|
||||
].map((item) => (
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
key={item.action}
|
||||
onClick={() => handleContextMenuAction(item.action)}
|
||||
className={`
|
||||
w-full px-4 py-2 text-left text-sm transition-colors
|
||||
${item.danger
|
||||
? 'text-red-400 hover:bg-red-500/20'
|
||||
: 'text-gray-300 hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
onClick={onQuickConnect}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-accent-blue hover:bg-blue-600 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
<Zap className="w-4 h-4" />
|
||||
快速连接
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
className="flex items-center justify-center px-3 py-2 bg-dark-700 hover:bg-dark-600 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-3 border-b border-dark-700">
|
||||
<SearchBox
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="搜索会话..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Session Tree */}
|
||||
<SessionTree
|
||||
sessions={sessions}
|
||||
expandedGroups={expandedGroups}
|
||||
selectedSessionId={selectedSessionId}
|
||||
onToggleGroup={toggleGroup}
|
||||
onSelectSession={handleConnect}
|
||||
onContextMenu={handleContextMenu}
|
||||
searchTerm={searchTerm}
|
||||
loading={sessionsLoading}
|
||||
onNewSession={onNewSession}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => addTab({ title: '设置', type: 'settings' })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>设置</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[140px]"
|
||||
>
|
||||
{[
|
||||
{ label: '连接', action: 'connect' },
|
||||
{ label: '编辑', action: 'edit' },
|
||||
{ label: '克隆', action: 'duplicate' },
|
||||
{ label: 'SFTP 传输', action: 'sftp' },
|
||||
{ label: '删除', action: 'delete', danger: true },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.action}
|
||||
onClick={() => handleContextMenuAction(item.action)}
|
||||
className={`
|
||||
w-full px-4 py-2 text-left text-sm transition-colors
|
||||
${item.danger
|
||||
? 'text-red-400 hover:bg-red-500/20'
|
||||
: 'text-gray-300 hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</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}
|
||||
</>
|
||||
|
||||
+72
-17
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* TabBar - 标签栏组件 (简化版)
|
||||
* TabBar - 标签栏组件(支持拖拽排序)
|
||||
*/
|
||||
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useConnectionPool } from '../stores/connection'
|
||||
import { TabItem } from './tabbar/TabItem'
|
||||
@@ -20,9 +20,15 @@ export default function TabBar() {
|
||||
closeRightTabs,
|
||||
closeLeftTabs,
|
||||
closeAllTabs,
|
||||
moveTab,
|
||||
} = useTabStore()
|
||||
|
||||
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({
|
||||
isOpen: false,
|
||||
@@ -30,20 +36,6 @@ export default function TabBar() {
|
||||
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 tab = tabs.find(t => t.id === tabId)
|
||||
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
|
||||
|
||||
return (
|
||||
<div className="flex items-center bg-dark-900 border-b border-dark-700 overflow-x-auto">
|
||||
{/* Tab List */}
|
||||
{tabs.map((tab) => (
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
index={index}
|
||||
isActive={tab.id === activeTabId}
|
||||
isDragging={dragFromIndex === index}
|
||||
dragOverIndex={dragOverIndex}
|
||||
onActivate={() => activateTab(tab.id)}
|
||||
onClose={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCloseTab(tab.id)
|
||||
}}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
+80
-506
@@ -1,15 +1,18 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { Search, ChevronUp, ChevronDown, X, Terminal as TerminalIcon } from 'lucide-react'
|
||||
import { themes } from '../themes/terminal'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
|
||||
import { getSessionConfig, getConnectionStore, type ConnectionState } from '../stores/connection'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { getConnectionStore } from '../stores/connection'
|
||||
import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore'
|
||||
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'
|
||||
|
||||
const logger = createLogger('TerminalView')
|
||||
@@ -19,125 +22,63 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
const xtermRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
const searchAddonRef = useRef<SearchAddon | null>(null)
|
||||
const shellIdRef = useRef<string | null>(null)
|
||||
const connectionIdRef = useRef<string | null>(null)
|
||||
const hasConnectedRef = useRef(false)
|
||||
const isConnectingRef = useRef(false)
|
||||
const handleShellDataRef = useRef<((_event: unknown, _connId: string, shellId: string, data: Buffer) => void) | null>(null)
|
||||
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 { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore()
|
||||
const { getTabById } = useTabStore()
|
||||
const snapshotStore = useTerminalSnapshotStore()
|
||||
|
||||
|
||||
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(() => {
|
||||
loadSettings()
|
||||
}, [loadSettings])
|
||||
|
||||
// 搜索相关回调
|
||||
const searchDecorations = {
|
||||
matchBackground: '#3b82f633',
|
||||
matchBorder: '#3b82f666',
|
||||
matchOverviewRuler: '#3b82f6',
|
||||
activeMatchBackground: '#3b82f6',
|
||||
activeMatchBorder: '#60a5fa',
|
||||
activeMatchColorOverviewRuler: '#60a5fa',
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value
|
||||
setSearchQuery(query)
|
||||
if (query) {
|
||||
searchAddonRef.current?.findNext(query, { decorations: searchDecorations, incremental: true })
|
||||
} else {
|
||||
searchAddonRef.current?.clearDecorations()
|
||||
setSearchResultCount(undefined)
|
||||
setSearchResultIndex(undefined)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSearchNext = useCallback(() => {
|
||||
if (searchAddonRef.current && searchQuery) {
|
||||
searchAddonRef.current.findNext(searchQuery, { decorations: searchDecorations })
|
||||
}
|
||||
}, [searchQuery])
|
||||
|
||||
const handleSearchPrevious = useCallback(() => {
|
||||
if (searchAddonRef.current && searchQuery) {
|
||||
searchAddonRef.current.findPrevious(searchQuery, { decorations: searchDecorations })
|
||||
}
|
||||
}, [searchQuery])
|
||||
|
||||
const handleCloseSearch = useCallback(() => {
|
||||
setShowSearch(false)
|
||||
searchAddonRef.current?.clearDecorations()
|
||||
setSearchQuery('')
|
||||
setSearchResultCount(undefined)
|
||||
setSearchResultIndex(undefined)
|
||||
xtermRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Ctrl+F / Cmd+F 打开搜索栏,Escape 关闭
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
// 只在当前标签激活时拦截
|
||||
if (!isActive) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!showSearch) {
|
||||
setShowSearch(true)
|
||||
// 延迟聚焦输入框,等 DOM 更新
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50)
|
||||
} else {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape' && showSearch) {
|
||||
handleCloseSearch()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isActive, showSearch, handleCloseSearch])
|
||||
|
||||
// 监听 SearchAddon 的 onDidChangeResults 事件
|
||||
useEffect(() => {
|
||||
const addon = searchAddonRef.current
|
||||
if (!addon) return
|
||||
const disposable = addon.onDidChangeResults((e) => {
|
||||
setSearchResultCount(e.resultCount)
|
||||
setSearchResultIndex(e.resultIndex !== undefined ? e.resultIndex + 1 : undefined)
|
||||
})
|
||||
return () => disposable.dispose()
|
||||
}, [])
|
||||
|
||||
// 初始化 xterm
|
||||
useEffect(() => {
|
||||
// 修复:只检查 terminalRef,不检查 xtermRef
|
||||
// 因为在严格模式下,组件会 unmount/remount,xtermRef 可能还存在
|
||||
// 但监听器需要重新注册
|
||||
if (!terminalRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 处理窗口大小调整
|
||||
const handleResize = () => {
|
||||
fitAddonRef.current?.fit()
|
||||
@@ -207,36 +148,28 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
|
||||
// IP 地址高亮处理 - 将包含 "from" 或 "Last login" 行中的 IP 地址标记为绿色
|
||||
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
|
||||
// 如果包含 Last login 或 from IP,将 IP 地址替换为绿色
|
||||
if (text.includes('Last login') || text.includes(' from ')) {
|
||||
return text.replace(ipRegex, '\x1b[32m$1\x1b[0m')
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// Shell 数据处理器 - 直接处理,不依赖外部 ref
|
||||
// 使用 ref 存储 handler,避免闭包问题
|
||||
// Shell 数据处理器
|
||||
handleShellDataRef.current = (_event: unknown, _connId: string, shellId: string, data: Buffer) => {
|
||||
const text = data.toString('utf-8')
|
||||
|
||||
// 使用 ref 获取最新的 shellId,避免闭包问题
|
||||
|
||||
if (shellId === shellIdRef.current && xtermRef.current) {
|
||||
// 处理数据,高亮 IP 地址(Last login 或 from IP 行)
|
||||
let processedData = data
|
||||
|
||||
// 如果包含 Last login 或 from IP,处理 IP 地址高亮
|
||||
|
||||
if (text.includes('Last login') || text.includes(' from ')) {
|
||||
logger.debug('[Terminal] Found IP address line, highlighting IP')
|
||||
const highlighted = highlightIPAddresses(text)
|
||||
processedData = Buffer.from(highlighted, 'utf-8')
|
||||
}
|
||||
|
||||
// 写入 xterm
|
||||
|
||||
xtermRef.current.write(processedData)
|
||||
|
||||
// 更新传输统计
|
||||
|
||||
const connId = connectionIdRef.current
|
||||
if (connId) {
|
||||
const conn = getConnectionStore().getConnection(connId)
|
||||
@@ -258,257 +191,23 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
|
||||
return () => {
|
||||
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(() => {
|
||||
if (!hasConnectedRef.current && isActive) {
|
||||
// 检查标签是否从持久化恢复(需要手动重新连接)
|
||||
const currentTab = getTabById(tabId)
|
||||
if (currentTab?.needsReconnect) {
|
||||
// 从持久化恢复的标签,不自动连接,显示「重新连接」界面
|
||||
logger.info('[Terminal] Tab restored from session, waiting for manual reconnect')
|
||||
setConnectionState('disconnected')
|
||||
setStatusMessage('上次会话已恢复,点击重新连接')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否有快照
|
||||
const hasSnapshot = snapshotStore.hasSnapshot(tabId)
|
||||
if (hasSnapshot && isActive) {
|
||||
// 先恢复快照
|
||||
restoreSnapshot()
|
||||
}
|
||||
|
||||
|
||||
connect()
|
||||
}
|
||||
}, []) // 只在挂载时执行
|
||||
@@ -520,58 +219,34 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
const pool = getConnectionStore()
|
||||
|
||||
if (isActive) {
|
||||
// 标签变为活跃
|
||||
if (connectionIdRef.current) {
|
||||
const conn = pool.getConnection(connectionIdRef.current)
|
||||
if (conn?.state === 'suspended') {
|
||||
setShowSuspendedOverlay(true)
|
||||
setConnectionState('suspended')
|
||||
} else {
|
||||
pool.setActiveTab(connectionIdRef.current, tabId)
|
||||
// 同步本地状态
|
||||
setConnectionState(conn?.state === 'connected' || conn?.state === 'background' ? conn.state : 'connected')
|
||||
// 不再恢复快照 - shell 是实时输出的,重复写入会导致内容重复
|
||||
}
|
||||
}
|
||||
xtermRef.current?.focus()
|
||||
} else {
|
||||
// 标签进入后台 - 保存快照用于恢复
|
||||
if (connectionIdRef.current) {
|
||||
pool.setBackgroundTab(connectionIdRef.current, tabId)
|
||||
saveSnapshot()
|
||||
}
|
||||
}
|
||||
}, [isActive, tabId, saveSnapshot])
|
||||
|
||||
// 重连功能
|
||||
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])
|
||||
}, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay])
|
||||
|
||||
// 组件卸载时清理监听器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 重置连接状态,防止严格模式下 remount 时状态混乱
|
||||
isConnectingRef.current = false
|
||||
listenerRegisteredRef.current = false // 重置监听器注册标记
|
||||
// 组件真正卸载时移除所有监听器
|
||||
listenerRegisteredRef.current = false
|
||||
window.electronAPI?.removeAllListeners('ssh:shellData')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const currentTab = getTabById(tabId)
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-900">
|
||||
{/* 终端容器 */}
|
||||
@@ -580,131 +255,30 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
ref={terminalRef}
|
||||
className="w-full h-full bg-dark-900 rounded"
|
||||
/>
|
||||
|
||||
{/* 搜索栏 - 终端顶部覆盖 */}
|
||||
|
||||
{/* 搜索栏 */}
|
||||
{showSearch && (
|
||||
<div className="absolute top-0 left-0 right-0 z-30 flex items-center gap-2 px-3 py-2 bg-dark-800/95 border-b border-dark-600 backdrop-blur-sm">
|
||||
<Search size={14} className="text-gray-400 shrink-0" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.shiftKey) {
|
||||
handleSearchPrevious()
|
||||
} else {
|
||||
handleSearchNext()
|
||||
}
|
||||
}
|
||||
e.stopPropagation()
|
||||
}}
|
||||
placeholder="搜索..."
|
||||
className="flex-1 bg-dark-900 text-gray-200 text-sm px-2 py-1 rounded border border-dark-600 focus:border-accent-blue focus:outline-none placeholder-gray-500"
|
||||
/>
|
||||
{searchResultCount !== undefined && (
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap min-w-[60px] text-center">
|
||||
{searchResultIndex ?? 0}/{searchResultCount}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSearchPrevious}
|
||||
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
|
||||
title="上一个 (Shift+Enter)"
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSearchNext}
|
||||
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
|
||||
title="下一个 (Enter)"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCloseSearch}
|
||||
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
|
||||
title="关闭 (Escape)"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<TerminalSearchBar
|
||||
searchQuery={searchQuery}
|
||||
searchResultCount={searchResultCount}
|
||||
searchResultIndex={searchResultIndex}
|
||||
searchInputRef={searchInputRef}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearchNext={handleSearchNext}
|
||||
onSearchPrevious={handleSearchPrevious}
|
||||
onClose={handleCloseSearch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 会话恢复 - 需要重新连接覆盖层 */}
|
||||
{connectionState === 'disconnected' && getTabById(tabId)?.needsReconnect && (
|
||||
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-accent-blue/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<TerminalIcon className="w-8 h-8 text-accent-blue" />
|
||||
</div>
|
||||
<div className="text-gray-200 mb-2 text-lg">会话已恢复</div>
|
||||
<div className="text-gray-400 text-sm mb-6">
|
||||
上次关闭时的终端标签已恢复<br />
|
||||
点击下方按钮重新建立 SSH 连接
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="px-6 py-2.5 bg-accent-blue hover:bg-blue-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 挂起状态覆盖层 */}
|
||||
{showSuspendedOverlay && (
|
||||
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
|
||||
<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>
|
||||
|
||||
{/* 连接状态覆盖层 */}
|
||||
<TerminalOverlay
|
||||
connectionState={connectionState}
|
||||
statusMessage={statusMessage}
|
||||
showSuspendedOverlay={showSuspendedOverlay}
|
||||
needsReconnect={!!currentTab?.needsReconnect}
|
||||
onReconnect={handleReconnect}
|
||||
onResume={resumeConnection}
|
||||
/>
|
||||
</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 { useTabStore } from '../stores/tabStore'
|
||||
import EmptyState from './common/EmptyState'
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onNewSession?: () => void
|
||||
@@ -30,7 +31,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
</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
|
||||
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"
|
||||
@@ -101,11 +102,11 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
<div className="w-full max-w-2xl">
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-4">最近连接</h3>
|
||||
{recentSessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-gray-500">
|
||||
<Inbox className="w-10 h-10 mb-3 text-gray-600" />
|
||||
<p className="text-sm">暂无会话记录</p>
|
||||
<p className="text-xs mt-1">点击上方「新建会话」创建第一个连接</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Clock}
|
||||
title="暂无最近连接"
|
||||
description="开始你的第一次连接吧"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{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
|
||||
* <Skeleton className="h-4 w-32" />
|
||||
* <Skeleton className="h-20 w-full" />
|
||||
*/
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<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 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 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
|
||||
action: () => void
|
||||
danger?: boolean
|
||||
separator?: boolean
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
@@ -45,19 +46,23 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
item.action()
|
||||
onClose()
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 text-sm transition-colors ${
|
||||
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-gray-200 hover:bg-dark-700 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
<div key={index}>
|
||||
{item.separator && index > 0 && (
|
||||
<div className="my-1 border-t border-dark-600" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
item.action()
|
||||
onClose()
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 text-sm transition-colors ${
|
||||
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-gray-200 hover:bg-dark-700 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
* 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 { SortHeader } from './SortHeader'
|
||||
import { FileRow } from './FileRow'
|
||||
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'
|
||||
|
||||
interface FileListPanelProps {
|
||||
@@ -31,6 +35,10 @@ interface FileListPanelProps {
|
||||
onSort: (field: SortField) => void
|
||||
contextMenu: { x: number; y: number; items: MenuItem[] } | null
|
||||
onCloseContextMenu: () => void
|
||||
/** 远程面板拖拽上传回调 */
|
||||
onDropFiles?: (files: FileList) => void
|
||||
/** 是否正在加载 */
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function FileListPanel({
|
||||
@@ -53,12 +61,65 @@ export function FileListPanel({
|
||||
onSort,
|
||||
contextMenu,
|
||||
onCloseContextMenu,
|
||||
onDropFiles,
|
||||
loading = false,
|
||||
}: FileListPanelProps) {
|
||||
const canGoBack = historyIndex > 0
|
||||
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 (
|
||||
<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 */}
|
||||
<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">
|
||||
@@ -100,11 +161,15 @@ export function FileListPanel({
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{files.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500 text-sm">
|
||||
空文件夹
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
{loading ? (
|
||||
<FileListSkeleton rows={6} />
|
||||
) : files.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title="目录为空"
|
||||
description="此目录下没有文件"
|
||||
/>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<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>
|
||||
|
||||
{/* Status Bar */}
|
||||
<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>
|
||||
|
||||
{/* 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 - 会话树组件
|
||||
*
|
||||
* 显示分组和会话列表
|
||||
* 显示分组和会话列表,带折叠动画
|
||||
*/
|
||||
|
||||
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 EmptyState from '../common/EmptyState'
|
||||
|
||||
interface SessionTreeProps {
|
||||
sessions: Session[]
|
||||
@@ -15,6 +17,10 @@ interface SessionTreeProps {
|
||||
onSelectSession: (session: Session) => void
|
||||
onContextMenu: (e: React.MouseEvent, session: Session) => void
|
||||
searchTerm: string
|
||||
/** 是否正在加载 */
|
||||
loading?: boolean
|
||||
/** 新建会话回调,用于空状态操作按钮 */
|
||||
onNewSession?: () => void
|
||||
}
|
||||
|
||||
export function SessionTree({
|
||||
@@ -25,7 +31,18 @@ export function SessionTree({
|
||||
onSelectSession,
|
||||
onContextMenu,
|
||||
searchTerm,
|
||||
loading = false,
|
||||
onNewSession,
|
||||
}: SessionTreeProps) {
|
||||
// 加载中时显示骨架屏
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<SessionTreeSkeleton groups={2} sessionsPerGroup={[3, 4]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Filter sessions by search
|
||||
const filteredSessions = sessions.filter(s =>
|
||||
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
@@ -42,56 +59,66 @@ export function SessionTree({
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{Object.entries(grouped).map(([group, groupSessions]) => (
|
||||
<div key={group} className="border-b border-dark-700 last:border-b-0">
|
||||
{/* Group Header */}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{expandedGroups.has(group) ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
<Folder className="w-4 h-4" />
|
||||
<span className="flex-1 text-left">{group === 'default' ? '默认分组' : group}</span>
|
||||
<span className="text-xs text-gray-500">{groupSessions.length}</span>
|
||||
</button>
|
||||
{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">
|
||||
{/* Group Header */}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<ChevronRight
|
||||
className="w-4 h-4 transition-transform duration-200 ease-in-out"
|
||||
style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
<Folder className="w-4 h-4" />
|
||||
<span className="flex-1 text-left">{group === 'default' ? '默认分组' : group}</span>
|
||||
<span className="text-xs text-gray-500">{groupSessions.length}</span>
|
||||
</button>
|
||||
|
||||
{/* Sessions */}
|
||||
{expandedGroups.has(group) && (
|
||||
<div className="animate-fade-in">
|
||||
{groupSessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => onSelectSession(session)}
|
||||
onContextMenu={(e) => onContextMenu(e, session)}
|
||||
className={`
|
||||
group flex items-center gap-2 px-4 py-2 text-sm cursor-pointer
|
||||
transition-colors pl-10
|
||||
${selectedSessionId === session.id
|
||||
? 'bg-dark-700 border-l-2 border-accent-blue'
|
||||
: 'hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Server className="w-4 h-4 text-accent-green flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-200 truncate">{session.name}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{session.host}:{session.port}</div>
|
||||
</div>
|
||||
{/* Sessions - CSS Grid collapse animation */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
|
||||
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div>
|
||||
{groupSessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => onSelectSession(session)}
|
||||
onContextMenu={(e) => onContextMenu(e, session)}
|
||||
className={`
|
||||
group flex items-center gap-2 px-4 py-2 text-sm cursor-pointer
|
||||
transition-colors pl-10
|
||||
${selectedSessionId === session.id
|
||||
? 'bg-dark-700 border-l-2 border-accent-blue'
|
||||
: 'hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Server className="w-4 h-4 text-accent-green flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-200 truncate">{session.name}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{session.host}:{session.port}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{filteredSessions.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500 text-sm">
|
||||
{searchTerm ? '未找到匹配的会话' : '暂无会话'}
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={ServerOff}
|
||||
title={searchTerm ? '未找到匹配的会话' : '暂无会话'}
|
||||
description={searchTerm ? '尝试其他关键词搜索' : '点击 + 按钮添加你的第一个 SSH 连接'}
|
||||
action={searchTerm ? undefined : onNewSession ? { label: '新建会话', onClick: onNewSession } : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* TabItem - 单个标签项
|
||||
* TabItem - 单个标签项(支持拖拽排序)
|
||||
*/
|
||||
|
||||
import { X, Terminal, FolderSync } from 'lucide-react'
|
||||
@@ -7,13 +7,33 @@ import type { Tab } from '../../stores/tabStore'
|
||||
|
||||
interface TabItemProps {
|
||||
tab: Tab
|
||||
index: number
|
||||
isActive: boolean
|
||||
isDragging: boolean
|
||||
dragOverIndex: number | null
|
||||
onActivate: () => void
|
||||
onClose: (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 = () => {
|
||||
switch (tab.type) {
|
||||
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 (
|
||||
<div
|
||||
draggable
|
||||
onClick={onActivate}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragStart={(e) => onDragStart(e, index)}
|
||||
onDragOver={(e) => onDragOver(e, index)}
|
||||
onDrop={(e) => onDrop(e, index)}
|
||||
onDragEnd={onDragEnd}
|
||||
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
|
||||
transition-colors relative
|
||||
${isActive
|
||||
? 'bg-dark-800 text-gray-200'
|
||||
transition-all duration-150 relative
|
||||
${isActive
|
||||
? 'bg-dark-800 text-gray-200'
|
||||
: '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 */}
|
||||
{tab.connectionStatus === 'connecting' && (
|
||||
<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" />
|
||||
</button>
|
||||
|
||||
{/* Active tab bottom indicator */}
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent-blue" />
|
||||
)}
|
||||
</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>
|
||||
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => 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
|
||||
settingsGet: (key: string) => Promise<IPCResponse>
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
|
||||
export { useSFTP } 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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
// ===== 彩色调试日志 =====
|
||||
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 {
|
||||
tabId: string
|
||||
}
|
||||
@@ -77,6 +60,17 @@ export interface UseSFTPReturn {
|
||||
|
||||
// 远程文件操作
|
||||
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
|
||||
@@ -92,7 +86,7 @@ export interface UseSFTPReturn {
|
||||
export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
const { sessions } = useSessionStore()
|
||||
const { updateTabConnectionInstance, updateTab } = useTabStore()
|
||||
const { createConnection, getConnection } = useConnectionPool()
|
||||
const { createConnection } = useConnectionPool()
|
||||
|
||||
// Local state
|
||||
const [localPath, setLocalPath] = useState('')
|
||||
@@ -124,50 +118,40 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
|
||||
// Initialize home path
|
||||
useEffect(() => {
|
||||
logHeader('📁 SFTP Initializing home path')
|
||||
logger.info('SFTP Initializing home path')
|
||||
if (window.electronAPI) {
|
||||
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) {
|
||||
setLocalPath(result.path)
|
||||
setLocalHistory([result.path])
|
||||
setLocalHistoryIndex(0)
|
||||
logSuccess(`Home path set: ${result.path}`)
|
||||
logger.info(`Home path set: ${result.path}`)
|
||||
// Load local directory
|
||||
loadLocalDirectory(result.path)
|
||||
} else {
|
||||
logError('Failed to get home path')
|
||||
logger.error('Failed to get home path')
|
||||
}
|
||||
}).catch((err: any) => {
|
||||
logError(`getHomePath error: ${err?.message || err}`)
|
||||
logger.error(`getHomePath error: ${err?.message || err}`)
|
||||
})
|
||||
} else {
|
||||
logWarning('electronAPI not available')
|
||||
logger.warn('electronAPI not available')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load local directory
|
||||
const loadLocalDirectory = useCallback(async (path: string) => {
|
||||
logHeader('📁 LOAD LOCAL DIRECTORY')
|
||||
logInfo('path', path)
|
||||
logger.info('Loading local directory', path)
|
||||
|
||||
if (!window.electronAPI?.localReadDir) {
|
||||
logWarning('localReadDir not available, using mock data')
|
||||
// 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`)
|
||||
logger.warn('localReadDir not available')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.localReadDir(path)
|
||||
logInfo('localReadDir result', result)
|
||||
logger.debug('localReadDir result', result)
|
||||
|
||||
if (result.success && result.files) {
|
||||
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--',
|
||||
}))
|
||||
setLocalFiles(files)
|
||||
logSuccess(`Loaded ${files.length} local files`)
|
||||
logger.info(`Loaded ${files.length} local files`)
|
||||
} else {
|
||||
logError(`Failed to load local directory: ${result.error}`)
|
||||
logger.error(`Failed to load local directory: ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logError(`loadLocalDirectory error: ${err?.message}`)
|
||||
logger.error('Failed to load local directory', err)
|
||||
logger.error(`loadLocalDirectory error: ${err?.message}`, err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load remote directory
|
||||
const loadRemoteDirectory = useCallback(async (path: string) => {
|
||||
logHeader('📁 LOAD REMOTE DIRECTORY')
|
||||
logInfo('path', path)
|
||||
logInfo('sftpId', sftpId)
|
||||
logger.info('Loading remote directory', { path, sftpId })
|
||||
|
||||
if (!sftpId || !window.electronAPI) {
|
||||
logError('sftpId or electronAPI not available')
|
||||
logger.error('sftpId or electronAPI not available')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sftpListDirectory(sftpId, path)
|
||||
logInfo('sftpListDirectory result', result)
|
||||
logger.debug('sftpListDirectory result', result)
|
||||
|
||||
if (result.success && result.files) {
|
||||
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--',
|
||||
}))
|
||||
setRemoteFiles(files)
|
||||
logSuccess(`Loaded ${files.length} remote files`)
|
||||
logger.info(`Loaded ${files.length} remote files`)
|
||||
} else {
|
||||
logError(`Failed to load remote directory: ${result.error}`)
|
||||
logger.error(`Failed to load remote directory: ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logError(`loadRemoteDirectory error: ${err?.message}`)
|
||||
logger.error('Failed to load remote directory', err)
|
||||
logger.error(`loadRemoteDirectory error: ${err?.message}`, err)
|
||||
setError('Failed to load remote directory')
|
||||
}
|
||||
}, [sftpId])
|
||||
@@ -284,11 +264,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
|
||||
// Connect to SFTP
|
||||
const connect = useCallback(async (sessionId: string) => {
|
||||
logHeader('📁 SFTP CONNECT')
|
||||
logInfo('sessionId', sessionId)
|
||||
logger.info('SFTP CONNECT', { sessionId })
|
||||
|
||||
if (!sessionId) {
|
||||
logError('No sessionId provided')
|
||||
logger.error('No sessionId provided')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -297,43 +276,43 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
|
||||
try {
|
||||
const config = await getSessionConfig(sessionId)
|
||||
logInfo('Config', `${config?.username}@${config?.host}:${config?.port}`)
|
||||
logger.debug('Config', `${config?.username}@${config?.host}:${config?.port}`)
|
||||
|
||||
if (!config) {
|
||||
setError('Session not found')
|
||||
logError('Session config not found')
|
||||
logger.error('Session config not found')
|
||||
return
|
||||
}
|
||||
|
||||
// Create connection
|
||||
logInfo('Creating connection...', tabId)
|
||||
logger.debug('Creating connection...', tabId)
|
||||
const result = await createConnection(tabId, config)
|
||||
logInfo('Connection result', result)
|
||||
logger.debug('Connection result', result)
|
||||
|
||||
if (!result.success || !result.connectionId) {
|
||||
setError(result.error || 'Failed to create connection')
|
||||
logError(`Connection failed: ${result.error}`)
|
||||
logger.error(`Connection failed: ${result.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
const connId = result.connectionId
|
||||
setConnectionId(connId)
|
||||
logSuccess(`Connection created: ${connId}`)
|
||||
logger.info(`Connection created: ${connId}`)
|
||||
|
||||
// Create SFTP
|
||||
logInfo('Creating SFTP session...', connId)
|
||||
logger.debug('Creating SFTP session...', connId)
|
||||
const sftpResult = await window.electronAPI?.sftpConnect(connId)
|
||||
logInfo('SFTP result', sftpResult)
|
||||
logger.debug('SFTP result', sftpResult)
|
||||
|
||||
if (!sftpResult?.success || !sftpResult.sftpId) {
|
||||
setError(sftpResult?.error || 'Failed to create SFTP session')
|
||||
logError(`SFTP creation failed: ${sftpResult?.error}`)
|
||||
logger.error(`SFTP creation failed: ${sftpResult?.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
setSftpId(sftpResult.sftpId)
|
||||
setIsConnected(true)
|
||||
logSuccess(`SFTP connected: ${sftpResult.sftpId}`)
|
||||
logger.info(`SFTP connected: ${sftpResult.sftpId}`)
|
||||
|
||||
// Update tab
|
||||
const connectionInstance: ConnectionInstance = {
|
||||
@@ -344,12 +323,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
updateTab(tabId, { title: `SFTP: ${config.username}@${config.host}` })
|
||||
|
||||
// Load initial remote directory
|
||||
logInfo('Loading remote home directory...', '/')
|
||||
logger.debug('Loading remote home directory...', '/')
|
||||
loadRemoteDirectory('/')
|
||||
|
||||
logSuccess('SFTP initialization complete')
|
||||
|
||||
logger.info('SFTP connected', { host: config.host, connectionId: connId })
|
||||
logger.info('SFTP initialization complete')
|
||||
} catch (err) {
|
||||
logger.error('SFTP connection failed', err)
|
||||
setError('Connection failed')
|
||||
@@ -360,25 +337,25 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
|
||||
// Disconnect
|
||||
const disconnect = useCallback(async () => {
|
||||
if (sftpId && window.electronAPI) {
|
||||
// Note: SFTP disconnect is handled automatically when connection closes
|
||||
}
|
||||
if (connectionId) {
|
||||
const conn = getConnection(connectionId)
|
||||
if (conn) {
|
||||
// Cleanup
|
||||
// Close SFTP session via SSH disconnect (SFTP is cleaned up when connection closes)
|
||||
if (connectionId && window.electronAPI) {
|
||||
try {
|
||||
await window.electronAPI.sshDisconnect(connectionId)
|
||||
logger.info('SFTP connection closed', { connectionId, sftpId })
|
||||
} catch (err) {
|
||||
logger.error('Error closing SFTP connection', err)
|
||||
}
|
||||
}
|
||||
setIsConnected(false)
|
||||
setSftpId(null)
|
||||
setConnectionId(null)
|
||||
setRemoteFiles([])
|
||||
}, [connectionId, sftpId, getConnection])
|
||||
}, [connectionId, sftpId])
|
||||
|
||||
// Upload files (real implementation via IPC)
|
||||
const uploadFiles = useCallback(async (files: FileList | null) => {
|
||||
if (!files || files.length === 0 || !sftpId || !window.electronAPI) {
|
||||
logWarning('uploadFiles: missing files, sftpId, or electronAPI')
|
||||
logger.warn('uploadFiles: missing files, sftpId, or electronAPI')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -415,7 +392,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
try {
|
||||
for (const task of newTasks) {
|
||||
const remoteFilePath = `${remotePath}/${task.fileName}`.replace(/\/+/g, '/')
|
||||
logInfo('Uploading', `${task.filePath} -> ${remoteFilePath}`)
|
||||
logger.debug('Uploading', `${task.filePath} -> ${remoteFilePath}`)
|
||||
|
||||
// Mark as transferring
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
@@ -429,12 +406,12 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
setTransferQueue(prev => prev.map(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 {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Upload failed' } : t
|
||||
))
|
||||
logError(`Upload failed: ${task.fileName} - ${result.error}`)
|
||||
logger.error(`Upload failed: ${task.fileName} - ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error('Upload failed', err)
|
||||
@@ -489,7 +466,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
try {
|
||||
for (const task of newTasks) {
|
||||
const localFilePath = `${localPath}/${task.fileName}`
|
||||
logInfo('Downloading', `${task.filePath} -> ${localFilePath}`)
|
||||
logger.debug('Downloading', `${task.filePath} -> ${localFilePath}`)
|
||||
|
||||
setTransferQueue(prev => prev.map(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 =>
|
||||
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 {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Download failed' } : t
|
||||
))
|
||||
logError(`Download failed: ${task.fileName} - ${result.error}`)
|
||||
logger.error(`Download failed: ${task.fileName} - ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error('Download failed', err)
|
||||
@@ -527,27 +504,26 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
// Delete remote files/directories
|
||||
const deleteRemote = useCallback(async (files: FileItem[]): Promise<boolean> => {
|
||||
if (!sftpId || files.length === 0 || !window.electronAPI) {
|
||||
logWarning('deleteRemote: missing sftpId, files, or electronAPI')
|
||||
logger.warn('deleteRemote: missing sftpId, files, or electronAPI')
|
||||
return false
|
||||
}
|
||||
|
||||
let allSuccess = true
|
||||
|
||||
for (const file of files) {
|
||||
logInfo('Deleting remote', `${file.path} (isDir: ${file.type === 'directory'})`)
|
||||
logger.debug('Deleting remote', `${file.path} (isDir: ${file.type === 'directory'})`)
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sftpDelete(sftpId, file.path, file.type === 'directory')
|
||||
|
||||
if (result.success) {
|
||||
logSuccess(`Deleted: ${file.name}`)
|
||||
logger.info(`Deleted: ${file.name}`)
|
||||
} else {
|
||||
logError(`Delete failed: ${file.name} - ${result.error}`)
|
||||
logger.error(`Delete failed: ${file.name} - ${result.error}`)
|
||||
allSuccess = false
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error('Delete failed', err)
|
||||
logError(`Delete error: ${file.name} - ${err?.message}`)
|
||||
logger.error(`Delete error: ${file.name} - ${err?.message}`, err)
|
||||
allSuccess = false
|
||||
}
|
||||
}
|
||||
@@ -560,6 +536,76 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
return allSuccess
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -605,6 +651,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
|
||||
// Remote file operations
|
||||
deleteRemote,
|
||||
renameRemote,
|
||||
getRemoteStat,
|
||||
chmodRemote,
|
||||
|
||||
sortField,
|
||||
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 interface ToastApi {
|
||||
/** 显示成功通知(绿色) */
|
||||
success: (message: string, options?: ToastOptions) => string
|
||||
/** 显示错误通知(红色) */
|
||||
error: (message: string, options?: ToastOptions) => string
|
||||
/** 显示警告通知(琥珀色) */
|
||||
warning: (message: string, options?: ToastOptions) => string
|
||||
/** 显示信息通知(蓝色) */
|
||||
info: (message: string, options?: ToastOptions) => string
|
||||
/** 移除指定通知 */
|
||||
remove: (id: string) => void
|
||||
/** 清空所有通知 */
|
||||
clearAll: () => void
|
||||
export type ToastType = 'info' | 'success' | 'error' | 'warning'
|
||||
|
||||
export interface Toast {
|
||||
id: string
|
||||
type: ToastType
|
||||
message: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局 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 DEFAULT_DURATION = 3000
|
||||
|
||||
const create = (type: ToastType) => (message: string, options?: ToastOptions) =>
|
||||
addToast(type, message, options)
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
return {
|
||||
success: create('success'),
|
||||
error: create('error'),
|
||||
warning: create('warning'),
|
||||
info: create('info'),
|
||||
remove: removeToast,
|
||||
clearAll,
|
||||
const addToast = useCallback((type: ToastType, message: string, duration?: number) => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`
|
||||
const toast: Toast = { id, type, message, duration: duration ?? DEFAULT_DURATION }
|
||||
setToasts(prev => [...prev, toast])
|
||||
|
||||
// Auto-remove after duration
|
||||
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'
|
||||
language: 'zh-CN' | 'en-US'
|
||||
sidebarWidth: number
|
||||
|
||||
// 终端
|
||||
fontFamily: string
|
||||
@@ -42,6 +43,7 @@ const defaultSettings: AppSettings = {
|
||||
showNotifications: false,
|
||||
theme: 'dark',
|
||||
language: 'zh-CN',
|
||||
sidebarWidth: 256,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 14,
|
||||
terminalTheme: 'dracula',
|
||||
|
||||
@@ -63,7 +63,11 @@ interface TabState {
|
||||
closeLeftTabs: (tabId: string) => Tab[]
|
||||
closeAllTabs: () => Tab[]
|
||||
activateTab: (tabId: string) => void
|
||||
activateTabByIndex: (index: number) => void
|
||||
switchToNextTab: () => void
|
||||
switchToPrevTab: () => void
|
||||
updateTabTitle: (tabId: string, title: string) => void
|
||||
moveTab: (fromIndex: number, toIndex: number) => void
|
||||
getTabById: (tabId: string) => Tab | undefined
|
||||
}
|
||||
|
||||
@@ -230,6 +234,31 @@ export const useTabStore = create<TabState>()(
|
||||
activeTabId: tabId,
|
||||
})
|
||||
},
|
||||
|
||||
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) => {
|
||||
const { tabs } = get()
|
||||
@@ -239,6 +268,29 @@ export const useTabStore = create<TabState>()(
|
||||
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) => {
|
||||
return get().tabs.find(t => t.id === tabId)
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user