Étend le flux de création de layout backend (DTO, usecases, store) et le frontend (sélecteur, adaptateurs, LayoutTabs/LayoutGrid) pour permettre d'ouvrir un layout déclaré par un plugin installé (ex. Android Health), sans passer par le message bloquant "extension backend pas encore livrée". Ticket #141 — QA vert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
417 lines
13 KiB
Rust
417 lines
13 KiB
Rust
//! 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, PluginManifestValidator, PluginPackageStore,
|
|
PluginRegistryStore, ProjectStore,
|
|
};
|
|
use domain::{DomainEvent, LayoutId, ProjectId};
|
|
|
|
use crate::error::AppError;
|
|
|
|
use super::store::{
|
|
default_tree, persist_doc, plugin_layout_tree, resolve_doc, LayoutKind, NamedLayout,
|
|
};
|
|
use crate::plugin::runtime_plugin_from_entry;
|
|
|
|
/// 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.clone(),
|
|
})
|
|
.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>,
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
}
|
|
|
|
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>,
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
) -> Self {
|
|
Self {
|
|
store,
|
|
fs,
|
|
ids,
|
|
events,
|
|
packages,
|
|
registry,
|
|
validator,
|
|
}
|
|
}
|
|
|
|
/// 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?;
|
|
if let LayoutKind::Plugin { plugin_origin, .. } = &input.kind {
|
|
self.ensure_runtime_layout_is_active(plugin_origin).await?;
|
|
}
|
|
|
|
let id = LayoutId::from_uuid(self.ids.new_uuid());
|
|
let tree = match &input.kind {
|
|
LayoutKind::Terminal | LayoutKind::GitGraph => default_tree(),
|
|
LayoutKind::Plugin {
|
|
plugin_origin,
|
|
state,
|
|
} => plugin_layout_tree(plugin_origin, state.clone()),
|
|
};
|
|
doc.layouts.push(NamedLayout {
|
|
id,
|
|
name: name.to_owned(),
|
|
kind: input.kind,
|
|
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 })
|
|
}
|
|
|
|
async fn ensure_runtime_layout_is_active(
|
|
&self,
|
|
origin: &super::store::PluginLayoutOrigin,
|
|
) -> Result<(), AppError> {
|
|
let registry = self
|
|
.registry
|
|
.load_registry()
|
|
.await
|
|
.map_err(|e| AppError::Store(e.to_string()))?;
|
|
for entry in registry.plugins {
|
|
if entry.id != origin.plugin_id || !entry.lifecycle_state.is_runtime_active() {
|
|
continue;
|
|
}
|
|
let runtime =
|
|
runtime_plugin_from_entry(self.packages.as_ref(), self.validator.as_ref(), entry)
|
|
.await?;
|
|
if runtime
|
|
.contributes
|
|
.layouts
|
|
.iter()
|
|
.any(|layout| layout.layout_type == origin.layout_type)
|
|
{
|
|
return Ok(());
|
|
}
|
|
return Err(AppError::Invalid(format!(
|
|
"plugin `{}` does not contribute layout `{}`",
|
|
origin.plugin_id.as_str(),
|
|
origin.layout_type.as_str()
|
|
)));
|
|
}
|
|
Err(AppError::Invalid(format!(
|
|
"plugin `{}` is not active at runtime",
|
|
origin.plugin_id.as_str()
|
|
)))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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,
|
|
}
|
|
|
|
/// Output of [`SetActiveLayout::execute`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SetActiveLayoutOutput {
|
|
/// The id of the layout that was **actually** made active. Equals the
|
|
/// requested id when it exists; otherwise the unchanged current active id
|
|
/// (self-healing fallback). Authoritative for the frontend (invariant I4).
|
|
pub active_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.
|
|
///
|
|
/// A stale requested id (e.g. an `activeId` left over after git overwrote
|
|
/// `layouts.json`) must **not** freeze the workspace: instead of hard-
|
|
/// erroring, it degrades silently to the current active layout (invariant
|
|
/// I3). The returned [`SetActiveLayoutOutput::active_id`] is the id that was
|
|
/// actually activated and is authoritative for the frontend (invariant I4).
|
|
///
|
|
/// # Errors
|
|
/// - [`AppError::FileSystem`] on persistence failure,
|
|
/// - [`AppError::Store`] on registry I/O failure.
|
|
pub async fn execute(
|
|
&self,
|
|
input: SetActiveLayoutInput,
|
|
) -> Result<SetActiveLayoutOutput, AppError> {
|
|
let project = self.store.load_project(input.project_id).await?;
|
|
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
|
// Self-heal: keep the requested id when valid, else fall back to the
|
|
// (always-valid, I2) current active id rather than erroring.
|
|
let active_id = doc.resolve_existing_id(Some(input.layout_id));
|
|
doc.active_id = active_id;
|
|
|
|
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
|
self.events.publish(DomainEvent::LayoutChanged {
|
|
project_id: input.project_id,
|
|
});
|
|
Ok(SetActiveLayoutOutput { active_id })
|
|
}
|
|
}
|