M4.15: Revert as an engine command with a confirmation

file.revert reads and decodes the saved file first: if that fails the
document is left as it is, still modified. Otherwise Session::replace_document
swaps the content in the same tab (same place, path, format and uid; history
and selection cleared; saved state), so every tab keeps its zoom. It runs only
for saved, modified documents. In the app, Revert asks first with the shared
confirm dialog, whose OK runs file.revert {confirmed: true} on the engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 11:07:15 +07:00
co-authored by Claude Opus 5.5
parent 515ce2ac94
commit 9fae8c56ec
10 changed files with 192 additions and 24 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`.
//! - `save`: `document.save`, Save As / a Copy / as Template and the per-format options
//! - `save`: `document.save`, Save As / a Copy / as Template, Revert and the per-format options
//! (`file.formatOptions`); [`save_with`] is the one save path of every frontend.
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
//! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`.
+32 -3
View File
@@ -1,5 +1,5 @@
//! Saving: `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate` and
//! `file.formatOptions`.
//! Saving: `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, `file.revert`
//! and `file.formatOptions`.
//!
//! Every frontend saves the same way: [`save_plan`] resolves where and how (path, format, options,
//! a suggested name and folder when there is no path), then [`save_with`] encodes, writes through
@@ -10,7 +10,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::Document;
use super::super::*;
use super::{Encoded, Format, encode_all, file_stem, format, format_for_name, write_encoded, write_file};
use super::{Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, write_encoded, write_file};
use crate::DocState;
/// The formats Save As offers, in menu order (append-only). The other writable formats are exports:
@@ -266,6 +266,26 @@ fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value> {
save_with(s, plan, write_file)
}
fn can_revert(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
match (&st.path, st.is_dirty()) {
(None, _) => Err("the document has never been saved".into()),
(_, false) => Err("no changes since the last save".into()),
_ => Ok(()),
}
}
/// File → Revert: read and decode the saved file first (on any failure the document is left as it
/// is), then replace the document in its tab.
fn revert(s: &mut Session, _: &Value) -> Result<Value> {
let index = s.active_index().ok_or(crate::EngineError::NoDocument)?;
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?;
doc.template = false;
s.replace_document(index, doc);
Ok(json!({ "path": path }))
}
/// `file.formatOptions`: a writable format's options with the values Save would use.
fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.formatOptions";
@@ -322,6 +342,15 @@ pub(super) fn specs() -> Vec<CommandSpec> {
has_doc,
|s, p| save(s, SaveMode::Template, p)
),
cmd!(
"file.revert",
"Revert",
["File"],
Some("F12"),
"{} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path}. The app asks first (a confirm dialog) unless confirmed: true",
can_revert,
revert
),
cmd!(
query "file.formatOptions",
"Format Options",
+29 -4
View File
@@ -643,10 +643,9 @@ impl Session {
self.untitled_counter += 1;
format!("Untitled-{}", self.untitled_counter)
}
/// Add a document and make it active.
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
// Text layout bounds are a cache (not saved): compute them now, or selection boxes and
// hit testing would use the rough estimate until each text object is edited.
/// Text layout bounds are a cache (not saved): compute them for a document just read, or
/// selection boxes and hit testing would use the rough estimate until each text is edited.
fn refresh_text_bounds(doc: &mut Document) {
let mut texts = vec![];
doc.walk(|n| {
if matches!(n.kind, NodeKind::Text(_)) {
@@ -660,6 +659,10 @@ impl Session {
cmd::typecmd::refresh_bounds(t);
}
}
}
/// Add a document and make it active.
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
Self::refresh_text_bounds(&mut doc);
self.reset_tool_for_doc_switch();
let mut st = DocState::new(doc, path);
st.history.limit = self.prefs.history_states as usize;
@@ -668,6 +671,28 @@ impl Session {
self.active = Some(i);
i
}
/// Replace the document in tab `index` (File → Revert): new content, cleared history and
/// selection, saved state. The tab keeps its place, path and format.
pub fn replace_document(&mut self, index: usize, mut doc: Document) -> bool {
if index >= self.docs.len() {
return false;
}
Self::refresh_text_bounds(&mut doc);
if self.active == Some(index) {
// Pending tool work (typing, a drag) belongs to the content being thrown away.
self.reset_tool_for_doc_switch();
}
let old = &self.docs[index];
let mut st = DocState::new(doc, old.path.clone());
st.history.limit = old.history.limit;
// Same open document (caches keyed by uid stay valid); a new revision redraws it.
st.uid = old.uid;
st.revision = old.revision + 1;
st.format = old.format;
st.save_options = old.save_options.clone();
self.docs[index] = st;
true
}
pub fn close_document(&mut self, index: usize) -> bool {
if index >= self.docs.len() {
return false;
+39 -1
View File
@@ -1,4 +1,5 @@
//! The save pipeline: Save writes a document's own format, Save As, a Copy and as Template.
//! The save pipeline: Save writes a document's own format, Save As / a Copy / as Template and
//! Revert.
use std::path::PathBuf;
@@ -33,6 +34,10 @@ fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
fn objects(s: &Session) -> usize {
s.doc().unwrap().doc.node_count()
}
#[test]
fn an_svg_path_writes_svg_and_save_writes_it_back() {
let d = dir("svg");
@@ -123,6 +128,39 @@ fn save_a_copy_keeps_the_path_title_and_modified_state() {
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn revert_reloads_the_saved_file_in_the_same_tab() {
let d = dir("revert");
let mut s = session();
rect(&mut s);
let native = path(&d, "a.vectorcraft");
s.execute("document.save", &json!({"path": native})).unwrap();
assert!(s.execute("file.revert", &json!({})).is_err(), "nothing to revert");
s.execute("file.new", &json!({})).unwrap();
s.set_active(0);
let uid = s.doc().unwrap().uid;
rect(&mut s);
rect(&mut s);
assert_eq!(objects(&s), 4);
s.execute("file.revert", &json!({})).unwrap();
let st = s.doc().unwrap();
assert_eq!((s.active_index(), s.documents().len(), objects(&s)), (Some(0), 2, 2));
assert!(!st.is_dirty() && st.history.undo.is_empty() && st.selection.is_empty());
assert_eq!((st.uid, st.path.as_deref()), (uid, Some(native.as_str())));
// With the file gone the document is left as it is, still modified.
rect(&mut s);
std::fs::remove_file(&native).unwrap();
assert!(s.execute("file.revert", &json!({})).is_err());
let st = s.doc().unwrap();
assert!(st.is_dirty());
assert_eq!((objects(&s), st.history.undo.len()), (3, 1));
// Never saved: nothing to revert to.
s.set_active(1);
rect(&mut s);
assert!(s.execute("file.revert", &json!({})).is_err());
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn save_plans_suggest_names_and_folders() {
let mut s = session();