Étend le port/usecases window et layout.customPluginLayout pour ouvrir et piloter des fenêtres OS hébergeant un layout plugin, en réutilisant contributes.layouts plutôt qu'un système de fenêtres parallèle. Câble la commande Tauri et l'état app-tauri correspondants. Cargo test -p domain --test window : 5/5 Cargo test -p application --test window_usecases : 7/7 Cargo test -p infrastructure --test window_state_store : 1/1 Cargo test -p app-tauri view_window_tests : 8/8 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
396 lines
12 KiB
Rust
396 lines
12 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::ensure_runtime_plugin_layout_contribution;
|
|
|
|
/// 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> {
|
|
ensure_runtime_plugin_layout_contribution(
|
|
self.packages.as_ref(),
|
|
self.registry.as_ref(),
|
|
self.validator.as_ref(),
|
|
&origin.plugin_id,
|
|
&origin.layout_type,
|
|
)
|
|
.await
|
|
.map(|_| ())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 })
|
|
}
|
|
}
|