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

commitc7878d70278455525316584d618e1ad1f557a958
parent253fbb4262
authorLucas Galante <[email protected]>
date2026-09-01 10:51
feat: tab and pane state ride save files completely

Two gaps closed: the dock_tabs loader rejected any arrangement carrying
the second network editor (its whole-coverage check expected exactly
the three core panes) — it now accepts network2 at most once, and its
presence in a list is what recreates the editor on load (absent, it
stays closed, replacing even a live one: the file's arrangement IS the
arrangement). And the second editor's own path now rides view_state
(current_path2), clamped against the loaded tree so a save whose graph
changed shape degrades to the deepest valid ancestor. The round-trip
test saves a fronted, dived second editor and loads it back whole.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/app.rs     |  9 +++++++--
 src/main.rs    | 23 +++++++++++++++++++++++
 src/project.rs | 23 +++++++++++++++++------
 3 files changed, 47 insertions(+), 8 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 3996c51..c6d7200 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -176,10 +176,15 @@ pub struct ProjectViewState {
     pub splitters: Option<(f32, f32)>,
     /// The docks' tab groups, Left/Right/Bottom order, pane names with the
     /// ACTIVE tab first. Empty (older saves) keeps the default one-pane-per-
-    /// dock arrangement; a list that does not name each docked pane exactly
-    /// once across the three groups is ignored the same way.
+    /// dock arrangement; a list that does not name each core docked pane
+    /// exactly once (plus "network2" at most once — its presence recreates
+    /// the second editor) is ignored the same way.
     #[serde(default)]
     pub dock_tabs: Vec<Vec<String>>,
+    /// The second network editor's own path. Clamped on load, so a save
+    /// whose tree changed shape degrades to the deepest valid ancestor.
+    #[serde(default)]
+    pub current_path2: Vec<usize>,
 }
 
 fn default_camera() -> String {
diff --git a/src/main.rs b/src/main.rs
index 61c1f79..2eb98e1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -720,6 +720,16 @@ mod tests {
         a.set_pane_collapsed(PARAM_IDX, true);
         a.splitter_layout.splitter1_x = 400.0;
         a.splitter_layout.splitter2_x = 1200.0;
+        // Tab state: a second network editor tabbed beside the first (and
+        // fronted), dived one level down its own path.
+        a.add_dock_tab(crate::app::Dock::Left, crate::slots::NETWORK_PANEL2_IDX);
+        let sphere = a
+            .fs_root
+            .children
+            .iter()
+            .position(|c| c.name == "Sphere 1")
+            .expect("default project has Sphere 1");
+        a.current_path2 = vec![sphere];
         a.save_to_file(&dir).expect("save");
 
         let mut b = State::new(false);
@@ -732,6 +742,19 @@ mod tests {
         assert!((b.splitter_layout.splitter1_x - 200.0).abs() < 1.0,
             "splitters restore as fractions: 400/1600 of an 800-wide window = 200, got {}",
             b.splitter_layout.splitter1_x);
+        // The tab arrangement rides the file: the second editor exists,
+        // fronted in the left dock with the primary waiting, on its own path.
+        assert_eq!(
+            b.pane_in_dock(crate::app::Dock::Left),
+            crate::slots::NETWORK_PANEL2_IDX,
+            "the fronted second editor must load fronted"
+        );
+        assert_eq!(
+            b.tab_dock_of_pane(crate::slots::NETWORK_PANEL_IDX),
+            Some(crate::app::Dock::Left),
+            "the primary must load as the waiting tab"
+        );
+        assert_eq!(b.current_path2, vec![sphere], "the second editor's path must round-trip");
 
         // A detached pane window must ignore the same file's pane state.
         let mut d = State::new(true);
diff --git a/src/project.rs b/src/project.rs
index 927efd3..75b76c7 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -126,6 +126,7 @@ impl State {
             collapsed_panes,
             splitters,
             dock_tabs,
+            current_path2: self.current_path2.clone(),
         }
     }
 
@@ -221,9 +222,12 @@ impl State {
             self.set_pane_collapsed(idx, desired);
         }
         // Dock tab groups: accepted only whole — three lists whose names
-        // resolve and cover each docked pane exactly once. Anything else
-        // (older saves' empty list included) keeps the current arrangement
-        // rather than loading half a layout.
+        // resolve, cover each CORE docked pane exactly once, and carry the
+        // second network editor at most once (its presence in a list is what
+        // recreates it; absent, it stays closed — including replacing a live
+        // one, since the file's arrangement is the arrangement). Anything
+        // else (older saves' empty list included) keeps the current layout
+        // rather than loading half of one.
         if vs.dock_tabs.len() == 3 {
             let resolved: Vec<Vec<usize>> = vs
                 .dock_tabs
@@ -235,15 +239,18 @@ impl State {
                         .collect()
                 })
                 .collect();
-            let mut all: Vec<usize> = resolved.iter().flatten().copied().collect();
-            all.sort_unstable();
+            let all: Vec<usize> = resolved.iter().flatten().copied().collect();
+            let n2 = crate::slots::NETWORK_PANEL2_IDX;
+            let n2_count = all.iter().filter(|&&s| s == n2).count();
+            let mut core: Vec<usize> = all.iter().copied().filter(|&s| s != n2).collect();
+            core.sort_unstable();
             let mut expected = vec![
                 crate::slots::NETWORK_PANEL_IDX,
                 crate::slots::PARAM_IDX,
                 crate::slots::SPREADSHEET_IDX,
             ];
             expected.sort_unstable();
-            if all == expected {
+            if core == expected && n2_count <= 1 {
                 for d in 0..3 {
                     self.dock_tabs[d] = resolved[d].clone();
                     self.dock_panes[d] =
@@ -253,6 +260,10 @@ impl State {
                 self.apply_layout();
             }
         }
+        // The second editor's own path, clamped against the loaded tree —
+        // a stale save must degrade to the deepest valid ancestor.
+        self.current_path2 = vs.current_path2.clone();
+        self.clamp_path2();
         if let Some((f1, f2)) = vs.splitters {
             if self.width > 1.0 && f1 > 0.02 && f2 < 0.98 && f1 < f2 {
                 self.splitter_layout.splitter1_x = f1 * self.width;