Fix #1685: a save or export that fails is reported in a dialog that stays

A save that couldn't write its file (a read-only folder, a file another
program holds open, a full disk) was only reported in the status bar, a line
the next message replaces; a background save's failure arrived there some
time after the user had moved on. Save, Save As, Save a Copy, Save as
Template, Export As and the commands that write one file now also say it in a
dialog naming the file and the reason, which stays until it is closed. The
document stays modified and keeps the file it had. A message that arrives
while another dialog is open waits for it to close instead of replacing it.
An agent's request gets the error as its reply, without a dialog; a failed
recovery write stays in the status bar.
This commit is contained in:
bflatastic 2026-10-14 08:44:42 +07:00
parent 8f8d49110f
commit e802ea8438
21 changed files with 154 additions and 11 deletions

View File

@ -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.

View File

@ -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"));

View File

@ -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}

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 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

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 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

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 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

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 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

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 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

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 垂直方向に分割
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

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 세로로 나누기
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

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
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}

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
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

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 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 Сведения о документе

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 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 Відомості про документ

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 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

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 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 文档信息

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 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 文件資訊

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));
}
/// 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)
@ -259,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() })?
}
};
write_to(&mut app.services, &path, bytes)?;
write_reporting(app, &path, bytes)?;
Ok(path)
}
@ -515,8 +538,10 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result<Value, 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 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();
@ -601,8 +626,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
Ok(out)
};
let enabled = app.session.prefs.background_export;
let mut r = background::run(app, enabled, label, None, work, |app, r| {
let r = r?;
let failed = target.clone();
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() {
Some(files) => format!("{} files", files.len()),
None => r["path"].as_str().unwrap_or_default().to_string(),
@ -672,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))
}
@ -829,13 +855,69 @@ 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 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();

View File

@ -780,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();

View File

@ -158,9 +158,9 @@ pub(crate) fn unembed(app: &mut VectorcraftApp, id: u64, name: &str) {
if app.services.download.is_some() {
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 Err(e) = crate::io::write_to(&mut app.services, name, &bytes)
{
app.status(e);
// 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;
}

View File

@ -306,6 +306,14 @@ pub struct UiState {
pub dock: bool,
pub view: ViewFlags,
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.
pub flyout: Option<usize>,
/// 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(),