mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
c1959adbf6
- uploadService.ts: Mark all bundle tasks as cancelled on early loop exit - uploadService.ts: Don't set wasCancelled for actual errors, preserving the error result for proper UI feedback - useSftpModalTransfers.ts: Re-throw real errors instead of masking them as cancellations, only return cancelled:true for user-initiated cancels This ensures genuine failures (permission denied, disk full, connection loss) are reported as errors with proper toast messages, while user cancellations are correctly identified and handled. Co-Authored-By: Claude <noreply@anthropic.com>
653 lines
20 KiB
TypeScript
653 lines
20 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 } 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;
|
|
}
|
|
|
|
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>;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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;
|
|
|
|
if (!this.bridge?.cancelSftpUpload) {
|
|
return;
|
|
}
|
|
|
|
// Cancel all active file uploads
|
|
const activeIds = Array.from(this.activeFileTransferIds);
|
|
for (const transferId of activeIds) {
|
|
try {
|
|
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 {
|
|
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);
|
|
}
|
|
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 with bundled folder support
|
|
*/
|
|
export async function uploadFromFileList(
|
|
fileList: FileList,
|
|
config: UploadConfig,
|
|
controller?: UploadController
|
|
): Promise<UploadResult[]> {
|
|
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks } = config;
|
|
|
|
if (controller) {
|
|
controller.reset();
|
|
controller.setBridge(bridge);
|
|
}
|
|
|
|
// Convert FileList to DropEntry array (simple files, no folders)
|
|
const entries: DropEntry[] = Array.from(fileList).map(file => ({
|
|
file,
|
|
relativePath: file.name,
|
|
isDirectory: false,
|
|
}));
|
|
|
|
if (entries.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
callbacks.onTaskProgress(bundleTaskId, {
|
|
transferred: newTransferred,
|
|
total: progress.totalBytes,
|
|
speed: update.speed,
|
|
percent: progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0,
|
|
});
|
|
}
|
|
} 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,
|
|
undefined,
|
|
undefined
|
|
);
|
|
} 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");
|
|
}
|
|
}
|
|
|
|
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;
|
|
progress.transferredBytes = progress.completedFilesBytes;
|
|
|
|
if (progress.completedCount >= progress.fileCount) {
|
|
callbacks?.onTaskCompleted?.(bundleTaskId, progress.totalBytes);
|
|
} else if (callbacks?.onTaskProgress) {
|
|
callbacks.onTaskProgress(bundleTaskId, {
|
|
transferred: progress.completedFilesBytes,
|
|
total: progress.totalBytes,
|
|
speed: 0,
|
|
percent: progress.totalBytes > 0 ? (progress.completedFilesBytes / progress.totalBytes) * 100 : 0,
|
|
});
|
|
}
|
|
}
|
|
} 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);
|
|
}
|