feat(v1.1): Toast通知系统 + 自定义对话框 + 终端搜索 + SFTP真实实现 + 标签持久化

任务7: Toast通知系统 — ToastProvider+4类型(success/error/warning/info)+右上角堆叠动画
任务8: 替换alert/confirm/prompt — ConfirmDialog+PromptDialog+useToast替换7处原生对话框
任务9: 终端搜索 — xterm SearchAddon Ctrl+F搜索栏(上一个/下一个/关闭)
任务10: SFTP上传/删除 — useSFTP.ts upload/delete方法实现
任务11: 会话恢复 — tabStore持久化到localStorage,启动恢复tabs

14 files changed, 1218 insertions(+), 241 deletions(-)
This commit is contained in:
OpsXShell Dev committed 2026-04-18 16:21:10 +08:00
1 parent 53274e2291
commit d344900118
14 files changed
+1224 -247

No files matched your search

+165 -58
View File
@@ -75,6 +75,9 @@ export interface UseSFTPReturn {
uploadFiles: (files: FileList | null) => Promise<void>
downloadFiles: () => Promise<void>
// 远程文件操作
deleteRemote: (files: FileItem[]) => Promise<boolean>
// 排序
sortField: SortField
sortOrder: SortOrder
@@ -372,89 +375,190 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
setRemoteFiles([])
}, [connectionId, sftpId, getConnection])
// Upload files
// Upload files (real implementation via IPC)
const uploadFiles = useCallback(async (files: FileList | null) => {
if (!files || !sftpId) return
if (!files || files.length === 0 || !sftpId || !window.electronAPI) {
logWarning('uploadFiles: missing files, sftpId, or electronAPI')
return
}
const newTasks: TransferTask[] = Array.from(files).map(file => ({
id: `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: 'upload',
type: 'upload' as const,
fileName: file.name,
filePath: `${localPath}/${file.name}`,
status: 'pending',
status: 'pending' as const,
progress: 0,
total: file.size,
transferred: 0,
}))
setTransferQueue(prev => [...prev, ...newTasks])
// Process uploads
for (const task of newTasks) {
try {
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'transferring' } : t
))
// Note: Actual upload implementation would go here
// This is a simplified version
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
))
} catch (err) {
logger.error('Upload failed', err)
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'failed', error: 'Upload failed' } : t
))
}
}
}, [sftpId, localPath, remotePath])
// Download files
setTransferQueue(prev => [...prev, ...newTasks])
// Set up progress listener scoped to this batch
const progressHandler = (_event: any, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => {
// Update the first task that is currently transferring
setTransferQueue(prev => {
const idx = prev.findIndex(t => t.type === 'upload' && t.status === 'transferring')
if (idx === -1) return prev
const t = prev[idx]
return [
...prev.slice(0, idx),
{ ...t, transferred: progress.transferred, total: progress.total, progress: progress.percentage },
...prev.slice(idx + 1),
]
})
}
window.electronAPI.onSFTPProgress(progressHandler)
try {
for (const task of newTasks) {
const remoteFilePath = `${remotePath}/${task.fileName}`.replace(/\/+/g, '/')
logInfo('Uploading', `${task.filePath} -> ${remoteFilePath}`)
// Mark as transferring
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'transferring', startTime: Date.now() } : t
))
try {
const result = await window.electronAPI.sftpUpload(sftpId, task.filePath, remoteFilePath)
if (result.success) {
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
))
logSuccess(`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}`)
}
} catch (err: any) {
logger.error('Upload failed', err)
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'failed', error: err?.message || 'Upload failed' } : t
))
}
}
// Refresh remote directory after all uploads
loadRemoteDirectory(remotePath)
} finally {
window.electronAPI.removeListener?.('sftp:progress', progressHandler)
}
}, [sftpId, localPath, remotePath, loadRemoteDirectory])
// Download files (real implementation via IPC)
const downloadFiles = useCallback(async () => {
if (!sftpId || remoteSelected.size === 0) return
if (!sftpId || remoteSelected.size === 0 || !window.electronAPI) return
const filesToDownload = remoteFiles.filter(f => remoteSelected.has(f.name))
const newTasks: TransferTask[] = filesToDownload.map(file => ({
id: `download-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: 'download',
type: 'download' as const,
fileName: file.name,
filePath: file.path,
status: 'pending',
status: 'pending' as const,
progress: 0,
total: file.size,
transferred: 0,
}))
setTransferQueue(prev => [...prev, ...newTasks])
// Process downloads
for (const task of newTasks) {
try {
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'transferring' } : t
// Set up progress listener
const progressHandler = (_event: any, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => {
setTransferQueue(prev => {
const idx = prev.findIndex(t => t.type === 'download' && t.status === 'transferring')
if (idx === -1) return prev
const t = prev[idx]
return [
...prev.slice(0, idx),
{ ...t, transferred: progress.transferred, total: progress.total, progress: progress.percentage },
...prev.slice(idx + 1),
]
})
}
window.electronAPI.onSFTPProgress(progressHandler)
try {
for (const task of newTasks) {
const localFilePath = `${localPath}/${task.fileName}`
logInfo('Downloading', `${task.filePath} -> ${localFilePath}`)
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'transferring', startTime: Date.now() } : t
))
if (window.electronAPI) {
await window.electronAPI.sftpDownload(sftpId, task.filePath, `${localPath}/${task.fileName}`)
try {
const result = await window.electronAPI.sftpDownload(sftpId, task.filePath, localFilePath)
if (result.success) {
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
))
logSuccess(`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}`)
}
} catch (err: any) {
logger.error('Download failed', err)
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'failed', error: err?.message || 'Download failed' } : t
))
}
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
))
} catch (err) {
logger.error('Download failed', err)
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'failed', error: 'Download failed' } : t
))
}
// Refresh local directory after all downloads
loadLocalDirectory(localPath)
} finally {
window.electronAPI.removeListener?.('sftp:progress', progressHandler)
}
}, [sftpId, remoteFiles, remoteSelected, localPath, loadLocalDirectory])
// 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')
return false
}
let allSuccess = true
for (const file of files) {
logInfo('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}`)
} else {
logError(`Delete failed: ${file.name} - ${result.error}`)
allSuccess = false
}
} catch (err: any) {
logger.error('Delete failed', err)
logError(`Delete error: ${file.name} - ${err?.message}`)
allSuccess = false
}
}
// Refresh local directory
loadLocalDirectory(localPath)
}, [sftpId, remoteFiles, remoteSelected, localPath, loadLocalDirectory])
// Refresh remote directory after deletion
if (allSuccess || files.length > 0) {
loadRemoteDirectory(remotePath)
}
return allSuccess
}, [sftpId, remotePath, loadRemoteDirectory])
// Cleanup on unmount
useEffect(() => {
@@ -499,6 +603,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
uploadFiles,
downloadFiles,
// Remote file operations
deleteRemote,
sortField,
sortOrder,
setSortField,