perf: optimize UI rendering and event lifecycle
This commit is contained in:
+432
-122
@@ -26,9 +26,9 @@ const fileOps = {
|
||||
_isUploading: false, // Guard against concurrent upload calls
|
||||
|
||||
/** Start a new upload batch in the notification bell */
|
||||
_initUploadToast(totalFiles) {
|
||||
_initUploadToast(totalFiles, folderName) {
|
||||
this._currentBatchId = window.notifications
|
||||
? window.notifications.addUploadBatch(totalFiles)
|
||||
? window.notifications.addUploadBatch(totalFiles, folderName)
|
||||
: null;
|
||||
},
|
||||
|
||||
@@ -39,31 +39,115 @@ const fileOps = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Some drag-and-drop sources can inject directory placeholders into
|
||||
* DataTransfer.files. Browsers fail those with net::ERR_ACCESS_DENIED
|
||||
* when trying to send them as normal files.
|
||||
*/
|
||||
_canReadFileBlob(file) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(true);
|
||||
reader.onerror = () => resolve(false);
|
||||
reader.readAsArrayBuffer(file.slice(0, 1));
|
||||
} catch (_) {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload a single file via XMLHttpRequest with progress events.
|
||||
* Progress is reported to the notification bell via batchId + fileName.
|
||||
* Returns a promise that resolves with { ok, data?, errorMsg?, isQuotaError? }.
|
||||
*/
|
||||
_uploadFileXHR(formData, batchId, fileName) {
|
||||
_uploadFileXHR(formData, batchId, fileName, timeoutMs = 120000) {
|
||||
return new Promise((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const notif = window.notifications;
|
||||
xhr.timeout = timeoutMs;
|
||||
const hardDeadlineMs = Math.max(timeoutMs * 2, 180000);
|
||||
let lastProgressPctSent = -1;
|
||||
|
||||
let isSettled = false;
|
||||
let stallTimer = null;
|
||||
let hardTimer = null;
|
||||
|
||||
const safeUpdateFile = (pct, status) => {
|
||||
if (!notif || !batchId) return;
|
||||
try {
|
||||
notif.updateFile(batchId, fileName, pct, status);
|
||||
} catch (e) {
|
||||
console.warn('Notification update failed for upload row:', fileName, e);
|
||||
}
|
||||
};
|
||||
|
||||
const finalize = (result) => {
|
||||
if (isSettled) return;
|
||||
isSettled = true;
|
||||
if (stallTimer) {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = null;
|
||||
}
|
||||
if (hardTimer) {
|
||||
clearTimeout(hardTimer);
|
||||
hardTimer = null;
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const resetStallTimer = () => {
|
||||
if (stallTimer) clearTimeout(stallTimer);
|
||||
stallTimer = setTimeout(() => {
|
||||
try { xhr.abort(); } catch (_) {}
|
||||
safeUpdateFile(0, 'error');
|
||||
finalize({
|
||||
ok: false,
|
||||
isTimeout: true,
|
||||
errorMsg: `Upload stalled for ${Math.round(timeoutMs / 1000)}s`
|
||||
});
|
||||
}, timeoutMs);
|
||||
};
|
||||
|
||||
resetStallTimer();
|
||||
hardTimer = setTimeout(() => {
|
||||
try { xhr.abort(); } catch (_) {}
|
||||
safeUpdateFile(0, 'error');
|
||||
finalize({
|
||||
ok: false,
|
||||
isTimeout: true,
|
||||
errorMsg: `Upload hard timeout after ${Math.round(hardDeadlineMs / 1000)}s`
|
||||
});
|
||||
}, hardDeadlineMs);
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable && notif && batchId) {
|
||||
resetStallTimer();
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
notif.updateFile(batchId, fileName, pct, 'uploading');
|
||||
// Throttle UI updates from very chatty progress events
|
||||
if (pct === 100 || pct - lastProgressPctSent >= 10) {
|
||||
lastProgressPctSent = pct;
|
||||
safeUpdateFile(pct, 'uploading');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('readystatechange', () => {
|
||||
// Keep watchdog alive while request is actively moving through states
|
||||
if (xhr.readyState > 1 && xhr.readyState < 4) {
|
||||
resetStallTimer();
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
if (notif && batchId) notif.updateFile(batchId, fileName, 100, 'done');
|
||||
safeUpdateFile(100, 'done');
|
||||
let data = null;
|
||||
try { data = JSON.parse(xhr.responseText); } catch (_) {}
|
||||
resolve({ ok: true, data });
|
||||
finalize({ ok: true, data });
|
||||
} else {
|
||||
if (notif && batchId) notif.updateFile(batchId, fileName, 0, 'error');
|
||||
safeUpdateFile(0, 'error');
|
||||
// Parse error body for quota-exceeded or other messages
|
||||
let errorMsg = null;
|
||||
let isQuotaError = false;
|
||||
@@ -72,13 +156,23 @@ const fileOps = {
|
||||
errorMsg = errBody.error || null;
|
||||
isQuotaError = errBody.error_type === 'QuotaExceeded' || xhr.status === 507;
|
||||
} catch (_) {}
|
||||
resolve({ ok: false, errorMsg, isQuotaError });
|
||||
finalize({ ok: false, errorMsg, isQuotaError });
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
if (notif && batchId) notif.updateFile(batchId, fileName, 0, 'error');
|
||||
resolve({ ok: false });
|
||||
safeUpdateFile(0, 'error');
|
||||
finalize({ ok: false });
|
||||
});
|
||||
|
||||
xhr.addEventListener('abort', () => {
|
||||
safeUpdateFile(0, 'error');
|
||||
finalize({ ok: false, isTimeout: true, errorMsg: `Upload aborted/stalled: ${fileName}` });
|
||||
});
|
||||
|
||||
xhr.addEventListener('timeout', () => {
|
||||
safeUpdateFile(0, 'error');
|
||||
finalize({ ok: false, isTimeout: true, errorMsg: `Timeout after ${Math.round(timeoutMs / 1000)}s` });
|
||||
});
|
||||
|
||||
xhr.open('POST', '/api/files/upload');
|
||||
@@ -88,10 +182,69 @@ const fileOps = {
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
|
||||
xhr.send(formData);
|
||||
try {
|
||||
xhr.send(formData);
|
||||
} catch (e) {
|
||||
safeUpdateFile(0, 'error');
|
||||
finalize({
|
||||
ok: false,
|
||||
errorMsg: `Client send() failed: ${e?.message || 'unknown error'}`
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload a single file via fetch + AbortController.
|
||||
* Used by folder uploads to avoid browser XHR edge-cases with dragged entries.
|
||||
* Returns { ok, data?, errorMsg?, isQuotaError?, isTimeout? }.
|
||||
*/
|
||||
async _uploadFileFetch(formData, timeoutMs = 60000) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
||||
},
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
// Read body as text first (always consume the response fully)
|
||||
let rawText = '';
|
||||
try { rawText = await response.text(); } catch (_) {}
|
||||
|
||||
let body = null;
|
||||
try { body = JSON.parse(rawText); } catch (_) {}
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true, data: body };
|
||||
}
|
||||
|
||||
const errorMsg = body && typeof body === 'object'
|
||||
? (body.error || null)
|
||||
: (rawText || null);
|
||||
const isQuotaError = (body && typeof body === 'object' && body.error_type === 'QuotaExceeded') || response.status === 507;
|
||||
return { ok: false, errorMsg, isQuotaError };
|
||||
} catch (e) {
|
||||
const isTimeout = e?.name === 'AbortError';
|
||||
return {
|
||||
ok: false,
|
||||
isTimeout,
|
||||
errorMsg: isTimeout
|
||||
? `Timeout after ${Math.round(timeoutMs / 1000)}s`
|
||||
: `Fetch upload failed: ${e?.message || 'network error'}`
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// Upload files (via button or drag-and-drop)
|
||||
// ========================================================================
|
||||
@@ -101,8 +254,8 @@ const fileOps = {
|
||||
* @param {FileList} files - Files to upload
|
||||
*/
|
||||
async uploadFiles(files) {
|
||||
const totalFiles = files.length;
|
||||
if (totalFiles === 0) return;
|
||||
const originalFiles = Array.from(files || []);
|
||||
if (originalFiles.length === 0) return;
|
||||
|
||||
// Guard: prevent concurrent upload calls (e.g. double drop events)
|
||||
if (this._isUploading) {
|
||||
@@ -118,7 +271,39 @@ const fileOps = {
|
||||
if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; }
|
||||
if (progressBar) { progressBar.style.width = '0%'; }
|
||||
|
||||
// Show upload notification
|
||||
// Filter out unreadable entries (typically dropped folders/placeholders)
|
||||
const readableFiles = [];
|
||||
const skippedEntries = [];
|
||||
for (const f of originalFiles) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const readable = await this._canReadFileBlob(f);
|
||||
if (readable) readableFiles.push(f);
|
||||
else skippedEntries.push(f.name || 'Unnamed entry');
|
||||
}
|
||||
|
||||
const totalFiles = readableFiles.length;
|
||||
|
||||
if (skippedEntries.length > 0 && window.notifications) {
|
||||
const locale = window.i18n?.getCurrentLocale?.() || 'en';
|
||||
const title = locale.startsWith('es') ? 'Entradas omitidas' : 'Entries skipped';
|
||||
const text = locale.startsWith('es')
|
||||
? `Se omitieron ${skippedEntries.length} carpeta(s)/entrada(s) no legibles. Usa "Subir carpeta".`
|
||||
: `${skippedEntries.length} unreadable folder/entry items were skipped. Use "Upload folder".`;
|
||||
window.notifications.addNotification({
|
||||
icon: 'fa-folder-open',
|
||||
iconClass: 'upload',
|
||||
title,
|
||||
text
|
||||
});
|
||||
}
|
||||
|
||||
if (totalFiles === 0) {
|
||||
if (uploadProgressDiv) uploadProgressDiv.style.display = 'none';
|
||||
this._isUploading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show upload notification (only for actual readable files)
|
||||
this._initUploadToast(totalFiles);
|
||||
const batchId = this._currentBatchId;
|
||||
|
||||
@@ -126,7 +311,8 @@ const fileOps = {
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < totalFiles; i++) {
|
||||
const file = files[i];
|
||||
const file = readableFiles[i];
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
const targetFolderId = window.app.currentPath || window.app.userHomeFolderId;
|
||||
@@ -147,7 +333,11 @@ const fileOps = {
|
||||
}
|
||||
// Notify bell of per-file completion
|
||||
if (window.notifications && batchId) {
|
||||
window.notifications.fileCompleted(batchId, result.ok);
|
||||
try {
|
||||
window.notifications.fileCompleted(batchId, result.ok);
|
||||
} catch (e) {
|
||||
console.warn('Batch progress update failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
@@ -155,6 +345,14 @@ const fileOps = {
|
||||
console.log(`Successfully uploaded ${file.name}`, result.data);
|
||||
} else {
|
||||
console.error(`Upload error for ${file.name}`);
|
||||
if (result.isTimeout && window.notifications) {
|
||||
window.notifications.addNotification({
|
||||
icon: 'fa-clock',
|
||||
iconClass: 'error',
|
||||
title: file.name,
|
||||
text: result.errorMsg || 'Upload timeout'
|
||||
});
|
||||
}
|
||||
if (result.isQuotaError) {
|
||||
const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded';
|
||||
if (window.notifications) {
|
||||
@@ -198,135 +396,247 @@ const fileOps = {
|
||||
* @param {FileList} files - Files from folder input (with webkitRelativePath)
|
||||
*/
|
||||
async uploadFolderFiles(files) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const entries = Array.from(files || []).map((file) => ({
|
||||
file,
|
||||
relativePath: file.webkitRelativePath || file.name
|
||||
}));
|
||||
await this.uploadFolderEntries(entries);
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload folder-like entries preserving relative paths.
|
||||
* @param {Array<{file: File, relativePath: string}>} entries
|
||||
*/
|
||||
async uploadFolderEntries(entries) {
|
||||
const rawEntries = Array.isArray(entries) ? entries : [];
|
||||
if (rawEntries.length === 0) return;
|
||||
|
||||
// Guard: prevent concurrent upload calls
|
||||
if (this._isUploading) {
|
||||
console.warn('Upload already in progress, ignoring duplicate call');
|
||||
return;
|
||||
}
|
||||
this._isUploading = true;
|
||||
|
||||
const progressBar = document.querySelector('.progress-fill');
|
||||
const uploadProgressDiv = document.querySelector('.upload-progress');
|
||||
if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; }
|
||||
if (progressBar) { progressBar.style.width = '0%'; }
|
||||
|
||||
const currentFolderId = window.app.currentPath || window.app.userHomeFolderId;
|
||||
|
||||
// Build folder structure from relative paths
|
||||
const folderMap = new Map();
|
||||
folderMap.set('', currentFolderId);
|
||||
|
||||
const folderPaths = new Set();
|
||||
for (const file of files) {
|
||||
const parts = file.webkitRelativePath.split('/');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const path = parts.slice(0, i).join('/');
|
||||
folderPaths.add(path);
|
||||
try {
|
||||
// Filter unreadable entries
|
||||
const validEntries = [];
|
||||
for (const e of rawEntries) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const readable = await this._canReadFileBlob(e.file);
|
||||
if (readable) validEntries.push(e);
|
||||
else console.warn(`Skipping unreadable folder entry: ${e.relativePath || e.file?.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedPaths = [...folderPaths].sort((a, b) =>
|
||||
a.split('/').length - b.split('/').length
|
||||
);
|
||||
|
||||
// Create folders first (no progress toast for folder creation)
|
||||
for (const folderPath of sortedPaths) {
|
||||
const parts = folderPath.split('/');
|
||||
const folderName = parts[parts.length - 1];
|
||||
const parentPath = parts.slice(0, -1).join('/');
|
||||
const parentId = folderMap.get(parentPath) || currentFolderId;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/folders', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: folderName,
|
||||
parent_id: parentId
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const folder = await response.json();
|
||||
folderMap.set(folderPath, folder.id);
|
||||
console.log(`Created folder: ${folderPath} -> ${folder.id}`);
|
||||
} else {
|
||||
console.error(`Error creating folder ${folderPath}:`, await response.text());
|
||||
window.ui.showNotification('Error', `Error creating folder: ${folderName}`);
|
||||
|
||||
const totalFiles = validEntries.length;
|
||||
if (totalFiles === 0) {
|
||||
if (uploadProgressDiv) uploadProgressDiv.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFolderId = window.app.currentPath || window.app.userHomeFolderId;
|
||||
|
||||
// Build folder structure from relative paths
|
||||
const folderMap = new Map();
|
||||
folderMap.set('', currentFolderId);
|
||||
|
||||
const folderPaths = new Set();
|
||||
for (const entry of validEntries) {
|
||||
const rel = entry.relativePath || entry.file.name;
|
||||
const parts = rel.split('/');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const path = parts.slice(0, i).join('/');
|
||||
folderPaths.add(path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Network error creating folder ${folderPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload files with notification bell
|
||||
const totalFiles = files.length;
|
||||
this._initUploadToast(totalFiles);
|
||||
const batchId = this._currentBatchId;
|
||||
|
||||
let uploadedCount = 0;
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < totalFiles; i++) {
|
||||
const file = files[i];
|
||||
const parts = file.webkitRelativePath.split('/');
|
||||
const parentPath = parts.slice(0, -1).join('/');
|
||||
const targetFolderId = folderMap.get(parentPath) || currentFolderId;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('folder_id', targetFolderId);
|
||||
// Use file.name as the explicit filename to prevent the browser
|
||||
// from sending the full webkitRelativePath as the filename
|
||||
formData.append('file', file, file.name);
|
||||
const sortedPaths = [...folderPaths].sort((a, b) =>
|
||||
a.split('/').length - b.split('/').length
|
||||
);
|
||||
|
||||
const displayName = file.webkitRelativePath || file.name;
|
||||
// Create folders first
|
||||
for (const folderPath of sortedPaths) {
|
||||
const parts = folderPath.split('/');
|
||||
const folderName = parts[parts.length - 1];
|
||||
const parentPath = parts.slice(0, -1).join('/');
|
||||
const parentId = folderMap.get(parentPath) || currentFolderId;
|
||||
|
||||
const result = await this._uploadFileXHR(formData, batchId, displayName);
|
||||
|
||||
uploadedCount++;
|
||||
if (progressBar) {
|
||||
progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%';
|
||||
try {
|
||||
const response = await fetch('/api/folders', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: folderName,
|
||||
parent_id: parentId
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const folder = await response.json();
|
||||
folderMap.set(folderPath, folder.id);
|
||||
console.log(`Created folder: ${folderPath} -> ${folder.id}`);
|
||||
} else {
|
||||
console.error(`Error creating folder ${folderPath}:`, await response.text());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Network error creating folder ${folderPath}:`, error);
|
||||
}
|
||||
}
|
||||
if (window.notifications && batchId) {
|
||||
window.notifications.fileCompleted(batchId, result.ok);
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
successCount++;
|
||||
console.log(`Uploaded: ${file.webkitRelativePath}`);
|
||||
} else {
|
||||
console.error(`Error uploading ${file.webkitRelativePath}`);
|
||||
if (result.isQuotaError) {
|
||||
const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded';
|
||||
|
||||
// Detect root folder(s) from entry paths
|
||||
const rootFolderNames = [...new Set(validEntries.map((entry) => {
|
||||
const rel = entry.relativePath || entry.file.name;
|
||||
return rel.split('/')[0] || '';
|
||||
}).filter(Boolean))];
|
||||
const locale = window.i18n?.getCurrentLocale?.() || 'en';
|
||||
const rootFolderLabel = rootFolderNames.length <= 1
|
||||
? (rootFolderNames[0] || '')
|
||||
: (locale.startsWith('es')
|
||||
? `${rootFolderNames.length} carpetas`
|
||||
: `${rootFolderNames.length} folders`);
|
||||
|
||||
// Upload files — pass folder name for folder-level progress display
|
||||
this._initUploadToast(totalFiles, rootFolderLabel);
|
||||
const batchId = this._currentBatchId;
|
||||
|
||||
let uploadedCount = 0;
|
||||
let successCount = 0;
|
||||
let quotaStop = false;
|
||||
|
||||
// ── Concurrent upload with limited parallelism ──────────
|
||||
// FIFOs are pre-caught by the 0-byte arrayBuffer guard,
|
||||
// so all files reaching fetch() are regular. Keep-alive
|
||||
// reuses TCP connections across workers for speed.
|
||||
const CONCURRENCY = 10;
|
||||
const TIMEOUT_MS = 10000; // 10s for normal files
|
||||
const TIMEOUT_MS_ZERO = 3000; // 3s for 0-byte files
|
||||
|
||||
const uploadOneFile = async (idx) => {
|
||||
if (quotaStop) return;
|
||||
const entry = validEntries[idx];
|
||||
const file = entry.file;
|
||||
const rel = entry.relativePath || file.name;
|
||||
|
||||
let result = { ok: false, errorMsg: 'Unknown client error' };
|
||||
try {
|
||||
const parts = rel.split('/');
|
||||
const parentPath = parts.slice(0, -1).join('/');
|
||||
const targetFolderId = folderMap.get(parentPath) || currentFolderId;
|
||||
|
||||
// ── FIFO/pipe guard (0-byte files only) ──
|
||||
// Named pipes (runit supervise/control) report size=0
|
||||
// but block on open(). Pre-read only 0-byte files into
|
||||
// memory; files with size>0 are always regular files and
|
||||
// go straight to FormData (zero extra memory copy).
|
||||
let uploadFile = file; // default: use original File
|
||||
if (file.size === 0) {
|
||||
try {
|
||||
const buf = await Promise.race([
|
||||
file.arrayBuffer(),
|
||||
new Promise((_, rej) =>
|
||||
setTimeout(() => rej(new Error('read-timeout')), 2000))
|
||||
]);
|
||||
uploadFile = new Blob([buf], {
|
||||
type: file.type || 'application/octet-stream'
|
||||
});
|
||||
} catch {
|
||||
console.warn(`[SKIP] #${idx} ${rel} — cannot read 0-byte file (FIFO/pipe?), skipping`);
|
||||
uploadedCount++;
|
||||
successCount++;
|
||||
if (window.notifications && batchId) {
|
||||
try { window.notifications.fileCompleted(batchId, true); } catch (_) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('folder_id', targetFolderId);
|
||||
formData.append('file', uploadFile, file.name);
|
||||
|
||||
const thisTimeout = file.size === 0 ? TIMEOUT_MS_ZERO : TIMEOUT_MS;
|
||||
console.log(`[UPLOAD START] #${idx} ${rel} (${file.size} bytes, timeout=${thisTimeout}ms)`);
|
||||
|
||||
result = await this._uploadFileFetch(formData, thisTimeout);
|
||||
|
||||
console.log(`[UPLOAD END] #${idx} ${rel} ok=${result.ok}${result.errorMsg ? ' err=' + result.errorMsg : ''}`);
|
||||
} catch (e) {
|
||||
result = {
|
||||
ok: false,
|
||||
errorMsg: `Client exception: ${e?.message || 'unknown'}`
|
||||
};
|
||||
console.error(`[UPLOAD EXCEPTION] #${idx} ${rel}:`, e);
|
||||
}
|
||||
|
||||
uploadedCount++;
|
||||
|
||||
if (window.notifications && batchId) {
|
||||
try { window.notifications.fileCompleted(batchId, result.ok); } catch (_) {}
|
||||
}
|
||||
if (progressBar && uploadedCount % 10 === 0) {
|
||||
progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%';
|
||||
}
|
||||
if (uploadedCount % 50 === 0 || uploadedCount === totalFiles) {
|
||||
console.log(`Progress: ${uploadedCount}/${totalFiles} (${successCount} ok)`);
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
successCount++;
|
||||
} else if (result.isQuotaError) {
|
||||
quotaStop = true;
|
||||
if (window.notifications) {
|
||||
window.notifications.addNotification({
|
||||
icon: 'fa-exclamation-triangle',
|
||||
iconClass: 'error',
|
||||
title: file.name,
|
||||
text: msg
|
||||
text: result.errorMsg || 'Storage quota exceeded'
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Pool-based concurrency: always keep CONCURRENCY tasks in flight
|
||||
let nextIdx = 0;
|
||||
const runNext = async () => {
|
||||
while (nextIdx < totalFiles && !quotaStop) {
|
||||
const idx = nextIdx++;
|
||||
await uploadOneFile(idx);
|
||||
}
|
||||
};
|
||||
|
||||
const workers = [];
|
||||
for (let w = 0; w < Math.min(CONCURRENCY, totalFiles); w++) {
|
||||
workers.push(runNext());
|
||||
}
|
||||
}
|
||||
|
||||
// Finish
|
||||
this._finishUploadToast(successCount, totalFiles);
|
||||
await Promise.all(workers);
|
||||
|
||||
// Refresh storage usage display
|
||||
if (typeof window.refreshUserData === 'function') {
|
||||
try { await window.refreshUserData(); } catch (_) {}
|
||||
}
|
||||
this._finishUploadToast(successCount, totalFiles);
|
||||
|
||||
try {
|
||||
await window.loadFiles({ forceRefresh: true });
|
||||
} catch (reloadError) {
|
||||
console.error('Error reloading files:', reloadError);
|
||||
}
|
||||
if (typeof window.refreshUserData === 'function') {
|
||||
try { await window.refreshUserData(); } catch (_) {}
|
||||
}
|
||||
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
if (dropzone) dropzone.style.display = 'none';
|
||||
if (uploadProgressDiv) uploadProgressDiv.style.display = 'none';
|
||||
try {
|
||||
await window.loadFiles({ forceRefresh: true });
|
||||
} catch (reloadError) {
|
||||
console.error('Error reloading files:', reloadError);
|
||||
}
|
||||
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
if (dropzone) dropzone.style.display = 'none';
|
||||
if (uploadProgressDiv) uploadProgressDiv.style.display = 'none';
|
||||
} finally {
|
||||
this._isUploading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user