Implémente la vue chat structurée par cellule agent (toggle TUI/CLI custom, préférence persistée `preferred_view`, reattach live, composer + pièces jointes) avec le socle backend AgentSession/ChatBridge (UserPrompt, cancel_current_turn, routage interrupt_agent, commande cancel_agent_chat). Corrige le bug bloquant relevé par QA : le bouton Cancel de CustomAgentChatView interrompait tout le tour via closeAgentChat au lieu de n'annuler que le tour courant via cancelAgentChat, ce qui tuait la session contrairement au contrat produit validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1279 lines
47 KiB
Rust
1279 lines
47 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};
|
|
use crate::plugin::{PluginId, PluginLayoutType};
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// Preferred live view for an agent leaf.
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum PreferredView {
|
|
/// Native terminal/TUI view.
|
|
#[default]
|
|
Tui,
|
|
/// Structured chat view.
|
|
Chat,
|
|
}
|
|
|
|
/// Returns `true` when the preferred view is the default `Tui`.
|
|
#[allow(clippy::trivially_copy_pass_by_ref)]
|
|
fn is_default_preferred_view(view: &PreferredView) -> bool {
|
|
*view == PreferredView::Tui
|
|
}
|
|
|
|
/// 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,
|
|
/// Preferred live view for this cell. Additive and defaulted to TUI for old
|
|
/// layouts; meaningful only when [`Self::agent`] is set.
|
|
#[serde(default, skip_serializing_if = "is_default_preferred_view")]
|
|
pub preferred_view: PreferredView,
|
|
}
|
|
|
|
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,
|
|
preferred_view: PreferredView::Tui,
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// Wither additif : pose la vue live préférée de la cellule.
|
|
#[must_use]
|
|
pub fn with_preferred_view(mut self, preferred_view: PreferredView) -> Self {
|
|
self.preferred_view = preferred_view;
|
|
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>,
|
|
}
|
|
|
|
/// Persisted custom layout cell provided by an installed plugin.
|
|
///
|
|
/// The domain stores only the stable provider identity, layout type, and opaque
|
|
/// state. It never stores or resolves the React component used to render it.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CustomPluginLayoutCell {
|
|
/// Node identifier.
|
|
pub id: NodeId,
|
|
/// Provider plugin id.
|
|
pub plugin_id: PluginId,
|
|
/// Persisted layout type declared by the provider plugin.
|
|
pub layout_type: PluginLayoutType,
|
|
/// Opaque plugin-owned state.
|
|
#[serde(default)]
|
|
pub state: serde_json::Value,
|
|
}
|
|
|
|
/// 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),
|
|
/// A plugin-provided custom layout leaf.
|
|
CustomPluginLayout(CustomPluginLayoutCell),
|
|
}
|
|
|
|
/// 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 the preferred live view 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_cell_preferred_view(
|
|
&self,
|
|
target: NodeId,
|
|
preferred_view: PreferredView,
|
|
) -> 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.preferred_view = preferred_view;
|
|
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)
|
|
}
|
|
|
|
/// Replaces the opaque state of a plugin-provided custom layout leaf.
|
|
///
|
|
/// Pure: returns a new validated tree.
|
|
///
|
|
/// # Errors
|
|
/// - [`LayoutError::NodeNotFound`] if `target` is not a custom plugin layout
|
|
/// leaf in the tree.
|
|
pub fn set_plugin_layout_state(
|
|
&self,
|
|
target: NodeId,
|
|
state: serde_json::Value,
|
|
) -> Result<Self, LayoutError> {
|
|
let mut found = false;
|
|
let root = map_node(&self.root, &mut |node| {
|
|
if let LayoutNode::CustomPluginLayout(cell) = node {
|
|
if cell.id == target {
|
|
found = true;
|
|
let mut cell = cell.clone();
|
|
cell.state = state.clone();
|
|
return LayoutNode::CustomPluginLayout(cell);
|
|
}
|
|
}
|
|
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::CustomPluginLayout(_) => {}
|
|
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::CustomPluginLayout(_) => 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::CustomPluginLayout(_) => 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::CustomPluginLayout(_) => 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::CustomPluginLayout(_) => 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,
|
|
/// A detached window hosting a plugin-contributed layout.
|
|
PluginLayout,
|
|
}
|
|
|
|
/// Persisted plugin layout surface hosted by a detached OS window.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PersistedPluginLayoutWindow {
|
|
/// Provider plugin id.
|
|
pub plugin_id: PluginId,
|
|
/// Layout type declared by the provider plugin.
|
|
pub layout_type: PluginLayoutType,
|
|
/// Opaque plugin-owned initial/window state.
|
|
#[serde(default)]
|
|
pub state: serde_json::Value,
|
|
}
|
|
|
|
/// 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>,
|
|
/// Plugin layout surface. Present only for [`PersistedWindowKind::PluginLayout`].
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub plugin_layout: Option<PersistedPluginLayoutWindow>,
|
|
/// 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())
|
|
}
|
|
}
|