Merge pull request #1865 from storytold/fix/1685-save-failure-dialog

Fix #1693, #1694, #1685: a failed save is reported in a dialog that stays; failed downloads; opens under a dialog
This commit is contained in:
BFlat 2026-10-14 09:51:48 +07:00 committed by GitHub
commit ecf86ca21e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 262 additions and 35 deletions

View File

@ -434,11 +434,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
ctx.request_repaint(); ctx.request_repaint();
}); });
})), })),
download: Some(Box::new(|name: &str, bytes: &[u8]| { // A download that couldn't be started fails the save or export it is for (#1693).
if let Err(e) = download(name, bytes) { download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))),
log::error!("download of {name} failed: {e}");
}
})),
inbox: Some(inbox), inbox: Some(inbox),
recovery_store: Some(Arc::new(BrowserStore { locks })), recovery_store: Some(Arc::new(BrowserStore { locks })),
// File → Print: the browser's print dialog. // 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 || { let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
web_sys::Url::revoke_object_url(&url).ok(); 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(()) Ok(())
} }

View File

@ -41,10 +41,27 @@ pub const MESSAGE: &str = "message";
pub(super) const MESSAGE_SPEC: DialogSpec = DialogSpec { ok: None, ..SPEC }; 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 /// 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) { 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. /// Closes before running, so a dialog the command opens stays open.

View File

@ -366,6 +366,7 @@ pub(crate) fn settle(app: &mut VectorcraftApp) {
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
about::show(app, ctx); about::show(app, ctx);
settle(app); settle(app);
confirm::show_waiting(app);
// The kind of dialog shown last frame: a different one (or none) means this one just opened, and // 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). // 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")); let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending"));

View File

@ -268,7 +268,10 @@ mod tests {
let written = Written::default(); let written = Written::default();
let w = written.clone(); let w = written.clone();
let services = Services { 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() ..Default::default()
}; };
(with_art(services), written) (with_art(services), written)

View File

@ -95,7 +95,10 @@ fn the_web_downloads_a_zip() {
let mut app = saved(&dir); let mut app = saved(&dir);
let got: Rc<RefCell<Vec<(String, usize)>>> = Rc::default(); let got: Rc<RefCell<Vec<(String, usize)>>> = Rc::default();
let g = got.clone(); 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(); app.run("ui.packageDialog", json!({})).unwrap();
assert!(!frame(&mut app).contains("Location:"), "no folder to choose"); assert!(!frame(&mut app).contains("Location:"), "no folder to choose");
confirm(&mut app).unwrap(); 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}"); assert!(detail.contains(gone) && !detail.contains("Source Sans 3"), "{detail}");
// The web's download says so too. // The web's download says so too.
app.ui.dialog = None; 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(); app.run("ui.packageDialog", json!({})).unwrap();
confirm(&mut app).unwrap(); confirm(&mut app).unwrap();
assert!(app.ui.status.contains(gone), "{}", app.ui.status); assert!(app.ui.status.contains(gone), "{}", app.ui.status);

View File

@ -24,7 +24,10 @@ fn app(dir: &std::path::Path, web: bool) -> (VectorcraftApp, Log) {
..Default::default() ..Default::default()
}; };
if web { 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); let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 60, "height": 40})).unwrap(); app.run("file.new", json!({"width": 60, "height": 40})).unwrap();

View File

