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

commit3a01805e1aa3004441aa02c62bca0fa64ae94b9d
parentcd6a5ada39
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 16:39
fix: a detached window's autosave no longer resets the main window's view

The main window reloads default_project.json whenever a detached window
writes it, and that reload applied the whole view state in the file —
the detached window's own defaults: plate sizes, pane visibility,
collapses, docks, display settings and the camera. Every autosave from a
detached pane therefore reset the main window's layout, and cancelled any
pane-edge drag in progress. The sync-channel load is now
load_sync_channel(path, keep_own_view); the main window's poll passes
true and takes only the tree and navigation (path, selection, pan,
active camera).

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

 CLAUDE.md          |   4 +++
 src/application.rs |   5 ++-
 src/main.rs        |  39 ++++++++++++++++++++
 src/project.rs     | 103 ++++++++++++++++++++++++++++++++---------------------
 4 files changed, 109 insertions(+), 42 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a053cfa..713cbae 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -61,6 +61,10 @@ gone.)
   control is the only way to Reattach — and reaps its children with `try_wait` from
   the frame tick, so a window the user closes hands its pane back. NOT `kill(pid, 0)`:
   an unreaped exited child is a zombie, which that probe calls alive forever.
+  The main window's reload of that channel takes the TREE and navigation only
+  (`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.
   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/application.rs b/src/application.rs
index 9a632bb..027583d 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -123,7 +123,10 @@ impl State {
                 if let Ok(mod_time) = m.modified() {
                     if Some(mod_time) != self.last_project_mod_time {
                         self.last_project_mod_time = Some(mod_time);
-                        if let Err(e) = self.load_from_file(&path) {
+                        // The main window keeps its own view: what changed
+                        // on disk was a detached window's edit to the tree.
+                        let keep_own_view = !self.is_detached_network && self.detached_pane.is_none();
+                        if let Err(e) = self.load_sync_channel(&path, keep_own_view) {
                             eprintln!("Failed to auto-reload project: {:?}", e);
                         } else {
                             redraw = true;
diff --git a/src/main.rs b/src/main.rs
index 63c862d..b8f10ea 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1396,6 +1396,45 @@ mod tests {
         assert!((s.positions[crate::slots::PARAM_IDX].2 - 650.0).abs() < 0.5, "drawn param width: {:?}", s.positions[crate::slots::PARAM_IDX]);
     }
 
+    /// The main window's sync reload takes a detached window's TREE edit and
+    /// keeps its own view. It used to apply the whole view state the detached
+    /// window wrote — that window's default plates, panes and camera — so
+    /// every autosave from it reset the main window's layout.
+    #[test]
+    fn a_detached_windows_save_does_not_reset_the_main_layout() {
+        let dir = std::env::temp_dir().join(format!("cce-designer-sync-view-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_network_layout.2 = 700.0;
+        main.floating_param_width = 650.0;
+        main.floating_spreadsheet_height = 420.0;
+        main.viewport_mut().rotation_x = 0.7;
+        main.rebuild_positions();
+
+        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.save_to_file(&channel).expect("child writes the channel");
+
+        main.app_drag = Some(crate::app::AppDrag::ParamResize { start_w: 650.0, start_mouse_x: 0.0 });
+        main.load_sync_channel(&channel, true).expect("main reloads");
+
+        assert!(main.fs_root.children.iter().any(|c| c.name == "synced_edit"), "the tree edit must sync");
+        assert!((main.floating_network_layout.2 - 700.0).abs() < 0.5, "network width: {}", main.floating_network_layout.2);
+        assert!((main.floating_param_width - 650.0).abs() < 0.5, "param width: {}", main.floating_param_width);
+        assert!((main.floating_spreadsheet_height - 420.0).abs() < 0.5, "spreadsheet height: {}", main.floating_spreadsheet_height);
+        assert!((main.viewport().rotation_x - 0.7).abs() < 1e-6, "camera: {}", main.viewport().rotation_x);
+        assert!(main.app_drag.is_some(), "a plate drag in progress survives the reload");
+
+        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 6a4f1b4..68e6b2c 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -382,56 +382,77 @@ impl State {
         }
     }
 
-    pub(crate) fn load_from_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
-        if path.file_name().map_or(false, |n| n == "default_project.json") {
-            let content = fs::read_to_string(path)?;
-            let mut proj: Project = serde_json::from_str(&content)?;
-            proj.sanitize_node_names();
-            proj.migrate_param_refs();
-            crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
-            self.fs_root = proj.root;
-            self.migrate_meta_settings_node();
+    /// Load the bundled `default_project.json`, which is also the detached
+    /// windows' sync channel. `keep_own_view` is the main window reloading
+    /// what a DETACHED window wrote: it takes the tree and where the user is
+    /// in it (path, selection, pan, active camera) and keeps its own layout,
+    /// display settings and camera view. A detached window has no plates,
+    /// docks or viewport of its own worth the name, so the view state it
+    /// writes is its defaults — and applying that on every one of its
+    /// autosaves reset the main window's plate sizes, pane visibility,
+    /// collapses and camera, and cancelled any pane-edge drag in progress.
+    pub(crate) fn load_sync_channel(&mut self, path: &Path, keep_own_view: bool) -> Result<(), Box<dyn std::error::Error>> {
+        let content = fs::read_to_string(path)?;
+        let mut proj: Project = serde_json::from_str(&content)?;
+        proj.sanitize_node_names();
+        proj.migrate_param_refs();
+        crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
+        self.fs_root = proj.root;
+        self.migrate_meta_settings_node();
+        if !keep_own_view {
             // Before the default view, whose camera-node rule has the last
             // word on the square aspect and the pivot marker.
             if let Some(d) = &proj.view_state.display {
                 self.apply_display_settings(d);
             }
             self.apply_pane_state_from_project(&proj.view_state);
-            self.set_active_camera(proj.view_state.active_camera);
-            self.pan_x = proj.view_state.pan.0;
-            self.pan_y = proj.view_state.pan.1;
-            self.pan_velocity_x = 0.0;
-            self.pan_velocity_y = 0.0;
-            self.last_frame_pan_x = self.pan_x;
-            self.last_frame_pan_y = self.pan_y;
-            self.current_path = proj.view_state.current_path;
+        }
+        self.set_active_camera(proj.view_state.active_camera);
+        self.pan_x = proj.view_state.pan.0;
+        self.pan_y = proj.view_state.pan.1;
+        self.pan_velocity_x = 0.0;
+        self.pan_velocity_y = 0.0;
+        self.last_frame_pan_x = self.pan_x;
+        self.last_frame_pan_y = self.pan_y;
+        self.current_path = proj.view_state.current_path;
+        if !keep_own_view {
             self.apply_default_view_from_project(proj.view_state.default_view);
+        }
 
-            let sel = proj.view_state.selected_node;
-            self.graph_mut().set_selected_node(sel);
-            if sel.is_some() {
-                self.focused_widget = Some(CONTENT_IDX);
-            } else {
-                self.focused_widget = None;
-            }
-            self.drag_widget = None;
+        let sel = proj.view_state.selected_node;
+        self.graph_mut().set_selected_node(sel);
+        if sel.is_some() {
+            self.focused_widget = Some(CONTENT_IDX);
+        } else {
+            self.focused_widget = None;
+        }
+        self.drag_widget = None;
+        if !keep_own_view {
+            // A pane-edge or dock drag is layout, which a sync reload no
+            // longer touches — the drag in hand stays in hand.
             self.app_drag = None;
-            self.last_click = None;
-
-            self.sync_grid_settings();
-            self.sync_nodes();
-            self.sync_cursor_and_selection_from_loaded();
-            self.sync_cursor_and_selection();
-            self.sync_parameters_pane();
-
-            self.rebuild_scene_geometry();
-            self.rebuild_positions();
-            self.apply_layout();
-            self.update_panel_bounds();
-            self.loaded_project_path = None;
-            self.mark_saved();
-            self.update_window_title();
-            return Ok(());
+        }
+        self.last_click = None;
+
+        self.sync_grid_settings();
+        self.sync_nodes();
+        self.sync_cursor_and_selection_from_loaded();
+        self.sync_cursor_and_selection();
+        self.sync_parameters_pane();
+
+        self.rebuild_scene_geometry();
+        self.rebuild_positions();
+        self.apply_layout();
+        self.update_panel_bounds();
+        self.loaded_project_path = None;
+        self.mark_saved();
+        self.update_window_title();
+        Ok(())
+    }
+
+    pub(crate) fn load_from_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
+        if path.file_name().map_or(false, |n| n == "default_project.json") {
+            return self.load_sync_channel(path, false);
         }
 
         let (state_file_path, project_dir) = if path.is_dir() {