From 78b767a4a2d5ea078cad4f6fc66c8b8a3d8f34cb Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Tue, 16 Jun 2026 01:17:32 +0300 Subject: [PATCH] feat(upload): add pause and cancel controls for active uploads - Add CSS grid layout for upload-active-actions and hidden state - Implement JavaScript logic for pausing and cancelling uploads with confirmation - Add test to verify home page includes upload control elements --- backend/libs/handlers/static_test.go | 25 +++ backend/static/css/20-upload.css | 10 + backend/static/js/40-upload.js | 282 +++++++++++++++++++++++++-- backend/templates/pages/home.html | 12 +- 4 files changed, 312 insertions(+), 17 deletions(-) diff --git a/backend/libs/handlers/static_test.go b/backend/libs/handlers/static_test.go index bd0afbd..1b92152 100644 --- a/backend/libs/handlers/static_test.go +++ b/backend/libs/handlers/static_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) @@ -29,6 +30,30 @@ func TestSetStaticCacheHeaders(t *testing.T) { } } +func TestHomeIncludesActiveUploadControls(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + app.Home(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + for _, want := range []string{ + `id="upload-active-actions"`, + `id="cancel-upload"`, + `id="pause-upload"`, + `Cancel Upload`, + `Pause Upload`, + } { + if !strings.Contains(response.Body.String(), want) { + t.Fatalf("home page missing %q", want) + } + } +} + func TestWebManifestIncludesShareTarget(t *testing.T) { data, err := os.ReadFile(filepath.Join("..", "..", "static", "site.webmanifest")) if err != nil { diff --git a/backend/static/css/20-upload.css b/backend/static/css/20-upload.css index e06c7bd..2c88a2f 100644 --- a/backend/static/css/20-upload.css +++ b/backend/static/css/20-upload.css @@ -48,6 +48,16 @@ width: 100%; } +.upload-active-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.upload-active-actions[hidden] { + display: none !important; +} + .upload-options .form-footer .upload-new-button { margin-top: -0.25rem; } diff --git a/backend/static/js/40-upload.js b/backend/static/js/40-upload.js index 6e48242..5088f5a 100644 --- a/backend/static/js/40-upload.js +++ b/backend/static/js/40-upload.js @@ -15,6 +15,11 @@ const manageLink = document.querySelector("#manage-link"); const newUpload = document.querySelector("#new-upload"); const folderPicker = document.querySelector("[data-folder-picker]"); + const submitButton = form && form.querySelector("button[type='submit']"); + const idleUploadActions = form ? Array.from(form.querySelectorAll(".upload-idle-action")) : []; + const activeUploadActions = document.querySelector("#upload-active-actions"); + const cancelUpload = document.querySelector("#cancel-upload"); + const pauseUpload = document.querySelector("#pause-upload"); const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions"; const SHARE_CACHE = "warpbox-share-target-v1"; const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest"; @@ -52,6 +57,12 @@ let recoveredDraft = null; let resumeMode = false; let sharedTargetDraft = null; + let activeUploadRequest = null; + let activeUploadSession = null; + let uploadPaused = false; + let uploadCancelled = false; + let uploadFinalizing = false; + let pauseWaiters = []; const maxUploadBytes = parseInt(form.dataset.maxUploadBytes || "-1", 10); const maxUploadLabel = form.dataset.maxUploadLabel || (maxUploadBytes > 0 && window.Warpbox.formatBytes ? window.Warpbox.formatBytes(maxUploadBytes) : "the configured limit"); @@ -145,7 +156,6 @@ } } - const submit = form.querySelector("button[type='submit']"); const formData = uploadFormData(); await maybeRequestUploadNotificationPermission(selectedFiles); if (resumeMode && recoveredDraft) { @@ -153,7 +163,7 @@ } else { renderQueue(selectedFiles, "queued"); } - setLoading(true, submit); + beginUpload(); try { const payload = await uploadResumable(form.action, formData, selectedFiles); @@ -175,14 +185,55 @@ fileSummary.textContent = "Upload complete."; } } catch (error) { + if (isUploadCancelledError(error)) { + await discardActiveUploadSession(); + await clearSharedTargetPayload(); + form.reset(); + resetFreshUploadState(); + return; + } updateStatus(error.message || "Upload failed"); notifyUploadError(error); showUploadNotification("Warpbox upload failed", error.message || "Upload failed"); } finally { - setLoading(false, submit); + finishUpload(); } }); + if (pauseUpload) { + pauseUpload.addEventListener("click", () => { + if (!uploadLocked || uploadCancelled) { + return; + } + if (uploadPaused) { + resumeActiveUpload(); + } else { + pauseActiveUpload(); + } + }); + } + + if (cancelUpload) { + cancelUpload.addEventListener("click", async () => { + if (!uploadLocked || uploadCancelled) { + return; + } + const confirmed = await window.Warpbox.confirmDialog( + "Cancel this upload? Uploaded chunks will be deleted and the form will be reset for a new upload.", + { + title: "Cancel upload?", + variant: "warning", + confirmLabel: "Cancel Upload", + cancelLabel: "Keep Uploading", + }, + ); + if (!confirmed || !uploadLocked || uploadFinalizing) { + return; + } + requestUploadCancellation(); + }); + } + if (copyURL) { copyURL.addEventListener("click", () => { window.Warpbox.copyText(latestBoxURL, copyURL, "Copied"); @@ -622,20 +673,147 @@ newUpload.style.display = visible ? "" : "none"; } - function setLoading(isLoading, submit) { + function beginUpload() { + uploadPaused = false; + uploadCancelled = false; + uploadFinalizing = false; + activeUploadRequest = null; + activeUploadSession = null; + setLoading(true); + } + + function finishUpload() { + activeUploadRequest = null; + activeUploadSession = null; + uploadPaused = false; + uploadCancelled = false; + uploadFinalizing = false; + releasePauseWaiters(); + setLoading(false); + } + + function setLoading(isLoading) { uploadLocked = isLoading; if (progress) { progress.hidden = !isLoading; } - if (submit) { - submit.disabled = isLoading; - submit.textContent = isLoading ? "Uploading..." : "Upload files"; + idleUploadActions.forEach((button) => { + button.style.display = isLoading ? "none" : ""; + }); + if (activeUploadActions) { + activeUploadActions.hidden = !isLoading; + } + if (submitButton) { + submitButton.disabled = isLoading; + submitButton.textContent = "Upload files"; } if (newUpload) { newUpload.disabled = isLoading; } + if (cancelUpload) { + cancelUpload.disabled = !isLoading; + cancelUpload.textContent = "Cancel Upload"; + } + updatePauseButton(); updateStatus(isLoading ? "Transferring files..." : ""); setTotalProgress(isLoading ? 0 : 100); + if (!isLoading) { + updateNewUploadVisibility(); + } + } + + function pauseActiveUpload() { + uploadPaused = true; + updatePauseButton(); + updateStatus("Upload paused."); + if (activeUploadRequest) { + activeUploadRequest.abort(); + } + } + + function resumeActiveUpload() { + uploadPaused = false; + updatePauseButton(); + updateStatus("Resuming upload..."); + releasePauseWaiters(); + } + + function requestUploadCancellation() { + if (uploadFinalizing) { + return; + } + uploadCancelled = true; + uploadPaused = false; + updateStatus("Cancelling upload..."); + if (cancelUpload) { + cancelUpload.disabled = true; + cancelUpload.textContent = "Cancelling..."; + } + if (pauseUpload) { + pauseUpload.disabled = true; + } + releasePauseWaiters(); + if (activeUploadRequest) { + activeUploadRequest.abort(); + } + } + + function updatePauseButton() { + if (!pauseUpload) { + return; + } + pauseUpload.disabled = !uploadLocked || uploadCancelled || uploadFinalizing; + pauseUpload.textContent = uploadPaused ? "Resume Upload" : "Pause Upload"; + pauseUpload.classList.toggle("button-primary", uploadPaused); + pauseUpload.classList.toggle("button-outline", !uploadPaused); + } + + function releasePauseWaiters() { + const waiters = pauseWaiters; + pauseWaiters = []; + waiters.forEach((resolve) => resolve()); + } + + async function waitForUploadReady() { + while (uploadPaused && !uploadCancelled) { + await new Promise((resolve) => pauseWaiters.push(resolve)); + } + if (uploadCancelled) { + throw uploadControlError("UploadCancelledError", "Upload cancelled"); + } + } + + function uploadControlError(name, message) { + const error = new Error(message); + error.name = name; + return error; + } + + function isUploadPausedError(error) { + return Boolean(error && error.name === "UploadPausedError"); + } + + function isUploadCancelledError(error) { + return Boolean(error && error.name === "UploadCancelledError"); + } + + async function discardActiveUploadSession() { + const session = activeUploadSession; + activeUploadSession = null; + if (!session || !session.sessionId) { + return; + } + await cancelResumableSession(session.sessionId, session.resumeToken).catch(() => {}); + removeResumableSession(session.sessionId); + } + + function beginUploadFinalization() { + uploadFinalizing = true; + updateStatus("Finalizing upload..."); + if (cancelUpload) { + cancelUpload.disabled = true; + } + updatePauseButton(); } function updateStatus(message) { @@ -709,8 +887,20 @@ } function uploadWithProgress(url, formData, files) { + return uploadWithProgressAttempt(url, formData, files).catch(async (error) => { + if (isUploadPausedError(error)) { + await waitForUploadReady(); + return uploadWithProgress(url, formData, files); + } + throw error; + }); + } + + async function uploadWithProgressAttempt(url, formData, files) { + await waitForUploadReady(); return new Promise((resolve, reject) => { const request = new XMLHttpRequest(); + activeUploadRequest = request; const rateTracker = createTransferRateTracker(0); request.open("POST", url); request.setRequestHeader("Accept", "application/json"); @@ -728,6 +918,9 @@ }); request.addEventListener("load", () => { + if (activeUploadRequest === request) { + activeUploadRequest = null; + } let payload = {}; try { payload = JSON.parse(request.responseText || "{}"); @@ -744,19 +937,37 @@ resolve(payload); }); - request.addEventListener("error", () => reject(new Error("Network error during upload"))); - request.addEventListener("abort", () => reject(new Error("Upload aborted"))); + request.addEventListener("error", () => { + if (activeUploadRequest === request) { + activeUploadRequest = null; + } + reject(new Error("Network error during upload")); + }); + request.addEventListener("abort", () => { + if (activeUploadRequest === request) { + activeUploadRequest = null; + } + if (uploadCancelled) { + reject(uploadControlError("UploadCancelledError", "Upload cancelled")); + } else if (uploadPaused) { + reject(uploadControlError("UploadPausedError", "Upload paused")); + } else { + reject(new Error("Upload aborted")); + } + }); request.send(formData); }); } async function uploadResumable(fallbackUrl, formData, files) { + await waitForUploadReady(); if (!window.fetch || typeof Blob === "undefined") { return uploadWithProgress(fallbackUrl, formData, files); } updateStatus("Fingerprinting files..."); const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file))); + await waitForUploadReady(); const createPayload = { files: files.map((file, index) => ({ name: uploadName(file), @@ -779,6 +990,7 @@ session = await findResumableSession(createPayload); } if (session) { + activeUploadSession = session; validateResumeSelection(session, createPayload); session = await addMissingResumableFiles(session, createPayload); if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) { @@ -787,6 +999,7 @@ if (persistable) { saveResumableSession(session, createPayload); } + activeUploadSession = session; } if (!session || session.status !== "uploading") { try { @@ -800,7 +1013,9 @@ if (persistable) { saveResumableSession(session, createPayload); } + activeUploadSession = session; } + await waitForUploadReady(); const sessionFiles = files.map((file, index) => matchSessionFile(session, createPayload.files[index])); if (sessionFiles.some((file) => !file)) { throw new Error("Upload session could not match the selected files"); @@ -822,6 +1037,7 @@ const sessionFile = sessionFiles[fileIndex]; const uploaded = new Set(sessionFile.uploadedChunks || []); for (let chunkIndex = 0; chunkIndex < sessionFile.chunkCount; chunkIndex++) { + await waitForUploadReady(); if (uploaded.has(chunkIndex)) { continue; } @@ -845,12 +1061,14 @@ setSingleFileProgress(fileIndex, file, 100); } - updateStatus("Finalizing upload..."); + await waitForUploadReady(); + beginUploadFinalization(); const resultPayload = await completeResumableSession(session.sessionId, session.resumeToken); const wasResumeMode = resumeMode; if (persistable) { removeResumableSession(session.sessionId); } + activeUploadSession = null; if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) { resumeMode = false; recoveredDraft = null; @@ -897,6 +1115,7 @@ function uploadChunk(sessionID, resumeToken, fileID, chunkIndex, chunk, onProgress) { return new Promise((resolve, reject) => { const request = new XMLHttpRequest(); + activeUploadRequest = request; request.open("PUT", `/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files/${encodeURIComponent(fileID)}/chunks/${chunkIndex}`); request.setRequestHeader("Accept", "application/json"); request.setRequestHeader("X-Warpbox-Resume-Token", resumeToken || ""); @@ -906,6 +1125,9 @@ } }); request.addEventListener("load", () => { + if (activeUploadRequest === request) { + activeUploadRequest = null; + } if (request.status < 200 || request.status >= 300) { let payload = {}; try { @@ -918,8 +1140,24 @@ } resolve(); }); - request.addEventListener("error", () => reject(new Error("Network error during chunk upload"))); - request.addEventListener("abort", () => reject(new Error("Chunk upload aborted"))); + request.addEventListener("error", () => { + if (activeUploadRequest === request) { + activeUploadRequest = null; + } + reject(new Error("Network error during chunk upload")); + }); + request.addEventListener("abort", () => { + if (activeUploadRequest === request) { + activeUploadRequest = null; + } + if (uploadCancelled) { + reject(uploadControlError("UploadCancelledError", "Upload cancelled")); + } else if (uploadPaused) { + reject(uploadControlError("UploadPausedError", "Upload paused")); + } else { + reject(new Error("Chunk upload aborted")); + } + }); request.send(chunk); }); } @@ -928,16 +1166,26 @@ const delays = [1000, 2000, 5000, 10000, 20000]; let lastError = null; for (let attempt = 0; attempt <= delays.length; attempt++) { + await waitForUploadReady(); try { return await uploadChunk(session.sessionId, session.resumeToken, sessionFile.id, chunkIndex, chunk, onProgress); } catch (error) { + if (isUploadCancelledError(error)) { + throw error; + } + if (isUploadPausedError(error)) { + await waitForUploadReady(); + await wait(150); + attempt -= 1; + continue; + } lastError = error; if (attempt >= delays.length) { break; } const seconds = Math.round(delays[attempt] / 1000); updateStatus(`Connection interrupted, retrying chunk ${chunkIndex + 1} in ${seconds}s`); - await wait(delays[attempt]); + await waitForUploadDelay(delays[attempt]); } } throw lastError || new Error("Chunk upload failed"); @@ -972,6 +1220,14 @@ return new Promise((resolve) => window.setTimeout(resolve, ms)); } + async function waitForUploadDelay(ms) { + const deadline = performance.now() + ms; + while (performance.now() < deadline) { + await wait(Math.min(100, Math.max(0, deadline - performance.now()))); + await waitForUploadReady(); + } + } + async function readUploadJSON(response, fallback) { let payload = {}; try { diff --git a/backend/templates/pages/home.html b/backend/templates/pages/home.html index 49680e7..0ad9ca0 100644 --- a/backend/templates/pages/home.html +++ b/backend/templates/pages/home.html @@ -76,10 +76,14 @@