feat(v1.2): 体验升级 — 快捷键系统 + 标签拖拽 + SFTP拖拽上传 + 组件拆分 + 响应式

任务1: 全局快捷键管理器 — useKeyboardShortcuts hook
  Ctrl+Tab/Shift+Tab标签切换, Ctrl+1~9跳转, F11全屏, Ctrl+Shift+N新建标签

任务2: 标签拖拽排序 — tabStore.moveTab + TabBar HTML5 DnD
  不可变更新, 拖拽视觉反馈(opacity+指示线), 活跃标签底部accent-blue指示线

任务3: SFTP拖拽上传 + useSFTP调试清理
  FileListPanel拖拽覆盖层(Upload图标+虚线边框), 5个调试函数→createLogger,
  disconnect()正确调用IPC, 删除mock data

任务4: SFTP右键菜单完善 — 重命名/复制路径/属性/权限
  FilePropertiesDialog+ChmodDialog, sftpStat/sftpChmod IPC全栈实现

任务5-6: 侧边栏分组折叠动画 + 宽度可拖拽调整
  Grid 0fr→1fr折叠动画, ChevronRight旋转, resize handle 180~400px, settings记忆

任务7: 响应式布局 + 视觉优化V1~V5
  useMediaQuery hook, <800px侧边栏overlay模式, SFTP垂直堆叠, WelcomeView响应式grid

任务8: Loading/Skeleton骨架屏
  Skeleton/SkeletonLine/SkeletonCircle/SkeletonBlock + FileListSkeleton/SessionTreeSkeleton/TabContentSkeleton

任务9: 空状态设计
  EmptyState通用组件, SessionTree/FileListPanel/WelcomeView/SFTPView空状态引导

任务10: TerminalView组件拆分重构 711行→285行
  useTerminalConnection(300行) + useTerminalSearch(123行)
  + TerminalOverlay(98行) + TerminalSearchBar(74行)

33 files changed, 2682 insertions(+), 872 deletions(-)
This commit is contained in:
OpsXShell Dev committed 2026-04-18 18:35:49 +08:00
1 parent d344900118
commit caba5f4c0d
33 files changed
+2683 -873

No files matched your search

