mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 21:44:57 +00:00
Recovery: a recovery copy that finishes later keeps a newer status line
A recovery copy written in the background set "Saved recovery data for <title>" (or its failure line) when it finished, over any message shown since it started, such as Restore's count with its notes or the "Saving…" line of a copy started after it. The status bar now counts the messages VectorcraftApp::status sets. A copy shows its result only while its own "Saving recovery data for …" line is still the latest message, or right away when it was written without the worker. A failure is still logged, and a failed folder is still recorded for automation replies, when its line is not shown.
This commit is contained in:
@@ -457,5 +457,6 @@ impl VectorcraftApp {
|
||||
/// Show a transient status message.
|
||||
pub fn status(&mut self, s: impl Into<String>) {
|
||||
self.ui.status = s.into();
|
||||
self.ui.status_sets = self.ui.status_sets.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,24 +125,37 @@ fn write(app: &mut VectorcraftApp, store: &Arc<dyn RecoveryStore>, pick: impl Fn
|
||||
for job in jobs {
|
||||
let (uid, label, title) = (job.uid, format!("Saving recovery data for {}", job.title), job.title.clone());
|
||||
let (done, at, saving) = (job.clone(), place.clone(), label.clone());
|
||||
// The status line before the job starts: on the worker, the job 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 work = move |_: Writer| job.write();
|
||||
// A failure is reported here, so the job's result is not an error: that would show as
|
||||
// "<label> failed: <error>" over this line, or as the round's message when it ran here.
|
||||
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| match (r, at) {
|
||||
(Ok(r), _) => {
|
||||
done.finish(&mut app.session);
|
||||
app.status(format!("Saved recovery data for {title}"));
|
||||
Ok(r)
|
||||
}
|
||||
(Err(e), Some(at)) => {
|
||||
copy_failed(app, &title, &at, &e);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
// A store the host installed has no folder to name: the job's own line.
|
||||
(Err(e), None) => {
|
||||
app.status(format!("{saving} failed: {e}"));
|
||||
log::warn!("vectorcraft: {}", app.ui.status);
|
||||
Ok(Value::Null)
|
||||
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
|
||||
// The result goes to the status bar only while it still shows this job's own line: a
|
||||
// message set since then (another job's, a restore's with its notes) stays.
|
||||
let show = app.ui.status_sets == sets || (app.ui.status_sets == sets.wrapping_add(1) && app.ui.status == started_line);
|
||||
match (r, at) {
|
||||
(Ok(r), _) => {
|
||||
done.finish(&mut app.session);
|
||||
if show {
|
||||
app.status(format!("Saved recovery data for {title}"));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
(Err(e), Some(at)) => {
|
||||
copy_failed(app, &title, &at, &e, show);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
// A store the host installed has no folder to name: the job's own line.
|
||||
(Err(e), None) => {
|
||||
let line = format!("{saving} failed: {e}");
|
||||
log::warn!("vectorcraft: {line}");
|
||||
if show {
|
||||
app.status(line);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
}
|
||||
};
|
||||
// Its own store, not the file writer: this only decides whether it runs on the worker.
|
||||
@@ -173,18 +186,23 @@ fn failed(app: &mut VectorcraftApp, place: Option<&(String, bool)>, e: &str) {
|
||||
log::warn!("vectorcraft: {}", app.ui.status);
|
||||
}
|
||||
|
||||
/// [`failed`] for the copy of document `title` that couldn't be written to folder `place`.
|
||||
fn copy_failed(app: &mut VectorcraftApp, title: &str, place: &(String, bool), e: &str) {
|
||||
match place {
|
||||
(folder, true) => app.status(format!(
|
||||
/// [`failed`] for the copy of document `title` that couldn't be written to folder `place`, shown
|
||||
/// in the status bar only when `show` holds. The log always has it, and the folder is recorded
|
||||
/// in [`Timer::named`] either way.
|
||||
fn copy_failed(app: &mut VectorcraftApp, title: &str, place: &(String, bool), e: &str, show: bool) {
|
||||
let line = match place {
|
||||
(folder, true) => format!(
|
||||
"Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)"
|
||||
)),
|
||||
(folder, false) => app.status(format!(
|
||||
),
|
||||
(folder, false) => format!(
|
||||
"Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)"
|
||||
)),
|
||||
}
|
||||
),
|
||||
};
|
||||
named(app, &place.0);
|
||||
log::warn!("vectorcraft: {}", app.ui.status);
|
||||
log::warn!("vectorcraft: {line}");
|
||||
if show {
|
||||
app.status(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that the status text named recovery folder `folder` ([`Timer::named`]).
|
||||
|
||||
@@ -574,6 +574,10 @@ pub struct UiState {
|
||||
#[serde(skip)]
|
||||
pub touch_drawing_tool: Option<String>,
|
||||
pub status: String,
|
||||
/// How many times [`crate::VectorcraftApp::status`] has set `status`, so that work finishing
|
||||
/// later can tell whether the line it started with is still the latest one. Not saved.
|
||||
#[serde(skip)]
|
||||
pub status_sets: u64,
|
||||
/// The Replace Font request behind the status bar's warning ([`ReplaceFontWarning`]). Not saved.
|
||||
#[serde(skip)]
|
||||
pub replace_font_warning: Option<ReplaceFontWarning>,
|
||||
@@ -1163,6 +1167,7 @@ impl Default for UiState {
|
||||
group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(),
|
||||
touch_drawing_tool: None,
|
||||
status: String::new(),
|
||||
status_sets: 0,
|
||||
replace_font_warning: None,
|
||||
status_notes: None,
|
||||
palette_open: false,
|
||||
|
||||
@@ -414,6 +414,88 @@ fn a_copy_that_fails_in_a_host_store_names_the_document() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Services that write recovery copies on the background worker.
|
||||
fn on_the_worker() -> Services {
|
||||
Services { write_shared: Some(Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }
|
||||
}
|
||||
|
||||
/// A copy that finishes after a newer message (a restore's, with its notes) was shown is still
|
||||
/// written, and the newer message and its notes stay in the status bar.
|
||||
#[test]
|
||||
fn a_copy_that_finishes_later_keeps_a_newer_status_and_its_notes() {
|
||||
let store = Arc::new(MemoryStore::default());
|
||||
let mut app = app_with(&store, on_the_worker());
|
||||
recovery::tick(&mut app, 0.0);
|
||||
assert_eq!(recovery::tick(&mut app, 120.0), 1);
|
||||
assert_eq!(app.background.jobs.len(), 1, "on the worker");
|
||||
let newer = "Restored 2 documents with 2 notes: one".to_string();
|
||||
crate::io::show_with_notes(&mut app, newer.clone(), vec!["one".into(), "two".into()]);
|
||||
background::wait_all(&mut app);
|
||||
assert_eq!(copies(&store), 1);
|
||||
assert!(app.session.doc().unwrap().recovery.is_some());
|
||||
assert_eq!(app.ui.status, newer);
|
||||
assert_eq!(app.ui.status_notes, Some((newer, vec!["one".into(), "two".into()])));
|
||||
}
|
||||
|
||||
/// Of two copies started in one round, the first one to finish leaves the second one's
|
||||
/// "Saving…" line, and the second one reports.
|
||||
#[test]
|
||||
fn a_copy_that_finishes_leaves_the_line_of_a_copy_started_after_it() {
|
||||
let store = Arc::new(MemoryStore::default());
|
||||
let mut app = app_with(&store, on_the_worker());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
recovery::tick(&mut app, 0.0);
|
||||
assert_eq!(recovery::tick(&mut app, 120.0), 2);
|
||||
let second = app.ui.status.clone();
|
||||
let Some(title) = second.strip_prefix("Saving recovery data for ").and_then(|s| s.strip_suffix('…')) else {
|
||||
panic!("{second}");
|
||||
};
|
||||
let title = title.to_string();
|
||||
background::wait_until(&mut app, |app| app.background.jobs.len() == 1);
|
||||
assert_eq!(app.ui.status, second);
|
||||
background::wait_all(&mut app);
|
||||
assert_eq!(copies(&store), 2);
|
||||
assert_eq!(app.ui.status, format!("Saved recovery data for {title}"));
|
||||
}
|
||||
|
||||
/// A copy that fails after a newer message was shown leaves that message in the status bar, in a
|
||||
/// store the host installed and in a recovery folder; the folder is still recorded.
|
||||
#[test]
|
||||
fn a_copy_that_fails_later_keeps_a_newer_status() {
|
||||
let store: Arc<dyn RecoveryStore> = Arc::new(Refusing(MemoryStore::default()));
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store), ..on_the_worker() });
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
app.session.prefs.autosave_interval = 2;
|
||||
recovery::tick(&mut app, 0.0);
|
||||
assert_eq!(recovery::tick(&mut app, 120.0), 1);
|
||||
app.status("Newer");
|
||||
background::wait_all(&mut app);
|
||||
assert_eq!(app.ui.status, "Newer");
|
||||
|
||||
let dir = scratch("later");
|
||||
let folder = dir.join("recovery");
|
||||
let shown = folder.to_string_lossy().into_owned();
|
||||
let mut app = app_in_folder(&shown, on_the_worker());
|
||||
assert_eq!(recovery::tick(&mut app, 120.0), 1);
|
||||
background::wait_all(&mut app);
|
||||
// A folder with a file in it takes the copy's place, so the next copy can't be written.
|
||||
let name = app.session.doc().unwrap().recovery.as_ref().unwrap().file.clone();
|
||||
let copy = folder.join(format!("{name}.vectorcraft"));
|
||||
std::fs::remove_file(©).unwrap();
|
||||
std::fs::create_dir(©).unwrap();
|
||||
std::fs::write(copy.join("in-the-way"), b"").unwrap();
|
||||
app.run("shape.ellipse", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
assert_eq!(recovery::tick(&mut app, 240.0), 1);
|
||||
app.status("Newer");
|
||||
background::wait_all(&mut app);
|
||||
assert_eq!(app.ui.status, "Newer");
|
||||
assert_eq!(app.recovery.named, std::slice::from_ref(&shown));
|
||||
drop(app);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #367: a tab whose timers were paused (in the background) long enough for another tab to
|
||||
/// take it for gone and discard its copy writes the copy again on its first heartbeat after it
|
||||
/// resumes, even with the timer off (the copy was written by hand).
|
||||
|
||||
@@ -1640,7 +1640,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604).
|
||||
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).
|
||||
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).
|
||||
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