diff --git a/ROADMAP.md b/ROADMAP.md index 58bc6da..55eb590 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,34 +12,35 @@ v1.5.0 安全升级 ─── 1 周 ──→ 安全 55→85, 代码 80→85 --- -## v1.1.0 基础可用 (2 周) — 当前阶段 +## v1.1.0 基础可用 (2 周) — ✅ 已完成 > **目标**: 解决所有 Critical UI/UX 问题,建立基础反馈系统,产品具备日常使用价值 -### 🔴 P0 — 必须完成 +### ✅ 全部完成 (提交: 53274e2 + d344900) -| # | 任务 | 类型 | 预计 | 验收标准 | -|---|------|------|------|----------| -| 1 | Toast 通知系统 | 新增 | 2天 | `useToast()` hook + `ToastContainer`,支持 success/error/warning/info 四种类型,自动消失+手动关闭 | -| 2 | 替换所有 alert/prompt/confirm | 修复 | 2天 | `App.tsx` 5处 alert → Toast,`SFTPView.tsx` prompt → 内联输入,`PortForwardView.tsx` confirm → ConfirmDialog | -| 3 | SFTP 上传/删除真实实现 | 修复 | 3天 | 上传通过 ssh2 sftp fastPut 实现,删除通过 rimraf/unlink 实现,右键菜单 action 绑定真实函数 | -| 4 | 终端搜索 (SearchAddon) | 新增 | 1天 | Ctrl+Shift+F 打开搜索栏,支持正则/大小写/全词匹配,高亮所有匹配 | -| 5 | 会话恢复 | 新增 | 3天 | 关闭前持久化标签状态到 SQLite,启动时恢复标签+自动重连 | -| 6 | 修复私钥浏览按钮 | 修复 | 0.5天 | dialog.showOpenDialog() 绑定到 onClick,过滤 `.pem/.key/id_*` | -| 7 | 清理生产调试日志 | 修复 | 1天 | 移除 `DEBUG=true`,所有 console.log 用 logger 替代或删除 | -| 8 | 设计系统统一颜色 | 修复 | 1天 | cyan→accent-blue, sky→accent-blue, slate→dark-*,新增 accent-orange | -| 9 | StatusBar 接入实际状态 | 修复 | 0.5天 | 显示当前连接 host、延迟 ms、标签数 | -| 10 | WelcomeView 真实最近连接 | 修复 | 1天 | 从 sessionStore 获取最近 5 条连接记录,空状态显示引导 | +| # | 任务 | 状态 | 提交 | +|---|------|------|------| +| 1 | Toast 通知系统 | ✅ | d344900 | +| 2 | 替换所有 alert/prompt/confirm | ✅ | d344900 | +| 3 | SFTP 上传/删除真实实现 | ✅ | d344900 | +| 4 | 终端搜索 (SearchAddon) | ✅ | d344900 | +| 5 | 会话恢复 (标签持久化) | ✅ | d344900 | +| 6 | 修复私钥浏览按钮 | ✅ | 53274e2 | +| 7 | 清理生产调试日志 | ✅ | 53274e2 | +| 8 | 设计系统统一颜色 | ✅ | 53274e2 | +| 9 | StatusBar 接入实际状态 | ✅ | 53274e2 | +| 10 | WelcomeView 真实最近连接 | ✅ | 53274e2 | +| 11 | better-sqlite3 → sql.js 迁移 | ✅ | 53274e2 | -### 里程碑推送 -- [ ] SFTP 功能可用 (上传+删除+右键菜单) -- [ ] Toast 系统上线 (零 alert) -- [ ] 终端搜索可用 -- [ ] 会话恢复可用 +### 里程碑 +- [x] SFTP 功能可用 (上传+删除+右键菜单) +- [x] Toast 系统上线 (零 alert) +- [x] 终端搜索可用 +- [x] 会话恢复可用 --- -## v1.2.0 体验升级 (2 周) +## v1.2.0 体验升级 (2 周) — 🚧 进行中 > **目标**: 快捷键补全、视觉打磨、交互优化,接近 Termius 体验水平 diff --git a/electron/ipc/sftp.ipc.ts b/electron/ipc/sftp.ipc.ts index 6a92b8d..bbb6cf9 100644 --- a/electron/ipc/sftp.ipc.ts +++ b/electron/ipc/sftp.ipc.ts @@ -15,6 +15,8 @@ export const SFTP_CHANNELS = { RENAME: 'sftp:rename', DELETE: 'sftp:delete', MKDIR: 'sftp:mkdir', + STAT: 'sftp:stat', + CHMOD: 'sftp:chmod', PROGRESS: 'sftp:progress', } as const @@ -193,4 +195,46 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void { return { success: false, error: error.message } } }) + + // SFTP stat + ipcMain.handle(SFTP_CHANNELS.STAT, async ( + _event: IpcMainInvokeEvent, + sftpId: string, + filePath: string + ) => { + logHeader('📋 SFTP IPC STAT') + logInfo('sftpId', sftpId) + logInfo('filePath', filePath) + + try { + const stats = await sshService.sftpStat(sftpId, filePath) + logSuccess(`Stat complete: ${stats.size} bytes`) + return { success: true, stats } + } catch (error: any) { + logError(`Stat failed: ${error.message}`) + return { success: false, error: error.message } + } + }) + + // SFTP chmod + ipcMain.handle(SFTP_CHANNELS.CHMOD, async ( + _event: IpcMainInvokeEvent, + sftpId: string, + filePath: string, + mode: number + ) => { + logHeader('🔒 SFTP IPC CHMOD') + logInfo('sftpId', sftpId) + logInfo('filePath', filePath) + logInfo('mode', mode.toString(8)) + + try { + await sshService.sftpChmod(sftpId, filePath, mode) + logSuccess('Chmod complete') + return { success: true } + } catch (error: any) { + logError(`Chmod failed: ${error.message}`) + return { success: false, error: error.message } + } + }) } diff --git a/electron/preload.ts b/electron/preload.ts index 78762bf..0129231 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -25,6 +25,8 @@ const api = { sftpRename: (sftpId: string, oldPath: string, newPath: string) => ipcRenderer.invoke('sftp:rename', sftpId, oldPath, newPath), sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => ipcRenderer.invoke('sftp:delete', sftpId, path, isDirectory), sftpMkdir: (sftpId: string, path: string) => ipcRenderer.invoke('sftp:mkdir', sftpId, path), + sftpStat: (sftpId: string, filePath: string) => ipcRenderer.invoke('sftp:stat', sftpId, filePath), + sftpChmod: (sftpId: string, filePath: string, mode: number) => ipcRenderer.invoke('sftp:chmod', sftpId, filePath, mode), // 端口转发 sshForwardLocal: (connectionId: string, localPort: number, remoteHost: string, remotePort: number) => diff --git a/electron/services/ssh-service.ts b/electron/services/ssh-service.ts index 7134996..ef59def 100644 --- a/electron/services/ssh-service.ts +++ b/electron/services/ssh-service.ts @@ -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 { + 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( diff --git a/src/App.tsx b/src/App.tsx index 94752d3..4c9bb89 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,17 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useCallback } from 'react' import Sidebar from './components/Sidebar' import TabBar from './components/TabBar' import TabContent from './components/TabContent' import StatusBar from './components/StatusBar' import SessionDialog from './components/SessionDialog' import QuickConnectDialog from './components/QuickConnectDialog' -import { ToastProvider } from './components/common/Toast' import { useTabStore } from './stores/tabStore' import { useSessionStore, type Session } from './stores' import { createLogger } from './utils/logger' import { useToast } from './hooks/useToast' +import { ToastContainer } from './components/common/ToastContainer' +import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' +import { useIsMobile } from './hooks/useMediaQuery' const logger = createLogger('App') @@ -19,7 +21,33 @@ function App() { const [editSession, setEditSession] = useState(null) const { activeTabId } = useTabStore() const { sessions } = useSessionStore() - const toast = useToast() + const { toasts, toast, removeToast } = useToast() + useKeyboardShortcuts() + + // Responsive: detect mobile + const isMobile = useIsMobile() + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) + + // When viewport changes to mobile, auto-collapse sidebar + useEffect(() => { + if (isMobile) { + setSidebarCollapsed(true) + } else { + setSidebarCollapsed(false) + setMobileSidebarOpen(false) + } + }, [isMobile]) + + // Toggle sidebar (for mobile overlay) + const toggleMobileSidebar = useCallback(() => { + setMobileSidebarOpen((prev) => !prev) + }, []) + + // Close mobile sidebar overlay + const closeMobileSidebar = useCallback(() => { + setMobileSidebarOpen(false) + }, []) // 导出会话 const handleExportSessions = async () => { @@ -198,30 +226,67 @@ function App() { }, [sessions]) return ( -
{/* Title bar spacer for macOS */}
{/* Main content */}
- {/* Sidebar */} - { - setEditSession(null) - setIsSessionDialogOpen(true) - }} - onQuickConnect={() => setIsQuickConnectOpen(true)} - onEditSession={(session) => { - setEditSession(session) - setIsSessionDialogOpen(true) - }} - /> + {/* Mobile overlay backdrop */} + {isMobile && mobileSidebarOpen && ( +
+ )} + + {/* Sidebar container with transition */} +
+ { + setEditSession(null) + setIsSessionDialogOpen(true) + if (isMobile) closeMobileSidebar() + }} + onQuickConnect={() => { + setIsQuickConnectOpen(true) + if (isMobile) closeMobileSidebar() + }} + onEditSession={(session) => { + setEditSession(session) + setIsSessionDialogOpen(true) + if (isMobile) closeMobileSidebar() + }} + /> +
{/* Main area */}
- {/* Tab bar */} - + {/* Tab bar with mobile sidebar toggle */} +
+ {isMobile && ( + + )} +
+ +
+
{/* Tab content */}
@@ -254,8 +319,10 @@ function App() { isOpen={isQuickConnectOpen} onClose={() => setIsQuickConnectOpen(false)} /> + + {/* Toast Notifications */} +
- ) } diff --git a/src/components/SFTPView.tsx b/src/components/SFTPView.tsx index 1d6c9d6..185a560 100644 --- a/src/components/SFTPView.tsx +++ b/src/components/SFTPView.tsx @@ -5,15 +5,24 @@ */ import { useState, useCallback } from 'react' -import { FolderOpen, Upload, Download, Trash2 } from 'lucide-react' +import { + FolderOpen, Upload, Download, Trash2, + Edit3, Copy, FileText, ShieldCheck, + Unplug, +} from 'lucide-react' import { useSFTP } from '../hooks' +import { useToast } from '../hooks/useToast' import { FileListPanel } from './sftp/FileListPanel' import { TransferPanel } from './sftp/TransferPanel' import { Toolbar } from './sftp/Toolbar' import { ConnectionPanel } from './sftp/ConnectionPanel' import { ContextMenu, type MenuItem } from './sftp/ContextMenu' +import { ToastContainer } from './common/ToastContainer' +import EmptyState from './common/EmptyState' import { sortFiles } from './sftp/utils' import { usePromptDialog } from './common/PromptDialog' +import FilePropertiesDialog from './sftp/FilePropertiesDialog' +import ChmodDialog from './sftp/ChmodDialog' import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.types' interface SFTPViewProps { @@ -51,9 +60,13 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView uploadFiles, downloadFiles, deleteRemote, + renameRemote, + getRemoteStat, + chmodRemote, error, } = useSFTP({ tabId }) + const { toasts, toast, removeToast } = useToast() const { prompt, PromptDialogComponent } = usePromptDialog() // UI State @@ -63,6 +76,13 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView const [showConnectionSelector, setShowConnectionSelector] = useState(false) const [contextMenu, setContextMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null) + // Properties dialog state + const [propertiesFile, setPropertiesFile] = useState(null) + + // Chmod dialog state + const [chmodFile, setChmodFile] = useState(null) + const [chmodInitialMode, setChmodInitialMode] = useState(0o644) + // Sort handler const handleSort = useCallback((field: SortField) => { if (sortField === field) { @@ -93,6 +113,61 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView }) }, [setRemoteSelected]) + // ===== Context menu handlers ===== + + // Rename handler + const handleRename = useCallback(async (file: FileItem) => { + const newName = await prompt({ + title: '重命名', + message: '输入新名称:', + placeholder: '新名称', + defaultValue: file.name, + }) + if (newName && newName !== file.name) { + const ok = await renameRemote(file, newName) + if (ok) { + toast.success(`已重命名为 ${newName}`) + } else { + toast.error('重命名失败') + } + } + }, [prompt, renameRemote, toast]) + + // Copy path handler + const handleCopyPath = useCallback(async (filePath: string) => { + try { + await navigator.clipboard.writeText(filePath) + toast.success('路径已复制到剪贴板') + } catch { + toast.error('复制路径失败') + } + }, [toast]) + + // Properties handler + const handleShowProperties = useCallback((file: FileItem) => { + setPropertiesFile(file) + }, []) + + // Permissions (chmod) handler — fetch stat first, then show dialog + const handleChmod = useCallback(async (file: FileItem) => { + const stats = await getRemoteStat(file.path) + const mode = stats?.mode ?? 0o644 + setChmodFile(file) + setChmodInitialMode(mode & 0o7777) + }, [getRemoteStat]) + + // Chmod confirm callback + const handleChmodConfirm = useCallback(async (filePath: string, mode: number) => { + const ok = await chmodRemote(filePath, mode) + if (ok) { + toast.success(`权限已更新为 ${mode.toString(8)}`) + return true + } else { + toast.error('权限修改失败') + return false + } + }, [chmodRemote, toast]) + // Local context menu const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => { e.preventDefault() @@ -107,9 +182,15 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView label: '上传', action: () => {}, }, + { icon: , label: '复制路径', separator: true, action: () => handleCopyPath(file.path) }, + { + icon: , + label: '属性', + action: () => handleShowProperties(file), + }, ] setContextMenu({ x: e.clientX, y: e.clientY, items }) - }, [navigateLocal]) + }, [navigateLocal, handleCopyPath, handleShowProperties]) // Remote context menu const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => { @@ -128,17 +209,22 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView downloadFiles() }, }, + { icon: , label: '重命名', separator: true, action: () => handleRename(file) }, + { icon: , label: '复制路径', action: () => handleCopyPath(file.path) }, { - icon: , - label: '删除', - danger: true, - action: () => { - deleteRemote([file]) - }, + icon: , + label: '属性', + action: () => handleShowProperties(file), }, + { + icon: , + label: '权限', + action: () => handleChmod(file), + }, + { icon: , label: '删除', separator: true, danger: true, action: () => deleteRemote([file]) }, ] setContextMenu({ x: e.clientX, y: e.clientY, items }) - }, [navigateRemote, downloadFiles, deleteRemote, setRemoteSelected]) + }, [navigateRemote, downloadFiles, deleteRemote, setRemoteSelected, handleRename, handleCopyPath, handleShowProperties, handleChmod]) // Navigate up const navigateLocalUp = useCallback(() => { @@ -164,7 +250,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView } }, [navigateRemote]) - // Handle upload + // Handle upload (via file picker) const handleUpload = useCallback(() => { const input = document.createElement('input') input.type = 'file' @@ -175,6 +261,20 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView input.click() }, [uploadFiles]) + // Handle drag-and-drop upload on remote panel + const handleDropFiles = useCallback((files: FileList) => { + if (!isConnected) return + const count = files.length + toast.info(`正在上传 ${count} 个文件...`) + uploadFiles(files) + .then(() => { + toast.success(`${count} 个文件上传完成`) + }) + .catch((err) => { + toast.error(`上传失败: ${err?.message || '未知错误'}`) + }) + }, [isConnected, uploadFiles, toast]) + // Handle new folder const handleNewFolder = useCallback(async () => { const name = await prompt({ @@ -216,6 +316,15 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView /> {/* File Panels */} + {!isConnected ? ( +
+ +
+ ) : (
{/* Local Panel */} setContextMenu(null)} + onDropFiles={isConnected ? handleDropFiles : undefined} + loading={isConnecting} />
+ )} {/* Transfer Panel */} )} {PromptDialogComponent} + + {/* File Properties Dialog */} + {propertiesFile && ( + 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 && ( + setChmodFile(null)} + fileName={chmodFile.name} + filePath={chmodFile.path} + initialMode={chmodInitialMode} + onConfirm={handleChmodConfirm} + /> + )} + + {/* Toast Notifications */} +
) } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 5327300..f343006 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,15 +1,19 @@ /** - * Sidebar - 侧边栏组件 (简化版) + * Sidebar - 侧边栏组件 + * 支持宽度拖拽调整 */ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { Plus, Zap, Server, Settings } from 'lucide-react' import { useSessionStore, Session } from '../stores' +import { useSettingsStore } from '../stores/settingsStore' import { useTabStore } from '../stores/tabStore' import { SessionTree } from './sidebar/SessionTree' import { SearchBox } from './sidebar/SearchBox' import { useConfirmDialog } from './common/ConfirmDialog' -// Sidebar Component + +const MIN_SIDEBAR_WIDTH = 180 +const MAX_SIDEBAR_WIDTH = 400 interface SidebarProps { onNewSession: () => void @@ -19,6 +23,7 @@ interface SidebarProps { export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) { const { sessions, setSessions, removeSession } = useSessionStore() + const { settings, setSetting } = useSettingsStore() const { addTab } = useTabStore() const { confirm, ConfirmDialogComponent } = useConfirmDialog() @@ -26,8 +31,19 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: const [expandedGroups, setExpandedGroups] = useState>(new Set(['default'])) const [selectedSessionId, setSelectedSessionId] = useState(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(null) + const sidebarRef = useRef(null) + + // Sync width from settings on load + useEffect(() => { + if (settings.sidebarWidth && settings.sidebarWidth !== sidebarWidth) { + setSidebarWidth(settings.sidebarWidth) + } + }, [settings.sidebarWidth]) // Load sessions useEffect(() => { @@ -35,6 +51,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: if (result.success && result.sessions) { setSessions(result.sessions) } + setSessionsLoading(false) }) }, [setSessions]) @@ -49,6 +66,44 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: return () => document.removeEventListener('mousedown', handleClickOutside) }, []) + // Resize handler + const handleResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault() + setIsResizing(true) + + const startX = e.clientX + const startWidth = sidebarWidth + + const handleMouseMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientX - startX + const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta)) + setSidebarWidth(newWidth) + } + + const handleMouseUp = () => { + setIsResizing(false) + document.body.style.userSelect = '' + document.body.style.cursor = '' + // Save to settings on drag end + setSetting('sidebarWidth', sidebarWidth) + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + // Prevent text selection during drag + document.body.style.userSelect = 'none' + document.body.style.cursor = 'col-resize' + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, [sidebarWidth, setSetting]) + + // Save final width on unmount or when resizing ends (we save in handleMouseUp) + useEffect(() => { + if (!isResizing && sidebarWidth !== settings.sidebarWidth) { + setSetting('sidebarWidth', sidebarWidth) + } + }, [isResizing, sidebarWidth, settings.sidebarWidth, setSetting]) + const toggleGroup = (group: string) => { const newExpanded = new Set(expandedGroups) if (newExpanded.has(group)) newExpanded.delete(group) @@ -120,95 +175,114 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: return ( <> -
- {/* Header */} -
-
-
- +
+
+ {/* Header */} +
+
+
+ +
+ OpsXShell
- OpsXShell -
- - {/* Quick Actions */} -
- - -
-
- - {/* Search */} -
- -
- - {/* Session Tree */} - - - {/* Footer */} -
- -
- - {/* Context Menu */} - {contextMenu && ( -
- {[ - { label: '连接', action: 'connect' }, - { label: '编辑', action: 'edit' }, - { label: '克隆', action: 'duplicate' }, - { label: 'SFTP 传输', action: 'sftp' }, - { label: '删除', action: 'delete', danger: true }, - ].map((item) => ( + + {/* Quick Actions */} +
- ))} + +
- )} + + {/* Search */} +
+ +
+ + {/* Session Tree */} + + + {/* Footer */} +
+ +
+ + {/* Context Menu */} + {contextMenu && ( +
+ {[ + { label: '连接', action: 'connect' }, + { label: '编辑', action: 'edit' }, + { label: '克隆', action: 'duplicate' }, + { label: 'SFTP 传输', action: 'sftp' }, + { label: '删除', action: 'delete', danger: true }, + ].map((item) => ( + + ))} +
+ )} +
+ + {/* Resize Handle */} +
{ConfirmDialogComponent} diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index b81a173..393aaf0 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -1,9 +1,9 @@ /** - * TabBar - 标签栏组件 (简化版) + * TabBar - 标签栏组件(支持拖拽排序) */ import { Plus } from 'lucide-react' -import { useState, useEffect } from 'react' +import { useState, useCallback, useRef } from 'react' import { useTabStore } from '../stores/tabStore' import { useConnectionPool } from '../stores/connection' import { TabItem } from './tabbar/TabItem' @@ -20,9 +20,15 @@ export default function TabBar() { closeRightTabs, closeLeftTabs, closeAllTabs, + moveTab, } = useTabStore() const { disconnectConnection } = useConnectionPool() + + // Drag state + const [dragFromIndex, setDragFromIndex] = useState(null) + const [dragOverIndex, setDragOverIndex] = useState(null) + const dragNodeRef = useRef(null) const [menuState, setMenuState] = useState({ isOpen: false, @@ -30,20 +36,6 @@ export default function TabBar() { position: { x: 0, y: 0 } }) - // 快捷键 - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'w' && activeTabId) { - e.preventDefault() - if (tabs.length > 1) { - handleCloseTab(activeTabId) - } - } - } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [activeTabId, tabs.length]) - const handleCloseTab = (tabId: string) => { const tab = tabs.find(t => t.id === tabId) if (tab?.connectionInstance?.connectionId) { @@ -61,22 +53,85 @@ export default function TabBar() { }) } + // --- Drag handlers --- + const handleDragStart = useCallback((e: React.DragEvent, index: number) => { + setDragFromIndex(index) + e.dataTransfer.effectAllowed = 'move' + // Set a transparent drag image so we rely on the ghost + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (e.currentTarget instanceof HTMLDivElement) { + dragNodeRef.current = e.currentTarget + } + }, []) + + const handleDragOver = useCallback((e: React.DragEvent, index: number) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + if (dragFromIndex === null || dragFromIndex === index) { + setDragOverIndex(null) + return + } + // Determine insert position: if dragging from lower index, place after; otherwise before + const rect = e.currentTarget.getBoundingClientRect() + const midX = rect.left + rect.width / 2 + const insertAt = e.clientX < midX ? index : index + 1 + // Don't show indicator if it would result in no-op + if ( + insertAt === dragFromIndex || + insertAt === dragFromIndex + 1 + ) { + setDragOverIndex(null) + } else { + setDragOverIndex(insertAt) + } + }, [dragFromIndex]) + + const handleDrop = useCallback((e: React.DragEvent, index: number) => { + e.preventDefault() + if (dragFromIndex === null) return + // Calculate actual toIndex based on mouse position + const rect = e.currentTarget.getBoundingClientRect() + const midX = rect.left + rect.width / 2 + let toIndex = e.clientX < midX ? index : index + 1 + // Adjust for the removal of the dragged item shifting indices + if (toIndex > dragFromIndex) { + toIndex -= 1 + } + if (dragFromIndex !== toIndex) { + moveTab(dragFromIndex, toIndex) + } + setDragFromIndex(null) + setDragOverIndex(null) + }, [dragFromIndex, moveTab]) + + const handleDragEnd = useCallback(() => { + setDragFromIndex(null) + setDragOverIndex(null) + }, []) + const currentTab = menuState.tabId ? tabs.find(t => t.id === menuState.tabId) : null return (
{/* Tab List */} - {tabs.map((tab) => ( + {tabs.map((tab, index) => ( activateTab(tab.id)} onClose={(e) => { e.stopPropagation() handleCloseTab(tab.id) }} onContextMenu={(e) => handleContextMenu(e, tab.id)} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDrop={handleDrop} + onDragEnd={handleDragEnd} /> ))} diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index c9f3de7..1855c5e 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -1,15 +1,18 @@ -import { useEffect, useRef, useState, useCallback } from 'react' +import { useEffect, useRef } from 'react' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { SearchAddon } from '@xterm/addon-search' import '@xterm/xterm/css/xterm.css' -import { Search, ChevronUp, ChevronDown, X, Terminal as TerminalIcon } from 'lucide-react' import { themes } from '../themes/terminal' import { useSettingsStore } from '../stores/settingsStore' -import { useTabStore, type ConnectionInstance } from '../stores/tabStore' -import { getSessionConfig, getConnectionStore, type ConnectionState } from '../stores/connection' +import { useTabStore } from '../stores/tabStore' +import { getConnectionStore } from '../stores/connection' import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore' import { createLogger } from '../utils/logger' +import { useTerminalConnection } from '../hooks/useTerminalConnection' +import { useTerminalSearch } from '../hooks/useTerminalSearch' +import TerminalOverlay from './terminal/TerminalOverlay' +import TerminalSearchBar from './terminal/TerminalSearchBar' import type { TerminalViewProps } from './terminal/types' const logger = createLogger('TerminalView') @@ -19,125 +22,63 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro const xtermRef = useRef(null) const fitAddonRef = useRef(null) const searchAddonRef = useRef(null) - const shellIdRef = useRef(null) - const connectionIdRef = useRef(null) - const hasConnectedRef = useRef(false) - const isConnectingRef = useRef(false) const handleShellDataRef = useRef<((_event: unknown, _connId: string, shellId: string, data: Buffer) => void) | null>(null) const listenerRegisteredRef = useRef(false) - - // 本地状态 - const [connectionState, setConnectionState] = useState('disconnected') - const [statusMessage, setStatusMessage] = useState('') - const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false) - - // 搜索状态 - const [showSearch, setShowSearch] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const [searchResultCount, setSearchResultCount] = useState(undefined) - const [searchResultIndex, setSearchResultIndex] = useState(undefined) - const searchInputRef = useRef(null) - - // 全局状态 + const { settings, loadSettings } = useSettingsStore() - const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore() + const { getTabById } = useTabStore() const snapshotStore = useTerminalSnapshotStore() - + const currentTheme = themes[settings.terminalTheme] || themes['dracula'] + // 连接管理 hook + const { + connectionState, + statusMessage, + showSuspendedOverlay, + connectionIdRef, + shellIdRef, + hasConnectedRef, + isConnectingRef, + connect, + resumeConnection, + handleReconnect, + saveSnapshot, + restoreSnapshot, + setShowSuspendedOverlay, + } = useTerminalConnection({ + tabId, + xtermRef, + }) + + // 搜索功能 hook + const { + showSearch, + searchQuery, + searchResultCount, + searchResultIndex, + searchInputRef, + handleSearchChange, + handleSearchNext, + handleSearchPrevious, + handleCloseSearch, + } = useTerminalSearch({ + xtermRef, + searchAddonRef, + isActive, + }) + // 加载设置 useEffect(() => { loadSettings() }, [loadSettings]) - // 搜索相关回调 - const searchDecorations = { - matchBackground: '#3b82f633', - matchBorder: '#3b82f666', - matchOverviewRuler: '#3b82f6', - activeMatchBackground: '#3b82f6', - activeMatchBorder: '#60a5fa', - activeMatchColorOverviewRuler: '#60a5fa', - } - - const handleSearchChange = useCallback((e: React.ChangeEvent) => { - const query = e.target.value - setSearchQuery(query) - if (query) { - searchAddonRef.current?.findNext(query, { decorations: searchDecorations, incremental: true }) - } else { - searchAddonRef.current?.clearDecorations() - setSearchResultCount(undefined) - setSearchResultIndex(undefined) - } - }, []) - - const handleSearchNext = useCallback(() => { - if (searchAddonRef.current && searchQuery) { - searchAddonRef.current.findNext(searchQuery, { decorations: searchDecorations }) - } - }, [searchQuery]) - - const handleSearchPrevious = useCallback(() => { - if (searchAddonRef.current && searchQuery) { - searchAddonRef.current.findPrevious(searchQuery, { decorations: searchDecorations }) - } - }, [searchQuery]) - - const handleCloseSearch = useCallback(() => { - setShowSearch(false) - searchAddonRef.current?.clearDecorations() - setSearchQuery('') - setSearchResultCount(undefined) - setSearchResultIndex(undefined) - xtermRef.current?.focus() - }, []) - - // Ctrl+F / Cmd+F 打开搜索栏,Escape 关闭 - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === 'f') { - // 只在当前标签激活时拦截 - if (!isActive) return - e.preventDefault() - e.stopPropagation() - if (!showSearch) { - setShowSearch(true) - // 延迟聚焦输入框,等 DOM 更新 - setTimeout(() => searchInputRef.current?.focus(), 50) - } else { - searchInputRef.current?.focus() - searchInputRef.current?.select() - } - } - if (e.key === 'Escape' && showSearch) { - handleCloseSearch() - } - } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [isActive, showSearch, handleCloseSearch]) - - // 监听 SearchAddon 的 onDidChangeResults 事件 - useEffect(() => { - const addon = searchAddonRef.current - if (!addon) return - const disposable = addon.onDidChangeResults((e) => { - setSearchResultCount(e.resultCount) - setSearchResultIndex(e.resultIndex !== undefined ? e.resultIndex + 1 : undefined) - }) - return () => disposable.dispose() - }, []) - // 初始化 xterm useEffect(() => { - // 修复:只检查 terminalRef,不检查 xtermRef - // 因为在严格模式下,组件会 unmount/remount,xtermRef 可能还存在 - // 但监听器需要重新注册 if (!terminalRef.current) { return } - + // 处理窗口大小调整 const handleResize = () => { fitAddonRef.current?.fit() @@ -207,36 +148,28 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro // IP 地址高亮处理 - 将包含 "from" 或 "Last login" 行中的 IP 地址标记为绿色 const highlightIPAddresses = (text: string): string => { - // 匹配 IPv4 地址格式 (xxx.xxx.xxx.xxx) const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g - // 如果包含 Last login 或 from IP,将 IP 地址替换为绿色 if (text.includes('Last login') || text.includes(' from ')) { return text.replace(ipRegex, '\x1b[32m$1\x1b[0m') } return text } - // Shell 数据处理器 - 直接处理,不依赖外部 ref - // 使用 ref 存储 handler,避免闭包问题 + // Shell 数据处理器 handleShellDataRef.current = (_event: unknown, _connId: string, shellId: string, data: Buffer) => { const text = data.toString('utf-8') - - // 使用 ref 获取最新的 shellId,避免闭包问题 + if (shellId === shellIdRef.current && xtermRef.current) { - // 处理数据,高亮 IP 地址(Last login 或 from IP 行) let processedData = data - - // 如果包含 Last login 或 from IP,处理 IP 地址高亮 + if (text.includes('Last login') || text.includes(' from ')) { logger.debug('[Terminal] Found IP address line, highlighting IP') const highlighted = highlightIPAddresses(text) processedData = Buffer.from(highlighted, 'utf-8') } - - // 写入 xterm + xtermRef.current.write(processedData) - - // 更新传输统计 + const connId = connectionIdRef.current if (connId) { const conn = getConnectionStore().getConnection(connId) @@ -258,257 +191,23 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro return () => { window.removeEventListener('resize', handleResize) - // 不在这里移除监听器,避免严格模式下的问题 - // 监听器会在组件真正卸载时通过 removeAllListeners 清理 - - // 注意:不要在严格模式下 dispose terminal } }, []) // 只在挂载时运行 - // 保存快照 - const saveSnapshot = useCallback(() => { - if (!xtermRef.current || !connectionIdRef.current) return - - useTerminalSnapshotStore.getState().createSnapshot(tabId, xtermRef.current, { - connectionId: connectionIdRef.current, - shellId: shellIdRef.current, - }) - }, [tabId]) - - // 恢复快照 - const restoreSnapshot = useCallback(() => { - if (!xtermRef.current) return false - - const snapshot = useTerminalSnapshotStore.getState().getSnapshot(tabId) - if (!snapshot) return false - - // 清屏 - xtermRef.current.clear() - - // 恢复内容 - const content = snapshot.buffer.join('\r\n') - if (content) { - xtermRef.current.write(content) - } - - // 恢复光标 - xtermRef.current.write(`\x1b[${snapshot.cursorY + 1};${snapshot.cursorX + 1}H`) - - return true - }, [tabId]) - - // 连接管理 - const connect = useCallback(async () => { - if (isConnectingRef.current) { - logger.debug('[Terminal] Connection already in progress, skipping') - return - } - isConnectingRef.current = true - - // 直接使用 getTabById,避免 useCallback 循环问题 - const currentTab = getTabById(tabId) - if (!currentTab) { - logger.debug('[Terminal] Tab not found:', tabId) - isConnectingRef.current = false - return - } - - logger.debug('[Terminal] Starting connection for tab:', tabId, 'sessionId:', currentTab.sessionId) - - // 检查是否已有连接 - if (connectionIdRef.current) { - const conn = getConnectionStore().getConnection(connectionIdRef.current) - if (conn) { - if (conn.state === 'connected' || conn.state === 'background') { - // 已有活跃连接,恢复显示 - shellIdRef.current = currentTab.connectionInstance?.shellId || null - setConnectionState(conn.state) - updateTabStatus(tabId, conn.state === 'connected' ? 'connected' : 'disconnected') - isConnectingRef.current = false - return - } else if (conn.state === 'suspended') { - // 连接被挂起,需要恢复 - setShowSuspendedOverlay(true) - setConnectionState('suspended') - updateTabStatus(tabId, 'disconnected') - isConnectingRef.current = false - return - } - } - } - - // 开始连接 - setConnectionState('connecting') - updateTabStatus(tabId, 'connecting') - - try { - // 获取配置 - let config: { - host: string - port: number - username: string - password?: string - privateKey?: string - passphrase?: string - } | null = null - - if (currentTab.connectionConfig) { - config = { - host: currentTab.connectionConfig.host, - port: currentTab.connectionConfig.port || 22, - username: currentTab.connectionConfig.username, - password: currentTab.connectionConfig.password, - privateKey: currentTab.connectionConfig.privateKey, - passphrase: currentTab.connectionConfig.passphrase, - } - } else if (currentTab.sessionId) { - config = await getSessionConfig(currentTab.sessionId) - } - - if (!config) { - throw new Error('无可用连接信息') - } - - // 创建连接 - const result = await getConnectionStore().createConnection(tabId, config) - - if (!result.success || !result.connectionId) { - throw new Error(result.error || '连接失败') - } - - const connectionId = result.connectionId - connectionIdRef.current = connectionId - - // 创建 Shell - 先生成前端 shellId,确保数据不会丢失 - const pendingShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` - logger.debug('[Terminal] Pre-assigning shellId:', pendingShellId) - shellIdRef.current = pendingShellId - - logger.debug('[Terminal] Creating shell for connection:', connectionId, 'with shellId:', pendingShellId) - const shellResult = await window.electronAPI?.sshCreateShell(connectionId, { - cols: xtermRef.current?.cols || 80, - rows: xtermRef.current?.rows || 24, - }, pendingShellId) - - if (!shellResult?.success || !shellResult.shellId) { - logger.error('[Terminal] Shell creation failed:', shellResult?.error) - shellIdRef.current = null - await getConnectionStore().disconnectConnection(connectionId, 'shell creation failed') - throw new Error(shellResult?.error || '创建终端失败') - } - - // shellId 应该和 pendingShellId 相同(由后端确认) - const confirmedShellId = shellResult.shellId - logger.debug('[Terminal] Shell confirmed with ID:', confirmedShellId) - // shellIdRef 已经是 confirmedShellId(等于 pendingShellId) - getConnectionStore().addShell(connectionId, confirmedShellId) - - // 更新标签 - const connectionInstance: ConnectionInstance = { - connectionId, - shellId: confirmedShellId, - } - updateTabConnectionInstance(tabId, connectionInstance) - - // 更新状态 - setConnectionState('connected') - updateTabStatus(tabId, 'connected') - getConnectionStore().setActiveTab(connectionId, tabId) - - // 更新标题 - if (currentTab.title === '新终端' || currentTab.title === 'Terminal') { - updateTab(tabId, { - title: `${config.username}@${config.host}`, - }) - } - - // 等待服务器返回真实的 Last login 信息和提示符 - // 不手动写入任何内容,让 SSH 服务器的自然输出显示 - - xtermRef.current?.focus() - - } catch (error: any) { - setConnectionState('error') - updateTabStatus(tabId, 'error') - setStatusMessage(`错误: ${error.message}`) - xtermRef.current?.writeln(`\r\n\x1b[31m错误: ${error.message}\x1b[0m`) - } finally { - isConnectingRef.current = false - hasConnectedRef.current = true - } - }, [tabId, updateTabConnectionInstance, updateTabStatus, getTabById]) - - // 注意:不再使用模拟的 Last login 信息 - // 真实的 SSH 服务器会自动发送欢迎信息和提示符 - - // 恢复挂起的连接 - const resumeConnection = useCallback(async () => { - if (!connectionIdRef.current) return - - setConnectionState('reconnecting') - const resumed = await getConnectionStore().resumeConnection(connectionIdRef.current) - - if (resumed) { - // 获取新的连接 ID - const currentTab = getTabById(tabId) - if (currentTab?.connectionInstance?.connectionId) { - connectionIdRef.current = currentTab.connectionInstance.connectionId - - // 创建新的 shell - 预分配 shellId 防止数据丢失 - const newShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` - shellIdRef.current = newShellId - - const shellResult = await window.electronAPI?.sshCreateShell(connectionIdRef.current, { - cols: xtermRef.current?.cols || 80, - rows: xtermRef.current?.rows || 24, - }, newShellId) - - if (shellResult?.success && shellResult.shellId) { - // shellId 应该和 newShellId 相同 - getConnectionStore().addShell(connectionIdRef.current, shellResult.shellId) - - updateTabConnectionInstance(tabId, { - connectionId: connectionIdRef.current, - shellId: shellResult.shellId, - }) - - setConnectionState('connected') - updateTabStatus(tabId, 'connected') - setShowSuspendedOverlay(false) - - // 恢复连接成功,等待服务器发送提示符 - return - } - } - } - - // 恢复失败,重新连接 - setConnectionState('disconnected') - hasConnectedRef.current = false - setShowSuspendedOverlay(false) - connect() - }, [connect, updateTabConnectionInstance, updateTabStatus, getTabById, tabId]) - // 初始连接 useEffect(() => { if (!hasConnectedRef.current && isActive) { - // 检查标签是否从持久化恢复(需要手动重新连接) const currentTab = getTabById(tabId) if (currentTab?.needsReconnect) { - // 从持久化恢复的标签,不自动连接,显示「重新连接」界面 logger.info('[Terminal] Tab restored from session, waiting for manual reconnect') - setConnectionState('disconnected') - setStatusMessage('上次会话已恢复,点击重新连接') return } - // 检查是否有快照 const hasSnapshot = snapshotStore.hasSnapshot(tabId) if (hasSnapshot && isActive) { - // 先恢复快照 restoreSnapshot() } - + connect() } }, []) // 只在挂载时执行 @@ -520,58 +219,34 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro const pool = getConnectionStore() if (isActive) { - // 标签变为活跃 if (connectionIdRef.current) { const conn = pool.getConnection(connectionIdRef.current) if (conn?.state === 'suspended') { setShowSuspendedOverlay(true) - setConnectionState('suspended') } else { pool.setActiveTab(connectionIdRef.current, tabId) - // 同步本地状态 - setConnectionState(conn?.state === 'connected' || conn?.state === 'background' ? conn.state : 'connected') - // 不再恢复快照 - shell 是实时输出的,重复写入会导致内容重复 } } xtermRef.current?.focus() } else { - // 标签进入后台 - 保存快照用于恢复 if (connectionIdRef.current) { pool.setBackgroundTab(connectionIdRef.current, tabId) saveSnapshot() } } - }, [isActive, tabId, saveSnapshot]) - - // 重连功能 - const handleReconnect = useCallback(() => { - if (connectionState === 'error' || connectionState === 'disconnected' || connectionState === 'suspended') { - if (connectionIdRef.current) { - getConnectionStore().disconnectConnection(connectionIdRef.current, 'manual reconnect') - } - connectionIdRef.current = null - shellIdRef.current = null - hasConnectedRef.current = false - setConnectionState('disconnected') - setShowSuspendedOverlay(false) - // 清除 needsReconnect 标志 - updateTab(tabId, { needsReconnect: false }) - xtermRef.current?.clear() - connect() - } - }, [connectionState, connect, updateTab, tabId]) + }, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay]) // 组件卸载时清理监听器 useEffect(() => { return () => { - // 重置连接状态,防止严格模式下 remount 时状态混乱 isConnectingRef.current = false - listenerRegisteredRef.current = false // 重置监听器注册标记 - // 组件真正卸载时移除所有监听器 + listenerRegisteredRef.current = false window.electronAPI?.removeAllListeners('ssh:shellData') } }, []) + const currentTab = getTabById(tabId) + return (
{/* 终端容器 */} @@ -580,131 +255,30 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro ref={terminalRef} className="w-full h-full bg-dark-900 rounded" /> - - {/* 搜索栏 - 终端顶部覆盖 */} + + {/* 搜索栏 */} {showSearch && ( -
- - { - if (e.key === 'Enter') { - if (e.shiftKey) { - handleSearchPrevious() - } else { - handleSearchNext() - } - } - e.stopPropagation() - }} - placeholder="搜索..." - className="flex-1 bg-dark-900 text-gray-200 text-sm px-2 py-1 rounded border border-dark-600 focus:border-accent-blue focus:outline-none placeholder-gray-500" - /> - {searchResultCount !== undefined && ( - - {searchResultIndex ?? 0}/{searchResultCount} - - )} - - - -
+ )} - - {/* 会话恢复 - 需要重新连接覆盖层 */} - {connectionState === 'disconnected' && getTabById(tabId)?.needsReconnect && ( -
-
-
- -
-
会话已恢复
-
- 上次关闭时的终端标签已恢复
- 点击下方按钮重新建立 SSH 连接 -
- -
-
- )} - - {/* 挂起状态覆盖层 */} - {showSuspendedOverlay && ( -
-
-
连接已挂起
-
- 长时间未操作,连接已进入挂起状态 -
- - -
-
- )} - - {/* 错误状态覆盖层 */} - {connectionState === 'error' && ( -
-
-
连接失败
-
{statusMessage}
- -
-
- )} - - {/* 连接中状态 */} - {connectionState === 'connecting' && ( -
-
连接中...
-
- )} - - {/* 连接状态指示器 - 简洁版 */} -
- {/* 状态指示已移至 TabBar */} -
+ + {/* 连接状态覆盖层 */} +
) diff --git a/src/components/WelcomeView.tsx b/src/components/WelcomeView.tsx index 3ae4885..241716f 100644 --- a/src/components/WelcomeView.tsx +++ b/src/components/WelcomeView.tsx @@ -1,6 +1,7 @@ -import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap, Inbox } from 'lucide-react' +import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap, Clock } from 'lucide-react' import { useSessionStore } from '../stores' import { useTabStore } from '../stores/tabStore' +import EmptyState from './common/EmptyState' interface WelcomeViewProps { onNewSession?: () => void @@ -30,7 +31,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
-
+
+ )} +
+ ) +} diff --git a/src/components/common/Loading.tsx b/src/components/common/Loading.tsx index dc0163b..3d88548 100644 --- a/src/components/common/Loading.tsx +++ b/src/components/common/Loading.tsx @@ -84,14 +84,26 @@ export function ButtonLoading({ text = '处理中...' }: { text?: string }) { } /** - * 骨架屏组件 + * 骨架屏组件 (向后兼容) + * + * 新代码请使用 @/components/common/Skeleton 中的增强版本 * * @example * - * */ export function Skeleton({ className = '' }: { className?: string }) { return (
) } + +// 重新导出增强版骨架屏组件,方便统一引用 +export { + Skeleton as SkeletonBase, + SkeletonLine, + SkeletonCircle, + SkeletonBlock, + FileListSkeleton, + SessionTreeSkeleton, + TabContentSkeleton, +} from './Skeleton' diff --git a/src/components/common/Skeleton.tsx b/src/components/common/Skeleton.tsx new file mode 100644 index 0000000..532b551 --- /dev/null +++ b/src/components/common/Skeleton.tsx @@ -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 + * + * + */ +export function Skeleton({ width, height, className }: SkeletonBaseProps) { + return ( +
+ ) +} + +// ─── SkeletonLine (行骨架) ───────────────────────────────────── + +export interface SkeletonLineProps extends SkeletonBaseProps { + /** 行数,默认 1 */ + lines?: number + /** 行间距 */ + gap?: number +} + +/** + * 行骨架 — 模拟文本行 + * + * @example + * + */ +export function SkeletonLine({ lines = 1, gap = 8, width, height = 14, className }: SkeletonLineProps) { + if (lines <= 1) { + return + } + + return ( +
+ {Array.from({ length: lines }).map((_, i) => ( + + ))} +
+ ) +} + +// ─── SkeletonCircle (圆形骨架) ───────────────────────────────── + +export interface SkeletonCircleProps extends SkeletonBaseProps { + /** 圆形尺寸(宽高相同),默认 40 */ + size?: number +} + +/** + * 圆形骨架 — 模拟头像/图标 + * + * @example + * + */ +export function SkeletonCircle({ size = 40, className }: SkeletonCircleProps) { + return ( + + ) +} + +// ─── SkeletonBlock (卡片/区块骨架) ───────────────────────────── + +export interface SkeletonBlockProps extends SkeletonBaseProps { + /** 圆角大小 */ + rounded?: 'sm' | 'md' | 'lg' +} + +/** + * 区块骨架 — 模拟卡片/面板 + * + * @example + * + */ +export function SkeletonBlock({ rounded = 'lg', className, ...rest }: SkeletonBlockProps) { + const roundedMap = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg' } + return ( + + ) +} + +// ─── FileListSkeleton (文件列表骨架屏) ───────────────────────── + +export interface FileListSkeletonProps { + /** 模拟行数,默认 6 */ + rows?: number + /** 自定义类名 */ + className?: string +} + +/** + * 文件列表骨架屏 + * 模拟文件列表 (图标 + 文件名 + 大小 + 日期) + * + * @example + * + */ +export function FileListSkeleton({ rows = 6, className }: FileListSkeletonProps) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ {/* 图标占位 */} + + + {/* 文件名 — 宽度随机模拟不同文件名长度 */} + + + {/* 弹性间隔 */} +
+ + {/* 大小占位 */} + + + {/* 日期占位 */} + +
+ ))} +
+ ) +} + +// ─── SessionTreeSkeleton (会话树骨架屏) ──────────────────────── + +export interface SessionTreeSkeletonProps { + /** 分组数量,默认 2 */ + groups?: number + /** 每组会话数量(范围 [min, max]),默认 [3, 4] */ + sessionsPerGroup?: [number, number] + /** 自定义类名 */ + className?: string +} + +/** + * 会话树骨架屏 + * 模拟分组头 + 会话项 (2 组, 每组 3-4 项) + * + * @example + * + */ +export function SessionTreeSkeleton({ + groups = 2, + sessionsPerGroup = [3, 4], + className, +}: SessionTreeSkeletonProps) { + return ( +
+ {Array.from({ length: groups }).map((_, gIdx) => { + const count = sessionsPerGroup[0] + (gIdx % (sessionsPerGroup[1] - sessionsPerGroup[0] + 1)) + return ( +
+ {/* 分组头 */} +
+ + + +
+ +
+ + {/* 会话项 */} +
+ {Array.from({ length: count }).map((_, sIdx) => ( +
+ {/* 服务器图标占位 */} + + {/* 名称 + host */} +
+ + +
+
+ ))} +
+
+ ) + })} +
+ ) +} + +// ─── TabContentSkeleton (终端内容骨架屏) ──────────────────────── + +export interface TabContentSkeletonProps { + /** 自定义类名 */ + className?: string +} + +/** + * 终端内容骨架屏 + * 黑色背景 + 模拟终端行 + 光标闪烁 + * + * @example + * + */ +export function TabContentSkeleton({ className }: TabContentSkeletonProps) { + return ( +
+ {/* 模拟终端提示行 */} + {Array.from({ length: 12 }).map((_, i) => ( +
+ {/* 提示符 */} + $ + {/* 命令行占位 */} + {i < 3 ? ( + + ) : ( + + )} +
+ ))} + + {/* 闪烁光标行 */} +
+ $ + +
+
+ ) +} + +// ─── 默认导出 (兼容旧代码中 import Skeleton from ...) ────────── + +export default Skeleton diff --git a/src/components/common/ToastContainer.tsx b/src/components/common/ToastContainer.tsx new file mode 100644 index 0000000..fba3849 --- /dev/null +++ b/src/components/common/ToastContainer.tsx @@ -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 = { + info: , + success: , + error: , + warning: , +} + +const bgMap: Record = { + 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 ( +
+ {toasts.map((toast) => ( +
+ {iconMap[toast.type]} + {toast.message} + +
+ ))} +
+ ) +} diff --git a/src/components/index.ts b/src/components/index.ts index de7c6a9..a7689a1 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -21,6 +21,16 @@ export { default as WelcomeView } from './WelcomeView' // 通用组件 export { default as ErrorBoundary } from './common/ErrorBoundary' -export { default as Loading, ButtonLoading, Skeleton } from './common/Loading' +export { default as Loading, ButtonLoading } from './common/Loading' +export { + Skeleton, + SkeletonLine, + SkeletonCircle, + SkeletonBlock, + FileListSkeleton, + SessionTreeSkeleton, + TabContentSkeleton, +} from './common/Skeleton' export { default as Button } from './common/Button' export { default as Modal } from './common/Modal' +export { default as EmptyState } from './common/EmptyState' diff --git a/src/components/sftp/ChmodDialog.tsx b/src/components/sftp/ChmodDialog.tsx new file mode 100644 index 0000000..a998ddf --- /dev/null +++ b/src/components/sftp/ChmodDialog.tsx @@ -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 +} + +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 + }) => ( + + ) + + const PermRow = ({ + scope, + label, + }: { + scope: 'owner' | 'group' | 'other' + label: string + }) => ( +
+ {label} + + + +
+ ) + + return ( + +
+ {/* File info */} +
+ +
+

{fileName}

+

{filePath}

+
+
+ + {/* Current octal mode display */} +
+ 当前权限: + {octalString} +
+ + {/* Permission matrix */} +
+
+ + 读取 (r) + 写入 (w) + 执行 (x) +
+ + + +
+
+
+ ) +} diff --git a/src/components/sftp/ContextMenu.tsx b/src/components/sftp/ContextMenu.tsx index 114e172..4d51ba4 100644 --- a/src/components/sftp/ContextMenu.tsx +++ b/src/components/sftp/ContextMenu.tsx @@ -9,6 +9,7 @@ export interface MenuItem { label: string action: () => void danger?: boolean + separator?: boolean } interface ContextMenuProps { @@ -45,19 +46,23 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }} > {items.map((item, index) => ( - +
+ {item.separator && index > 0 && ( +
+ )} + +
))}
) diff --git a/src/components/sftp/FileListPanel.tsx b/src/components/sftp/FileListPanel.tsx index 7da0bdd..5bfc6db 100644 --- a/src/components/sftp/FileListPanel.tsx +++ b/src/components/sftp/FileListPanel.tsx @@ -2,13 +2,17 @@ * FileListPanel - 文件列表面板 * * 本地面板或远程面板的文件列表组件 + * 远程面板支持拖拽上传 */ -import { Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown } from 'lucide-react' +import { useState, useCallback, useRef } from 'react' +import { Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, FolderOpen } from 'lucide-react' import { Breadcrumb } from './Breadcrumb' import { SortHeader } from './SortHeader' import { FileRow } from './FileRow' import { ContextMenu, type MenuItem } from './ContextMenu' +import { FileListSkeleton } from '../common/Skeleton' +import EmptyState from '../common/EmptyState' import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types' interface FileListPanelProps { @@ -31,6 +35,10 @@ interface FileListPanelProps { onSort: (field: SortField) => void contextMenu: { x: number; y: number; items: MenuItem[] } | null onCloseContextMenu: () => void + /** 远程面板拖拽上传回调 */ + onDropFiles?: (files: FileList) => void + /** 是否正在加载 */ + loading?: boolean } export function FileListPanel({ @@ -53,12 +61,65 @@ export function FileListPanel({ onSort, contextMenu, onCloseContextMenu, + onDropFiles, + loading = false, }: FileListPanelProps) { const canGoBack = historyIndex > 0 const canGoForward = historyIndex < history.length - 1 + // Drag state for remote panel upload + const [isDragOver, setIsDragOver] = useState(false) + const dragCounterRef = useRef(0) + + const handleDragEnter = useCallback((e: React.DragEvent) => { + if (isLocal || !onDropFiles) return + e.preventDefault() + e.stopPropagation() + dragCounterRef.current++ + if (dragCounterRef.current === 1) { + setIsDragOver(true) + } + }, [isLocal, onDropFiles]) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + if (isLocal || !onDropFiles) return + e.preventDefault() + e.stopPropagation() + dragCounterRef.current-- + if (dragCounterRef.current === 0) { + setIsDragOver(false) + } + }, [isLocal, onDropFiles]) + + const handleDragOver = useCallback((e: React.DragEvent) => { + if (isLocal || !onDropFiles) return + e.preventDefault() + e.stopPropagation() + }, [isLocal, onDropFiles]) + + const handleDrop = useCallback((e: React.DragEvent) => { + if (isLocal || !onDropFiles) return + e.preventDefault() + e.stopPropagation() + dragCounterRef.current = 0 + setIsDragOver(false) + + const droppedFiles = e.dataTransfer.files + if (droppedFiles && droppedFiles.length > 0) { + onDropFiles(droppedFiles) + } + }, [isLocal, onDropFiles]) + return ( -
+
{/* Header */}
@@ -100,11 +161,15 @@ export function FileListPanel({
{/* File List */} -
- {files.length === 0 ? ( -
- 空文件夹 -
+
+ {loading ? ( + + ) : files.length === 0 ? ( + ) : ( files.map((file) => ( )) )} + + {/* Drag overlay - pointer-events-none so it doesn't interfere with drop events */} + {isDragOver && ( +
+
+ + 拖拽文件到此处上传 +
+
+ )}
{/* Status Bar */}
- {selected.size > 0 ? `已选择 ${selected.size} 项` : `共 ${files.length} 项`} + {loading ? '加载中...' : selected.size > 0 ? `已选择 ${selected.size} 项` : `共 ${files.length} 项`}
{/* Context Menu */} diff --git a/src/components/sftp/FilePropertiesDialog.tsx b/src/components/sftp/FilePropertiesDialog.tsx new file mode 100644 index 0000000..789aa8f --- /dev/null +++ b/src/components/sftp/FilePropertiesDialog.tsx @@ -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 +} + +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(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 ( + +
+ {/* File icon + name header */} +
+
+ +
+
+

{fileName}

+

{typeLabel}

+
+
+ + {loading && ( +
+
+ 加载详细信息... +
+ )} + + {/* Properties table */} +
+ } label="名称" value={fileName} /> + } label="路径" value={filePath} monospace /> + } label="大小" value={formatFileSize(size)} /> + } label="权限" value={stringMode + (octalMode ? ` (${octalMode})` : '')} monospace /> + } label="修改时间" value={formatTimestamp(mtime)} /> + {stats && ( + } label="访问时间" value={formatTimestamp(atime)} /> + )} +
+
+ + ) +} + +function PropertyRow({ + icon, + label, + value, + monospace = false, +}: { + icon: React.ReactNode + label: string + value: string + monospace?: boolean +}) { + return ( +
+ {icon} + {label} + + {value} + +
+ ) +} diff --git a/src/components/sidebar/SessionTree.tsx b/src/components/sidebar/SessionTree.tsx index f2bd47d..54d6fc2 100644 --- a/src/components/sidebar/SessionTree.tsx +++ b/src/components/sidebar/SessionTree.tsx @@ -1,11 +1,13 @@ /** * SessionTree - 会话树组件 * - * 显示分组和会话列表 + * 显示分组和会话列表,带折叠动画 */ -import { ChevronRight, ChevronDown, Folder, Server } from 'lucide-react' +import { ChevronRight, Folder, Server, ServerOff } from 'lucide-react' +import { SessionTreeSkeleton } from '../common/Skeleton' import type { Session } from '../../stores/sessionStore' +import EmptyState from '../common/EmptyState' interface SessionTreeProps { sessions: Session[] @@ -15,6 +17,10 @@ interface SessionTreeProps { onSelectSession: (session: Session) => void onContextMenu: (e: React.MouseEvent, session: Session) => void searchTerm: string + /** 是否正在加载 */ + loading?: boolean + /** 新建会话回调,用于空状态操作按钮 */ + onNewSession?: () => void } export function SessionTree({ @@ -25,7 +31,18 @@ export function SessionTree({ onSelectSession, onContextMenu, searchTerm, + loading = false, + onNewSession, }: SessionTreeProps) { + // 加载中时显示骨架屏 + if (loading) { + return ( +
+ +
+ ) + } + // Filter sessions by search const filteredSessions = sessions.filter(s => s.name.toLowerCase().includes(searchTerm.toLowerCase()) || @@ -42,56 +59,66 @@ export function SessionTree({ return (
- {Object.entries(grouped).map(([group, groupSessions]) => ( -
- {/* Group Header */} - + {Object.entries(grouped).map(([group, groupSessions]) => { + const isExpanded = expandedGroups.has(group) + return ( +
+ {/* Group Header */} + - {/* Sessions */} - {expandedGroups.has(group) && ( -
- {groupSessions.map((session) => ( -
onSelectSession(session)} - onContextMenu={(e) => onContextMenu(e, session)} - className={` - group flex items-center gap-2 px-4 py-2 text-sm cursor-pointer - transition-colors pl-10 - ${selectedSessionId === session.id - ? 'bg-dark-700 border-l-2 border-accent-blue' - : 'hover:bg-dark-700' - } - `} - > - -
-
{session.name}
-
{session.host}:{session.port}
-
+ {/* Sessions - CSS Grid collapse animation */} +
+
+
+ {groupSessions.map((session) => ( +
onSelectSession(session)} + onContextMenu={(e) => onContextMenu(e, session)} + className={` + group flex items-center gap-2 px-4 py-2 text-sm cursor-pointer + transition-colors pl-10 + ${selectedSessionId === session.id + ? 'bg-dark-700 border-l-2 border-accent-blue' + : 'hover:bg-dark-700' + } + `} + > + +
+
{session.name}
+
{session.host}:{session.port}
+
+
+ ))}
- ))} +
- )} -
- ))} +
+ ) + })} {filteredSessions.length === 0 && ( -
- {searchTerm ? '未找到匹配的会话' : '暂无会话'} -
+ )}
) diff --git a/src/components/tabbar/TabItem.tsx b/src/components/tabbar/TabItem.tsx index 488072b..765777c 100644 --- a/src/components/tabbar/TabItem.tsx +++ b/src/components/tabbar/TabItem.tsx @@ -1,5 +1,5 @@ /** - * TabItem - 单个标签项 + * TabItem - 单个标签项(支持拖拽排序) */ import { X, Terminal, FolderSync } from 'lucide-react' @@ -7,13 +7,33 @@ import type { Tab } from '../../stores/tabStore' interface TabItemProps { tab: Tab + index: number isActive: boolean + isDragging: boolean + dragOverIndex: number | null onActivate: () => void onClose: (e: React.MouseEvent) => void onContextMenu: (e: React.MouseEvent) => void + onDragStart: (e: React.DragEvent, index: number) => void + onDragOver: (e: React.DragEvent, index: number) => void + onDrop: (e: React.DragEvent, index: number) => void + onDragEnd: () => void } -export function TabItem({ tab, isActive, onActivate, onClose, onContextMenu }: TabItemProps) { +export function TabItem({ + tab, + index, + isActive, + isDragging, + dragOverIndex, + onActivate, + onClose, + onContextMenu, + onDragStart, + onDragOver, + onDrop, + onDragEnd, +}: TabItemProps) { const getIcon = () => { switch (tab.type) { case 'terminal': @@ -25,20 +45,39 @@ export function TabItem({ tab, isActive, onActivate, onClose, onContextMenu }: T } } + // Determine drop indicator position + const showLeftIndicator = dragOverIndex === index && !isDragging + const showRightIndicator = dragOverIndex === index + 1 && !isDragging + return (
onDragStart(e, index)} + onDragOver={(e) => onDragOver(e, index)} + onDrop={(e) => onDrop(e, index)} + onDragEnd={onDragEnd} className={` - group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[200px] + group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[200px] border-r border-dark-700 cursor-pointer select-none - transition-colors relative - ${isActive - ? 'bg-dark-800 text-gray-200' + transition-all duration-150 relative + ${isActive + ? 'bg-dark-800 text-gray-200' : 'bg-dark-900 text-gray-500 hover:bg-dark-800 hover:text-gray-400' } + ${isDragging ? 'opacity-50' : 'opacity-100'} `} > + {/* Left drop indicator */} + {showLeftIndicator && ( +
+ )} + {/* Right drop indicator */} + {showRightIndicator && ( +
+ )} + {/* Status indicator */} {tab.connectionStatus === 'connecting' && ( @@ -67,6 +106,11 @@ export function TabItem({ tab, isActive, onActivate, onClose, onContextMenu }: T > + + {/* Active tab bottom indicator */} + {isActive && ( +
+ )}
) } diff --git a/src/components/terminal/TerminalOverlay.tsx b/src/components/terminal/TerminalOverlay.tsx new file mode 100644 index 0000000..080f0c0 --- /dev/null +++ b/src/components/terminal/TerminalOverlay.tsx @@ -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 && ( +
+
+
+ +
+
会话已恢复
+
+ 上次关闭时的终端标签已恢复
+ 点击下方按钮重新建立 SSH 连接 +
+ +
+
+ )} + + {/* 挂起状态覆盖层 */} + {showSuspendedOverlay && ( +
+
+
连接已挂起
+
+ 长时间未操作,连接已进入挂起状态 +
+ + +
+
+ )} + + {/* 错误状态覆盖层 */} + {connectionState === 'error' && ( +
+
+
连接失败
+
{statusMessage}
+ +
+
+ )} + + {/* 连接中状态 */} + {connectionState === 'connecting' && ( +
+
连接中...
+
+ )} + + {/* 连接状态指示器 - 简洁版 */} +
+ {/* 状态指示已移至 TabBar */} +
+ + ) +} diff --git a/src/components/terminal/TerminalSearchBar.tsx b/src/components/terminal/TerminalSearchBar.tsx new file mode 100644 index 0000000..83d5908 --- /dev/null +++ b/src/components/terminal/TerminalSearchBar.tsx @@ -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 + onSearchChange: (e: React.ChangeEvent) => void + onSearchNext: () => void + onSearchPrevious: () => void + onClose: () => void +} + +export default function TerminalSearchBar({ + searchQuery, + searchResultCount, + searchResultIndex, + searchInputRef, + onSearchChange, + onSearchNext, + onSearchPrevious, + onClose, +}: TerminalSearchBarProps) { + return ( +
+ + { + 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 && ( + + {searchResultIndex ?? 0}/{searchResultCount} + + )} + + + +
+ ) +} diff --git a/src/electron.d.ts b/src/electron.d.ts index 4310546..db19481 100644 --- a/src/electron.d.ts +++ b/src/electron.d.ts @@ -68,6 +68,18 @@ interface ElectronAPI { sftpRename: (sftpId: string, oldPath: string, newPath: string) => Promise sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => Promise sftpMkdir: (sftpId: string, path: string) => Promise + sftpStat: (sftpId: string, filePath: string) => Promise + sftpChmod: (sftpId: string, filePath: string, mode: number) => Promise // Settings settingsGet: (key: string) => Promise diff --git a/src/hooks/index.ts b/src/hooks/index.ts index cd5504a..094e332 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -6,6 +6,11 @@ export { useSFTP } from './useSFTP' export type { UseSFTPOptions, UseSFTPReturn } from './useSFTP' +export { useKeyboardShortcuts } from './useKeyboardShortcuts' +export { useToast } from './useToast' +export type { Toast, ToastType } from './useToast' + +export { useMediaQuery, useIsMobile } from './useMediaQuery' // 待实现 // export { useTerminal } from './useTerminal' diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..823d415 --- /dev/null +++ b/src/hooks/useKeyboardShortcuts.ts @@ -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 diff --git a/src/hooks/useMediaQuery.ts b/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..ab5922b --- /dev/null +++ b/src/hooks/useMediaQuery.ts @@ -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)') +} diff --git a/src/hooks/useSFTP.ts b/src/hooks/useSFTP.ts index b735e0d..7f00866 100644 --- a/src/hooks/useSFTP.ts +++ b/src/hooks/useSFTP.ts @@ -13,23 +13,6 @@ import type { FileItem, TransferTask, SortField, SortOrder } from '../../shared/ const logger = createLogger('useSFTP') -// ===== 彩色调试日志 ===== -function logHeader(msg: string) { - console.log(`%c${msg}`, 'background: #16a085; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;') -} -function logInfo(label: string, value: any) { - console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #1abc9c; font-weight: bold;') -} -function logSuccess(msg: string) { - console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;') -} -function logError(msg: string) { - console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;') -} -function logWarning(msg: string) { - console.log(`%c⚠ ${msg}`, 'color: #f39c12; font-weight: bold;') -} - export interface UseSFTPOptions { tabId: string } @@ -77,6 +60,17 @@ export interface UseSFTPReturn { // 远程文件操作 deleteRemote: (files: FileItem[]) => Promise + renameRemote: (file: FileItem, newName: string) => Promise + 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 // 排序 sortField: SortField @@ -92,7 +86,7 @@ export interface UseSFTPReturn { export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { const { sessions } = useSessionStore() const { updateTabConnectionInstance, updateTab } = useTabStore() - const { createConnection, getConnection } = useConnectionPool() + const { createConnection } = useConnectionPool() // Local state const [localPath, setLocalPath] = useState('') @@ -124,50 +118,40 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { // Initialize home path useEffect(() => { - logHeader('📁 SFTP Initializing home path') + logger.info('SFTP Initializing home path') if (window.electronAPI) { window.electronAPI.getHomePath?.().then((result: { success: boolean; path?: string }) => { - logInfo('Home path result', result) + logger.debug('Home path result', result) if (result.success && result.path) { setLocalPath(result.path) setLocalHistory([result.path]) setLocalHistoryIndex(0) - logSuccess(`Home path set: ${result.path}`) + logger.info(`Home path set: ${result.path}`) // Load local directory loadLocalDirectory(result.path) } else { - logError('Failed to get home path') + logger.error('Failed to get home path') } }).catch((err: any) => { - logError(`getHomePath error: ${err?.message || err}`) + logger.error(`getHomePath error: ${err?.message || err}`) }) } else { - logWarning('electronAPI not available') + logger.warn('electronAPI not available') } }, []) // Load local directory const loadLocalDirectory = useCallback(async (path: string) => { - logHeader('📁 LOAD LOCAL DIRECTORY') - logInfo('path', path) + logger.info('Loading local directory', path) if (!window.electronAPI?.localReadDir) { - logWarning('localReadDir not available, using mock data') - // Mock files for development - const mockFiles: FileItem[] = [ - { name: 'Desktop', path: `${path}/Desktop`, type: 'directory', size: 0, modified: Date.now() / 1000 - 86400, permissions: 'drwxr-xr-x' }, - { name: 'Documents', path: `${path}/Documents`, type: 'directory', size: 0, modified: Date.now() / 1000 - 172800, permissions: 'drwxr-xr-x' }, - { name: 'Downloads', path: `${path}/Downloads`, type: 'directory', size: 0, modified: Date.now() / 1000 - 3600, permissions: 'drwxr-xr-x' }, - { name: 'config.json', path: `${path}/config.json`, type: 'file', size: 4096, modified: Date.now() / 1000 - 7200, permissions: '-rw-r--r--' }, - ] - setLocalFiles(mockFiles) - logSuccess(`Loaded ${mockFiles.length} mock files`) + logger.warn('localReadDir not available') return } try { const result = await window.electronAPI.localReadDir(path) - logInfo('localReadDir result', result) + logger.debug('localReadDir result', result) if (result.success && result.files) { const files: FileItem[] = result.files.map((f: any) => ({ @@ -179,30 +163,27 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { permissions: f.isDirectory ? 'drwxr-xr-x' : '-rw-r--r--', })) setLocalFiles(files) - logSuccess(`Loaded ${files.length} local files`) + logger.info(`Loaded ${files.length} local files`) } else { - logError(`Failed to load local directory: ${result.error}`) + logger.error(`Failed to load local directory: ${result.error}`) } } catch (err: any) { - logError(`loadLocalDirectory error: ${err?.message}`) - logger.error('Failed to load local directory', err) + logger.error(`loadLocalDirectory error: ${err?.message}`, err) } }, []) // Load remote directory const loadRemoteDirectory = useCallback(async (path: string) => { - logHeader('📁 LOAD REMOTE DIRECTORY') - logInfo('path', path) - logInfo('sftpId', sftpId) + logger.info('Loading remote directory', { path, sftpId }) if (!sftpId || !window.electronAPI) { - logError('sftpId or electronAPI not available') + logger.error('sftpId or electronAPI not available') return } try { const result = await window.electronAPI.sftpListDirectory(sftpId, path) - logInfo('sftpListDirectory result', result) + logger.debug('sftpListDirectory result', result) if (result.success && result.files) { const files: FileItem[] = result.files.map((f: any) => ({ @@ -214,13 +195,12 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { permissions: f.longname?.substring(0, 10) || '-rw-r--r--', })) setRemoteFiles(files) - logSuccess(`Loaded ${files.length} remote files`) + logger.info(`Loaded ${files.length} remote files`) } else { - logError(`Failed to load remote directory: ${result.error}`) + logger.error(`Failed to load remote directory: ${result.error}`) } } catch (err: any) { - logError(`loadRemoteDirectory error: ${err?.message}`) - logger.error('Failed to load remote directory', err) + logger.error(`loadRemoteDirectory error: ${err?.message}`, err) setError('Failed to load remote directory') } }, [sftpId]) @@ -284,11 +264,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { // Connect to SFTP const connect = useCallback(async (sessionId: string) => { - logHeader('📁 SFTP CONNECT') - logInfo('sessionId', sessionId) + logger.info('SFTP CONNECT', { sessionId }) if (!sessionId) { - logError('No sessionId provided') + logger.error('No sessionId provided') return } @@ -297,43 +276,43 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { try { const config = await getSessionConfig(sessionId) - logInfo('Config', `${config?.username}@${config?.host}:${config?.port}`) + logger.debug('Config', `${config?.username}@${config?.host}:${config?.port}`) if (!config) { setError('Session not found') - logError('Session config not found') + logger.error('Session config not found') return } // Create connection - logInfo('Creating connection...', tabId) + logger.debug('Creating connection...', tabId) const result = await createConnection(tabId, config) - logInfo('Connection result', result) + logger.debug('Connection result', result) if (!result.success || !result.connectionId) { setError(result.error || 'Failed to create connection') - logError(`Connection failed: ${result.error}`) + logger.error(`Connection failed: ${result.error}`) return } const connId = result.connectionId setConnectionId(connId) - logSuccess(`Connection created: ${connId}`) + logger.info(`Connection created: ${connId}`) // Create SFTP - logInfo('Creating SFTP session...', connId) + logger.debug('Creating SFTP session...', connId) const sftpResult = await window.electronAPI?.sftpConnect(connId) - logInfo('SFTP result', sftpResult) + logger.debug('SFTP result', sftpResult) if (!sftpResult?.success || !sftpResult.sftpId) { setError(sftpResult?.error || 'Failed to create SFTP session') - logError(`SFTP creation failed: ${sftpResult?.error}`) + logger.error(`SFTP creation failed: ${sftpResult?.error}`) return } setSftpId(sftpResult.sftpId) setIsConnected(true) - logSuccess(`SFTP connected: ${sftpResult.sftpId}`) + logger.info(`SFTP connected: ${sftpResult.sftpId}`) // Update tab const connectionInstance: ConnectionInstance = { @@ -344,12 +323,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { updateTab(tabId, { title: `SFTP: ${config.username}@${config.host}` }) // Load initial remote directory - logInfo('Loading remote home directory...', '/') + logger.debug('Loading remote home directory...', '/') loadRemoteDirectory('/') - logSuccess('SFTP initialization complete') - - logger.info('SFTP connected', { host: config.host, connectionId: connId }) + logger.info('SFTP initialization complete') } catch (err) { logger.error('SFTP connection failed', err) setError('Connection failed') @@ -360,25 +337,25 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { // Disconnect const disconnect = useCallback(async () => { - if (sftpId && window.electronAPI) { - // Note: SFTP disconnect is handled automatically when connection closes - } - if (connectionId) { - const conn = getConnection(connectionId) - if (conn) { - // Cleanup + // Close SFTP session via SSH disconnect (SFTP is cleaned up when connection closes) + if (connectionId && window.electronAPI) { + try { + await window.electronAPI.sshDisconnect(connectionId) + logger.info('SFTP connection closed', { connectionId, sftpId }) + } catch (err) { + logger.error('Error closing SFTP connection', err) } } setIsConnected(false) setSftpId(null) setConnectionId(null) setRemoteFiles([]) - }, [connectionId, sftpId, getConnection]) + }, [connectionId, sftpId]) // Upload files (real implementation via IPC) const uploadFiles = useCallback(async (files: FileList | null) => { if (!files || files.length === 0 || !sftpId || !window.electronAPI) { - logWarning('uploadFiles: missing files, sftpId, or electronAPI') + logger.warn('uploadFiles: missing files, sftpId, or electronAPI') return } @@ -415,7 +392,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { try { for (const task of newTasks) { const remoteFilePath = `${remotePath}/${task.fileName}`.replace(/\/+/g, '/') - logInfo('Uploading', `${task.filePath} -> ${remoteFilePath}`) + logger.debug('Uploading', `${task.filePath} -> ${remoteFilePath}`) // Mark as transferring setTransferQueue(prev => prev.map(t => @@ -429,12 +406,12 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { setTransferQueue(prev => prev.map(t => t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t )) - logSuccess(`Upload complete: ${task.fileName}`) + logger.info(`Upload complete: ${task.fileName}`) } else { setTransferQueue(prev => prev.map(t => t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Upload failed' } : t )) - logError(`Upload failed: ${task.fileName} - ${result.error}`) + logger.error(`Upload failed: ${task.fileName} - ${result.error}`) } } catch (err: any) { logger.error('Upload failed', err) @@ -489,7 +466,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { try { for (const task of newTasks) { const localFilePath = `${localPath}/${task.fileName}` - logInfo('Downloading', `${task.filePath} -> ${localFilePath}`) + logger.debug('Downloading', `${task.filePath} -> ${localFilePath}`) setTransferQueue(prev => prev.map(t => t.id === task.id ? { ...t, status: 'transferring', startTime: Date.now() } : t @@ -502,12 +479,12 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { setTransferQueue(prev => prev.map(t => t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t )) - logSuccess(`Download complete: ${task.fileName}`) + logger.info(`Download complete: ${task.fileName}`) } else { setTransferQueue(prev => prev.map(t => t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Download failed' } : t )) - logError(`Download failed: ${task.fileName} - ${result.error}`) + logger.error(`Download failed: ${task.fileName} - ${result.error}`) } } catch (err: any) { logger.error('Download failed', err) @@ -527,27 +504,26 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { // Delete remote files/directories const deleteRemote = useCallback(async (files: FileItem[]): Promise => { if (!sftpId || files.length === 0 || !window.electronAPI) { - logWarning('deleteRemote: missing sftpId, files, or electronAPI') + logger.warn('deleteRemote: missing sftpId, files, or electronAPI') return false } let allSuccess = true for (const file of files) { - logInfo('Deleting remote', `${file.path} (isDir: ${file.type === 'directory'})`) + logger.debug('Deleting remote', `${file.path} (isDir: ${file.type === 'directory'})`) try { const result = await window.electronAPI.sftpDelete(sftpId, file.path, file.type === 'directory') if (result.success) { - logSuccess(`Deleted: ${file.name}`) + logger.info(`Deleted: ${file.name}`) } else { - logError(`Delete failed: ${file.name} - ${result.error}`) + logger.error(`Delete failed: ${file.name} - ${result.error}`) allSuccess = false } } catch (err: any) { - logger.error('Delete failed', err) - logError(`Delete error: ${file.name} - ${err?.message}`) + logger.error(`Delete error: ${file.name} - ${err?.message}`, err) allSuccess = false } } @@ -560,6 +536,76 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { return allSuccess }, [sftpId, remotePath, loadRemoteDirectory]) + // Rename remote file/directory + const renameRemote = useCallback(async (file: FileItem, newName: string): Promise => { + 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 => { + if (!sftpId || !window.electronAPI) { + logger.warn('chmodRemote: missing sftpId or electronAPI') + return false + } + + try { + const result = await window.electronAPI.sftpChmod(sftpId, filePath, mode) + if (result.success) { + logger.info(`Chmod success: ${filePath} -> ${mode.toString(8)}`) + loadRemoteDirectory(remotePath) + return true + } else { + logger.error(`Chmod failed: ${result.error}`) + return false + } + } catch (err: any) { + logger.error(`Chmod error: ${err?.message}`, err) + return false + } + }, [sftpId, remotePath, loadRemoteDirectory]) + // Cleanup on unmount useEffect(() => { return () => { @@ -605,6 +651,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { // Remote file operations deleteRemote, + renameRemote, + getRemoteStat, + chmodRemote, sortField, sortOrder, diff --git a/src/hooks/useTerminalConnection.ts b/src/hooks/useTerminalConnection.ts new file mode 100644 index 0000000..2dfbea6 --- /dev/null +++ b/src/hooks/useTerminalConnection.ts @@ -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 +} + +export interface UseTerminalConnectionReturn { + connectionState: ConnectionState + statusMessage: string + showSuspendedOverlay: boolean + connectionIdRef: React.MutableRefObject + shellIdRef: React.MutableRefObject + hasConnectedRef: React.MutableRefObject + isConnectingRef: React.MutableRefObject + connect: () => Promise + resumeConnection: () => Promise + handleReconnect: () => void + saveSnapshot: () => void + restoreSnapshot: () => boolean + setShowSuspendedOverlay: React.Dispatch> +} + +export function useTerminalConnection({ + tabId, + xtermRef, +}: UseTerminalConnectionParams): UseTerminalConnectionReturn { + const connectionIdRef = useRef(null) + const shellIdRef = useRef(null) + const hasConnectedRef = useRef(false) + const isConnectingRef = useRef(false) + + const [connectionState, setConnectionState] = useState('disconnected') + const [statusMessage, setStatusMessage] = useState('') + 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, + } +} diff --git a/src/hooks/useTerminalSearch.ts b/src/hooks/useTerminalSearch.ts new file mode 100644 index 0000000..e948198 --- /dev/null +++ b/src/hooks/useTerminalSearch.ts @@ -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 + searchAddonRef: React.MutableRefObject + isActive: boolean +} + +export interface UseTerminalSearchReturn { + showSearch: boolean + searchQuery: string + searchResultCount: number | undefined + searchResultIndex: number | undefined + searchInputRef: React.MutableRefObject + handleSearchChange: (e: React.ChangeEvent) => 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(undefined) + const [searchResultIndex, setSearchResultIndex] = useState(undefined) + const searchInputRef = useRef(null) + + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + 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, + } +} diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts index 0a90a26..0969d6b 100644 --- a/src/hooks/useToast.ts +++ b/src/hooks/useToast.ts @@ -1,53 +1,48 @@ /** - * useToast — 全局 Toast 通知 Hook + * useToast - Toast 通知系统 * - * 提供便捷的通知调用方法,可在任何组件中使用 - * - * @example - * const toast = useToast() - * toast.success('保存成功') - * toast.error('连接失败', { duration: 5000 }) - * toast.warning('磁盘空间不足') - * toast.info('正在同步...') + * 轻量级通知提示,用于操作反馈 */ -import { useToastStore, type ToastType, type ToastOptions } from '../stores/toastStore' +import { useState, useCallback } from 'react' -/** Toast 操作接口 */ -export interface ToastApi { - /** 显示成功通知(绿色) */ - success: (message: string, options?: ToastOptions) => string - /** 显示错误通知(红色) */ - error: (message: string, options?: ToastOptions) => string - /** 显示警告通知(琥珀色) */ - warning: (message: string, options?: ToastOptions) => string - /** 显示信息通知(蓝色) */ - info: (message: string, options?: ToastOptions) => string - /** 移除指定通知 */ - remove: (id: string) => void - /** 清空所有通知 */ - clearAll: () => void +export type ToastType = 'info' | 'success' | 'error' | 'warning' + +export interface Toast { + id: string + type: ToastType + message: string + duration?: number } -/** - * 全局 Toast 通知 Hook - */ -export function useToast(): ToastApi { - const addToast = useToastStore((s) => s.addToast) - const removeToast = useToastStore((s) => s.removeToast) - const clearAll = useToastStore((s) => s.clearAll) +const DEFAULT_DURATION = 3000 - const create = (type: ToastType) => (message: string, options?: ToastOptions) => - addToast(type, message, options) +export function useToast() { + const [toasts, setToasts] = useState([]) - return { - success: create('success'), - error: create('error'), - warning: create('warning'), - info: create('info'), - remove: removeToast, - clearAll, + const addToast = useCallback((type: ToastType, message: string, duration?: number) => { + const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 5)}` + const toast: Toast = { id, type, message, duration: duration ?? DEFAULT_DURATION } + setToasts(prev => [...prev, toast]) + + // Auto-remove after duration + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, toast.duration) + + return id + }, []) + + const removeToast = useCallback((id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, []) + + const toast = { + info: (message: string, duration?: number) => addToast('info', message, duration), + success: (message: string, duration?: number) => addToast('success', message, duration), + error: (message: string, duration?: number) => addToast('error', message, duration ?? 5000), + warning: (message: string, duration?: number) => addToast('warning', message, duration), } -} -export default useToast + return { toasts, toast, removeToast } +} diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 6412938..69679b3 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -12,6 +12,7 @@ export interface AppSettings { // 外观 theme: 'dark' | 'light' | 'system' language: 'zh-CN' | 'en-US' + sidebarWidth: number // 终端 fontFamily: string @@ -42,6 +43,7 @@ const defaultSettings: AppSettings = { showNotifications: false, theme: 'dark', language: 'zh-CN', + sidebarWidth: 256, fontFamily: 'JetBrains Mono', fontSize: 14, terminalTheme: 'dracula', diff --git a/src/stores/tabStore.ts b/src/stores/tabStore.ts index 7791f66..733f732 100644 --- a/src/stores/tabStore.ts +++ b/src/stores/tabStore.ts @@ -63,7 +63,11 @@ interface TabState { closeLeftTabs: (tabId: string) => Tab[] closeAllTabs: () => Tab[] activateTab: (tabId: string) => void + activateTabByIndex: (index: number) => void + switchToNextTab: () => void + switchToPrevTab: () => void updateTabTitle: (tabId: string, title: string) => void + moveTab: (fromIndex: number, toIndex: number) => void getTabById: (tabId: string) => Tab | undefined } @@ -230,6 +234,31 @@ export const useTabStore = create()( activeTabId: tabId, }) }, + + activateTabByIndex: (index) => { + const { tabs } = get() + if (index < 0 || index >= tabs.length) return + const targetTab = tabs[index] + if (targetTab) { + get().activateTab(targetTab.id) + } + }, + + switchToNextTab: () => { + const { tabs, activeTabId } = get() + if (tabs.length <= 1) return + const currentIndex = tabs.findIndex(t => t.id === activeTabId) + const nextIndex = (currentIndex + 1) % tabs.length + get().activateTab(tabs[nextIndex].id) + }, + + switchToPrevTab: () => { + const { tabs, activeTabId } = get() + if (tabs.length <= 1) return + const currentIndex = tabs.findIndex(t => t.id === activeTabId) + const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length + get().activateTab(tabs[prevIndex].id) + }, updateTabTitle: (tabId, title) => { const { tabs } = get() @@ -239,6 +268,29 @@ export const useTabStore = create()( set({ tabs: updatedTabs }) }, + moveTab: (fromIndex, toIndex) => { + const { tabs, activeTabId } = get() + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= tabs.length || + toIndex >= tabs.length + ) { + return + } + // Immutable reorder + const newTabs = [...tabs] + const [moved] = newTabs.splice(fromIndex, 1) + newTabs.splice(toIndex, 0, moved) + // Re-apply isActive based on activeTabId + const reordered = newTabs.map(t => ({ + ...t, + isActive: t.id === activeTabId, + })) + set({ tabs: reordered }) + }, + getTabById: (tabId) => { return get().tabs.find(t => t.id === tabId) },