feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
336
crates/application/src/layout/management.rs
Normal file
336
crates/application/src/layout/management.rs
Normal file
@ -0,0 +1,336 @@
|
||||
//! Named-layout management use cases (#4): list, create, rename, delete and set
|
||||
//! the active layout. Each loads the project's layouts store (see
|
||||
//! [`super::store`]), applies the change and persists it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, FileSystem, IdGenerator, ProjectStore};
|
||||
use domain::{DomainEvent, LayoutId, ProjectId};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::store::{default_tree, persist_doc, resolve_doc, LayoutKind, NamedLayout};
|
||||
|
||||
/// Lightweight descriptor of a named layout (no tree), for the layouts tab bar.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LayoutInfo {
|
||||
/// Stable identifier.
|
||||
pub id: LayoutId,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Kind of this layout.
|
||||
pub kind: LayoutKind,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListLayouts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ListLayouts::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListLayoutsInput {
|
||||
/// Project whose layouts to list.
|
||||
pub project_id: ProjectId,
|
||||
}
|
||||
|
||||
/// Output of [`ListLayouts::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListLayoutsOutput {
|
||||
/// All named layouts (id + name), in order.
|
||||
pub layouts: Vec<LayoutInfo>,
|
||||
/// The active layout.
|
||||
pub active_id: LayoutId,
|
||||
}
|
||||
|
||||
/// Lists a project's named layouts and the active one.
|
||||
pub struct ListLayouts {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl ListLayouts {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProjectStore>, fs: Arc<dyn FileSystem>) -> Self {
|
||||
Self { store, fs }
|
||||
}
|
||||
|
||||
/// Lists the layouts.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::NotFound`] for an unknown project, [`AppError::FileSystem`] /
|
||||
/// [`AppError::Store`] on I/O failure.
|
||||
pub async fn execute(&self, input: ListLayoutsInput) -> Result<ListLayoutsOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
Ok(ListLayoutsOutput {
|
||||
layouts: doc
|
||||
.layouts
|
||||
.iter()
|
||||
.map(|l| LayoutInfo {
|
||||
id: l.id,
|
||||
name: l.name.clone(),
|
||||
kind: l.kind,
|
||||
})
|
||||
.collect(),
|
||||
active_id: doc.active_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CreateLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateLayoutInput {
|
||||
/// Owning project.
|
||||
pub project_id: ProjectId,
|
||||
/// Display name for the new layout.
|
||||
pub name: String,
|
||||
/// Kind of the new layout (defaults to Terminal).
|
||||
pub kind: LayoutKind,
|
||||
}
|
||||
|
||||
/// Output of [`CreateLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateLayoutOutput {
|
||||
/// The id minted for the new (now active) layout.
|
||||
pub layout_id: LayoutId,
|
||||
}
|
||||
|
||||
/// Creates a new empty named layout and makes it active.
|
||||
pub struct CreateLayout {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl CreateLayout {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
fs,
|
||||
ids,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the layout.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] for an empty name, [`AppError::NotFound`] for an
|
||||
/// unknown project, I/O errors otherwise.
|
||||
pub async fn execute(&self, input: CreateLayoutInput) -> Result<CreateLayoutOutput, AppError> {
|
||||
let name = input.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::Invalid("layout name is empty".to_owned()));
|
||||
}
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
|
||||
let id = LayoutId::from_uuid(self.ids.new_uuid());
|
||||
doc.layouts.push(NamedLayout {
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
kind: input.kind,
|
||||
tree: default_tree(),
|
||||
});
|
||||
doc.active_id = id; // a freshly-created layout becomes active.
|
||||
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
Ok(CreateLayoutOutput { layout_id: id })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RenameLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`RenameLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RenameLayoutInput {
|
||||
/// Owning project.
|
||||
pub project_id: ProjectId,
|
||||
/// Layout to rename.
|
||||
pub layout_id: LayoutId,
|
||||
/// New display name.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Renames a named layout.
|
||||
pub struct RenameLayout {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl RenameLayout {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self { store, fs, events }
|
||||
}
|
||||
|
||||
/// Renames the layout.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] for an empty name, [`AppError::NotFound`] if the
|
||||
/// project or layout is unknown.
|
||||
pub async fn execute(&self, input: RenameLayoutInput) -> Result<(), AppError> {
|
||||
let name = input.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::Invalid("layout name is empty".to_owned()));
|
||||
}
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
let named = doc
|
||||
.find_mut(input.layout_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("layout {}", input.layout_id)))?;
|
||||
named.name = name.to_owned();
|
||||
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`DeleteLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteLayoutInput {
|
||||
/// Owning project.
|
||||
pub project_id: ProjectId,
|
||||
/// Layout to delete.
|
||||
pub layout_id: LayoutId,
|
||||
}
|
||||
|
||||
/// Output of [`DeleteLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteLayoutOutput {
|
||||
/// The active layout after the deletion.
|
||||
pub active_id: LayoutId,
|
||||
}
|
||||
|
||||
/// Deletes a named layout. The last remaining layout cannot be deleted; if the
|
||||
/// active layout is removed, the first remaining one becomes active.
|
||||
pub struct DeleteLayout {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl DeleteLayout {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self { store, fs, events }
|
||||
}
|
||||
|
||||
/// Deletes the layout.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] if it is the last layout, [`AppError::NotFound`] if
|
||||
/// the project or layout is unknown.
|
||||
pub async fn execute(&self, input: DeleteLayoutInput) -> Result<DeleteLayoutOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
|
||||
if doc.layouts.len() <= 1 {
|
||||
return Err(AppError::Invalid(
|
||||
"cannot delete the last layout".to_owned(),
|
||||
));
|
||||
}
|
||||
if doc.find(input.layout_id).is_none() {
|
||||
return Err(AppError::NotFound(format!("layout {}", input.layout_id)));
|
||||
}
|
||||
doc.layouts.retain(|l| l.id != input.layout_id);
|
||||
if doc.active_id == input.layout_id {
|
||||
doc.active_id = doc.layouts[0].id;
|
||||
}
|
||||
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
Ok(DeleteLayoutOutput {
|
||||
active_id: doc.active_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SetActiveLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`SetActiveLayout::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetActiveLayoutInput {
|
||||
/// Owning project.
|
||||
pub project_id: ProjectId,
|
||||
/// Layout to make active.
|
||||
pub layout_id: LayoutId,
|
||||
}
|
||||
|
||||
/// Switches the active layout of a project.
|
||||
pub struct SetActiveLayout {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl SetActiveLayout {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self { store, fs, events }
|
||||
}
|
||||
|
||||
/// Sets the active layout.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::NotFound`] if the project or layout is unknown.
|
||||
pub async fn execute(&self, input: SetActiveLayoutInput) -> Result<(), AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
if doc.find(input.layout_id).is_none() {
|
||||
return Err(AppError::NotFound(format!("layout {}", input.layout_id)));
|
||||
}
|
||||
doc.active_id = input.layout_id;
|
||||
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project_id,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
36
crates/application/src/layout/mod.rs
Normal file
36
crates/application/src/layout/mod.rs
Normal file
@ -0,0 +1,36 @@
|
||||
//! Layout use cases (ARCHITECTURE §6, §7, L4).
|
||||
//!
|
||||
//! The terminal layout of a tab is a pure [`domain::LayoutTree`] (a recursive
|
||||
//! spreadsheet-like grid). Its mutating operations (`split`/`merge`/`resize`/
|
||||
//! `move`/`set_session`) are **pure functions** that live in the domain; this
|
||||
//! module is the thin application orchestration that:
|
||||
//!
|
||||
//! - resolves the project root (via the [`domain::ports::ProjectStore`]),
|
||||
//! - reads/writes the tree from/to `.ideai/layout.json` (via the
|
||||
//! [`domain::ports::FileSystem`] port — so the layout *travels with the
|
||||
//! project*, including on remote hosts, ARCHITECTURE §7.3, §9.1),
|
||||
//! - publishes [`domain::DomainEvent::LayoutChanged`] on every mutation.
|
||||
//!
|
||||
//! ## Layout ↔ terminal session binding (L3 ↔ L4)
|
||||
//!
|
||||
//! A [`domain::LeafCell`] carries `Option<SessionId>`. When the UI opens a
|
||||
//! terminal in a cell, it calls `OpenTerminal` (L3) — which mints the
|
||||
//! `SessionId` — then records that id in the hosting leaf through the
|
||||
//! `SetSession` layout operation here. The layout is the single source of truth
|
||||
//! for *which cell hosts which session*; the live PTY handle stays in L3's
|
||||
//! `TerminalSessions` registry, keyed by that same `SessionId`.
|
||||
|
||||
mod management;
|
||||
mod store;
|
||||
mod usecases;
|
||||
|
||||
pub use management::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
|
||||
};
|
||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||
pub use usecases::{
|
||||
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,
|
||||
MutateLayoutInput, MutateLayoutOutput,
|
||||
};
|
||||
182
crates/application/src/layout/store.rs
Normal file
182
crates/application/src/layout/store.rs
Normal file
@ -0,0 +1,182 @@
|
||||
//! Persistence of a project's **named terminal layouts** (#4).
|
||||
//!
|
||||
//! A project no longer has a single layout: `.ideai/layouts.json` holds a
|
||||
//! collection of named [`LayoutTree`]s plus which one is active:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "version": 1, "activeId": "…", "layouts": [ { "id": "…", "name": "Default", "tree": { … } } ] }
|
||||
//! ```
|
||||
//!
|
||||
//! Migration: a project that still has the legacy single `.ideai/layout.json`
|
||||
//! (pre-#4) is upgraded transparently — its tree becomes the first "Default"
|
||||
//! layout. A missing/corrupt store self-heals to one default layout (same
|
||||
//! self-healing contract as the original L4 single-file resolver).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::ports::{FileSystem, RemotePath};
|
||||
use domain::{LayoutId, LayoutTree, LeafCell, NodeId, Project};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::project::meta::{from_json_bytes, join_root, to_json_bytes, IDEAI_DIR};
|
||||
|
||||
/// File name of the named-layouts store inside a project's `.ideai/`.
|
||||
pub const LAYOUTS_FILE: &str = "layouts.json";
|
||||
|
||||
/// Legacy single-layout file (pre-#4), migrated on first read if present.
|
||||
const LEGACY_LAYOUT_FILE: &str = "layout.json";
|
||||
|
||||
/// Current schema version of `layouts.json`.
|
||||
const LAYOUTS_VERSION: u32 = 1;
|
||||
|
||||
/// Name given to the layout created by default / migrated from the legacy file.
|
||||
const DEFAULT_LAYOUT_NAME: &str = "Default";
|
||||
|
||||
/// Discriminates the kind of content a named layout holds.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LayoutKind {
|
||||
/// A terminal-grid layout (the original kind).
|
||||
#[default]
|
||||
Terminal,
|
||||
/// A Git-graph visualisation layout.
|
||||
GitGraph,
|
||||
}
|
||||
|
||||
/// One named layout: a stable id, a display name and its terminal grid tree.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NamedLayout {
|
||||
/// Stable identifier.
|
||||
pub id: LayoutId,
|
||||
/// Display name (shown in the layouts tab bar).
|
||||
pub name: String,
|
||||
/// The kind of this layout (terminal grid or git-graph).
|
||||
#[serde(default)]
|
||||
pub kind: LayoutKind,
|
||||
/// The terminal grid for this layout (present but ignored for GitGraph).
|
||||
pub tree: LayoutTree,
|
||||
}
|
||||
|
||||
/// On-disk shape of `.ideai/layouts.json`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LayoutsDoc {
|
||||
/// Schema version.
|
||||
pub version: u32,
|
||||
/// The currently active layout (always one of `layouts`).
|
||||
pub active_id: LayoutId,
|
||||
/// All named layouts (at least one).
|
||||
pub layouts: Vec<NamedLayout>,
|
||||
}
|
||||
|
||||
impl LayoutsDoc {
|
||||
/// The active layout id, or the explicit one when provided.
|
||||
#[must_use]
|
||||
pub fn resolve_id(&self, id: Option<LayoutId>) -> LayoutId {
|
||||
id.unwrap_or(self.active_id)
|
||||
}
|
||||
|
||||
/// Finds a layout by id.
|
||||
#[must_use]
|
||||
pub fn find(&self, id: LayoutId) -> Option<&NamedLayout> {
|
||||
self.layouts.iter().find(|l| l.id == id)
|
||||
}
|
||||
|
||||
/// Mutable access to a layout by id.
|
||||
pub fn find_mut(&mut self, id: LayoutId) -> Option<&mut NamedLayout> {
|
||||
self.layouts.iter_mut().find(|l| l.id == id)
|
||||
}
|
||||
|
||||
/// Structural validity: non-empty, `active_id` present, every tree valid.
|
||||
fn is_valid(&self) -> bool {
|
||||
!self.layouts.is_empty()
|
||||
&& self.find(self.active_id).is_some()
|
||||
&& self.layouts.iter().all(|l| l.tree.validate().is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
/// The default single-cell layout tree (one empty leaf).
|
||||
#[must_use]
|
||||
pub fn default_tree() -> LayoutTree {
|
||||
LayoutTree::single(LeafCell {
|
||||
id: NodeId::new_random(),
|
||||
session: None,
|
||||
agent: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a fresh doc holding one layout (`tree`) made active.
|
||||
fn doc_with(id: LayoutId, name: &str, tree: LayoutTree) -> LayoutsDoc {
|
||||
LayoutsDoc {
|
||||
version: LAYOUTS_VERSION,
|
||||
active_id: id,
|
||||
layouts: vec![NamedLayout {
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
kind: LayoutKind::Terminal,
|
||||
tree,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn layouts_path(project: &Project) -> RemotePath {
|
||||
RemotePath::new(join_root(
|
||||
&project.root,
|
||||
&format!("{IDEAI_DIR}/{LAYOUTS_FILE}"),
|
||||
))
|
||||
}
|
||||
|
||||
fn legacy_path(project: &Project) -> RemotePath {
|
||||
RemotePath::new(join_root(
|
||||
&project.root,
|
||||
&format!("{IDEAI_DIR}/{LEGACY_LAYOUT_FILE}"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Reads the legacy single layout tree if present and valid (for migration).
|
||||
async fn read_legacy_tree(fs: &dyn FileSystem, project: &Project) -> Option<LayoutTree> {
|
||||
let bytes = fs.read(&legacy_path(project)).await.ok()?;
|
||||
let tree = from_json_bytes::<LayoutTree>(&bytes).ok()?;
|
||||
tree.validate().is_ok().then_some(tree)
|
||||
}
|
||||
|
||||
/// Writes the doc to `.ideai/layouts.json` (creating `.ideai/` if needed).
|
||||
pub async fn persist_doc(
|
||||
fs: &dyn FileSystem,
|
||||
project: &Project,
|
||||
doc: &LayoutsDoc,
|
||||
) -> Result<(), AppError> {
|
||||
let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR));
|
||||
fs.create_dir_all(&ideai_dir).await?;
|
||||
fs.write(&layouts_path(project), &to_json_bytes(doc)?).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves the project's layouts doc, with idempotent initialisation /
|
||||
/// migration / self-healing (mirrors the L4 single-file resolver contract).
|
||||
///
|
||||
/// - **Present & valid**: returned as-is (no write).
|
||||
/// - **Absent**: migrated from the legacy `layout.json` if present, else seeded
|
||||
/// with one empty "Default" layout — and **persisted** (write-through, so ids
|
||||
/// are stable from the first load).
|
||||
/// - **Present but corrupt/invalid**: overwritten with a fresh default.
|
||||
pub async fn resolve_doc(fs: &dyn FileSystem, project: &Project) -> Result<LayoutsDoc, AppError> {
|
||||
if let Ok(bytes) = fs.read(&layouts_path(project)).await {
|
||||
if let Ok(doc) = from_json_bytes::<LayoutsDoc>(&bytes) {
|
||||
if doc.is_valid() {
|
||||
return Ok(doc);
|
||||
}
|
||||
}
|
||||
// Present-but-invalid: fall through and re-seed.
|
||||
}
|
||||
|
||||
// First run for this project (or self-heal): migrate the legacy tree if any.
|
||||
let tree = match read_legacy_tree(fs, project).await {
|
||||
Some(tree) => tree,
|
||||
None => default_tree(),
|
||||
};
|
||||
let doc = doc_with(LayoutId::new_random(), DEFAULT_LAYOUT_NAME, tree);
|
||||
persist_doc(fs, project, &doc).await?;
|
||||
Ok(doc)
|
||||
}
|
||||
232
crates/application/src/layout/usecases.rs
Normal file
232
crates/application/src/layout/usecases.rs
Normal file
@ -0,0 +1,232 @@
|
||||
//! [`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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user