+152 -10
View File
@@ -5,15 +5,24 @@
*/
import { useState, useCallback } from 'react'
import { FolderOpen, Upload, Download, Trash2 } from 'lucide-react'
import {
FolderOpen, Upload, Download, Trash2,
Edit3, Copy, FileText, ShieldCheck,
Unplug,
} from 'lucide-react'
import { useSFTP } from '../hooks'
import { useToast } from '../hooks/useToast'
import { FileListPanel } from './sftp/FileListPanel'
import { TransferPanel } from './sftp/TransferPanel'
import { Toolbar } from './sftp/Toolbar'
import { ConnectionPanel } from './sftp/ConnectionPanel'
import { ContextMenu, type MenuItem } from './sftp/ContextMenu'
import { ToastContainer } from './common/ToastContainer'
import EmptyState from './common/EmptyState'
import { sortFiles } from './sftp/utils'
import { usePromptDialog } from './common/PromptDialog'
import FilePropertiesDialog from './sftp/FilePropertiesDialog'
import ChmodDialog from './sftp/ChmodDialog'
import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.types'
interface SFTPViewProps {
@@ -51,9 +60,13 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
uploadFiles,
downloadFiles,
deleteRemote,
renameRemote,
getRemoteStat,
chmodRemote,
error,
} = useSFTP({ tabId })
const { toasts, toast, removeToast } = useToast()
const { prompt, PromptDialogComponent } = usePromptDialog()
// UI State
@@ -63,6 +76,13 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
const [showConnectionSelector, setShowConnectionSelector] = useState(false)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null)
// Properties dialog state
const [propertiesFile, setPropertiesFile] = useState<FileItem | null>(null)
// Chmod dialog state
const [chmodFile, setChmodFile] = useState<FileItem | null>(null)
const [chmodInitialMode, setChmodInitialMode] = useState(0o644)
// Sort handler
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
@@ -93,6 +113,61 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
})
}, [setRemoteSelected])
// ===== Context menu handlers =====
// Rename handler
const handleRename = useCallback(async (file: FileItem) => {
const newName = await prompt({
title: '重命名',
message: '输入新名称:',
placeholder: '新名称',
defaultValue: file.name,
})
if (newName && newName !== file.name) {
const ok = await renameRemote(file, newName)
if (ok) {
toast.success(`已重命名为 ${newName}`)
} else {
toast.error('重命名失败')
}
}
}, [prompt, renameRemote, toast])
// Copy path handler
const handleCopyPath = useCallback(async (filePath: string) => {
try {
await navigator.clipboard.writeText(filePath)
toast.success('路径已复制到剪贴板')
} catch {
toast.error('复制路径失败')
}
}, [toast])
// Properties handler
const handleShowProperties = useCallback((file: FileItem) => {
setPropertiesFile(file)
}, [])
// Permissions (chmod) handler — fetch stat first, then show dialog
const handleChmod = useCallback(async (file: FileItem) => {
const stats = await getRemoteStat(file.path)
const mode = stats?.mode ?? 0o644
setChmodFile(file)
setChmodInitialMode(mode & 0o7777)
}, [getRemoteStat])
// Chmod confirm callback
const handleChmodConfirm = useCallback(async (filePath: string, mode: number) => {
const ok = await chmodRemote(filePath, mode)
if (ok) {
toast.success(`权限已更新为 ${mode.toString(8)}`)
return true
} else {
toast.error('权限修改失败')
return false
}
}, [chmodRemote, toast])
// Local context menu
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
e.preventDefault()
@@ -107,9 +182,15 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
label: '上传',
action: () => {},
},
{ icon: <Copy className="w-4 h-4" />, label: '复制路径', separator: true, action: () => handleCopyPath(file.path) },
{
icon: <FileText className="w-4 h-4" />,
label: '属性',
action: () => handleShowProperties(file),
},
]
setContextMenu({ x: e.clientX, y: e.clientY, items })
}, [navigateLocal])
}, [navigateLocal, handleCopyPath, handleShowProperties])
// Remote context menu
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
@@ -128,17 +209,22 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
downloadFiles()
},
},
{ icon: <Edit3 className="w-4 h-4" />, label: '重命名', separator: true, action: () => handleRename(file) },
{ icon: <Copy className="w-4 h-4" />, label: '复制路径', action: () => handleCopyPath(file.path) },
{
icon: <Trash2 className="w-4 h-4" />,
label: '删除',
danger: true,
action: () => {
deleteRemote([file])
},
icon: <FileText className="w-4 h-4" />,
label: '属性',
action: () => handleShowProperties(file),
},
{
icon: <ShieldCheck className="w-4 h-4" />,
label: '权限',
action: () => handleChmod(file),
},
{ icon: <Trash2 className="w-4 h-4" />, label: '删除', separator: true, danger: true, action: () => deleteRemote([file]) },
]
setContextMenu({ x: e.clientX, y: e.clientY, items })
}, [navigateRemote, downloadFiles, deleteRemote, setRemoteSelected])
}, [navigateRemote, downloadFiles, deleteRemote, setRemoteSelected, handleRename, handleCopyPath, handleShowProperties, handleChmod])
// Navigate up
const navigateLocalUp = useCallback(() => {
@@ -164,7 +250,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
}
}, [navigateRemote])
// Handle upload
// Handle upload (via file picker)
const handleUpload = useCallback(() => {
const input = document.createElement('input')
input.type = 'file'
@@ -175,6 +261,20 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
input.click()
}, [uploadFiles])
// Handle drag-and-drop upload on remote panel
const handleDropFiles = useCallback((files: FileList) => {
if (!isConnected) return
const count = files.length
toast.info(`正在上传 ${count} 个文件...`)
uploadFiles(files)
.then(() => {
toast.success(`${count} 个文件上传完成`)
})
.catch((err) => {
toast.error(`上传失败: ${err?.message || '未知错误'}`)
})
}, [isConnected, uploadFiles, toast])
// Handle new folder
const handleNewFolder = useCallback(async () => {
const name = await prompt({
@@ -216,6 +316,15 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
/>
{/* File Panels */}
{!isConnected ? (
<div className="flex-1 flex items-center justify-center">
<EmptyState
icon={Unplug}
title="未连接到远程服务器"
description="请先连接 SSH 会话以使用 SFTP"
/>
</div>
) : (
<div className="flex-1 flex gap-4 p-4 min-h-0">
{/* Local Panel */}
<FileListPanel
@@ -261,8 +370,11 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
onSort={handleSort}
contextMenu={contextMenu}
onCloseContextMenu={() => setContextMenu(null)}
onDropFiles={isConnected ? handleDropFiles : undefined}
loading={isConnecting}
/>
</div>
)}
{/* Transfer Panel */}
<TransferPanel
@@ -283,6 +395,36 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
/>
)}
{PromptDialogComponent}
{/* File Properties Dialog */}
{propertiesFile && (
<FilePropertiesDialog
isOpen={!!propertiesFile}
onClose={() => setPropertiesFile(null)}
fileName={propertiesFile.name}
filePath={propertiesFile.path}
fileType={propertiesFile.type}
permissions={propertiesFile.permissions}
initialSize={propertiesFile.size}
initialModified={propertiesFile.modified}
onFetchStat={isConnected ? getRemoteStat : undefined}
/>
)}
{/* Chmod Dialog */}
{chmodFile && (
<ChmodDialog
isOpen={!!chmodFile}
onClose={() => setChmodFile(null)}
fileName={chmodFile.name}
filePath={chmodFile.path}
initialMode={chmodInitialMode}
onConfirm={handleChmodConfirm}
/>
)}
{/* Toast Notifications */}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
)
}
+161 -87
View File
@@ -1,15 +1,19 @@
/**
* Sidebar - 侧边栏组件 (简化版)
* Sidebar - 侧边栏组件
* 支持宽度拖拽调整
*/
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Plus, Zap, Server, Settings } from 'lucide-react'
import { useSessionStore, Session } from '../stores'
import { useSettingsStore } from '../stores/settingsStore'
import { useTabStore } from '../stores/tabStore'
import { SessionTree } from './sidebar/SessionTree'
import { SearchBox } from './sidebar/SearchBox'
import { useConfirmDialog } from './common/ConfirmDialog'
// Sidebar Component
const MIN_SIDEBAR_WIDTH = 180
const MAX_SIDEBAR_WIDTH = 400
interface SidebarProps {
onNewSession: () => void
@@ -19,6 +23,7 @@ interface SidebarProps {
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) {
const { sessions, setSessions, removeSession } = useSessionStore()
const { settings, setSetting } = useSettingsStore()
const { addTab } = useTabStore()
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
@@ -26,8 +31,19 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['default']))
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; session: Session } | null>(null)
const [isResizing, setIsResizing] = useState(false)
const [sidebarWidth, setSidebarWidth] = useState(settings.sidebarWidth || 256)
const [sessionsLoading, setSessionsLoading] = useState(true)
const contextMenuRef = useRef<HTMLDivElement>(null)
const sidebarRef = useRef<HTMLDivElement>(null)
// Sync width from settings on load
useEffect(() => {
if (settings.sidebarWidth && settings.sidebarWidth !== sidebarWidth) {
setSidebarWidth(settings.sidebarWidth)
}
}, [settings.sidebarWidth])
// Load sessions
useEffect(() => {
@@ -35,6 +51,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
if (result.success && result.sessions) {
setSessions(result.sessions)
}
setSessionsLoading(false)
})
}, [setSessions])
@@ -49,6 +66,44 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Resize handler
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
const startX = e.clientX
const startWidth = sidebarWidth
const handleMouseMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX
const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta))
setSidebarWidth(newWidth)
}
const handleMouseUp = () => {
setIsResizing(false)
document.body.style.userSelect = ''
document.body.style.cursor = ''
// Save to settings on drag end
setSetting('sidebarWidth', sidebarWidth)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
// Prevent text selection during drag
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [sidebarWidth, setSetting])
// Save final width on unmount or when resizing ends (we save in handleMouseUp)
useEffect(() => {
if (!isResizing && sidebarWidth !== settings.sidebarWidth) {
setSetting('sidebarWidth', sidebarWidth)
}
}, [isResizing, sidebarWidth, settings.sidebarWidth, setSetting])
const toggleGroup = (group: string) => {
const newExpanded = new Set(expandedGroups)
if (newExpanded.has(group)) newExpanded.delete(group)
@@ -120,95 +175,114 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
return (
<>
<div className="w-64 bg-dark-800 border-r border-dark-700 flex flex-col">
{/* Header */}
<div className="p-4 border-b border-dark-700">
<div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center">
<Server className="w-5 h-5 text-white" />
<div
ref={sidebarRef}
className="relative flex flex-shrink-0 bg-dark-800 border-r border-dark-700"
style={{ width: sidebarWidth }}
>
<div className="flex flex-col flex-1 min-w-0">
{/* Header */}
<div className="p-4 border-b border-dark-700">
<div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center">
<Server className="w-5 h-5 text-white" />
</div>
<span className="font-semibold text-lg">OpsXShell</span>
</div>
<span className="font-semibold text-lg">OpsXShell</span>
</div>
{/* Quick Actions */}
<div className="flex gap-2">
<button
onClick={onQuickConnect}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-accent-blue hover:bg-blue-600 rounded text-sm font-medium transition-colors"
>
<Zap className="w-4 h-4" />
快速连接
</button>
<button
onClick={onNewSession}
className="flex items-center justify-center px-3 py-2 bg-dark-700 hover:bg-dark-600 rounded text-sm font-medium transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
{/* Search */}
<div className="p-3 border-b border-dark-700">
<SearchBox
value={searchTerm}
onChange={setSearchTerm}
placeholder="搜索会话..."
/>
</div>
{/* Session Tree */}
<SessionTree
sessions={sessions}
expandedGroups={expandedGroups}
selectedSessionId={selectedSessionId}
onToggleGroup={toggleGroup}
onSelectSession={handleConnect}
onContextMenu={handleContextMenu}
searchTerm={searchTerm}
/>
{/* Footer */}
<div className="p-3 border-t border-dark-700">
<button
onClick={() => addTab({ title: '设置', type: 'settings' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:bg-dark-700 rounded transition-colors"
>
<Settings className="w-4 h-4" />
<span>设置</span>
</button>
</div>
{/* Context Menu */}
{contextMenu && (
<div
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[140px]"
>
{[
{ label: '连接', action: 'connect' },
{ label: '编辑', action: 'edit' },
{ label: '克隆', action: 'duplicate' },
{ label: 'SFTP 传输', action: 'sftp' },
{ label: '删除', action: 'delete', danger: true },
].map((item) => (
{/* Quick Actions */}
<div className="flex gap-2">
<button
key={item.action}
onClick={() => handleContextMenuAction(item.action)}
className={`
w-full px-4 py-2 text-left text-sm transition-colors
${item.danger
? 'text-red-400 hover:bg-red-500/20'
: 'text-gray-300 hover:bg-dark-700'
}
`}
onClick={onQuickConnect}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-accent-blue hover:bg-blue-600 rounded text-sm font-medium transition-colors"
>
{item.label}
<Zap className="w-4 h-4" />
快速连接
</button>
))}
<button
onClick={onNewSession}
className="flex items-center justify-center px-3 py-2 bg-dark-700 hover:bg-dark-600 rounded text-sm font-medium transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Search */}
<div className="p-3 border-b border-dark-700">
<SearchBox
value={searchTerm}
onChange={setSearchTerm}
placeholder="搜索会话..."
/>
</div>
{/* Session Tree */}
<SessionTree
sessions={sessions}
expandedGroups={expandedGroups}
selectedSessionId={selectedSessionId}
onToggleGroup={toggleGroup}
onSelectSession={handleConnect}
onContextMenu={handleContextMenu}
searchTerm={searchTerm}
loading={sessionsLoading}
onNewSession={onNewSession}
/>
{/* Footer */}
<div className="p-3 border-t border-dark-700">
<button
onClick={() => addTab({ title: '设置', type: 'settings' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:bg-dark-700 rounded transition-colors"
>
<Settings className="w-4 h-4" />
<span>设置</span>
</button>
</div>
{/* Context Menu */}
{contextMenu && (
<div
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[140px]"
>
{[
{ label: '连接', action: 'connect' },
{ label: '编辑', action: 'edit' },
{ label: '克隆', action: 'duplicate' },
{ label: 'SFTP 传输', action: 'sftp' },
{ label: '删除', action: 'delete', danger: true },
].map((item) => (
<button
key={item.action}
onClick={() => handleContextMenuAction(item.action)}
className={`
w-full px-4 py-2 text-left text-sm transition-colors
${item.danger
? 'text-red-400 hover:bg-red-500/20'
: 'text-gray-300 hover:bg-dark-700'
}
`}
>
{item.label}
</button>
))}
</div>
)}
</div>
{/* Resize Handle */}
<div
onMouseDown={handleResizeStart}
className={`
absolute top-0 right-0 bottom-0 w-1 cursor-col-resize
hover:bg-accent-blue/50 active:bg-accent-blue/70
transition-colors duration-150
${isResizing ? 'bg-accent-blue/70' : ''}
`}
/>
</div>
{ConfirmDialogComponent}
</>
+72 -17
View File
@@ -1,9 +1,9 @@
/**
* TabBar - 标签栏组件 (简化版)
* TabBar - 标签栏组件(支持拖拽排序)
*/
import { Plus } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useState, useCallback, useRef } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useConnectionPool } from '../stores/connection'
import { TabItem } from './tabbar/TabItem'
@@ -20,9 +20,15 @@ export default function TabBar() {
closeRightTabs,
closeLeftTabs,
closeAllTabs,
moveTab,
} = useTabStore()
const { disconnectConnection } = useConnectionPool()
// Drag state
const [dragFromIndex, setDragFromIndex] = useState<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const dragNodeRef = useRef<HTMLDivElement | null>(null)
const [menuState, setMenuState] = useState({
isOpen: false,
@@ -30,20 +36,6 @@ export default function TabBar() {
position: { x: 0, y: 0 }
})
// 快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'w' && activeTabId) {
e.preventDefault()
if (tabs.length > 1) {
handleCloseTab(activeTabId)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [activeTabId, tabs.length])
const handleCloseTab = (tabId: string) => {
const tab = tabs.find(t => t.id === tabId)
if (tab?.connectionInstance?.connectionId) {
@@ -61,22 +53,85 @@ export default function TabBar() {
})
}
// --- Drag handlers ---
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
setDragFromIndex(index)
e.dataTransfer.effectAllowed = 'move'
// Set a transparent drag image so we rely on the ghost
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (e.currentTarget instanceof HTMLDivElement) {
dragNodeRef.current = e.currentTarget
}
}, [])
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (dragFromIndex === null || dragFromIndex === index) {
setDragOverIndex(null)
return
}
// Determine insert position: if dragging from lower index, place after; otherwise before
const rect = e.currentTarget.getBoundingClientRect()
const midX = rect.left + rect.width / 2
const insertAt = e.clientX < midX ? index : index + 1
// Don't show indicator if it would result in no-op
if (
insertAt === dragFromIndex ||
insertAt === dragFromIndex + 1
) {
setDragOverIndex(null)
} else {
setDragOverIndex(insertAt)
}
}, [dragFromIndex])
const handleDrop = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault()
if (dragFromIndex === null) return
// Calculate actual toIndex based on mouse position
const rect = e.currentTarget.getBoundingClientRect()
const midX = rect.left + rect.width / 2
let toIndex = e.clientX < midX ? index : index + 1
// Adjust for the removal of the dragged item shifting indices
if (toIndex > dragFromIndex) {
toIndex -= 1
}
if (dragFromIndex !== toIndex) {
moveTab(dragFromIndex, toIndex)
}
setDragFromIndex(null)
setDragOverIndex(null)
}, [dragFromIndex, moveTab])
const handleDragEnd = useCallback(() => {
setDragFromIndex(null)
setDragOverIndex(null)
}, [])
const currentTab = menuState.tabId ? tabs.find(t => t.id === menuState.tabId) : null
return (
<div className="flex items-center bg-dark-900 border-b border-dark-700 overflow-x-auto">
{/* Tab List */}
{tabs.map((tab) => (
{tabs.map((tab, index) => (
<TabItem
key={tab.id}
tab={tab}
index={index}
isActive={tab.id === activeTabId}
isDragging={dragFromIndex === index}
dragOverIndex={dragOverIndex}
onActivate={() => activateTab(tab.id)}
onClose={(e) => {
e.stopPropagation()
handleCloseTab(tab.id)
}}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))}
+80 -506
View File
@@ -1,15 +1,18 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { useEffect, useRef } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css'
import { Search, ChevronUp, ChevronDown, X, Terminal as TerminalIcon } from 'lucide-react'
import { themes } from '../themes/terminal'
import { useSettingsStore } from '../stores/settingsStore'
import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
import { getSessionConfig, getConnectionStore, type ConnectionState } from '../stores/connection'
import { useTabStore } from '../stores/tabStore'
import { getConnectionStore } from '../stores/connection'
import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore'
import { createLogger } from '../utils/logger'
import { useTerminalConnection } from '../hooks/useTerminalConnection'
import { useTerminalSearch } from '../hooks/useTerminalSearch'
import TerminalOverlay from './terminal/TerminalOverlay'
import TerminalSearchBar from './terminal/TerminalSearchBar'
import type { TerminalViewProps } from './terminal/types'
const logger = createLogger('TerminalView')
@@ -19,125 +22,63 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
const xtermRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const searchAddonRef = useRef<SearchAddon | null>(null)
const shellIdRef = useRef<string | null>(null)
const connectionIdRef = useRef<string | null>(null)
const hasConnectedRef = useRef(false)
const isConnectingRef = useRef(false)
const handleShellDataRef = useRef<((_event: unknown, _connId: string, shellId: string, data: Buffer) => void) | null>(null)
const listenerRegisteredRef = useRef(false)
// 本地状态
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
const [statusMessage, setStatusMessage] = useState<string>('')
const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false)
// 搜索状态
const [showSearch, setShowSearch] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchResultCount, setSearchResultCount] = useState<number | undefined>(undefined)
const [searchResultIndex, setSearchResultIndex] = useState<number | undefined>(undefined)
const searchInputRef = useRef<HTMLInputElement>(null)
// 全局状态
const { settings, loadSettings } = useSettingsStore()
const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore()
const { getTabById } = useTabStore()
const snapshotStore = useTerminalSnapshotStore()
const currentTheme = themes[settings.terminalTheme] || themes['dracula']
// 连接管理 hook
const {
connectionState,
statusMessage,
showSuspendedOverlay,
connectionIdRef,
shellIdRef,
hasConnectedRef,
isConnectingRef,
connect,
resumeConnection,
handleReconnect,
saveSnapshot,
restoreSnapshot,
setShowSuspendedOverlay,
} = useTerminalConnection({
tabId,
xtermRef,
})
// 搜索功能 hook
const {
showSearch,
searchQuery,
searchResultCount,
searchResultIndex,
searchInputRef,
handleSearchChange,
handleSearchNext,
handleSearchPrevious,
handleCloseSearch,
} = useTerminalSearch({
xtermRef,
searchAddonRef,
isActive,
})
// 加载设置
useEffect(() => {
loadSettings()
}, [loadSettings])
// 搜索相关回调
const searchDecorations = {
matchBackground: '#3b82f633',
matchBorder: '#3b82f666',
matchOverviewRuler: '#3b82f6',
activeMatchBackground: '#3b82f6',
activeMatchBorder: '#60a5fa',
activeMatchColorOverviewRuler: '#60a5fa',
}
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value
setSearchQuery(query)
if (query) {
searchAddonRef.current?.findNext(query, { decorations: searchDecorations, incremental: true })
} else {
searchAddonRef.current?.clearDecorations()
setSearchResultCount(undefined)
setSearchResultIndex(undefined)
}
}, [])
const handleSearchNext = useCallback(() => {
if (searchAddonRef.current && searchQuery) {
searchAddonRef.current.findNext(searchQuery, { decorations: searchDecorations })
}
}, [searchQuery])
const handleSearchPrevious = useCallback(() => {
if (searchAddonRef.current && searchQuery) {
searchAddonRef.current.findPrevious(searchQuery, { decorations: searchDecorations })
}
}, [searchQuery])
const handleCloseSearch = useCallback(() => {
setShowSearch(false)
searchAddonRef.current?.clearDecorations()
setSearchQuery('')
setSearchResultCount(undefined)
setSearchResultIndex(undefined)
xtermRef.current?.focus()
}, [])
// Ctrl+F / Cmd+F 打开搜索栏,Escape 关闭
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
// 只在当前标签激活时拦截
if (!isActive) return
e.preventDefault()
e.stopPropagation()
if (!showSearch) {
setShowSearch(true)
// 延迟聚焦输入框,等 DOM 更新
setTimeout(() => searchInputRef.current?.focus(), 50)
} else {
searchInputRef.current?.focus()
searchInputRef.current?.select()
}
}
if (e.key === 'Escape' && showSearch) {
handleCloseSearch()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isActive, showSearch, handleCloseSearch])
// 监听 SearchAddon 的 onDidChangeResults 事件
useEffect(() => {
const addon = searchAddonRef.current
if (!addon) return
const disposable = addon.onDidChangeResults((e) => {
setSearchResultCount(e.resultCount)
setSearchResultIndex(e.resultIndex !== undefined ? e.resultIndex + 1 : undefined)
})
return () => disposable.dispose()
}, [])
// 初始化 xterm
useEffect(() => {
// 修复:只检查 terminalRef,不检查 xtermRef
// 因为在严格模式下,组件会 unmount/remount,xtermRef 可能还存在
// 但监听器需要重新注册
if (!terminalRef.current) {
return
}
// 处理窗口大小调整
const handleResize = () => {
fitAddonRef.current?.fit()
@@ -207,36 +148,28 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
// IP 地址高亮处理 - 将包含 "from" 或 "Last login" 行中的 IP 地址标记为绿色
const highlightIPAddresses = (text: string): string => {
// 匹配 IPv4 地址格式 (xxx.xxx.xxx.xxx)
const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g
// 如果包含 Last login 或 from IP,将 IP 地址替换为绿色
if (text.includes('Last login') || text.includes(' from ')) {
return text.replace(ipRegex, '\x1b[32m$1\x1b[0m')
}
return text
}
// Shell 数据处理器 - 直接处理,不依赖外部 ref
// 使用 ref 存储 handler,避免闭包问题
// Shell 数据处理器
handleShellDataRef.current = (_event: unknown, _connId: string, shellId: string, data: Buffer) => {
const text = data.toString('utf-8')
// 使用 ref 获取最新的 shellId,避免闭包问题
if (shellId === shellIdRef.current && xtermRef.current) {
// 处理数据,高亮 IP 地址(Last login 或 from IP 行)
let processedData = data
// 如果包含 Last login 或 from IP,处理 IP 地址高亮
if (text.includes('Last login') || text.includes(' from ')) {
logger.debug('[Terminal] Found IP address line, highlighting IP')
const highlighted = highlightIPAddresses(text)
processedData = Buffer.from(highlighted, 'utf-8')
}
// 写入 xterm
xtermRef.current.write(processedData)
// 更新传输统计
const connId = connectionIdRef.current
if (connId) {
const conn = getConnectionStore().getConnection(connId)
@@ -258,257 +191,23 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
return () => {
window.removeEventListener('resize', handleResize)
// 不在这里移除监听器,避免严格模式下的问题
// 监听器会在组件真正卸载时通过 removeAllListeners 清理
// 注意:不要在严格模式下 dispose terminal
}
}, []) // 只在挂载时运行
// 保存快照
const saveSnapshot = useCallback(() => {
if (!xtermRef.current || !connectionIdRef.current) return
useTerminalSnapshotStore.getState().createSnapshot(tabId, xtermRef.current, {
connectionId: connectionIdRef.current,
shellId: shellIdRef.current,
})
}, [tabId])
// 恢复快照
const restoreSnapshot = useCallback(() => {
if (!xtermRef.current) return false
const snapshot = useTerminalSnapshotStore.getState().getSnapshot(tabId)
if (!snapshot) return false
// 清屏
xtermRef.current.clear()
// 恢复内容
const content = snapshot.buffer.join('\r\n')
if (content) {
xtermRef.current.write(content)
}
// 恢复光标
xtermRef.current.write(`\x1b[${snapshot.cursorY + 1};${snapshot.cursorX + 1}H`)
return true
}, [tabId])
// 连接管理
const connect = useCallback(async () => {
if (isConnectingRef.current) {
logger.debug('[Terminal] Connection already in progress, skipping')
return
}
isConnectingRef.current = true
// 直接使用 getTabById,避免 useCallback 循环问题
const currentTab = getTabById(tabId)
if (!currentTab) {
logger.debug('[Terminal] Tab not found:', tabId)
isConnectingRef.current = false
return
}
logger.debug('[Terminal] Starting connection for tab:', tabId, 'sessionId:', currentTab.sessionId)
// 检查是否已有连接
if (connectionIdRef.current) {
const conn = getConnectionStore().getConnection(connectionIdRef.current)
if (conn) {
if (conn.state === 'connected' || conn.state === 'background') {
// 已有活跃连接,恢复显示
shellIdRef.current = currentTab.connectionInstance?.shellId || null
setConnectionState(conn.state)
updateTabStatus(tabId, conn.state === 'connected' ? 'connected' : 'disconnected')
isConnectingRef.current = false
return
} else if (conn.state === 'suspended') {
// 连接被挂起,需要恢复
setShowSuspendedOverlay(true)
setConnectionState('suspended')
updateTabStatus(tabId, 'disconnected')
isConnectingRef.current = false
return
}
}
}
// 开始连接
setConnectionState('connecting')
updateTabStatus(tabId, 'connecting')
try {
// 获取配置
let config: {
host: string
port: number
username: string
password?: string
privateKey?: string
passphrase?: string
} | null = null
if (currentTab.connectionConfig) {
config = {
host: currentTab.connectionConfig.host,
port: currentTab.connectionConfig.port || 22,
username: currentTab.connectionConfig.username,
password: currentTab.connectionConfig.password,
privateKey: currentTab.connectionConfig.privateKey,
passphrase: currentTab.connectionConfig.passphrase,
}
} else if (currentTab.sessionId) {
config = await getSessionConfig(currentTab.sessionId)
}
if (!config) {
throw new Error('无可用连接信息')
}
// 创建连接
const result = await getConnectionStore().createConnection(tabId, config)
if (!result.success || !result.connectionId) {
throw new Error(result.error || '连接失败')
}
const connectionId = result.connectionId
connectionIdRef.current = connectionId
// 创建 Shell - 先生成前端 shellId,确保数据不会丢失
const pendingShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
logger.debug('[Terminal] Pre-assigning shellId:', pendingShellId)
shellIdRef.current = pendingShellId
logger.debug('[Terminal] Creating shell for connection:', connectionId, 'with shellId:', pendingShellId)
const shellResult = await window.electronAPI?.sshCreateShell(connectionId, {
cols: xtermRef.current?.cols || 80,
rows: xtermRef.current?.rows || 24,
}, pendingShellId)
if (!shellResult?.success || !shellResult.shellId) {
logger.error('[Terminal] Shell creation failed:', shellResult?.error)
shellIdRef.current = null
await getConnectionStore().disconnectConnection(connectionId, 'shell creation failed')
throw new Error(shellResult?.error || '创建终端失败')
}
// shellId 应该和 pendingShellId 相同(由后端确认)
const confirmedShellId = shellResult.shellId
logger.debug('[Terminal] Shell confirmed with ID:', confirmedShellId)
// shellIdRef 已经是 confirmedShellId(等于 pendingShellId)
getConnectionStore().addShell(connectionId, confirmedShellId)
// 更新标签
const connectionInstance: ConnectionInstance = {
connectionId,
shellId: confirmedShellId,
}
updateTabConnectionInstance(tabId, connectionInstance)
// 更新状态
setConnectionState('connected')
updateTabStatus(tabId, 'connected')
getConnectionStore().setActiveTab(connectionId, tabId)
// 更新标题
if (currentTab.title === '新终端' || currentTab.title === 'Terminal') {
updateTab(tabId, {
title: `${config.username}@${config.host}`,
})
}
// 等待服务器返回真实的 Last login 信息和提示符
// 不手动写入任何内容,让 SSH 服务器的自然输出显示
xtermRef.current?.focus()
} catch (error: any) {
setConnectionState('error')
updateTabStatus(tabId, 'error')
setStatusMessage(`错误: ${error.message}`)
xtermRef.current?.writeln(`\r\n\x1b[31m错误: ${error.message}\x1b[0m`)
} finally {
isConnectingRef.current = false
hasConnectedRef.current = true
}
}, [tabId, updateTabConnectionInstance, updateTabStatus, getTabById])
// 注意:不再使用模拟的 Last login 信息
// 真实的 SSH 服务器会自动发送欢迎信息和提示符
// 恢复挂起的连接
const resumeConnection = useCallback(async () => {
if (!connectionIdRef.current) return
setConnectionState('reconnecting')
const resumed = await getConnectionStore().resumeConnection(connectionIdRef.current)
if (resumed) {
// 获取新的连接 ID
const currentTab = getTabById(tabId)
if (currentTab?.connectionInstance?.connectionId) {
connectionIdRef.current = currentTab.connectionInstance.connectionId
// 创建新的 shell - 预分配 shellId 防止数据丢失
const newShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
shellIdRef.current = newShellId
const shellResult = await window.electronAPI?.sshCreateShell(connectionIdRef.current, {
cols: xtermRef.current?.cols || 80,
rows: xtermRef.current?.rows || 24,
}, newShellId)
if (shellResult?.success && shellResult.shellId) {
// shellId 应该和 newShellId 相同
getConnectionStore().addShell(connectionIdRef.current, shellResult.shellId)
updateTabConnectionInstance(tabId, {
connectionId: connectionIdRef.current,
shellId: shellResult.shellId,
})
setConnectionState('connected')
updateTabStatus(tabId, 'connected')
setShowSuspendedOverlay(false)
// 恢复连接成功,等待服务器发送提示符
return
}
}
}
// 恢复失败,重新连接
setConnectionState('disconnected')
hasConnectedRef.current = false
setShowSuspendedOverlay(false)
connect()
}, [connect, updateTabConnectionInstance, updateTabStatus, getTabById, tabId])
// 初始连接
useEffect(() => {
if (!hasConnectedRef.current && isActive) {
// 检查标签是否从持久化恢复(需要手动重新连接)
const currentTab = getTabById(tabId)
if (currentTab?.needsReconnect) {
// 从持久化恢复的标签,不自动连接,显示「重新连接」界面
logger.info('[Terminal] Tab restored from session, waiting for manual reconnect')
setConnectionState('disconnected')
setStatusMessage('上次会话已恢复,点击重新连接')
return
}
// 检查是否有快照
const hasSnapshot = snapshotStore.hasSnapshot(tabId)
if (hasSnapshot && isActive) {
// 先恢复快照
restoreSnapshot()
}
connect()
}
}, []) // 只在挂载时执行
@@ -520,58 +219,34 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
const pool = getConnectionStore()
if (isActive) {
// 标签变为活跃
if (connectionIdRef.current) {
const conn = pool.getConnection(connectionIdRef.current)
if (conn?.state === 'suspended') {
setShowSuspendedOverlay(true)
setConnectionState('suspended')
} else {
pool.setActiveTab(connectionIdRef.current, tabId)
// 同步本地状态
setConnectionState(conn?.state === 'connected' || conn?.state === 'background' ? conn.state : 'connected')
// 不再恢复快照 - shell 是实时输出的,重复写入会导致内容重复
}
}
xtermRef.current?.focus()
} else {
// 标签进入后台 - 保存快照用于恢复
if (connectionIdRef.current) {
pool.setBackgroundTab(connectionIdRef.current, tabId)
saveSnapshot()
}
}
}, [isActive, tabId, saveSnapshot])
// 重连功能
const handleReconnect = useCallback(() => {
if (connectionState === 'error' || connectionState === 'disconnected' || connectionState === 'suspended') {
if (connectionIdRef.current) {
getConnectionStore().disconnectConnection(connectionIdRef.current, 'manual reconnect')
}
connectionIdRef.current = null
shellIdRef.current = null
hasConnectedRef.current = false
setConnectionState('disconnected')
setShowSuspendedOverlay(false)
// 清除 needsReconnect 标志
updateTab(tabId, { needsReconnect: false })
xtermRef.current?.clear()
connect()
}
}, [connectionState, connect, updateTab, tabId])
}, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay])
// 组件卸载时清理监听器
useEffect(() => {
return () => {
// 重置连接状态,防止严格模式下 remount 时状态混乱
isConnectingRef.current = false
listenerRegisteredRef.current = false // 重置监听器注册标记
// 组件真正卸载时移除所有监听器
listenerRegisteredRef.current = false
window.electronAPI?.removeAllListeners('ssh:shellData')
}
}, [])
const currentTab = getTabById(tabId)
return (
<div className="h-full flex flex-col bg-dark-900">
{/* 终端容器 */}
@@ -580,131 +255,30 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
ref={terminalRef}
className="w-full h-full bg-dark-900 rounded"
/>
{/* 搜索栏 - 终端顶部覆盖 */}
{/* 搜索栏 */}
{showSearch && (
<div className="absolute top-0 left-0 right-0 z-30 flex items-center gap-2 px-3 py-2 bg-dark-800/95 border-b border-dark-600 backdrop-blur-sm">
<Search size={14} className="text-gray-400 shrink-0" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={handleSearchChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.shiftKey) {
handleSearchPrevious()
} else {
handleSearchNext()
}
}
e.stopPropagation()
}}
placeholder="搜索..."
className="flex-1 bg-dark-900 text-gray-200 text-sm px-2 py-1 rounded border border-dark-600 focus:border-accent-blue focus:outline-none placeholder-gray-500"
/>
{searchResultCount !== undefined && (
<span className="text-xs text-gray-400 whitespace-nowrap min-w-[60px] text-center">
{searchResultIndex ?? 0}/{searchResultCount}
</span>
)}
<button
onClick={handleSearchPrevious}
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
title="上一个 (Shift+Enter)"
>
<ChevronUp size={16} />
</button>
<button
onClick={handleSearchNext}
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
title="下一个 (Enter)"
>
<ChevronDown size={16} />
</button>
<button
onClick={handleCloseSearch}
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
title="关闭 (Escape)"
>
<X size={16} />
</button>
</div>
<TerminalSearchBar
searchQuery={searchQuery}
searchResultCount={searchResultCount}
searchResultIndex={searchResultIndex}
searchInputRef={searchInputRef}
onSearchChange={handleSearchChange}
onSearchNext={handleSearchNext}
onSearchPrevious={handleSearchPrevious}
onClose={handleCloseSearch}
/>
)}
{/* 会话恢复 - 需要重新连接覆盖层 */}
{connectionState === 'disconnected' && getTabById(tabId)?.needsReconnect && (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
<div className="text-center">
<div className="w-16 h-16 bg-accent-blue/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
<TerminalIcon className="w-8 h-8 text-accent-blue" />
</div>
<div className="text-gray-200 mb-2 text-lg">会话已恢复</div>
<div className="text-gray-400 text-sm mb-6">
上次关闭时的终端标签已恢复<br />
点击下方按钮重新建立 SSH 连接
</div>
<button
onClick={handleReconnect}
className="px-6 py-2.5 bg-accent-blue hover:bg-blue-500 text-white rounded-lg transition-colors"
>
重新连接
</button>
</div>
</div>
)}
{/* 挂起状态覆盖层 */}
{showSuspendedOverlay && (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
<div className="text-center">
<div className="text-yellow-400 mb-2 text-lg">连接已挂起</div>
<div className="text-gray-400 text-sm mb-4">
长时间未操作,连接已进入挂起状态
</div>
<button
onClick={resumeConnection}
className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors mr-2"
>
恢复连接
</button>
<button
onClick={handleReconnect}
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-gray-200 rounded transition-colors"
>
重新连接
</button>
</div>
</div>
)}
{/* 错误状态覆盖层 */}
{connectionState === 'error' && (
<div className="absolute inset-0 bg-dark-900/80 flex items-center justify-center z-10">
<div className="text-center">
<div className="text-red-400 mb-2">连接失败</div>
<div className="text-gray-400 text-sm mb-4">{statusMessage}</div>
<button
onClick={handleReconnect}
className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors"
>
重新连接
</button>
</div>
</div>
)}
{/* 连接中状态 */}
{connectionState === 'connecting' && (
<div className="absolute inset-0 bg-dark-900/50 flex items-center justify-center pointer-events-none z-10">
<div className="text-accent-blue animate-pulse">连接中...</div>
</div>
)}
{/* 连接状态指示器 - 简洁版 */}
<div className="absolute top-3 right-3 flex items-center gap-2 z-20">
{/* 状态指示已移至 TabBar */}
</div>
{/* 连接状态覆盖层 */}
<TerminalOverlay
connectionState={connectionState}
statusMessage={statusMessage}
showSuspendedOverlay={showSuspendedOverlay}
needsReconnect={!!currentTab?.needsReconnect}
onReconnect={handleReconnect}
onResume={resumeConnection}
/>
</div>
</div>
)
+8 -7
View File
@@ -1,6 +1,7 @@
import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap, Inbox } from 'lucide-react'
import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap, Clock } from 'lucide-react'
import { useSessionStore } from '../stores'
import { useTabStore } from '../stores/tabStore'
import EmptyState from './common/EmptyState'
interface WelcomeViewProps {
onNewSession?: () => void
@@ -30,7 +31,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
</div>
</div>
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl mb-12">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 w-full max-w-2xl mb-12">
<button
onClick={onQuickConnect}
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-blue transition-all group"
@@ -101,11 +102,11 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<div className="w-full max-w-2xl">
<h3 className="text-sm font-medium text-gray-400 mb-4">最近连接</h3>
{recentSessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-gray-500">
<Inbox className="w-10 h-10 mb-3 text-gray-600" />
<p className="text-sm">暂无会话记录</p>
<p className="text-xs mt-1">点击上方「新建会话」创建第一个连接</p>
</div>
<EmptyState
icon={Clock}
title="暂无最近连接"
description="开始你的第一次连接吧"
/>
) : (
<div className="space-y-2">
{recentSessions.map((session) => (
+54
View File
@@ -0,0 +1,54 @@
/**
* EmptyState - 通用空状态组件
*
* 居中布局,大图标 + 标题 + 描述 + 可选操作按钮
* 用于各组件的空状态引导
*/
import { type LucideIcon } from 'lucide-react'
import Button from './Button'
interface EmptyStateAction {
label: string
onClick: () => void
}
interface EmptyStateProps {
/** Lucide 图标组件 */
icon: LucideIcon
/** 标题文字 */
title: string
/** 描述文字 */
description: string
/** 可选操作按钮 */
action?: EmptyStateAction
}
export default function EmptyState({
icon: Icon,
title,
description,
action,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12 px-4">
{/* 图标容器 */}
<div className="w-16 h-16 rounded-full bg-dark-700 flex items-center justify-center mb-4">
<Icon className="w-8 h-8 text-accent-blue" />
</div>
{/* 标题 */}
<h3 className="text-dark-300 font-medium mb-1">{title}</h3>
{/* 描述 */}
<p className="text-dark-500 text-sm mb-4 text-center max-w-xs">{description}</p>
{/* 操作按钮 */}
{action && (
<Button variant="primary" size="sm" onClick={action.onClick}>
{action.label}
</Button>
)}
</div>
)
}
+14 -2
View File
@@ -84,14 +84,26 @@ export function ButtonLoading({ text = '处理中...' }: { text?: string }) {
}
/**
* 骨架屏组件
* 骨架屏组件 (向后兼容)
*
* 新代码请使用 @/components/common/Skeleton 中的增强版本
*
* @example
* <Skeleton className="h-4 w-32" />
* <Skeleton className="h-20 w-full" />
*/
export function Skeleton({ className = '' }: { className?: string }) {
return (
<div className={`bg-dark-700 animate-pulse rounded ${className}`} />
)
}
// 重新导出增强版骨架屏组件,方便统一引用
export {
Skeleton as SkeletonBase,
SkeletonLine,
SkeletonCircle,
SkeletonBlock,
FileListSkeleton,
SessionTreeSkeleton,
TabContentSkeleton,
} from './Skeleton'
+299
View File
@@ -0,0 +1,299 @@
/**
* Skeleton - 骨架屏加载态组件
*
* 提供通用骨架屏基础组件和专用骨架屏
* - 基础变体: SkeletonLine, SkeletonCircle, SkeletonBlock
* - 专用组件: FileListSkeleton, SessionTreeSkeleton, TabContentSkeleton
*/
// ─── 工具函数 ────────────────────────────────────────────────
/**
* 合并类名
*/
function mergeClass(...classes: (string | undefined | false)[]): string {
return classes.filter(Boolean).join(' ')
}
// ─── 基础 Skeleton Props ─────────────────────────────────────
export interface SkeletonBaseProps {
/** 自定义宽度 */
width?: string | number
/** 自定义高度 */
height?: string | number
/** 自定义类名 */
className?: string
}
// ─── Skeleton (基础骨架块) ─────────────────────────────────────
/**
* 基础骨架屏组件 — 圆角矩形 + 脉冲动画
*
* @example
* <Skeleton width="100%" height={20} />
* <Skeleton className="h-4 w-32" />
*/
export function Skeleton({ width, height, className }: SkeletonBaseProps) {
return (
<div
className={mergeClass('bg-dark-600/60 animate-pulse rounded', className)}
style={{
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
}}
/>
)
}
// ─── SkeletonLine (行骨架) ─────────────────────────────────────
export interface SkeletonLineProps extends SkeletonBaseProps {
/** 行数,默认 1 */
lines?: number
/** 行间距 */
gap?: number
}
/**
* 行骨架 — 模拟文本行
*
* @example
* <SkeletonLine lines={3} width="80%" height={14} />
*/
export function SkeletonLine({ lines = 1, gap = 8, width, height = 14, className }: SkeletonLineProps) {
if (lines <= 1) {
return <Skeleton width={width} height={height} className={className} />
}
return (
<div className="flex flex-col" style={{ gap }}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
width={i === lines - 1 ? '60%' : width || '100%'}
height={height}
className={className}
/>
))}
</div>
)
}
// ─── SkeletonCircle (圆形骨架) ─────────────────────────────────
export interface SkeletonCircleProps extends SkeletonBaseProps {
/** 圆形尺寸(宽高相同),默认 40 */
size?: number
}
/**
* 圆形骨架 — 模拟头像/图标
*
* @example
* <SkeletonCircle size={40} />
*/
export function SkeletonCircle({ size = 40, className }: SkeletonCircleProps) {
return (
<Skeleton
width={size}
height={size}
className={mergeClass('rounded-full', className)}
/>
)
}
// ─── SkeletonBlock (卡片/区块骨架) ─────────────────────────────
export interface SkeletonBlockProps extends SkeletonBaseProps {
/** 圆角大小 */
rounded?: 'sm' | 'md' | 'lg'
}
/**
* 区块骨架 — 模拟卡片/面板
*
* @example
* <SkeletonBlock width="100%" height={120} />
*/
export function SkeletonBlock({ rounded = 'lg', className, ...rest }: SkeletonBlockProps) {
const roundedMap = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg' }
return (
<Skeleton
className={mergeClass(roundedMap[rounded], className)}
{...rest}
/>
)
}
// ─── FileListSkeleton (文件列表骨架屏) ─────────────────────────
export interface FileListSkeletonProps {
/** 模拟行数,默认 6 */
rows?: number
/** 自定义类名 */
className?: string
}
/**
* 文件列表骨架屏
* 模拟文件列表 (图标 + 文件名 + 大小 + 日期)
*
* @example
* <FileListSkeleton rows={6} />
*/
export function FileListSkeleton({ rows = 6, className }: FileListSkeletonProps) {
return (
<div className={mergeClass('flex flex-col', className)}>
{Array.from({ length: rows }).map((_, i) => (
<div
key={i}
className="flex items-center gap-3 px-4 py-2 border-b border-dark-700/50"
>
{/* 图标占位 */}
<Skeleton width={16} height={16} className="rounded-sm flex-shrink-0" />
{/* 文件名 — 宽度随机模拟不同文件名长度 */}
<Skeleton
height={13}
width={`${40 + ((i * 17) % 30)}%`}
/>
{/* 弹性间隔 */}
<div className="flex-1" />
{/* 大小占位 */}
<Skeleton width={48} height={12} className="flex-shrink-0" />
{/* 日期占位 */}
<Skeleton width={80} height={12} className="flex-shrink-0" />
</div>
))}
</div>
)
}
// ─── SessionTreeSkeleton (会话树骨架屏) ────────────────────────
export interface SessionTreeSkeletonProps {
/** 分组数量,默认 2 */
groups?: number
/** 每组会话数量(范围 [min, max]),默认 [3, 4] */
sessionsPerGroup?: [number, number]
/** 自定义类名 */
className?: string
}
/**
* 会话树骨架屏
* 模拟分组头 + 会话项 (2 组, 每组 3-4 项)
*
* @example
* <SessionTreeSkeleton />
*/
export function SessionTreeSkeleton({
groups = 2,
sessionsPerGroup = [3, 4],
className,
}: SessionTreeSkeletonProps) {
return (
<div className={mergeClass('flex flex-col', className)}>
{Array.from({ length: groups }).map((_, gIdx) => {
const count = sessionsPerGroup[0] + (gIdx % (sessionsPerGroup[1] - sessionsPerGroup[0] + 1))
return (
<div key={gIdx} className="border-b border-dark-700 last:border-b-0">
{/* 分组头 */}
<div className="flex items-center gap-2 px-4 py-2">
<Skeleton width={14} height={14} className="rounded-sm" />
<Skeleton width={14} height={14} className="rounded-sm" />
<Skeleton width={72} height={14} />
<div className="flex-1" />
<Skeleton width={18} height={12} />
</div>
{/* 会话项 */}
<div className="pl-10">
{Array.from({ length: count }).map((_, sIdx) => (
<div
key={sIdx}
className="flex items-center gap-2 px-4 py-2"
>
{/* 服务器图标占位 */}
<Skeleton width={16} height={16} className="rounded-sm flex-shrink-0" />
{/* 名称 + host */}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<Skeleton
height={13}
width={`${50 + ((sIdx * 13) % 30)}%`}
/>
<Skeleton height={11} width={`${40 + ((sIdx * 11) % 25)}%`} />
</div>
</div>
))}
</div>
</div>
)
})}
</div>
)
}
// ─── TabContentSkeleton (终端内容骨架屏) ────────────────────────
export interface TabContentSkeletonProps {
/** 自定义类名 */
className?: string
}
/**
* 终端内容骨架屏
* 黑色背景 + 模拟终端行 + 光标闪烁
*
* @example
* <TabContentSkeleton />
*/
export function TabContentSkeleton({ className }: TabContentSkeletonProps) {
return (
<div
className={mergeClass(
'flex-1 bg-[#1a1b26] p-4 flex flex-col gap-1.5 font-mono text-sm',
className,
)}
>
{/* 模拟终端提示行 */}
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
{/* 提示符 */}
<span className="text-accent-green/40 select-none">$</span>
{/* 命令行占位 */}
{i < 3 ? (
<Skeleton
height={13}
width={`${30 + ((i * 19) % 50)}%`}
className="rounded-sm"
/>
) : (
<Skeleton
height={13}
width={`${15 + ((i * 7) % 45)}%`}
className="rounded-sm opacity-50"
/>
)}
</div>
))}
{/* 闪烁光标行 */}
<div className="flex items-center gap-2 mt-1">
<span className="text-accent-green/40 select-none">$</span>
<span
className="inline-block w-2 h-4 bg-gray-400 animate-pulse"
/>
</div>
</div>
)
}
// ─── 默认导出 (兼容旧代码中 import Skeleton from ...) ──────────
export default Skeleton
+49
View File
@@ -0,0 +1,49 @@
/**
* ToastContainer - Toast 通知显示容器
*/
import { CheckCircle, Info, AlertTriangle, XCircle, X } from 'lucide-react'
import type { Toast, ToastType } from '../../hooks/useToast'
interface ToastContainerProps {
toasts: Toast[]
onRemove: (id: string) => void
}
const iconMap: Record<ToastType, React.ReactNode> = {
info: <Info className="w-4 h-4 text-blue-400" />,
success: <CheckCircle className="w-4 h-4 text-green-400" />,
error: <XCircle className="w-4 h-4 text-red-400" />,
warning: <AlertTriangle className="w-4 h-4 text-yellow-400" />,
}
const bgMap: Record<ToastType, string> = {
info: 'bg-blue-900/80 border-blue-700',
success: 'bg-green-900/80 border-green-700',
error: 'bg-red-900/80 border-red-700',
warning: 'bg-yellow-900/80 border-yellow-700',
}
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
if (toasts.length === 0) return null
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto flex items-center gap-2 px-4 py-3 rounded-lg border shadow-lg backdrop-blur-sm text-sm text-white min-w-[280px] max-w-[400px] animate-fade-in ${bgMap[toast.type]}`}
>
{iconMap[toast.type]}
<span className="flex-1">{toast.message}</span>
<button
onClick={() => onRemove(toast.id)}
className="p-0.5 hover:bg-white/10 rounded"
>
<X className="w-3.5 h-3.5 text-gray-300" />
</button>
</div>
))}
</div>
)
}
+11 -1
View File
@@ -21,6 +21,16 @@ export { default as WelcomeView } from './WelcomeView'
// 通用组件
export { default as ErrorBoundary } from './common/ErrorBoundary'
export { default as 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'
+170
View File
@@ -0,0 +1,170 @@
/**
* ChmodDialog — 权限编辑对话框
*
* 使用复选框 r/w/x 三组权限来修改文件权限
*/
import { useState, useCallback } from 'react'
import { Shield } from 'lucide-react'
import Modal from '../common/Modal'
interface ChmodDialogProps {
isOpen: boolean
onClose: () => void
fileName: string
filePath: string
/** Initial mode as octal number (e.g. 0o755). If not provided, defaults to 0o644 */
initialMode?: number
/** Callback when user confirms */
onConfirm: (filePath: string, mode: number) => Promise<boolean> | boolean
}
interface PermGroup {
read: boolean
write: boolean
execute: boolean
}
function modeToPermGroups(mode: number): { owner: PermGroup; group: PermGroup; other: PermGroup } {
return {
owner: {
read: !!(mode & 0o400),
write: !!(mode & 0o200),
execute: !!(mode & 0o100),
},
group: {
read: !!(mode & 0o040),
write: !!(mode & 0o020),
execute: !!(mode & 0o010),
},
other: {
read: !!(mode & 0o004),
write: !!(mode & 0o002),
execute: !!(mode & 0o001),
},
}
}
function permGroupsToMode(groups: { owner: PermGroup; group: PermGroup; other: PermGroup }): number {
let mode = 0
if (groups.owner.read) mode |= 0o400
if (groups.owner.write) mode |= 0o200
if (groups.owner.execute) mode |= 0o100
if (groups.group.read) mode |= 0o040
if (groups.group.write) mode |= 0o020
if (groups.group.execute) mode |= 0o010
if (groups.other.read) mode |= 0o004
if (groups.other.write) mode |= 0o002
if (groups.other.execute) mode |= 0o001
return mode
}
export default function ChmodDialog({
isOpen,
onClose,
fileName,
filePath,
initialMode = 0o644,
onConfirm,
}: ChmodDialogProps) {
const [groups, setGroups] = useState(modeToPermGroups(initialMode))
const [saving, setSaving] = useState(false)
const currentMode = permGroupsToMode(groups)
const octalString = currentMode.toString(8).padStart(3, '0')
const togglePerm = useCallback((scope: 'owner' | 'group' | 'other', perm: 'read' | 'write' | 'execute') => {
setGroups((prev) => ({
...prev,
[scope]: {
...prev[scope],
[perm]: !prev[scope][perm],
},
}))
}, [])
const handleConfirm = useCallback(async () => {
setSaving(true)
const ok = await onConfirm(filePath, currentMode)
setSaving(false)
if (ok) {
onClose()
}
}, [filePath, currentMode, onConfirm, onClose])
const PermCheckbox = ({
scope,
perm,
label,
}: {
scope: 'owner' | 'group' | 'other'
perm: 'read' | 'write' | 'execute'
label: string
}) => (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={groups[scope][perm]}
onChange={() => togglePerm(scope, perm)}
className="w-4 h-4 rounded border-dark-500 bg-dark-700 text-accent-blue focus:ring-accent-blue focus:ring-offset-0"
/>
<span className="text-gray-300 text-sm">{label}</span>
</label>
)
const PermRow = ({
scope,
label,
}: {
scope: 'owner' | 'group' | 'other'
label: string
}) => (
<div className="flex items-center gap-4">
<span className="text-gray-400 w-16 text-sm">{label}</span>
<PermCheckbox scope={scope} perm="read" label="读取" />
<PermCheckbox scope={scope} perm="write" label="写入" />
<PermCheckbox scope={scope} perm="execute" label="执行" />
</div>
)
return (
<Modal
isOpen={isOpen}
title="权限编辑"
onClose={onClose}
onConfirm={handleConfirm}
confirmText="应用"
loading={saving}
>
<div className="space-y-4 min-w-[360px]">
{/* File info */}
<div className="flex items-center gap-3 pb-3 border-b border-dark-600">
<Shield className="w-5 h-5 text-accent-blue" />
<div className="min-w-0 flex-1">
<p className="text-white font-medium truncate">{fileName}</p>
<p className="text-gray-500 text-xs font-mono truncate">{filePath}</p>
</div>
</div>
{/* Current octal mode display */}
<div className="flex items-center justify-center gap-2 py-2">
<span className="text-gray-400 text-sm">当前权限:</span>
<span className="text-accent-blue font-mono font-bold text-lg">{octalString}</span>
</div>
{/* Permission matrix */}
<div className="space-y-3 bg-dark-700 rounded-lg p-4">
<div className="flex items-center gap-4">
<span className="w-16" />
<span className="text-gray-500 text-xs w-16">读取 (r)</span>
<span className="text-gray-500 text-xs w-16">写入 (w)</span>
<span className="text-gray-500 text-xs w-16">执行 (x)</span>
</div>
<PermRow scope="owner" label="所有者" />
<PermRow scope="group" label="所属组" />
<PermRow scope="other" label="其他人" />
</div>
</div>
</Modal>
)
}
+18 -13
View File
@@ -9,6 +9,7 @@ export interface MenuItem {
label: string
action: () => void
danger?: boolean
separator?: boolean
}
interface ContextMenuProps {
@@ -45,19 +46,23 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }}
>
{items.map((item, index) => (
<button
key={index}
onClick={() => {
item.action()
onClose()
}}
className={`w-full flex items-center gap-3 px-4 py-2 text-sm transition-colors ${
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-gray-200 hover:bg-dark-700 hover:text-white'
}`}
>
{item.icon}
<span>{item.label}</span>
</button>
<div key={index}>
{item.separator && index > 0 && (
<div className="my-1 border-t border-dark-600" />
)}
<button
onClick={() => {
item.action()
onClose()
}}
className={`w-full flex items-center gap-3 px-4 py-2 text-sm transition-colors ${
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-gray-200 hover:bg-dark-700 hover:text-white'
}`}
>
{item.icon}
<span>{item.label}</span>
</button>
</div>
))}
</div>
)
+83 -8
View File
@@ -2,13 +2,17 @@
* FileListPanel - 文件列表面板
*
* 本地面板或远程面板的文件列表组件
* 远程面板支持拖拽上传
*/
import { Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown } from 'lucide-react'
import { useState, useCallback, useRef } from 'react'
import { Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, FolderOpen } from 'lucide-react'
import { Breadcrumb } from './Breadcrumb'
import { SortHeader } from './SortHeader'
import { FileRow } from './FileRow'
import { ContextMenu, type MenuItem } from './ContextMenu'
import { FileListSkeleton } from '../common/Skeleton'
import EmptyState from '../common/EmptyState'
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
interface FileListPanelProps {
@@ -31,6 +35,10 @@ interface FileListPanelProps {
onSort: (field: SortField) => void
contextMenu: { x: number; y: number; items: MenuItem[] } | null
onCloseContextMenu: () => void
/** 远程面板拖拽上传回调 */
onDropFiles?: (files: FileList) => void
/** 是否正在加载 */
loading?: boolean
}
export function FileListPanel({
@@ -53,12 +61,65 @@ export function FileListPanel({
onSort,
contextMenu,
onCloseContextMenu,
onDropFiles,
loading = false,
}: FileListPanelProps) {
const canGoBack = historyIndex > 0
const canGoForward = historyIndex < history.length - 1
// Drag state for remote panel upload
const [isDragOver, setIsDragOver] = useState(false)
const dragCounterRef = useRef(0)
const handleDragEnter = useCallback((e: React.DragEvent) => {
if (isLocal || !onDropFiles) return
e.preventDefault()
e.stopPropagation()
dragCounterRef.current++
if (dragCounterRef.current === 1) {
setIsDragOver(true)
}
}, [isLocal, onDropFiles])
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (isLocal || !onDropFiles) return
e.preventDefault()
e.stopPropagation()
dragCounterRef.current--
if (dragCounterRef.current === 0) {
setIsDragOver(false)
}
}, [isLocal, onDropFiles])
const handleDragOver = useCallback((e: React.DragEvent) => {
if (isLocal || !onDropFiles) return
e.preventDefault()
e.stopPropagation()
}, [isLocal, onDropFiles])
const handleDrop = useCallback((e: React.DragEvent) => {
if (isLocal || !onDropFiles) return
e.preventDefault()
e.stopPropagation()
dragCounterRef.current = 0
setIsDragOver(false)
const droppedFiles = e.dataTransfer.files
if (droppedFiles && droppedFiles.length > 0) {
onDropFiles(droppedFiles)
}
}, [isLocal, onDropFiles])
return (
<div className="flex-1 flex flex-col bg-dark-800 rounded-lg border border-dark-700 overflow-hidden">
<div
className={`flex-1 flex flex-col bg-dark-800 rounded-lg border overflow-hidden transition-colors ${
isDragOver ? 'border-accent-blue shadow-lg shadow-accent-blue/20' : 'border-dark-700'
}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600">
<div className="flex items-center gap-2">
@@ -100,11 +161,15 @@ export function FileListPanel({
</div>
{/* File List */}
<div className="flex-1 overflow-y-auto">
{files.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-500 text-sm">
空文件夹
</div>
<div className="flex-1 overflow-y-auto relative">
{loading ? (
<FileListSkeleton rows={6} />
) : files.length === 0 ? (
<EmptyState
icon={FolderOpen}
title="目录为空"
description="此目录下没有文件"
/>
) : (
files.map((file) => (
<FileRow
@@ -117,11 +182,21 @@ export function FileListPanel({
/>
))
)}
{/* Drag overlay - pointer-events-none so it doesn't interfere with drop events */}
{isDragOver && (
<div className="absolute inset-0 bg-accent-blue/10 border-2 border-dashed border-accent-blue rounded-lg flex items-center justify-center pointer-events-none z-10">
<div className="flex flex-col items-center gap-2 text-accent-blue">
<Upload className="w-10 h-10" />
<span className="text-sm font-medium">拖拽文件到此处上传</span>
</div>
</div>
)}
</div>
{/* Status Bar */}
<div className="px-4 py-2 border-t border-dark-700 text-xs text-gray-400">
{selected.size > 0 ? `已选择 ${selected.size} 项` : `共 ${files.length} 项`}
{loading ? '加载中...' : selected.size > 0 ? `已选择 ${selected.size} 项` : `共 ${files.length} 项`}
</div>
{/* Context Menu */}
@@ -0,0 +1,181 @@
/**
* FilePropertiesDialog — 文件属性查看对话框
*
* 显示文件名、路径、大小、权限、修改时间、类型等信息
*/
import { useState, useEffect } from 'react'
import { File, Folder, Link2, HardDrive, Clock, Shield, Info } from 'lucide-react'
import Modal from '../common/Modal'
interface FileStats {
size: number
mode: number
mtime: number
atime: number
isDirectory: boolean
isFile: boolean
isSymbolicLink: boolean
}
interface FilePropertiesDialogProps {
isOpen: boolean
onClose: () => void
fileName: string
filePath: string
fileType: 'file' | 'directory' | 'symlink'
/** Initial permissions string e.g. "-rwxr-xr-x" */
permissions?: string
/** Initial size from file listing */
initialSize?: number
/** Initial modified timestamp from file listing */
initialModified?: number
/** Async function to fetch fresh stat from the server */
onFetchStat?: (filePath: string) => Promise<FileStats | null>
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${units[i]}`
}
function formatTimestamp(ts: number): string {
if (!ts) return '-'
const date = new Date(ts * 1000)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function modeToOctal(mode: number): string {
// Lower 9 bits = rwxrwxrwx
return (mode & 0o7777).toString(8).padStart(4, '0')
}
function modeToString(mode: number): string {
const perms = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']
const owner = perms[(mode >> 6) & 7]
const group = perms[(mode >> 3) & 7]
const other = perms[mode & 7]
let prefix = '-'
if (mode & 0o40000) prefix = 'd'
else if (mode & 0o120000) prefix = 'l'
return `${prefix}${owner}${group}${other}`
}
export default function FilePropertiesDialog({
isOpen,
onClose,
fileName,
filePath,
fileType,
permissions: initialPermissions,
initialSize,
initialModified,
onFetchStat,
}: FilePropertiesDialogProps) {
const [stats, setStats] = useState<FileStats | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (isOpen && onFetchStat) {
setLoading(true)
onFetchStat(filePath)
.then((result) => {
if (result) setStats(result)
})
.finally(() => setLoading(false))
}
}, [isOpen, filePath, onFetchStat])
const size = stats?.size ?? initialSize ?? 0
const mtime = stats?.mtime ?? initialModified ?? 0
const atime = stats?.atime ?? 0
const octalMode = stats ? modeToOctal(stats.mode) : null
const stringMode = stats ? modeToString(stats.mode) : (initialPermissions ?? '-')
const typeLabel = fileType === 'directory'
? '文件夹'
: fileType === 'symlink'
? '符号链接'
: '文件'
const TypeIcon = fileType === 'directory'
? Folder
: fileType === 'symlink'
? Link2
: File
return (
<Modal
isOpen={isOpen}
title="文件属性"
onClose={onClose}
showConfirm={false}
cancelText="关闭"
>
<div className="space-y-4 min-w-[320px]">
{/* File icon + name header */}
<div className="flex items-center gap-3 pb-3 border-b border-dark-600">
<div className="p-2 bg-dark-700 rounded-lg">
<TypeIcon className="w-8 h-8 text-accent-blue" />
</div>
<div className="min-w-0 flex-1">
<p className="text-white font-semibold truncate">{fileName}</p>
<p className="text-gray-400 text-xs">{typeLabel}</p>
</div>
</div>
{loading && (
<div className="flex items-center gap-2 text-gray-400 text-sm">
<div className="animate-spin h-4 w-4 border-2 border-accent-blue border-t-transparent rounded-full" />
<span>加载详细信息...</span>
</div>
)}
{/* Properties table */}
<div className="space-y-2 text-sm">
<PropertyRow icon={<Info className="w-4 h-4" />} label="名称" value={fileName} />
<PropertyRow icon={<HardDrive className="w-4 h-4" />} label="路径" value={filePath} monospace />
<PropertyRow icon={<HardDrive className="w-4 h-4" />} label="大小" value={formatFileSize(size)} />
<PropertyRow icon={<Shield className="w-4 h-4" />} label="权限" value={stringMode + (octalMode ? ` (${octalMode})` : '')} monospace />
<PropertyRow icon={<Clock className="w-4 h-4" />} label="修改时间" value={formatTimestamp(mtime)} />
{stats && (
<PropertyRow icon={<Clock className="w-4 h-4" />} label="访问时间" value={formatTimestamp(atime)} />
)}
</div>
</div>
</Modal>
)
}
function PropertyRow({
icon,
label,
value,
monospace = false,
}: {
icon: React.ReactNode
label: string
value: string
monospace?: boolean
}) {
return (
<div className="flex items-start gap-3">
<span className="text-gray-500 mt-0.5 shrink-0">{icon}</span>
<span className="text-gray-400 w-20 shrink-0">{label}</span>
<span
className={`text-gray-200 break-all ${monospace ? 'font-mono text-xs' : ''}`}
>
{value}
</span>
</div>
)
}
+74 -47
View File
@@ -1,11 +1,13 @@
/**
* SessionTree - 会话树组件
*
* 显示分组和会话列表
* 显示分组和会话列表,带折叠动画
*/
import { ChevronRight, ChevronDown, Folder, Server } from 'lucide-react'
import { ChevronRight, Folder, Server, ServerOff } from 'lucide-react'
import { SessionTreeSkeleton } from '../common/Skeleton'
import type { Session } from '../../stores/sessionStore'
import EmptyState from '../common/EmptyState'
interface SessionTreeProps {
sessions: Session[]
@@ -15,6 +17,10 @@ interface SessionTreeProps {
onSelectSession: (session: Session) => void
onContextMenu: (e: React.MouseEvent, session: Session) => void
searchTerm: string
/** 是否正在加载 */
loading?: boolean
/** 新建会话回调,用于空状态操作按钮 */
onNewSession?: () => void
}
export function SessionTree({
@@ -25,7 +31,18 @@ export function SessionTree({
onSelectSession,
onContextMenu,
searchTerm,
loading = false,
onNewSession,
}: SessionTreeProps) {
// 加载中时显示骨架屏
if (loading) {
return (
<div className="flex-1 overflow-y-auto">
<SessionTreeSkeleton groups={2} sessionsPerGroup={[3, 4]} />
</div>
)
}
// Filter sessions by search
const filteredSessions = sessions.filter(s =>
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -42,56 +59,66 @@ export function SessionTree({
return (
<div className="flex-1 overflow-y-auto">
{Object.entries(grouped).map(([group, groupSessions]) => (
<div key={group} className="border-b border-dark-700 last:border-b-0">
{/* Group Header */}
<button
onClick={() => onToggleGroup(group)}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-400 hover:bg-dark-700 transition-colors"
>
{expandedGroups.has(group) ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
<Folder className="w-4 h-4" />
<span className="flex-1 text-left">{group === 'default' ? '默认分组' : group}</span>
<span className="text-xs text-gray-500">{groupSessions.length}</span>
</button>
{Object.entries(grouped).map(([group, groupSessions]) => {
const isExpanded = expandedGroups.has(group)
return (
<div key={group} className="border-b border-dark-700 last:border-b-0">
{/* Group Header */}
<button
onClick={() => onToggleGroup(group)}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-400 hover:bg-dark-700 transition-colors"
>
<ChevronRight
className="w-4 h-4 transition-transform duration-200 ease-in-out"
style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
<Folder className="w-4 h-4" />
<span className="flex-1 text-left">{group === 'default' ? '默认分组' : group}</span>
<span className="text-xs text-gray-500">{groupSessions.length}</span>
</button>
{/* Sessions */}
{expandedGroups.has(group) && (
<div className="animate-fade-in">
{groupSessions.map((session) => (
<div
key={session.id}
onClick={() => onSelectSession(session)}
onContextMenu={(e) => onContextMenu(e, session)}
className={`
group flex items-center gap-2 px-4 py-2 text-sm cursor-pointer
transition-colors pl-10
${selectedSessionId === session.id
? 'bg-dark-700 border-l-2 border-accent-blue'
: 'hover:bg-dark-700'
}
`}
>
<Server className="w-4 h-4 text-accent-green flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-200 truncate">{session.name}</div>
<div className="text-xs text-gray-500 truncate">{session.host}:{session.port}</div>
</div>
{/* Sessions - CSS Grid collapse animation */}
<div
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
>
<div className="overflow-hidden">
<div>
{groupSessions.map((session) => (
<div
key={session.id}
onClick={() => onSelectSession(session)}
onContextMenu={(e) => onContextMenu(e, session)}
className={`
group flex items-center gap-2 px-4 py-2 text-sm cursor-pointer
transition-colors pl-10
${selectedSessionId === session.id
? 'bg-dark-700 border-l-2 border-accent-blue'
: 'hover:bg-dark-700'
}
`}
>
<Server className="w-4 h-4 text-accent-green flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-200 truncate">{session.name}</div>
<div className="text-xs text-gray-500 truncate">{session.host}:{session.port}</div>
</div>
</div>
))}
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
)
})}
{filteredSessions.length === 0 && (
<div className="p-8 text-center text-gray-500 text-sm">
{searchTerm ? '未找到匹配的会话' : '暂无会话'}
</div>
<EmptyState
icon={ServerOff}
title={searchTerm ? '未找到匹配的会话' : '暂无会话'}
description={searchTerm ? '尝试其他关键词搜索' : '点击 + 按钮添加你的第一个 SSH 连接'}
action={searchTerm ? undefined : onNewSession ? { label: '新建会话', onClick: onNewSession } : undefined}
/>
)}
</div>
)
+50 -6
View File
@@ -1,5 +1,5 @@
/**
* TabItem - 单个标签项
* TabItem - 单个标签项(支持拖拽排序)
*/
import { X, Terminal, FolderSync } from 'lucide-react'
@@ -7,13 +7,33 @@ import type { Tab } from '../../stores/tabStore'
interface TabItemProps {
tab: Tab
index: number
isActive: boolean
isDragging: boolean
dragOverIndex: number | null
onActivate: () => void
onClose: (e: React.MouseEvent) => void
onContextMenu: (e: React.MouseEvent) => void
onDragStart: (e: React.DragEvent, index: number) => void
onDragOver: (e: React.DragEvent, index: number) => void
onDrop: (e: React.DragEvent, index: number) => void
onDragEnd: () => void
}
export function TabItem({ tab, isActive, onActivate, onClose, onContextMenu }: TabItemProps) {
export function TabItem({
tab,
index,
isActive,
isDragging,
dragOverIndex,
onActivate,
onClose,
onContextMenu,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
}: TabItemProps) {
const getIcon = () => {
switch (tab.type) {
case 'terminal':
@@ -25,20 +45,39 @@ export function TabItem({ tab, isActive, onActivate, onClose, onContextMenu }: T
}
}
// Determine drop indicator position
const showLeftIndicator = dragOverIndex === index && !isDragging
const showRightIndicator = dragOverIndex === index + 1 && !isDragging
return (
<div
draggable
onClick={onActivate}
onContextMenu={onContextMenu}
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => onDragOver(e, index)}
onDrop={(e) => onDrop(e, index)}
onDragEnd={onDragEnd}
className={`
group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[200px]
group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[200px]
border-r border-dark-700 cursor-pointer select-none
transition-colors relative
${isActive
? 'bg-dark-800 text-gray-200'
transition-all duration-150 relative
${isActive
? 'bg-dark-800 text-gray-200'
: 'bg-dark-900 text-gray-500 hover:bg-dark-800 hover:text-gray-400'
}
${isDragging ? 'opacity-50' : 'opacity-100'}
`}
>
{/* Left drop indicator */}
{showLeftIndicator && (
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-accent-blue z-10 transition-all duration-150" />
)}
{/* Right drop indicator */}
{showRightIndicator && (
<div className="absolute right-0 top-0 bottom-0 w-0.5 bg-accent-blue z-10 transition-all duration-150" />
)}
{/* Status indicator */}
{tab.connectionStatus === 'connecting' && (
<span className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
@@ -67,6 +106,11 @@ export function TabItem({ tab, isActive, onActivate, onClose, onContextMenu }: T
>
<X className="w-3.5 h-3.5" />
</button>
{/* Active tab bottom indicator */}
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent-blue" />
)}
</div>
)
}
@@ -0,0 +1,98 @@
import { Terminal as TerminalIcon } from 'lucide-react'
import type { ConnectionState } from '../../stores/connection'
export interface TerminalOverlayProps {
connectionState: ConnectionState
statusMessage: string
showSuspendedOverlay: boolean
needsReconnect: boolean
onReconnect: () => void
onResume: () => void
}
export default function TerminalOverlay({
connectionState,
statusMessage,
showSuspendedOverlay,
needsReconnect,
onReconnect,
onResume,
}: TerminalOverlayProps) {
return (
<>
{/* 会话恢复 - 需要重新连接覆盖层 */}
{connectionState === 'disconnected' && needsReconnect && (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
<div className="text-center">
<div className="w-16 h-16 bg-accent-blue/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
<TerminalIcon className="w-8 h-8 text-accent-blue" />
</div>
<div className="text-gray-200 mb-2 text-lg">会话已恢复</div>
<div className="text-gray-400 text-sm mb-6">
上次关闭时的终端标签已恢复<br />
点击下方按钮重新建立 SSH 连接
</div>
<button
onClick={onReconnect}
className="px-6 py-2.5 bg-accent-blue hover:bg-blue-500 text-white rounded-lg transition-colors"
>
重新连接
</button>
</div>
</div>
)}
{/* 挂起状态覆盖层 */}
{showSuspendedOverlay && (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
<div className="text-center">
<div className="text-yellow-400 mb-2 text-lg">连接已挂起</div>
<div className="text-gray-400 text-sm mb-4">
长时间未操作,连接已进入挂起状态
</div>
<button
onClick={onResume}
className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors mr-2"
>
恢复连接
</button>
<button
onClick={onReconnect}
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-gray-200 rounded transition-colors"
>
重新连接
</button>
</div>
</div>
)}
{/* 错误状态覆盖层 */}
{connectionState === 'error' && (
<div className="absolute inset-0 bg-dark-900/80 flex items-center justify-center z-10">
<div className="text-center">
<div className="text-red-400 mb-2">连接失败</div>
<div className="text-gray-400 text-sm mb-4">{statusMessage}</div>
<button
onClick={onReconnect}
className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors"
>
重新连接
</button>
</div>
</div>
)}
{/* 连接中状态 */}
{connectionState === 'connecting' && (
<div className="absolute inset-0 bg-dark-900/50 flex items-center justify-center pointer-events-none z-10">
<div className="text-accent-blue animate-pulse">连接中...</div>
</div>
)}
{/* 连接状态指示器 - 简洁版 */}
<div className="absolute top-3 right-3 flex items-center gap-2 z-20">
{/* 状态指示已移至 TabBar */}
</div>
</>
)
}
@@ -0,0 +1,74 @@
import React from 'react'
import { Search, ChevronUp, ChevronDown, X } from 'lucide-react'
export interface TerminalSearchBarProps {
searchQuery: string
searchResultCount: number | undefined
searchResultIndex: number | undefined
searchInputRef: React.MutableRefObject<HTMLInputElement | null>
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onSearchNext: () => void
onSearchPrevious: () => void
onClose: () => void
}
export default function TerminalSearchBar({
searchQuery,
searchResultCount,
searchResultIndex,
searchInputRef,
onSearchChange,
onSearchNext,
onSearchPrevious,
onClose,
}: TerminalSearchBarProps) {
return (
<div className="absolute top-0 left-0 right-0 z-30 flex items-center gap-2 px-3 py-2 bg-dark-800/95 border-b border-dark-600 backdrop-blur-sm">
<Search size={14} className="text-gray-400 shrink-0" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={onSearchChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.shiftKey) {
onSearchPrevious()
} else {
onSearchNext()
}
}
e.stopPropagation()
}}
placeholder="搜索..."
className="flex-1 bg-dark-900 text-gray-200 text-sm px-2 py-1 rounded border border-dark-600 focus:border-accent-blue focus:outline-none placeholder-gray-500"
/>
{searchResultCount !== undefined && (
<span className="text-xs text-gray-400 whitespace-nowrap min-w-[60px] text-center">
{searchResultIndex ?? 0}/{searchResultCount}
</span>
)}
<button
onClick={onSearchPrevious}
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
title="上一个 (Shift+Enter)"
>
<ChevronUp size={16} />
</button>
<button
onClick={onSearchNext}
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
title="下一个 (Enter)"
>
<ChevronDown size={16} />
</button>
<button
onClick={onClose}
className="p-1 hover:bg-dark-600 rounded text-gray-400 hover:text-gray-200 transition-colors"
title="关闭 (Escape)"
>
<X size={16} />
</button>
</div>
)
}