git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit9d02e3ca35b15701047daf68063f13fd0e268fee
parent3a01805e1a
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 16:55
fix: a detached window no longer writes its layout into the sync channel

A detached window saved its own view state into default_project.json —
its default plates, panes, display settings and camera — so the file
carried the wrong layout between autosaves, and a full load of it (Open,
or startup with no default project set) restored that. Its save now
keeps the view state already in the file, the main window's, and
replaces only the navigation the main window's reload reads (path,
selection, pan, active camera). With no readable file the layout blocks
are written absent, which a full load treats as "keep the live layout".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md      |  3 +++
 src/main.rs    | 44 ++++++++++++++++++++++++++++++++++++++++++++
 src/project.rs | 35 ++++++++++++++++++++++++++++++++++-
 3 files changed, 81 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 713cbae..a639381 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,6 +65,9 @@ gone.)
   (`load_sync_channel(path, keep_own_view: true)`), never the view state a
   detached window wrote — that is the detached window's defaults, and applying
   it reset the main window's plate sizes, panes and camera on every autosave.
+  The write side matches: a detached window's save keeps the view state
+  already in the file and replaces only the navigation (`detached_view_state`),
+  so the file always carries the main window's layout.
   Note that detaching REWRITES `default_project.json` in the source tree, since that
   file is the sync channel; it is versioned, so check `git status` after testing.
 
diff --git a/src/main.rs b/src/main.rs
index b8f10ea..b3aeabc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1435,6 +1435,50 @@ mod tests {
         let _ = fs::remove_dir_all(&dir);
     }
 
+    /// A detached window writes its tree and navigation into the sync channel
+    /// and leaves the layout there as the main window last wrote it — it used
+    /// to write its own defaults, so a full load of the file restored the
+    /// wrong plates.
+    #[test]
+    fn a_detached_window_leaves_the_channels_layout_alone() {
+        let dir = std::env::temp_dir().join(format!("cce-designer-sync-write-test-{}", std::process::id()));
+        let _ = fs::remove_dir_all(&dir);
+        fs::create_dir_all(&dir).unwrap();
+        let channel = dir.join("default_project.json");
+
+        let mut main = State::new(false);
+        main.resize(1600.0, 900.0, 1.0);
+        main.floating_param_width = 650.0;
+        main.viewport_mut().rotation_x = 0.7;
+        main.rebuild_positions();
+        main.save_to_file(&channel).expect("main writes the channel");
+
+        let mut child = State::new(false);
+        child.detached_pane = Some(crate::slots::PARAM_IDX);
+        child.resize(400.0, 300.0, 1.0);
+        child.rebuild_positions();
+        child.fs_root.children[0].name = "synced_edit".to_string();
+        child.graph_mut().set_selected_node(Some(0));
+        child.save_to_file(&channel).expect("child writes the channel");
+
+        let proj: crate::app::Project = serde_json::from_str(&fs::read_to_string(&channel).unwrap()).unwrap();
+        assert!(proj.root.children.iter().any(|c| c.name == "synced_edit"), "the tree is the child's");
+        assert_eq!(proj.view_state.selected_node, Some(0), "so is the navigation");
+        let plates = proj.view_state.plates.expect("the main window's plates stay in the file");
+        assert!((plates.params_width - 650.0 / 1600.0).abs() < 1e-4, "params width: {}", plates.params_width);
+        let view = proj.view_state.default_view.expect("the main window's camera stays in the file");
+        assert!((view.rotation.0 - 0.7).abs() < 1e-6, "camera: {:?}", view.rotation);
+
+        // No file to keep a layout from: the layout blocks go absent, which
+        // a full load reads as "keep the live layout".
+        let _ = fs::remove_file(&channel);
+        child.save_to_file(&channel).expect("child writes a fresh channel");
+        let proj: crate::app::Project = serde_json::from_str(&fs::read_to_string(&channel).unwrap()).unwrap();
+        assert!(proj.view_state.plates.is_none() && proj.view_state.visible_panes.is_none(), "no layout of the child's");
+
+        let _ = fs::remove_dir_all(&dir);
+    }
+
     /// A dragged plate edge is an unsaved change — the save file carries the
     /// plate geometry, so the title's asterisk must follow it, and clear on
     /// save. A window resize alone must NOT dirty it.
diff --git a/src/project.rs b/src/project.rs
index 68e6b2c..0efa450 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -224,6 +224,34 @@ impl State {
         }
     }
 
+    /// The view state a DETACHED window writes into the sync channel: the
+    /// file's own (the main window's layout, display settings and camera,
+    /// as it last wrote them) with only the navigation replaced — the path,
+    /// selection, pan and active camera, which are the parts the main
+    /// window's reload takes. A detached window has no layout worth saving;
+    /// it used to write its defaults here, leaving a file that restored the
+    /// wrong plates and panes whenever it was loaded in full. With no
+    /// readable file the layout blocks go absent, which a full load reads
+    /// as "keep the live layout".
+    fn detached_view_state(&self, path: &Path) -> ProjectViewState {
+        #[derive(serde::Deserialize)]
+        struct ViewOnly {
+            #[serde(default)]
+            view_state: ProjectViewState,
+        }
+        let mut vs = fs::read_to_string(path)
+            .ok()
+            .and_then(|c| serde_json::from_str::<ViewOnly>(&c).ok())
+            .map(|v| v.view_state)
+            .unwrap_or_default();
+        let own = self.project_view_state();
+        vs.active_camera = own.active_camera;
+        vs.pan = own.pan;
+        vs.current_path = own.current_path;
+        vs.selected_node = own.selected_node;
+        vs
+    }
+
     pub(crate) fn save_to_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
         // The View subnet params mirror the live pane flags, but nothing
         // refreshes them on a pane toggle — sync the mirror now so the saved
@@ -235,11 +263,16 @@ impl State {
             self.migrate_meta_settings_node();
         }
         if path.file_name().map_or(false, |n| n == "default_project.json") {
+            let view_state = if self.is_detached_network || self.detached_pane.is_some() {
+                self.detached_view_state(path)
+            } else {
+                self.project_view_state()
+            };
             let proj = Project {
                 name: "Default Project".to_string(),
                 root: self.fs_root.clone(),
                 format: crate::app::PROJECT_FORMAT,
-                view_state: self.project_view_state(),
+                view_state,
             };
             let content = serde_json::to_string_pretty(&proj)?;
             fs::write(path, content)?;