Merge pull request #863 from itrycodinganditsbad/fix-748

Save the active comp and open comp tabs in project files (#748)
This commit is contained in:
Brandon Thomas
2026-10-12 04:20:33 -04:00
7 changed files with 141 additions and 4 deletions
+1 -1
View File
@@ -128,7 +128,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
let xml = crate::xml_project::to_xml(&s.project.to_file_json()?).map_err(EngineError::Other)?;
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?;
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Saved an XML copy to {path}"));
Ok(json!({"path": path, "bytes": xml.len()}))
+2 -1
View File
@@ -47,6 +47,7 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
Project::from_json_repaired(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
resolve_relative_footage(&mut proj, path, s.services.as_ref());
s.replace_project(proj, Some(path.to_string()));
s.restore_view(&effectcraft_project::saved_view(&text));
s.note_project_path(path);
// Values past the limits (a corrupt or hostile file) were brought within them (#788).
if !repaired.is_empty() {
@@ -97,7 +98,7 @@ fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn
/// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project
/// wouldn't read back.
pub(crate) fn file_text(s: &Session, path: &str) -> Result<String> {
let json = s.project.to_file_json()?;
let json = s.project.to_file_json_with_view(&s.saved_view())?;
if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) }
}
+20
View File
@@ -859,6 +859,26 @@ impl Session {
self.events.push(Event::OpenComp(id));
}
/// The open compositions, as a project file records them ([`effectcraft_project::SavedView`]).
pub fn saved_view(&self) -> effectcraft_project::SavedView {
effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone() }
}
/// Reopen the compositions a project file was saved with (#748). Ids that no longer name a
/// comp are skipped; with none left the project stays on the comp [`Session::replace_project`]
/// opened (its first), as files from before the view was saved do.
pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) {
let mut open: Vec<ItemId> = Vec::new();
for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) {
if !open.contains(&id) {
open.push(id);
}
}
let Some(active) = view.active_comp.filter(|id| self.project.comp(*id).is_some()).or_else(|| open.first().copied()) else { return };
self.state.open_comps = open;
self.open_comp(active);
}
pub fn toast(&mut self, msg: impl Into<String>) {
self.events.push(Event::Toast { message: msg.into(), error: false });
}
+58
View File
@@ -628,3 +628,61 @@ fn new_text_rejects_malformed_positions_without_creating_a_layer() {
s.execute("layer.newText", json!({"text": "No explicit position"})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
}
/// The active comp and the open comp tabs are saved with the project and restored on open (#748).
#[test]
fn reopening_a_project_restores_its_active_comp() {
use effectcraft_project::ItemId;
let dir = std::env::temp_dir().join(format!("ec-test-748-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("view.ecproj").to_string_lossy().to_string();
let mut s = Session::default();
let a = s.execute("comp.new", json!({"name": "CompA"})).unwrap()["comp"].as_u64().unwrap();
let b = s.execute("comp.new", json!({"name": "CompB"})).unwrap()["comp"].as_u64().unwrap();
let c = s.execute("comp.new", json!({"name": "CompC"})).unwrap()["comp"].as_u64().unwrap();
s.execute("comp.open", json!({"comp": "CompB"})).unwrap();
s.state.open_comps.retain(|id| id.0 != c);
s.execute("file.saveAs", json!({"path": path})).unwrap();
let mut s2 = Session::default();
s2.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(s2.state.active_comp, Some(ItemId(b)));
assert_eq!(s2.state.open_comps, vec![ItemId(a), ItemId(b)]);
assert!(!s2.is_dirty());
// A file from before the view was saved opens on its first comp, as it always did.
let text = std::fs::read_to_string(&path).unwrap();
let mut old: serde_json::Value = serde_json::from_str(&text).unwrap();
let obj = old.as_object_mut().unwrap();
obj.remove("activeComp");
obj.remove("openComps");
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
let mut s3 = Session::default();
s3.execute("file.open", json!({"path": path})).unwrap();
assert_eq!((s3.state.active_comp, s3.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)]));
// Ids that no longer name a comp (edited by hand, or written by another tool) fall back.
obj_set(&mut old, json!(999), json!([999, b, "x"]));
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
let mut s4 = Session::default();
s4.execute("file.open", json!({"path": path})).unwrap();
assert_eq!((s4.state.active_comp, s4.state.open_comps.clone()), (Some(ItemId(b)), vec![ItemId(b)]));
obj_set(&mut old, json!(999), json!([]));
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
let mut s5 = Session::default();
s5.execute("file.open", json!({"path": path})).unwrap();
assert_eq!((s5.state.active_comp, s5.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)]));
// XML copies carry it too.
let xml = dir.join("view.ecprojx").to_string_lossy().to_string();
s.execute("file.saveAs", json!({"path": xml})).unwrap();
let mut s6 = Session::default();
s6.execute("file.open", json!({"path": xml})).unwrap();
assert_eq!(s6.state.active_comp, Some(ItemId(b)));
let _ = std::fs::remove_dir_all(dir);
fn obj_set(v: &mut serde_json::Value, active: serde_json::Value, open: serde_json::Value) {
let o = v.as_object_mut().unwrap();
o.insert("activeComp".into(), active);
o.insert("openComps".into(), open);
}
}