feat(v1.2): 体验升级 — 快捷键系统 + 标签拖拽 + SFTP拖拽上传 + 组件拆分 + 响应式
任务1: 全局快捷键管理器 — useKeyboardShortcuts hook Ctrl+Tab/Shift+Tab标签切换, Ctrl+1~9跳转, F11全屏, Ctrl+Shift+N新建标签 任务2: 标签拖拽排序 — tabStore.moveTab + TabBar HTML5 DnD 不可变更新, 拖拽视觉反馈(opacity+指示线), 活跃标签底部accent-blue指示线 任务3: SFTP拖拽上传 + useSFTP调试清理 FileListPanel拖拽覆盖层(Upload图标+虚线边框), 5个调试函数→createLogger, disconnect()正确调用IPC, 删除mock data 任务4: SFTP右键菜单完善 — 重命名/复制路径/属性/权限 FilePropertiesDialog+ChmodDialog, sftpStat/sftpChmod IPC全栈实现 任务5-6: 侧边栏分组折叠动画 + 宽度可拖拽调整 Grid 0fr→1fr折叠动画, ChevronRight旋转, resize handle 180~400px, settings记忆 任务7: 响应式布局 + 视觉优化V1~V5 useMediaQuery hook, <800px侧边栏overlay模式, SFTP垂直堆叠, WelcomeView响应式grid 任务8: Loading/Skeleton骨架屏 Skeleton/SkeletonLine/SkeletonCircle/SkeletonBlock + FileListSkeleton/SessionTreeSkeleton/TabContentSkeleton 任务9: 空状态设计 EmptyState通用组件, SessionTree/FileListPanel/WelcomeView/SFTPView空状态引导 任务10: TerminalView组件拆分重构 711行→285行 useTerminalConnection(300行) + useTerminalSearch(123行) + TerminalOverlay(98行) + TerminalSearchBar(74行) 33 files changed, 2682 insertions(+), 872 deletions(-)
This commit is contained in:
1 parent
d344900118
commit
caba5f4c0d
33 files changed
+2683
-873
No files matched your search
+137
-88
@@ -13,23 +13,6 @@ import type { FileItem, TransferTask, SortField, SortOrder } from '../../shared/
|
||||
|
||||
const logger = createLogger('useSFTP')
|
||||
|
||||
// ===== 彩色调试日志 =====
|
||||
function logHeader(msg: string) {
|
||||
console.log(`%c${msg}`, 'background: #16a085; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;')
|
||||
}
|
||||
function logInfo(label: string, value: any) {
|
||||
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #1abc9c; font-weight: bold;')
|
||||
}
|
||||
function logSuccess(msg: string) {
|
||||
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;')
|
||||
}
|
||||
function logError(msg: string) {
|
||||
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;')
|
||||
}
|
||||
function logWarning(msg: string) {
|
||||
console.log(`%c⚠ ${msg}`, 'color: #f39c12; font-weight: bold;')
|
||||
}
|
||||
|
||||
export interface UseSFTPOptions {
|
||||
tabId: string
|
||||
}
|
||||
@@ -77,6 +60,17 @@ export interface UseSFTPReturn {
|
||||
|
||||
// 远程文件操作
|
||||
deleteRemote: (files: FileItem[]) => Promise<boolean>
|
||||
renameRemote: (file: FileItem, newName: string) => Promise<boolean>
|
||||
getRemoteStat: (filePath: string) => Promise<{
|
||||
size: number
|
||||
mode: number
|
||||
mtime: number
|
||||
atime: number
|
||||
isDirectory: boolean
|
||||
isFile: boolean
|
||||
isSymbolicLink: boolean
|
||||
} | null>
|
||||
chmodRemote: (filePath: string, mode: number) => Promise<boolean>
|
||||
|
||||
// 排序
|
||||
sortField: SortField
|
||||
@@ -92,7 +86,7 @@ export interface UseSFTPReturn {
|
||||
export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
const { sessions } = useSessionStore()
|
||||
const { updateTabConnectionInstance, updateTab } = useTabStore()
|
||||
const { createConnection, getConnection } = useConnectionPool()
|
||||
const { createConnection } = useConnectionPool()
|
||||
|
||||
// Local state
|
||||
const [localPath, setLocalPath] = useState('')
|
||||
@@ -124,50 +118,40 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
|
||||
// Initialize home path
|
||||
useEffect(() => {
|
||||
logHeader('📁 SFTP Initializing home path')
|
||||
logger.info('SFTP Initializing home path')
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.getHomePath?.().then((result: { success: boolean; path?: string }) => {
|
||||
logInfo('Home path result', result)
|
||||
logger.debug('Home path result', result)
|
||||
if (result.success && result.path) {
|
||||
setLocalPath(result.path)
|
||||
setLocalHistory([result.path])
|
||||
setLocalHistoryIndex(0)
|
||||
logSuccess(`Home path set: ${result.path}`)
|
||||
logger.info(`Home path set: ${result.path}`)
|
||||
// Load local directory
|
||||
loadLocalDirectory(result.path)
|
||||
} else {
|
||||
logError('Failed to get home path')
|
||||
logger.error('Failed to get home path')
|
||||
}
|
||||
}).catch((err: any) => {
|
||||
logError(`getHomePath error: ${err?.message || err}`)
|
||||
logger.error(`getHomePath error: ${err?.message || err}`)
|
||||
})
|
||||
} else {
|
||||
logWarning('electronAPI not available')
|
||||
logger.warn('electronAPI not available')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load local directory
|
||||
const loadLocalDirectory = useCallback(async (path: string) => {
|
||||
logHeader('📁 LOAD LOCAL DIRECTORY')
|
||||
logInfo('path', path)
|
||||
logger.info('Loading local directory', path)
|
||||
|
||||
if (!window.electronAPI?.localReadDir) {
|
||||
logWarning('localReadDir not available, using mock data')
|
||||
// Mock files for development
|
||||
const mockFiles: FileItem[] = [
|
||||
{ name: 'Desktop', path: `${path}/Desktop`, type: 'directory', size: 0, modified: Date.now() / 1000 - 86400, permissions: 'drwxr-xr-x' },
|
||||
{ name: 'Documents', path: `${path}/Documents`, type: 'directory', size: 0, modified: Date.now() / 1000 - 172800, permissions: 'drwxr-xr-x' },
|
||||
{ name: 'Downloads', path: `${path}/Downloads`, type: 'directory', size: 0, modified: Date.now() / 1000 - 3600, permissions: 'drwxr-xr-x' },
|
||||
{ name: 'config.json', path: `${path}/config.json`, type: 'file', size: 4096, modified: Date.now() / 1000 - 7200, permissions: '-rw-r--r--' },
|
||||
]
|
||||
setLocalFiles(mockFiles)
|
||||
logSuccess(`Loaded ${mockFiles.length} mock files`)
|
||||
logger.warn('localReadDir not available')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.localReadDir(path)
|
||||
logInfo('localReadDir result', result)
|
||||
logger.debug('localReadDir result', result)
|
||||
|
||||
if (result.success && result.files) {
|
||||
const files: FileItem[] = result.files.map((f: any) => ({
|
||||
@@ -179,30 +163,27 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
permissions: f.isDirectory ? 'drwxr-xr-x' : '-rw-r--r--',
|
||||
}))
|
||||
setLocalFiles(files)
|
||||
logSuccess(`Loaded ${files.length} local files`)
|
||||
logger.info(`Loaded ${files.length} local files`)
|
||||
} else {
|
||||
logError(`Failed to load local directory: ${result.error}`)
|
||||
logger.error(`Failed to load local directory: ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logError(`loadLocalDirectory error: ${err?.message}`)
|
||||
logger.error('Failed to load local directory', err)
|
||||
logger.error(`loadLocalDirectory error: ${err?.message}`, err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load remote directory
|
||||
const loadRemoteDirectory = useCallback(async (path: string) => {
|
||||
logHeader('📁 LOAD REMOTE DIRECTORY')
|
||||
logInfo('path', path)
|
||||
logInfo('sftpId', sftpId)
|
||||
logger.info('Loading remote directory', { path, sftpId })
|
||||
|
||||
if (!sftpId || !window.electronAPI) {
|
||||
logError('sftpId or electronAPI not available')
|
||||
logger.error('sftpId or electronAPI not available')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sftpListDirectory(sftpId, path)
|
||||
logInfo('sftpListDirectory result', result)
|
||||
logger.debug('sftpListDirectory result', result)
|
||||
|
||||
if (result.success && result.files) {
|
||||
const files: FileItem[] = result.files.map((f: any) => ({
|
||||
@@ -214,13 +195,12 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
permissions: f.longname?.substring(0, 10) || '-rw-r--r--',
|
||||
}))
|
||||
setRemoteFiles(files)
|
||||
logSuccess(`Loaded ${files.length} remote files`)
|
||||
logger.info(`Loaded ${files.length} remote files`)
|
||||
} else {
|
||||
logError(`Failed to load remote directory: ${result.error}`)
|
||||
logger.error(`Failed to load remote directory: ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logError(`loadRemoteDirectory error: ${err?.message}`)
|
||||
logger.error('Failed to load remote directory', err)
|
||||
logger.error(`loadRemoteDirectory error: ${err?.message}`, err)
|
||||
setError('Failed to load remote directory')
|
||||
}
|
||||
}, [sftpId])
|
||||
@@ -284,11 +264,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
|
||||
// Connect to SFTP
|
||||
const connect = useCallback(async (sessionId: string) => {
|
||||
logHeader('📁 SFTP CONNECT')
|
||||
logInfo('sessionId', sessionId)
|
||||
logger.info('SFTP CONNECT', { sessionId })
|
||||
|
||||
if (!sessionId) {
|
||||
logError('No sessionId provided')
|
||||
logger.error('No sessionId provided')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -297,43 +276,43 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
|
||||
try {
|
||||
const config = await getSessionConfig(sessionId)
|
||||
logInfo('Config', `${config?.username}@${config?.host}:${config?.port}`)
|
||||
logger.debug('Config', `${config?.username}@${config?.host}:${config?.port}`)
|
||||
|
||||
if (!config) {
|
||||
setError('Session not found')
|
||||
logError('Session config not found')
|
||||
logger.error('Session config not found')
|
||||
return
|
||||
}
|
||||
|
||||
// Create connection
|
||||
logInfo('Creating connection...', tabId)
|
||||
logger.debug('Creating connection...', tabId)
|
||||
const result = await createConnection(tabId, config)
|
||||
logInfo('Connection result', result)
|
||||
logger.debug('Connection result', result)
|
||||
|
||||
if (!result.success || !result.connectionId) {
|
||||
setError(result.error || 'Failed to create connection')
|
||||
logError(`Connection failed: ${result.error}`)
|
||||
logger.error(`Connection failed: ${result.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
const connId = result.connectionId
|
||||
setConnectionId(connId)
|
||||
logSuccess(`Connection created: ${connId}`)
|
||||
logger.info(`Connection created: ${connId}`)
|
||||
|
||||
// Create SFTP
|
||||
logInfo('Creating SFTP session...', connId)
|
||||
logger.debug('Creating SFTP session...', connId)
|
||||
const sftpResult = await window.electronAPI?.sftpConnect(connId)
|
||||
logInfo('SFTP result', sftpResult)
|
||||
logger.debug('SFTP result', sftpResult)
|
||||
|
||||
if (!sftpResult?.success || !sftpResult.sftpId) {
|
||||
setError(sftpResult?.error || 'Failed to create SFTP session')
|
||||
logError(`SFTP creation failed: ${sftpResult?.error}`)
|
||||
logger.error(`SFTP creation failed: ${sftpResult?.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
setSftpId(sftpResult.sftpId)
|
||||
setIsConnected(true)
|
||||
logSuccess(`SFTP connected: ${sftpResult.sftpId}`)
|
||||
logger.info(`SFTP connected: ${sftpResult.sftpId}`)
|
||||
|
||||
// Update tab
|
||||
const connectionInstance: ConnectionInstance = {
|
||||
@@ -344,12 +323,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
updateTab(tabId, { title: `SFTP: ${config.username}@${config.host}` })
|
||||
|
||||
// Load initial remote directory
|
||||
logInfo('Loading remote home directory...', '/')
|
||||
logger.debug('Loading remote home directory...', '/')
|
||||
loadRemoteDirectory('/')
|
||||
|
||||
logSuccess('SFTP initialization complete')
|
||||
|
||||
logger.info('SFTP connected', { host: config.host, connectionId: connId })
|
||||
logger.info('SFTP initialization complete')
|
||||
} catch (err) {
|
||||
logger.error('SFTP connection failed', err)
|
||||
setError('Connection failed')
|
||||
@@ -360,25 +337,25 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
|
||||
// Disconnect
|
||||
const disconnect = useCallback(async () => {
|
||||
if (sftpId && window.electronAPI) {
|
||||
// Note: SFTP disconnect is handled automatically when connection closes
|
||||
}
|
||||
if (connectionId) {
|
||||
const conn = getConnection(connectionId)
|
||||
if (conn) {
|
||||
// Cleanup
|
||||
// Close SFTP session via SSH disconnect (SFTP is cleaned up when connection closes)
|
||||
if (connectionId && window.electronAPI) {
|
||||
try {
|
||||
await window.electronAPI.sshDisconnect(connectionId)
|
||||
logger.info('SFTP connection closed', { connectionId, sftpId })
|
||||
} catch (err) {
|
||||
logger.error('Error closing SFTP connection', err)
|
||||
}
|
||||
}
|
||||
setIsConnected(false)
|
||||
setSftpId(null)
|
||||
setConnectionId(null)
|
||||
setRemoteFiles([])
|
||||
}, [connectionId, sftpId, getConnection])
|
||||
}, [connectionId, sftpId])
|
||||
|
||||
// Upload files (real implementation via IPC)
|
||||
const uploadFiles = useCallback(async (files: FileList | null) => {
|
||||
if (!files || files.length === 0 || !sftpId || !window.electronAPI) {
|
||||
logWarning('uploadFiles: missing files, sftpId, or electronAPI')
|
||||
logger.warn('uploadFiles: missing files, sftpId, or electronAPI')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -415,7 +392,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
try {
|
||||
for (const task of newTasks) {
|
||||
const remoteFilePath = `${remotePath}/${task.fileName}`.replace(/\/+/g, '/')
|
||||
logInfo('Uploading', `${task.filePath} -> ${remoteFilePath}`)
|
||||
logger.debug('Uploading', `${task.filePath} -> ${remoteFilePath}`)
|
||||
|
||||
// Mark as transferring
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
@@ -429,12 +406,12 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
||||
))
|
||||
logSuccess(`Upload complete: ${task.fileName}`)
|
||||
logger.info(`Upload complete: ${task.fileName}`)
|
||||
} else {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Upload failed' } : t
|
||||
))
|
||||
logError(`Upload failed: ${task.fileName} - ${result.error}`)
|
||||
logger.error(`Upload failed: ${task.fileName} - ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error('Upload failed', err)
|
||||
@@ -489,7 +466,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
try {
|
||||
for (const task of newTasks) {
|
||||
const localFilePath = `${localPath}/${task.fileName}`
|
||||
logInfo('Downloading', `${task.filePath} -> ${localFilePath}`)
|
||||
logger.debug('Downloading', `${task.filePath} -> ${localFilePath}`)
|
||||
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'transferring', startTime: Date.now() } : t
|
||||
@@ -502,12 +479,12 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
||||
))
|
||||
logSuccess(`Download complete: ${task.fileName}`)
|
||||
logger.info(`Download complete: ${task.fileName}`)
|
||||
} else {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Download failed' } : t
|
||||
))
|
||||
logError(`Download failed: ${task.fileName} - ${result.error}`)
|
||||
logger.error(`Download failed: ${task.fileName} - ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error('Download failed', err)
|
||||
@@ -527,27 +504,26 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
// Delete remote files/directories
|
||||
const deleteRemote = useCallback(async (files: FileItem[]): Promise<boolean> => {
|
||||
if (!sftpId || files.length === 0 || !window.electronAPI) {
|
||||
logWarning('deleteRemote: missing sftpId, files, or electronAPI')
|
||||
logger.warn('deleteRemote: missing sftpId, files, or electronAPI')
|
||||
return false
|
||||
}
|
||||
|
||||
let allSuccess = true
|
||||
|
||||
for (const file of files) {
|
||||
logInfo('Deleting remote', `${file.path} (isDir: ${file.type === 'directory'})`)
|
||||
logger.debug('Deleting remote', `${file.path} (isDir: ${file.type === 'directory'})`)
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sftpDelete(sftpId, file.path, file.type === 'directory')
|
||||
|
||||
if (result.success) {
|
||||
logSuccess(`Deleted: ${file.name}`)
|
||||
logger.info(`Deleted: ${file.name}`)
|
||||
} else {
|
||||
logError(`Delete failed: ${file.name} - ${result.error}`)
|
||||
logger.error(`Delete failed: ${file.name} - ${result.error}`)
|
||||
allSuccess = false
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error('Delete failed', err)
|
||||
logError(`Delete error: ${file.name} - ${err?.message}`)
|
||||
logger.error(`Delete error: ${file.name} - ${err?.message}`, err)
|
||||
allSuccess = false
|
||||
}
|
||||
}
|
||||
@@ -560,6 +536,76 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
return allSuccess
|
||||
}, [sftpId, remotePath, loadRemoteDirectory])
|
||||
|
||||
// Rename remote file/directory
|
||||
const renameRemote = useCallback(async (file: FileItem, newName: string): Promise<boolean> => {
|
||||
if (!sftpId || !window.electronAPI) {
|
||||
logger.warn('renameRemote: missing sftpId or electronAPI')
|
||||
return false
|
||||
}
|
||||
|
||||
const dirPath = file.path.substring(0, file.path.lastIndexOf('/'))
|
||||
const newPath = dirPath ? `${dirPath}/${newName}` : `/${newName}`
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sftpRename(sftpId, file.path, newPath)
|
||||
if (result.success) {
|
||||
logger.info(`Renamed: ${file.name} -> ${newName}`)
|
||||
loadRemoteDirectory(remotePath)
|
||||
return true
|
||||
} else {
|
||||
logger.error(`Rename failed: ${result.error}`)
|
||||
return false
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(`Rename error: ${err?.message}`, err)
|
||||
return false
|
||||
}
|
||||
}, [sftpId, remotePath, loadRemoteDirectory])
|
||||
|
||||
// Get remote file stat
|
||||
const getRemoteStat = useCallback(async (filePath: string) => {
|
||||
if (!sftpId || !window.electronAPI) {
|
||||
logger.warn('getRemoteStat: missing sftpId or electronAPI')
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sftpStat(sftpId, filePath)
|
||||
if (result.success && result.stats) {
|
||||
return result.stats
|
||||
} else {
|
||||
logger.error(`Stat failed: ${result.error}`)
|
||||
return null
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(`Stat error: ${err?.message}`, err)
|
||||
return null
|
||||
}
|
||||
}, [sftpId])
|
||||
|
||||
// Chmod remote file
|
||||
const chmodRemote = useCallback(async (filePath: string, mode: number): Promise<boolean> => {
|
||||
if (!sftpId || !window.electronAPI) {
|
||||
logger.warn('chmodRemote: missing sftpId or electronAPI')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sftpChmod(sftpId, filePath, mode)
|
||||
if (result.success) {
|
||||
logger.info(`Chmod success: ${filePath} -> ${mode.toString(8)}`)
|
||||
loadRemoteDirectory(remotePath)
|
||||
return true
|
||||
} else {
|
||||
logger.error(`Chmod failed: ${result.error}`)
|
||||
return false
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(`Chmod error: ${err?.message}`, err)
|
||||
return false
|
||||
}
|
||||
}, [sftpId, remotePath, loadRemoteDirectory])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -605,6 +651,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
|
||||
// Remote file operations
|
||||
deleteRemote,
|
||||
renameRemote,
|
||||
getRemoteStat,
|
||||
chmodRemote,
|
||||
|
||||
sortField,
|
||||
sortOrder,
|
||||
|
||||
Reference in new issue
Block a user