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:
2026-06-06 01:27:01 +02:00
parent 55b3bee2c8
commit 307ae71857
273 changed files with 48740 additions and 0 deletions

692
crates/domain/src/layout.rs Normal file
View File

@ -0,0 +1,692 @@
//! 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,
}
/// 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>,
}
/// 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 {
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: None,
agent: leaf.agent,
});
}
if leaf.id == to {
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: Some(session),
agent: leaf.agent,
});
}
}
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;
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session,
agent: leaf.agent,
});
}
}
node.clone()
});
if !found {
return Err(LayoutError::NodeNotFound(target));
}
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;
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: leaf.session,
agent,
});
}
}
node.clone()
});
if !found {
return Err(LayoutError::NodeNotFound(target));
}
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
/// 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 })
}
}