feat(layout): supporte la création de layouts plugins (customPluginLayout)

É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>
This commit is contained in:
2026-08-03 18:34:21 +02:00
parent 168f93df78
commit 45617992ef
29 changed files with 907 additions and 99 deletions

View File

@ -4,12 +4,18 @@
use std::sync::Arc;
use domain::ports::{EventBus, FileSystem, IdGenerator, ProjectStore};
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, resolve_doc, LayoutKind, NamedLayout};
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)]
@ -70,7 +76,7 @@ impl ListLayouts {
.map(|l| LayoutInfo {
id: l.id,
name: l.name.clone(),
kind: l.kind,
kind: l.kind.clone(),
})
.collect(),
active_id: doc.active_id,
@ -106,6 +112,9 @@ pub struct CreateLayout {
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 {
@ -116,12 +125,18 @@ impl CreateLayout {
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,
}
}
@ -137,13 +152,23 @@ impl CreateLayout {
}
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: default_tree(),
tree,
});
doc.active_id = id; // a freshly-created layout becomes active.
@ -153,6 +178,42 @@ impl CreateLayout {
});
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()
)))
}
}
// ---------------------------------------------------------------------------

View File

@ -35,6 +35,7 @@ pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOut
pub use snapshot::{
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
};
pub use store::PluginLayoutOrigin;
pub(crate) use store::{persist_doc, resolve_doc};
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
pub use usecases::{

View File

@ -15,7 +15,10 @@
use serde::{Deserialize, Serialize};
use domain::ports::{FileSystem, RemotePath};
use domain::{LayoutId, LayoutTree, LeafCell, NodeId, Project};
use domain::{
CustomPluginLayoutCell, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, PluginId,
PluginLayoutType, Project,
};
use crate::error::AppError;
use crate::project::meta::{from_json_bytes, join_root, to_json_bytes, IDEAI_DIR};
@ -33,7 +36,7 @@ const LAYOUTS_VERSION: u32 = 1;
const DEFAULT_LAYOUT_NAME: &str = "Default";
/// Discriminates the kind of content a named layout holds.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LayoutKind {
/// A terminal-grid layout (the original kind).
@ -41,6 +44,23 @@ pub enum LayoutKind {
Terminal,
/// A Git-graph visualisation layout.
GitGraph,
/// A plugin-provided custom layout.
Plugin {
/// Provider plugin id and layout type.
plugin_origin: PluginLayoutOrigin,
/// Opaque initial state owned by the plugin.
state: serde_json::Value,
},
}
/// Stable origin of a plugin-provided custom layout.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginLayoutOrigin {
/// Provider plugin id.
pub plugin_id: PluginId,
/// Layout type declared by the provider plugin.
pub layout_type: PluginLayoutType,
}
/// One named layout: a stable id, a display name and its terminal grid tree.
@ -118,6 +138,17 @@ pub fn default_tree() -> LayoutTree {
LayoutTree::single(LeafCell::new(NodeId::new_random()))
}
/// Builds a single custom plugin layout tree.
#[must_use]
pub fn plugin_layout_tree(origin: &PluginLayoutOrigin, state: serde_json::Value) -> LayoutTree {
LayoutTree::new(LayoutNode::CustomPluginLayout(CustomPluginLayoutCell {
id: NodeId::new_random(),
plugin_id: origin.plugin_id.clone(),
layout_type: origin.layout_type.clone(),
state,
}))
}
/// Builds a fresh doc holding one layout (`tree`) made active.
fn doc_with(id: LayoutId, name: &str, tree: LayoutTree) -> LayoutsDoc {
LayoutsDoc {

View File

@ -98,6 +98,13 @@ pub enum LayoutOperation {
/// Conversation id to record, or `None` to clear.
conversation_id: Option<String>,
},
/// Persist opaque state for a plugin-provided custom layout leaf.
SetPluginLayoutState {
/// The custom plugin layout node.
target: NodeId,
/// Opaque plugin-owned state.
state: serde_json::Value,
},
}
impl LayoutOperation {
@ -123,6 +130,9 @@ impl LayoutOperation {
target,
conversation_id,
} => tree.set_cell_conversation(*target, conversation_id.clone()),
Self::SetPluginLayoutState { target, state } => {
tree.set_plugin_layout_state(*target, state.clone())
}
};
result.map_err(map_layout_err)
}

View File

@ -114,10 +114,10 @@ pub use layout::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, ReconcileLayouts,
ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout, RenameLayoutInput,
SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput, SnapshotRunningAgents,
SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, PluginLayoutOrigin,
ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout,
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput,
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
};
pub use mcp_tool_permissions::{
McpToolPermissionCatalogue, ReadMcpToolPermissions, ReadMcpToolPermissionsInput,

View File

@ -2764,7 +2764,7 @@ impl ListPluginRuntimeContributions {
}
}
async fn runtime_plugin_from_entry(
pub(crate) async fn runtime_plugin_from_entry(
packages: &dyn PluginPackageStore,
validator: &dyn PluginManifestValidator,
entry: PluginRegistryEntry,