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:
OpsXShell Dev committed 2026-04-18 18:35:49 +08:00
1 parent d344900118
commit caba5f4c0d
33 files changed
+2669 -859

No files matched your search

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