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:
6
.ideai/tickets/141/carnet.md
Normal file
6
.ideai/tickets/141/carnet.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#141"
|
||||||
|
version: 1
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
updatedAt: 1785766766212
|
||||||
|
---
|
||||||
38
.ideai/tickets/141/issue.md
Normal file
38
.ideai/tickets/141/issue.md
Normal file
@ -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.
|
||||||
@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nextNumber": 141
|
"nextNumber": 142
|
||||||
}
|
}
|
||||||
@ -1822,6 +1822,20 @@
|
|||||||
"kind": "user"
|
"kind": "user"
|
||||||
},
|
},
|
||||||
"updatedAt": 1785760572251
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -10,8 +10,10 @@ use app_tauri_lib::dto::{
|
|||||||
};
|
};
|
||||||
use application::{
|
use application::{
|
||||||
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput,
|
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput,
|
||||||
|
PluginLayoutOrigin,
|
||||||
};
|
};
|
||||||
use domain::ids::{AgentId, LayoutId, NodeId};
|
use domain::ids::{AgentId, LayoutId, NodeId};
|
||||||
|
use domain::{PluginId, PluginLayoutType};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -55,6 +57,24 @@ fn layout_info_dto_git_graph_kind() {
|
|||||||
assert_eq!(v["kind"], "gitGraph");
|
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
|
// ListLayoutsDto
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -138,6 +158,43 @@ fn create_layout_request_with_git_graph_kind() {
|
|||||||
assert_eq!(kind, application::LayoutKind::GitGraph);
|
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]
|
#[test]
|
||||||
fn create_layout_request_unknown_kind_is_invalid() {
|
fn create_layout_request_unknown_kind_is_invalid() {
|
||||||
let project_id = Uuid::from_u128(12).to_string();
|
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)
|
// setCellConversation operation deserialisation (T4b)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -4,12 +4,18 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
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 domain::{DomainEvent, LayoutId, ProjectId};
|
||||||
|
|
||||||
use crate::error::AppError;
|
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.
|
/// Lightweight descriptor of a named layout (no tree), for the layouts tab bar.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@ -70,7 +76,7 @@ impl ListLayouts {
|
|||||||
.map(|l| LayoutInfo {
|
.map(|l| LayoutInfo {
|
||||||
id: l.id,
|
id: l.id,
|
||||||
name: l.name.clone(),
|
name: l.name.clone(),
|
||||||
kind: l.kind,
|
kind: l.kind.clone(),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
active_id: doc.active_id,
|
active_id: doc.active_id,
|
||||||
@ -106,6 +112,9 @@ pub struct CreateLayout {
|
|||||||
fs: Arc<dyn FileSystem>,
|
fs: Arc<dyn FileSystem>,
|
||||||
ids: Arc<dyn IdGenerator>,
|
ids: Arc<dyn IdGenerator>,
|
||||||
events: Arc<dyn EventBus>,
|
events: Arc<dyn EventBus>,
|
||||||
|
packages: Arc<dyn PluginPackageStore>,
|
||||||
|
registry: Arc<dyn PluginRegistryStore>,
|
||||||
|
validator: Arc<dyn PluginManifestValidator>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CreateLayout {
|
impl CreateLayout {
|
||||||
@ -116,12 +125,18 @@ impl CreateLayout {
|
|||||||
fs: Arc<dyn FileSystem>,
|
fs: Arc<dyn FileSystem>,
|
||||||
ids: Arc<dyn IdGenerator>,
|
ids: Arc<dyn IdGenerator>,
|
||||||
events: Arc<dyn EventBus>,
|
events: Arc<dyn EventBus>,
|
||||||
|
packages: Arc<dyn PluginPackageStore>,
|
||||||
|
registry: Arc<dyn PluginRegistryStore>,
|
||||||
|
validator: Arc<dyn PluginManifestValidator>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
store,
|
store,
|
||||||
fs,
|
fs,
|
||||||
ids,
|
ids,
|
||||||
events,
|
events,
|
||||||
|
packages,
|
||||||
|
registry,
|
||||||
|
validator,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,13 +152,23 @@ impl CreateLayout {
|
|||||||
}
|
}
|
||||||
let project = self.store.load_project(input.project_id).await?;
|
let project = self.store.load_project(input.project_id).await?;
|
||||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).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 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 {
|
doc.layouts.push(NamedLayout {
|
||||||
id,
|
id,
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
tree: default_tree(),
|
tree,
|
||||||
});
|
});
|
||||||
doc.active_id = id; // a freshly-created layout becomes active.
|
doc.active_id = id; // a freshly-created layout becomes active.
|
||||||
|
|
||||||
@ -153,6 +178,42 @@ impl CreateLayout {
|
|||||||
});
|
});
|
||||||
Ok(CreateLayoutOutput { layout_id: 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()
|
||||||
|
)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -35,6 +35,7 @@ pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOut
|
|||||||
pub use snapshot::{
|
pub use snapshot::{
|
||||||
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
|
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
|
||||||
};
|
};
|
||||||
|
pub use store::PluginLayoutOrigin;
|
||||||
pub(crate) use store::{persist_doc, resolve_doc};
|
pub(crate) use store::{persist_doc, resolve_doc};
|
||||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||||
pub use usecases::{
|
pub use usecases::{
|
||||||
|
|||||||
@ -15,7 +15,10 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use domain::ports::{FileSystem, RemotePath};
|
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::error::AppError;
|
||||||
use crate::project::meta::{from_json_bytes, join_root, to_json_bytes, IDEAI_DIR};
|
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";
|
const DEFAULT_LAYOUT_NAME: &str = "Default";
|
||||||
|
|
||||||
/// Discriminates the kind of content a named layout holds.
|
/// 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")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum LayoutKind {
|
pub enum LayoutKind {
|
||||||
/// A terminal-grid layout (the original kind).
|
/// A terminal-grid layout (the original kind).
|
||||||
@ -41,6 +44,23 @@ pub enum LayoutKind {
|
|||||||
Terminal,
|
Terminal,
|
||||||
/// A Git-graph visualisation layout.
|
/// A Git-graph visualisation layout.
|
||||||
GitGraph,
|
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.
|
/// 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()))
|
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.
|
/// Builds a fresh doc holding one layout (`tree`) made active.
|
||||||
fn doc_with(id: LayoutId, name: &str, tree: LayoutTree) -> LayoutsDoc {
|
fn doc_with(id: LayoutId, name: &str, tree: LayoutTree) -> LayoutsDoc {
|
||||||
LayoutsDoc {
|
LayoutsDoc {
|
||||||
|
|||||||
@ -98,6 +98,13 @@ pub enum LayoutOperation {
|
|||||||
/// Conversation id to record, or `None` to clear.
|
/// Conversation id to record, or `None` to clear.
|
||||||
conversation_id: Option<String>,
|
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 {
|
impl LayoutOperation {
|
||||||
@ -123,6 +130,9 @@ impl LayoutOperation {
|
|||||||
target,
|
target,
|
||||||
conversation_id,
|
conversation_id,
|
||||||
} => tree.set_cell_conversation(*target, conversation_id.clone()),
|
} => 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)
|
result.map_err(map_layout_err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -114,10 +114,10 @@ pub use layout::{
|
|||||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||||
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
|
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
|
||||||
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
|
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
|
||||||
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, ReconcileLayouts,
|
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, PluginLayoutOrigin,
|
||||||
ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout, RenameLayoutInput,
|
ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout,
|
||||||
SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput, SnapshotRunningAgents,
|
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput,
|
||||||
SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
||||||
};
|
};
|
||||||
pub use mcp_tool_permissions::{
|
pub use mcp_tool_permissions::{
|
||||||
McpToolPermissionCatalogue, ReadMcpToolPermissions, ReadMcpToolPermissionsInput,
|
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,
|
packages: &dyn PluginPackageStore,
|
||||||
validator: &dyn PluginManifestValidator,
|
validator: &dyn PluginManifestValidator,
|
||||||
entry: PluginRegistryEntry,
|
entry: PluginRegistryEntry,
|
||||||
|
|||||||
@ -13,19 +13,23 @@ use async_trait::async_trait;
|
|||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::layout::Workspace;
|
use domain::layout::Workspace;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, ProjectStore, RemotePath,
|
DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, PluginManifestBytes,
|
||||||
StoreError,
|
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, ProjectStore,
|
||||||
|
RemotePath, StoreError,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId,
|
AgentId, ContentHash, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, LocalPath, NodeId,
|
||||||
ProjectPath, RemoteRef, SessionId,
|
PluginBundleUrl, PluginId, PluginInstallSource, PluginLifecycleState, PluginPackageRef,
|
||||||
|
PluginRegistry, PluginRegistryEntry, Project, ProjectId, ProjectPath, RelativePath, RemoteRef,
|
||||||
|
RemovalOutcome, SessionId, StagedPluginPackage,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use application::plugin::JsonPluginManifestValidator;
|
||||||
use application::{
|
use application::{
|
||||||
CreateLayout, CreateLayoutInput, DeleteLayout, DeleteLayoutInput, LayoutKind, LayoutOperation,
|
CreateLayout, CreateLayoutInput, DeleteLayout, DeleteLayoutInput, LayoutKind, LayoutOperation,
|
||||||
ListLayouts, ListLayoutsInput, LoadLayout, LoadLayoutInput, MutateLayout, MutateLayoutInput,
|
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<u8> {
|
||||||
|
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<Mutex<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakePluginPackages {
|
||||||
|
fn new(manifest: Vec<u8>) -> Self {
|
||||||
|
Self {
|
||||||
|
manifest: Arc::new(Mutex::new(manifest)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PluginPackageStore for FakePluginPackages {
|
||||||
|
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_manifest(
|
||||||
|
&self,
|
||||||
|
_package: &PluginPackageRef,
|
||||||
|
) -> Result<PluginManifestBytes, PluginStoreError> {
|
||||||
|
Ok(PluginManifestBytes {
|
||||||
|
bytes: self.manifest.lock().unwrap().clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_from_archive(
|
||||||
|
&self,
|
||||||
|
_archive: &LocalPath,
|
||||||
|
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||||
|
Err(PluginStoreError::Invalid("unused".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_from_directory(
|
||||||
|
&self,
|
||||||
|
_dir: &LocalPath,
|
||||||
|
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||||
|
Err(PluginStoreError::Invalid("unused".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commit_install(
|
||||||
|
&self,
|
||||||
|
staged: StagedPluginPackage,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
) -> Result<PluginPackageRef, PluginStoreError> {
|
||||||
|
Ok(PluginPackageRef {
|
||||||
|
plugin_id: Some(plugin_id.clone()),
|
||||||
|
root: staged.root,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_package(
|
||||||
|
&self,
|
||||||
|
_plugin_id: &PluginId,
|
||||||
|
) -> Result<RemovalOutcome, PluginStoreError> {
|
||||||
|
Ok(RemovalOutcome::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bundle_url(
|
||||||
|
&self,
|
||||||
|
plugin_id: &PluginId,
|
||||||
|
entry: &RelativePath,
|
||||||
|
hash: &ContentHash,
|
||||||
|
) -> Result<PluginBundleUrl, PluginStoreError> {
|
||||||
|
Ok(PluginBundleUrl::new(format!(
|
||||||
|
"idea-plugin://{}/current/{}/{}",
|
||||||
|
plugin_id.as_str(),
|
||||||
|
hash.as_str(),
|
||||||
|
entry.as_str()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct FakePluginRegistry {
|
||||||
|
registry: Arc<Mutex<PluginRegistry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PluginRegistry, PluginRegistryError> {
|
||||||
|
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
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -803,11 +953,12 @@ async fn mgmt_env(project_id: ProjectId) -> (FakeStore, FakeFs, SpyBus) {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_layout_appends_and_activates_it() {
|
async fn create_layout_appends_and_activates_it() {
|
||||||
let (store, fs, bus) = mgmt_env(pid(30)).await;
|
let (store, fs, bus) = mgmt_env(pid(30)).await;
|
||||||
let create = CreateLayout::new(
|
let create = create_layout(
|
||||||
Arc::new(store.clone()),
|
store.clone(),
|
||||||
Arc::new(fs.clone()),
|
fs.clone(),
|
||||||
Arc::new(SeqIds::new(0xABC)),
|
SeqIds::new(0xABC),
|
||||||
Arc::new(bus.clone()),
|
bus.clone(),
|
||||||
|
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
||||||
);
|
);
|
||||||
let out = create
|
let out = create
|
||||||
.execute(CreateLayoutInput {
|
.execute(CreateLayoutInput {
|
||||||
@ -832,11 +983,12 @@ async fn create_layout_appends_and_activates_it() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_layout_rejects_empty_name() {
|
async fn create_layout_rejects_empty_name() {
|
||||||
let (store, fs, bus) = mgmt_env(pid(31)).await;
|
let (store, fs, bus) = mgmt_env(pid(31)).await;
|
||||||
let err = CreateLayout::new(
|
let err = create_layout(
|
||||||
Arc::new(store),
|
store,
|
||||||
Arc::new(fs),
|
fs,
|
||||||
Arc::new(SeqIds::new(1)),
|
SeqIds::new(1),
|
||||||
Arc::new(bus),
|
bus,
|
||||||
|
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
||||||
)
|
)
|
||||||
.execute(CreateLayoutInput {
|
.execute(CreateLayoutInput {
|
||||||
project_id: pid(31),
|
project_id: pid(31),
|
||||||
@ -848,6 +1000,149 @@ async fn create_layout_rejects_empty_name() {
|
|||||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
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]
|
#[tokio::test]
|
||||||
async fn rename_layout_changes_the_name() {
|
async fn rename_layout_changes_the_name() {
|
||||||
let (store, fs, bus) = mgmt_env(pid(32)).await;
|
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() {
|
async fn delete_active_layout_reassigns_active() {
|
||||||
let (store, fs, bus) = mgmt_env(pid(34)).await;
|
let (store, fs, bus) = mgmt_env(pid(34)).await;
|
||||||
// Add a second layout (becomes active), then delete it → active falls back.
|
// Add a second layout (becomes active), then delete it → active falls back.
|
||||||
let created = CreateLayout::new(
|
let created = create_layout(
|
||||||
Arc::new(store.clone()),
|
store.clone(),
|
||||||
Arc::new(fs.clone()),
|
fs.clone(),
|
||||||
Arc::new(SeqIds::new(0xD)),
|
SeqIds::new(0xD),
|
||||||
Arc::new(bus.clone()),
|
bus.clone(),
|
||||||
|
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
||||||
)
|
)
|
||||||
.execute(CreateLayoutInput {
|
.execute(CreateLayoutInput {
|
||||||
project_id: pid(34),
|
project_id: pid(34),
|
||||||
@ -925,11 +1221,12 @@ async fn delete_active_layout_reassigns_active() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn set_active_layout_switches_and_load_follows() {
|
async fn set_active_layout_switches_and_load_follows() {
|
||||||
let (store, fs, bus) = mgmt_env(pid(35)).await;
|
let (store, fs, bus) = mgmt_env(pid(35)).await;
|
||||||
let created = CreateLayout::new(
|
let created = create_layout(
|
||||||
Arc::new(store.clone()),
|
store.clone(),
|
||||||
Arc::new(fs.clone()),
|
fs.clone(),
|
||||||
Arc::new(SeqIds::new(0xE)),
|
SeqIds::new(0xE),
|
||||||
Arc::new(bus.clone()),
|
bus.clone(),
|
||||||
|
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
||||||
)
|
)
|
||||||
.execute(CreateLayoutInput {
|
.execute(CreateLayoutInput {
|
||||||
project_id: pid(35),
|
project_id: pid(35),
|
||||||
|
|||||||
@ -1118,9 +1118,9 @@ pub fn parse_session_id(raw: &str) -> Result<SessionId, ErrorDto> {
|
|||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput,
|
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.
|
/// Response DTO carrying a layout tree.
|
||||||
///
|
///
|
||||||
@ -1214,6 +1214,15 @@ pub enum LayoutOperationDto {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
conversation_id: Option<String>,
|
conversation_id: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// 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 {
|
impl LayoutOperationDto {
|
||||||
@ -1264,6 +1273,10 @@ impl LayoutOperationDto {
|
|||||||
target: parse_node_id(&target)?,
|
target: parse_node_id(&target)?,
|
||||||
conversation_id,
|
conversation_id,
|
||||||
},
|
},
|
||||||
|
Self::SetPluginLayoutState { target, state } => LayoutOperation::SetPluginLayoutState {
|
||||||
|
target: parse_node_id(&target)?,
|
||||||
|
state,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1306,7 +1319,7 @@ pub struct LayoutInfoDto {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
/// Display name.
|
/// Display name.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Layout kind: `"terminal"` or `"gitGraph"`.
|
/// Layout kind: `"terminal"`, `"gitGraph"` or `"plugin"`.
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1315,6 +1328,7 @@ impl From<LayoutInfo> for LayoutInfoDto {
|
|||||||
let kind = match info.kind {
|
let kind = match info.kind {
|
||||||
LayoutKind::Terminal => "terminal",
|
LayoutKind::Terminal => "terminal",
|
||||||
LayoutKind::GitGraph => "gitGraph",
|
LayoutKind::GitGraph => "gitGraph",
|
||||||
|
LayoutKind::Plugin { .. } => "plugin",
|
||||||
}
|
}
|
||||||
.to_owned();
|
.to_owned();
|
||||||
Self {
|
Self {
|
||||||
@ -1384,9 +1398,15 @@ pub struct CreateLayoutRequestDto {
|
|||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
/// Display name for the new layout.
|
/// Display name for the new layout.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Optional layout kind: `"terminal"` (default) or `"gitGraph"`.
|
/// Optional layout kind: `"terminal"` (default), `"gitGraph"` or `"plugin"`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub kind: Option<String>,
|
pub kind: Option<String>,
|
||||||
|
/// Plugin origin for `"plugin"` layouts.
|
||||||
|
#[serde(default)]
|
||||||
|
pub plugin_origin: Option<PluginLayoutOriginDto>,
|
||||||
|
/// Opaque initial state for `"plugin"` layouts.
|
||||||
|
#[serde(default)]
|
||||||
|
pub state: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CreateLayoutRequestDto {
|
impl CreateLayoutRequestDto {
|
||||||
@ -1398,6 +1418,16 @@ impl CreateLayoutRequestDto {
|
|||||||
match self.kind.as_deref() {
|
match self.kind.as_deref() {
|
||||||
None | Some("terminal") => Ok(LayoutKind::Terminal),
|
None | Some("terminal") => Ok(LayoutKind::Terminal),
|
||||||
Some("gitGraph") => Ok(LayoutKind::GitGraph),
|
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 {
|
Some(other) => Err(ErrorDto {
|
||||||
code: "INVALID".to_owned(),
|
code: "INVALID".to_owned(),
|
||||||
message: format!("unknown layout kind: {other}"),
|
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<PluginLayoutOriginDto> for PluginLayoutOrigin {
|
||||||
|
type Error = ErrorDto;
|
||||||
|
|
||||||
|
fn try_from(value: PluginLayoutOriginDto) -> Result<Self, Self::Error> {
|
||||||
|
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`.
|
/// Request DTO for `rename_layout`.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -1536,6 +1536,9 @@ impl BackendCore {
|
|||||||
Arc::clone(&fs_port),
|
Arc::clone(&fs_port),
|
||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
Arc::clone(&events_port),
|
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(
|
let rename_layout = Arc::new(RenameLayout::new(
|
||||||
Arc::clone(&store_port),
|
Arc::clone(&store_port),
|
||||||
|
|||||||
@ -603,6 +603,38 @@ impl LayoutTree {
|
|||||||
Ok(tree)
|
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<Self, LayoutError> {
|
||||||
|
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
|
/// Sets (or, with `None`, clears) the **engine session id** cache
|
||||||
/// ([`LeafCell::engine_session_id`]) on the leaf `target` — the resumable id of
|
/// ([`LeafCell::engine_session_id`]) on the leaf `target` — the resumable id of
|
||||||
/// the current provider (id Claude `--resume`, UUID minté pour `--session-id`).
|
/// the current provider (id Claude `--resume`, UUID minté pour `--session-id`).
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import type {
|
|||||||
LayoutList,
|
LayoutList,
|
||||||
LayoutOperation,
|
LayoutOperation,
|
||||||
LayoutTree,
|
LayoutTree,
|
||||||
|
PluginLayoutOrigin,
|
||||||
LocalModelServerConfig,
|
LocalModelServerConfig,
|
||||||
Memory,
|
Memory,
|
||||||
MemoryIndexEntry,
|
MemoryIndexEntry,
|
||||||
@ -116,8 +117,15 @@ export class HttpLayoutGateway implements LayoutGateway {
|
|||||||
listLayouts(projectId: string): Promise<LayoutList> {
|
listLayouts(projectId: string): Promise<LayoutList> {
|
||||||
return this.http.invoke<LayoutList>("list_layouts", { projectId });
|
return this.http.invoke<LayoutList>("list_layouts", { projectId });
|
||||||
}
|
}
|
||||||
createLayout(projectId: string, name: string, kind?: LayoutKind): Promise<{ layoutId: string }> {
|
createLayout(
|
||||||
return this.http.invoke<{ layoutId: string }>("create_layout", { request: { projectId, name, kind } });
|
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<void> {
|
renameLayout(projectId: string, layoutId: string, name: string): Promise<void> {
|
||||||
return this.http.invoke<void>("rename_layout", { request: { projectId, layoutId, name } });
|
return this.http.invoke<void>("rename_layout", { request: { projectId, layoutId, name } });
|
||||||
|
|||||||
@ -10,7 +10,13 @@
|
|||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
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";
|
import type { LayoutGateway } from "@/ports";
|
||||||
|
|
||||||
export class TauriLayoutGateway implements LayoutGateway {
|
export class TauriLayoutGateway implements LayoutGateway {
|
||||||
@ -30,9 +36,14 @@ export class TauriLayoutGateway implements LayoutGateway {
|
|||||||
return invoke<LayoutList>("list_layouts", { projectId });
|
return invoke<LayoutList>("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", {
|
return invoke<{ layoutId: string }>("create_layout", {
|
||||||
request: { projectId, name, kind },
|
request: { projectId, name, kind, pluginOrigin },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import type {
|
|||||||
LayoutList,
|
LayoutList,
|
||||||
LayoutOperation,
|
LayoutOperation,
|
||||||
LayoutTree,
|
LayoutTree,
|
||||||
|
PluginLayoutOrigin,
|
||||||
LocalModelServerConfig,
|
LocalModelServerConfig,
|
||||||
ModelServerCommandPreview,
|
ModelServerCommandPreview,
|
||||||
Memory,
|
Memory,
|
||||||
@ -958,6 +959,7 @@ interface MockLayoutEntry {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
kind: LayoutKind;
|
kind: LayoutKind;
|
||||||
|
pluginOrigin?: PluginLayoutOrigin | null;
|
||||||
tree: LayoutTree;
|
tree: LayoutTree;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1028,14 +1030,38 @@ export class MockLayoutGateway implements LayoutGateway {
|
|||||||
|
|
||||||
async listLayouts(projectId: string): Promise<LayoutList> {
|
async listLayouts(projectId: string): Promise<LayoutList> {
|
||||||
const ps = this.getProjectLayouts(projectId);
|
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 };
|
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 ps = this.getProjectLayouts(projectId);
|
||||||
const layoutId = `layout-${Math.random().toString(36).slice(2, 10)}`;
|
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 };
|
return { layoutId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ describe("createMockGateways", () => {
|
|||||||
"plugin",
|
"plugin",
|
||||||
"pluginConfig",
|
"pluginConfig",
|
||||||
"pluginEvents",
|
"pluginEvents",
|
||||||
|
"pluginStorage",
|
||||||
"pluginTask",
|
"pluginTask",
|
||||||
"pluginToolchain",
|
"pluginToolchain",
|
||||||
"pluginWorkspace",
|
"pluginWorkspace",
|
||||||
|
|||||||
@ -952,16 +952,24 @@ export type LayoutOperation =
|
|||||||
| { type: "setSession"; target: string; session?: string | null }
|
| { type: "setSession"; target: string; session?: string | null }
|
||||||
| { type: "setCellAgent"; target: string; agent: string | null }
|
| { type: "setCellAgent"; target: string; agent: string | null }
|
||||||
| { type: "setCellConversation"; target: string; conversationId: 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. */
|
/** 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`. */
|
/** Named layout entry returned by `listLayouts`. */
|
||||||
export interface LayoutInfo {
|
export interface LayoutInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
kind: LayoutKind;
|
kind: LayoutKind;
|
||||||
|
pluginOrigin?: PluginLayoutOrigin | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -179,7 +179,7 @@ function NodeView({
|
|||||||
<PluginLayoutCellView
|
<PluginLayoutCellView
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
cell={node.node}
|
cell={node.node}
|
||||||
onStateChange={(nextState) => vm.setPluginLayoutState(node.node.id, nextState)}
|
onStateChange={(nextState) => void vm.setPluginLayoutState(node.node.id, nextState)}
|
||||||
onOpenPlugins={() => onOpenPluginsSettings?.()}
|
onOpenPlugins={() => onOpenPluginsSettings?.()}
|
||||||
onChooseAnotherLayout={() => vm.replacePluginLayoutWithTerminal(node.node.id)}
|
onChooseAnotherLayout={() => vm.replacePluginLayoutWithTerminal(node.node.id)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -45,9 +45,6 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
|
|||||||
// Show/hide the create-kind dropdown.
|
// Show/hide the create-kind dropdown.
|
||||||
const [showCreateMenu, setShowCreateMenu] = useState(false);
|
const [showCreateMenu, setShowCreateMenu] = useState(false);
|
||||||
const { registry: pluginRegistry } = usePluginRuntime();
|
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<string | null>(null);
|
|
||||||
|
|
||||||
async function handleSelect(id: string) {
|
async function handleSelect(id: string) {
|
||||||
// The effect above propagates the new active layout (id + kind) to the parent.
|
// 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;
|
if (vm.layouts.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -203,20 +212,21 @@ export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps)
|
|||||||
</button>
|
</button>
|
||||||
<PluginLayoutSelectorSection
|
<PluginLayoutSelectorSection
|
||||||
registry={pluginRegistry}
|
registry={pluginRegistry}
|
||||||
onSelect={(choice) => {
|
onSelect={(choice) =>
|
||||||
setShowCreateMenu(false);
|
void handleCreatePluginLayout(
|
||||||
setPluginLayoutNotice(
|
choice.layout.label,
|
||||||
`« ${choice.layout.label} » (${choice.pluginDisplayName}) : la création de layouts plugins nécessite une extension backend pas encore livrée.`,
|
choice.pluginId,
|
||||||
);
|
choice.layout.type,
|
||||||
}}
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(vm.error || pluginLayoutNotice) && (
|
{vm.error && (
|
||||||
<span className="ml-2 text-xs text-danger" role="alert">
|
<span className="ml-2 text-xs text-danger" role="alert">
|
||||||
{vm.error ?? pluginLayoutNotice}
|
{vm.error}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -499,6 +499,20 @@ describe("customPluginLayout — parsing a backend-shaped JSON tree", () => {
|
|||||||
expect(updated).toEqual(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", () => {
|
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 tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
|
||||||
const updated = replaceCustomPluginLayoutWithTerminal(
|
const updated = replaceCustomPluginLayoutWithTerminal(
|
||||||
|
|||||||
@ -282,6 +282,18 @@ export function applyOperation(
|
|||||||
if (!found) throw notFound(op.target);
|
if (!found) throw notFound(op.target);
|
||||||
return { root };
|
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).
|
* Pure helper for patching a `customPluginLayout` node's opaque `state`.
|
||||||
*
|
* The runtime path persists the same change through `mutate_layout` with
|
||||||
* Client-side only: the backend has no `LayoutOperation` variant to persist
|
* `setPluginLayoutState`; this helper remains useful for local tree transforms
|
||||||
* plugin layout state yet (carnet v2 §3.5 — no backend refonte expected for
|
* and focused parsing tests.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function setCustomPluginLayoutState(
|
export function setCustomPluginLayoutState(
|
||||||
tree: LayoutTree,
|
tree: LayoutTree,
|
||||||
|
|||||||
@ -15,6 +15,14 @@ import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
|||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import { MockLayoutGateway, MockAgentGateway, MockTerminalGateway } from "@/adapters/mock";
|
import { MockLayoutGateway, MockAgentGateway, MockTerminalGateway } from "@/adapters/mock";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
|
import { PluginRuntimeProvider } from "@/features/plugins";
|
||||||
|
import {
|
||||||
|
PluginCommandRegistry,
|
||||||
|
PluginLayoutRegistry,
|
||||||
|
PluginMenuRegistry,
|
||||||
|
PluginRuntimeRegistry,
|
||||||
|
type LoadedPlugin,
|
||||||
|
} from "@/plugins/runtime";
|
||||||
import { LayoutTabs } from "./LayoutTabs";
|
import { LayoutTabs } from "./LayoutTabs";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -25,17 +33,45 @@ function renderTabs(
|
|||||||
layout: MockLayoutGateway,
|
layout: MockLayoutGateway,
|
||||||
projectId = "p1",
|
projectId = "p1",
|
||||||
onActiveLayoutChange = vi.fn(),
|
onActiveLayoutChange = vi.fn(),
|
||||||
|
pluginRegistry?: PluginRuntimeRegistry,
|
||||||
) {
|
) {
|
||||||
const gateways = {
|
const gateways = {
|
||||||
layout,
|
layout,
|
||||||
terminal: new MockTerminalGateway(),
|
terminal: new MockTerminalGateway(),
|
||||||
agent: new MockAgentGateway(),
|
agent: new MockAgentGateway(),
|
||||||
} as unknown as Gateways;
|
} as unknown as Gateways;
|
||||||
return render(
|
const ui = (
|
||||||
<DIProvider gateways={gateways}>
|
<DIProvider gateways={gateways}>
|
||||||
<LayoutTabs projectId={projectId} onActiveLayoutChange={onActiveLayoutChange} />
|
{pluginRegistry ? (
|
||||||
</DIProvider>,
|
<PluginRuntimeProvider
|
||||||
|
value={{ registry: pluginRegistry, failures: [], pending: [], loading: false }}
|
||||||
|
>
|
||||||
|
<LayoutTabs projectId={projectId} onActiveLayoutChange={onActiveLayoutChange} />
|
||||||
|
</PluginRuntimeProvider>
|
||||||
|
) : (
|
||||||
|
<LayoutTabs projectId={projectId} onActiveLayoutChange={onActiveLayoutChange} />
|
||||||
|
)}
|
||||||
|
</DIProvider>
|
||||||
);
|
);
|
||||||
|
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");
|
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 () => {
|
it("MockLayoutGateway.createLayout defaults to kind=terminal", async () => {
|
||||||
const layout = new MockLayoutGateway();
|
const layout = new MockLayoutGateway();
|
||||||
const { layoutId } = await layout.createLayout("p1", "My Terminal");
|
const { layoutId } = await layout.createLayout("p1", "My Terminal");
|
||||||
|
|||||||
@ -20,7 +20,6 @@ import { useGateways } from "@/app/di";
|
|||||||
import {
|
import {
|
||||||
leaves,
|
leaves,
|
||||||
replaceCustomPluginLayoutWithTerminal,
|
replaceCustomPluginLayoutWithTerminal,
|
||||||
setCustomPluginLayoutState,
|
|
||||||
splitOp,
|
splitOp,
|
||||||
} from "./layout";
|
} from "./layout";
|
||||||
|
|
||||||
@ -65,12 +64,9 @@ export interface LayoutViewModel {
|
|||||||
*/
|
*/
|
||||||
setCellConversation: (target: string, conversationId: string | null) => Promise<void>;
|
setCellConversation: (target: string, conversationId: string | null) => Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Patches a `customPluginLayout` node's opaque state locally (#43, F4) —
|
* Persists a `customPluginLayout` node's opaque state through `mutate_layout`.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
setPluginLayoutState: (nodeId: string, state: unknown) => void;
|
setPluginLayoutState: (nodeId: string, state: unknown) => Promise<void>;
|
||||||
/**
|
/**
|
||||||
* "Choisir un autre layout" fallback action (#43, F4): locally swaps a
|
* "Choisir un autre layout" fallback action (#43, F4): locally swaps a
|
||||||
* `customPluginLayout` node for a blank terminal leaf of the same id.
|
* `customPluginLayout` node for a blank terminal leaf of the same id.
|
||||||
@ -265,11 +261,10 @@ export function useLayout(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const setPluginLayoutState = useCallback(
|
const setPluginLayoutState = useCallback(
|
||||||
(nodeId: string, state: unknown) => {
|
async (nodeId: string, state: unknown) => {
|
||||||
setLayout((prev) => (prev ? setCustomPluginLayoutState(prev, nodeId, state) : prev));
|
await mutate({ type: "setPluginLayoutState", target: nodeId, state });
|
||||||
setLayoutVersion((v) => v + 1);
|
|
||||||
},
|
},
|
||||||
[],
|
[mutate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const replacePluginLayoutWithTerminal = useCallback((nodeId: string) => {
|
const replacePluginLayoutWithTerminal = useCallback((nodeId: string) => {
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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";
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
export interface LayoutsViewModel {
|
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). */
|
/** Switches the active layout (does NOT force a re-fetch here; the caller uses the returned activeId). */
|
||||||
setActive: (layoutId: string) => Promise<void>;
|
setActive: (layoutId: string) => Promise<void>;
|
||||||
/** Creates a new layout with the given name and kind; resolves with the new layoutId. */
|
/** Creates a new layout with the given name and kind; resolves with the new layoutId. */
|
||||||
create: (name: string, kind?: LayoutKind) => Promise<string | null>;
|
create: (
|
||||||
|
name: string,
|
||||||
|
kind?: LayoutKind,
|
||||||
|
pluginOrigin?: PluginLayoutOrigin,
|
||||||
|
) => Promise<string | null>;
|
||||||
/** Renames a layout. */
|
/** Renames a layout. */
|
||||||
rename: (layoutId: string, name: string) => Promise<void>;
|
rename: (layoutId: string, name: string) => Promise<void>;
|
||||||
/** Deletes a layout; refuses if it is the last one. */
|
/** Deletes a layout; refuses if it is the last one. */
|
||||||
@ -144,12 +148,16 @@ export function useLayouts(projectId: string | null): LayoutsViewModel {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const create = useCallback(
|
const create = useCallback(
|
||||||
async (name: string, kind?: LayoutKind): Promise<string | null> => {
|
async (
|
||||||
|
name: string,
|
||||||
|
kind?: LayoutKind,
|
||||||
|
pluginOrigin?: PluginLayoutOrigin,
|
||||||
|
): Promise<string | null> => {
|
||||||
if (!projectId || !gateway) return null;
|
if (!projectId || !gateway) return null;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { layoutId } = await gateway.createLayout(projectId, name, kind);
|
const { layoutId } = await gateway.createLayout(projectId, name, kind, pluginOrigin);
|
||||||
const updated = await gateway.listLayouts(projectId);
|
const updated = await gateway.listLayouts(projectId);
|
||||||
setLayouts(updated.layouts);
|
setLayouts(updated.layouts);
|
||||||
return layoutId;
|
return layoutId;
|
||||||
|
|||||||
@ -6,15 +6,8 @@
|
|||||||
* plugins never appear — carnet §1.3: only `enabled` plugins are loaded, so
|
* plugins never appear — carnet §1.3: only `enabled` plugins are loaded, so
|
||||||
* there is nothing to filter here beyond what the registry already omits).
|
* there is nothing to filter here beyond what the registry already omits).
|
||||||
*
|
*
|
||||||
* Presentational only; not yet mounted in a layout-creation flow. The
|
* Presentational only: the caller owns whether selecting a contribution creates
|
||||||
* existing "layout" concept in this codebase (`LayoutTabs`, `LayoutKind`) is
|
* a named plugin layout, replaces an unavailable cell, or just previews it.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PluginLayoutContribution } from "@/domain";
|
import type { PluginLayoutContribution } from "@/domain";
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import type {
|
|||||||
LayoutList,
|
LayoutList,
|
||||||
LayoutOperation,
|
LayoutOperation,
|
||||||
LayoutTree,
|
LayoutTree,
|
||||||
|
PluginLayoutOrigin,
|
||||||
LocalModelServerConfig,
|
LocalModelServerConfig,
|
||||||
ModelServerCommandPreview,
|
ModelServerCommandPreview,
|
||||||
Memory,
|
Memory,
|
||||||
@ -469,7 +470,12 @@ export interface LayoutGateway {
|
|||||||
/** Lists all named layouts for a project, with the current active id. */
|
/** Lists all named layouts for a project, with the current active id. */
|
||||||
listLayouts(projectId: string): Promise<LayoutList>;
|
listLayouts(projectId: string): Promise<LayoutList>;
|
||||||
/** Creates a new named layout for a project; returns the new layout id. */
|
/** 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. */
|
/** Renames a layout. */
|
||||||
renameLayout(projectId: string, layoutId: string, name: string): Promise<void>;
|
renameLayout(projectId: string, layoutId: string, name: string): Promise<void>;
|
||||||
/** Deletes a layout; returns the new active layout id. */
|
/** Deletes a layout; returns the new active layout id. */
|
||||||
|
|||||||
Reference in New Issue
Block a user