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

commit8f4e5639b7217047bb9fef2c0e8e83c91776f463
parent7e078e4bb2
authorLucas Galante <[email protected]>
date2026-08-24 10:44
feat: the Session node — one permanent root container for session settings

The Main/View/Guides/Render utility subnets used to sit flat in the root
network. They now live inside a single permanent "Session" node (node type
"session") at /, which is the home for anything scoped to the whole session.

ensure_menubar_subnets creates it and migrates older saves: root-level
settings nodes are MOVED in — same nodes, params intact — not recreated, so
an old project's values keep seeding exactly as before (pinned by a
foreign-param probe test, since the live-synced toggles are rewritten from
app state by design and cannot distinguish the two).

Permanence is enforced at the one gate every deletion route funnels through —
delete_node refuses the session type (context menu, Delete key, and MCP all
route there; MCP gets an honest error message instead of "out of bounds").
The context menu also stops offering Delete, and the graph widget draws
session nodes without a geometry toggle, like utility nodes (cce-ui side).

The nesting broke two first-segment assumptions: the settings-dir check that
gates geometry templates out of utility dirs read current_path[0], which is
now the Session node — in_settings_dir() walks the whole path instead — and
three tests plus update_recent_files_layout resolved Main at the root.

Live-verified: get_state shows Session containing the four; deleting it over
MCP is refused; entering it and selecting Main drives the params pane; the
sphere renders unaffected.

 CLAUDE.md      | 13 +++++++++
 src/app.rs     | 62 ++++++++++++++++++++++++++++++++++-----
 src/main.rs    | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 src/project.rs | 71 ++++++++++++++++++++++++++++++++++++++-------
 src/window.rs  |  7 ++++-
 5 files changed, 220 insertions(+), 24 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 0937fd2..4e1bb40 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -162,6 +162,19 @@ The `zcce_inspector_v1` integration (window-position tracking + widget-state
 streaming to cce-test-interface) was dropped in the engine migration; the HTTP API
 is the introspection surface.
 
+### The Session node
+
+Session-wide settings live under one permanent root node: `Session` (node type
+`session`) contains the Main/View/Guides/Render utility subnets that used to
+sit flat in `/`. `ensure_menubar_subnets` creates it and MIGRATES root-level
+settings nodes from older saves into it (moved, not recreated — params
+survive). It cannot be deleted: `delete_node` refuses the `session` type (the
+one gate every deletion route funnels through), the context menu omits Delete,
+and the graph draws it without a geometry toggle. `State::session_node()` /
+`in_settings_dir()` are the accessors — the latter walks the whole
+`current_path`, since a first-segment check stopped working the day the
+settings nodes gained a parent.
+
 ### App-written settings: `~/.config/cce/cce-designer/state.kdl`
 
 `default_project` in state.kdl points at the project the main window opens on
diff --git a/src/app.rs b/src/app.rs
index ffaf3b5..5ffa1db 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -146,7 +146,7 @@ impl FsNode {
     /// of them learned about new container types: subnet-like types by name,
     /// otherwise anything that actually has children.
     pub fn is_enterable(&self) -> bool {
-        matches!(self.node_type.as_str(), "node" | "utility" | "simnet")
+        matches!(self.node_type.as_str(), "node" | "utility" | "simnet" | "session")
             || !self.children.is_empty()
     }
 }
@@ -964,7 +964,10 @@ impl State {
         }
         opts.push("Other".to_string());
 
