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

+137 -88
View File
@@ -13,23 +13,6 @@ import type { FileItem, TransferTask, SortField, SortOrder } from '../../shared/
const logger = createLogger('useSFTP')
// ===== 彩色调试日志 =====
function logHeader(msg: string) {
console.log(`%c${msg}`, 'background: #16a085; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;')
}
function logInfo(label: string, value: any) {
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #1abc9c; font-weight: bold;')
}
function logSuccess(msg: string) {
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;')
}
function logError(msg: string) {
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;')
}
function logWarning(msg: string) {
console.log(`%c⚠ ${msg}`, 'color: #f39c12; font-weight: bold;')
}
export interface UseSFTPOptions {
tabId: string
}
@@ -77,6 +60,17 @@ export interface UseSFTPReturn {
// 远程文件操作
deleteRemote: (files: FileItem[]) => Promise<boolean>
renameRemote: (file: FileItem, newName: string) => Promise<boolean>
getRemoteStat: (filePath: string) => Promise<{
size: number
mode: number
mtime: number
atime: number
isDirectory: boolean
isFile: boolean
isSymbolicLink: boolean
} | null>
chmodRemote: (filePath: string, mode: number) => Promise<boolean>
// 排序
sortField: SortField
@@ -92,7 +86,7 @@ export interface UseSFTPReturn {
export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
const { sessions } = useSessionStore()
const { updateTabConnectionInstance, updateTab } = useTabStore()
const { createConnection, getConnection } = useConnectionPool()
const { createConnection } = useConnectionPool()
// Local state
const [localPath, setLocalPath] = useState('')
@@ -124,50 +118,40 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Initialize home path
useEffect(() => {
logHeader('📁 SFTP Initializing home path')
logger.info('SFTP Initializing home path')
if (window.electronAPI) {
window.electronAPI.getHomePath?.().then((result: { success: boolean; path?: string }) => {
logInfo('Home path result', result)
logger.debug('Home path result', result)
if (result.success && result.path) {
setLocalPath(result.path)
setLocalHistory([result.path])
setLocalHistoryIndex(0)
logSuccess(`Home path set: ${result.path}`)
logger.info(`Home path set: ${result.path}`)
// Load local directory
loadLocalDirectory(result.path)
} else {
logError('Failed to get home path')
logger.error('Failed to get home path')
}
}).catch((err: any) => {
logError(`getHomePath error: ${err?.message || err}`)
logger.error(`getHomePath error: ${err?.message || err}`)
})
} else {
logWarning('electronAPI not available')
logger.warn('electronAPI not available')
}
}, [])
// Load local directory
const loadLocalDirectory = useCallback(async (path: string) => {
logHeader('📁 LOAD LOCAL DIRECTORY')
logInfo('path', path)
logger.info('Loading local directory', path)
if (!window.electronAPI?.localReadDir) {
logWarning('localReadDir not available, using mock data')
// Mock files for development
const mockFiles: FileItem[] = [
{ name: 'Desktop', path: `${path}/Desktop`, type: 'directory', size: 0, modified: Date.now() / 1000 - 86400, permissions: 'drwxr-xr-x' },
{ name: 'Documents', path: `${path}/Documents`, type: 'directory', size: 0, modified: Date.now() / 1000 - 172800, permissions: 'drwxr-xr-x' },
{ name: 'Downloads', path: `${path}/Downloads`, type: 'directory', size: 0, modified: Date.now() / 1000 - 3600, permissions: 'drwxr-xr-x' },
{ name: 'config.json', path: `${path}/config.json`, type: 'file', size: 4096, modified: Date.now() / 1000 - 7200, permissions: '-rw-r--r--' },
]
setLocalFiles(mockFiles)
logSuccess(`Loaded ${mockFiles.length} mock files`)
logger.warn('localReadDir not available')
return
}
try {
const result = await window.electronAPI.localReadDir(path)
logInfo('localReadDir result', result)
logger.debug('localReadDir result', result)
if (result.success && result.files) {
const files: FileItem[] = result.files.map((f: any) => ({
@@ -179,30 +163,27 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
permissions: f.isDirectory ? 'drwxr-xr-x' : '-rw-r--r--',
}))
setLocalFiles(files)
logSuccess(`Loaded ${files.length} local files`)
logger.info(`Loaded ${files.length} local files`)
} else {
logError(`Failed to load local directory: ${result.error}`)
logger.error(`Failed to load local directory: ${result.error}`)
}
} catch (err: any) {
logError(`loadLocalDirectory error: ${err?.message}`)
logger.error('Failed to load local directory', err)
logger.error(`loadLocalDirectory error: ${err?.message}`, err)
}
}, [])
// Load remote directory
const loadRemoteDirectory = useCallback(async (path: string) => {
logHeader('📁 LOAD REMOTE DIRECTORY')
logInfo('path', path)
logInfo('sftpId', sftpId)
logger.info('Loading remote directory', { path, sftpId })
if (!sftpId || !window.electronAPI) {
logError('sftpId or electronAPI not available')
logger.error('sftpId or electronAPI not available')
return
}
try {
const result = await window.electronAPI.sftpListDirectory(sftpId, path)
logInfo('sftpListDirectory result', result)
logger.debug('sftpListDirectory result', result)
if (result.success && result.files) {
const files: FileItem[] = result.files.map((f: any) => ({
@@ -214,13 +195,12 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
permissions: f.longname?.substring(0, 10) || '-rw-r--r--',
}))
setRemoteFiles(files)
logSuccess(`Loaded ${files.length} remote files`)
logger.info(`Loaded ${files.length} remote files`)
} else {
logError(`Failed to load remote directory: ${result.error}`)
logger.error(`Failed to load remote directory: ${result.error}`)
}
} catch (err: any) {
logError(`loadRemoteDirectory error: ${err?.message}`)
logger.error('Failed to load remote directory', err)
logger.error(`loadRemoteDirectory error: ${err?.message}`, err)
setError('Failed to load remote directory')
}
}, [sftpId])
@@ -284,11 +264,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Connect to SFTP
const connect = useCallback(async (sessionId: string) => {
logHeader('📁 SFTP CONNECT')
logInfo('sessionId', sessionId)
logger.info('SFTP CONNECT', { sessionId })
if (!sessionId) {
logError('No sessionId provided')
logger.error('No sessionId provided')
return
}
@@ -297,43 +276,43 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
try {
const config = await getSessionConfig(sessionId)
logInfo('Config', `${config?.username}@${config?.host}:${config?.port}`)
logger.debug('Config', `${config?.username}@${config?.host}:${config?.port}`)
if (!config) {
setError('Session not found')
logError('Session config not found')
logger.error('Session config not found')
return
}
// Create connection
logInfo('Creating connection...', tabId)
logger.debug('Creating connection...', tabId)
const result = await createConnection(tabId, config)
logInfo('Connection result', result)
logger.debug('Connection result', result)
if (!result.success || !result.connectionId) {
setError(result.error || 'Failed to create connection')
logError(`Connection failed: ${result.error}`)
logger.error(`Connection failed: ${result.error}`)
return
}
const connId = result.connectionId
setConnectionId(connId)
logSuccess(`Connection created: ${connId}`)
logger.info(`Connection created: ${connId}`)
// Create SFTP
logInfo('Creating SFTP session...', connId)
logger.debug('Creating SFTP session...', connId)
const sftpResult = await window.electronAPI?.sftpConnect(connId)
logInfo('SFTP result', sftpResult)
logger.debug('SFTP result', sftpResult)
if (!sftpResult?.success || !sftpResult.sftpId) {
setError(sftpResult?.error || 'Failed to create SFTP session')
logError(`SFTP creation failed: ${sftpResult?.error}`)
logger.error(`SFTP creation failed: ${sftpResult?.error}`)
return
}
setSftpId(sftpResult.sftpId)
setIsConnected(true)
logSuccess(`SFTP connected: ${sftpResult.sftpId}`)
logger.info(`SFTP connected: ${sftpResult.sftpId}`)
// Update tab
const connectionInstance: ConnectionInstance = {
@@ -344,12 +323,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
updateTab(tabId, { title: `SFTP: ${config.username}@${config.host}` })
// Load initial remote directory
logInfo('Loading remote home directory...', '/')
logger.debug('Loading remote home directory...', '/')
loadRemoteDirectory('/')
logSuccess('SFTP initialization complete')
logger.info('SFTP connected', { host: config.host, connectionId: connId })
logger.info('SFTP initialization complete')
} catch (err) {
logger.error('SFTP connection failed', err)
setError('Connection failed')
@@ -360,25 +337,25 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Disconnect
const disconnect = useCallback(async () => {
if (sftpId && window.electronAPI) {
// Note: SFTP disconnect is handled automatically when connection closes
}
if (connectionId) {
const conn = getConnection(connectionId)
if (conn) {
// Cleanup
// Close SFTP session via SSH disconnect (SFTP is cleaned up when connection closes)
if (connectionId && window.electronAPI) {
try {
await window.electronAPI.sshDisconnect(connectionId)
logger.info('SFTP connection closed', { connectionId, sftpId })
} catch (err) {
logger.error('Error closing SFTP connection', err)
}
}
setIsConnected(false)
setSftpId(null)
setConnectionId(null)
setRemoteFiles([])
}, [connectionId, sftpId, getConnection])
}, [connectionId, sftpId])
// Upload files (real implementation via IPC)
const uploadFiles = useCallback(async (files: FileList | null) => {
if (!files || files.length === 0 || !sftpId || !window.electronAPI) {
logWarning('uploadFiles: missing files, sftpId, or electronAPI')
logger.warn('uploadFiles: missing files, sftpId, or electronAPI')
return
}
@@ -415,7 +392,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
try {
for (const task of newTasks) {
const remoteFilePath = `${remotePath}/${task.fileName}`.replace(/\/+/g, '/')
logInfo('Uploading', `${task.filePath} -> ${remoteFilePath}`)
logger.debug('Uploading', `${task.filePath} -> ${remoteFilePath}`)
// Mark as transferring
setTransferQueue(prev => prev.map(t =>
@@ -429,12 +406,12 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
))
logSuccess(`Upload complete: ${task.fileName}`)
logger.info(`Upload complete: ${task.fileName}`)
} else {
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Upload failed' } : t
))
logError(`Upload failed: ${task.fileName} - ${result.error}`)
logger.error(`Upload failed: ${task.fileName} - ${result.error}`)
}
} catch (err: any) {
logger.error('Upload failed', err)
@@ -489,7 +466,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
try {
for (const task of newTasks) {
const localFilePath = `${localPath}/${task.fileName}`
logInfo('Downloading', `${task.filePath} -> ${localFilePath}`)
logger.debug('Downloading', `${task.filePath} -> ${localFilePath}`)
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'transferring', startTime: Date.now() } : t
@@ -502,12 +479,12 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
))
logSuccess(`Download complete: ${task.fileName}`)
logger.info(`Download complete: ${task.fileName}`)
} else {
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Download failed' } : t
))
logError(`Download failed: ${task.fileName} - ${result.error}`)
logger.error(`Download failed: ${task.fileName} - ${result.error}`)
}
} catch (err: any) {
logger.error('Download failed', err)
@@ -527,27 +504,26 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Delete remote files/directories
const deleteRemote = useCallback(async (files: FileItem[]): Promise<boolean> => {
if (!sftpId || files.length === 0 || !window.electronAPI) {
logWarning('deleteRemote: missing sftpId, files, or electronAPI')
logger.warn('deleteRemote: missing sftpId, files, or electronAPI')
return false
}
let allSuccess = true
for (const file of files) {
logInfo('Deleting remote', `${file.path} (isDir: ${file.type === 'directory'})`)
logger.debug('Deleting remote', `${file.path} (isDir: ${file.type === 'directory'})`)
try {
const result = await window.electronAPI.sftpDelete(sftpId, file.path, file.type === 'directory')
if (result.success) {
logSuccess(`Deleted: ${file.name}`)
logger.info(`Deleted: ${file.name}`)
} else {
logError(`Delete failed: ${file.name} - ${result.error}`)
logger.error(`Delete failed: ${file.name} - ${result.error}`)
allSuccess = false
}
} catch (err: any) {
logger.error('Delete failed', err)
logError(`Delete error: ${file.name} - ${err?.message}`)
logger.error(`Delete error: ${file.name} - ${err?.message}`, err)
allSuccess = false
}
}
@@ -560,6 +536,76 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
return allSuccess
}, [sftpId, remotePath, loadRemoteDirectory])
// Rename remote file/directory
const renameRemote = useCallback(async (file: FileItem, newName: string): Promise<boolean> => {
if (!sftpId || !window.electronAPI) {
logger.warn('renameRemote: missing sftpId or electronAPI')
return false
}
const dirPath = file.path.substring(0, file.path.lastIndexOf('/'))
const newPath = dirPath ? `${dirPath}/${newName}` : `/${newName}`
try {
const result = await window.electronAPI.sftpRename(sftpId, file.path, newPath)
if (result.success) {
logger.info(`Renamed: ${file.name} -> ${newName}`)
loadRemoteDirectory(remotePath)
return true
} else {
logger.error(`Rename failed: ${result.error}`)
return false
}
} catch (err: any) {
logger.error(`Rename error: ${err?.message}`, err)
return false
}
}, [sftpId, remotePath, loadRemoteDirectory])
// Get remote file stat
const getRemoteStat = useCallback(async (filePath: string) => {
if (!sftpId || !window.electronAPI) {
logger.warn('getRemoteStat: missing sftpId or electronAPI')
return null
}
try {
const result = await window.electronAPI.sftpStat(sftpId, filePath)
if (result.success && result.stats) {
return result.stats
} else {
logger.error(`Stat failed: ${result.error}`)
return null
}
} catch (err: any) {
logger.error(`Stat error: ${err?.message}`, err)
return null
}
}, [sftpId])
// Chmod remote file
const chmodRemote = useCallback(async (filePath: string, mode: number): Promise<boolean> => {
if (!sftpId || !window.electronAPI) {
logger.warn('chmodRemote: missing sftpId or electronAPI')
return false
}
try {
const result = await window.electronAPI.sftpChmod(sftpId, filePath, mode)
if (result.success) {
logger.info(`Chmod success: ${filePath} -> ${mode.toString(8)}`)
loadRemoteDirectory(remotePath)
return true
} else {
logger.error(`Chmod failed: ${result.error}`)
return false
}
} catch (err: any) {
logger.error(`Chmod error: ${err?.message}`, err)
return false
}
}, [sftpId, remotePath, loadRemoteDirectory])
// Cleanup on unmount
useEffect(() => {
return () => {
@@ -605,6 +651,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Remote file operations
deleteRemote,
renameRemote,
getRemoteStat,
chmodRemote,
sortField,
sortOrder,