Partie backend. Les fenêtres/panneaux détachés étaient restaurés avec un project_id figé au moment du détachement, si bien qu'ils restaient collés à un projet mort ou incohérent après redémarrage. Ils sont désormais restaurés en mode panel-only, sans project_id figé, et suivent le projet en focus de la fenêtre principale via un event focused-project exposé par la couche fenêtre. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1143 lines
42 KiB
Rust
1143 lines
42 KiB
Rust
//! 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<LayoutTree,
|
|
//! LayoutError>` 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<SessionId>,
|
|
/// The agent to launch automatically in this cell, if any.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub agent: Option<AgentId>,
|
|
/// **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<String>,
|
|
/// **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<String>,
|
|
/// 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<AgentId>) -> 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<String>) -> 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<String>) -> 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<WeightedChild>,
|
|
}
|
|
|
|
/// 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<f32>,
|
|
/// Row heights (relative); length = number of rows.
|
|
pub row_weights: Vec<f32>,
|
|
/// Cell placements with spans.
|
|
pub cells: Vec<GridCell>,
|
|
}
|
|
|
|
/// 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<Self, LayoutError> {
|
|
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<Self, LayoutError> {
|
|
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<Self, LayoutError> {
|
|
let mut outcome: Option<Result<(), LayoutError>> = 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<Self, LayoutError> {
|
|
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<SessionId>,
|
|
) -> Result<Self, LayoutError> {
|
|
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<Self, LayoutError> {
|
|
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<AgentId>,
|
|
) -> Result<Self, LayoutError> {
|
|
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<String>,
|
|
) -> Result<Self, LayoutError> {
|
|
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<String>,
|
|
) -> Result<Self, LayoutError> {
|
|
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<Self, LayoutError> {
|
|
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<AgentId, NodeId> = HashMap::new();
|
|
let mut has_signal: HashSet<AgentId> = 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<Option<SessionId>, LayoutError> {
|
|
fn find(node: &LayoutNode, id: NodeId) -> Option<Option<SessionId>> {
|
|
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<SessionId>,
|
|
) -> 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<SessionId>,
|
|
) -> 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<Tab>,
|
|
/// 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<Tab>, active_tab: TabId) -> Result<Self, LayoutError> {
|
|
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<Window>,
|
|
}
|
|
|
|
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<Self, LayoutError> {
|
|
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 })
|
|
}
|
|
}
|
|
|
|
/// Current persisted schema version for OS window state.
|
|
pub const WINDOW_STATE_SNAPSHOT_VERSION: u32 = 1;
|
|
|
|
/// Persisted kind of an IdeA OS window.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum PersistedWindowKind {
|
|
/// The primary IdeA window.
|
|
Main,
|
|
/// A detached project view window.
|
|
View,
|
|
}
|
|
|
|
/// Physical top-left position of a window or monitor.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PersistedWindowPosition {
|
|
/// X coordinate in physical pixels.
|
|
pub x: i32,
|
|
/// Y coordinate in physical pixels.
|
|
pub y: i32,
|
|
}
|
|
|
|
/// Physical size of a window or monitor.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PersistedWindowSize {
|
|
/// Width in physical pixels.
|
|
pub width: u32,
|
|
/// Height in physical pixels.
|
|
pub height: u32,
|
|
}
|
|
|
|
/// Best-effort monitor descriptor captured with a window.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PersistedMonitorState {
|
|
/// Human-readable monitor name when the platform exposes one.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub name: Option<String>,
|
|
/// Scale factor reported by the windowing system.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub scale_factor: Option<f64>,
|
|
/// Monitor origin in the global desktop coordinate space.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub position: Option<PersistedWindowPosition>,
|
|
/// Monitor physical size.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub size: Option<PersistedWindowSize>,
|
|
}
|
|
|
|
/// Persisted state of one IdeA webview window.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PersistedWindowState {
|
|
/// Stable Tauri label. For detached views this is `view-<panel>`.
|
|
pub label: String,
|
|
/// Window kind.
|
|
pub kind: PersistedWindowKind,
|
|
/// Detached view panel id. Present only for [`PersistedWindowKind::View`].
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub panel: Option<String>,
|
|
/// Legacy detached view project id. Ignored by panel-only restore.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub project_id: Option<crate::ids::ProjectId>,
|
|
/// App URL loaded in the window.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub url: Option<String>,
|
|
/// Whether the window was visible.
|
|
pub visible: bool,
|
|
/// Whether the window was maximized.
|
|
pub maximized: bool,
|
|
/// Whether the window was fullscreen.
|
|
pub fullscreen: bool,
|
|
/// Outer window position.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub outer_position: Option<PersistedWindowPosition>,
|
|
/// Outer window size.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub outer_size: Option<PersistedWindowSize>,
|
|
/// Current monitor at snapshot time.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub monitor: Option<PersistedMonitorState>,
|
|
/// Optional focus recency, left unset until focus tracking exists.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub last_focused_at: Option<u64>,
|
|
}
|
|
|
|
/// Persisted snapshot of the IdeA OS windows.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct WindowStateSnapshot {
|
|
/// Schema version.
|
|
pub version: u32,
|
|
/// Captured windows.
|
|
pub windows: Vec<PersistedWindowState>,
|
|
}
|
|
|
|
impl WindowStateSnapshot {
|
|
/// Builds a versioned snapshot from captured windows.
|
|
#[must_use]
|
|
pub fn new(windows: Vec<PersistedWindowState>) -> Self {
|
|
Self {
|
|
version: WINDOW_STATE_SNAPSHOT_VERSION,
|
|
windows,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for WindowStateSnapshot {
|
|
fn default() -> Self {
|
|
Self::new(Vec::new())
|
|
}
|
|
}
|