Files
Netcatty/lib/uploadService.ts
T
Rice 2a67667c0b feat(uploadService): add fallback handling for compressed upload failures
- Add try-catch wrapper around uploadFoldersCompressed to handle compression failures gracefully
- Implement fallback to regular upload when compressed upload is not supported
- Check for failed folders in compressed results and trigger full fallback if needed
- Return error indicator from uploadFoldersCompressed instead of attempting inline fallback
- Improve error logging to distinguish between compression support issues and other failures
- Ensure all entries are uploaded via regular upload path when compression is unavailable
- This prevents upload failures when the server doesn't support compressed folder uploads
2026-01-29 12:27:05 +08:00

1040 lines
37 KiB
TypeScript

/**
* Shared Upload Service
*
* Provides core upload logic for both SftpView and SftpModal components.
* Handles bundled folder uploads with aggregate progress tracking,
* cancellation support, and works for both local and remote (SFTP) uploads.
*/
import { extractDropEntries, DropEntry, getPathForFile } from "./sftpFileUtils";
// ============================================================================
// Types
// ============================================================================
export interface UploadProgress {
transferred: number;
total: number;
speed: number;
/** Percentage (0-100) */
percent: number;
}
export interface UploadTaskInfo {
id: string;
fileName: string;
/** Display name for bundled tasks (e.g., "folder (5 files)") */
displayName: string;
isDirectory: boolean;
totalBytes: number;
transferredBytes: number;
speed: number;
fileCount: number;
completedCount: number;
}
export interface UploadResult {
fileName: string;
success: boolean;
error?: string;
cancelled?: boolean;
}
export interface UploadCallbacks {
/** Called when a new task is created (for bundled folders or standalone files) */
onTaskCreated?: (task: UploadTaskInfo) => void;
/** Called when task progress is updated */
onTaskProgress?: (taskId: string, progress: UploadProgress) => void;
/** Called when a task is completed */
onTaskCompleted?: (taskId: string, totalBytes: number) => void;
/** Called when a task fails */
onTaskFailed?: (taskId: string, error: string) => void;
/** Called when a task is cancelled */
onTaskCancelled?: (taskId: string) => void;
/** Called when scanning starts (for showing placeholder) */
onScanningStart?: (taskId: string) => void;
/** Called when scanning ends */
onScanningEnd?: (taskId: string) => void;
/** Called when task name needs to be updated (for phase changes) */
onTaskNameUpdate?: (taskId: string, newName: string) => void;
}
export interface UploadBridge {
writeLocalFile?: (path: string, data: ArrayBuffer) => Promise<void>;
mkdirLocal?: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
writeSftpBinary?: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
writeSftpBinaryWithProgress?: (
sftpId: string,
path: string,
data: ArrayBuffer,
taskId: string,
onProgress: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
) => Promise<{ success: boolean; cancelled?: boolean } | undefined>;
cancelSftpUpload?: (taskId: string) => Promise<unknown>;
/** Stream transfer using local file path (avoids loading file into memory) */
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; cancelled?: boolean }>;
cancelTransfer?: (transferId: string) => Promise<void>;
}
export interface UploadConfig {
/** Target directory path */
targetPath: string;
/** SFTP session ID (null for local) */
sftpId: string | null;
/** Is this a local file system upload? */
isLocal: boolean;
/** The bridge for file operations */
bridge: UploadBridge;
/** Path joining function */
joinPath: (base: string, name: string) => string;
/** Callbacks for progress updates */
callbacks?: UploadCallbacks;
/** Use compressed upload for folders (requires tar on both local and remote) */
useCompressedUpload?: boolean;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Detect root folders from drop entries for bundled task creation
*/
export function detectRootFolders(entries: DropEntry[]): Map<string, DropEntry[]> {
const rootFolders = new Map<string, DropEntry[]>();
for (const entry of entries) {
const parts = entry.relativePath.split('/');
const rootName = parts[0];
// Group if there's more than one part (from a folder) or the entry is a directory
if (parts.length > 1 || entry.isDirectory) {
if (!rootFolders.has(rootName)) {
rootFolders.set(rootName, []);
}
rootFolders.get(rootName)!.push(entry);
} else {
// Standalone file - use its name as key with special prefix
const key = `__file__${entry.relativePath}`;
rootFolders.set(key, [entry]);
}
}
return rootFolders;
}
/**
* Sort entries: directories first, then by path depth
*/
export function sortEntries(entries: DropEntry[]): DropEntry[] {
return [...entries].sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
const aDepth = a.relativePath.split('/').length;
const bDepth = b.relativePath.split('/').length;
return aDepth - bDepth;
});
}
// ============================================================================
// Upload Controller
// ============================================================================
/**
* Controller for managing upload operations with cancellation support
*/
export class UploadController {
private cancelled = false;
private activeFileTransferIds = new Set<string>();
private currentTransferId = "";
private bridge: UploadBridge | null = null;
/**
* Cancel all active uploads
*/
async cancel(): Promise<void> {
this.cancelled = true;
console.log('[UploadController] Cancelling uploads, active IDs:', Array.from(this.activeFileTransferIds));
// Cancel all active file uploads
const activeIds = Array.from(this.activeFileTransferIds);
for (const transferId of activeIds) {
try {
// Try cancelTransfer first (for stream transfers)
if (this.bridge?.cancelTransfer) {
console.log('[UploadController] Calling cancelTransfer for:', transferId);
await this.bridge.cancelTransfer(transferId);
}
// Also try cancelSftpUpload (for legacy uploads)
if (this.bridge?.cancelSftpUpload) {
console.log('[UploadController] Calling cancelSftpUpload for:', transferId);
await this.bridge.cancelSftpUpload(transferId);
}
} catch (e) {
console.log('[UploadController] Cancel error:', e);
// Ignore cancel errors
}
}
// Also cancel current one if not in the set
if (this.currentTransferId && !activeIds.includes(this.currentTransferId)) {
try {
if (this.bridge?.cancelTransfer) {
console.log('[UploadController] Calling cancelTransfer for current:', this.currentTransferId);
await this.bridge.cancelTransfer(this.currentTransferId);
}
if (this.bridge?.cancelSftpUpload) {
console.log('[UploadController] Calling cancelSftpUpload for current:', this.currentTransferId);
await this.bridge.cancelSftpUpload(this.currentTransferId);
}
} catch (e) {
console.log('[UploadController] Cancel current error:', e);
// Ignore cancel errors
}
}
}
/**
* Check if upload was cancelled
*/
isCancelled(): boolean {
return this.cancelled;
}
/**
* Get all active transfer IDs
*/
getActiveTransferIds(): string[] {
const ids = Array.from(this.activeFileTransferIds);
if (this.currentTransferId && !ids.includes(this.currentTransferId)) {
ids.push(this.currentTransferId);
}
return ids;
}
/**
* Reset controller state for new upload
*/
reset(): void {
this.cancelled = false;
this.activeFileTransferIds.clear();
this.currentTransferId = "";
}
/**
* Set the bridge for cancellation
*/
setBridge(bridge: UploadBridge): void {
this.bridge = bridge;
}
/**
* Track a file transfer ID
*/
addActiveTransfer(id: string): void {
this.activeFileTransferIds.add(id);
this.currentTransferId = id;
}
/**
* Remove a tracked file transfer ID
*/
removeActiveTransfer(id: string): void {
this.activeFileTransferIds.delete(id);
if (this.currentTransferId === id) {
this.currentTransferId = "";
}
}
/**
* Clear current transfer ID
*/
clearCurrentTransfer(): void {
this.currentTransferId = "";
}
}
// ============================================================================
// Core Upload Function
// ============================================================================
/**
* Upload files from a DataTransfer object with bundled folder support
*
* @param dataTransfer - The DataTransfer object from a drop event
* @param config - Upload configuration
* @param controller - Optional upload controller for cancellation
* @returns Array of upload results
*/
export async function uploadFromDataTransfer(
dataTransfer: DataTransfer,
config: UploadConfig,
controller?: UploadController
): Promise<UploadResult[]> {
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks } = config;
// Reset controller if provided
if (controller) {
controller.reset();
controller.setBridge(bridge);
}
// Create scanning placeholder
const scanningTaskId = crypto.randomUUID();
callbacks?.onScanningStart?.(scanningTaskId);
let entries: DropEntry[];
try {
entries = await extractDropEntries(dataTransfer);
} finally {
callbacks?.onScanningEnd?.(scanningTaskId);
}
if (entries.length === 0) {
return [];
}
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
/**
* Upload a FileList or File array with bundled folder support
*/
export async function uploadFromFileList(
fileList: FileList | File[],
config: UploadConfig,
controller?: UploadController
): Promise<UploadResult[]> {
console.log('[uploadFromFileList] Called with', fileList.length, 'files');
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload } = config;
console.log('[uploadFromFileList] Config:', { targetPath, sftpId, isLocal, useCompressedUpload });
if (controller) {
controller.reset();
controller.setBridge(bridge);
}
// Convert FileList to DropEntry array
// Use webkitRelativePath for folder uploads, fallback to file.name for regular file uploads
const entries: DropEntry[] = Array.from(fileList).map(file => {
const localPath = getPathForFile(file);
// Use webkitRelativePath if available (folder upload), otherwise use file.name (regular file upload)
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
console.log('[uploadFromFileList] File:', {
name: file.name,
size: file.size,
localPath,
webkitRelativePath: (file as File & { webkitRelativePath?: string }).webkitRelativePath,
relativePath
});
if (localPath) {
// Set the path property on the file for stream transfer
(file as File & { path?: string }).path = localPath;
}
return {
file,
relativePath,
isDirectory: false,
};
});
console.log('[uploadFromFileList] Created', entries.length, 'entries');
if (entries.length === 0) {
console.log('[uploadFromFileList] No entries, returning empty');
return [];
}
// Check if this is a folder upload and compressed upload is enabled
if (useCompressedUpload && !isLocal && sftpId) {
const rootFolders = detectRootFolders(entries);
const folderEntries = Array.from(rootFolders.entries()).filter(([key]) => !key.startsWith("__file__"));
if (folderEntries.length > 0) {
console.log('[uploadFromFileList] Using compressed upload for folders:', folderEntries.map(([key]) => key));
try {
const compressedResults = await uploadFoldersCompressed(folderEntries, entries, targetPath, sftpId, callbacks, controller);
// Check if any folders failed due to lack of compression support
const failedFolders = compressedResults.filter(result =>
!result.success && result.error === "Compressed upload not supported - fallback needed"
);
if (failedFolders.length > 0) {
console.log('[uploadFromFileList] Some folders failed compressed upload, falling back to regular upload');
// Fall back to regular upload for all entries
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
return compressedResults;
} catch (error) {
console.log('[uploadFromFileList] Compressed upload failed, falling back to regular upload:', error);
// Fall back to regular upload
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
}
}
console.log('[uploadFromFileList] Using regular upload');
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
/**
* Core upload logic for entries
*/
async function uploadEntries(
entries: DropEntry[],
targetPath: string,
sftpId: string | null,
isLocal: boolean,
bridge: UploadBridge,
joinPath: (base: string, name: string) => string,
callbacks?: UploadCallbacks,
controller?: UploadController
): Promise<UploadResult[]> {
const results: UploadResult[] = [];
const createdDirs = new Set<string>();
const ensureDirectory = async (dirPath: string) => {
if (createdDirs.has(dirPath)) return;
try {
if (isLocal) {
if (bridge.mkdirLocal) {
await bridge.mkdirLocal(dirPath);
}
} else if (sftpId) {
await bridge.mkdirSftp(sftpId, dirPath);
}
createdDirs.add(dirPath);
} catch {
createdDirs.add(dirPath);
}
};
// Group entries by root folder
const rootFolders = detectRootFolders(entries);
const sortedEntries = sortEntries(entries);
let wasCancelled = false;
const yieldToMain = () => new Promise<void>(resolve => setTimeout(resolve, 0));
// Track bundled task progress
const bundleProgress = new Map<string, {
totalBytes: number;
transferredBytes: number;
fileCount: number;
completedCount: number;
currentSpeed: number;
completedFilesBytes: number;
}>();
// Create bundled tasks for each root folder
const bundleTaskIds = new Map<string, string>(); // rootName -> bundleTaskId
for (const [rootName, rootEntries] of rootFolders) {
const isStandaloneFile = rootName.startsWith("__file__");
if (isStandaloneFile) continue;
// Calculate total bytes for this folder
let totalBytes = 0;
let fileCount = 0;
for (const entry of rootEntries) {
if (!entry.isDirectory && entry.file) {
totalBytes += entry.file.size;
fileCount++;
}
}
if (fileCount === 0) continue;
const bundleTaskId = crypto.randomUUID();
bundleTaskIds.set(rootName, bundleTaskId);
bundleProgress.set(bundleTaskId, {
totalBytes,
transferredBytes: 0,
fileCount,
completedCount: 0,
currentSpeed: 0,
completedFilesBytes: 0,
});
// Notify task created
if (callbacks?.onTaskCreated) {
const displayName = fileCount === 1 ? rootName : `${rootName} (${fileCount} files)`;
callbacks.onTaskCreated({
id: bundleTaskId,
fileName: rootName,
displayName,
isDirectory: true,
totalBytes,
transferredBytes: 0,
speed: 0,
fileCount,
completedCount: 0,
});
}
}
// Helper to get bundle task ID for an entry
const getBundleTaskId = (entry: DropEntry): string | null => {
const parts = entry.relativePath.split('/');
const rootName = parts[0];
if (parts.length > 1 || entry.isDirectory) {
return bundleTaskIds.get(rootName) || null;
}
return null;
};
try {
for (const entry of sortedEntries) {
await yieldToMain();
if (controller?.isCancelled()) {
wasCancelled = true;
// Mark all created tasks as cancelled before breaking
for (const [, bundleTaskId] of bundleTaskIds) {
const progress = bundleProgress.get(bundleTaskId);
if (progress && progress.completedCount < progress.fileCount) {
callbacks?.onTaskCancelled?.(bundleTaskId);
}
}
break;
}
const entryTargetPath = joinPath(targetPath, entry.relativePath);
const bundleTaskId = getBundleTaskId(entry);
let standaloneTransferId = "";
let fileTotalBytes = 0;
try {
if (entry.isDirectory) {
await ensureDirectory(entryTargetPath);
} else if (entry.file) {
fileTotalBytes = entry.file.size;
// For standalone files (not in a folder), create individual task
if (!bundleTaskId) {
standaloneTransferId = crypto.randomUUID();
if (callbacks?.onTaskCreated) {
callbacks.onTaskCreated({
id: standaloneTransferId,
fileName: entry.relativePath,
displayName: entry.relativePath,
isDirectory: false,
totalBytes: fileTotalBytes,
transferredBytes: 0,
speed: 0,
fileCount: 1,
completedCount: 0,
});
}
}
// Ensure parent directories exist
const pathParts = entry.relativePath.split('/');
if (pathParts.length > 1) {
let parentPath = targetPath;
for (let i = 0; i < pathParts.length - 1; i++) {
parentPath = joinPath(parentPath, pathParts[i]);
await ensureDirectory(parentPath);
}
}
// Check if file has a local path (Electron provides file.path for dropped files)
const localFilePath = (entry.file as File & { path?: string }).path;
console.log('[UploadService] Processing file:', {
relativePath: entry.relativePath,
localFilePath,
hasStreamTransfer: !!bridge.startStreamTransfer,
sftpId,
isLocal,
fileSize: fileTotalBytes,
});
// Use stream transfer if available and we have a local file path (avoids loading file into memory)
if (localFilePath && bridge.startStreamTransfer && sftpId && !isLocal) {
console.log('[UploadService] Using stream transfer for:', localFilePath);
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
const onProgress = (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
pendingProgressUpdate = { transferred, total, speed };
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
// For bundled tasks, only update the current file's progress
// Don't add to completedFilesBytes until the file is fully completed
const newTransferred = progress.completedFilesBytes + update.transferred;
progress.transferredBytes = newTransferred;
progress.currentSpeed = update.speed;
const percent = progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0;
// Ensure progress doesn't exceed 99.9% until all files are completed
const displayPercent = progress.completedCount >= progress.fileCount ? percent : Math.min(percent, 99.9);
callbacks.onTaskProgress(bundleTaskId, {
transferred: newTransferred,
total: progress.totalBytes,
speed: update.speed,
percent: displayPercent,
});
}
} else if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
});
}
}
});
}
};
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
let streamResult: { transferId: string; totalBytes?: number; error?: string; cancelled?: boolean } | undefined;
try {
streamResult = await bridge.startStreamTransfer(
{
transferId: fileTransferId,
sourcePath: localFilePath,
targetPath: entryTargetPath,
sourceType: 'local',
targetType: 'sftp',
targetSftpId: sftpId,
totalBytes: fileTotalBytes,
},
onProgress,
undefined,
undefined
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
if (streamResult?.cancelled || streamResult?.error?.includes('cancelled')) {
console.log(`[UploadService] Stream transfer cancelled for: ${entry.relativePath}`);
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
if (streamResult?.error) {
console.error(`[UploadService] Stream transfer failed for: ${entry.relativePath}`, streamResult.error);
throw new Error(streamResult.error);
}
console.log(`[UploadService] Stream transfer successful for: ${entry.relativePath}`);
} else {
// Fallback: load file into memory (for small files or when stream transfer is not available)
console.log('[UploadService] FALLBACK: Loading file into memory:', {
relativePath: entry.relativePath,
fileSize: fileTotalBytes,
reason: !localFilePath ? 'no local path' : !bridge.startStreamTransfer ? 'no stream transfer' : 'other',
});
const arrayBuffer = await entry.file.arrayBuffer();
if (isLocal) {
if (!bridge.writeLocalFile) {
throw new Error("writeLocalFile not available");
}
await bridge.writeLocalFile(entryTargetPath, arrayBuffer);
} else if (sftpId) {
if (bridge.writeSftpBinaryWithProgress) {
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
const onProgress = (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
pendingProgressUpdate = { transferred, total, speed };
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
const newTransferred = progress.completedFilesBytes + update.transferred;
progress.transferredBytes = newTransferred;
progress.currentSpeed = update.speed;
const percent = progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0;
// Ensure progress doesn't show 100% until all files are completed
const displayPercent = progress.completedCount >= progress.fileCount ? percent : Math.min(percent, 99.9);
callbacks.onTaskProgress(bundleTaskId, {
transferred: newTransferred,
total: progress.totalBytes,
speed: update.speed,
percent: displayPercent,
});
}
} else if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
});
}
}
});
}
};
// Use unique file transfer ID for backend cancellation tracking
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
let result;
try {
result = await bridge.writeSftpBinaryWithProgress(
sftpId,
entryTargetPath,
arrayBuffer,
fileTransferId,
onProgress,
() => {
// File upload completed successfully
console.log(`[UploadService] File upload completed: ${entry.relativePath}`);
},
(error) => {
// File upload failed
console.error(`[UploadService] File upload failed: ${entry.relativePath}`, error);
}
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
if (result?.cancelled) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
if (!result || result.success === false) {
if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("Upload failed and no fallback method available");
}
}
} else if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("No SFTP write method available");
}
}
}
// File processing completed (both stream transfer and fallback paths)
controller?.clearCurrentTransfer();
results.push({ fileName: entry.relativePath, success: true });
console.log(`[UploadService] File completed: ${entry.relativePath}, size: ${fileTotalBytes}`);
// Update progress tracking
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
progress.completedCount++;
progress.completedFilesBytes += fileTotalBytes;
// Set transferredBytes to completedFilesBytes to avoid double counting
progress.transferredBytes = progress.completedFilesBytes;
if (progress.completedCount >= progress.fileCount) {
// All files completed - set final progress to 100% and mark as completed
callbacks?.onTaskProgress?.(bundleTaskId, {
transferred: progress.totalBytes,
total: progress.totalBytes,
speed: 0,
percent: 100,
});
// Call completion callback synchronously
callbacks?.onTaskCompleted?.(bundleTaskId, progress.totalBytes);
} else if (callbacks?.onTaskProgress) {
const percent = progress.totalBytes > 0 ? (progress.completedFilesBytes / progress.totalBytes) * 100 : 0;
// Ensure progress doesn't exceed 99.9% until all files are completed
const displayPercent = Math.min(percent, 99.9);
callbacks.onTaskProgress(bundleTaskId, {
transferred: progress.completedFilesBytes,
total: progress.totalBytes,
speed: 0,
percent: displayPercent,
});
}
}
} else if (standaloneTransferId) {
callbacks?.onTaskCompleted?.(standaloneTransferId, fileTotalBytes);
}
}
} catch (error) {
controller?.clearCurrentTransfer();
// Check if this was a cancellation
if (controller?.isCancelled()) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
const errorMessage = error instanceof Error ? error.message : String(error);
if (!entry.isDirectory) {
results.push({
fileName: entry.relativePath,
success: false,
error: errorMessage,
});
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskFailed?.(taskId, errorMessage);
}
}
// Any error stops the entire upload - fail fast approach
// Note: We don't set wasCancelled here because this is an error, not a cancellation
break;
}
}
} finally {
controller?.clearCurrentTransfer();
}
if (wasCancelled) {
results.push({ fileName: "", success: false, cancelled: true });
}
return results;
}
/**
* Upload entries directly (used when entries are already extracted)
*/
export async function uploadEntriesDirect(
entries: DropEntry[],
config: UploadConfig,
controller?: UploadController
): Promise<UploadResult[]> {
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks } = config;
if (controller) {
controller.reset();
controller.setBridge(bridge);
}
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
/**
* Upload folders using compression
*/
async function uploadFoldersCompressed(
folderEntries: Array<[string, DropEntry[]]>,
allEntries: DropEntry[],
targetPath: string,
sftpId: string,
callbacks?: UploadCallbacks,
controller?: UploadController
): Promise<UploadResult[]> {
const results: UploadResult[] = [];
// Import the compressed upload service
const { startCompressedUpload, checkCompressedUploadSupport } = await import('../infrastructure/services/compressUploadService');
for (const [folderName, entries] of folderEntries) {
if (controller?.isCancelled()) {
break;
}
// Get the local folder path from the first file in the folder
const firstFile = entries.find(e => e.file);
if (!firstFile?.file) {
results.push({ fileName: folderName, success: false, error: "No files found in folder" });
continue;
}
const localFilePath = getPathForFile(firstFile.file);
if (!localFilePath) {
results.push({ fileName: folderName, success: false, error: "Could not get local file path" });
continue;
}
// Extract folder path from the first file path
const relativePath = (firstFile.file as File & { webkitRelativePath?: string }).webkitRelativePath || firstFile.file.name;
const pathParts = relativePath.split('/');
// Get the parent directory of the file
// For example: if localFilePath is "/Users/rice/Downloads/110-temp/insideServer/file.txt"
// and relativePath is "insideServer/file.txt", we want "/Users/rice/Downloads/110-temp/insideServer"
let folderPath = localFilePath;
if (pathParts.length > 1) {
// Remove the filename from the end to get the folder path
const fileName = pathParts[pathParts.length - 1];
if (localFilePath.endsWith(fileName)) {
folderPath = localFilePath.substring(0, localFilePath.length - fileName.length - 1);
}
} else {
// Single file, get its parent directory
const lastSlash = Math.max(localFilePath.lastIndexOf('/'), localFilePath.lastIndexOf('\\'));
if (lastSlash > 0) {
folderPath = localFilePath.substring(0, lastSlash);
}
}
console.log('[uploadFoldersCompressed] Processing folder:', { folderName, folderPath, localFilePath, relativePath });
try {
// Check if compressed upload is supported
const support = await checkCompressedUploadSupport(sftpId);
if (!support.supported) {
console.log('[uploadFoldersCompressed] Compressed upload not supported, falling back to regular upload');
// Fall back to regular upload for this folder using the real bridge and config
// We need to get the original config from the parent function
// Since we don't have access to the original config here, we'll return an error
// and let the caller handle the fallback
results.push({
fileName: folderName,
success: false,
error: "Compressed upload not supported - fallback needed"
});
continue;
}
const compressionId = crypto.randomUUID();
// Create a task for this folder compression
const totalBytes = entries.reduce((sum, entry) => sum + (entry.file?.size || 0), 0);
const taskId = compressionId;
if (callbacks?.onTaskCreated) {
callbacks.onTaskCreated({
id: taskId,
fileName: folderName,
displayName: `${folderName} (compressed)`,
isDirectory: true,
totalBytes,
transferredBytes: 0,
speed: 0,
fileCount: entries.length,
completedCount: 0,
});
}
// Start compressed upload
const result = await startCompressedUpload(
{
compressionId,
folderPath,
targetPath,
sftpId,
folderName,
},
(phase, transferred, total) => {
console.log(`[UploadService] Compress progress: ${phase} ${transferred}/${total}`);
if (callbacks?.onTaskProgress) {
// Map compression progress to actual file bytes
const progressPercent = total > 0 ? (transferred / total) * 100 : 0;
const mappedTransferred = Math.floor((progressPercent / 100) * totalBytes);
callbacks.onTaskProgress(taskId, {
transferred: mappedTransferred,
total: totalBytes,
speed: 0, // Speed is handled by the compression service
percent: progressPercent,
});
}
// Update task name based on phase
if (callbacks?.onTaskNameUpdate) {
let displayName = folderName;
if (phase === 'compressing') {
displayName = `${folderName} (正在压缩)`;
} else if (phase === 'extracting') {
displayName = `${folderName} (正在解压)`;
} else if (phase === 'uploading') {
displayName = `${folderName} (正在上传)`;
} else {
displayName = `${folderName} (压缩传输)`;
}
callbacks.onTaskNameUpdate(taskId, displayName);
}
},
() => {
console.log(`[UploadService] Compress complete callback called for taskId: ${taskId}`);
// Mark task as completed immediately
if (callbacks?.onTaskCompleted) {
console.log(`[UploadService] Calling onTaskCompleted for taskId: ${taskId}`);
callbacks.onTaskCompleted(taskId, totalBytes);
}
},
(error) => {
if (callbacks?.onTaskFailed) {
callbacks.onTaskFailed(taskId, error);
}
}
);
if (result.success) {
results.push({ fileName: folderName, success: true });
} else {
results.push({ fileName: folderName, success: false, error: result.error });
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
results.push({ fileName: folderName, success: false, error: errorMessage });
if (callbacks?.onTaskFailed) {
callbacks.onTaskFailed(folderName, errorMessage);
}
}
}
return results;
}