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