diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 91b2992..618d0ab 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 954cbd4..180e06e 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -68,6 +68,13 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="feedback-modal"', body) self.assertIn('id="feedback-message"', body) self.assertIn('id="feedback-close-btn"', body) + self.assertIn('id="download-modal"', body) + self.assertIn('id="download-modal-target"', body) + self.assertIn('id="download-modal-current-file"', body) + self.assertIn('id="download-modal-progress-bar"', body) + self.assertIn('id="download-modal-count"', body) + self.assertIn('id="download-modal-status"', body) + self.assertIn('id="download-modal-close-btn"', body) self.assertIn('id="context-menu"', body) self.assertIn('id="context-menu-scope"', body) self.assertIn('id="context-menu-target"', body) @@ -211,11 +218,26 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function feedbackElements()', app_js) self.assertIn('function openFeedbackModal(message)', app_js) self.assertIn('function closeFeedbackModal()', app_js) + self.assertIn('function downloadModalElements()', app_js) + self.assertIn('function isZipDownloadSelection(items)', app_js) + self.assertIn('function openZipDownloadModal(selectedItems)', app_js) + self.assertIn('function markZipDownloadReady(fileName)', app_js) + self.assertIn('function markZipDownloadFailed(err)', app_js) + self.assertIn('function closeDownloadModal()', app_js) self.assertIn('function contextMenuElements()', app_js) self.assertIn('function openContextMenu(pane, entry, event)', app_js) self.assertIn('function closeContextMenu()', app_js) self.assertIn('function isOpenableSelection(item)', app_js) self.assertIn('async function downloadFileRequest(paths)', app_js) + self.assertIn('const zipDownload = isZipDownloadSelection(selectedItems);', app_js) + self.assertIn('openZipDownloadModal(selectedItems);', app_js) + self.assertIn('statusText: "preparing"', app_js) + self.assertIn('statusText: "packaging items"', app_js) + self.assertIn('statusText: "ready"', app_js) + self.assertIn('statusText: `failed: ${err.message}`', app_js) + self.assertIn('countText: "Step 1/3"', app_js) + self.assertIn('countText: "Step 2/3"', app_js) + self.assertIn('countText: "Step 3/3"', app_js) self.assertIn('function applyContextMenuSelection()', app_js) self.assertIn('function startContextMenuOpen()', app_js) self.assertIn('function startContextMenuEdit()', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 7495988..d0aa8ec 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -81,6 +81,11 @@ let uploadState = { conflictResolver: null, cancelRequested: false, }; +let downloadProgressState = { + active: false, + archiveLabel: "", + totalItems: 0, +}; let folderUploadPlanState = { targetPane: "left", targetPath: "", @@ -321,6 +326,18 @@ function feedbackElements() { }; } +function downloadModalElements() { + return { + overlay: document.getElementById("download-modal"), + target: document.getElementById("download-modal-target"), + currentFile: document.getElementById("download-modal-current-file"), + count: document.getElementById("download-modal-count"), + progressBar: document.getElementById("download-modal-progress-bar"), + status: document.getElementById("download-modal-status"), + closeButton: document.getElementById("download-modal-close-btn"), + }; +} + function contextMenuElements() { return { menu: document.getElementById("context-menu"), @@ -347,6 +364,111 @@ function isOpenableSelection(item) { return isImageSelection(item) || isVideoSelection(item); } +function isZipDownloadSelection(items) { + return items.length > 1 || (items.length === 1 && items[0].kind === "directory"); +} + +function selectedItemCountLabel(totalItems) { + return `${totalItems} selected item${totalItems === 1 ? "" : "s"}`; +} + +function isDownloadModalOpen() { + return !downloadModalElements().overlay.classList.contains("hidden"); +} + +function setDownloadModalVisible(visible) { + const elements = downloadModalElements(); + if (!elements.overlay) { + return; + } + elements.overlay.classList.toggle("hidden", !visible); +} + +function updateDownloadModalDisplay(info) { + const elements = downloadModalElements(); + if (!elements.overlay) { + return; + } + elements.target.textContent = info.targetText || ""; + elements.currentFile.textContent = info.currentFileText || ""; + elements.count.textContent = info.countText || ""; + elements.status.textContent = info.statusText || ""; + elements.progressBar.style.width = `${Math.max(0, Math.min(100, info.percent || 0))}%`; + elements.closeButton.disabled = !!info.active; + elements.closeButton.classList.toggle("hidden", !!info.active); +} + +function openZipDownloadModal(selectedItems) { + downloadProgressState.active = true; + downloadProgressState.archiveLabel = "ZIP archive"; + downloadProgressState.totalItems = selectedItems.length; + setDownloadModalVisible(true); + updateDownloadModalDisplay({ + active: true, + targetText: "Preparing ZIP download", + currentFileText: `Selection: ${selectedItemCountLabel(selectedItems.length)}`, + countText: "Step 1/3", + statusText: "preparing", + percent: 33, + }); + requestAnimationFrame(() => { + if (!downloadProgressState.active) { + return; + } + updateDownloadModalDisplay({ + active: true, + targetText: "Preparing ZIP download", + currentFileText: `Packaging ${selectedItemCountLabel(downloadProgressState.totalItems)}`, + countText: "Step 2/3", + statusText: "packaging items", + percent: 66, + }); + }); +} + +function markZipDownloadReady(fileName) { + downloadProgressState.active = false; + downloadProgressState.archiveLabel = fileName || "ZIP archive"; + updateDownloadModalDisplay({ + active: false, + targetText: `Ready: ${downloadProgressState.archiveLabel}`, + currentFileText: `Prepared ${selectedItemCountLabel(downloadProgressState.totalItems)}`, + countText: "Step 3/3", + statusText: "ready", + percent: 100, + }); + window.setTimeout(closeDownloadModal, 240); +} + +function markZipDownloadFailed(err) { + downloadProgressState.active = false; + updateDownloadModalDisplay({ + active: false, + targetText: "Preparing ZIP download", + currentFileText: `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`, + countText: "Step 2/3", + statusText: `failed: ${err.message}`, + percent: 66, + }); +} + +function closeDownloadModal() { + if (downloadProgressState.active) { + return; + } + downloadProgressState.archiveLabel = ""; + downloadProgressState.totalItems = 0; + updateDownloadModalDisplay({ + active: false, + targetText: "", + currentFileText: "", + countText: "", + statusText: "", + percent: 0, + }); + setDownloadModalVisible(false); +} + function isContextMenuOpen() { return contextMenuState.open && !contextMenuElements().menu.classList.contains("hidden"); } @@ -499,6 +621,10 @@ async function startDownloadSelected() { if (selectedItems.length === 0) { return; } + const zipDownload = isZipDownloadSelection(selectedItems); + if (zipDownload) { + openZipDownloadModal(selectedItems); + } try { const selected = selectedItems[0]; const { blob, fileName } = await downloadFileRequest(selectedItems.map((item) => item.path)); @@ -507,12 +633,20 @@ async function startDownloadSelected() { anchor.href = url; anchor.download = fileName || selected.name; document.body.append(anchor); + if (zipDownload) { + markZipDownloadReady(anchor.download); + } anchor.click(); anchor.remove(); URL.revokeObjectURL(url); setStatus(`Download started: ${anchor.download}`); } catch (err) { - setActionError("Download", err); + if (zipDownload) { + markZipDownloadFailed(err); + setStatus("Download failed"); + } else { + setActionError("Download", err); + } } } @@ -3615,6 +3749,13 @@ function handleKeyboardShortcuts(event) { } return; } + if (isDownloadModalOpen()) { + if (event.key === "Escape" && !downloadProgressState.active) { + event.preventDefault(); + closeDownloadModal(); + } + return; + } if (isInfoOpen()) { if (event.key === "Escape") { event.preventDefault(); @@ -3882,6 +4023,17 @@ function setupEvents() { } }; } + const downloadModal = downloadModalElements(); + if (downloadModal.closeButton) { + downloadModal.closeButton.onclick = closeDownloadModal; + } + if (downloadModal.overlay) { + downloadModal.overlay.onclick = (event) => { + if (event.target === downloadModal.overlay) { + closeDownloadModal(); + } + }; + } const contextMenu = contextMenuElements(); if (contextMenu.renameButton) { contextMenu.renameButton.onclick = startContextMenuRename; diff --git a/webui/html/base.css b/webui/html/base.css index c26925a..a0c30b0 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -772,6 +772,12 @@ button:disabled { text-align: left; } +#download-modal .popup-card { + max-width: 320px; + padding: 12px 14px; + text-align: left; +} + .upload-modal-progress { width: 100%; height: 4px; @@ -793,6 +799,27 @@ button:disabled { color: var(--color-text-muted); } +.download-modal-progress { + width: 100%; + height: 4px; + border-radius: 999px; + background: var(--color-border); + margin: 6px 0; + overflow: hidden; +} + +.download-modal-progress-bar { + height: 100%; + width: 0; + background: var(--color-accent); + transition: width 150ms ease; +} + +.download-modal-count { + font-size: 12px; + color: var(--color-text-muted); +} + .popup-meta { color: var(--color-text-muted); font-size: 12px; diff --git a/webui/html/index.html b/webui/html/index.html index 656a9c0..ed7b507 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -118,6 +118,22 @@ +
+