diff --git a/application/state/useSftpState.ts b/application/state/useSftpState.ts index 99b6f748b..7db5259f7 100644 --- a/application/state/useSftpState.ts +++ b/application/state/useSftpState.ts @@ -107,14 +107,100 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { // SFTP session refs const sftpSessionsRef = useRef>(new Map()); // connectionId -> sftpId + // Progress simulation refs + const progressIntervalsRef = useRef>(new Map()); + + // Simulate progress for a transfer (used when real progress callbacks aren't available) + const startProgressSimulation = useCallback((taskId: string, estimatedBytes: number) => { + // Clear any existing interval for this task + const existing = progressIntervalsRef.current.get(taskId); + if (existing) clearInterval(existing); + + // Estimate transfer speed based on file size (simulate realistic speeds) + // Smaller files: faster perceived progress, larger files: slower but steady + const baseSpeed = Math.max(50000, Math.min(500000, estimatedBytes / 10)); // 50KB/s to 500KB/s base + const variability = 0.3; // 30% speed variation + + let transferred = 0; + const interval = setInterval(() => { + // Add some randomness to simulate real network conditions + const speedFactor = 1 + (Math.random() - 0.5) * variability; + const chunkSize = Math.floor(baseSpeed * speedFactor * 0.1); // Update every 100ms + transferred = Math.min(transferred + chunkSize, estimatedBytes); + + setTransfers(prev => prev.map(t => { + if (t.id !== taskId || t.status !== 'transferring') return t; + return { + ...t, + transferredBytes: transferred, + totalBytes: estimatedBytes, + speed: chunkSize * 10, // Convert to per-second + }; + })); + + // If we've reached the estimated size, slow down to show we're finishing + if (transferred >= estimatedBytes * 0.95) { + clearInterval(interval); + progressIntervalsRef.current.delete(taskId); + } + }, 100); + + progressIntervalsRef.current.set(taskId, interval); + }, []); + + const stopProgressSimulation = useCallback((taskId: string) => { + const interval = progressIntervalsRef.current.get(taskId); + if (interval) { + clearInterval(interval); + progressIntervalsRef.current.delete(taskId); + } + }, []); + + // Check if an error indicates a stale/lost SFTP session + const isSessionError = (err: unknown): boolean => { + if (!(err instanceof Error)) return false; + const msg = err.message.toLowerCase(); + return msg.includes('session not found') || + msg.includes('sftp session') || + msg.includes('not found') || + msg.includes('closed') || + msg.includes('connection reset'); + }; + + // Handle session error by clearing the connection state for a side + const handleSessionError = useCallback((side: 'left' | 'right', error: Error) => { + const pane = side === 'left' ? leftPane : rightPane; + const setPane = side === 'left' ? setLeftPane : setRightPane; + + if (pane.connection) { + // Clean up stale session reference + sftpSessionsRef.current.delete(pane.connection.id); + } + + setPane({ + connection: null, + files: [], + loading: false, + error: 'SFTP session lost. Please reconnect.', + selectedFiles: new Set(), + filter: '', + }); + }, [leftPane, rightPane]); + // Cleanup on unmount useEffect(() => { return () => { + // Clear all SFTP sessions sftpSessionsRef.current.forEach(async (sftpId) => { try { await window.nebula?.closeSftp(sftpId); } catch {} }); + // Clear all progress simulation intervals + progressIntervalsRef.current.forEach((interval) => { + clearInterval(interval); + }); + progressIntervalsRef.current.clear(); }; }, []); @@ -132,9 +218,21 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { // Connect to a host const connect = useCallback(async (side: 'left' | 'right', host: Host | 'local') => { + const currentPane = side === 'left' ? leftPane : rightPane; const setPane = side === 'left' ? setLeftPane : setRightPane; const connectionId = `${side}-${Date.now()}`; + // First, disconnect any existing connection + if (currentPane.connection && !currentPane.connection.isLocal) { + const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id); + if (oldSftpId) { + try { + await window.nebula?.closeSftp(oldSftpId); + } catch {} + sftpSessionsRef.current.delete(currentPane.connection.id); + } + } + if (host === 'local') { // Local filesystem connection // Try to get home directory from backend, fallback to platform-specific default @@ -244,7 +342,7 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { })); } } - }, [getHostCredentials]); + }, [getHostCredentials, leftPane, rightPane]); // Disconnect const disconnect = useCallback(async (side: 'left' | 'right') => { @@ -415,8 +513,39 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { files = await listLocalFiles(path); } else { const sftpId = sftpSessionsRef.current.get(pane.connection.id); - if (!sftpId) throw new Error('SFTP session not found'); - files = await listRemoteFiles(sftpId, path); + if (!sftpId) { + // Session lost - clear connection state + setPane({ + connection: null, + files: [], + loading: false, + error: 'SFTP session lost. Please reconnect.', + selectedFiles: new Set(), + filter: '', + }); + return; + } + + try { + files = await listRemoteFiles(sftpId, path); + } catch (err) { + // Check if it's a session error + const errorMsg = err instanceof Error ? err.message : 'Unknown error'; + if (errorMsg.includes('session') || errorMsg.includes('not found') || errorMsg.includes('closed')) { + // Clean up stale session reference + sftpSessionsRef.current.delete(pane.connection.id); + setPane({ + connection: null, + files: [], + loading: false, + error: 'SFTP session expired. Please reconnect.', + selectedFiles: new Set(), + filter: '', + }); + return; + } + throw err; + } } setPane(prev => ({ @@ -523,14 +652,21 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { await window.nebula?.mkdirLocal?.(fullPath); } else { const sftpId = sftpSessionsRef.current.get(pane.connection.id); - if (!sftpId) throw new Error('SFTP session not found'); + if (!sftpId) { + handleSessionError(side, new Error('SFTP session not found')); + return; + } await window.nebula?.mkdirSftp(sftpId, fullPath); } await refresh(side); } catch (err) { + if (isSessionError(err)) { + handleSessionError(side, err as Error); + return; + } throw err; } - }, [leftPane, rightPane, refresh]); + }, [leftPane, rightPane, refresh, handleSessionError]); // Delete files const deleteFiles = useCallback(async (side: 'left' | 'right', fileNames: string[]) => { @@ -545,15 +681,22 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { await window.nebula?.deleteLocalFile?.(fullPath); } else { const sftpId = sftpSessionsRef.current.get(pane.connection.id); - if (!sftpId) throw new Error('SFTP session not found'); + if (!sftpId) { + handleSessionError(side, new Error('SFTP session not found')); + return; + } await window.nebula?.deleteSftp?.(sftpId, fullPath); } } await refresh(side); } catch (err) { + if (isSessionError(err)) { + handleSessionError(side, err as Error); + return; + } throw err; } - }, [leftPane, rightPane, refresh]); + }, [leftPane, rightPane, refresh, handleSessionError]); // Rename file const renameFile = useCallback(async (side: 'left' | 'right', oldName: string, newName: string) => { @@ -568,14 +711,21 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { await window.nebula?.renameLocalFile?.(oldPath, newPath); } else { const sftpId = sftpSessionsRef.current.get(pane.connection.id); - if (!sftpId) throw new Error('SFTP session not found'); + if (!sftpId) { + handleSessionError(side, new Error('SFTP session not found')); + return; + } await window.nebula?.renameSftp?.(sftpId, oldPath, newPath); } await refresh(side); } catch (err) { + if (isSessionError(err)) { + handleSessionError(side, err as Error); + return; + } throw err; } - }, [leftPane, rightPane, refresh]); + }, [leftPane, rightPane, refresh, handleSessionError]); // Transfer files const startTransfer = useCallback(async ( @@ -590,15 +740,38 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { const sourcePath = sourcePane.connection.currentPath; const targetPath = targetPane.connection.currentPath; + + // Get SFTP session ID if remote + const sourceSftpId = sourcePane.connection.isLocal ? null : + sftpSessionsRef.current.get(sourcePane.connection.id); - // Create transfer tasks - const newTasks: TransferTask[] = sourceFiles.map(file => { + // Create transfer tasks with actual file sizes + const newTasks: TransferTask[] = []; + + for (const file of sourceFiles) { const direction: TransferDirection = sourcePane.connection!.isLocal && !targetPane.connection!.isLocal ? 'upload' : !sourcePane.connection!.isLocal && targetPane.connection!.isLocal ? 'download' : 'remote-to-remote'; - return { + // Get actual file size from source + let fileSize = 0; + if (!file.isDirectory) { + try { + const fullPath = joinPath(sourcePath, file.name); + if (sourcePane.connection!.isLocal) { + const stat = await window.nebula?.statLocal?.(fullPath); + if (stat) fileSize = stat.size; + } else if (sourceSftpId) { + const stat = await window.nebula?.statSftp?.(sourceSftpId, fullPath); + if (stat) fileSize = stat.size; + } + } catch { + // If stat fails, we'll use estimate later + } + } + + newTasks.push({ id: crypto.randomUUID(), fileName: file.name, sourcePath: joinPath(sourcePath, file.name), @@ -607,13 +780,13 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { targetConnectionId: targetPane.connection!.id, direction, status: 'pending' as TransferStatus, - totalBytes: 0, + totalBytes: fileSize, transferredBytes: 0, speed: 0, startTime: Date.now(), isDirectory: file.isDirectory, - }; - }); + }); + } setTransfers(prev => [...prev, ...newTasks]); @@ -633,7 +806,45 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { setTransfers(prev => prev.map(t => t.id === task.id ? { ...t, ...updates } : t)); }; - updateTask({ status: 'transferring' }); + // Get actual file size if not already known + let actualFileSize = task.totalBytes; + if (!task.isDirectory && actualFileSize === 0) { + try { + const sourceSftpId = sourcePane.connection?.isLocal ? null : + sftpSessionsRef.current.get(sourcePane.connection!.id); + + if (sourcePane.connection?.isLocal) { + const stat = await window.nebula?.statLocal?.(task.sourcePath); + if (stat) actualFileSize = stat.size; + } else if (sourceSftpId) { + const stat = await window.nebula?.statSftp?.(sourceSftpId, task.sourcePath); + if (stat) actualFileSize = stat.size; + } + } catch { + // Ignore stat errors, use estimate + } + } + + // Estimate file size for progress simulation (use a reasonable default if unknown) + const estimatedSize = actualFileSize > 0 ? actualFileSize : + task.isDirectory ? 1024 * 1024 : // 1MB estimate for directories + 256 * 1024; // 256KB default for files + + // Check if streaming transfer is available (will provide real progress) + const hasStreamingTransfer = !!window.nebula?.startStreamTransfer; + + updateTask({ + status: 'transferring', + totalBytes: estimatedSize, + transferredBytes: 0, + startTime: Date.now(), + }); + + // Only use simulated progress for directories or when streaming is not available + const useSimulatedProgress = task.isDirectory || !hasStreamingTransfer; + if (useSimulatedProgress) { + startProgressSimulation(task.id, estimatedSize); + } try { const sourceSftpId = sourcePane.connection?.isLocal ? null : @@ -642,10 +853,30 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { sftpSessionsRef.current.get(targetPane.connection!.id); // Check if file already exists at target (conflict detection) - if (!task.isDirectory) { + // Skip if user already resolved conflict with replace/duplicate + if (!task.isDirectory && !task.skipConflictCheck) { let targetExists = false; let existingStat: { size: number; mtime: number } | null = null; + let sourceStat: { size: number; mtime: number } | null = null; + // Get source file stat for accurate size and mtime + try { + if (sourcePane.connection?.isLocal) { + const stat = await window.nebula?.statLocal?.(task.sourcePath); + if (stat) { + sourceStat = { size: stat.size, mtime: stat.lastModified || Date.now() }; + } + } else if (sourceSftpId && window.nebula?.statSftp) { + const stat = await window.nebula.statSftp(sourceSftpId, task.sourcePath); + if (stat) { + sourceStat = { size: stat.size, mtime: stat.lastModified || Date.now() }; + } + } + } catch { + // Use estimated size if stat fails + } + + // Get target file stat to check for conflict try { if (targetPane.connection?.isLocal) { const stat = await window.nebula?.statLocal?.(task.targetPath); @@ -665,6 +896,9 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { } if (targetExists && existingStat) { + // Stop progress simulation while waiting for user decision + stopProgressSimulation(task.id); + // Add conflict for user to resolve const newConflict: FileConflict = { transferId: task.id, @@ -672,12 +906,12 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { sourcePath: task.sourcePath, targetPath: task.targetPath, existingSize: existingStat.size, - newSize: task.totalBytes, + newSize: sourceStat?.size || estimatedSize, // Use actual source size existingModified: existingStat.mtime, - newModified: Date.now(), + newModified: sourceStat?.mtime || Date.now(), // Use actual source mtime }; setConflicts(prev => [...prev, newConflict]); - updateTask({ status: 'pending' }); // Wait for user decision + updateTask({ status: 'pending', totalBytes: sourceStat?.size || estimatedSize }); // Wait for user decision return; } } @@ -690,28 +924,90 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { await transferFile(task, sourceSftpId, targetSftpId, sourcePane.connection!.isLocal, targetPane.connection!.isLocal); } - updateTask({ status: 'completed', endTime: Date.now() }); + // Stop progress simulation (only if it was started) + if (useSimulatedProgress) { + stopProgressSimulation(task.id); + } + + // Get the current state of the task to use accurate totalBytes + setTransfers(prev => prev.map(t => { + if (t.id !== task.id) return t; + return { + ...t, + status: 'completed' as TransferStatus, + endTime: Date.now(), + transferredBytes: t.totalBytes, // Use actual totalBytes from state + speed: 0, + }; + })); // Refresh target pane const targetSide = targetPane === leftPane ? 'left' : 'right'; await refresh(targetSide as 'left' | 'right'); } catch (err) { + // Stop progress simulation on failure (only if it was started) + if (useSimulatedProgress) { + stopProgressSimulation(task.id); + } updateTask({ status: 'failed', error: err instanceof Error ? err.message : 'Transfer failed', endTime: Date.now(), + speed: 0, }); } }; - // Transfer a single file + // Transfer a single file using streaming with real progress const transferFile = async ( task: TransferTask, sourceSftpId: string | null, targetSftpId: string | null, sourceIsLocal: boolean, targetIsLocal: boolean - ) => { + ): Promise => { + // Try to use streaming transfer if available + if (window.nebula?.startStreamTransfer) { + return new Promise((resolve, reject) => { + const options = { + transferId: task.id, + sourcePath: task.sourcePath, + targetPath: task.targetPath, + sourceType: sourceIsLocal ? 'local' as const : 'sftp' as const, + targetType: targetIsLocal ? 'local' as const : 'sftp' as const, + sourceSftpId: sourceSftpId || undefined, + targetSftpId: targetSftpId || undefined, + totalBytes: task.totalBytes || undefined, + }; + + const onProgress = (transferred: number, total: number, speed: number) => { + setTransfers(prev => prev.map(t => { + if (t.id !== task.id) return t; + // Check if cancelled + if (t.status === 'cancelled') return t; + return { + ...t, + transferredBytes: transferred, + totalBytes: total || t.totalBytes, + speed, + }; + })); + }; + + const onComplete = () => { + resolve(); + }; + + const onError = (error: string) => { + reject(new Error(error)); + }; + + window.nebula!.startStreamTransfer!(options, onProgress, onComplete, onError) + .catch(reject); + }); + } + + // Fallback to legacy transfer (read all then write all) let content: ArrayBuffer | string; // Read from source @@ -797,12 +1093,28 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { }; // Cancel transfer + // This will stop the streaming transfer at the backend level if supported const cancelTransfer = useCallback(async (transferId: string) => { + // Stop progress simulation (for directory transfers or fallback mode) + stopProgressSimulation(transferId); + + // Mark as cancelled setTransfers(prev => prev.map(t => t.id === transferId ? { ...t, status: 'cancelled' as TransferStatus, endTime: Date.now() } : t )); - await window.nebula?.cancelTransfer?.(transferId); - }, []); + + // Remove from conflicts if present + setConflicts(prev => prev.filter(c => c.transferId !== transferId)); + + // Cancel at backend level if streaming transfer is in progress + if (window.nebula?.cancelTransfer) { + try { + await window.nebula.cancelTransfer(transferId); + } catch (err) { + console.warn('Failed to cancel transfer at backend:', err); + } + } + }, [stopProgressSimulation]); // Retry failed transfer const retryTransfer = useCallback(async (transferId: string) => { @@ -831,43 +1143,66 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { }, []); // Handle file conflict - const resolveConflict = useCallback((conflictId: string, action: 'replace' | 'skip' | 'duplicate') => { + const resolveConflict = useCallback(async (conflictId: string, action: 'replace' | 'skip' | 'duplicate') => { const conflict = conflicts.find(c => c.transferId === conflictId); if (!conflict) return; // Remove from conflicts list setConflicts(prev => prev.filter(c => c.transferId !== conflictId)); - // Handle based on action - setTransfers(prev => prev.map(t => { - if (t.id !== conflictId) return t; - - switch (action) { - case 'skip': - // Mark as cancelled - return { ...t, status: 'cancelled' as TransferStatus }; - case 'replace': - // Mark as pending to continue transfer (will overwrite) - return { ...t, status: 'pending' as TransferStatus }; - case 'duplicate': - // Generate new name and update task - const ext = t.fileName.includes('.') ? '.' + t.fileName.split('.').pop() : ''; - const baseName = t.fileName.includes('.') - ? t.fileName.slice(0, t.fileName.lastIndexOf('.')) - : t.fileName; - const newName = `${baseName} (copy)${ext}`; - const newTargetPath = t.targetPath.replace(t.fileName, newName); - return { - ...t, - fileName: newName, - targetPath: newTargetPath, - status: 'pending' as TransferStatus - }; - default: - return t; - } - })); - }, [conflicts]); + // Find the task + const task = transfers.find(t => t.id === conflictId); + if (!task) return; + + if (action === 'skip') { + // Mark as cancelled + setTransfers(prev => prev.map(t => + t.id === conflictId ? { ...t, status: 'cancelled' as TransferStatus } : t + )); + return; + } + + // For replace or duplicate, we need to update the task and re-process + let updatedTask = { ...task }; + + if (action === 'duplicate') { + // Generate new name and update task + const ext = task.fileName.includes('.') ? '.' + task.fileName.split('.').pop() : ''; + const baseName = task.fileName.includes('.') + ? task.fileName.slice(0, task.fileName.lastIndexOf('.')) + : task.fileName; + const newName = `${baseName} (copy)${ext}`; + const newTargetPath = task.targetPath.replace(task.fileName, newName); + updatedTask = { + ...task, + fileName: newName, + targetPath: newTargetPath, + skipConflictCheck: true, // Skip check for new name + }; + } else if (action === 'replace') { + // For replace, we just need to skip the conflict check + updatedTask = { + ...task, + skipConflictCheck: true, // User explicitly chose to replace + }; + } + + // Update task status and re-process + setTransfers(prev => prev.map(t => + t.id === conflictId ? { ...updatedTask, status: 'pending' as TransferStatus } : t + )); + + // Find source and target panes and re-process transfer + const sourcePane = updatedTask.sourceConnectionId.startsWith('left') ? leftPane : rightPane; + const targetPane = updatedTask.targetConnectionId.startsWith('left') ? leftPane : rightPane; + + if (sourcePane.connection && targetPane.connection) { + // Small delay to ensure state is updated + setTimeout(async () => { + await processTransfer(updatedTask, sourcePane, targetPane); + }, 100); + } + }, [conflicts, transfers, leftPane, rightPane]); // Get filtered files const getFilteredFiles = (pane: SftpPane): SftpFileEntry[] => { @@ -895,7 +1230,7 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { const sftpId = sftpSessionsRef.current.get(pane.connection.id); if (!sftpId || !window.nebula?.chmodSftp) { - console.warn('chmod not available'); + handleSessionError(side, new Error('SFTP session not found')); return; } @@ -903,9 +1238,13 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => { await window.nebula.chmodSftp(sftpId, filePath, mode); await refresh(side); } catch (err) { + if (isSessionError(err)) { + handleSessionError(side, err as Error); + return; + } console.error('Failed to change permissions:', err); } - }, [leftPane, rightPane, refresh]); + }, [leftPane, rightPane, refresh, handleSessionError]); return { // Panes diff --git a/components/SftpViewNew.tsx b/components/SftpViewNew.tsx index 53c7cc5a8..908f8f710 100644 --- a/components/SftpViewNew.tsx +++ b/components/SftpViewNew.tsx @@ -166,6 +166,15 @@ const FileRow: React.FC<{ ); }; +// Helper to format bytes for transfer display +const formatTransferBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const size = bytes / Math.pow(1024, i); + return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +}; + // Transfer item component const TransferItem: React.FC<{ task: TransferTask; @@ -173,26 +182,40 @@ const TransferItem: React.FC<{ onRetry: () => void; onDismiss: () => void; }> = ({ task, onCancel, onRetry, onDismiss }) => { - const progress = task.totalBytes > 0 ? (task.transferredBytes / task.totalBytes) * 100 : 0; - const speedFormatted = task.speed > 0 - ? `${(task.speed / 1024).toFixed(1)} KB/s` - : ''; + const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0; + + // Format speed with appropriate unit + const formatSpeed = (bytesPerSecond: number): string => { + if (bytesPerSecond <= 0) return ''; + if (bytesPerSecond >= 1024 * 1024) { + return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`; + } + return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`; + }; + const speedFormatted = formatSpeed(task.speed); const remainingBytes = task.totalBytes - task.transferredBytes; const remainingTime = task.speed > 0 ? Math.ceil(remainingBytes / task.speed) : 0; const remainingFormatted = remainingTime > 60 - ? `~${Math.ceil(remainingTime / 60)} min remaining` + ? `~${Math.ceil(remainingTime / 60)}m left` : remainingTime > 0 - ? `~${remainingTime}s remaining` + ? `~${remainingTime}s left` + : ''; + + // Format bytes transferred / total + const bytesDisplay = task.status === 'transferring' && task.totalBytes > 0 + ? `${formatTransferBytes(task.transferredBytes)} / ${formatTransferBytes(task.totalBytes)}` + : task.status === 'completed' && task.totalBytes > 0 + ? formatTransferBytes(task.totalBytes) : ''; return (
{task.status === 'transferring' && } - {task.status === 'pending' && } + {task.status === 'pending' && } {task.status === 'completed' && } {task.status === 'failed' && } {task.status === 'cancelled' && } @@ -200,22 +223,54 @@ const TransferItem: React.FC<{
- {task.fileName} + {task.fileName} {task.status === 'transferring' && speedFormatted && ( - {speedFormatted} + {speedFormatted} + )} + {task.status === 'transferring' && remainingFormatted && ( + {remainingFormatted} )}
- {task.status === 'transferring' && ( -
-
+ {(task.status === 'transferring' || task.status === 'pending') && ( +
+
-
+ {/* Animated shine effect */} + {task.status === 'transferring' && ( +
+ )}
- {Math.round(progress)}% + + {task.status === 'pending' ? 'waiting...' : `${Math.round(progress)}%`} + +
+ )} + {task.status === 'transferring' && bytesDisplay && ( +
+ {bytesDisplay} +
+ )} + {task.status === 'completed' && bytesDisplay && ( +
+ Completed • {bytesDisplay}
)} {task.status === 'failed' && task.error && ( @@ -230,7 +285,7 @@ const TransferItem: React.FC<{ )} {(task.status === 'pending' || task.status === 'transferring') && ( - )} @@ -262,7 +317,8 @@ interface SftpPaneViewProps { onCreateDirectory: (name: string) => Promise; onDeleteFiles: (fileNames: string[]) => Promise; onRenameFile: (oldName: string, newName: string) => Promise; - onStartTransfer: (files: { name: string; isDirectory: boolean }[]) => void; + onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void; + onReceiveFromOtherPane: (files: { name: string; isDirectory: boolean }[]) => void; onEditPermissions?: (file: SftpFileEntry) => void; draggedFiles: { name: string; isDirectory: boolean; side: 'left' | 'right' }[] | null; onDragStart: (files: { name: string; isDirectory: boolean }[], side: 'left' | 'right') => void; @@ -286,7 +342,8 @@ const SftpPaneViewInner: React.FC = ({ onCreateDirectory, onDeleteFiles, onRenameFile, - onStartTransfer, + onCopyToOtherPane, + onReceiveFromOtherPane, onEditPermissions, draggedFiles, onDragStart, @@ -383,7 +440,8 @@ const SftpPaneViewInner: React.FC = ({ setDragOverEntry(null); if (!draggedFiles || draggedFiles[0]?.side === side) return; - onStartTransfer(draggedFiles.map(f => ({ name: f.name, isDirectory: f.isDirectory }))); + // Files are being dropped ON this pane FROM the other pane + onReceiveFromOtherPane(draggedFiles.map(f => ({ name: f.name, isDirectory: f.isDirectory }))); }; const handleFileDragStart = (entry: SftpFileEntry, e: React.DragEvent) => { @@ -424,9 +482,9 @@ const SftpPaneViewInner: React.FC = ({ if (!draggedFiles || draggedFiles[0]?.side === side) return; if (entry.type !== 'directory') return; - // Navigate to directory first, then transfer - // For now, just transfer to current directory - onStartTransfer(draggedFiles.map(f => ({ name: f.name, isDirectory: f.isDirectory }))); + // Files dropped ON a directory in this pane FROM the other pane + // For now, just transfer to current directory (TODO: transfer into the target directory) + onReceiveFromOtherPane(draggedFiles.map(f => ({ name: f.name, isDirectory: f.isDirectory }))); }; const openRenameDialog = (name: string) => { @@ -678,7 +736,7 @@ const SftpPaneViewInner: React.FC = ({ const file = displayFiles.find(f => f.name === name); return { name, isDirectory: file?.type === 'directory' || false }; }); - onStartTransfer(fileData); + onCopyToOtherPane(fileData); }}> Copy to other pane @@ -1146,12 +1204,22 @@ const SftpViewInner: React.FC = ({ hosts, keys }) => { setDraggedFiles(null); }, []); - const handleStartTransferLeft = useCallback((files: { name: string; isDirectory: boolean }[]) => { - sftp.startTransfer(files, 'left', 'right'); + // Copy to other pane: source is the current pane, target is the other pane + const handleCopyToOtherPaneLeft = useCallback((files: { name: string; isDirectory: boolean }[]) => { + sftp.startTransfer(files, 'left', 'right'); // from left to right }, [sftp.startTransfer]); - const handleStartTransferRight = useCallback((files: { name: string; isDirectory: boolean }[]) => { - sftp.startTransfer(files, 'right', 'left'); + const handleCopyToOtherPaneRight = useCallback((files: { name: string; isDirectory: boolean }[]) => { + sftp.startTransfer(files, 'right', 'left'); // from right to left + }, [sftp.startTransfer]); + + // Receive from other pane (drag-drop): source is other pane, target is this pane + const handleReceiveFromOtherPaneLeft = useCallback((files: { name: string; isDirectory: boolean }[]) => { + sftp.startTransfer(files, 'right', 'left'); // from right to left + }, [sftp.startTransfer]); + + const handleReceiveFromOtherPaneRight = useCallback((files: { name: string; isDirectory: boolean }[]) => { + sftp.startTransfer(files, 'left', 'right'); // from left to right }, [sftp.startTransfer]); // Pane-specific callbacks using useCallback @@ -1221,7 +1289,8 @@ const SftpViewInner: React.FC = ({ hosts, keys }) => { onCreateDirectory={handleCreateDirectoryLeft} onDeleteFiles={handleDeleteFilesLeft} onRenameFile={handleRenameFileLeft} - onStartTransfer={handleStartTransferLeft} + onCopyToOtherPane={handleCopyToOtherPaneLeft} + onReceiveFromOtherPane={handleReceiveFromOtherPaneLeft} onEditPermissions={handleEditPermissionsLeft} draggedFiles={draggedFiles} onDragStart={handleDragStart} @@ -1248,7 +1317,8 @@ const SftpViewInner: React.FC = ({ hosts, keys }) => { onCreateDirectory={handleCreateDirectoryRight} onDeleteFiles={handleDeleteFilesRight} onRenameFile={handleRenameFileRight} - onStartTransfer={handleStartTransferRight} + onCopyToOtherPane={handleCopyToOtherPaneRight} + onReceiveFromOtherPane={handleReceiveFromOtherPaneRight} onEditPermissions={handleEditPermissionsRight} draggedFiles={draggedFiles} onDragStart={handleDragStart} diff --git a/domain/models.ts b/domain/models.ts index 015ee95cc..67bae1b90 100755 --- a/domain/models.ts +++ b/domain/models.ts @@ -176,6 +176,7 @@ export interface TransferTask { isDirectory: boolean; childTasks?: string[]; // For directory transfers parentTaskId?: string; + skipConflictCheck?: boolean; // Skip conflict check for replace operations } export interface FileConflict { diff --git a/electron/main.cjs b/electron/main.cjs index c5cd430c5..547f251da 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -417,12 +417,288 @@ const registerSSHBridge = (win) => { return true; }; + // Delete file or directory via SFTP + const deleteSftp = async (_event, payload) => { + const client = sftpClients.get(payload.sftpId); + if (!client) throw new Error("SFTP session not found"); + + // Check if it's a directory or file + const stat = await client.stat(payload.path); + if (stat.isDirectory) { + await client.rmdir(payload.path, true); // recursive delete + } else { + await client.delete(payload.path); + } + return true; + }; + + // Rename file or directory via SFTP + const renameSftp = async (_event, payload) => { + const client = sftpClients.get(payload.sftpId); + if (!client) throw new Error("SFTP session not found"); + await client.rename(payload.oldPath, payload.newPath); + return true; + }; + + // Stat file via SFTP + const statSftp = async (_event, payload) => { + const client = sftpClients.get(payload.sftpId); + if (!client) throw new Error("SFTP session not found"); + const stat = await client.stat(payload.path); + return { + name: path.basename(payload.path), + type: stat.isDirectory ? "directory" : stat.isSymbolicLink ? "symlink" : "file", + size: stat.size, + lastModified: stat.modifyTime, + permissions: stat.mode ? (stat.mode & 0o777).toString(8) : undefined, + }; + }; + + // Change permissions via SFTP + const chmodSftp = async (_event, payload) => { + const client = sftpClients.get(payload.sftpId); + if (!client) throw new Error("SFTP session not found"); + await client.chmod(payload.path, parseInt(payload.mode, 8)); + return true; + }; + + // Local filesystem operations + const listLocalDir = async (_event, payload) => { + const dirPath = payload.path; + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + const result = []; + + for (const entry of entries) { + try { + const fullPath = path.join(dirPath, entry.name); + const stat = await fs.promises.stat(fullPath); + result.push({ + name: entry.name, + type: entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file", + size: `${stat.size} bytes`, + lastModified: stat.mtime.toISOString(), + }); + } catch (err) { + // Skip files we can't stat (permission denied, etc.) + console.warn(`Could not stat ${entry.name}:`, err.message); + } + } + return result; + }; + + const readLocalFile = async (_event, payload) => { + const buffer = await fs.promises.readFile(payload.path); + return buffer; + }; + + const writeLocalFile = async (_event, payload) => { + await fs.promises.writeFile(payload.path, Buffer.from(payload.content)); + return true; + }; + + const deleteLocalFile = async (_event, payload) => { + const stat = await fs.promises.stat(payload.path); + if (stat.isDirectory()) { + await fs.promises.rm(payload.path, { recursive: true, force: true }); + } else { + await fs.promises.unlink(payload.path); + } + return true; + }; + + const renameLocalFile = async (_event, payload) => { + await fs.promises.rename(payload.oldPath, payload.newPath); + return true; + }; + + const mkdirLocal = async (_event, payload) => { + await fs.promises.mkdir(payload.path, { recursive: true }); + return true; + }; + + const statLocal = async (_event, payload) => { + const stat = await fs.promises.stat(payload.path); + return { + name: path.basename(payload.path), + type: stat.isDirectory() ? "directory" : stat.isSymbolicLink() ? "symlink" : "file", + size: stat.size, + lastModified: stat.mtime.getTime(), + }; + }; + + const getHomeDir = async () => { + return os.homedir(); + }; + electronModule.ipcMain.handle("nebula:sftp:open", openSftp); electronModule.ipcMain.handle("nebula:sftp:list", listSftp); electronModule.ipcMain.handle("nebula:sftp:read", readSftp); electronModule.ipcMain.handle("nebula:sftp:write", writeSftp); electronModule.ipcMain.handle("nebula:sftp:close", closeSftp); electronModule.ipcMain.handle("nebula:sftp:mkdir", mkdirSftp); + electronModule.ipcMain.handle("nebula:sftp:delete", deleteSftp); + electronModule.ipcMain.handle("nebula:sftp:rename", renameSftp); + electronModule.ipcMain.handle("nebula:sftp:stat", statSftp); + electronModule.ipcMain.handle("nebula:sftp:chmod", chmodSftp); + + // Local filesystem handlers + electronModule.ipcMain.handle("nebula:local:list", listLocalDir); + electronModule.ipcMain.handle("nebula:local:read", readLocalFile); + electronModule.ipcMain.handle("nebula:local:write", writeLocalFile); + electronModule.ipcMain.handle("nebula:local:delete", deleteLocalFile); + electronModule.ipcMain.handle("nebula:local:rename", renameLocalFile); + electronModule.ipcMain.handle("nebula:local:mkdir", mkdirLocal); + electronModule.ipcMain.handle("nebula:local:stat", statLocal); + electronModule.ipcMain.handle("nebula:local:homedir", getHomeDir); + + // Streaming transfer with progress and cancellation support + const activeTransfers = new Map(); // transferId -> { cancelled: boolean, stream?: ReadableStream } + + const startTransfer = async (event, payload) => { + const { transferId, sourcePath, targetPath, sourceType, targetType, sourceSftpId, targetSftpId, totalBytes } = payload; + const sender = event.sender; + + // Register transfer for cancellation + activeTransfers.set(transferId, { cancelled: false }); + + const sendProgress = (transferred, speed) => { + if (!activeTransfers.get(transferId)?.cancelled) { + sender.send("nebula:transfer:progress", { transferId, transferred, speed, totalBytes }); + } + }; + + const sendComplete = () => { + activeTransfers.delete(transferId); + sender.send("nebula:transfer:complete", { transferId }); + }; + + const sendError = (error) => { + activeTransfers.delete(transferId); + sender.send("nebula:transfer:error", { transferId, error: error.message || String(error) }); + }; + + try { + let readStream; + let writeStream; + let fileSize = totalBytes || 0; + + // Create read stream based on source type + if (sourceType === 'local') { + if (!fileSize) { + const stat = await fs.promises.stat(sourcePath); + fileSize = stat.size; + } + readStream = fs.createReadStream(sourcePath); + } else if (sourceType === 'sftp') { + const client = sftpClients.get(sourceSftpId); + if (!client) throw new Error("Source SFTP session not found"); + if (!fileSize) { + const stat = await client.stat(sourcePath); + fileSize = stat.size; + } + // ssh2-sftp-client's get with stream + readStream = client.sftp.createReadStream(sourcePath); + } else { + throw new Error("Invalid source type"); + } + + // Create write stream based on target type + if (targetType === 'local') { + // Ensure directory exists + const dir = path.dirname(targetPath); + await fs.promises.mkdir(dir, { recursive: true }); + writeStream = fs.createWriteStream(targetPath); + } else if (targetType === 'sftp') { + const client = sftpClients.get(targetSftpId); + if (!client) throw new Error("Target SFTP session not found"); + // Ensure directory exists + const dir = path.dirname(targetPath).replace(/\\/g, '/'); + try { await client.mkdir(dir, true); } catch {} + writeStream = client.sftp.createWriteStream(targetPath); + } else { + throw new Error("Invalid target type"); + } + + // Store streams for potential cancellation + const transfer = activeTransfers.get(transferId); + if (transfer) { + transfer.readStream = readStream; + transfer.writeStream = writeStream; + } + + // Track progress + let transferred = 0; + let lastTime = Date.now(); + let lastTransferred = 0; + let speed = 0; + + readStream.on('data', (chunk) => { + // Check if cancelled + if (activeTransfers.get(transferId)?.cancelled) { + readStream.destroy(); + writeStream.destroy(); + return; + } + + transferred += chunk.length; + + // Calculate speed every 200ms + const now = Date.now(); + const elapsed = now - lastTime; + if (elapsed >= 200) { + speed = Math.round((transferred - lastTransferred) / (elapsed / 1000)); + lastTime = now; + lastTransferred = transferred; + sendProgress(transferred, speed); + } + }); + + readStream.on('error', (err) => { + writeStream.destroy(); + sendError(err); + }); + + writeStream.on('error', (err) => { + readStream.destroy(); + sendError(err); + }); + + writeStream.on('finish', () => { + if (!activeTransfers.get(transferId)?.cancelled) { + // Send final progress with 100% + sendProgress(fileSize, speed); + sendComplete(); + } + }); + + // Pipe read to write + readStream.pipe(writeStream); + + return { transferId, totalBytes: fileSize }; + } catch (err) { + sendError(err); + return { transferId, error: err.message }; + } + }; + + const cancelTransfer = async (_event, payload) => { + const { transferId } = payload; + const transfer = activeTransfers.get(transferId); + if (transfer) { + transfer.cancelled = true; + if (transfer.readStream) { + try { transfer.readStream.destroy(); } catch {} + } + if (transfer.writeStream) { + try { transfer.writeStream.destroy(); } catch {} + } + activeTransfers.delete(transferId); + } + return { success: true }; + }; + + electronModule.ipcMain.handle("nebula:transfer:start", startTransfer); + electronModule.ipcMain.handle("nebula:transfer:cancel", cancelTransfer); }; // Store reference to main window for theme updates diff --git a/electron/preload.cjs b/electron/preload.cjs index 6c4d1732a..85ec56387 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -2,6 +2,9 @@ const { ipcRenderer, contextBridge } = require("electron"); const dataListeners = new Map(); const exitListeners = new Map(); +const transferProgressListeners = new Map(); +const transferCompleteListeners = new Map(); +const transferErrorListeners = new Map(); ipcRenderer.on("nebula:data", (_event, payload) => { const set = dataListeners.get(payload.sessionId); @@ -30,6 +33,48 @@ ipcRenderer.on("nebula:exit", (_event, payload) => { exitListeners.delete(payload.sessionId); }); +// Transfer progress events +ipcRenderer.on("nebula:transfer:progress", (_event, payload) => { + const cb = transferProgressListeners.get(payload.transferId); + if (cb) { + try { + cb(payload.transferred, payload.totalBytes, payload.speed); + } catch (err) { + console.error("Transfer progress callback failed", err); + } + } +}); + +ipcRenderer.on("nebula:transfer:complete", (_event, payload) => { + const cb = transferCompleteListeners.get(payload.transferId); + if (cb) { + try { + cb(); + } catch (err) { + console.error("Transfer complete callback failed", err); + } + } + // Cleanup listeners + transferProgressListeners.delete(payload.transferId); + transferCompleteListeners.delete(payload.transferId); + transferErrorListeners.delete(payload.transferId); +}); + +ipcRenderer.on("nebula:transfer:error", (_event, payload) => { + const cb = transferErrorListeners.get(payload.transferId); + if (cb) { + try { + cb(payload.error); + } catch (err) { + console.error("Transfer error callback failed", err); + } + } + // Cleanup listeners + transferProgressListeners.delete(payload.transferId); + transferCompleteListeners.delete(payload.transferId); + transferErrorListeners.delete(payload.transferId); +}); + const api = { startSSHSession: async (options) => { const result = await ipcRenderer.invoke("nebula:start", options); @@ -80,9 +125,63 @@ const api = { mkdirSftp: async (sftpId, path) => { return ipcRenderer.invoke("nebula:sftp:mkdir", { sftpId, path }); }, + deleteSftp: async (sftpId, path) => { + return ipcRenderer.invoke("nebula:sftp:delete", { sftpId, path }); + }, + renameSftp: async (sftpId, oldPath, newPath) => { + return ipcRenderer.invoke("nebula:sftp:rename", { sftpId, oldPath, newPath }); + }, + statSftp: async (sftpId, path) => { + return ipcRenderer.invoke("nebula:sftp:stat", { sftpId, path }); + }, + chmodSftp: async (sftpId, path, mode) => { + return ipcRenderer.invoke("nebula:sftp:chmod", { sftpId, path, mode }); + }, + // Local filesystem operations + listLocalDir: async (path) => { + return ipcRenderer.invoke("nebula:local:list", { path }); + }, + readLocalFile: async (path) => { + return ipcRenderer.invoke("nebula:local:read", { path }); + }, + writeLocalFile: async (path, content) => { + return ipcRenderer.invoke("nebula:local:write", { path, content }); + }, + deleteLocalFile: async (path) => { + return ipcRenderer.invoke("nebula:local:delete", { path }); + }, + renameLocalFile: async (oldPath, newPath) => { + return ipcRenderer.invoke("nebula:local:rename", { oldPath, newPath }); + }, + mkdirLocal: async (path) => { + return ipcRenderer.invoke("nebula:local:mkdir", { path }); + }, + statLocal: async (path) => { + return ipcRenderer.invoke("nebula:local:stat", { path }); + }, + getHomeDir: async () => { + return ipcRenderer.invoke("nebula:local:homedir"); + }, setTheme: async (theme) => { return ipcRenderer.invoke("nebula:setTheme", theme); }, + // Streaming transfer with real progress + startStreamTransfer: async (options, onProgress, onComplete, onError) => { + const { transferId } = options; + // Register callbacks + if (onProgress) transferProgressListeners.set(transferId, onProgress); + if (onComplete) transferCompleteListeners.set(transferId, onComplete); + if (onError) transferErrorListeners.set(transferId, onError); + + return ipcRenderer.invoke("nebula:transfer:start", options); + }, + cancelTransfer: async (transferId) => { + // Cleanup listeners + transferProgressListeners.delete(transferId); + transferCompleteListeners.delete(transferId); + transferErrorListeners.delete(transferId); + return ipcRenderer.invoke("nebula:transfer:cancel", { transferId }); + }, // Window controls for custom title bar windowMinimize: () => ipcRenderer.invoke("nebula:window:minimize"), windowMaximize: () => ipcRenderer.invoke("nebula:window:maximize"), diff --git a/global.d.ts b/global.d.ts index f8e917f8b..32c9bb069 100644 --- a/global.d.ts +++ b/global.d.ts @@ -73,6 +73,23 @@ interface NebulaBridge { cancelTransfer?(transferId: string): Promise; onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void; + // Streaming transfer with real progress and cancellation + startStreamTransfer?( + options: { + transferId: string; + sourcePath: string; + targetPath: string; + sourceType: 'local' | 'sftp'; + targetType: 'local' | 'sftp'; + sourceSftpId?: string; + targetSftpId?: string; + totalBytes?: number; + }, + onProgress?: (transferred: number, total: number, speed: number) => void, + onComplete?: () => void, + onError?: (error: string) => void + ): Promise<{ transferId: string; totalBytes?: number; error?: string }>; + // Local filesystem operations listLocalDir?(path: string): Promise; readLocalFile?(path: string): Promise; diff --git a/index.css b/index.css index c6cab51f2..5c12d3ea4 100644 --- a/index.css +++ b/index.css @@ -29,8 +29,13 @@ body { } @keyframes shimmer { - 0% { background-position: -120px 0; } - 100% { background-position: 120px 0; } + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +@keyframes progress-shimmer { + 0% { transform: translateX(-200%); } + 100% { transform: translateX(200%); } } .glass-panel {