Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
233 lines
7.9 KiB
Rust
233 lines
7.9 KiB
Rust
//! [`LoadLayout`] and [`MutateLayout`] (ARCHITECTURE §6, §7; L4 + #4).
|
|
//!
|
|
//! A project owns **several named layouts** (see [`super::store`]). These two use
|
|
//! cases operate on **one** layout — the one identified by `layout_id`, or the
|
|
//! active layout when `layout_id` is `None`. They stay thin orchestrators: the
|
|
//! mutating operations are the domain's pure `LayoutTree` functions.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use domain::ports::{EventBus, FileSystem, ProjectStore};
|
|
use domain::{AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId, ProjectId, SessionId};
|
|
|
|
use crate::error::AppError;
|
|
|
|
use super::store::{persist_doc, resolve_doc};
|
|
|
|
/// Maps a [`LayoutError`] to the application error type.
|
|
fn map_layout_err(e: LayoutError) -> AppError {
|
|
match e {
|
|
LayoutError::NodeNotFound(id) => AppError::NotFound(format!("layout node {id}")),
|
|
other => AppError::Invalid(other.to_string()),
|
|
}
|
|
}
|
|
|
|
/// A layout mutation expressed in terms of the pure domain operations.
|
|
///
|
|
/// Each variant maps 1:1 to a pure `LayoutTree` method
|
|
/// (`split`/`merge`/`resize`/`move`/`set_session`). Decoupling the *operation*
|
|
/// from the *tree* keeps the use case a thin orchestrator and lets the
|
|
/// presentation layer (and undo/redo) speak in intentions.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum LayoutOperation {
|
|
/// Split a leaf into a two-child split (original + a new empty leaf).
|
|
Split {
|
|
/// The leaf to split.
|
|
target: NodeId,
|
|
/// Row (columns) or Column (rows).
|
|
direction: Direction,
|
|
/// Id for the new sibling leaf.
|
|
new_leaf: NodeId,
|
|
/// Id for the wrapping split container.
|
|
container: NodeId,
|
|
},
|
|
/// Collapse a split container back to one of its children.
|
|
Merge {
|
|
/// The split container to collapse.
|
|
container: NodeId,
|
|
/// Index of the child to keep.
|
|
keep_index: usize,
|
|
},
|
|
/// Reassign the relative weights of a split's children.
|
|
Resize {
|
|
/// The split container to resize.
|
|
container: NodeId,
|
|
/// New weights (one per child, all `> 0`).
|
|
weights: Vec<f32>,
|
|
},
|
|
/// Move the session hosted by one leaf to another (empty) leaf.
|
|
Move {
|
|
/// Source leaf (left empty).
|
|
from: NodeId,
|
|
/// Target leaf (must be empty).
|
|
to: NodeId,
|
|
},
|
|
/// Attach or detach a session to/from a leaf (cell ↔ terminal binding).
|
|
SetSession {
|
|
/// The hosting leaf.
|
|
target: NodeId,
|
|
/// Session to host, or `None` to clear.
|
|
session: Option<SessionId>,
|
|
},
|
|
/// Attach or detach an agent to/from a leaf (per-cell agent, feature #3).
|
|
SetCellAgent {
|
|
/// The hosting leaf.
|
|
target: NodeId,
|
|
/// Agent to associate, or `None` to clear.
|
|
agent: Option<AgentId>,
|
|
},
|
|
}
|
|
|
|
impl LayoutOperation {
|
|
/// Applies this operation to `tree`, returning the new validated tree.
|
|
fn apply(&self, tree: &LayoutTree) -> Result<LayoutTree, AppError> {
|
|
let result = match self {
|
|
Self::Split {
|
|
target,
|
|
direction,
|
|
new_leaf,
|
|
container,
|
|
} => tree.split(
|
|
*target,
|
|
*direction,
|
|
LeafCell {
|
|
id: *new_leaf,
|
|
session: None,
|
|
agent: None,
|
|
},
|
|
*container,
|
|
),
|
|
Self::Merge {
|
|
container,
|
|
keep_index,
|
|
} => tree.merge(*container, *keep_index),
|
|
Self::Resize { container, weights } => tree.resize(*container, weights),
|
|
Self::Move { from, to } => tree.move_session(*from, *to),
|
|
Self::SetSession { target, session } => tree.set_session(*target, *session),
|
|
Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent),
|
|
};
|
|
result.map_err(map_layout_err)
|
|
}
|
|
}
|
|
|
|
/// Input for [`LoadLayout::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LoadLayoutInput {
|
|
/// Project whose layout to load.
|
|
pub project_id: ProjectId,
|
|
/// Which named layout to load; `None` loads the active one.
|
|
pub layout_id: Option<LayoutId>,
|
|
}
|
|
|
|
/// Output of [`LoadLayout::execute`].
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct LoadLayoutOutput {
|
|
/// The id of the layout that was loaded (resolved from active when omitted).
|
|
pub layout_id: LayoutId,
|
|
/// The loaded layout tree.
|
|
pub layout: LayoutTree,
|
|
}
|
|
|
|
/// Loads one named layout (the active one by default), self-healing / migrating
|
|
/// the layouts store as needed (see [`super::store::resolve_doc`]).
|
|
pub struct LoadLayout {
|
|
store: Arc<dyn ProjectStore>,
|
|
fs: Arc<dyn FileSystem>,
|
|
}
|
|
|
|
impl LoadLayout {
|
|
/// Builds the use case from its injected ports.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn ProjectStore>, fs: Arc<dyn FileSystem>) -> Self {
|
|
Self { store, fs }
|
|
}
|
|
|
|
/// Executes the load.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the project or the requested layout is unknown,
|
|
/// - [`AppError::FileSystem`] if seeding the default layouts fails to persist,
|
|
/// - [`AppError::Store`] on registry I/O failure.
|
|
pub async fn execute(&self, input: LoadLayoutInput) -> Result<LoadLayoutOutput, AppError> {
|
|
let project = self.store.load_project(input.project_id).await?;
|
|
let doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
|
let id = doc.resolve_id(input.layout_id);
|
|
let named = doc
|
|
.find(id)
|
|
.ok_or_else(|| AppError::NotFound(format!("layout {id}")))?;
|
|
Ok(LoadLayoutOutput {
|
|
layout_id: id,
|
|
layout: named.tree.clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Input for [`MutateLayout::execute`].
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct MutateLayoutInput {
|
|
/// Project whose layout to mutate.
|
|
pub project_id: ProjectId,
|
|
/// Which named layout to mutate; `None` mutates the active one.
|
|
pub layout_id: Option<LayoutId>,
|
|
/// The operation to apply.
|
|
pub operation: LayoutOperation,
|
|
}
|
|
|
|
/// Output of [`MutateLayout::execute`].
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct MutateLayoutOutput {
|
|
/// The id of the mutated layout.
|
|
pub layout_id: LayoutId,
|
|
/// The new, persisted layout tree.
|
|
pub layout: LayoutTree,
|
|
}
|
|
|
|
/// Applies a pure layout operation to one named layout, persists the whole
|
|
/// layouts store and publishes [`DomainEvent::LayoutChanged`].
|
|
pub struct MutateLayout {
|
|
store: Arc<dyn ProjectStore>,
|
|
fs: Arc<dyn FileSystem>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl MutateLayout {
|
|
/// Builds the use case from its injected ports.
|
|
#[must_use]
|
|
pub fn new(
|
|
store: Arc<dyn ProjectStore>,
|
|
fs: Arc<dyn FileSystem>,
|
|
events: Arc<dyn EventBus>,
|
|
) -> Self {
|
|
Self { store, fs, events }
|
|
}
|
|
|
|
/// Executes the mutation.
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::NotFound`] if the project, layout or a referenced node is unknown,
|
|
/// - [`AppError::Invalid`] if the operation violates a layout invariant,
|
|
/// - [`AppError::FileSystem`] on persistence failure,
|
|
/// - [`AppError::Store`] on registry I/O failure.
|
|
pub async fn execute(&self, input: MutateLayoutInput) -> Result<MutateLayoutOutput, AppError> {
|
|
let project = self.store.load_project(input.project_id).await?;
|
|
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
|
let id = doc.resolve_id(input.layout_id);
|
|
|
|
let named = doc
|
|
.find_mut(id)
|
|
.ok_or_else(|| AppError::NotFound(format!("layout {id}")))?;
|
|
let next = input.operation.apply(&named.tree)?;
|
|
named.tree = next.clone();
|
|
|
|
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
|
self.events.publish(DomainEvent::LayoutChanged {
|
|
project_id: input.project_id,
|
|
});
|
|
|
|
Ok(MutateLayoutOutput {
|
|
layout_id: id,
|
|
layout: next,
|
|
})
|
|
}
|
|
}
|