mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
Status bar: a background save or failed job keeps a newer status line
A save written in the background reported "Saved <path>" when it finished, and a background job that failed reported "<label> failed: <error>", even when another command or job had set a newer status line in the meantime. Both now use the rule Data Recovery copies already follow: the result goes to the status bar only while no other line has been set since the job started, or the only line set since is the job's own "<label>…" line and it is still shown. The save itself is unchanged: the file is written, the document counts as saved, Open Recent lists it, and a failed write still opens its error dialog.
This commit is contained in:
@@ -56,6 +56,10 @@ pub struct Job {
|
||||
pub progress: Option<Arc<Progress>>,
|
||||
/// Continue in Background: the progress dialog no longer shows this job.
|
||||
pub hidden: bool,
|
||||
/// [`crate::state::UiState::status_sets`] before the job set its status line.
|
||||
sets: u64,
|
||||
/// The status line the job set ("<label>…"); empty when it set none.
|
||||
line: String,
|
||||
then: Then,
|
||||
}
|
||||
|
||||
@@ -167,13 +171,13 @@ pub fn run_with_progress(
|
||||
if let Some(tx) = app.background.worker() {
|
||||
match tx.send(Task { id, work, write, scope: ScopeHandle::capture() }) {
|
||||
Ok(()) => {
|
||||
let (sets, status) = (app.ui.status_sets, if label.is_empty() { String::new() } else { format!("{label}…") });
|
||||
let bg = &mut app.background;
|
||||
bg.next_id += 1;
|
||||
bg.jobs.push(Job { id, label: label.clone(), doc, progress, hidden: false, then: Box::new(then) });
|
||||
bg.jobs.push(Job { id, label: label.clone(), doc, progress, hidden: false, sets, line: status.clone(), then: Box::new(then) });
|
||||
if label.is_empty() {
|
||||
return Ok(json!({ "background": true }));
|
||||
}
|
||||
let status = format!("{label}…");
|
||||
app.status(status.clone());
|
||||
return Ok(json!({ "background": true, "status": status }));
|
||||
}
|
||||
@@ -250,9 +254,27 @@ fn finish(app: &mut VectorcraftApp, (id, r): Done) {
|
||||
apply(app, job, r);
|
||||
}
|
||||
|
||||
/// Hand a job its result; a failure goes to the status bar (the call that started it returned).
|
||||
/// Does the status bar still show what a job left there? No line has been set since `sets`
|
||||
/// ([`crate::state::UiState::status_sets`] when the job started), or the one line set since is
|
||||
/// the job's own `line` ("<label>…"; empty for a job that set none) and it is still shown. A job's
|
||||
/// result goes to the status bar only then: a line set since (by another command or another job)
|
||||
/// stays, also when it has the same text as an earlier one.
|
||||
pub(crate) fn status_unchanged(app: &VectorcraftApp, sets: u64, line: &str) -> bool {
|
||||
let now = app.ui.status_sets;
|
||||
now == sets || (!line.is_empty() && now == sets.wrapping_add(1) && app.ui.status == line)
|
||||
}
|
||||
|
||||
/// Hand a job its result; a failure goes to the status bar (the call that started it returned)
|
||||
/// while the status bar still shows the job's own line ([`status_unchanged`]) and the job's
|
||||
/// callback set no line of its own.
|
||||
fn apply(app: &mut VectorcraftApp, job: Job, r: Result<Value, String>) {
|
||||
if let Err(e) = (job.then)(app, r) {
|
||||
app.status(format!("{} failed: {e}", job.label));
|
||||
let Job { label, sets, line, then, .. } = job;
|
||||
let shown = status_unchanged(app, sets, &line);
|
||||
let before = app.ui.status_sets;
|
||||
if let Err(e) = then(app, r)
|
||||
&& shown
|
||||
&& app.ui.status_sets == before
|
||||
{
|
||||
app.status(format!("{label} failed: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,6 +594,9 @@ fn write_plan(
|
||||
})
|
||||
};
|
||||
let failed = target.clone().unwrap_or_default();
|
||||
// The status line before the save starts: on the worker, the save sets one line ("<label>…")
|
||||
// right after this; written right away, it sets none before `then` runs.
|
||||
let (sets, started_line) = (app.ui.status_sets, format!("{label}…"));
|
||||
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
|
||||
if r.is_err()
|
||||
&& location_refused.load(std::sync::atomic::Ordering::Relaxed)
|
||||
@@ -623,7 +626,10 @@ fn write_plan(
|
||||
let beside: Vec<String> = beside.filter(|p| *p != path).map(str::to_string).collect();
|
||||
app.picks.written_beside.insert(uid, beside);
|
||||
}
|
||||
report_saved(app, &path, &r);
|
||||
// A line set while the file was written (another command's, another job's) stays.
|
||||
if background::status_unchanged(app, sets, &started_line) {
|
||||
report_saved(app, &path, &r);
|
||||
}
|
||||
Ok(r)
|
||||
};
|
||||
let mut r = background::run(app, enabled, label, Some(uid), work, then)?;
|
||||
|
||||
@@ -248,6 +248,122 @@ fn the_status_bar_shows_progress() {
|
||||
assert!(done.iter().any(|t| t == "Exported /out/p.svg") && !done.iter().any(|t| t.contains('…')), "{done:?}");
|
||||
}
|
||||
|
||||
/// An app whose thread-safe writer fails with `e` once `gate` opens.
|
||||
fn failing(gate: Gate, e: &'static str) -> VectorcraftApp {
|
||||
let services = Services {
|
||||
write: Some(Box::new(|_: &str, _: &[u8]| Err("the UI thread must not write here".into()))),
|
||||
write_shared: Some(Arc::new(move |_: &str, _: &[u8]| {
|
||||
gate.wait();
|
||||
Err(e.into())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
VectorcraftApp::new(Session::new(), services)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_save_keeps_a_newer_status_on_success() {
|
||||
let gate = Gate::default();
|
||||
let (mut app, files) = app(gate.clone());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app.run("file.save", json!({"path": "/docs/n.vectorcraft"})).unwrap();
|
||||
assert_eq!(app.ui.status, "Saving n.vectorcraft…");
|
||||
io::show_with_notes(&mut app, "Newer".into(), vec!["a note".into()]);
|
||||
gate.open();
|
||||
background::wait_all(&mut app);
|
||||
assert_eq!(app.ui.status, "Newer");
|
||||
assert_eq!(app.ui.status_notes, Some(("Newer".to_string(), vec!["a note".to_string()])));
|
||||
// The save itself completes all the same.
|
||||
assert_eq!(written(&files), ["/docs/n.vectorcraft"]);
|
||||
let st = app.session.doc().unwrap();
|
||||
assert!(!st.is_dirty());
|
||||
assert_eq!(st.path.as_deref(), Some("/docs/n.vectorcraft"));
|
||||
assert_eq!(app.ui.recent_files.first().map(String::as_str), Some("/docs/n.vectorcraft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_save_keeps_a_newer_status_on_failure() {
|
||||
let gate = Gate::default();
|
||||
let mut app = failing(gate.clone(), "disk full");
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
app.run("file.save", json!({"path": "/docs/f.vectorcraft"})).unwrap();
|
||||
app.status("Newer");
|
||||
gate.open();
|
||||
background::wait_all(&mut app);
|
||||
assert_eq!(app.ui.status, "Newer");
|
||||
// Not saved, and the write error's dialog still says so.
|
||||
let st = app.session.doc().unwrap();
|
||||
assert!(st.is_dirty() && st.path.is_none(), "not saved");
|
||||
let d = app.ui.dialog.as_ref().expect("the write error's dialog");
|
||||
assert_eq!((d.kind.as_str(), d.str("message").as_str()), (crate::dialogs::confirm::MESSAGE, "Can't save “f.vectorcraft”."));
|
||||
assert!(d.str("detail").contains("disk full"), "{}", d.str("detail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_save_reports_when_its_progress_line_is_current() {
|
||||
// Success and failure, nothing set meanwhile: the save's own result.
|
||||
let gate = Gate::default();
|
||||
let (mut good, _) = app(gate.clone());
|
||||
good.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
good.run("file.save", json!({"path": "/docs/c.vectorcraft"})).unwrap();
|
||||
gate.open();
|
||||
background::wait_all(&mut good);
|
||||
assert_eq!(good.ui.status, "Saved /docs/c.vectorcraft");
|
||||
let gate = Gate::default();
|
||||
let mut bad = failing(gate.clone(), "disk full");
|
||||
bad.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
bad.run("file.save", json!({"path": "/docs/f.vectorcraft"})).unwrap();
|
||||
gate.open();
|
||||
background::wait_all(&mut bad);
|
||||
assert!(bad.ui.status.starts_with("Saving f.vectorcraft failed: ") && bad.ui.status.contains("disk full"), "{}", bad.ui.status);
|
||||
// Another line, then one with the same text as the save's: the later line is not the save's.
|
||||
for ok in [true, false] {
|
||||
let gate = Gate::default();
|
||||
let mut app = if ok { app(gate.clone()).0 } else { failing(gate.clone(), "disk full") };
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app.run("file.save", json!({"path": "/docs/s.vectorcraft"})).unwrap();
|
||||
app.status("Other");
|
||||
app.status("Saving s.vectorcraft…");
|
||||
gate.open();
|
||||
background::wait_all(&mut app);
|
||||
assert_eq!(app.ui.status, "Saving s.vectorcraft…", "ok: {ok}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_failure_does_not_replace_a_newer_status() {
|
||||
for newer in [true, false] {
|
||||
let gate = Gate::default();
|
||||
let (mut app, _) = app(gate.clone());
|
||||
let ran = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||
let (g, seen) = (gate.clone(), ran.clone());
|
||||
let r = background::run(
|
||||
&mut app,
|
||||
true,
|
||||
"Exporting x.svg".into(),
|
||||
None,
|
||||
move |_| {
|
||||
g.wait();
|
||||
Err("broke".into())
|
||||
},
|
||||
move |_, r| {
|
||||
seen.set(true);
|
||||
r
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["background"], true);
|
||||
if newer {
|
||||
app.status("Newer");
|
||||
}
|
||||
gate.open();
|
||||
background::wait_all(&mut app);
|
||||
assert!(ran.get(), "the job's callback runs");
|
||||
assert_eq!(app.ui.status, if newer { "Newer" } else { "Exporting x.svg failed: broke" });
|
||||
}
|
||||
}
|
||||
|
||||
/// An app with a control channel: its UI-thread writer records files (or fails with `fail`), and
|
||||
/// its thread-safe writer does the same once `gate` opens.
|
||||
fn controlled(gate: Gate, fail: Option<&'static str>) -> (VectorcraftApp, std::sync::mpsc::Sender<ControlRequest>, Files) {
|
||||
|
||||
@@ -1708,6 +1708,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Preferences › File Handling › Data Recovery › Folder has a Choose… button on the desktop that picks the folder Data Recovery keeps its copies in ([Illustrator reference, Set Data Recovery preferences](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf)); a picked folder that Data Recovery cannot create its folders in or list is refused with a status message, and the field keeps its value. OK applies the same check to a folder typed into the field (VectorCraft's own rule): when it fails, the dialog stays open, the status bar says why and no preference changes; an empty field (the default folder) and the folder already saved are not checked. Preferences › Plug-ins & Scratch Disks is where additional plug-ins folders are set ([Illustrator preferences help](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html)); its Additional Plug-ins Folder has a Choose… button on the desktop as well, VectorCraft's own, matching Data Recovery's, and VectorCraft's own rule refuses a picked or typed folder whose contents can't be listed. Additional Fonts Folder and Templates Folder stay typed only.
|
||||
`prefs.set` applies the dialog's check to `recoveryFolder` and `pluginsFolder` for every caller (VectorCraft's own rule): a folder that fails returns an error and no preference changes; an empty value and the folder already saved are not checked.
|
||||
When a Data Recovery copy can't be written, the status bar and the log show the error (with the document's title when one document's copy fails), the folder copies go to, whether that is the default recovery folder or the folder set in Preferences, and the preference that picks another folder (`recoveryFolder`; VectorCraft's own message). A copy written in the background reports its result in the status bar only while the status bar still shows that copy's own "Saving recovery data for …" line, so a message shown since then, such as Restore's count with its notes or another copy's line, stays; a failure is still written to the log (VectorCraft's own rule).
|
||||
A file saved in the background reports "Saved …" or its failure in the status bar only while the status bar still shows that save's own "Saving …" line, and a failed background export or print shows "… failed" by the same rule, so a message shown since then stays, also one with the same text as an earlier line; the file is still written, the document still counts as saved, Open Recent still lists it, and a failed write still opens its error dialog (VectorCraft's own rule).
|
||||
After Restore in the dialog Data Recovery shows at launch, the status bar keeps the restored documents' notes (VectorCraft's own rule): it shows the count followed by the first note, and hovering it lists each document's lines about its linked files and its warnings, named with the document's title when more than one was restored. The questions about the documents' missing or modified linked files come one document at a time, with that document made the current one, and a copy that can't be restored shows its error while the hover keeps the count and the restored documents' notes (`tests/recovery.rs`).
|
||||
A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing).
|
||||
A name returned by the desktop save panel with the extension of a format the save does not write has it replaced with the saved format's: Save As of a new document saves `card.ait` and `card.png` as `card.vectorcraft`, and Save as Template saves `card.pdf` as `card.vctemplate`. An extension that names no format stays part of the name (`notes.v2` is saved as `notes.v2.vectorcraft`).
|
||||
|
||||
Reference in New Issue
Block a user