From 45617992ef87f1335d3a42a9ae8ac87397448e09 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 3 Aug 2026 18:34:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(layout):=20supporte=20la=20cr=C3=A9ation?= =?UTF-8?q?=20de=20layouts=20plugins=20(customPluginLayout)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit É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 --- .ideai/tickets/141/carnet.md | 6 + .ideai/tickets/141/issue.md | 38 ++ .ideai/tickets/counter.json | 2 +- .ideai/tickets/index.json | 14 + crates/app-tauri/tests/dto_layouts.rs | 81 ++++ crates/application/src/layout/management.rs | 69 +++- crates/application/src/layout/mod.rs | 1 + crates/application/src/layout/store.rs | 35 +- crates/application/src/layout/usecases.rs | 10 + crates/application/src/lib.rs | 8 +- crates/application/src/plugin/mod.rs | 2 +- crates/application/tests/layout_usecases.rs | 347 ++++++++++++++++-- crates/backend/src/dto.rs | 67 +++- crates/backend/src/lib.rs | 3 + crates/domain/src/layout.rs | 32 ++ .../adapters/http/requestResponseGateways.ts | 12 +- frontend/src/adapters/layout.ts | 17 +- frontend/src/adapters/mock/index.ts | 32 +- frontend/src/adapters/mock/mock.test.ts | 1 + frontend/src/domain/index.ts | 12 +- frontend/src/features/layout/LayoutGrid.tsx | 2 +- frontend/src/features/layout/LayoutTabs.tsx | 32 +- frontend/src/features/layout/layout.test.ts | 14 + frontend/src/features/layout/layout.ts | 25 +- .../layout/layoutTabsGitGraph.test.tsx | 94 ++++- frontend/src/features/layout/useLayout.ts | 15 +- frontend/src/features/layout/useLayouts.ts | 16 +- .../plugins/PluginLayoutSelectorSection.tsx | 11 +- frontend/src/ports/index.ts | 8 +- 29 files changed, 907 insertions(+), 99 deletions(-) create mode 100644 .ideai/tickets/141/carnet.md create mode 100644 .ideai/tickets/141/issue.md diff --git a/.ideai/tickets/141/carnet.md b/.ideai/tickets/141/carnet.md new file mode 100644 index 0000000..dd292fd --- /dev/null +++ b/.ideai/tickets/141/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#141" +version: 1 +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedAt: 1785766766212 +--- diff --git a/.ideai/tickets/141/issue.md b/.ideai/tickets/141/issue.md new file mode 100644 index 0000000..6ff28e3 --- /dev/null +++ b/.ideai/tickets/141/issue.md @@ -0,0 +1,38 @@ +--- +id: "5ed1fc9b-1eef-4236-be89-e5d351ece549" +number: 141 +title: "Supporter l’ouverture des layouts plugins comme Android Health" +status: "open" +priority: "high" +sprint: null +links: [] +agentRefs: [] +attachments: [] +createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +createdAt: 1785766766212 +updatedAt: 1785766766212 +version: 1 +--- +Le plugin Android `dev.idea.android-plugin` déclare un layout `idea-android.health` et l’enregistre correctement à l’activation, mais l’ouverture depuis l’UI échoue avec le message : « la création de layouts plugins nécessite une extension backend pas encore livrée ». + +Constat confirmé : +- Ce n’est pas un bug du plugin Android sur la déclaration/enregistrement du layout. +- Ce n’est pas un problème du SDK sur le contrat de layout. +- Le blocage est côté IdeA host : le frontend expose bien les contributions de layout plugin, mais la création effective d’une cellule/layout plugin depuis le sélecteur n’est pas supportée de bout en bout. + +Preuves repo : +- `frontend/src/features/layout/LayoutTabs.tsx` affiche explicitement ce message et mentionne l’absence de l’extension backend `create_layout` pour les plugin layouts. +- `frontend/src/features/plugins/PluginLayoutSelectorSection.tsx` documente aussi que la partie frontend est prête mais que le flux réel dépend d’une extension backend non livrée. +- Le rendu d’un `customPluginLayout` déjà présent semble supporté (`PluginLayoutCellView`, `CustomPluginLayoutCell`) ; le manque porte sur la création de cette cellule depuis l’UI. + +Attendu : +- Permettre à l’utilisateur de créer/ouvrir un layout plugin déclaré dans un plugin installé et chargé, notamment `Android Health` du plugin Android. +- Étendre le flux de création de layout côté backend + DTO/layout kind si nécessaire pour supporter `customPluginLayout`/`pluginId`/`layoutType`/`state`. +- Retirer le message bloquant une fois le support livré. + +Critères d’acceptation : +1. Depuis le sélecteur de layouts, choisir `Android Health` crée une cellule/layout plugin valide dans l’arbre de layout. +2. Le layout `idea-android.health` du plugin `dev.idea.android-plugin` se rend correctement via le runtime plugin chargé. +3. L’état opaque du layout peut être persistant comme prévu par le contrat `customPluginLayout`. +4. Aucun message « extension backend pas encore livrée » n’apparaît plus pour les plugin layouts supportés. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index ef4f3b5..5607b32 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 141 + "nextNumber": 142 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 512da8f..a8787fa 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -1822,6 +1822,20 @@ "kind": "user" }, "updatedAt": 1785760572251 + }, + { + "issueRef": "#141", + "path": "141", + "title": "Supporter l’ouverture des layouts plugins comme Android Health", + "status": "open", + "priority": "high", + "sprint": null, + "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + }, + "updatedAt": 1785766766212 } ] } \ No newline at end of file diff --git a/crates/app-tauri/tests/dto_layouts.rs b/crates/app-tauri/tests/dto_layouts.rs index 2d55020..1d118f2 100644 --- a/crates/app-tauri/tests/dto_layouts.rs +++ b/crates/app-tauri/tests/dto_layouts.rs @@ -10,8 +10,10 @@ use app_tauri_lib::dto::{ }; use application::{ CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput, + PluginLayoutOrigin, }; use domain::ids::{AgentId, LayoutId, NodeId}; +use domain::{PluginId, PluginLayoutType}; use serde_json::json; use uuid::Uuid; @@ -55,6 +57,24 @@ fn layout_info_dto_git_graph_kind() { assert_eq!(v["kind"], "gitGraph"); } +#[test] +fn layout_info_dto_plugin_kind() { + let info = LayoutInfo { + id: lid(3), + name: "Android Health".to_owned(), + kind: LayoutKind::Plugin { + plugin_origin: PluginLayoutOrigin { + plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(), + layout_type: PluginLayoutType::new("idea-android.health").unwrap(), + }, + state: json!({}), + }, + }; + let dto = LayoutInfoDto::from(info); + let v = serde_json::to_value(&dto).unwrap(); + assert_eq!(v["kind"], "plugin"); +} + // --------------------------------------------------------------------------- // ListLayoutsDto // --------------------------------------------------------------------------- @@ -138,6 +158,43 @@ fn create_layout_request_with_git_graph_kind() { assert_eq!(kind, application::LayoutKind::GitGraph); } +#[test] +fn create_layout_request_with_plugin_kind() { + let project_id = Uuid::from_u128(13).to_string(); + let raw = json!({ + "projectId": project_id, + "name": "Android Health", + "kind": "plugin", + "pluginOrigin": { + "pluginId": "dev.idea.android-plugin", + "layoutType": "idea-android.health" + }, + "state": { "deviceId": "pixel-8" } + }); + let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap(); + let kind = dto.parse_kind().unwrap(); + match kind { + application::LayoutKind::Plugin { + plugin_origin, + state, + } => { + assert_eq!(plugin_origin.plugin_id.as_str(), "dev.idea.android-plugin"); + assert_eq!(plugin_origin.layout_type.as_str(), "idea-android.health"); + assert_eq!(state["deviceId"], "pixel-8"); + } + _ => panic!("expected plugin layout kind"), + } +} + +#[test] +fn create_layout_request_plugin_kind_requires_origin() { + let project_id = Uuid::from_u128(14).to_string(); + let raw = json!({ "projectId": project_id, "name": "Android Health", "kind": "plugin" }); + let dto: CreateLayoutRequestDto = serde_json::from_value(raw).unwrap(); + let err = dto.parse_kind().unwrap_err(); + assert_eq!(err.code, "INVALID"); +} + #[test] fn create_layout_request_unknown_kind_is_invalid() { let project_id = Uuid::from_u128(12).to_string(); @@ -260,6 +317,30 @@ fn set_cell_agent_op_deserialises_with_absent_agent_defaults_to_none() { } } +// --------------------------------------------------------------------------- +// setPluginLayoutState operation deserialisation (#141) +// --------------------------------------------------------------------------- + +#[test] +fn set_plugin_layout_state_op_deserialises_opaque_state() { + let target = nid(3); + let raw = json!({ + "type": "setPluginLayoutState", + "target": target.to_string(), + "state": { "deviceId": "pixel-8", "healthy": true } + }); + let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap(); + let op = dto.into_operation().unwrap(); + match op { + application::LayoutOperation::SetPluginLayoutState { target: t, state } => { + assert_eq!(t, target); + assert_eq!(state["deviceId"], "pixel-8"); + assert_eq!(state["healthy"], true); + } + _ => panic!("expected SetPluginLayoutState"), + } +} + // --------------------------------------------------------------------------- // setCellConversation operation deserialisation (T4b) // --------------------------------------------------------------------------- diff --git a/crates/application/src/layout/management.rs b/crates/application/src/layout/management.rs index d5bc67c..080cd0e 100644 --- a/crates/application/src/layout/management.rs +++ b/crates/application/src/layout/management.rs @@ -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, ids: Arc, events: Arc, + packages: Arc, + registry: Arc, + validator: Arc, } impl CreateLayout { @@ -116,12 +125,18 @@ impl CreateLayout { fs: Arc, ids: Arc, events: Arc, + packages: Arc, + registry: Arc, + validator: Arc, ) -> 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() + ))) + } } // --------------------------------------------------------------------------- diff --git a/crates/application/src/layout/mod.rs b/crates/application/src/layout/mod.rs index 6908d42..4e2fa33 100644 --- a/crates/application/src/layout/mod.rs +++ b/crates/application/src/layout/mod.rs @@ -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::{ diff --git a/crates/application/src/layout/store.rs b/crates/application/src/layout/store.rs index d203b34..5d5f10e 100644 --- a/crates/application/src/layout/store.rs +++ b/crates/application/src/layout/store.rs @@ -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 { diff --git a/crates/application/src/layout/usecases.rs b/crates/application/src/layout/usecases.rs index 93db87f..2f86768 100644 --- a/crates/application/src/layout/usecases.rs +++ b/crates/application/src/layout/usecases.rs @@ -98,6 +98,13 @@ pub enum LayoutOperation { /// Conversation id to record, or `None` to clear. conversation_id: Option, }, + /// 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) } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 615e36f..0d1e5f2 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index b685b74..2de5965 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -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, diff --git a/crates/application/tests/layout_usecases.rs b/crates/application/tests/layout_usecases.rs index a5996f6..5c4f0cc 100644 --- a/crates/application/tests/layout_usecases.rs +++ b/crates/application/tests/layout_usecases.rs @@ -13,19 +13,23 @@ use async_trait::async_trait; use domain::events::DomainEvent; use domain::layout::Workspace; use domain::ports::{ - DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, ProjectStore, RemotePath, - StoreError, + DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, PluginManifestBytes, + PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, ProjectStore, + RemotePath, StoreError, }; use domain::{ - AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId, - ProjectPath, RemoteRef, SessionId, + AgentId, ContentHash, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, LocalPath, NodeId, + PluginBundleUrl, PluginId, PluginInstallSource, PluginLifecycleState, PluginPackageRef, + PluginRegistry, PluginRegistryEntry, Project, ProjectId, ProjectPath, RelativePath, RemoteRef, + RemovalOutcome, SessionId, StagedPluginPackage, }; use uuid::Uuid; +use application::plugin::JsonPluginManifestValidator; use application::{ CreateLayout, CreateLayoutInput, DeleteLayout, DeleteLayoutInput, LayoutKind, LayoutOperation, ListLayouts, ListLayoutsInput, LoadLayout, LoadLayoutInput, MutateLayout, MutateLayoutInput, - RenameLayout, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, + PluginLayoutOrigin, RenameLayout, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, }; // --------------------------------------------------------------------------- @@ -158,6 +162,152 @@ impl IdGenerator for SeqIds { } } +fn plugin_manifest() -> Vec { + br#"{ + "ideaPluginManifestVersion": 1, + "id": "dev.idea.android-plugin", + "displayName": "Android", + "version": "1.0.0", + "engines": {"idea": ">=0.1.0 <1.0.0"}, + "main": "dist/index.js", + "trustLevel": "full", + "capabilities": ["ui"], + "contributes": { + "layouts": [{"type":"idea-android.health","label":"Android Health","component":"AndroidHealth"}] + } + }"#.to_vec() +} + +#[derive(Clone)] +struct FakePluginPackages { + manifest: Arc>>, +} + +impl FakePluginPackages { + fn new(manifest: Vec) -> Self { + Self { + manifest: Arc::new(Mutex::new(manifest)), + } + } +} + +#[async_trait] +impl PluginPackageStore for FakePluginPackages { + async fn list_installed(&self) -> Result, PluginStoreError> { + Ok(Vec::new()) + } + + async fn read_manifest( + &self, + _package: &PluginPackageRef, + ) -> Result { + Ok(PluginManifestBytes { + bytes: self.manifest.lock().unwrap().clone(), + }) + } + + async fn install_from_archive( + &self, + _archive: &LocalPath, + ) -> Result { + Err(PluginStoreError::Invalid("unused".to_owned())) + } + + async fn install_from_directory( + &self, + _dir: &LocalPath, + ) -> Result { + Err(PluginStoreError::Invalid("unused".to_owned())) + } + + async fn commit_install( + &self, + staged: StagedPluginPackage, + plugin_id: &PluginId, + ) -> Result { + Ok(PluginPackageRef { + plugin_id: Some(plugin_id.clone()), + root: staged.root, + }) + } + + async fn remove_package( + &self, + _plugin_id: &PluginId, + ) -> Result { + Ok(RemovalOutcome::NotFound) + } + + fn bundle_url( + &self, + plugin_id: &PluginId, + entry: &RelativePath, + hash: &ContentHash, + ) -> Result { + Ok(PluginBundleUrl::new(format!( + "idea-plugin://{}/current/{}/{}", + plugin_id.as_str(), + hash.as_str(), + entry.as_str() + ))) + } +} + +#[derive(Clone)] +struct FakePluginRegistry { + registry: Arc>, +} + +impl FakePluginRegistry { + fn with_state(lifecycle_state: PluginLifecycleState) -> Self { + Self { + registry: Arc::new(Mutex::new(PluginRegistry { + version: 1, + plugins: vec![PluginRegistryEntry { + id: PluginId::new("dev.idea.android-plugin").unwrap(), + lifecycle_state, + source: PluginInstallSource::Directory { + path_label: "/plugin".to_owned(), + }, + content_hash: ContentHash::new("abc123").unwrap(), + restart_required: false, + error: None, + }], + })), + } + } +} + +#[async_trait] +impl PluginRegistryStore for FakePluginRegistry { + async fn load_registry(&self) -> Result { + Ok(self.registry.lock().unwrap().clone()) + } + + async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError> { + *self.registry.lock().unwrap() = registry.clone(); + Ok(()) + } +} + +fn create_layout( + store: FakeStore, + fs: FakeFs, + ids: SeqIds, + bus: SpyBus, + registry: FakePluginRegistry, +) -> CreateLayout { + CreateLayout::new( + Arc::new(store), + Arc::new(fs), + Arc::new(ids), + Arc::new(bus), + Arc::new(FakePluginPackages::new(plugin_manifest())), + Arc::new(registry), + Arc::new(JsonPluginManifestValidator::new("0.3.0")), + ) +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -803,11 +953,12 @@ async fn mgmt_env(project_id: ProjectId) -> (FakeStore, FakeFs, SpyBus) { #[tokio::test] async fn create_layout_appends_and_activates_it() { let (store, fs, bus) = mgmt_env(pid(30)).await; - let create = CreateLayout::new( - Arc::new(store.clone()), - Arc::new(fs.clone()), - Arc::new(SeqIds::new(0xABC)), - Arc::new(bus.clone()), + let create = create_layout( + store.clone(), + fs.clone(), + SeqIds::new(0xABC), + bus.clone(), + FakePluginRegistry::with_state(PluginLifecycleState::Enabled), ); let out = create .execute(CreateLayoutInput { @@ -832,11 +983,12 @@ async fn create_layout_appends_and_activates_it() { #[tokio::test] async fn create_layout_rejects_empty_name() { let (store, fs, bus) = mgmt_env(pid(31)).await; - let err = CreateLayout::new( - Arc::new(store), - Arc::new(fs), - Arc::new(SeqIds::new(1)), - Arc::new(bus), + let err = create_layout( + store, + fs, + SeqIds::new(1), + bus, + FakePluginRegistry::with_state(PluginLifecycleState::Enabled), ) .execute(CreateLayoutInput { project_id: pid(31), @@ -848,6 +1000,149 @@ async fn create_layout_rejects_empty_name() { assert_eq!(err.code(), "INVALID", "got {err:?}"); } +#[tokio::test] +async fn create_layout_plugin_validates_active_runtime_contribution_and_persists_root() { + let (store, fs, bus) = mgmt_env(pid(131)).await; + let create = create_layout( + store.clone(), + fs.clone(), + SeqIds::new(0x141), + bus, + FakePluginRegistry::with_state(PluginLifecycleState::Enabled), + ); + let out = create + .execute(CreateLayoutInput { + project_id: pid(131), + name: "Android Health".to_owned(), + kind: LayoutKind::Plugin { + plugin_origin: PluginLayoutOrigin { + plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(), + layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(), + }, + state: serde_json::json!({ "deviceId": "pixel-8" }), + }, + }) + .await + .unwrap(); + + let list = ListLayouts::new(Arc::new(store), Arc::new(fs.clone())) + .execute(ListLayoutsInput { + project_id: pid(131), + }) + .await + .unwrap(); + assert_eq!(list.active_id, out.layout_id); + assert_eq!( + list.layouts[1].kind, + LayoutKind::Plugin { + plugin_origin: PluginLayoutOrigin { + plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(), + layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(), + }, + state: serde_json::json!({ "deviceId": "pixel-8" }), + } + ); + + let tree_json = active_tree_json(&fs); + assert_eq!(tree_json["root"]["type"], "customPluginLayout"); + assert_eq!( + tree_json["root"]["node"]["pluginId"], + "dev.idea.android-plugin" + ); + assert_eq!( + tree_json["root"]["node"]["layoutType"], + "idea-android.health" + ); + assert_eq!(tree_json["root"]["node"]["state"]["deviceId"], "pixel-8"); +} + +#[tokio::test] +async fn create_layout_plugin_rejects_inactive_runtime_contribution() { + let (store, fs, bus) = mgmt_env(pid(132)).await; + let err = create_layout( + store, + fs, + SeqIds::new(0x142), + bus, + FakePluginRegistry::with_state(PluginLifecycleState::Disabled), + ) + .execute(CreateLayoutInput { + project_id: pid(132), + name: "Android Health".to_owned(), + kind: LayoutKind::Plugin { + plugin_origin: PluginLayoutOrigin { + plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(), + layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(), + }, + state: serde_json::json!({}), + }, + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "INVALID", "got {err:?}"); +} + +#[tokio::test] +async fn mutate_set_plugin_layout_state_persists_opaque_state() { + let (store, fs, bus) = mgmt_env(pid(133)).await; + let create = create_layout( + store.clone(), + fs.clone(), + SeqIds::new(0x143), + bus.clone(), + FakePluginRegistry::with_state(PluginLifecycleState::Enabled), + ); + create + .execute(CreateLayoutInput { + project_id: pid(133), + name: "Android Health".to_owned(), + kind: LayoutKind::Plugin { + plugin_origin: PluginLayoutOrigin { + plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(), + layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(), + }, + state: serde_json::json!({ "deviceId": "pixel-8" }), + }, + }) + .await + .unwrap(); + + let loaded = LoadLayout::new(Arc::new(store.clone()), Arc::new(fs.clone())) + .execute(LoadLayoutInput { + project_id: pid(133), + layout_id: None, + }) + .await + .unwrap(); + let target = match loaded.layout.root { + LayoutNode::CustomPluginLayout(cell) => cell.id, + other => panic!("expected custom plugin layout root, got {other:?}"), + }; + + let out = MutateLayout::new(Arc::new(store), Arc::new(fs.clone()), Arc::new(bus)) + .execute(MutateLayoutInput { + project_id: pid(133), + layout_id: None, + operation: LayoutOperation::SetPluginLayoutState { + target, + state: serde_json::json!({ "deviceId": "pixel-8", "healthy": true }), + }, + }) + .await + .unwrap(); + + match out.layout.root { + LayoutNode::CustomPluginLayout(cell) => { + assert_eq!(cell.state["healthy"], true); + } + other => panic!("expected custom plugin layout root, got {other:?}"), + } + assert_eq!( + active_tree_json(&fs)["root"]["node"]["state"]["healthy"], + true + ); +} + #[tokio::test] async fn rename_layout_changes_the_name() { let (store, fs, bus) = mgmt_env(pid(32)).await; @@ -886,11 +1181,12 @@ async fn delete_layout_rejects_the_last_one() { async fn delete_active_layout_reassigns_active() { let (store, fs, bus) = mgmt_env(pid(34)).await; // Add a second layout (becomes active), then delete it → active falls back. - let created = CreateLayout::new( - Arc::new(store.clone()), - Arc::new(fs.clone()), - Arc::new(SeqIds::new(0xD)), - Arc::new(bus.clone()), + let created = create_layout( + store.clone(), + fs.clone(), + SeqIds::new(0xD), + bus.clone(), + FakePluginRegistry::with_state(PluginLifecycleState::Enabled), ) .execute(CreateLayoutInput { project_id: pid(34), @@ -925,11 +1221,12 @@ async fn delete_active_layout_reassigns_active() { #[tokio::test] async fn set_active_layout_switches_and_load_follows() { let (store, fs, bus) = mgmt_env(pid(35)).await; - let created = CreateLayout::new( - Arc::new(store.clone()), - Arc::new(fs.clone()), - Arc::new(SeqIds::new(0xE)), - Arc::new(bus.clone()), + let created = create_layout( + store.clone(), + fs.clone(), + SeqIds::new(0xE), + bus.clone(), + FakePluginRegistry::with_state(PluginLifecycleState::Enabled), ) .execute(CreateLayoutInput { project_id: pid(35), diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 5cab71d..84265cb 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1118,9 +1118,9 @@ pub fn parse_session_id(raw: &str) -> Result { use application::{ CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput, - LoadLayoutOutput, MutateLayoutOutput, SetActiveLayoutOutput, + LoadLayoutOutput, MutateLayoutOutput, PluginLayoutOrigin, SetActiveLayoutOutput, }; -use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId}; +use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId, PluginId, PluginLayoutType}; /// Response DTO carrying a layout tree. /// @@ -1214,6 +1214,15 @@ pub enum LayoutOperationDto { #[serde(default)] conversation_id: Option, }, + /// Persist opaque plugin layout state. + #[serde(rename_all = "camelCase")] + SetPluginLayoutState { + /// Custom plugin layout node. + target: String, + /// Opaque plugin-owned state. + #[serde(default)] + state: serde_json::Value, + }, } impl LayoutOperationDto { @@ -1264,6 +1273,10 @@ impl LayoutOperationDto { target: parse_node_id(&target)?, conversation_id, }, + Self::SetPluginLayoutState { target, state } => LayoutOperation::SetPluginLayoutState { + target: parse_node_id(&target)?, + state, + }, }) } } @@ -1306,7 +1319,7 @@ pub struct LayoutInfoDto { pub id: String, /// Display name. pub name: String, - /// Layout kind: `"terminal"` or `"gitGraph"`. + /// Layout kind: `"terminal"`, `"gitGraph"` or `"plugin"`. pub kind: String, } @@ -1315,6 +1328,7 @@ impl From for LayoutInfoDto { let kind = match info.kind { LayoutKind::Terminal => "terminal", LayoutKind::GitGraph => "gitGraph", + LayoutKind::Plugin { .. } => "plugin", } .to_owned(); Self { @@ -1384,9 +1398,15 @@ pub struct CreateLayoutRequestDto { pub project_id: String, /// Display name for the new layout. pub name: String, - /// Optional layout kind: `"terminal"` (default) or `"gitGraph"`. + /// Optional layout kind: `"terminal"` (default), `"gitGraph"` or `"plugin"`. #[serde(default)] pub kind: Option, + /// Plugin origin for `"plugin"` layouts. + #[serde(default)] + pub plugin_origin: Option, + /// Opaque initial state for `"plugin"` layouts. + #[serde(default)] + pub state: serde_json::Value, } impl CreateLayoutRequestDto { @@ -1398,6 +1418,16 @@ impl CreateLayoutRequestDto { match self.kind.as_deref() { None | Some("terminal") => Ok(LayoutKind::Terminal), Some("gitGraph") => Ok(LayoutKind::GitGraph), + Some("plugin") | Some("customPluginLayout") => { + let origin = self.plugin_origin.clone().ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "pluginOrigin is required for plugin layouts".to_owned(), + })?; + Ok(LayoutKind::Plugin { + plugin_origin: origin.try_into()?, + state: self.state.clone(), + }) + } Some(other) => Err(ErrorDto { code: "INVALID".to_owned(), message: format!("unknown layout kind: {other}"), @@ -1406,6 +1436,35 @@ impl CreateLayoutRequestDto { } } +/// Stable provider identity for a plugin layout creation request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginLayoutOriginDto { + /// Provider plugin id. + pub plugin_id: String, + /// Layout type declared by the provider. + pub layout_type: String, +} + +impl TryFrom for PluginLayoutOrigin { + type Error = ErrorDto; + + fn try_from(value: PluginLayoutOriginDto) -> Result { + let plugin_id = PluginId::new(value.plugin_id).map_err(|e| ErrorDto { + code: "INVALID".to_owned(), + message: e.to_string(), + })?; + let layout_type = PluginLayoutType::new(value.layout_type).map_err(|e| ErrorDto { + code: "INVALID".to_owned(), + message: e.to_string(), + })?; + Ok(Self { + plugin_id, + layout_type, + }) + } +} + /// Request DTO for `rename_layout`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 415b2b1..30e51e4 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -1536,6 +1536,9 @@ impl BackendCore { Arc::clone(&fs_port), Arc::clone(&ids) as Arc, Arc::clone(&events_port), + Arc::clone(&plugin_package_store), + Arc::clone(&plugin_registry_store), + Arc::clone(&plugin_manifest_validator), )); let rename_layout = Arc::new(RenameLayout::new( Arc::clone(&store_port), diff --git a/crates/domain/src/layout.rs b/crates/domain/src/layout.rs index 236acc2..9e5b9f5 100644 --- a/crates/domain/src/layout.rs +++ b/crates/domain/src/layout.rs @@ -603,6 +603,38 @@ impl LayoutTree { Ok(tree) } + /// Replaces the opaque state of a plugin-provided custom layout leaf. + /// + /// Pure: returns a new validated tree. + /// + /// # Errors + /// - [`LayoutError::NodeNotFound`] if `target` is not a custom plugin layout + /// leaf in the tree. + pub fn set_plugin_layout_state( + &self, + target: NodeId, + state: serde_json::Value, + ) -> Result { + let mut found = false; + let root = map_node(&self.root, &mut |node| { + if let LayoutNode::CustomPluginLayout(cell) = node { + if cell.id == target { + found = true; + let mut cell = cell.clone(); + cell.state = state.clone(); + return LayoutNode::CustomPluginLayout(cell); + } + } + node.clone() + }); + if !found { + return Err(LayoutError::NodeNotFound(target)); + } + let tree = Self { root }; + tree.validate()?; + Ok(tree) + } + /// Sets (or, with `None`, clears) the **engine session id** cache /// ([`LeafCell::engine_session_id`]) on the leaf `target` — the resumable id of /// the current provider (id Claude `--resume`, UUID minté pour `--session-id`). diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 0af3231..66512b1 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -26,6 +26,7 @@ import type { LayoutList, LayoutOperation, LayoutTree, + PluginLayoutOrigin, LocalModelServerConfig, Memory, MemoryIndexEntry, @@ -116,8 +117,15 @@ export class HttpLayoutGateway implements LayoutGateway { listLayouts(projectId: string): Promise { return this.http.invoke("list_layouts", { projectId }); } - createLayout(projectId: string, name: string, kind?: LayoutKind): Promise<{ layoutId: string }> { - return this.http.invoke<{ layoutId: string }>("create_layout", { request: { projectId, name, kind } }); + createLayout( + projectId: string, + name: string, + kind?: LayoutKind, + pluginOrigin?: PluginLayoutOrigin, + ): Promise<{ layoutId: string }> { + return this.http.invoke<{ layoutId: string }>("create_layout", { + request: { projectId, name, kind, pluginOrigin }, + }); } renameLayout(projectId: string, layoutId: string, name: string): Promise { return this.http.invoke("rename_layout", { request: { projectId, layoutId, name } }); diff --git a/frontend/src/adapters/layout.ts b/frontend/src/adapters/layout.ts index 8e022f6..fe3aee6 100644 --- a/frontend/src/adapters/layout.ts +++ b/frontend/src/adapters/layout.ts @@ -10,7 +10,13 @@ import { invoke } from "@tauri-apps/api/core"; -import type { LayoutKind, LayoutList, LayoutOperation, LayoutTree } from "@/domain"; +import type { + LayoutKind, + LayoutList, + LayoutOperation, + LayoutTree, + PluginLayoutOrigin, +} from "@/domain"; import type { LayoutGateway } from "@/ports"; export class TauriLayoutGateway implements LayoutGateway { @@ -30,9 +36,14 @@ export class TauriLayoutGateway implements LayoutGateway { return invoke("list_layouts", { projectId }); } - createLayout(projectId: string, name: string, kind?: LayoutKind): Promise<{ layoutId: string }> { + createLayout( + projectId: string, + name: string, + kind?: LayoutKind, + pluginOrigin?: PluginLayoutOrigin, + ): Promise<{ layoutId: string }> { return invoke<{ layoutId: string }>("create_layout", { - request: { projectId, name, kind }, + request: { projectId, name, kind, pluginOrigin }, }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index d27b47e..9e9fe0a 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -27,6 +27,7 @@ import type { LayoutList, LayoutOperation, LayoutTree, + PluginLayoutOrigin, LocalModelServerConfig, ModelServerCommandPreview, Memory, @@ -958,6 +959,7 @@ interface MockLayoutEntry { id: string; name: string; kind: LayoutKind; + pluginOrigin?: PluginLayoutOrigin | null; tree: LayoutTree; } @@ -1028,14 +1030,38 @@ export class MockLayoutGateway implements LayoutGateway { async listLayouts(projectId: string): Promise { const ps = this.getProjectLayouts(projectId); - const layouts: LayoutInfo[] = ps.layouts.map((l) => ({ id: l.id, name: l.name, kind: l.kind })); + const layouts: LayoutInfo[] = ps.layouts.map((l) => ({ + id: l.id, + name: l.name, + kind: l.kind, + pluginOrigin: l.pluginOrigin, + })); return { layouts, activeId: ps.activeId }; } - async createLayout(projectId: string, name: string, kind: LayoutKind = "terminal"): Promise<{ layoutId: string }> { + async createLayout( + projectId: string, + name: string, + kind: LayoutKind = "terminal", + pluginOrigin?: PluginLayoutOrigin, + ): Promise<{ layoutId: string }> { const ps = this.getProjectLayouts(projectId); const layoutId = `layout-${Math.random().toString(36).slice(2, 10)}`; - ps.layouts.push({ id: layoutId, name, kind, tree: singleLeafTree() }); + const tree = + kind === "plugin" && pluginOrigin + ? { + root: { + type: "customPluginLayout" as const, + node: { + id: `plugin-cell-${Math.random().toString(36).slice(2, 10)}`, + pluginId: pluginOrigin.pluginId, + layoutType: pluginOrigin.layoutType, + state: null, + }, + }, + } + : singleLeafTree(); + ps.layouts.push({ id: layoutId, name, kind, pluginOrigin: pluginOrigin ?? null, tree }); return { layoutId }; } diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 3e4ce4d..f38001c 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -29,6 +29,7 @@ describe("createMockGateways", () => { "plugin", "pluginConfig", "pluginEvents", + "pluginStorage", "pluginTask", "pluginToolchain", "pluginWorkspace", diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 71d18ba..85cb056 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -952,16 +952,24 @@ export type LayoutOperation = | { type: "setSession"; target: string; session?: string | null } | { type: "setCellAgent"; target: string; agent: string | null } | { type: "setCellConversation"; target: string; conversationId: string | null } - | { type: "setAgentRunning"; target: string; running: boolean }; + | { type: "setAgentRunning"; target: string; running: boolean } + | { type: "setPluginLayoutState"; target: string; state: unknown }; /** The kind of a named layout. */ -export type LayoutKind = "terminal" | "gitGraph"; +export type LayoutKind = "terminal" | "gitGraph" | "plugin"; + +/** Origin metadata required to create and render a plugin-provided layout. */ +export interface PluginLayoutOrigin { + pluginId: string; + layoutType: string; +} /** Named layout entry returned by `listLayouts`. */ export interface LayoutInfo { id: string; name: string; kind: LayoutKind; + pluginOrigin?: PluginLayoutOrigin | null; } /** diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 86709e0..bf43f84 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -179,7 +179,7 @@ function NodeView({ vm.setPluginLayoutState(node.node.id, nextState)} + onStateChange={(nextState) => void vm.setPluginLayoutState(node.node.id, nextState)} onOpenPlugins={() => onOpenPluginsSettings?.()} onChooseAnotherLayout={() => vm.replacePluginLayoutWithTerminal(node.node.id)} /> diff --git a/frontend/src/features/layout/LayoutTabs.tsx b/frontend/src/features/layout/LayoutTabs.tsx index 93c9cd1..ea0fc83 100644 --- a/frontend/src/features/layout/LayoutTabs.tsx +++ b/frontend/src/features/layout/LayoutTabs.tsx @@ -45,9 +45,6 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps) // Show/hide the create-kind dropdown. const [showCreateMenu, setShowCreateMenu] = useState(false); const { registry: pluginRegistry } = usePluginRuntime(); - // Plugin layouts need a `create_layout` backend extension that doesn't exist - // yet (#43, F4 open point) — surfaced rather than silently swallowed. - const [pluginLayoutNotice, setPluginLayoutNotice] = useState(null); async function handleSelect(id: string) { // The effect above propagates the new active layout (id + kind) to the parent. @@ -88,6 +85,18 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps) } } + async function handleCreatePluginLayout( + name: string, + pluginId: string, + layoutType: string, + ) { + setShowCreateMenu(false); + const newId = await vm.create(name, "plugin", { pluginId, layoutType }); + if (newId) { + await vm.setActive(newId); + } + } + if (vm.layouts.length === 0) return null; return ( @@ -203,20 +212,21 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps) { - setShowCreateMenu(false); - setPluginLayoutNotice( - `« ${choice.layout.label} » (${choice.pluginDisplayName}) : la création de layouts plugins nécessite une extension backend pas encore livrée.`, - ); - }} + onSelect={(choice) => + void handleCreatePluginLayout( + choice.layout.label, + choice.pluginId, + choice.layout.type, + ) + } /> )} - {(vm.error || pluginLayoutNotice) && ( + {vm.error && ( - {vm.error ?? pluginLayoutNotice} + {vm.error} )} diff --git a/frontend/src/features/layout/layout.test.ts b/frontend/src/features/layout/layout.test.ts index 821af09..d8832ed 100644 --- a/frontend/src/features/layout/layout.test.ts +++ b/frontend/src/features/layout/layout.test.ts @@ -499,6 +499,20 @@ describe("customPluginLayout — parsing a backend-shaped JSON tree", () => { expect(updated).toEqual(tree); }); + it("applyOperation persists plugin layout state via setPluginLayoutState", () => { + const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree; + const updated = applyOperation(tree, { + type: "setPluginLayoutState", + target: "018f0c5a-2b4b-70d4-a7c2-300000000001", + state: { branchFilter: "release/1" }, + }); + expect(updated.root.type).toBe("customPluginLayout"); + if (updated.root.type !== "customPluginLayout") throw new Error("unreachable"); + expect(updated.root.node.state).toEqual({ branchFilter: "release/1" }); + expect(updated.root.node.pluginId).toBe("dev.acme.gitgraph"); + expect(updated.root.node.layoutType).toBe("dev.acme.gitgraph.layout"); + }); + it("replaceCustomPluginLayoutWithTerminal swaps the node for a blank terminal leaf of the same id", () => { const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree; const updated = replaceCustomPluginLayoutWithTerminal( diff --git a/frontend/src/features/layout/layout.ts b/frontend/src/features/layout/layout.ts index 89b354f..d99dc36 100644 --- a/frontend/src/features/layout/layout.ts +++ b/frontend/src/features/layout/layout.ts @@ -282,6 +282,18 @@ export function applyOperation( if (!found) throw notFound(op.target); return { root }; } + case "setPluginLayoutState": { + let found = false; + const root = mapNode(tree.root, (n) => { + if (n.type === "customPluginLayout" && n.node.id === op.target) { + found = true; + return { type: "customPluginLayout", node: { ...n.node, state: op.state } }; + } + return n; + }); + if (!found) throw notFound(op.target); + return { root }; + } } } @@ -324,15 +336,10 @@ export function droppedSessions( } /** - * Locally patches a `customPluginLayout` node's opaque `state` (#43, F4). - * - * Client-side only: the backend has no `LayoutOperation` variant to persist - * plugin layout state yet (carnet v2 §3.5 — no backend refonte expected for - * F4), so this does NOT call `LayoutGateway.mutateLayout`. It's the same - * "real, in-session, not yet cross-restart-persisted" contract every plugin - * component's `setState` gets: the tree re-renders with the new state - * immediately, but a reload re-fetches the last **persisted** value from the - * backend. Returns `tree` unchanged if no such node is found. + * Pure helper for patching a `customPluginLayout` node's opaque `state`. + * The runtime path persists the same change through `mutate_layout` with + * `setPluginLayoutState`; this helper remains useful for local tree transforms + * and focused parsing tests. */ export function setCustomPluginLayoutState( tree: LayoutTree, diff --git a/frontend/src/features/layout/layoutTabsGitGraph.test.tsx b/frontend/src/features/layout/layoutTabsGitGraph.test.tsx index 540f403..f2c7170 100644 --- a/frontend/src/features/layout/layoutTabsGitGraph.test.tsx +++ b/frontend/src/features/layout/layoutTabsGitGraph.test.tsx @@ -15,6 +15,14 @@ import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import type { Gateways } from "@/ports"; import { MockLayoutGateway, MockAgentGateway, MockTerminalGateway } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; +import { PluginRuntimeProvider } from "@/features/plugins"; +import { + PluginCommandRegistry, + PluginLayoutRegistry, + PluginMenuRegistry, + PluginRuntimeRegistry, + type LoadedPlugin, +} from "@/plugins/runtime"; import { LayoutTabs } from "./LayoutTabs"; // --------------------------------------------------------------------------- @@ -25,17 +33,45 @@ function renderTabs( layout: MockLayoutGateway, projectId = "p1", onActiveLayoutChange = vi.fn(), + pluginRegistry?: PluginRuntimeRegistry, ) { const gateways = { layout, terminal: new MockTerminalGateway(), agent: new MockAgentGateway(), } as unknown as Gateways; - return render( + const ui = ( - - , + {pluginRegistry ? ( + + + + ) : ( + + )} + ); + return render(ui); +} + +function stubPlugin(pluginId: string, displayName: string, layoutType: string): LoadedPlugin { + const contributes = { + menus: [], + menuItems: [], + layouts: [{ type: layoutType, label: "Android Health", component: "AndroidHealth" }], + mcpServers: [], + }; + return { + pluginId, + displayName, + contributes, + commands: new PluginCommandRegistry(pluginId, new Set()), + layouts: new PluginLayoutRegistry(pluginId, new Set([layoutType])), + menu: new PluginMenuRegistry(pluginId), + dispose: async () => {}, + }; } // --------------------------------------------------------------------------- @@ -68,6 +104,58 @@ describe("LayoutTabs — git graph kind", () => { expect(created!.kind).toBe("gitGraph"); }); + it("MockLayoutGateway.createLayout stores plugin origin and creates a plugin layout tree", async () => { + const layout = new MockLayoutGateway(); + const pluginOrigin = { + pluginId: "dev.idea.android-plugin", + layoutType: "idea-android.health", + }; + const { layoutId } = await layout.createLayout("p1", "Android Health", "plugin", pluginOrigin); + const { layouts } = await layout.listLayouts("p1"); + const created = layouts.find((l) => l.id === layoutId); + expect(created).toMatchObject({ kind: "plugin", pluginOrigin }); + + const tree = await layout.loadLayout("p1", layoutId); + expect(tree.root).toMatchObject({ + type: "customPluginLayout", + node: { + pluginId: "dev.idea.android-plugin", + layoutType: "idea-android.health", + state: null, + }, + }); + }); + + it("choosing a plugin layout creates and activates a plugin layout without the old blocking notice", async () => { + const layout = new MockLayoutGateway(); + const registry = new PluginRuntimeRegistry(); + registry.add(stubPlugin("dev.idea.android-plugin", "Android", "idea-android.health")); + const onActiveLayoutChange = vi.fn(); + renderTabs(layout, "p1", onActiveLayoutChange, registry); + + await waitFor(() => { + expect(screen.getByLabelText("create layout")).toBeTruthy(); + }); + + fireEvent.click(screen.getByLabelText("create layout")); + fireEvent.click(await screen.findByRole("menuitem", { name: /Android Health/ })); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "Android Health" })).toBeTruthy(); + expect(onActiveLayoutChange).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Android Health", + kind: "plugin", + pluginOrigin: { + pluginId: "dev.idea.android-plugin", + layoutType: "idea-android.health", + }, + }), + ); + }); + expect(screen.queryByText(/extension backend pas encore livrée/i)).toBeNull(); + }); + it("MockLayoutGateway.createLayout defaults to kind=terminal", async () => { const layout = new MockLayoutGateway(); const { layoutId } = await layout.createLayout("p1", "My Terminal"); diff --git a/frontend/src/features/layout/useLayout.ts b/frontend/src/features/layout/useLayout.ts index 22dd052..31f116d 100644 --- a/frontend/src/features/layout/useLayout.ts +++ b/frontend/src/features/layout/useLayout.ts @@ -20,7 +20,6 @@ import { useGateways } from "@/app/di"; import { leaves, replaceCustomPluginLayoutWithTerminal, - setCustomPluginLayoutState, splitOp, } from "./layout"; @@ -65,12 +64,9 @@ export interface LayoutViewModel { */ setCellConversation: (target: string, conversationId: string | null) => Promise; /** - * Patches a `customPluginLayout` node's opaque state locally (#43, F4) — - * in-session only, no backend `LayoutOperation` for this exists yet (carnet - * v2 §3.5). The tree re-renders immediately; a reload re-fetches the last - * value actually persisted by the backend. + * Persists a `customPluginLayout` node's opaque state through `mutate_layout`. */ - setPluginLayoutState: (nodeId: string, state: unknown) => void; + setPluginLayoutState: (nodeId: string, state: unknown) => Promise; /** * "Choisir un autre layout" fallback action (#43, F4): locally swaps a * `customPluginLayout` node for a blank terminal leaf of the same id. @@ -265,11 +261,10 @@ export function useLayout( ); const setPluginLayoutState = useCallback( - (nodeId: string, state: unknown) => { - setLayout((prev) => (prev ? setCustomPluginLayoutState(prev, nodeId, state) : prev)); - setLayoutVersion((v) => v + 1); + async (nodeId: string, state: unknown) => { + await mutate({ type: "setPluginLayoutState", target: nodeId, state }); }, - [], + [mutate], ); const replacePluginLayoutWithTerminal = useCallback((nodeId: string) => { diff --git a/frontend/src/features/layout/useLayouts.ts b/frontend/src/features/layout/useLayouts.ts index e910351..20207a9 100644 --- a/frontend/src/features/layout/useLayouts.ts +++ b/frontend/src/features/layout/useLayouts.ts @@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react"; -import type { GatewayError, LayoutInfo, LayoutKind } from "@/domain"; +import type { GatewayError, LayoutInfo, LayoutKind, PluginLayoutOrigin } from "@/domain"; import { useGateways } from "@/app/di"; export interface LayoutsViewModel { @@ -22,7 +22,11 @@ export interface LayoutsViewModel { /** Switches the active layout (does NOT force a re-fetch here; the caller uses the returned activeId). */ setActive: (layoutId: string) => Promise; /** Creates a new layout with the given name and kind; resolves with the new layoutId. */ - create: (name: string, kind?: LayoutKind) => Promise; + create: ( + name: string, + kind?: LayoutKind, + pluginOrigin?: PluginLayoutOrigin, + ) => Promise; /** Renames a layout. */ rename: (layoutId: string, name: string) => Promise; /** Deletes a layout; refuses if it is the last one. */ @@ -144,12 +148,16 @@ export function useLayouts(projectId: string | null): LayoutsViewModel { ); const create = useCallback( - async (name: string, kind?: LayoutKind): Promise => { + async ( + name: string, + kind?: LayoutKind, + pluginOrigin?: PluginLayoutOrigin, + ): Promise => { if (!projectId || !gateway) return null; setBusy(true); setError(null); try { - const { layoutId } = await gateway.createLayout(projectId, name, kind); + const { layoutId } = await gateway.createLayout(projectId, name, kind, pluginOrigin); const updated = await gateway.listLayouts(projectId); setLayouts(updated.layouts); return layoutId; diff --git a/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx b/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx index 7890cd5..2e8fb4b 100644 --- a/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx +++ b/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx @@ -6,15 +6,8 @@ * plugins never appear — carnet §1.3: only `enabled` plugins are loaded, so * there is nothing to filter here beyond what the registry already omits). * - * Presentational only; not yet mounted in a layout-creation flow. The - * existing "layout" concept in this codebase (`LayoutTabs`, `LayoutKind`) is - * a whole-tab kind (`"terminal" | "gitGraph"`) picked via a fixed two-item - * dropdown, backed by a `create(name, kind)` Tauri command that only knows - * those two kinds. Wiring an actual "create a plugin layout" action needs a - * backend `LayoutKind`/`create_layout` extension (carnet §10 flags F4 as - * "DevFrontend + DevBackend si ajustement DTO layout") — this component is - * the frontend half, ready to drop into that flow once the DTO lands; see the - * F4 delivery report's open point. + * Presentational only: the caller owns whether selecting a contribution creates + * a named plugin layout, replaces an unavailable cell, or just previews it. */ import type { PluginLayoutContribution } from "@/domain"; diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index dfbc456..000cfc1 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -30,6 +30,7 @@ import type { LayoutList, LayoutOperation, LayoutTree, + PluginLayoutOrigin, LocalModelServerConfig, ModelServerCommandPreview, Memory, @@ -469,7 +470,12 @@ export interface LayoutGateway { /** Lists all named layouts for a project, with the current active id. */ listLayouts(projectId: string): Promise; /** Creates a new named layout for a project; returns the new layout id. */ - createLayout(projectId: string, name: string, kind?: LayoutKind): Promise<{ layoutId: string }>; + createLayout( + projectId: string, + name: string, + kind?: LayoutKind, + pluginOrigin?: PluginLayoutOrigin, + ): Promise<{ layoutId: string }>; /** Renames a layout. */ renameLayout(projectId: string, layoutId: string, name: string): Promise; /** Deletes a layout; returns the new active layout id. */