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:
@ -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()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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::{
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user