feat: enhance SFTP transfer functionality with progress tracking and cancellation support

- Added helper functions to format transfer bytes and speed for better UI representation.
- Improved TransferItem component to display bytes transferred and remaining time.
- Implemented streaming transfer with real-time progress updates and cancellation capability in the Electron main process.
- Introduced IPC handlers for file operations including delete, rename, and stat for both SFTP and local filesystems.
- Updated preload script to handle transfer progress, completion, and error events.
- Enhanced global type definitions to support new streaming transfer API.
- Modified CSS animations for a smoother progress indication.
This commit is contained in:
LAPTOP-O016UC3M\Qi Chen
2025-12-08 21:03:31 +08:00
parent 0616ae9b23
commit 47ee220be7
7 changed files with 897 additions and 90 deletions
+397 -58
View File
@@ -107,14 +107,100 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
// SFTP session refs
const sftpSessionsRef = useRef<Map<string, string>>(new Map()); // connectionId -> sftpId
// Progress simulation refs
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(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<void> => {
// 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
+100 -30
View File
@@ -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 (
<div className="flex items-center gap-3 px-4 py-2.5 bg-background/60 border-t border-border/40 backdrop-blur-sm">
<div className="h-6 w-6 rounded flex items-center justify-center shrink-0">
{task.status === 'transferring' && <Loader2 size={14} className="animate-spin text-primary" />}
{task.status === 'pending' && <ArrowDown size={14} className="text-muted-foreground" />}
{task.status === 'pending' && <ArrowDown size={14} className="text-muted-foreground animate-bounce" />}
{task.status === 'completed' && <CheckCircle2 size={14} className="text-green-500" />}
{task.status === 'failed' && <XCircle size={14} className="text-destructive" />}
{task.status === 'cancelled' && <XCircle size={14} className="text-muted-foreground" />}
@@ -200,22 +223,54 @@ const TransferItem: React.FC<{
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm truncate">{task.fileName}</span>
<span className="text-sm truncate font-medium">{task.fileName}</span>
{task.status === 'transferring' && speedFormatted && (
<span className="text-xs text-muted-foreground">{speedFormatted}</span>
<span className="text-xs text-primary/80 font-mono">{speedFormatted}</span>
)}
{task.status === 'transferring' && remainingFormatted && (
<span className="text-xs text-muted-foreground">{remainingFormatted}</span>
)}
</div>
{task.status === 'transferring' && (
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-1.5 bg-secondary/80 rounded-full overflow-hidden">
{(task.status === 'transferring' || task.status === 'pending') && (
<div className="flex items-center gap-2 mt-1.5">
<div className="flex-1 h-2 bg-secondary/80 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary to-primary/80 rounded-full transition-all duration-300 ease-out relative"
style={{ width: `${progress}%` }}
className={cn(
"h-full rounded-full relative overflow-hidden",
task.status === 'pending'
? "bg-muted-foreground/50 animate-pulse"
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
)}
style={{
width: task.status === 'pending' ? '100%' : `${progress}%`,
transition: 'width 150ms ease-out'
}}
>
<div className="absolute inset-0 bg-white/20 animate-pulse" />
{/* Animated shine effect */}
{task.status === 'transferring' && (
<div
className="absolute inset-0 w-1/2 h-full"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.4) 50%, transparent 100%)',
animation: 'progress-shimmer 1.5s ease-in-out infinite',
}}
/>
)}
</div>
</div>
<span className="text-[10px] text-muted-foreground shrink-0 w-20 text-right">{Math.round(progress)}%</span>
<span className="text-[11px] text-muted-foreground shrink-0 min-w-[40px] text-right font-mono">
{task.status === 'pending' ? 'waiting...' : `${Math.round(progress)}%`}
</span>
</div>
)}
{task.status === 'transferring' && bytesDisplay && (
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
{bytesDisplay}
</div>
)}
{task.status === 'completed' && bytesDisplay && (
<div className="text-[10px] text-green-600 mt-0.5">
Completed • {bytesDisplay}
</div>
)}
{task.status === 'failed' && task.error && (
@@ -230,7 +285,7 @@ const TransferItem: React.FC<{
</Button>
)}
{(task.status === 'pending' || task.status === 'transferring') && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={onCancel} title="Cancel">
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
<X size={12} />
</Button>
)}
@@ -262,7 +317,8 @@ interface SftpPaneViewProps {
onCreateDirectory: (name: string) => Promise<void>;
onDeleteFiles: (fileNames: string[]) => Promise<void>;
onRenameFile: (oldName: string, newName: string) => Promise<void>;
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<SftpPaneViewProps> = ({
onCreateDirectory,
onDeleteFiles,
onRenameFile,
onStartTransfer,
onCopyToOtherPane,
onReceiveFromOtherPane,
onEditPermissions,
draggedFiles,
onDragStart,
@@ -383,7 +440,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
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<SftpPaneViewProps> = ({
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<SftpPaneViewProps> = ({
const file = displayFiles.find(f => f.name === name);
return { name, isDirectory: file?.type === 'directory' || false };
});
onStartTransfer(fileData);
onCopyToOtherPane(fileData);
}}>
<Copy size={14} className="mr-2" /> Copy to other pane
</ContextMenuItem>
@@ -1146,12 +1204,22 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ 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<SftpViewProps> = ({ 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<SftpViewProps> = ({ hosts, keys }) => {
onCreateDirectory={handleCreateDirectoryRight}
onDeleteFiles={handleDeleteFilesRight}
onRenameFile={handleRenameFileRight}
onStartTransfer={handleStartTransferRight}
onCopyToOtherPane={handleCopyToOtherPaneRight}
onReceiveFromOtherPane={handleReceiveFromOtherPaneRight}
onEditPermissions={handleEditPermissionsRight}
draggedFiles={draggedFiles}
onDragStart={handleDragStart}
+1
View File
@@ -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 {
+276
View File
@@ -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
+99
View File
@@ -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"),
Vendored
+17
View File
@@ -73,6 +73,23 @@ interface NebulaBridge {
cancelTransfer?(transferId: string): Promise<void>;
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<RemoteFile[]>;
readLocalFile?(path: string): Promise<ArrayBuffer>;
+7 -2
View File
@@ -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 {