mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 22:20:50 -06:00
- Every Preferences > File Handling > Data Recovery interval, modified documents
changed since their last copy are written to the recovery store in the
background (the same snapshot and encoder as Background Save; the status bar
shows it). Complex documents (over 20,000 objects) are skipped while Turn off
Data Recovery for complex documents is on.
- A copy goes when its document is saved, reverted, closed or undone back to its
saved state, and when the app quits with nothing unsaved.
- Each running app keeps its copies in an area of its own: a sub-folder whose
.lock file it keeps locked for its lifetime (File::try_lock), or on the web a
heartbeat refreshed every minute. Only areas nobody holds (lock free, or no
heartbeat for three intervals, at least three minutes) are offered, and an
area is held while it is restored or discarded, so a second running instance
never offers or deletes a live instance's copies. Empty leftover areas are
tidied away.
- The next launch after a crash asks with the Recover Documents dialog (Restore /
Discard / Cancel): restored documents open as "<name> [Recovered]", unsaved, and
Save asks where to save them, suggesting their original file; the copy moves
into the new app's area.
- Store: the recoveryFolder preference, else Data Recovery beside the desktop
app's preferences; browser storage on the web (Services::recovery_store);
tests use an in-memory store (with in-memory locks, or heartbeats).
- Engine: file.recovery.save, file.recovery.list, file.recovery.restore {file?},
file.recovery.discard {file?}; DocState.recovered and DocState.recovery.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
8.4 KiB
Rust
205 lines
8.4 KiB
Rust
//! Closing documents with unsaved changes. File → Close / Close All, a tab's ×, Quit and the
|
||
//! window's close button first ask Save / Don't Save / Cancel for each modified document (one
|
||
//! dialog per document); Cancel stops the whole operation and a cancelled Save As keeps the
|
||
//! document open.
|
||
//!
|
||
//! The question is an ordinary dialog (`UiState::dialog`, kind [`KIND`]), so agents answer it
|
||
//! like any other: `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then
|
||
//! confirm discards, `ui.dialog.cancel` cancels.
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::state::Dialog;
|
||
use crate::{VectorcraftApp, io};
|
||
|
||
/// Dialog kind. Fields: `index` (document), `name` (its title), `then` (`close`, `closeAll` or
|
||
/// `quit`: what continues after this document) and `discard` (Don't Save).
|
||
pub const KIND: &str = "saveChanges";
|
||
|
||
/// File → Close (or a tab's ×) for document `i`.
|
||
pub fn close(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
|
||
// A save still running decides whether there is anything left to save.
|
||
crate::background::wait_all(app);
|
||
let dirty = app.session.documents().get(i).ok_or("no such document")?.is_dirty();
|
||
if dirty { ask(app, i, "close") } else { close_now(app, i) }
|
||
}
|
||
|
||
/// File → Close All (`then` = `closeAll`) or Quit (`quit`): ask about the next modified document,
|
||
/// or finish once none is left.
|
||
pub fn close_all(app: &mut VectorcraftApp, then: &str) -> Result<Value, String> {
|
||
crate::background::wait_all(app);
|
||
if let Some(i) = app.session.documents().iter().position(|d| d.is_dirty()) {
|
||
return ask(app, i, then);
|
||
}
|
||
if then == "quit" {
|
||
// Nothing unsaved is left: no recovery copies either.
|
||
vectorcraft_engine::cmd::recovery::forget_all(&mut app.session);
|
||
// The host closes the window.
|
||
app.ui.status = "quit".into();
|
||
return Ok(Value::Null);
|
||
}
|
||
if app.session.documents().is_empty() {
|
||
return Ok(json!({ "closed": 0 }));
|
||
}
|
||
let r = app.session.execute("file.closeAll", &json!({})).map_err(|e| e.to_string());
|
||
app.sync_views();
|
||
r
|
||
}
|
||
|
||
/// Is any open document modified (the window's close button must ask first)?
|
||
pub fn any_dirty(app: &VectorcraftApp) -> bool {
|
||
app.session.documents().iter().any(|d| d.is_dirty())
|
||
}
|
||
|
||
/// Show document `i` and ask whether to save it.
|
||
fn ask(app: &mut VectorcraftApp, i: usize, then: &str) -> Result<Value, String> {
|
||
app.session.set_active(i);
|
||
let name = app.session.documents()[i].title();
|
||
app.ui.dialog = Some(Dialog::new(KIND, json!({ "index": i, "name": name, "then": then })));
|
||
Ok(json!({ "pending": KIND }))
|
||
}
|
||
|
||
/// Close document `i` through the engine command, keeping `views` aligned with the documents.
|
||
fn close_now(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
|
||
let r = app.session.execute("file.close", &json!({ "index": i })).map_err(|e| e.to_string())?;
|
||
if i < app.views.len() {
|
||
app.views.remove(i);
|
||
}
|
||
app.sync_views();
|
||
Ok(r)
|
||
}
|
||
|
||
/// Answer the open dialog: save (or, with `discard`, don't), close the document and carry on.
|
||
pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||
let d = app.ui.dialog.take().ok_or("no dialog open")?;
|
||
let i = d.fields.get("index").and_then(Value::as_u64).map(|i| i as usize).filter(|i| *i < app.session.documents().len());
|
||
let i = i.ok_or("no such document")?;
|
||
if !d.bool("discard") {
|
||
app.session.set_active(i);
|
||
let r = io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?;
|
||
// Closing needs the file written: wait for a background save, and stop if it failed.
|
||
if r["background"] == true {
|
||
crate::background::wait_all(app);
|
||
if let Some(d) = app.session.documents().get(i).filter(|d| d.is_dirty()) {
|
||
return Err(format!("{}: not saved, so not closed", d.title()));
|
||
}
|
||
}
|
||
}
|
||
close_now(app, i)?;
|
||
match d.str("then").as_str() {
|
||
"close" => Ok(Value::Null),
|
||
then => close_all(app, then),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::sync::{Arc, Mutex};
|
||
use vectorcraft_engine::Session;
|
||
|
||
/// An app whose Save As picks `out.vectorcraft` and records written paths.
|
||
fn app() -> (VectorcraftApp, Arc<Mutex<Vec<String>>>) {
|
||
let written = Arc::new(Mutex::new(vec![]));
|
||
let w = written.clone();
|
||
let services = crate::Services {
|
||
pick_save: Some(Box::new(|_: &crate::FilePick| Some("out.vectorcraft".to_string()))),
|
||
write: Some(Box::new(move |p: &str, _: &[u8]| {
|
||
w.lock().unwrap().push(p.to_string());
|
||
Ok(())
|
||
})),
|
||
..Default::default()
|
||
};
|
||
(VectorcraftApp::new(Session::new(), services), written)
|
||
}
|
||
|
||
fn new_doc(app: &mut VectorcraftApp, dirty: bool) {
|
||
app.run("file.new", json!({ "width": 100, "height": 100 })).unwrap();
|
||
if dirty {
|
||
app.run("shape.rectangle", json!({ "x": 0, "y": 0, "width": 10, "height": 10 })).unwrap();
|
||
}
|
||
}
|
||
|
||
fn docs(app: &VectorcraftApp) -> usize {
|
||
app.session.documents().len()
|
||
}
|
||
|
||
fn pending(app: &VectorcraftApp) -> Option<usize> {
|
||
app.ui.dialog.as_ref().filter(|d| d.kind == KIND).and_then(|d| d.fields["index"].as_u64()).map(|i| i as usize)
|
||
}
|
||
|
||
#[test]
|
||
fn clean_documents_close_at_once_keeping_views_aligned() {
|
||
let (mut app, _) = app();
|
||
for _ in 0..3 {
|
||
new_doc(&mut app, false);
|
||
}
|
||
assert!(!any_dirty(&app));
|
||
app.views[0].zoom = 2.0;
|
||
app.views[2].zoom = 3.0;
|
||
close(&mut app, 1).unwrap();
|
||
assert_eq!(docs(&app), 2);
|
||
assert_eq!(app.views.iter().map(|v| v.zoom).collect::<Vec<_>>(), vec![2.0, 3.0]);
|
||
assert_eq!(app.run("file.close", json!({})).unwrap(), Value::Null);
|
||
assert_eq!(docs(&app), 1);
|
||
assert!(app.ui.dialog.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn closing_a_modified_document_asks_and_cancel_keeps_it() {
|
||
let (mut app, written) = app();
|
||
new_doc(&mut app, true);
|
||
new_doc(&mut app, false);
|
||
assert!(any_dirty(&app));
|
||
assert_eq!(app.run("file.close", json!({ "index": 0 })).unwrap(), json!({ "pending": KIND }));
|
||
assert_eq!((docs(&app), pending(&app), app.session.active_index()), (2, Some(0), Some(0)));
|
||
// Cancel (Esc / Cancel button / ui.dialog.cancel) closes nothing.
|
||
app.ui.dialog = None;
|
||
assert_eq!(docs(&app), 2);
|
||
// Save writes the file (Save As for an untitled document), then closes it.
|
||
app.run("file.close", json!({ "index": 0 })).unwrap();
|
||
confirm(&mut app).unwrap();
|
||
assert_eq!(*written.lock().unwrap(), vec!["out.vectorcraft".to_string()]);
|
||
assert_eq!((docs(&app), app.views.len()), (1, 1));
|
||
}
|
||
|
||
#[test]
|
||
fn a_cancelled_save_as_keeps_the_document_open() {
|
||
let (mut app, _) = app();
|
||
app.services.pick_save = Some(Box::new(|_: &crate::FilePick| None));
|
||
new_doc(&mut app, true);
|
||
close(&mut app, 0).unwrap();
|
||
assert!(confirm(&mut app).is_err());
|
||
assert_eq!(docs(&app), 1);
|
||
assert!(app.ui.dialog.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn close_all_and_quit_ask_once_per_modified_document() {
|
||
let (mut app, written) = app();
|
||
new_doc(&mut app, false);
|
||
new_doc(&mut app, true);
|
||
new_doc(&mut app, true);
|
||
// Close All: asks about each modified document in turn, then closes the rest.
|
||
app.run("file.closeAll", json!({})).unwrap();
|
||
assert_eq!(pending(&app), Some(1));
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||
confirm(&mut app).unwrap();
|
||
assert_eq!((docs(&app), pending(&app)), (2, Some(1)));
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||
confirm(&mut app).unwrap();
|
||
assert_eq!((docs(&app), app.views.len(), app.ui.dialog.is_none()), (0, 0, true));
|
||
assert!(written.lock().unwrap().is_empty());
|
||
// Quit: nothing modified quits at once; otherwise only after every answer.
|
||
new_doc(&mut app, true);
|
||
app.run("app.quit", json!({})).unwrap();
|
||
assert_ne!(app.ui.status, "quit");
|
||
assert_eq!(pending(&app), Some(0));
|
||
confirm(&mut app).unwrap();
|
||
assert_eq!(app.ui.status, "quit");
|
||
app.ui.status.clear();
|
||
app.run("app.quit", json!({})).unwrap();
|
||
assert_eq!(app.ui.status, "quit");
|
||
}
|
||
}
|