diff --git a/crates/engine/src/cmd/app_more.rs b/crates/engine/src/cmd/app_more.rs index f607c8288..5bd897ba9 100644 --- a/crates/engine/src/cmd/app_more.rs +++ b/crates/engine/src/cmd/app_more.rs @@ -152,7 +152,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result { fn save_copy_xml(s: &mut Session, p: &Value) -> Result { 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_with_view(&s.saved_view())?).map_err(EngineError::Other)?; + let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view(), false)?).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()})) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 194845803..beb161a53 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -87,7 +87,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { /// 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 { - let json = s.project.to_file_json_with_view(&s.saved_view())?; + let compact = s.prefs.project.compact_project_files && !crate::xml_project::is_xml_path(path); + let json = s.project.to_file_json_with_view(&s.saved_view(), compact)?; if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) } } diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index e3f2006d8..d330ce190 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -122,6 +122,11 @@ pub fn pages() -> Vec { Section("New Project"), s("project.useTemplate", "New Project Loads Template", B, true), s("project.templatePath", "Template Project", Kind::Path, true), + Section("Project Files"), + s("project.compactProjectFiles", "Save Compact Project Files", B, true), + Note( + "Saves .ecproj files as compact JSON on one line: smaller and faster to save and open, but hard to read or diff in version control. Off, they are indented, one value per line.", + ), Section("Auto-Save"), s("autoSave.enabled", "Automatically Save Projects", B, true), s("autoSave.intervalMinutes", "Save Every", Kind::Int(1, 240, "Minutes"), true), diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index e0bea8b85..aa683e2b8 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -62,6 +62,9 @@ page!(ProjectPrefs { /// Open a template project for File ▸ New ▸ New Project. use_template: bool = false, template_path: String = String::new(), + /// Write `.ecproj` files as compact JSON on one line (smaller, faster to save and open). Off: + /// indented, one value per line, so they read and diff cleanly in version control. + compact_project_files: bool = false, }); page!(AutoSave { diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 81bed3c43..03003a77b 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -389,6 +389,23 @@ fn new_project_template_and_default_renderer() { let _ = std::fs::remove_dir_all(d); } +#[test] +fn project_files_are_indented_unless_compact_is_on() { + let d = tmp("compact-project-files"); + let path = d.join("p.ecproj"); + let mut s = session_with_comp(); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let pretty = std::fs::read_to_string(&path).unwrap(); + assert!(pretty.starts_with("{\n \"savedBy\""), "indented by default"); + s.execute("prefs.set", json!({"key": "project.compactProjectFiles", "value": true})).unwrap(); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let compact = std::fs::read_to_string(&path).unwrap(); + assert!(!compact.contains('\n'), "one line when Save Compact Project Files is on"); + let open = |text: &str| effectcraft_project::Project::from_json(text).unwrap(); + assert_eq!(open(&compact), open(&pretty), "both read back as the same project"); + let _ = std::fs::remove_dir_all(d); +} + // ---------------------------------------------------------------- shortcuts #[test] diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 22759e27d..b2b64ed2c 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -267,10 +267,11 @@ impl Project { /// (NaN or infinity, written as `null`, and nesting too deep to parse) are refused on every /// save; debug builds also read the text back. pub fn to_file_json(&self) -> Result { - self.to_file_json_with_view(&SavedView::default()) + self.to_file_json_with_view(&SavedView::default(), false) } - /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]). - pub fn to_file_json_with_view(&self, view: &SavedView) -> Result { + /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]), on one + /// line when `compact` (Settings ▸ Project ▸ Save Compact Project Files). + pub fn to_file_json_with_view(&self, view: &SavedView, compact: bool) -> Result { if self.schema > SCHEMA_VERSION { return Err(ProjectError::Invalid(format!( "the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({SCHEMA_VERSION}))", @@ -289,7 +290,8 @@ impl Project { let file = File { saved_by: APP_VERSION, view, project: self }; file_check::check(&file) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; - let text = serde_json::to_string_pretty(&file).map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + let text = if compact { serde_json::to_string(&file) } else { serde_json::to_string_pretty(&file) } + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; #[cfg(debug_assertions)] Project::from_json(&text) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index f9ee16167..c0bdb012f 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -183,7 +183,7 @@ fn project_files_refuse_newer_schemas() { crate::SCHEMA_VERSION ); assert_eq!(p.to_file_json().unwrap_err().to_string(), expected); - assert_eq!(p.to_file_json_with_view(&crate::SavedView::default()).unwrap_err().to_string(), expected); + assert_eq!(p.to_file_json_with_view(&crate::SavedView::default(), false).unwrap_err().to_string(), expected); } #[test] @@ -197,7 +197,7 @@ fn saved_files_reopen_in_pretty_and_compact_form() { fn project_files_remember_the_open_comps() { let (p, cid) = project_with_layer(); let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid], comp_times: [(cid, effectcraft_time::Tick(4_237_833_600))].into() }; - let text = p.to_file_json_with_view(&view).unwrap(); + let text = p.to_file_json_with_view(&view, false).unwrap(); assert_eq!(crate::saved_view(&text), view); assert_eq!(Project::from_json(&text).unwrap(), p, "the view is not part of the project"); // Files from before #748 have no view; a garbled one is ignored, not an error. diff --git a/docs/preferences.md b/docs/preferences.md index ce96da0bf..2402b9a32 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -42,6 +42,7 @@ Commands (CLI, MCP, control channel): - `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch leaves a `launch-pending` marker in the settings folder until its window has been drawing for a few seconds (or the app quits normally before that); a launch that stops earlier (a graphics driver crashing as the window opens or as its surface is re-created) leaves the marker behind, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. - `project.useTemplate`: New Project Loads Template - `project.templatePath`: Template Project +- `project.compactProjectFiles`: Save Compact Project Files (default off). Off: File ▸ Save writes `.ecproj` files as indented JSON, one value per line, easy to read and diff in version control. On: compact JSON on one line, smaller and faster to save and open. Either opens the same; auto-saves, XML copies and template packages are not affected. - `autoSave.enabled`: Automatically Save Projects - `autoSave.intervalMinutes`: Save Every - `autoSave.maxVersions`: Maximum Project Versions