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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user