Files
Netcatty/lib/uploadService.ts
陈大猫 c542520dee feat: SFTP sidebar polish - workspace caching, toolbar overflow, terminal cwd navigation
## Summary
- Add SFTP side panel with workspace-level connection caching for instant switching between terminal endpoints
- Responsive toolbar with overflow menu that collapses action buttons when panel is narrow, prioritizing breadcrumb path display
- Silent terminal CWD detection via separate SSH exec channel (no visible commands in terminal)
- Extract SftpTransferQueue as reusable component with i18n support
- Remove passphrase from port forwarding credentials (decrypted at load time)
- Add compressed upload support to uploadEntriesDirect
- Fix various eslint warnings and code quality issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 23:39:56 +08:00

1197 lines
44 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 activeCompressionIds = new Set<string>();
private currentTransferId = "";
private bridge: UploadBridge | null = null;
/**
* Cancel all active uploads
*/
async cancel(): Promise<void> {
this.cancelled = true;
// Cancel all active compressed uploads
const activeCompressionIds = Array.from(this.activeCompressionIds);
for (const compressionId of activeCompressionIds) {
try {
// Import and call cancelCompressedUpload
const { cancelCompressedUpload } = await import('../infrastructure/services/compressUploadService');
await cancelCompressedUpload(compressionId);
} catch {
// Ignore cancel errors
}
}
// 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) {
await this.bridge.cancelTransfer(transferId);
}
// Also try cancelSftpUpload (for legacy uploads)
if (this.bridge?.cancelSftpUpload) {
await this.bridge.cancelSftpUpload(transferId);
}
} catch {
// Ignore cancel errors
}
}
// Also cancel current one if not in the set
if (this.currentTransferId && !activeIds.includes(this.currentTransferId)) {
try {
if (this.bridge?.cancelTransfer) {
await this.bridge.cancelTransfer(this.currentTransferId);
}
if (this.bridge?.cancelSftpUpload) {
await this.bridge.cancelSftpUpload(this.currentTransferId);
}
} catch {
// 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);
}
// Also include compression IDs
const compressionIds = Array.from(this.activeCompressionIds);
return [...ids, ...compressionIds];
}
/**
* Reset controller state for new upload
*/
reset(): void {
this.cancelled = false;
this.activeFileTransferIds.clear();
this.activeCompressionIds.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 = "";
}
/**
* Track a compression ID
*/
addActiveCompression(id: string): void {
this.activeCompressionIds.add(id);
}
/**
* Remove a tracked compression ID
*/
removeActiveCompression(id: string): void {
this.activeCompressionIds.delete(id);
}
}
// ============================================================================
// 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, useCompressedUpload } = 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 [];
}
// 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__"));
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
if (folderEntries.length > 0) {
try {
const compressedResults = await uploadFoldersCompressed(folderEntries, 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"
);
const successfulFolders = compressedResults.filter(result =>
result.success || result.error !== "Compressed upload not supported - fallback needed"
);
let fallbackResults: UploadResult[] = [];
if (failedFolders.length > 0) {
// Get entries only for failed folders, not already successful ones
const failedFolderNames = new Set(failedFolders.map(f => f.fileName));
const failedFolderEntries = entries.filter(entry => {
const topFolder = entry.relativePath.split('/')[0];
return failedFolderNames.has(topFolder);
});
if (failedFolderEntries.length > 0) {
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
}
// Upload standalone files using regular upload if any exist
let standaloneResults: UploadResult[] = [];
if (standaloneFileEntries.length > 0) {
const standaloneEntries = standaloneFileEntries.flatMap(([, entries]) => entries);
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
// Combine results: successful compressed + fallback results + standalone files
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
} catch {
// Fall back to regular upload
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
}
}
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[]> {
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload } = config;
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;
if (localPath) {
// Set the path property on the file for stream transfer
(file as File & { path?: string }).path = localPath;
}
return {
file,
relativePath,
isDirectory: false,
};
});
if (entries.length === 0) {
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__"));
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
if (folderEntries.length > 0) {
try {
const compressedResults = await uploadFoldersCompressed(folderEntries, 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"
);
const successfulFolders = compressedResults.filter(result =>
result.success || result.error !== "Compressed upload not supported - fallback needed"
);
let fallbackResults: UploadResult[] = [];
if (failedFolders.length > 0) {
// Get entries only for failed folders, not already successful ones
const failedFolderNames = new Set(failedFolders.map(f => f.fileName));
const failedFolderEntries = entries.filter(entry => {
const topFolder = entry.relativePath.split('/')[0];
return failedFolderNames.has(topFolder);
});
if (failedFolderEntries.length > 0) {
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
}
// Upload standalone files using regular upload if any exist
let standaloneResults: UploadResult[] = [];
if (standaloneFileEntries.length > 0) {
const standaloneEntries = standaloneFileEntries.flatMap(([, entries]) => entries);
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
// Combine results: successful compressed + fallback results + standalone files
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
} catch {
// Fall back to regular upload
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
}
}
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;
// Use stream transfer if available and we have a local file path (avoids loading file into memory)
if (localFilePath && bridge.startStreamTransfer && sftpId && !isLocal) {
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')) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
if (streamResult?.error) {
throw new Error(streamResult.error);
}
} else {
// Fallback: load file into memory (for small files or when stream transfer is not available)
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
},
(error) => {
// File upload failed - error is handled by the caller
void 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 });
// 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, useCompressedUpload } = config;
if (controller) {
controller.reset();
controller.setBridge(bridge);
}
if (entries.length === 0) {
return [];
}
// Support compressed folder uploads (same logic as uploadFromDataTransfer)
if (useCompressedUpload && !isLocal && sftpId) {
const rootFolders = detectRootFolders(entries);
const folderEntries = Array.from(rootFolders.entries()).filter(([key]) => !key.startsWith("__file__"));
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
if (folderEntries.length > 0) {
try {
const compressedResults = await uploadFoldersCompressed(folderEntries, targetPath, sftpId, callbacks, controller);
const failedFolders = compressedResults.filter(result =>
!result.success && result.error === "Compressed upload not supported - fallback needed"
);
const successfulFolders = compressedResults.filter(result =>
result.success || result.error !== "Compressed upload not supported - fallback needed"
);
let fallbackResults: UploadResult[] = [];
if (failedFolders.length > 0) {
const failedFolderNames = new Set(failedFolders.map(f => f.fileName));
const failedFolderEntries = entries.filter(entry => {
const topFolder = entry.relativePath.split('/')[0];
return failedFolderNames.has(topFolder);
});
if (failedFolderEntries.length > 0) {
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
}
let standaloneResults: UploadResult[] = [];
if (standaloneFileEntries.length > 0) {
const standaloneEntries = standaloneFileEntries.flatMap(([, e]) => e);
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
} catch {
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
}
}
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
/**
* Upload folders using compression
*/
async function uploadFoldersCompressed(
folderEntries: Array<[string, 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) {
// Empty folder - mark for fallback to regular upload which will create the directory
results.push({ fileName: folderName, success: false, error: "Compressed upload not supported - fallback needed" });
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
// Use DropEntry.relativePath which works for both file input and drag-drop scenarios
// For file input: webkitRelativePath is set (e.g., "folder/subdir/file.txt")
// For drag-drop: DropEntry.relativePath contains the correct path from extractDropEntries
const relativePath = firstFile.relativePath || (firstFile.file as File & { webkitRelativePath?: string }).webkitRelativePath || firstFile.file.name;
// Normalize path separators for cross-platform compatibility
const normalizePathSeparators = (path: string) => path.replace(/\\/g, '/');
const normalizedLocalPath = normalizePathSeparators(localFilePath);
const normalizedRelativePath = normalizePathSeparators(relativePath);
// Calculate the root folder path by removing the full relativePath from localFilePath
// For example: if localFilePath is "/Users/rice/Downloads/110-temp/insideServer/subdir/file.txt"
// and relativePath is "insideServer/subdir/file.txt", we want "/Users/rice/Downloads/110-temp/insideServer"
let folderPath = localFilePath;
if (normalizedRelativePath && normalizedLocalPath.endsWith(normalizedRelativePath)) {
// Remove the relativePath from the end to get the base directory
const basePath = localFilePath.substring(0, localFilePath.length - relativePath.length);
// Remove trailing slash/backslash if present
const cleanBasePath = basePath.replace(/[/\\]$/, '');
// Add the folder name to get the actual folder path
folderPath = cleanBasePath + (cleanBasePath ? (localFilePath.includes('\\') ? '\\' : '/') : '') + folderName;
} else {
// Fallback: try to extract based on folder name with normalized separators
const normalizedFolderPattern1 = '/' + folderName + '/';
const normalizedFolderPattern2 = '\\' + folderName + '\\';
const folderIndex1 = normalizedLocalPath.lastIndexOf(normalizedFolderPattern1);
const folderIndex2 = localFilePath.lastIndexOf(normalizedFolderPattern2);
const folderIndex = Math.max(folderIndex1, folderIndex2);
if (folderIndex >= 0) {
folderPath = localFilePath.substring(0, folderIndex + folderName.length + 1);
} else {
// Last resort: remove just the filename (original logic)
const pathParts = normalizedRelativePath.split('/');
if (pathParts.length > 1) {
const fileName = pathParts[pathParts.length - 1];
if (normalizedLocalPath.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);
}
}
}
}
let taskId: string | null = null; // Declare taskId outside try block for error handling
try {
// Check if compressed upload is supported
const support = await checkCompressedUploadSupport(sftpId);
if (!support.supported) {
// Fall back to regular upload for this folder
results.push({
fileName: folderName,
success: false,
error: "Compressed upload not supported - fallback needed"
});
continue;
}
const compressionId = crypto.randomUUID();
// Check for cancellation before starting
if (controller?.isCancelled()) {
results.push({ fileName: folderName, success: false, cancelled: true });
break;
}
// Register compression ID with controller for cancellation support
controller?.addActiveCompression(compressionId);
// Create a task for this folder compression
const totalBytes = entries.reduce((sum, entry) => sum + (entry.file?.size || 0), 0);
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) => {
// Check for cancellation during progress updates
if (controller?.isCancelled()) {
return;
}
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) {
// Pass phase identifier for UI layer to handle i18n
// Format: "folderName|phase" where phase is: compressing, extracting, uploading, or compressed
const phaseKey = phase === 'compressing' ? 'compressing'
: phase === 'extracting' ? 'extracting'
: phase === 'uploading' ? 'uploading'
: 'compressed';
callbacks.onTaskNameUpdate(taskId, `${folderName}|${phaseKey}`);
}
},
() => {
// Remove compression ID from controller
controller?.removeActiveCompression(compressionId);
// Mark task as completed immediately
if (callbacks?.onTaskCompleted) {
callbacks.onTaskCompleted(taskId, totalBytes);
}
},
(error) => {
// Remove compression ID from controller on error
controller?.removeActiveCompression(compressionId);
if (callbacks?.onTaskFailed) {
callbacks.onTaskFailed(taskId, error);
}
}
);
if (result.success) {
results.push({ fileName: folderName, success: true });
} else if (result.error?.includes('cancelled') || controller?.isCancelled()) {
// Handle cancellation
results.push({ fileName: folderName, success: false, cancelled: true });
if (callbacks?.onTaskCancelled) {
callbacks.onTaskCancelled(taskId);
}
} else {
results.push({ fileName: folderName, success: false, error: result.error });
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Remove compression ID from controller on error
if (taskId) {
controller?.removeActiveCompression(taskId);
}
// Check if this was a cancellation
if (controller?.isCancelled() || errorMessage.includes('cancelled')) {
results.push({ fileName: folderName, success: false, cancelled: true });
if (callbacks?.onTaskCancelled && taskId) {
callbacks.onTaskCancelled(taskId);
}
} else {
results.push({ fileName: folderName, success: false, error: errorMessage });
// Only call onTaskFailed if we have a valid taskId (task was created) and it's not a cancellation
if (callbacks?.onTaskFailed && taskId) {
callbacks.onTaskFailed(taskId, errorMessage);
}
}
}
}
return results;
}