M3.9: closing a modified project asks to save it first; the modified mark follows undo

- Quit and the window's close button, File ▸ New Project, Open Project, Open Recent, Close
  Project, the demo project and a Home template show Save / Don't Save / Cancel when the
  project has unsaved changes (Save asks for a path when untitled; cancelling that keeps the
  project open). File ▸ Revert asks to discard. Before, all of them dropped unsaved work
  without asking.
- The window title shows the project's path and `*` while it is modified.
- The modified mark follows undo: the session keeps the snapshot it last opened or saved
  (`Session::saved_project`), so undoing, jumping in the History panel or rolling back a batch
  to the saved state clears it.
- An edit that changes nothing (a value set to what it already is) records no undo step and
  doesn't mark the project modified (`Project::same_content`, comparing untouched comps by
  pointer).
- Agents' `engine.execute` / MCP calls are never asked; `ui.menu.invoke` shows the prompt like a
  click (automation ids `dialog.unsaved.save` / `dontSave` / `revert` / `cancel`), and the
  control channel's `app.quit {force: true}` skips it.
- Docs: architecture, agents, control protocol, parity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-04 23:02:14 +07:00
co-authored by Claude Opus 5.5
parent 10644a83aa
commit e0ed28f55d
18 changed files with 437 additions and 9 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ fn save_to(s: &mut Session, path: &str) -> Result<Value> {
let json = if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other)? } else { json };
s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.path = Some(path.to_string());
s.saved_revision = s.revision;
s.mark_saved();
s.note_project_path(path);
s.toast(format!("Saved {path}"));
Ok(json!({"path": path, "bytes": json.len()}))
+2 -2
View File
@@ -112,7 +112,7 @@ fn apply(s: &mut Session, found: Vec<(ItemId, Found)>) -> Value {
s.bump();
if !dirty {
// Only the disk changed: the project isn't modified.
s.saved_revision = s.revision;
s.mark_saved();
}
s.events.push(crate::Event::PurgeCaches);
}
@@ -148,7 +148,7 @@ mod tests {
let there = dir.join("there.png");
std::fs::write(&there, b"x").unwrap();
let mut s = session_with(&[there.to_str().unwrap(), "/nonexistent/ec/gone.png"]);
s.saved_revision = s.revision;
s.mark_saved();
let r = s.execute("footage.check", json!({"wait": true})).unwrap();
assert_eq!(r, json!({"checked": 2, "missing": 1, "probed": 0}));
let missing: Vec<bool> = s
+22 -2
View File
@@ -298,6 +298,9 @@ pub struct Session {
pub history: History,
pub revision: u64,
pub saved_revision: u64,
/// The project as last opened or saved: the project is unmodified while it is this very
/// snapshot, so undoing back to the saved state clears the modified mark.
pub saved_project: Arc<Project>,
pub path: Option<String>,
pub state: EditorState,
pub services: Arc<dyn Services>,
@@ -417,8 +420,10 @@ pub type PluginLoader = fn(&[u8], &str) -> std::result::Result<Value, String>;
impl Default for Session {
fn default() -> Self {
let project = Arc::new(Project::default());
Session {
project: Arc::new(Project::default()),
saved_project: project.clone(),
project,
history: History::default(),
revision: 0,
saved_revision: 0,
@@ -541,6 +546,11 @@ impl Session {
self.roto_pending.push(r);
}
}
// Nothing changed (a value set to what it already was): no undo step, not modified.
if p.same_content(&before) {
self.state = st;
return Ok(r);
}
let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key;
if !same {
// Undone steps aren't lost: they stay in the History panel as a branch.
@@ -558,6 +568,10 @@ impl Session {
pub fn bump(&mut self) {
self.revision += 1;
// Back at the saved state (undo, redo, a history jump or a rolled-back batch).
if Arc::ptr_eq(&self.project, &self.saved_project) {
self.saved_revision = self.revision;
}
if self.disk_cache.is_some() {
self.layer_cache.set_disk_salt(effectcraft_render::disk_cache::footage_salt(&self.project));
}
@@ -611,6 +625,12 @@ impl Session {
self.revision != self.saved_revision
}
/// The project as it is now is the saved one (after opening or saving it).
pub fn mark_saved(&mut self) {
self.saved_project = self.project.clone();
self.saved_revision = self.revision;
}
pub fn active_comp(&self) -> Option<&Comp> {
self.project.comp(self.state.active_comp?)
}
@@ -840,7 +860,7 @@ impl Session {
self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() };
self.path = path;
self.bump();
self.saved_revision = self.revision;
self.mark_saved();
let first = self.project.comps().next().map(|(id, _)| *id);
if let Some(c) = first {
self.open_comp(c);
+42
View File
@@ -133,3 +133,45 @@ fn undo_levels_trim_branches() {
assert_eq!(s.history.branch_states(), 0);
assert_eq!(list(&mut s).len(), 4);
}
#[test]
fn modified_mark_follows_undo_and_no_op_edits_record_nothing() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap();
solid(&mut s, "A");
let path = std::env::temp_dir().join(format!("ec-dirty-{}.ecproj", std::process::id()));
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
assert!(!s.is_dirty());
// Setting a value to what it already is: no undo step, still saved.
let steps = s.history.undo.len();
s.execute("prop.set", json!({"layer": "A", "path": "transform/opacity", "value": 100})).unwrap();
assert_eq!(s.history.undo.len(), steps);
assert!(!s.is_dirty());
// A real change, then undo back to the saved state: not modified; redo: modified again.
s.execute("prop.set", json!({"layer": "A", "path": "transform/opacity", "value": 50})).unwrap();
assert!(s.is_dirty());
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.is_dirty(), "undo back to the saved state");
s.execute("edit.redo", json!({})).unwrap();
assert!(s.is_dirty());
// History jumps: the saved state is the parent of the current (changed) one.
let states = list(&mut s);
let cur = states.iter().find(|n| n["current"] == true).unwrap();
let (changed, saved) = (cur["index"].as_u64().unwrap(), cur["parent"].as_u64().unwrap());
s.execute("edit.history.goto", json!({"index": saved})).unwrap();
assert!(!s.is_dirty(), "jumped to the saved state");
s.execute("edit.history.goto", json!({"index": changed})).unwrap();
assert!(s.is_dirty());
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.is_dirty());
let r = s.execute(
"engine.batch",
json!({"atomic": true, "steps": [
{"command": "prop.set", "params": {"layer": "A", "path": "transform/opacity", "value": 10}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(r.is_err());
assert!(!s.is_dirty(), "rolled back");
let _ = std::fs::remove_file(path);
}