Add ZIP download toast feedback

Allow display_time=0 to create persistent grouped toasts that remain until dismissed or replaced. Show immediate grouped Preparing download and Downloading feedback for backup ZIPs and file-browser directory/bulk ZIP downloads, with error replacement and focused static coverage.

Alessandro committed May 7, 2026 at 18:43 UTC 764339b5e797b4e7fc974065365f5aba8c1d89d6
5 files changed +180 -22
api/notification_create.py
+3 -3
@@ -26,10 +26,10 @@ class NotificationCreate(ApiHandler):
26 # Validate display_time
27 try:
28 display_time = int(display_time)
29 - if display_time <= 0:
30 - display_time = 3 # Reset to default if invalid
29 + if display_time < 0:
30 + display_time = 3 # Reset to default if negative
31 except (ValueError, TypeError):
32 - display_time = 3 # Reset to default if not convertible to int
32 + display_time = 3 # Reset to default if not numeric
33
34 # Validate notification type
35 try:
tests/test_download_toast_regressions.py new
+63
@@ -0,0 +1,63 @@
1 +from pathlib import Path
2 +
3 +
4 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
5 +
6 +
7 +def read(*parts: str) -> str:
8 + return PROJECT_ROOT.joinpath(*parts).read_text(encoding="utf-8")
9 +
10 +
11 +def test_notification_store_supports_persistent_grouped_toasts():
12 + store = read("webui", "components", "notifications", "notification-store.js")
13 + api = read("api", "notification_create.py")
14 +
15 + assert "isPersistentToast(toast)" in store
16 + assert "return this.getToastDisplayTime(toast) <= 0;" in store
17 + assert store.count("if (this.isPersistentToast(toast)) return;") >= 2
18 + assert "this.restartToastTimer(toast.toastId);" in store
19 + assert "this.removeFromToastStack(existingToast.toastId);" in store
20 + assert "if display_time < 0:" in api
21 + assert "if display_time <= 0:" not in api
22 +
23 +
24 +def test_backup_zip_downloads_emit_grouped_preparing_and_downloading_toasts():
25 + store = read("webui", "components", "settings", "backup", "backup-store.js")
26 +
27 + assert 'window.toastFrontendInfo?.("Preparing download...", "Download", 0, group, undefined, true);' in store
28 + assert 'window.toastFrontendInfo?.("Downloading...", "Download", 3, group, undefined, true);' in store
29 + assert 'window.toastFrontendError?.(message || "Download failed", "Download Error", 8, group, undefined, true);' in store
30 + assert 'this.createDownloadToastGroup("backup-create")' in store
31 + assert 'this.createDownloadToastGroup("backup-download")' in store
32 +
33 + create_start = store.index("async createBackup()")
34 + create_prepare = store.index("this.showDownloadPreparingToast(downloadToastGroup);", create_start)
35 + create_fetch = store.index("const response = await fetchApi('/backup_create'", create_start)
36 + assert create_prepare < create_fetch
37 +
38 + download_start = store.index("async downloadBackup")
39 + download_prepare = store.index("this.showDownloadPreparingToast(downloadToastGroup);", download_start)
40 + download_fetch = store.index("const response = await fetchApi('/backup_download'", download_start)
41 + assert download_prepare < download_fetch
42 +
43 +
44 +def test_file_browser_zip_downloads_emit_grouped_preparing_and_downloading_toasts():
45 + store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
46 +
47 + assert 'window.toastFrontendInfo?.("Preparing download...", "Download", 0, group, undefined, true);' in store
48 + assert 'window.toastFrontendInfo?.("Downloading...", "Download", 3, group, undefined, true);' in store
49 + assert 'this.createDownloadToastGroup("file-browser-bulk-download")' in store
50 + assert 'this.createDownloadToastGroup("file-browser-directory-download")' in store
51 + assert "if (file.is_dir) {" in store
52 + assert "return this.downloadDirectory(file);" in store
53 + assert "link.download = file.name;" in store
54 +
55 + bulk_start = store.index("async bulkDownloadFiles()")
56 + bulk_prepare = store.index("this.showDownloadPreparingToast(downloadToastGroup);", bulk_start)
57 + bulk_fetch = store.index('const resp = await fetchApi("/download_work_dir_files"', bulk_start)
58 + assert bulk_prepare < bulk_fetch
59 +
60 + directory_start = store.index("async downloadDirectory(file)")
61 + directory_prepare = store.index("this.showDownloadPreparingToast(downloadToastGroup);", directory_start)
62 + directory_fetch = store.index("const resp = await fetchApi(`/download_work_dir_file", directory_start)
63 + assert directory_prepare < directory_fetch
webui/components/modals/file-browser/file-browser-store.js
+60 -8
@@ -610,14 +610,32 @@ const model = {
610 return asciiMatch?.[1] || fallback;
611 },
612
613 + createDownloadToastGroup(prefix) {
614 + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
615 + },
616 +
617 + showDownloadPreparingToast(group) {
618 + window.toastFrontendInfo?.("Preparing download...", "Download", 0, group, undefined, true);
619 + },
620 +
621 + showDownloadStartedToast(group) {
622 + window.toastFrontendInfo?.("Downloading...", "Download", 3, group, undefined, true);
623 + },
624 +
625 + showDownloadErrorToast(group, message) {
626 + window.toastFrontendError?.(message || "Download failed", "Download Error", 8, group, undefined, true);
627 + },
628 +
629 async bulkDownloadFiles() {
630 const selectedFiles = this.selectedFiles;
631 if (!selectedFiles.length || this.isBulkBusy) return;
632
633 this.isBulkBusy = true;
634 this.closeDropdown();
635 + const downloadToastGroup = this.createDownloadToastGroup("file-browser-bulk-download");
636
637 try {
638 + this.showDownloadPreparingToast(downloadToastGroup);
639 const resp = await fetchApi("/download_work_dir_files", {
640 method: "POST",
641 headers: { "Content-Type": "application/json" },
@@ -643,14 +661,11 @@ const model = {
661 document.body.removeChild(link);
662 setTimeout(() => URL.revokeObjectURL(url), 0);
663
646 - window.toastFrontendSuccess(
647 - `Prepared ${selectedFiles.length} ${selectedFiles.length === 1 ? "item" : "items"} as ZIP`,
648 - "File Browser"
649 - );
664 + this.showDownloadStartedToast(downloadToastGroup);
665 } catch (error) {
651 - window.toastFrontendError(
652 - error?.message || "Failed to download selected files",
653 - "File Browser"
666 + this.showDownloadErrorToast(
667 + downloadToastGroup,
668 + error?.message || "Failed to download selected files"
669 );
670 } finally {
671 this.isBulkBusy = false;
@@ -756,10 +771,47 @@ const model = {
771 }
772 },
773
774 + async downloadDirectory(file) {
775 + const downloadToastGroup = this.createDownloadToastGroup("file-browser-directory-download");
776 +
777 + try {
778 + this.showDownloadPreparingToast(downloadToastGroup);
779 + const resp = await fetchApi(`/download_work_dir_file?path=${encodeURIComponent(file.path)}`, {
780 + method: "GET",
781 + });
782 +
783 + if (!resp.ok) {
784 + const message = await resp.text();
785 + throw new Error(message || "Download failed");
786 + }
787 +
788 + const blob = await resp.blob();
789 + const url = URL.createObjectURL(blob);
790 + const fallback = `${file.name}.zip`;
791 + const link = document.createElement("a");
792 + link.href = url;
793 + link.download = this.getDownloadFilename(resp, fallback);
794 + document.body.appendChild(link);
795 + link.click();
796 + document.body.removeChild(link);
797 + setTimeout(() => URL.revokeObjectURL(url), 0);
798 + this.showDownloadStartedToast(downloadToastGroup);
799 + } catch (error) {
800 + this.showDownloadErrorToast(
801 + downloadToastGroup,
802 + error?.message || "Failed to download directory"
803 + );
804 + }
805 + },
806 +
807 downloadFile(file) {
808 + if (file.is_dir) {
809 + return this.downloadDirectory(file);
810 + }
811 +
812 const link = document.createElement("a");
813 link.href = `/api/download_work_dir_file?path=${encodeURIComponent(file.path)}`;
762 - link.download = file.is_dir ? `${file.name}.zip` : file.name;
814 + link.download = file.name;
815 document.body.appendChild(link);
816 link.click();
817 document.body.removeChild(link);
webui/components/notifications/notification-store.js
+22 -10
@@ -111,6 +111,15 @@ const model = {
111 }
112 },
113
114 + getToastDisplayTime(toast) {
115 + const displayTime = Number(toast?.display_time);
116 + return Number.isFinite(displayTime) ? displayTime : 3;
117 + },
118 +
119 + isPersistentToast(toast) {
120 + return this.getToastDisplayTime(toast) <= 0;
121 + },
122 +
123 // NEW: Add notification to toast stack
124 addToToastStack(notification) {
125 // If notification has a group, remove any existing toasts with the same group
@@ -148,6 +157,8 @@ const model = {
157 if (toastIndex < 0) return;
158
159 const toast = this.toastStack[toastIndex];
160 + if (this.isPersistentToast(toast)) return;
161 +
162 if (toast.autoRemoveTimer) {
163 clearTimeout(toast.autoRemoveTimer);
164 toast.autoRemoveTimer = null;
@@ -159,11 +170,12 @@ const model = {
170 if (toastIndex < 0) return;
171
172 const toast = this.toastStack[toastIndex];
173 + if (this.isPersistentToast(toast)) return;
174
175 this.clearToastTimer(toastId);
176 toast.autoRemoveTimer = setTimeout(() => {
177 this.removeFromToastStack(toast.toastId);
166 - }, toast.display_time * 1000);
178 + }, this.getToastDisplayTime(toast) * 1000);
179 },
180
181 // NEW: Remove toast from stack
@@ -173,6 +185,7 @@ const model = {
185 const toast = this.toastStack[index];
186 if (toast.autoRemoveTimer) {
187 clearTimeout(toast.autoRemoveTimer);
188 + toast.autoRemoveTimer = null;
189 }
190 this.toastStack.splice(index, 1);
191
@@ -198,8 +211,9 @@ const model = {
211 this.toastStack.forEach((toast) => {
212 if (toast.autoRemoveTimer) {
213 clearTimeout(toast.autoRemoveTimer);
201 - if (withCallback) this.afterToastRemoved(toast, removedByUser);
214 + toast.autoRemoveTimer = null;
215 }
216 + if (withCallback) this.afterToastRemoved(toast, removedByUser);
217 });
218 this.toastStack = [];
219 },
@@ -208,8 +222,11 @@ const model = {
222 cleanupExpiredToasts() {
223 const now = Date.now();
224 this.toastStack = this.toastStack.filter((toast) => {
225 + if (this.isPersistentToast(toast)) {
226 + return true;
227 + }
228 const age = now - toast.addedAt;
212 - const maxAge = toast.display_time * 1000;
229 + const maxAge = this.getToastDisplayTime(toast) * 1000;
230
231 if (age > maxAge) {
232 if (toast.autoRemoveTimer) {
@@ -624,10 +641,7 @@ const model = {
641
642 if (existingToastIndex >= 0) {
643 const existingToast = this.toastStack[existingToastIndex];
627 - if (existingToast.autoRemoveTimer) {
628 - clearTimeout(existingToast.autoRemoveTimer);
629 - }
630 - this.toastStack.splice(existingToastIndex, 1);
644 + this.removeFromToastStack(existingToast.toastId);
645 }
646 }
647
@@ -653,9 +667,7 @@ const model = {
667 }
668
669 // Set auto-dismiss timer
656 - toast.autoRemoveTimer = setTimeout(() => {
657 - this.removeFromToastStack(toast.toastId);
658 - }, notification.display_time * 1000);
670 + this.restartToastTimer(toast.toastId);
671
672 return notification.id;
673 },
webui/components/settings/backup/backup-store.js
+32 -1
@@ -86,6 +86,22 @@ const model = {
86 this.fileOperationsLog = '';
87 },
88
89 + createDownloadToastGroup(prefix) {
90 + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
91 + },
92 +
93 + showDownloadPreparingToast(group) {
94 + window.toastFrontendInfo?.("Preparing download...", "Download", 0, group, undefined, true);
95 + },
96 +
97 + showDownloadStartedToast(group) {
98 + window.toastFrontendInfo?.("Downloading...", "Download", 3, group, undefined, true);
99 + },
100 +
101 + showDownloadErrorToast(group, message) {
102 + window.toastFrontendError?.(message || "Download failed", "Download Error", 8, group, undefined, true);
103 + },
104 +
105 // Cleanup method for modal close
106 onClose() {
107 this.resetState();
@@ -389,12 +405,15 @@ const model = {
405 return;
406 }
407
408 + const downloadToastGroup = this.createDownloadToastGroup("backup-create");
409 +
410 try {
411 this.loading = true;
412 this.loadingMessage = 'Creating backup...';
413 this.error = '';
414 this.clearFileOperations();
415 this.addFileOperation('Starting backup creation...');
416 + this.showDownloadPreparingToast(downloadToastGroup);
417
418 const metadata = this.backupMetadataConfig;
419
@@ -421,7 +440,7 @@ const model = {
440 window.URL.revokeObjectURL(url);
441
442 this.addFileOperation('Backup created and downloaded successfully!');
424 - window.toastFrontendInfo('Backup created and downloaded successfully', 'Backup Status');
443 + this.showDownloadStartedToast(downloadToastGroup);
444 } else {
445 // Try to parse error response
446 const errorText = await response.text();
@@ -432,18 +451,23 @@ const model = {
451 this.error = `Backup creation failed: ${response.status} ${response.statusText}`;
452 }
453 this.addFileOperation(`Error: ${this.error}`);
454 + this.showDownloadErrorToast(downloadToastGroup, this.error);
455 }
456
457 } catch (error) {
458 this.error = `Backup error: ${error.message}`;
459 this.addFileOperation(`Error: ${error.message}`);
460 + this.showDownloadErrorToast(downloadToastGroup, this.error);
461 } finally {
462 this.loading = false;
463 }
464 },
465
466 async downloadBackup(backupPath, backupName) {
467 + const downloadToastGroup = this.createDownloadToastGroup("backup-download");
468 +
469 try {
470 + this.showDownloadPreparingToast(downloadToastGroup);
471 const response = await fetchApi('/backup_download', {
472 method: 'POST',
473 headers: { 'Content-Type': 'application/json' },
@@ -458,9 +482,16 @@ const model = {
482 a.download = `${backupName}.zip`;
483 a.click();
484 window.URL.revokeObjectURL(url);
485 + this.showDownloadStartedToast(downloadToastGroup);
486 + } else {
487 + const errorText = await response.text();
488 + this.error = errorText || `Download failed: ${response.status}`;
489 + this.showDownloadErrorToast(downloadToastGroup, this.error);
490 }
491 } catch (error) {
492 console.error('Download error:', error);
493 + this.error = error.message || 'Download failed';
494 + this.showDownloadErrorToast(downloadToastGroup, this.error);
495 }
496 },
497