@ -21,7 +21,10 @@ fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) {
let l = log.clone(); let l = log.clone();
let mut services = Services::default(); let mut services = Services::default();
if web { 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 { } else {
services.reveal = Some(Box::new(move |path: &str| { services.reveal = Some(Box::new(move |path: &str| {
l.borrow_mut().push((path.to_string(), vec![])); l.borrow_mut().push((path.to_string(), vec![]));

View File

@ -21,7 +21,10 @@ fn app(artboards: usize, web: bool) -> (VectorcraftApp, Log) {
let l = log.clone(); let l = log.clone();
let mut services = Services::default(); let mut services = Services::default();
if web { 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 { } else {
services.reveal = Some(Box::new(move |path: &str| { services.reveal = Some(Box::new(move |path: &str| {
l.borrow_mut().push((path.to_string(), vec![])); l.borrow_mut().push((path.to_string(), vec![]));

View File

@ -3508,6 +3508,8 @@ effect Regular Pravidelný
Serif / Mincho Patkové / mincho Serif / Mincho Patkové / mincho
Changes made since it was last saved will be lost. Změny provedené od posledního uložení budou ztraceny. 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 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} Downloaded {what} Staženo: {what}
Exported {count} file(s) to {folder} Exportováno souborů: {count} do {folder} Exported {count} file(s) to {folder} Exportováno souborů: {count} do {folder}
Exported {what} Exportováno: {what} Exported {what} Exportováno: {what}

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -754,6 +754,8 @@
Do Not Move Nicht verschieben Do Not Move Nicht verschieben
Do you want to save the changes you made to “{name}”? Möchten Sie die Änderungen an „{name}“ speichern? 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 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 Dokument
Document Color Mode Dokumentfarbmodus Document Color Mode Dokumentfarbmodus
Document Info Dokumentinformationen Document Info Dokumentinformationen

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -768,6 +768,8 @@
Do Not Move No mover Do Not Move No mover
Do you want to save the changes you made to “{name}”? ¿Quieres guardar los cambios hechos en “{name}”? 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 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 Documento
Document Color Mode Modo de color del documento Document Color Mode Modo de color del documento
Document Info Información del documento Document Info Información del documento

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -770,6 +770,8 @@
Do Not Move Ne pas déplacer 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} » ? 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 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 Document
Document Color Mode Mode colorimétrique du document Document Color Mode Mode colorimétrique du document
Document Info Informations sur le document Document Info Informations sur le document

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -485,6 +485,8 @@
Calligraphic Kalligrafikus Calligraphic Kalligrafikus
Calligraphic Brush Options Kalligrafikus ecset beállításai Calligraphic Brush Options Kalligrafikus ecset beállításai
Can't open “{name}”. Nem nyitható meg: „{name}”. 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 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 Mégse
Cancel Key Object Kulcsobjektum megszüntetése Cancel Key Object Kulcsobjektum megszüntetése

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -768,6 +768,8 @@
Do Not Move Non spostare Do Not Move Non spostare
Do you want to save the changes you made to “{name}”? Vuoi salvare le modifiche apportate a “{name}”? 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 open “{name}”. Impossibile aprire «{name}».
Can't save “{name}”. Impossibile salvare «{name}».
Can't export “{name}”. Impossibile esportare «{name}».
Document Documento Document Documento
Document Color Mode Metodo colore documento Document Color Mode Metodo colore documento
Document Info Informazioni documento Document Info Informazioni documento

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1223,6 +1223,8 @@
Divide Vertically Into 垂直方向に分割 Divide Vertically Into 垂直方向に分割
Do you want to save the changes you made to “{name}”? 「{name}」への変更を保存しますか? Do you want to save the changes you made to “{name}”? 「{name}」への変更を保存しますか?
Can't open “{name}”. 「{name}」を開けません。 Can't open “{name}”. 「{name}」を開けません。
Can't save “{name}”. 「{name}」を保存できません。
Can't export “{name}”. 「{name}」を書き出せません。
Document ドキュメント Document ドキュメント
Document Ink Options ドキュメントのインキオプション Document Ink Options ドキュメントのインキオプション
Document JSON ドキュメント JSON Document JSON ドキュメント JSON

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1218,6 +1218,8 @@
Divide Vertically Into 세로로 나누기 Divide Vertically Into 세로로 나누기
Do you want to save the changes you made to “{name}”? “{name}”의 변경 내용을 저장하시겠습니까? Do you want to save the changes you made to “{name}”? “{name}”의 변경 내용을 저장하시겠습니까?
Can't open “{name}”. “{name}”을(를) 열 수 없습니다. Can't open “{name}”. “{name}”을(를) 열 수 없습니다.
Can't save “{name}”. “{name}”을(를) 저장할 수 없습니다.
Can't export “{name}”. “{name}”을(를) 내보낼 수 없습니다.
Document 문서 Document 문서
Document Ink Options 문서 잉크 옵션 Document Ink Options 문서 잉크 옵션
Document JSON 문서 JSON Document JSON 문서 JSON

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3391,6 +3391,8 @@ effect Regular Normalny
Serif / Mincho Szeryfowe / mincho Serif / Mincho Szeryfowe / mincho
Changes made since it was last saved will be lost. Zmiany wprowadzone od ostatniego zapisu zostaną utracone. 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 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} Downloaded {what} Pobrano {what}
Exported {count} file(s) to {folder} Wyeksportowane pliki do {folder}: {count} Exported {count} file(s) to {folder} Wyeksportowane pliki do {folder}: {count}
Exported {what} Wyeksportowano {what} Exported {what} Wyeksportowano {what}

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -692,6 +692,8 @@
Divide Vertically Into Dividir Verticalmente Em 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}”? 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 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 Documento
Document Color Mode Modo de Cor do Documento Document Color Mode Modo de Cor do Documento
Document Info Informações do Documento Document Info Informações do Documento

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -766,6 +766,8 @@
Do Not Move Не перемещать Do Not Move Не перемещать
Do you want to save the changes you made to “{name}”? Сохранить изменения, внесённые в «{name}»? Do you want to save the changes you made to “{name}”? Сохранить изменения, внесённые в «{name}»?
Can't open “{name}”. Не удаётся открыть «{name}». Can't open “{name}”. Не удаётся открыть «{name}».
Can't save “{name}”. Не удаётся сохранить «{name}».
Can't export “{name}”. Не удаётся экспортировать «{name}».
Document Документ Document Документ
Document Color Mode Цветовой режим документа Document Color Mode Цветовой режим документа
Document Info Сведения о документе Document Info Сведения о документе

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -762,6 +762,8 @@
Do Not Move Не переміщати Do Not Move Не переміщати
Do you want to save the changes you made to “{name}”? Зберегти зміни, внесені до «{name}»? Do you want to save the changes you made to “{name}”? Зберегти зміни, внесені до «{name}»?
Can't open “{name}”. Не вдається відкрити «{name}». Can't open “{name}”. Не вдається відкрити «{name}».
Can't save “{name}”. Не вдається зберегти «{name}».
Can't export “{name}”. Не вдається експортувати «{name}».
Document Документ Document Документ
Document Color Mode Колірний режим документа Document Color Mode Колірний режим документа
Document Info Відомості про документ Document Info Відомості про документ

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -401,6 +401,8 @@
Calligraphic Thư pháp Calligraphic Thư pháp
Calligraphic Brush Options Tùy chọn cọ 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 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 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 Hủy
Cancel Key Object Hủy đối tượng chính Cancel Key Object Hủy đối tượng chính

Can't render this file because it has a wrong number of fields in line 11.

View File

@ -723,6 +723,8 @@
Do Not Move 不移动 Do Not Move 不移动
Do you want to save the changes you made to “{name}”? 要存储对“{name}”所做的更改吗? Do you want to save the changes you made to “{name}”? 要存储对“{name}”所做的更改吗?
Can't open “{name}”. 无法打开“{name}”。 Can't open “{name}”. 无法打开“{name}”。
Can't save “{name}”. 无法保存“{name}”。
Can't export “{name}”. 无法导出“{name}”。
Document 文档 Document 文档
Document Color Mode 文档颜色模式 Document Color Mode 文档颜色模式
Document Info 文档信息 Document Info 文档信息

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -741,6 +741,8 @@
Do Not Move 不移動 Do Not Move 不移動
Do you want to save the changes you made to “{name}”? 要儲存對「{name}」所做的變更嗎? Do you want to save the changes you made to “{name}”? 要儲存對「{name}」所做的變更嗎?
Can't open “{name}”. 無法開啟「{name}」。 Can't open “{name}”. 無法開啟「{name}」。
Can't save “{name}”. 無法儲存「{name}」。
Can't export “{name}”. 無法匯出「{name}」。
Document 文件 Document 文件
Document Color Mode 文件色彩模式 Document Color Mode 文件色彩模式
Document Info 文件資訊 Document Info 文件資訊

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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)); 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 /// [`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 /// 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) /// `{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<String>) -> Resu
/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`. /// 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> { pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> {
if let Some(dl) = services.download.as_mut() { if let Some(dl) = services.download.as_mut() {
dl(&fileio::file_name(path), bytes); return dl(&fileio::file_name(path), bytes);
return Ok(());
} }
services.write.as_mut().ok_or("no file writer")?(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<String>, name:
pick_path(app, &FilePick { name: name.to_string(), folder, ..Default::default() })? 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) Ok(path)
} }
@ -516,8 +538,10 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result<Value, String>
} }
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}; };
let failed = target.clone().unwrap_or_default();
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| { let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
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. // The document may have closed meanwhile: the file is written all the same.
done.complete(&mut app.session, uid); done.complete(&mut app.session, uid);
let path = r["path"].as_str().unwrap_or_default().to_string(); 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<Strin
Ok(out) Ok(out)
}; };
let enabled = app.session.prefs.background_export; let enabled = app.session.prefs.background_export;
let mut r = background::run(app, enabled, label, None, work, |app, r| { let failed = target.clone();
let r = r?; let mut r = background::run(app, enabled, label, None, work, move |app, r| {
let r = r.inspect_err(|e| report_write_error(app, true, &failed, e))?;
let what = match r["files"].as_array() { let what = match r["files"].as_array() {
Some(files) => format!("{} files", files.len()), Some(files) => format!("{} files", files.len()),
None => r["path"].as_str().unwrap_or_default().to_string(), 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, Some(p) => p,
None => target_path(app, None, ext)?, None => target_path(app, None, ext)?,
}; };
write_to(&mut app.services, &path, &bytes)?; write_reporting(app, &path, &bytes)?;
Ok((path, v)) 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() { } else if let Some(download) = app.services.download.as_mut() {
for (name, data) in &returned { for (name, data) in &returned {
let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?; 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() { let what = match returned.as_slice() {
[(name, _)] => (*name).to_string(), [(name, _)] => (*name).to_string(),
@ -830,6 +855,91 @@ mod tests {
assert!(app.ui.dialog.is_none()); 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<u8> { fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec<u8> {
let v = app.session.execute(cmd, &p).unwrap(); let v = app.session.execute(cmd, &p).unwrap();
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()

View File

@ -188,7 +188,9 @@ pub type PickSave = Box<dyn FnMut(&FilePick) -> Option<String>>;
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>; pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>; pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>; pub type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>; /// 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<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
/// Opens a URL in the system browser. /// Opens a URL in the system browser.
pub type OpenUrlFn = Box<dyn FnMut(&str)>; pub type OpenUrlFn = Box<dyn FnMut(&str)>;
@ -778,7 +780,10 @@ impl VectorcraftApp {
let reply = req.reply.clone(); let reply = req.reply.clone();
// Guarded per request: a panic must not drop the channel (taken out of `self` above). // Guarded per request: a panic must not drop the channel (taken out of `self` above).
let roots = self.automation_roots.clone(); 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, Ok(outcome) => outcome,
Err(msg) => { Err(msg) => {
self.picks.recover(); self.picks.recover();

View File

@ -68,8 +68,14 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec<u8>) -> Result<Val
Ok(json!({ "background": true, "status": status })) Ok(json!({ "background": true, "status": status }))
} }
/// Open the files that are ready (each frame). /// Open the files that are ready (each frame). Not while a dialog is open: a document that opens
/// becomes the active one, and the dialog's OK acts on the active document (#1694: Save As
/// options confirmed after another file had finished opening saved that file's art under the
/// first one's name). The files wait, their status line too, until the dialog is closed.
pub fn poll(app: &mut VectorcraftApp) { pub fn poll(app: &mut VectorcraftApp) {
if app.ui.dialog.is_some() {
return;
}
let mut i = 0; let mut i = 0;
while let Some(p) = app.opening.pending.get(i) { while let Some(p) = app.opening.pending.get(i) {
let r = match p.done.try_recv() { let r = match p.done.try_recv() {
@ -108,6 +114,32 @@ mod tests {
use super::*; use super::*;
use crate::Services; use crate::Services;
/// #1694: a file that finishes opening while a dialog is up waits for it to close, so the
/// dialog's document stays the active one.
#[test]
fn a_file_ready_while_a_dialog_is_open_waits_for_it_to_close() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
let (first, before) = (app.session.doc().unwrap().uid, app.session.documents().len());
app.ui.dialog = Some(crate::state::Dialog::new(crate::dialogs::save_options::KIND, json!({"path": "/art/First.vectorcraft"})));
start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap();
let t0 = std::time::Instant::now();
while t0.elapsed().as_millis() < 400 {
poll(&mut app);
std::thread::yield_now();
}
assert_eq!((app.session.documents().len(), app.session.doc().unwrap().uid), (before, first), "still the dialog's document");
assert_eq!(app.opening.pending.len(), 1);
app.ui.dialog = None;
let t0 = std::time::Instant::now();
while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 {
poll(&mut app);
std::thread::yield_now();
}
assert_eq!(app.session.documents().len(), before + 1);
assert_eq!(app.session.doc().unwrap().path.as_deref(), Some("/art/Second.vectorcraft"));
}
#[test] #[test]
fn a_file_read_off_the_ui_thread_opens_as_one_read_at_once() { fn a_file_read_off_the_ui_thread_opens_as_one_read_at_once() {
let mut app = VectorcraftApp::new(Session::new(), Services::default()); let mut app = VectorcraftApp::new(Session::new(), Services::default());

View File

@ -646,8 +646,13 @@ mod tests {
// The web downloads the file under that name. // The web downloads the file under that name.
let downloads = std::rc::Rc::new(std::cell::RefCell::new(vec![])); let downloads = std::rc::Rc::new(std::cell::RefCell::new(vec![]));
let dl = downloads.clone(); let dl = downloads.clone();
let services = let services = crate::Services {
crate::Services { download: Some(Box::new(move |name: &str, _: &[u8]| dl.borrow_mut().push(name.to_string()))), ..Default::default() }; download: Some(Box::new(move |name: &str, _: &[u8]| {
dl.borrow_mut().push(name.to_string());
Ok(())
})),
..Default::default()
};
let mut web = VectorcraftApp::new(Session::new(), services); let mut web = VectorcraftApp::new(Session::new(), services);
web.run("file.new", json!({"width": 100, "height": 100})).unwrap(); web.run("file.new", json!({"width": 100, "height": 100})).unwrap();
web.run("ui.saveSwatchLibrary", json!({})).unwrap(); web.run("ui.saveSwatchLibrary", json!({})).unwrap();

View File

@ -155,11 +155,12 @@ pub(crate) fn go_to(app: &mut VectorcraftApp, id: u64) {
/// Write embedded image `id` to a picked file (the web downloads it) and link it there. /// Write embedded image `id` to a picked file (the web downloads it) and link it there.
pub(crate) fn unembed(app: &mut VectorcraftApp, id: u64, name: &str) { pub(crate) fn unembed(app: &mut VectorcraftApp, id: u64, name: &str) {
if let Some(dl) = app.services.download.as_mut() { if app.services.download.is_some() {
if let Ok(v) = app.session.execute("links.unembed", &json!({ "id": id })) if let Ok(v) = app.session.execute("links.unembed", &json!({ "id": id }))
&& let (Some(name), Some(bytes)) = (v["name"].as_str(), v["dataBase64"].as_str().and_then(vectorcraft_format::base64_decode)) && let (Some(name), Some(bytes)) = (v["name"].as_str(), v["dataBase64"].as_str().and_then(vectorcraft_format::base64_decode))
{ {
dl(name, &bytes); // A download that can't start is said like any file that can't be written.
let _ = crate::io::write_reporting(app, name, &bytes);
} }
return; return;
} }

View File

@ -21,7 +21,10 @@ fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) {
let l = log.clone(); let l = log.clone();
let mut services = Services::default(); let mut services = Services::default();
if web { 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); let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); app.run("file.new", json!({"width": 200, "height": 100})).unwrap();

View File

@ -485,10 +485,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
// Its save dialog is open: the file is written when it answers. // Its save dialog is open: the file is written when it answers.
None if app.file_dialog_open() => Err("cancelled".into()), None if app.file_dialog_open() => Err("cancelled".into()),
None => match app.services.download.as_mut() { None => match app.services.download.as_mut() {
Some(dl) => { Some(dl) => dl("VectorCraft Shortcuts.json", &bytes).map(|()| Value::Null),
dl("VectorCraft Shortcuts.json", &bytes);
Ok(Value::Null)
}
None => Ok(v), None => Ok(v),
}, },
} }

View File

@ -306,6 +306,14 @@ pub struct UiState {
pub dock: bool, pub dock: bool,
pub view: ViewFlags, pub view: ViewFlags,
pub dialog: Option<Dialog>, pub dialog: Option<Dialog>,
/// 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<Dialog>,
/// 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. /// Toolbar group whose flyout is open.
pub flyout: Option<usize>, pub flyout: Option<usize>,
/// Last tool shown for each toolbar group (flyout selection sticks). /// Last tool shown for each toolbar group (flyout selection sticks).
@ -681,6 +689,8 @@ impl Default for UiState {
dock: true, dock: true,
view: ViewFlags::default(), view: ViewFlags::default(),
dialog: None, dialog: None,
told: vec![],
answering_control: false,
flyout: None, flyout: None,
group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(), group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(),
status: String::new(), status: String::new(),

View File

@ -86,7 +86,10 @@ fn the_web_save_as_dialog_shows_the_native_options() {
let written = crate::tests_svg::Written::default(); let written = crate::tests_svg::Written::default();
let w = written.clone(); let w = written.clone();
let services = crate::Services { 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() ..Default::default()
}; };
let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), services); let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), services);