-        if let Some(main_node) = self.fs_root.children.iter_mut().find(|c| c.name == "Main") {
+        let main_node = self
+            .session_node_mut()
+            .and_then(|s| s.children.iter_mut().find(|c| c.name == "Main"));
+        if let Some(main_node) = main_node {
             if let Some(p) = main_node.params.iter_mut().find(|p| p.name == "Open") {
                 p.options = opts;
                 if !p.options.contains(&p.default) {
@@ -1262,6 +1265,37 @@ impl State {
         self.splitter_layout.clamp(self.width, self.detached_circular_network);
     }
 
+    /// The Session node: the permanent root container for the session-wide
+    /// settings nodes (Main/View/Guides/Render). `ensure_menubar_subnets`
+    /// guarantees it exists, so `None` only before the first ensure.
+    pub fn session_node(&self) -> Option<&FsNode> {
+        self.fs_root.children.iter().find(|c| c.node_type == "session")
+    }
+
+    pub fn session_node_mut(&mut self) -> Option<&mut FsNode> {
+        self.fs_root.children.iter_mut().find(|c| c.node_type == "session")
+    }
+
+    /// Is the network currently inside a settings directory (the Session node
+    /// or any utility node)? Geometry templates are refused there. Checks the
+    /// whole path, not `current_path[0]` — the settings nodes live NESTED
+    /// under Session now, so the old first-segment check would miss them.
+    pub fn in_settings_dir(&self) -> bool {
+        let mut node = &self.fs_root;
+        for &idx in &self.current_path {
+            match node.children.get(idx) {
+                Some(child) => {
+                    if matches!(child.node_type.as_str(), "utility" | "session") {
+                        return true;
+                    }
+                    node = child;
+                }
+                None => return false,
+            }
+        }
+        false
+    }
+
     pub fn current_dir(&self) -> &FsNode {
         let mut node = &self.fs_root;
         for &i in &self.current_path {
@@ -1907,8 +1941,7 @@ impl State {
         let Some(sender) = self.event_sender.clone() else { return };
         // In a utility dir geometry templates are rejected at placement —
         // don't offer them.
-        let in_utility = !self.current_path.is_empty()
-            && self.fs_root.children[self.current_path[0]].node_type == "utility";
+        let in_utility = self.in_settings_dir();
         let items: String = self
             .node_templates
             .iter()
@@ -1947,7 +1980,15 @@ impl State {
             let dir = self.current_dir();
             let Some(node) = dir.children.get(slot) else { return };
             let enterable = node.is_enterable();
-            (node.node_type == "utility", node.geometry_visible, enterable)
+            (
+                matches!(node.node_type.as_str(), "utility" | "session"),
+                node.geometry_visible,
+                enterable,
+            )
+        };
+        let deletable = {
+            let dir = self.current_dir();
+            dir.children.get(slot).map(|n| n.node_type != "session").unwrap_or(false)
         };
         let mut options: Vec<String> = Vec::new();
         let mut actions: Vec<NodeMenuAction> = Vec::new();
@@ -1959,8 +2000,10 @@ impl State {
             options.push(if geom_visible { "Hide Geometry" } else { "Show Geometry" }.to_string());
             actions.push(NodeMenuAction::ToggleGeometry);
         }
-        options.push("Delete".to_string());
-        actions.push(NodeMenuAction::Delete);
+        if deletable {
+            options.push("Delete".to_string());
+            actions.push(NodeMenuAction::Delete);
+        }
 
         let target = self.slots.get_dyn(CONTENT_IDX).base().id();
         cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, 0, target);
@@ -2415,6 +2458,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
 
     pub fn delete_node(&mut self, slot: usize) -> bool {
         let len = self.current_dir().children.len();
+        // The Session node is permanent: every deletion route (context menu,
+        // Delete key, MCP) funnels through here, so this is the one gate.
+        if slot < len && self.current_dir().children[slot].node_type == "session" {
+            return false;
+        }
         if slot < len {
             self.current_dir_mut().children.remove(slot);
             if let Some(sel_idx) = self.graph().selected_node() {
diff --git a/src/main.rs b/src/main.rs
index da30991..a74a666 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -107,7 +107,8 @@ mod tests {
     fn test_main_node_offers_set_as_default() {
         let mut state = State::new(false);
         state.ensure_menubar_subnets();
-        let main = state.fs_root.children.iter().find(|c| c.name == "Main").expect("Main node");
+        let (s_idx, m_idx) = session_and_main(&state);
+        let main = &state.fs_root.children[s_idx].children[m_idx];
         let names: Vec<&str> = main.params.iter().map(|p| p.name.as_str()).collect();
         let idx = names.iter().position(|n| *n == "Set As Default").expect("Set As Default param");
         let save_as = names.iter().position(|n| *n == "Save As").unwrap();
@@ -345,7 +346,7 @@ mod tests {
     fn test_legacy_style_params_are_dropped_from_main() {
         let mut state = State::new(false);
         state.ensure_menubar_subnets();
-        let main_idx = state.fs_root.children.iter().position(|c| c.name == "Main").expect("Main node");
+        let (s_idx, main_idx) = session_and_main(&state);
 
         // Re-seed the params exactly as a pre-removal save carries them.
         for (name, ty, val) in [
@@ -354,7 +355,7 @@ mod tests {
             ("Edge Profile", "ramp", "smooth;0.000:0.000,1.000:1.000"),
             ("Plate Color", "rgba", "#11223344"),
         ] {
-            state.fs_root.children[main_idx].params.push(crate::app::ParamDef {
+            state.fs_root.children[s_idx].children[main_idx].params.push(crate::app::ParamDef {
                 name: name.to_string(),
                 label: String::new(),
                 param_type: ty.to_string(),
@@ -367,7 +368,8 @@ mod tests {
         }
 
         state.ensure_menubar_subnets();
-        let names: Vec<&str> = state.fs_root.children[main_idx]
+        let (s_idx, main_idx) = session_and_main(&state);
+        let names: Vec<&str> = state.fs_root.children[s_idx].children[main_idx]
             .params.iter().map(|p| p.name.as_str()).collect();
         for retired in ["Style", "Bevel Profile", "Edge Profile", "Plate Color"] {
             assert!(!names.contains(&retired), "retired style param survived load: {retired} in {names:?}");
@@ -393,6 +395,81 @@ mod tests {
         }
     }
 
+    /// The settings nodes live inside the permanent Session node now; tests
+    /// that need Main resolve it through there.
+    fn session_and_main(state: &State) -> (usize, usize) {
+        let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").expect("Session node");
+        let m_idx = state.fs_root.children[s_idx].children.iter().position(|c| c.name == "Main").expect("Main inside Session");
+        (s_idx, m_idx)
+    }
+
+    /// The Session node: exists at root, typed "session", holds exactly the
+    /// four settings nodes, and refuses deletion through the one gate every
+    /// deletion route funnels into.
+    #[test]
+    fn test_session_node_exists_and_cannot_be_deleted() {
+        let mut state = State::new(false);
+        state.ensure_menubar_subnets();
+
+        let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").expect("Session node at root");
+        let session = &state.fs_root.children[s_idx];
+        assert_eq!(session.name, "Session");
+        let names: Vec<&str> = session.children.iter().map(|c| c.name.as_str()).collect();
+        for expected in ["Main", "View", "Guides", "Render"] {
+            assert!(names.contains(&expected), "Session is missing {expected}: {names:?}");
+        }
+        // None of the four remain at root.
+        for c in &state.fs_root.children {
+            assert!(
+                !(c.node_type == "utility" && matches!(c.name.as_str(), "Main" | "View" | "Guides" | "Render")),
+                "settings node '{}' still at root", c.name
+            );
+        }
+
+        let before = state.fs_root.children.len();
+        assert!(!state.delete_node(s_idx), "delete_node deleted the Session node");
+        assert_eq!(state.fs_root.children.len(), before, "Session vanished anyway");
+        assert!(state.fs_root.children[s_idx].node_type == "session");
+    }
+
+    /// An old save carries Main/View/Guides/Render at the root with the user's
+    /// values in their params — migration must MOVE them (values intact), not
+    /// recreate them fresh.
+    #[test]
+    fn test_old_saves_migrate_settings_nodes_into_session() {
+        let mut state = State::new(false);
+        state.ensure_menubar_subnets();
+
+        // Simulate the old shape: pull the four back out to root, drop the
+        // Session node, and plant a probe param ensure doesn't own — the live-
+        // synced toggles are rewritten from app state by design, so only a
+        // foreign param can distinguish MOVED (probe survives) from RECREATED
+        // (probe gone).
+        let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").unwrap();
+        let mut session = state.fs_root.children.remove(s_idx);
+        for mut child in session.children.drain(..) {
+            if child.name == "Guides" {
+                child.params.push(crate::app::ParamDef {
+                    name: "migration probe".to_string(),
+                    label: String::new(),
+                    param_type: "text".to_string(),
+                    default: "survived".to_string(),
+                    options: vec![],
+                    min: None,
+                    max: None,
+                    step: None,
+                });
+            }
+            state.fs_root.children.push(child);
+        }
+
+        state.ensure_menubar_subnets();
+        let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").expect("Session recreated");
+        let guides = state.fs_root.children[s_idx].children.iter().find(|c| c.name == "Guides").expect("Guides migrated in");
+        let v = guides.params.iter().find(|p| p.name == "migration probe").map(|p| p.default.as_str());
+        assert_eq!(v, Some("survived"), "migration recreated Guides instead of moving it");
+    }
+
     #[test]
     fn test_load_default_project() {
         let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("default_project.json");
@@ -1310,8 +1387,10 @@ mod tests {
         // beneath the params pane's "Open" dropdown bled through it.
         let mut state = State::new(false);
         state.ensure_menubar_subnets();
-        let main_idx = state.fs_root.children.iter().position(|c| c.name == "Main").expect("Main node");
-        state.graph_mut().set_selected_node(Some(main_idx));
+        let (s_idx, m_idx) = session_and_main(&state);
+        state.current_path.push(s_idx);
+        state.on_path_changed();
+        state.graph_mut().set_selected_node(Some(m_idx));
         state.sync_parameters_pane();
 
         {
diff --git a/src/project.rs b/src/project.rs
index 65e0f92..55c37c2 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -397,8 +397,55 @@ impl State {
         // Retain only the Main utility subnet, removing the rest
         self.fs_root.children.retain(|c| c.name != "Network" && c.name != "Viewport" && c.name != "Parameters" && c.name != "Spreadsheet");
 
+        // The Session node: the permanent root container for the session-wide
+        // settings nodes (Main/View/Guides/Render). Older saves carried the
+        // four at the root — they are MOVED in, params intact, so an old
+        // project's values survive as the seeds. The node itself is
+        // undeletable (delete_node refuses the "session" type).
+        let mut migrated: Vec<FsNode> = Vec::new();
+        {
+            let mut idx = 0;
+            while idx < self.fs_root.children.len() {
+                let c = &self.fs_root.children[idx];
+                if c.node_type == "utility"
+                    && matches!(c.name.as_str(), "Main" | "View" | "Guides" | "Render")
+                {
+                    migrated.push(self.fs_root.children.remove(idx));
+                } else {
+                    idx += 1;
+                }
+            }
+        }
+        let session_idx = match self.fs_root.children.iter().position(|c| c.node_type == "session" || c.name == "Session") {
+            Some(i) => {
+                self.fs_root.children[i].node_type = "session".to_string();
+                i
+            }
+            None => {
+                self.fs_root.children.push(FsNode {
+                    id: crate::app::generate_node_id(),
+                    name: "Session".to_string(),
+                    node_type: "session".to_string(),
+                    children: vec![],
+                    params: vec![],
+                    geometry_visible: true,
+                    position: (0.0, 0.0),
+                    inputs: 0,
+                    outputs: 0,
+                });
+                self.fs_root.children.len() - 1
+            }
+        };
+        for node in migrated {
+            let session = &mut self.fs_root.children[session_idx];
+            if !session.children.iter().any(|c| c.name == node.name) {
+                session.children.push(node);
+            }
+        }
+        let session = &mut self.fs_root.children[session_idx];
+
         // 1. Main subnet
-        let main_node = find_or_create_subnet(&mut self.fs_root, "Main", "utility", (0.0, 0.0));
+        let main_node = find_or_create_subnet(session, "Main", "utility", (0.0, 0.0));
         main_node.children.clear();
 
         ensure_param(main_node, "File", "section", "", &[], None, None, None);
@@ -580,7 +627,7 @@ impl State {
         // node; the header menu items stay as command access). Pane state is
         // session-owned and never applied from the project, so the toggles
         // seed and refresh from live state.
-        let view_node = find_or_create_subnet(&mut self.fs_root, "View", "utility", (0.0, 2.0));
+        let view_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "View", "utility", (0.0, 2.0));
         view_node.children.clear();
         ensure_param(view_node, "Panes", "section", "", &[], None, None, None);
         ensure_param(view_node, "Show Network Pane", "toggle", bool_str(show_network), &[], None, None, None);
@@ -611,7 +658,7 @@ impl State {
         // migrated off Main). The utility column keeps one empty cell between
         // nodes: Main (0,0), View (0,2), Guides (0,4), Render (0,6); older
         // saves parked at prior defaults slide to the spaced slots.
-        let guides_node = find_or_create_subnet(&mut self.fs_root, "Guides", "utility", (0.0, 4.0));
+        let guides_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "Guides", "utility", (0.0, 4.0));
         if guides_node.position == (0.0, 1.0) || guides_node.position == (0.0, 2.0) {
             guides_node.position = (0.0, 4.0);
         }
@@ -646,7 +693,7 @@ impl State {
         // Main. Toggles reflect live state so a reopened project shows real
         // switches. Two rows below Guides (the spaced column); older saves
         // parked at the prior defaults slide down.
-        let render_node = find_or_create_subnet(&mut self.fs_root, "Render", "utility", (0.0, 6.0));
+        let render_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "Render", "utility", (0.0, 6.0));
         if render_node.position == (0.0, 1.0) || render_node.position == (0.0, 2.0) || render_node.position == (0.0, 4.0) {
             render_node.position = (0.0, 6.0);
         }
@@ -754,8 +801,14 @@ impl State {
     }
 
     pub(crate) fn apply_settings_from_menubar_subnets(&mut self) {
-        if let Some(guides_idx) = self.fs_root.children.iter().position(|c| c.name == "Guides") {
-            let params = self.fs_root.children[guides_idx].params.clone();
+        let session_params = |root: &FsNode, name: &str| -> Option<Vec<ParamDef>> {
+            root.children
+                .iter()
+                .find(|c| c.node_type == "session")
+                .and_then(|s| s.children.iter().find(|c| c.name == name))
+                .map(|n| n.params.clone())
+        };
+        if let Some(params) = session_params(&self.fs_root, "Guides") {
             for p in &params {
                 match p.name.as_str() {
                     "Show Grid Guide" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_grid = val; }
@@ -768,8 +821,7 @@ impl State {
                 }
             }
         }
-        if let Some(main_idx) = self.fs_root.children.iter().position(|c| c.name == "Main") {
-            let params = self.fs_root.children[main_idx].params.clone();
+        if let Some(params) = session_params(&self.fs_root, "Main") {
             for p in &params {
                 match p.name.as_str() {
                     // Network Settings
@@ -818,8 +870,7 @@ impl State {
             }
         }
 
-        if let Some(render_idx) = self.fs_root.children.iter().position(|c| c.name == "Render") {
-            let params = self.fs_root.children[render_idx].params.clone();
+        if let Some(params) = session_params(&self.fs_root, "Render") {
             for p in &params {
                 match p.name.as_str() {
                     "Show Wireframe" => if let Ok(val) = p.default.parse::<bool>() { self.wireframe = val; }
diff --git a/src/window.rs b/src/window.rs
index f93b1bb..54ea2e9 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -770,7 +770,7 @@ impl State {
                 if let Some(idx) = template_idx {
                     let mut node = state.node_templates[idx].node.clone();
                     let mut allowed = true;
-                    let is_in_utility = !state.current_path.is_empty() && state.fs_root.children[state.current_path[0]].node_type == "utility";
+                    let is_in_utility = state.in_settings_dir();
                     if is_in_utility {
                         if crate::geometry::is_geometry_node_type(&node.node_type) {
                             allowed = false;
@@ -800,6 +800,11 @@ impl State {
                 }
             }
             McpAction::DeleteNode { slot } => {
+                if slot < state.current_dir().children.len()
+                    && state.current_dir().children[slot].node_type == "session"
+                {
+                    return Err("The Session node is permanent and cannot be deleted".to_string());
+                }
                 if state.delete_node(slot) {
                     needs_redraw = true;
                     Ok("Node deleted".to_string())