diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index 50028187..e4e3bbd7 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -434,11 +434,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op ctx.request_repaint(); }); })), - download: Some(Box::new(|name: &str, bytes: &[u8]| { - if let Err(e) = download(name, bytes) { - log::error!("download of {name} failed: {e}"); - } - })), + // A download that couldn't be started fails the save or export it is for (#1693). + download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))), inbox: Some(inbox), recovery_store: Some(Arc::new(BrowserStore { locks })), // File → Print: the browser's print dialog. @@ -517,7 +514,8 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { web_sys::Url::revoke_object_url(&url).ok(); }); - window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?; + // The download has started: a timer that can't be set leaves the URL allocated, no more. + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000); Ok(()) } diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index ba166874..555a1b83 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -41,10 +41,27 @@ pub const MESSAGE: &str = "message"; pub(super) const MESSAGE_SPEC: DialogSpec = DialogSpec { ok: None, ..SPEC }; +/// Most messages kept for later while a dialog is open ([`tell`]). +const MAX_WAITING: usize = 16; + /// Tell the user `message` (with an optional `detail` line), both in the UI language, in a dialog -/// they close. +/// they close. While another dialog is open (the message comes from work in the background) it +/// waits its turn instead of replacing it: neither what the user was doing there nor the message +/// is lost. pub fn tell(app: &mut VectorcraftApp, message: &str, detail: &str) { - app.ui.dialog = Some(Dialog::new(MESSAGE, json!({"message": message, "detail": detail}))); + let d = Dialog::new(MESSAGE, json!({"message": message, "detail": detail})); + if app.ui.dialog.is_none() { + app.ui.dialog = Some(d); + } else if app.ui.told.len() < MAX_WAITING && !app.ui.dialog.iter().chain(&app.ui.told).any(|t| t.kind == MESSAGE && t.fields == d.fields) { + app.ui.told.push(d); + } +} + +/// Show the next message that waited for a dialog to close (each frame). +pub(super) fn show_waiting(app: &mut VectorcraftApp) { + if app.ui.dialog.is_none() && !app.ui.told.is_empty() { + app.ui.dialog = Some(app.ui.told.remove(0)); + } } /// Closes before running, so a dialog the command opens stays open. diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 40d1ca19..5deb182a 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -366,6 +366,7 @@ pub(crate) fn settle(app: &mut VectorcraftApp) { pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { about::show(app, ctx); settle(app); + confirm::show_waiting(app); // The kind of dialog shown last frame: a different one (or none) means this one just opened, and // its first field is to take the keyboard focus (`focus_id`, until a field takes it). let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending")); diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index 92958d8a..0d5b5a06 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -268,7 +268,10 @@ mod tests { let written = Written::default(); let w = written.clone(); let services = Services { - download: Some(Box::new(move |name: &str, b: &[u8]| w.borrow_mut().push((name.to_string(), b.to_vec())))), + download: Some(Box::new(move |name: &str, b: &[u8]| { + w.borrow_mut().push((name.to_string(), b.to_vec())); + Ok(()) + })), ..Default::default() }; (with_art(services), written) diff --git a/crates/ui-egui/src/dialogs/tests_package.rs b/crates/ui-egui/src/dialogs/tests_package.rs index f9607395..ecd26301 100644 --- a/crates/ui-egui/src/dialogs/tests_package.rs +++ b/crates/ui-egui/src/dialogs/tests_package.rs @@ -95,7 +95,10 @@ fn the_web_downloads_a_zip() { let mut app = saved(&dir); let got: Rc>> = Rc::default(); let g = got.clone(); - app.services.download = Some(Box::new(move |name: &str, b: &[u8]| g.borrow_mut().push((name.to_string(), b.len())))); + app.services.download = Some(Box::new(move |name: &str, b: &[u8]| { + g.borrow_mut().push((name.to_string(), b.len())); + Ok(()) + })); app.run("ui.packageDialog", json!({})).unwrap(); assert!(!frame(&mut app).contains("Location:"), "no folder to choose"); confirm(&mut app).unwrap(); @@ -124,7 +127,7 @@ fn fonts_left_out_of_the_package_are_named() { assert!(detail.contains(gone) && !detail.contains("Source Sans 3"), "{detail}"); // The web's download says so too. app.ui.dialog = None; - app.services.download = Some(Box::new(|_: &str, _: &[u8]| {})); + app.services.download = Some(Box::new(|_: &str, _: &[u8]| Ok(()))); app.run("ui.packageDialog", json!({})).unwrap(); confirm(&mut app).unwrap(); assert!(app.ui.status.contains(gone), "{}", app.ui.status); diff --git a/crates/ui-egui/src/dialogs/tests_save_for_web.rs b/crates/ui-egui/src/dialogs/tests_save_for_web.rs index 164087a9..b13d36f2 100644 --- a/crates/ui-egui/src/dialogs/tests_save_for_web.rs +++ b/crates/ui-egui/src/dialogs/tests_save_for_web.rs @@ -24,7 +24,10 @@ fn app(dir: &std::path::Path, web: bool) -> (VectorcraftApp, Log) { ..Default::default() }; if web { - services.download = Some(Box::new(move |name: &str, b: &[u8]| l2.borrow_mut().push(format!("download {name} {}", b.len())))); + services.download = Some(Box::new(move |name: &str, b: &[u8]| { + l2.borrow_mut().push(format!("download {name} {}", b.len())); + Ok(()) + })); } let mut app = VectorcraftApp::new(Session::new(), services); app.run("file.new", json!({"width": 60, "height": 40})).unwrap(); diff --git a/crates/ui-egui/src/dialogs/tests_screen_assets.rs b/crates/ui-egui/src/dialogs/tests_screen_assets.rs index b052d165..9274b522 100644 --- a/crates/ui-egui/src/dialogs/tests_screen_assets.rs +++ b/crates/ui-egui/src/dialogs/tests_screen_assets.rs @@ -21,7 +21,10 @@ fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) { let l = log.clone(); let mut services = Services::default(); if web { - services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec())))); + services.download = Some(Box::new(move |name: &str, bytes: &[u8]| { + l.borrow_mut().push((name.to_string(), bytes.to_vec())); + Ok(()) + })); } else { services.reveal = Some(Box::new(move |path: &str| { l.borrow_mut().push((path.to_string(), vec![])); diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 506001ab..4341415c 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -21,7 +21,10 @@ fn app(artboards: usize, web: bool) -> (VectorcraftApp, Log) { let l = log.clone(); let mut services = Services::default(); if web { - services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec())))); + services.download = Some(Box::new(move |name: &str, bytes: &[u8]| { + l.borrow_mut().push((name.to_string(), bytes.to_vec())); + Ok(()) + })); } else { services.reveal = Some(Box::new(move |path: &str| { l.borrow_mut().push((path.to_string(), vec![])); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4b35d262..db12bd3a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3508,6 +3508,8 @@ effect Regular Pravidelný Serif / Mincho Patkové / mincho Changes made since it was last saved will be lost. Změny provedené od posledního uložení budou ztraceny. Can't open “{name}”. „{name}“ nelze otevřít. + Can't save “{name}”. „{name}“ nelze uložit. + Can't export “{name}”. „{name}“ nelze exportovat. Downloaded {what} Staženo: {what} Exported {count} file(s) to {folder} Exportováno souborů: {count} do {folder} Exported {what} Exportováno: {what} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 51b06bad..cf7bb1be 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -754,6 +754,8 @@ Do Not Move Nicht verschieben Do you want to save the changes you made to “{name}”? Möchten Sie die Änderungen an „{name}“ speichern? Can't open “{name}”. „{name}“ kann nicht geöffnet werden. + Can't save “{name}”. „{name}“ kann nicht gespeichert werden. + Can't export “{name}”. „{name}“ kann nicht exportiert werden. Document Dokument Document Color Mode Dokumentfarbmodus Document Info Dokumentinformationen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c9dba867..b04fc1f2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -768,6 +768,8 @@ Do Not Move No mover Do you want to save the changes you made to “{name}”? ¿Quieres guardar los cambios hechos en “{name}”? Can't open “{name}”. No se puede abrir «{name}». + Can't save “{name}”. No se puede guardar «{name}». + Can't export “{name}”. No se puede exportar «{name}». Document Documento Document Color Mode Modo de color del documento Document Info Información del documento diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index cccd38e7..4176af3a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -770,6 +770,8 @@ Do Not Move Ne pas déplacer Do you want to save the changes you made to “{name}”? Voulez-vous enregistrer les modifications apportées à « {name} » ? Can't open “{name}”. Impossible d’ouvrir « {name} ». + Can't save “{name}”. Impossible d’enregistrer « {name} ». + Can't export “{name}”. Impossible d’exporter « {name} ». Document Document Document Color Mode Mode colorimétrique du document Document Info Informations sur le document diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 89d39aae..ec2e69fd 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -485,6 +485,8 @@ Calligraphic Kalligrafikus Calligraphic Brush Options Kalligrafikus ecset beállításai Can't open “{name}”. Nem nyitható meg: „{name}”. + Can't save “{name}”. Nem menthető: „{name}”. + Can't export “{name}”. Nem exportálható: „{name}”. Can't print these settings: see the Summary Ezekkel a beállításokkal nem lehet nyomtatni: lásd az Összegzést Cancel Mégse Cancel Key Object Kulcsobjektum megszüntetése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 494252d0..b414c815 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -768,6 +768,8 @@ Do Not Move Non spostare Do you want to save the changes you made to “{name}”? Vuoi salvare le modifiche apportate a “{name}”? Can't open “{name}”. Impossibile aprire «{name}». + Can't save “{name}”. Impossibile salvare «{name}». + Can't export “{name}”. Impossibile esportare «{name}». Document Documento Document Color Mode Metodo colore documento Document Info Informazioni documento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index edb50b54..90b7b3c6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1223,6 +1223,8 @@ Divide Vertically Into 垂直方向に分割 Do you want to save the changes you made to “{name}”? 「{name}」への変更を保存しますか? Can't open “{name}”. 「{name}」を開けません。 + Can't save “{name}”. 「{name}」を保存できません。 + Can't export “{name}”. 「{name}」を書き出せません。 Document ドキュメント Document Ink Options ドキュメントのインキオプション Document JSON ドキュメント JSON diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 14070de7..02b1d58b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1218,6 +1218,8 @@ Divide Vertically Into 세로로 나누기 Do you want to save the changes you made to “{name}”? “{name}”의 변경 내용을 저장하시겠습니까? Can't open “{name}”. “{name}”을(를) 열 수 없습니다. + Can't save “{name}”. “{name}”을(를) 저장할 수 없습니다. + Can't export “{name}”. “{name}”을(를) 내보낼 수 없습니다. Document 문서 Document Ink Options 문서 잉크 옵션 Document JSON 문서 JSON diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 869e2a9b..c98e48a5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3391,6 +3391,8 @@ effect Regular Normalny Serif / Mincho Szeryfowe / mincho Changes made since it was last saved will be lost. Zmiany wprowadzone od ostatniego zapisu zostaną utracone. Can't open “{name}”. Nie można otworzyć „{name}”. + Can't save “{name}”. Nie można zapisać „{name}”. + Can't export “{name}”. Nie można wyeksportować „{name}”. Downloaded {what} Pobrano {what} Exported {count} file(s) to {folder} Wyeksportowane pliki do {folder}: {count} Exported {what} Wyeksportowano {what} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e5b3e964..c39b61c7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -692,6 +692,8 @@ Divide Vertically Into Dividir Verticalmente Em Do you want to save the changes you made to “{name}”? Deseja salvar as alterações feitas em “{name}”? Can't open “{name}”. Não é possível abrir “{name}”. + Can't save “{name}”. Não é possível salvar “{name}”. + Can't export “{name}”. Não é possível exportar “{name}”. Document Documento Document Color Mode Modo de Cor do Documento Document Info Informações do Documento diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1b92d00d..e4a9a39f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -766,6 +766,8 @@ Do Not Move Не перемещать Do you want to save the changes you made to “{name}”? Сохранить изменения, внесённые в «{name}»? Can't open “{name}”. Не удаётся открыть «{name}». + Can't save “{name}”. Не удаётся сохранить «{name}». + Can't export “{name}”. Не удаётся экспортировать «{name}». Document Документ Document Color Mode Цветовой режим документа Document Info Сведения о документе diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 91999762..268d1933 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -762,6 +762,8 @@ Do Not Move Не переміщати Do you want to save the changes you made to “{name}”? Зберегти зміни, внесені до «{name}»? Can't open “{name}”. Не вдається відкрити «{name}». + Can't save “{name}”. Не вдається зберегти «{name}». + Can't export “{name}”. Не вдається експортувати «{name}». Document Документ Document Color Mode Колірний режим документа Document Info Відомості про документ diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 44dfae0a..63cf26f6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -401,6 +401,8 @@ Calligraphic Thư pháp Calligraphic Brush Options Tùy chọn cọ thư pháp Can't open “{name}”. Không thể mở “{name}”. + Can't save “{name}”. Không thể lưu “{name}”. + Can't export “{name}”. Không thể xuất “{name}”. Can't print these settings: see the Summary Không thể in các cài đặt này: xem Tóm tắt Cancel Hủy Cancel Key Object Hủy đối tượng chính diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0ed1c633..6ac0c6a7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -723,6 +723,8 @@ Do Not Move 不移动 Do you want to save the changes you made to “{name}”? 要存储对“{name}”所做的更改吗? Can't open “{name}”. 无法打开“{name}”。 + Can't save “{name}”. 无法保存“{name}”。 + Can't export “{name}”. 无法导出“{name}”。 Document 文档 Document Color Mode 文档颜色模式 Document Info 文档信息 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 70e2d1a0..6b769b8a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -741,6 +741,8 @@ Do Not Move 不移動 Do you want to save the changes you made to “{name}”? 要儲存對「{name}」所做的變更嗎? Can't open “{name}”. 無法開啟「{name}」。 + Can't save “{name}”. 無法儲存「{name}」。 + Can't export “{name}”. 無法匯出「{name}」。 Document 文件 Document Color Mode 文件色彩模式 Document Info 文件資訊 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 314462c3..d5dd5b5d 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -167,6 +167,29 @@ pub fn report_open_error(app: &mut VectorcraftApp, name: &str, e: &str) { dialogs::confirm::tell(app, &message, &crate::i18n::message(crate::i18n::current(), e)); } +/// A save or an export that didn't write `path`: say so in a dialog that names the file and the +/// reason and stays until it is closed, as an editor does (#1685). The status bar's line, which +/// the caller's error becomes, is replaced by the next message, and a document that wasn't saved +/// must not look saved: it stays modified, and what was at `path` is as it was. Not for a request +/// an agent sent and waits on: the error is its reply. +pub(crate) fn report_write_error(app: &mut VectorcraftApp, export: bool, path: &str, e: &str) { + if app.ui.answering_control { + return; + } + let name = fileio::file_name(path); + let message = if export { tl!("Can't export “{name}”.") } else { tl!("Can't save “{name}”.") }; + dialogs::confirm::tell(app, &crate::i18n::fmt(message, &[("name", &name)]), &crate::i18n::message(crate::i18n::current(), e)); +} + +/// [`write_to`], a failure reported ([`report_write_error`]). +pub(crate) fn write_reporting(app: &mut VectorcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> { + let r = write_to(&mut app.services, path, bytes); + if let Err(e) = &r { + report_write_error(app, false, path, e); + } + r +} + /// [`open_path`] for a file the user asked for, a failure reported ([`report_open_error`]). On /// the desktop a document is decoded off the UI thread ([`crate::opening`]): the result is then /// `{background: true, status}`, and the document opens in its tab (or the failure is reported) @@ -239,8 +262,7 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option) -> Resu /// Web: download `bytes` under `path`'s file name; desktop: write them to `path`. pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> { if let Some(dl) = services.download.as_mut() { - dl(&fileio::file_name(path), bytes); - return Ok(()); + return dl(&fileio::file_name(path), bytes); } services.write.as_mut().ok_or("no file writer")?(path, bytes) } @@ -260,7 +282,7 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option, name: pick_path(app, &FilePick { name: name.to_string(), folder, ..Default::default() })? } }; - write_to(&mut app.services, &path, bytes)?; + write_reporting(app, &path, bytes)?; Ok(path) } @@ -516,8 +538,10 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result } .map_err(|e| e.to_string()) }; + let failed = target.clone().unwrap_or_default(); let then = move |app: &mut VectorcraftApp, r: Result| { - let r = r?; + // Not written: the document keeps its file, format and modified state, and the user is told. + let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; // The document may have closed meanwhile: the file is written all the same. done.complete(&mut app.session, uid); let path = r["path"].as_str().unwrap_or_default().to_string(); @@ -602,8 +626,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option format!("{} files", files.len()), None => r["path"].as_str().unwrap_or_default().to_string(), @@ -673,7 +698,7 @@ pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut par Some(p) => p, None => target_path(app, None, ext)?, }; - write_to(&mut app.services, &path, &bytes)?; + write_reporting(app, &path, &bytes)?; Ok((path, v)) } @@ -768,7 +793,7 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> } else if let Some(download) = app.services.download.as_mut() { for (name, data) in &returned { let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?; - download(name, &bytes); + download(name, &bytes)?; } let what = match returned.as_slice() { [(name, _)] => (*name).to_string(), @@ -830,6 +855,91 @@ mod tests { assert!(app.ui.dialog.is_none()); } + /// #1685: a save or an export that can't write its file says so in a dialog that names the + /// file and the reason and stays until it is closed; the document stays modified and keeps + /// the file it had. An agent's request only gets the error, and a message that arrives while + /// another dialog is open waits for it to close. + #[test] + fn a_save_that_cannot_write_is_reported_in_a_dialog_that_stays() { + let refuse: crate::WriteFn = Box::new(|p: &str, _: &[u8]| Err(format!("{p}: Access is denied. (os error 5)"))); + let mut app = VectorcraftApp::new(Session::new(), Services { write: Some(refuse), ..Default::default() }); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + let unsaved = |app: &VectorcraftApp| { + let st = app.session.doc().unwrap(); + (st.is_dirty(), st.path.clone()) + }; + for (command, path, message) in [ + ("file.save", "/docs/Poster.vectorcraft", "Can't save “Poster.vectorcraft”."), + ("file.saveAs", "/docs/Poster.vectorcraft", "Can't save “Poster.vectorcraft”."), + ("file.saveCopy", "/docs/Copy.vectorcraft", "Can't save “Copy.vectorcraft”."), + ("file.exportAs", "/docs/Poster.png", "Can't export “Poster.png”."), + ("file.export.pdf", "/docs/Poster.pdf", "Can't save “Poster.pdf”."), + ] { + let e = app.run(command, json!({"path": path})).unwrap_err(); + assert!(e.contains("Access is denied"), "{command}: {e}"); + let d = app.ui.dialog.take().unwrap_or_else(|| panic!("{command}: a dialog says so")); + assert_eq!((d.kind.as_str(), d.str("message").as_str()), (dialogs::confirm::MESSAGE, message), "{command}"); + assert_eq!(d.str("detail"), format!("{path}: Access is denied. (os error 5)"), "{command}"); + assert_eq!(unsaved(&app), (true, None), "{command}"); + } + // An agent waiting on its request gets the error as its reply, and no dialog. + app.ui.answering_control = true; + assert!(app.run("file.save", json!({"path": "/docs/Poster.vectorcraft"})).is_err()); + app.ui.answering_control = false; + assert!(app.ui.dialog.is_none() && app.ui.told.is_empty()); + // Under another dialog (a background save that fails later) the message waits its turn, + // once, and is drawn when that dialog is closed. + dialogs::confirm::ask(&mut app, "Delete the swatch?", "", "edit.undo", json!({})); + for _ in 0..2 { + report_write_error(&mut app, false, "/docs/Poster.vectorcraft", "the disk is full"); + } + assert_eq!((app.ui.dialog.as_ref().map(|d| d.kind.as_str()), app.ui.told.len()), (Some(dialogs::confirm::KIND), 1)); + app.ui.dialog = None; + for _ in 0..2 { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + } + let d = app.ui.dialog.as_ref().expect("the message is shown, and stays"); + assert_eq!( + (d.kind.as_str(), d.str("message").as_str(), d.str("detail").as_str()), + (dialogs::confirm::MESSAGE, "Can't save “Poster.vectorcraft”.", "the disk is full") + ); + assert!(app.ui.told.is_empty()); + } + + /// #1693: a browser download that can't be started is a failed save: the document stays + /// modified and without a file, and Save Changes doesn't close it. + #[test] + fn a_download_that_cannot_start_leaves_the_document_unsaved_and_open() { + let started = Rc::new(RefCell::new(false)); + let ok = started.clone(); + let download: crate::DownloadFn = + Box::new(move |_: &str, _: &[u8]| if *ok.borrow() { Ok(()) } else { Err("the browser refused the download".into()) }); + let mut app = VectorcraftApp::new(Session::new(), Services { download: Some(download), ..Default::default() }); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + let unsaved = |app: &VectorcraftApp| { + let st = app.session.doc().unwrap(); + (st.is_dirty(), st.path.clone()) + }; + let e = save(&mut app, SaveMode::Save, &json!({"path": "Poster.vectorcraft"}), false).unwrap_err(); + assert!(e.contains("the browser refused the download"), "{e}"); + assert_eq!(unsaved(&app), (true, None)); + // Save Changes › Save: not saved, so not closed. + let (i, uid) = (app.session.active_index().unwrap(), app.session.doc().unwrap().uid); + app.ui.dialog = Some(crate::state::Dialog::new(crate::unsaved::KIND, json!({"index": i, "uid": uid, "name": "Poster", "then": "close"}))); + app.session.doc_mut().unwrap().path = Some("Poster.vectorcraft".into()); + assert!(crate::unsaved::confirm(&mut app).is_err()); + assert_eq!((app.session.documents().len(), app.session.doc().unwrap().is_dirty()), (1, true)); + // Once the download starts, the save counts. + *started.borrow_mut() = true; + save(&mut app, SaveMode::Save, &json!({}), false).unwrap(); + assert!(!app.session.doc().unwrap().is_dirty()); + } + fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec { let v = app.session.execute(cmd, &p).unwrap(); vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e3e953cc..012decbf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -188,7 +188,9 @@ pub type PickSave = Box Option>; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; pub type Inbox = Arc)>>>; -pub type DownloadFn = Box; +/// Starts a browser download of the bytes under a file name; an error when it couldn't be started +/// (the save or export then fails as a refused write does: #1693). +pub type DownloadFn = Box Result<(), String>>; /// Opens a URL in the system browser. pub type OpenUrlFn = Box; @@ -778,7 +780,10 @@ impl VectorcraftApp { let reply = req.reply.clone(); // Guarded per request: a panic must not drop the channel (taken out of `self` above). let roots = self.automation_roots.clone(); - let outcome = match file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))) { + self.ui.answering_control = true; + let answer = file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))); + self.ui.answering_control = false; + let outcome = match answer { Ok(outcome) => outcome, Err(msg) => { self.picks.recover(); diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 99fb9c23..62f4ef08 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -68,8 +68,14 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result (VectorcraftApp, Log, [u64; 2]) { let l = log.clone(); let mut services = Services::default(); if web { - services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec())))); + services.download = Some(Box::new(move |name: &str, bytes: &[u8]| { + l.borrow_mut().push((name.to_string(), bytes.to_vec())); + Ok(()) + })); } let mut app = VectorcraftApp::new(Session::new(), services); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 612f1370..7602867b 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -485,10 +485,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("cancelled".into()), None => match app.services.download.as_mut() { - Some(dl) => { - dl("VectorCraft Shortcuts.json", &bytes); - Ok(Value::Null) - } + Some(dl) => dl("VectorCraft Shortcuts.json", &bytes).map(|()| Value::Null), None => Ok(v), }, } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e4eb10a0..1fc6f089 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -306,6 +306,14 @@ pub struct UiState { pub dock: bool, pub view: ViewFlags, pub dialog: Option, + /// Messages to show in a dialog once the open one is closed (`dialogs::confirm::tell`), in + /// the order they came. + #[serde(skip)] + pub told: Vec, + /// A control request is being answered: its caller gets what fails as its reply, so no + /// dialog says it too (`io::report_write_error`). + #[serde(skip)] + pub answering_control: bool, /// Toolbar group whose flyout is open. pub flyout: Option, /// Last tool shown for each toolbar group (flyout selection sticks). @@ -681,6 +689,8 @@ impl Default for UiState { dock: true, view: ViewFlags::default(), dialog: None, + told: vec![], + answering_control: false, flyout: None, group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(), status: String::new(), diff --git a/crates/ui-egui/src/tests_nativeoptions.rs b/crates/ui-egui/src/tests_nativeoptions.rs index aec94be4..59b8c91d 100644 --- a/crates/ui-egui/src/tests_nativeoptions.rs +++ b/crates/ui-egui/src/tests_nativeoptions.rs @@ -86,7 +86,10 @@ fn the_web_save_as_dialog_shows_the_native_options() { let written = crate::tests_svg::Written::default(); let w = written.clone(); let services = crate::Services { - download: Some(Box::new(move |name: &str, b: &[u8]| w.borrow_mut().push((name.to_string(), b.to_vec())))), + download: Some(Box::new(move |name: &str, b: &[u8]| { + w.borrow_mut().push((name.to_string(), b.to_vec())); + Ok(()) + })), ..Default::default() }; let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), services);