//! Pure, immutable terminal layout model (the "spreadsheet-like" recursive grid) //! and its operations. //! //! See ARCHITECTURE.md §7. The model is a recursive split/grid tree. All //! mutating operations are **pure functions** `&LayoutTree -> Result` returning a new tree, which makes them trivially testable and //! enables undo/redo at the application layer. use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::ids::{AgentId, NodeId, SessionId, TabId, WindowId}; /// Direction of a [`SplitContainer`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Direction { /// Children laid out left→right (columns). Row, /// Children laid out top→bottom (rows). Column, } /// Returns `true` when a boolean is `false`. Used as a `skip_serializing_if` /// predicate so that default (`false`) flags are omitted from the serialized /// form, preserving backward/forward compatibility with leaves that predate the /// field. #[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool { !*b } /// A leaf cell hosting zero or one terminal session. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LeafCell { /// Node identifier. pub id: NodeId, /// The hosted session, if any (0 or 1). #[serde(default, skip_serializing_if = "Option::is_none")] pub session: Option, /// The agent to launch automatically in this cell, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option, /// **Id de paire IdeA** (`ConversationId`, UUID) de la conversation hébergée par /// la cellule — pivot **logique** et **model-agnostic** de la reprise (ARCHITECTURE /// §19.7). C'est sous cette clé que le log canonique et le `handoff.md` sont /// rangés (`P6b`) puis retrouvés au (re)lancement (`P7`) ; elle **survit** au swap /// de profil. **Distinct** de l'id de session moteur (resumable Claude/Codex), qui /// vit désormais dans [`Self::engine_session_id`] / `providers.json`, plus jamais /// ici. Reste nommé `conversation_id` (compat sérialisation/DTO). #[serde(default, skip_serializing_if = "Option::is_none")] pub conversation_id: Option, /// **Cache** de l'id de session **moteur** (resumable du provider courant : id /// Claude `--resume`, ou l'UUID minté pour `--session-id`) — distinct de l'id de /// paire ([`Self::conversation_id`]). Additif (`None` par défaut) ; la **source de /// vérité** du resumable est `providers.json` (ARCHITECTURE §19.7, lot P8b). Sert /// au plan de session `--resume` (P8c) et à l'inspection/popup, sans jamais /// polluer la clé logique de la conversation. #[serde(default, skip_serializing_if = "Option::is_none")] pub engine_session_id: Option, /// Whether the cell's agent process was running at the moment the cell was /// last closed. Used to decide whether to auto-resume the agent on reopen. #[serde(default, skip_serializing_if = "is_false")] pub agent_was_running: bool, } impl LeafCell { /// Crée une feuille **vide** (ni session, ni agent, ni conversation), porteuse du /// seul `id`. Constructeur de commodité **additif** : il n'altère aucune /// construction par littéral existante, mais offre un point d'entrée stable face /// aux champs additifs (comme [`Self::engine_session_id`]) — les appelants /// composent ensuite avec les withers `with_*`. #[must_use] pub fn new(id: NodeId) -> Self { Self { id, session: None, agent: None, conversation_id: None, engine_session_id: None, agent_was_running: false, } } /// Wither additif : pose l'agent auto-lancé de la cellule. #[must_use] pub fn with_agent(mut self, agent: Option) -> Self { self.agent = agent; self } /// Wither additif : pose l'**id de paire IdeA** ([`Self::conversation_id`]). #[must_use] pub fn with_conversation(mut self, conversation_id: Option) -> Self { self.conversation_id = conversation_id; self } /// Wither additif : pose le **cache de l'id de session moteur** /// ([`Self::engine_session_id`]). #[must_use] pub fn with_engine_session(mut self, engine_session_id: Option) -> Self { self.engine_session_id = engine_session_id; self } } /// A weighted child within a [`SplitContainer`]. The `weight` is a *relative* /// resizable share; the UI normalises it for rendering. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WeightedChild { /// The child node. pub node: LayoutNode, /// Relative weight; invariant: `> 0`. pub weight: f32, } /// A simple n-ary weighted split (rows or columns). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SplitContainer { /// Node identifier. pub id: NodeId, /// Split direction. pub direction: Direction, /// Ordered children (left→right / top→bottom). pub children: Vec, } /// A grid cell placement with spans (spreadsheet-style merging). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GridCell { /// The hosted node (may itself be a split/grid — recursive). pub node: LayoutNode, /// Zero-based row index. pub row: u16, /// Zero-based column index. pub col: u16, /// Row span; invariant: `>= 1`. pub row_span: u16, /// Column span; invariant: `>= 1`. pub col_span: u16, } /// A spreadsheet-like grid with per-column / per-row weights and span-based /// merging. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GridContainer { /// Node identifier. pub id: NodeId, /// Column widths (relative); length = number of columns. pub col_weights: Vec, /// Row heights (relative); length = number of rows. pub row_weights: Vec, /// Cell placements with spans. pub cells: Vec, } /// A node in the layout tree. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "type", content = "node")] pub enum LayoutNode { /// A terminal-hosting leaf. Leaf(LeafCell), /// A weighted split. Split(SplitContainer), /// A spreadsheet-style grid. Grid(GridContainer), } /// The root of a layout (one per tab). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LayoutTree { /// Root node. pub root: LayoutNode, } /// Errors produced by layout validation and operations. #[derive(Debug, Clone, PartialEq, Error)] pub enum LayoutError { /// A weight was not strictly positive. #[error("weight must be > 0, got {weight}")] NonPositiveWeight { /// Offending weight. weight: f32, }, /// A split had no children. #[error("a split container must have at least one child")] EmptySplit, /// A grid span was less than one. #[error("grid span must be >= 1")] InvalidSpan, /// A grid cell extends beyond the grid bounds. #[error("grid cell at ({row},{col}) span ({row_span}x{col_span}) exceeds grid {rows}x{cols}")] SpanOutOfBounds { /// Cell row. row: u16, /// Cell column. col: u16, /// Row span. row_span: u16, /// Column span. col_span: u16, /// Grid rows. rows: u16, /// Grid cols. cols: u16, }, /// Two grid cells overlap. #[error("grid cells overlap at ({row},{col})")] OverlappingCells { /// Row of the overlap. row: u16, /// Column of the overlap. col: u16, }, /// Part of the grid surface is not covered by any cell. #[error("grid surface not fully covered: cell ({row},{col}) is empty")] UncoveredCell { /// Uncovered row. row: u16, /// Uncovered column. col: u16, }, /// The same session appears in more than one leaf. #[error("session {0} appears in more than one leaf")] DuplicateSession(SessionId), /// A referenced node id was not found in the tree. #[error("node {0} not found")] NodeNotFound(NodeId), /// A merge/move spanned two distinct containers. #[error("operation cannot span two distinct containers")] CrossContainer, /// A referenced tab id was not found in any window. #[error("tab {0} not found")] TabNotFound(TabId), } impl LayoutTree { /// Wraps a root node into a tree (without validation). #[must_use] pub fn new(root: LayoutNode) -> Self { Self { root } } /// Convenience: a single-leaf tree. #[must_use] pub fn single(leaf: LeafCell) -> Self { Self { root: LayoutNode::Leaf(leaf), } } /// Validates **all** layout invariants on the whole tree: /// positive weights, valid/non-overlapping/fully-covering grid spans, and /// at-most-one-leaf-per-session uniqueness. /// /// # Errors /// Returns the first [`LayoutError`] encountered. pub fn validate(&self) -> Result<(), LayoutError> { let mut sessions = std::collections::HashSet::new(); validate_node(&self.root, &mut sessions) } /// Splits the leaf `target` into a [`SplitContainer`] with the original leaf /// and a new leaf `new_leaf`, in the given `direction` with equal weights. /// /// Pure: returns a new validated tree. /// /// # Errors /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree, /// - any validation error of the resulting tree. pub fn split( &self, target: NodeId, direction: Direction, new_leaf: LeafCell, container_id: NodeId, ) -> Result { let mut found = false; let root = map_node(&self.root, &mut |node| { if let LayoutNode::Leaf(leaf) = node { if leaf.id == target { found = true; return LayoutNode::Split(SplitContainer { id: container_id, direction, children: vec![ WeightedChild { node: LayoutNode::Leaf(leaf.clone()), weight: 1.0, }, WeightedChild { node: LayoutNode::Leaf(new_leaf.clone()), weight: 1.0, }, ], }); } } node.clone() }); if !found { return Err(LayoutError::NodeNotFound(target)); } let tree = Self { root }; tree.validate()?; Ok(tree) } /// Merges a [`SplitContainer`] identified by `container` back into a single /// node, keeping the child at `keep_index` and discarding the rest. /// /// Pure: returns a new validated tree. /// /// # Errors /// - [`LayoutError::NodeNotFound`] if `container` is not a split in the tree, /// - [`LayoutError::CrossContainer`] if `keep_index` is out of range, /// - any validation error of the resulting tree. pub fn merge(&self, container: NodeId, keep_index: usize) -> Result { let mut result: Result<(), LayoutError> = Ok(()); let root = map_node(&self.root, &mut |node| { if let LayoutNode::Split(split) = node { if split.id == container { match split.children.get(keep_index) { Some(child) => return child.node.clone(), None => result = Err(LayoutError::CrossContainer), } } } node.clone() }); result?; if root == self.root { return Err(LayoutError::NodeNotFound(container)); } let tree = Self { root }; tree.validate()?; Ok(tree) } /// Resizes the children of a [`SplitContainer`] by assigning new `weights`. /// /// Pure: returns a new validated tree. /// /// # Errors /// - [`LayoutError::NodeNotFound`] if `container` is not a split, /// - [`LayoutError::CrossContainer`] if `weights.len()` differs from the /// child count, /// - [`LayoutError::NonPositiveWeight`] (via validation) if any weight ≤ 0. pub fn resize(&self, container: NodeId, weights: &[f32]) -> Result { let mut outcome: Option> = None; let root = map_node(&self.root, &mut |node| { if let LayoutNode::Split(split) = node { if split.id == container { if split.children.len() != weights.len() { outcome = Some(Err(LayoutError::CrossContainer)); return node.clone(); } let children = split .children .iter() .zip(weights.iter()) .map(|(child, &w)| WeightedChild { node: child.node.clone(), weight: w, }) .collect(); outcome = Some(Ok(())); return LayoutNode::Split(SplitContainer { id: split.id, direction: split.direction, children, }); } } node.clone() }); match outcome { Some(Ok(())) => { let tree = Self { root }; tree.validate()?; Ok(tree) } Some(Err(e)) => Err(e), None => Err(LayoutError::NodeNotFound(container)), } } /// Moves the session currently hosted by leaf `from` to leaf `to`. /// /// `from` is left empty; `to` must currently be empty. This models dragging /// a terminal between cells without duplicating it. Pure: returns a new /// validated tree. /// /// # Errors /// - [`LayoutError::NodeNotFound`] if either leaf is missing, /// - [`LayoutError::CrossContainer`] if `from` has no session or `to` is /// occupied, /// - any validation error of the resulting tree. pub fn move_session(&self, from: NodeId, to: NodeId) -> Result { let session = self.session_in_leaf(from)?; let Some(session) = session else { return Err(LayoutError::CrossContainer); }; // Target must exist and be empty. match self.session_in_leaf(to)? { None => {} Some(_) => return Err(LayoutError::CrossContainer), } let root = map_node(&self.root, &mut |node| { if let LayoutNode::Leaf(leaf) = node { if leaf.id == from { let mut leaf = leaf.clone(); leaf.session = None; return LayoutNode::Leaf(leaf); } if leaf.id == to { let mut leaf = leaf.clone(); leaf.session = Some(session); return LayoutNode::Leaf(leaf); } } node.clone() }); let tree = Self { root }; tree.validate()?; Ok(tree) } /// Attaches (or, with `None`, detaches) a [`SessionId`] to the leaf `target`. /// /// This is the bridge between the layout and the terminal layer (L3/L4): when /// [`crate::terminal::TerminalSession`] is opened for a cell, the application /// records its id in the hosting leaf with this pure operation. /// /// Pure: returns a new validated tree. /// /// # Errors /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree, /// - [`LayoutError::DuplicateSession`] (via validation) if `session` is already /// hosted by another leaf. pub fn set_session( &self, target: NodeId, session: Option, ) -> Result { let mut found = false; let root = map_node(&self.root, &mut |node| { if let LayoutNode::Leaf(leaf) = node { if leaf.id == target { found = true; let mut leaf = leaf.clone(); leaf.session = session; return LayoutNode::Leaf(leaf); } } node.clone() }); if !found { return Err(LayoutError::NodeNotFound(target)); } let tree = Self { root }; tree.validate()?; Ok(tree) } /// Attaches an existing live [`SessionId`] to `target`, moving it away from /// any other visible leaf first. /// /// This models the IdeA-first visible/background contract: a live agent /// session may keep running while detached from the grid, and opening a cell /// on that already-running session simply re-attaches its view. The session is /// visible in at most one cell because any previous host is cleared before the /// target is set. /// /// Pure: returns a new validated tree. /// /// # Errors /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree, /// - [`LayoutError::CrossContainer`] if `target` hosts a different session. pub fn attach_session(&self, target: NodeId, session: SessionId) -> Result { let target_session = self.session_in_leaf(target)?; if let Some(existing) = target_session { if existing == session { return Ok(self.clone()); } return Err(LayoutError::CrossContainer); } let root = map_node(&self.root, &mut |node| { if let LayoutNode::Leaf(leaf) = node { if leaf.session == Some(session) && leaf.id != target { let mut leaf = leaf.clone(); leaf.session = None; return LayoutNode::Leaf(leaf); } if leaf.id == target { let mut leaf = leaf.clone(); leaf.session = Some(session); return LayoutNode::Leaf(leaf); } } node.clone() }); let tree = Self { root }; tree.validate()?; Ok(tree) } /// Attaches (or, with `None`, detaches) an [`AgentId`] to the leaf `target`. /// /// Records which agent should be auto-launched in the cell (feature #3). /// /// Pure: returns a new validated tree. /// /// # Errors /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree. pub fn set_cell_agent( &self, target: NodeId, agent: Option, ) -> Result { let mut found = false; let root = map_node(&self.root, &mut |node| { if let LayoutNode::Leaf(leaf) = node { if leaf.id == target { found = true; let mut leaf = leaf.clone(); leaf.agent = agent; return LayoutNode::Leaf(leaf); } } node.clone() }); if !found { return Err(LayoutError::NodeNotFound(target)); } let tree = Self { root }; tree.validate()?; Ok(tree) } /// Sets (or, with `None`, clears) the persistent CLI `conversation_id` on /// the leaf `target`. /// /// Unlike [`Self::set_session`] (the ephemeral PTY binding), the conversation /// id survives PTY close/reopen and is what lets the agent CLI *resume* its /// previous conversation. /// /// Pure: returns a new validated tree. /// /// # Errors /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree. pub fn set_cell_conversation( &self, target: NodeId, conversation_id: Option, ) -> Result { let mut found = false; let root = map_node(&self.root, &mut |node| { if let LayoutNode::Leaf(leaf) = node { if leaf.id == target { found = true; let mut leaf = leaf.clone(); leaf.conversation_id = conversation_id.clone(); return LayoutNode::Leaf(leaf); } } node.clone() }); if !found { return Err(LayoutError::NodeNotFound(target)); } let tree = Self { root }; tree.validate()?; Ok(tree) } /// Sets (or, with `None`, clears) the **engine session id** cache /// ([`LeafCell::engine_session_id`]) on the leaf `target` — the resumable id of /// the current provider (id Claude `--resume`, UUID minté pour `--session-id`). /// /// Jumeau additif de [`Self::set_cell_conversation`] (ARCHITECTURE §19.7) : /// l'id de paire **logique** reste porté par `conversation_id`, l'id **moteur** /// est rangé séparément ici (cache ; source de vérité `providers.json`). Ne touche /// **que** ce champ, laissant `conversation_id` intact — c'est la séparation des /// deux clés qui corrige l'incohérence P6/P7. /// /// Pure: returns a new validated tree. /// /// # Errors /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree. pub fn set_cell_engine_session( &self, target: NodeId, engine_session_id: Option, ) -> Result { let mut found = false; let root = map_node(&self.root, &mut |node| { if let LayoutNode::Leaf(leaf) = node { if leaf.id == target { found = true; let mut leaf = leaf.clone(); leaf.engine_session_id = engine_session_id.clone(); return LayoutNode::Leaf(leaf); } } node.clone() }); if !found { return Err(LayoutError::NodeNotFound(target)); } let tree = Self { root }; tree.validate()?; Ok(tree) } /// Records whether the cell's agent process was `running` at close time, on /// the leaf `target`. /// /// Pure: returns a new validated tree. /// /// # Errors /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree. pub fn set_agent_running(&self, target: NodeId, running: bool) -> Result { let mut found = false; let root = map_node(&self.root, &mut |node| { if let LayoutNode::Leaf(leaf) = node { if leaf.id == target { found = true; let mut leaf = leaf.clone(); leaf.agent_was_running = running; return LayoutNode::Leaf(leaf); } } node.clone() }); if !found { return Err(LayoutError::NodeNotFound(target)); } let tree = Self { root }; tree.validate()?; Ok(tree) } /// Collects every leaf that carries an agent, as `(leaf id, agent id)` pairs. /// /// Used by the close-time snapshot of running agents: the application walks /// these leaves and records, on each, whether the agent's PTY was still live /// (`agent_was_running`) before the global PTY kill. Pure read-only traversal. #[must_use] pub fn agent_leaves(&self) -> Vec<(NodeId, AgentId)> { fn walk(node: &LayoutNode, out: &mut Vec<(NodeId, AgentId)>) { match node { LayoutNode::Leaf(leaf) => { if let Some(agent) = leaf.agent { out.push((leaf.id, agent)); } } LayoutNode::Split(split) => { for child in &split.children { walk(&child.node, out); } } LayoutNode::Grid(grid) => { for cell in &grid.cells { walk(&cell.node, out); } } } } let mut out = Vec::new(); walk(&self.root, &mut out); out } /// Réconcilie les feuilles en doublon sur un même agent : à la réouverture /// d'un projet, un `layouts.json` persisté peut contenir **plusieurs** /// feuilles portant le **même** `agent` id (constaté). L'invariant produit /// est **« 1 session vivante par agent »** : une seule feuille doit rester /// « hôte » (potentiellement vivante / reprenable), les autres deviennent des /// **vues mortes** — elles gardent leur agent (la cellule reste), mais ne sont /// plus considérées « était en cours » : leur `agent_was_running` repasse à /// `false` et leur `conversation_id` est retiré, de sorte qu'aucune reprise ni /// relance ne les vise. /// /// ## Règle déterministe de choix de l'hôte /// /// Parmi les N feuilles d'un même agent, **dans l'ordre de parcours /// déterministe de l'arbre** (le même pré-ordre que [`Self::agent_leaves`]), /// l'hôte est la **première feuille porteuse d'un signal de reprise** /// (`conversation_id.is_some()` **ou** `agent_was_running`) ; à défaut de tout /// signal, l'hôte est simplement la **première** feuille rencontrée. On garde /// ainsi sur l'unique hôte l'état de reprise le plus pertinent, et le choix est /// totalement déterministe (l'ordre de parcours est stable). /// /// ## Idempotence /// /// Un arbre **sans doublon** est renvoyé **inchangé** (`self.clone()` à /// l'identique). Un arbre **déjà réconcilié** (≤ 1 feuille par agent porte un /// signal de reprise) l'est aussi : la 2ᵉ passe ne dé-flagge rien. La fonction /// est donc un point fixe — utile pour garantir le no-op à la 2ᵉ ouverture. /// /// Pure : renvoie un nouvel arbre (non revalidé — la réconciliation ne touche /// ni la structure ni les sessions, seuls des champs scalaires de feuille). #[must_use] pub fn reconcile_duplicate_agents(&self) -> Self { use std::collections::{HashMap, HashSet}; // Première passe (lecture seule) : pour chaque agent, déterminer le node // hôte selon la règle déterministe ci-dessus. let mut host_of: HashMap = HashMap::new(); let mut has_signal: HashSet = HashSet::new(); for (node_id, agent_id) in self.agent_leaves() { // `leaf` ne peut pas être `None` ici : le node vient de l'arbre. let signal = self .leaf(node_id) .is_some_and(|l| l.conversation_id.is_some() || l.agent_was_running); match host_of.get(&agent_id) { // Pas encore d'hôte : cette feuille le devient (provisoirement). None => { host_of.insert(agent_id, node_id); if signal { has_signal.insert(agent_id); } } // Un hôte sans signal est supplanté par la première feuille à // signal rencontrée ensuite. Some(_) if signal && !has_signal.contains(&agent_id) => { host_of.insert(agent_id, node_id); has_signal.insert(agent_id); } Some(_) => {} } } // Seconde passe : dé-flagger toute feuille d'agent qui n'est pas son hôte. let root = map_node(&self.root, &mut |node| { if let LayoutNode::Leaf(leaf) = node { if let Some(agent) = leaf.agent { let is_host = host_of.get(&agent) == Some(&leaf.id); if !is_host && (leaf.agent_was_running || leaf.conversation_id.is_some()) { let mut leaf = leaf.clone(); leaf.conversation_id = None; leaf.engine_session_id = None; leaf.agent_was_running = false; return LayoutNode::Leaf(leaf); } } } node.clone() }); Self { root } } /// Retrouve la [`LeafCell`] portant l'identifiant `node`. /// /// Renvoie `None` si aucun node ne porte cet id, **ou** si le node existe mais /// est un split/grid (pas une feuille). Traversée pure en lecture seule, dans /// le même esprit que [`Self::agent_leaves`] / [`Self::session_in_leaf`]. #[must_use] pub fn leaf(&self, node: NodeId) -> Option<&LeafCell> { fn find(n: &LayoutNode, id: NodeId) -> Option<&LeafCell> { match n { LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf), LayoutNode::Leaf(_) => None, LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)), LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)), } } find(&self.root, node) } /// Returns `Ok(Some(session))` / `Ok(None)` for the session held by the leaf /// `id`, or [`LayoutError::NodeNotFound`] if no such leaf exists. fn session_in_leaf(&self, id: NodeId) -> Result, LayoutError> { fn find(node: &LayoutNode, id: NodeId) -> Option> { match node { LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session), LayoutNode::Leaf(_) => None, LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)), LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)), } } find(&self.root, id).ok_or(LayoutError::NodeNotFound(id)) } } /// Recursively rebuilds a node, applying `f` to every node (post-order: `f` sees /// already-rebuilt children). fn map_node(node: &LayoutNode, f: &mut impl FnMut(&LayoutNode) -> LayoutNode) -> LayoutNode { let rebuilt = match node { LayoutNode::Leaf(_) => node.clone(), LayoutNode::Split(split) => LayoutNode::Split(SplitContainer { id: split.id, direction: split.direction, children: split .children .iter() .map(|c| WeightedChild { node: map_node(&c.node, f), weight: c.weight, }) .collect(), }), LayoutNode::Grid(grid) => LayoutNode::Grid(GridContainer { id: grid.id, col_weights: grid.col_weights.clone(), row_weights: grid.row_weights.clone(), cells: grid .cells .iter() .map(|c| GridCell { node: map_node(&c.node, f), row: c.row, col: c.col, row_span: c.row_span, col_span: c.col_span, }) .collect(), }), }; f(&rebuilt) } /// Recursive validation of a single node, accumulating seen sessions to enforce /// global uniqueness. fn validate_node( node: &LayoutNode, sessions: &mut std::collections::HashSet, ) -> Result<(), LayoutError> { match node { LayoutNode::Leaf(leaf) => { if let Some(session) = leaf.session { if !sessions.insert(session) { return Err(LayoutError::DuplicateSession(session)); } } Ok(()) } LayoutNode::Split(split) => { if split.children.is_empty() { return Err(LayoutError::EmptySplit); } for child in &split.children { if child.weight <= 0.0 { return Err(LayoutError::NonPositiveWeight { weight: child.weight, }); } validate_node(&child.node, sessions)?; } Ok(()) } LayoutNode::Grid(grid) => validate_grid(grid, sessions), } } /// Validates a grid: positive weights, in-bounds spans, no overlaps, full /// coverage, and recursion into cell contents. fn validate_grid( grid: &GridContainer, sessions: &mut std::collections::HashSet, ) -> Result<(), LayoutError> { for &w in grid.col_weights.iter().chain(grid.row_weights.iter()) { if w <= 0.0 { return Err(LayoutError::NonPositiveWeight { weight: w }); } } let rows = grid.row_weights.len(); let cols = grid.col_weights.len(); // Occupancy matrix to detect overlaps and gaps. let mut occupied = vec![false; rows * cols]; for cell in &grid.cells { if cell.row_span < 1 || cell.col_span < 1 { return Err(LayoutError::InvalidSpan); } let row_end = cell.row as usize + cell.row_span as usize; let col_end = cell.col as usize + cell.col_span as usize; if row_end > rows || col_end > cols { return Err(LayoutError::SpanOutOfBounds { row: cell.row, col: cell.col, row_span: cell.row_span, col_span: cell.col_span, rows: rows as u16, cols: cols as u16, }); } for r in cell.row as usize..row_end { for c in cell.col as usize..col_end { let idx = r * cols + c; if occupied[idx] { return Err(LayoutError::OverlappingCells { row: r as u16, col: c as u16, }); } occupied[idx] = true; } } validate_node(&cell.node, sessions)?; } // Full coverage. for r in 0..rows { for c in 0..cols { if !occupied[r * cols + c] { return Err(LayoutError::UncoveredCell { row: r as u16, col: c as u16, }); } } } Ok(()) } // --------------------------------------------------------------------------- // Window / Tab / Workspace — persisted presentation entities (ARCHITECTURE §3.2) // --------------------------------------------------------------------------- /// An open tab, bound 1:1 to a project. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Tab { /// Tab identifier. pub id: TabId, /// The project shown in this tab. pub project_id: crate::ids::ProjectId, /// The terminal layout of this tab. pub layout: LayoutTree, } /// An OS window holding one or more tabs. /// /// Invariant: a non-closed window holds at least one tab (see [`Window::new`]). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Window { /// Window identifier. pub id: WindowId, /// Tabs in this window. pub tabs: Vec, /// Currently active tab. pub active_tab: TabId, } impl Window { /// Builds a window, enforcing the "≥ 1 tab" invariant and that `active_tab` /// refers to one of the tabs. /// /// # Errors /// Returns [`LayoutError::CrossContainer`] if `tabs` is empty or `active_tab` /// is not present. pub fn new(id: WindowId, tabs: Vec, active_tab: TabId) -> Result { if tabs.is_empty() || !tabs.iter().any(|t| t.id == active_tab) { return Err(LayoutError::CrossContainer); } Ok(Self { id, tabs, active_tab, }) } } /// The set of windows for a user session. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Workspace { /// All open OS windows. pub windows: Vec, } impl Workspace { /// Detaches a tab into a brand-new window (ARCHITECTURE §10, L10). /// /// A **pure** transformation returning the next workspace state — the tab is /// *moved*, never duplicated (the "a project is open in exactly one tab" /// invariant). If the source window becomes empty it is removed; otherwise, if /// the detached tab was the active one, the source's active tab falls back to /// its first remaining tab. The new window holds the tab and makes it active. /// /// # Errors /// - [`LayoutError::TabNotFound`] if no window contains `tab_id`, /// - propagates [`Window::new`] invariants for the created window. pub fn move_tab_to_new_window( &self, tab_id: TabId, new_window_id: WindowId, ) -> Result { let mut windows = self.windows.clone(); // Locate the (window, tab) holding `tab_id`. let (wi, ti) = windows .iter() .enumerate() .find_map(|(wi, w)| { w.tabs .iter() .position(|t| t.id == tab_id) .map(|ti| (wi, ti)) }) .ok_or(LayoutError::TabNotFound(tab_id))?; let tab = windows[wi].tabs.remove(ti); if windows[wi].tabs.is_empty() { // The source window is now empty: drop it (windows hold ≥ 1 tab). windows.remove(wi); } else if windows[wi].active_tab == tab_id { // The moved tab was active: fall back to the first remaining tab. windows[wi].active_tab = windows[wi].tabs[0].id; } let detached = Window::new(new_window_id, vec![tab.clone()], tab.id)?; windows.push(detached); Ok(Self { windows }) } }