mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
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:
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user