From 551eb09ad2ab5d2f9e7490ca9fa0b1b59b727895 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 4 Aug 2026 00:39:42 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(window):=20support=20des=20fen=C3=AAtr?= =?UTF-8?q?es=20plugin-h=C3=A9berg=C3=A9es=20(backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étend le port/usecases window et layout.customPluginLayout pour ouvrir et piloter des fenêtres OS hébergeant un layout plugin, en réutilisant contributes.layouts plutôt qu'un système de fenêtres parallèle. Câble la commande Tauri et l'état app-tauri correspondants. Cargo test -p domain --test window : 5/5 Cargo test -p application --test window_usecases : 7/7 Cargo test -p infrastructure --test window_state_store : 1/1 Cargo test -p app-tauri view_window_tests : 8/8 Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/commands.rs | 219 +++++++++++++- crates/app-tauri/src/lib.rs | 140 +++++++-- crates/app-tauri/src/state.rs | 31 ++ crates/application/src/layout/management.rs | 41 +-- crates/application/src/lib.rs | 3 +- crates/application/src/plugin/mod.rs | 62 +++- crates/application/src/window/mod.rs | 3 +- crates/application/src/window/usecases.rs | 120 +++++++- crates/application/tests/window_usecases.rs | 279 +++++++++++++++++- crates/backend/src/lib.rs | 55 ++-- crates/domain/src/layout.rs | 18 ++ crates/domain/src/lib.rs | 6 +- crates/domain/tests/window.rs | 1 + .../tests/window_state_store.rs | 1 + 14 files changed, 884 insertions(+), 95 deletions(-) diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 075a264..a122ca8 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -4,7 +4,9 @@ //! [`AppState`], map `Result` to `Result`. No business logic lives here. -use serde::Serialize; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use serde::{Deserialize, Serialize}; use tauri::ipc::Channel; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent}; @@ -18,10 +20,10 @@ use application::{ GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, - LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, - ReadConversationPageInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, - ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReconcileLiveStateInput, - RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, + LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput, + ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput, + ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, + ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, @@ -33,6 +35,7 @@ use application::{ use backend::stream::OutputSink; use domain::ports::ModelServerRuntime; use domain::ports::PtyHandle; +use domain::{PersistedPluginLayoutWindow, PluginId, PluginLayoutType}; use crate::dto::{ model_server_config_domain, parse_agent_id, parse_close_terminal, parse_delete_profile, @@ -2946,6 +2949,37 @@ pub struct ViewWindowSnapshot { pub visible: bool, } +/// Request accepted by `open_plugin_layout_window`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenPluginLayoutWindowRequestDto { + /// Provider plugin id. + pub plugin_id: String, + /// Layout type declared by the provider plugin. + pub layout_type: String, + /// Opaque plugin-owned initial/window state. + #[serde(default)] + pub state: serde_json::Value, +} + +/// Response returned by `open_plugin_layout_window`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenPluginLayoutWindowResponseDto { + /// Stable Tauri window label for this plugin layout. + pub label: String, + /// App URL loaded by the plugin-layout window. + pub url: String, + /// Whether the command reused and focused an existing window. + pub already_open: bool, + /// Runtime provider display name. + pub provider_plugin_display_name: String, + /// Layout display label. + pub layout_label: String, + /// Persistable plugin layout surface. + pub surface: PersistedPluginLayoutWindow, +} + /// Payload emitted on `view-window://lifecycle`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -2958,6 +2992,14 @@ pub struct ViewWindowLifecycleEventDto { pub label: String, } +fn encode_label_part(raw: &str) -> String { + URL_SAFE_NO_PAD.encode(raw.as_bytes()) +} + +fn decode_label_part(raw: &str) -> Option { + String::from_utf8(URL_SAFE_NO_PAD.decode(raw).ok()?).ok() +} + /// Payload emitted on `focused-project://changed`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -3039,6 +3081,38 @@ pub(crate) fn view_window_url(panel: ViewPanel) -> String { format!("index.html?panel={}", panel.as_str()) } +pub(crate) fn plugin_layout_window_label( + plugin_id: &PluginId, + layout_type: &PluginLayoutType, +) -> String { + format!( + "view-plugin-layout-{}.{}", + encode_label_part(plugin_id.as_str()), + encode_label_part(layout_type.as_str()) + ) +} + +pub(crate) fn plugin_layout_window_url(surface: &PersistedPluginLayoutWindow) -> String { + let state = URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&surface.state).unwrap_or_else(|_| b"null".to_vec())); + format!( + "index.html?pluginLayout=1&pluginId={}&layoutType={}&state={}", + encode_label_part(surface.plugin_id.as_str()), + encode_label_part(surface.layout_type.as_str()), + state + ) +} + +pub(crate) fn plugin_layout_window_from_label(label: &str) -> Option { + let rest = label.strip_prefix("view-plugin-layout-")?; + let (plugin_id, layout_type) = rest.split_once('.')?; + Some(PersistedPluginLayoutWindow { + plugin_id: PluginId::new(decode_label_part(plugin_id)?).ok()?, + layout_type: PluginLayoutType::new(decode_label_part(layout_type)?).ok()?, + state: serde_json::Value::Null, + }) +} + fn view_panel_from_window_label(label: &str) -> Option { let rest = label.strip_prefix("view-")?; if let Ok(panel) = ViewPanel::parse(rest) { @@ -3172,6 +3246,83 @@ pub async fn open_view_window( }) } +/// `open_plugin_layout_window` — open or focus a detached OS window for a +/// plugin-contributed layout. +/// +/// The contribution must exist in the plugin's existing `contributes.layouts` +/// manifest surface and the plugin must be runtime-active. +#[tauri::command] +pub async fn open_plugin_layout_window( + app: AppHandle, + input: OpenPluginLayoutWindowRequestDto, + state: State<'_, AppState>, +) -> Result { + let plugin_id = PluginId::new(input.plugin_id).map_err(|e| ErrorDto { + code: "INVALID".to_owned(), + message: e.to_string(), + })?; + let layout_type = PluginLayoutType::new(input.layout_type).map_err(|e| ErrorDto { + code: "INVALID".to_owned(), + message: e.to_string(), + })?; + let out = state + .open_plugin_layout_window + .execute(OpenPluginLayoutWindowInput { + plugin_id, + layout_type, + state: input.state, + }) + .await + .map_err(ErrorDto::from)?; + let label = plugin_layout_window_label(&out.surface.plugin_id, &out.surface.layout_type); + let url = plugin_layout_window_url(&out.surface); + state.set_plugin_window_surface(label.clone(), out.surface.clone()); + + if let Some(window) = app.get_webview_window(&label) { + window.show().map_err(internal_window_error)?; + window.set_focus().map_err(internal_window_error)?; + return Ok(OpenPluginLayoutWindowResponseDto { + label, + url, + already_open: true, + provider_plugin_display_name: out.contribution.provider_plugin_display_name, + layout_label: out.contribution.label, + surface: out.surface, + }); + } + + let window = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.clone().into())) + .title(format!("IdeA - {}", out.contribution.label)) + .inner_size(1120.0, 760.0) + .min_inner_size(720.0, 480.0) + .resizable(true) + .maximizable(true) + .minimizable(true) + .closable(true) + .decorations(true) + .build() + .map_err(internal_window_error)?; + + let event_app = app.clone(); + let event_label = label.clone(); + window.on_window_event(move |event| { + if let WindowEvent::CloseRequested { .. } = event { + if let Some(state) = event_app.try_state::() { + state.clear_plugin_window_surface(&event_label); + } + } + }); + + Ok(OpenPluginLayoutWindowResponseDto { + label, + url, + already_open: false, + provider_plugin_display_name: out.contribution.provider_plugin_display_name, + layout_label: out.contribution.label, + surface: out.surface, + }) +} + /// `close_view_window` — request closing the detached OS window for one panel. /// /// # Errors @@ -3273,6 +3424,64 @@ mod view_window_tests { assert!(!json.contains("projectId"), "json was {json}"); assert!(!json.contains("project_id"), "no snake_case leak: {json}"); } + + #[test] + fn plugin_layout_window_label_round_trips_surface_identity() { + let plugin_id = PluginId::new("dev.idea.android-plugin").unwrap(); + let layout_type = PluginLayoutType::new("idea-android.health").unwrap(); + + let label = plugin_layout_window_label(&plugin_id, &layout_type); + let surface = plugin_layout_window_from_label(&label).unwrap(); + + assert!(label.starts_with("view-plugin-layout-")); + assert_eq!(surface.plugin_id, plugin_id); + assert_eq!(surface.layout_type, layout_type); + assert_eq!(surface.state, serde_json::Value::Null); + } + + #[test] + fn plugin_layout_window_url_carries_surface_contract() { + let surface = PersistedPluginLayoutWindow { + plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(), + layout_type: PluginLayoutType::new("idea-android.health").unwrap(), + state: serde_json::json!({ "deviceId": "pixel-8" }), + }; + + let url = plugin_layout_window_url(&surface); + + assert!(url.starts_with("index.html?pluginLayout=1&")); + assert!(url.contains("pluginId="), "url was {url}"); + assert!(url.contains("layoutType="), "url was {url}"); + assert!(url.contains("state="), "url was {url}"); + } + + #[test] + fn plugin_layout_window_response_payload_is_camel_case() { + let payload = OpenPluginLayoutWindowResponseDto { + label: "view-plugin-layout-x.y".to_owned(), + url: "index.html?pluginLayout=1".to_owned(), + already_open: false, + provider_plugin_display_name: "Android".to_owned(), + layout_label: "Android Health".to_owned(), + surface: PersistedPluginLayoutWindow { + plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(), + layout_type: PluginLayoutType::new("idea-android.health").unwrap(), + state: serde_json::json!({ "deviceId": "pixel-8" }), + }, + }; + + let json = serde_json::to_string(&payload).unwrap(); + assert!(json.contains("\"alreadyOpen\":false"), "json was {json}"); + assert!( + json.contains("\"providerPluginDisplayName\":\"Android\""), + "json was {json}" + ); + assert!( + json.contains("\"layoutLabel\":\"Android Health\""), + "json was {json}" + ); + assert!(!json.contains("provider_plugin_display_name")); + } } /// `move_tab_to_new_window` — detach a tab into a brand-new OS window. diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 72c9f3a..b26786f 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -35,8 +35,8 @@ use std::sync::Arc; use application::{AppError, GetAppExitWorkGuardStateInput, SnapshotOpenWindowsInput}; use domain::{ - PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize, - PersistedWindowState, ProjectId, + PersistedMonitorState, PersistedPluginLayoutWindow, PersistedWindowKind, + PersistedWindowPosition, PersistedWindowSize, PersistedWindowState, ProjectId, }; use tauri::{ Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, @@ -383,6 +383,7 @@ pub fn run() { commands::set_focused_project, commands::get_focused_project, commands::list_open_view_windows, + commands::open_plugin_layout_window, commands::open_view_window, commands::close_view_window, commands::move_tab_to_new_window, @@ -550,12 +551,23 @@ fn snapshot_open_webview_windows(handle: &tauri::AppHandle) -> Vec Option { - let (kind, panel, project_id, url) = persisted_window_identity(label)?; +fn snapshot_webview_window( + handle: &tauri::AppHandle, + label: &str, + window: &WebviewWindow, +) -> Option { + let (kind, panel, project_id, mut plugin_layout, url) = + persisted_window_identity_from_label(label)?; + if kind == PersistedWindowKind::PluginLayout { + plugin_layout = handle + .try_state::() + .and_then(|state| state.get_plugin_window_surface(label)) + .or(plugin_layout); + } let outer_position = window .outer_position() .ok() @@ -586,6 +598,7 @@ fn snapshot_webview_window(label: &str, window: &WebviewWindow) -> Option Option Option<( PersistedWindowKind, Option, Option, + Option, Option, )> { if label == "main" { - return Some((PersistedWindowKind::Main, None, None, None)); + return Some((PersistedWindowKind::Main, None, None, None, None)); + } + + if let Some(surface) = commands::plugin_layout_window_from_label(label) { + let url = commands::plugin_layout_window_url(&surface); + return Some(( + PersistedWindowKind::PluginLayout, + None, + None, + Some(surface), + Some(url), + )); } let panel = persisted_view_identity_from_label(label)?; @@ -614,6 +639,7 @@ fn persisted_window_identity( PersistedWindowKind::View, Some(panel.as_str().to_owned()), None, + None, Some(commands::view_window_url(panel)), )) } @@ -650,6 +676,9 @@ fn restore_open_webview_windows(handle: &tauri::AppHandle) { PersistedWindowKind::View => { restore_view_window(handle, &window_state); } + PersistedWindowKind::PluginLayout => { + restore_plugin_layout_window(handle, &window_state); + } } } } @@ -696,6 +725,51 @@ fn restore_view_window(handle: &tauri::AppHandle, state: &PersistedWindowState) commands::emit_view_window_lifecycle(handle, "opened", panel, &label); } +fn restore_plugin_layout_window(handle: &tauri::AppHandle, state: &PersistedWindowState) { + let Some(surface) = state + .plugin_layout + .clone() + .or_else(|| commands::plugin_layout_window_from_label(&state.label)) + else { + return; + }; + let label = commands::plugin_layout_window_label(&surface.plugin_id, &surface.layout_type); + if handle.get_webview_window(&label).is_some() { + return; + } + let url = commands::plugin_layout_window_url(&surface); + + let Ok(window) = WebviewWindowBuilder::new(handle, &label, WebviewUrl::App(url.into())) + .title(format!("IdeA - {}", surface.layout_type.as_str())) + .inner_size(1120.0, 760.0) + .min_inner_size(720.0, 480.0) + .resizable(true) + .maximizable(true) + .minimizable(true) + .closable(true) + .decorations(true) + .visible(state.visible) + .build() + else { + return; + }; + + if let Some(app_state) = handle.try_state::() { + app_state.set_plugin_window_surface(label.clone(), surface); + } + let event_app = handle.clone(); + let event_label = label.clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { .. } = event { + if let Some(state) = event_app.try_state::() { + state.clear_plugin_window_surface(&event_label); + } + } + }); + + apply_persisted_window_state(handle, &window, state); +} + fn apply_persisted_window_state( handle: &tauri::AppHandle, window: &WebviewWindow, @@ -760,8 +834,8 @@ mod tests { use super::plugin_workspace_invoke_handler; use super::{ apply_main_close_decision, confirm_next_main_window_close, consume_exit_guard_confirmation, - decide_main_close_action, persisted_view_identity_from_label, persisted_window_identity, - should_install_exit_guard, MainCloseAction, + decide_main_close_action, persisted_view_identity_from_label, + persisted_window_identity_from_label, should_install_exit_guard, MainCloseAction, }; use super::{should_close_with_main_window, PersistedWindowKind}; use application::AppExitWorkGuardState; @@ -889,18 +963,22 @@ mod tests { #[test] fn persisted_identity_accepts_main_and_stable_view_labels() { - let (kind, panel, project_id, url) = persisted_window_identity("main").unwrap(); + let (kind, panel, project_id, plugin_layout, url) = + persisted_window_identity_from_label("main").unwrap(); assert_eq!(kind, PersistedWindowKind::Main); assert!(panel.is_none()); assert!(project_id.is_none()); + assert!(plugin_layout.is_none()); assert!(url.is_none()); - let (kind, panel, project_id, url) = - persisted_window_identity("view-tickets-0000000000000000000000000000002a").unwrap(); + let (kind, panel, project_id, plugin_layout, url) = + persisted_window_identity_from_label("view-tickets-0000000000000000000000000000002a") + .unwrap(); assert_eq!(kind, PersistedWindowKind::View); assert_eq!(panel.as_deref(), Some("tickets")); assert!(project_id.is_none()); + assert!(plugin_layout.is_none()); assert_eq!(url.as_deref(), Some("index.html?panel=tickets")); assert_eq!( persisted_view_identity_from_label("view-tickets-0000000000000000000000000000002a") @@ -912,21 +990,45 @@ mod tests { #[test] fn persisted_identity_accepts_new_panel_only_view_labels() { - let (kind, panel, project_id, url) = persisted_window_identity("view-agents").unwrap(); + let (kind, panel, project_id, plugin_layout, url) = + persisted_window_identity_from_label("view-agents").unwrap(); assert_eq!(kind, PersistedWindowKind::View); assert_eq!(panel.as_deref(), Some("agents")); assert!(project_id.is_none()); + assert!(plugin_layout.is_none()); assert_eq!(url.as_deref(), Some("index.html?panel=agents")); } + #[test] + fn persisted_identity_accepts_plugin_layout_view_labels() { + let plugin_id = domain::PluginId::new("dev.idea.android-plugin").unwrap(); + let layout_type = domain::PluginLayoutType::new("idea-android.health").unwrap(); + let label = crate::commands::plugin_layout_window_label(&plugin_id, &layout_type); + + let (kind, panel, project_id, plugin_layout, url) = + persisted_window_identity_from_label(&label).unwrap(); + + assert_eq!(kind, PersistedWindowKind::PluginLayout); + assert!(panel.is_none()); + assert!(project_id.is_none()); + let plugin_layout = plugin_layout.unwrap(); + assert_eq!(plugin_layout.plugin_id, plugin_id); + assert_eq!(plugin_layout.layout_type, layout_type); + assert!(url + .as_deref() + .unwrap() + .starts_with("index.html?pluginLayout=1&")); + } + #[test] fn persisted_identity_filters_unknown_or_headless_labels() { - assert!(persisted_window_identity("mcp-server").is_none()); - assert!(persisted_window_identity("settings").is_none()); - assert!( - persisted_window_identity("view-unknown-0000000000000000000000000000002a").is_none() - ); - assert!(persisted_window_identity("view-tickets-not-a-project").is_none()); + assert!(persisted_window_identity_from_label("mcp-server").is_none()); + assert!(persisted_window_identity_from_label("settings").is_none()); + assert!(persisted_window_identity_from_label( + "view-unknown-0000000000000000000000000000002a" + ) + .is_none()); + assert!(persisted_window_identity_from_label("view-tickets-not-a-project").is_none()); } #[test] diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 7c3c6b7..879a2e1 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -5,11 +5,13 @@ //! OS-window focus state, then dereferences to the shared core so existing //! commands keep their field/method access unchanged. +use std::collections::HashMap; use std::ops::Deref; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use backend::BackendCore; +use domain::PersistedPluginLayoutWindow; use infrastructure::{TemplateToolProvider, TicketToolProvider}; use serde::{Deserialize, Serialize}; @@ -44,6 +46,8 @@ pub struct AppState { pub embedded_server: Arc, /// Project currently focused by the main window; panel-only windows follow it. focused_project: Mutex>, + /// Detached plugin-layout surfaces keyed by Tauri window label. + plugin_window_surfaces: Mutex>, } impl AppState { @@ -96,6 +100,7 @@ impl AppState { resource_dir, )), focused_project: Mutex::new(None), + plugin_window_surfaces: Mutex::new(HashMap::new()), } } @@ -122,6 +127,32 @@ impl AppState { .expect("focused project mutex poisoned") .clone() } + + /// Records the plugin layout surface hosted by a detached window. + pub fn set_plugin_window_surface(&self, label: String, surface: PersistedPluginLayoutWindow) { + self.plugin_window_surfaces + .lock() + .expect("plugin window surface mutex poisoned") + .insert(label, surface); + } + + /// Reads the plugin layout surface hosted by a detached window. + #[must_use] + pub fn get_plugin_window_surface(&self, label: &str) -> Option { + self.plugin_window_surfaces + .lock() + .expect("plugin window surface mutex poisoned") + .get(label) + .cloned() + } + + /// Removes a detached plugin layout surface. + pub fn clear_plugin_window_surface(&self, label: &str) { + self.plugin_window_surfaces + .lock() + .expect("plugin window surface mutex poisoned") + .remove(label); + } } impl Deref for AppState { diff --git a/crates/application/src/layout/management.rs b/crates/application/src/layout/management.rs index 080cd0e..ba553bd 100644 --- a/crates/application/src/layout/management.rs +++ b/crates/application/src/layout/management.rs @@ -15,7 +15,7 @@ use crate::error::AppError; use super::store::{ default_tree, persist_doc, plugin_layout_tree, resolve_doc, LayoutKind, NamedLayout, }; -use crate::plugin::runtime_plugin_from_entry; +use crate::plugin::ensure_runtime_plugin_layout_contribution; /// Lightweight descriptor of a named layout (no tree), for the layouts tab bar. #[derive(Debug, Clone, PartialEq, Eq)] @@ -183,36 +183,15 @@ impl CreateLayout { &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() - ))) + ensure_runtime_plugin_layout_contribution( + self.packages.as_ref(), + self.registry.as_ref(), + self.validator.as_ref(), + &origin.plugin_id, + &origin.layout_type, + ) + .await + .map(|_| ()) } } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 0d1e5f2..0f3f09f 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -222,7 +222,8 @@ pub use ticket_assistant::{ OpenTicketAssistantOutput, }; pub use window::{ - MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, RestoreOpenWindows, + MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, OpenPluginLayoutWindow, + OpenPluginLayoutWindowInput, OpenPluginLayoutWindowOutput, RestoreOpenWindows, RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput, }; pub use workstate::{ diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index 2de5965..d0f3548 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -14,8 +14,9 @@ use domain::ports::{ use domain::{ AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash, DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, PluginInstallSource, - PluginLifecycleState, PluginManifest, PluginMcpServerSpec, PluginRegistryEntry, - PluginTrustLevel, Project, ProjectId, ProjectPath, RemovalOutcome, StagedPluginPackage, TaskId, + PluginLayoutType, PluginLifecycleState, PluginManifest, PluginMcpServerSpec, + PluginRegistryEntry, PluginTrustLevel, Project, ProjectId, ProjectPath, RemovalOutcome, + StagedPluginPackage, TaskId, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -159,6 +160,20 @@ pub struct PluginRuntimePlugin { pub contributes: PluginContributionSet, } +/// Runtime-validated plugin layout contribution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginRuntimeLayoutContribution { + /// Plugin id. + pub plugin_id: String, + /// Provider display name. + pub provider_plugin_display_name: String, + /// Layout type. + pub layout_type: String, + /// Layout display label. + pub label: String, +} + /// Input for plugin-owned storage reads/deletes. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] @@ -2801,6 +2816,49 @@ pub(crate) async fn runtime_plugin_from_entry( }) } +/// Validates that a plugin layout contribution exists and is active at runtime. +/// +/// This is the canonical application-layer rule for every surface that wants to +/// host a plugin layout, whether inside a named project layout or a detached OS +/// window. +pub async fn ensure_runtime_plugin_layout_contribution( + packages: &dyn PluginPackageStore, + registry: &dyn PluginRegistryStore, + validator: &dyn PluginManifestValidator, + plugin_id: &PluginId, + layout_type: &PluginLayoutType, +) -> Result { + let registry = registry.load_registry().await.map_err(map_registry)?; + for entry in registry.plugins { + if entry.id != *plugin_id || !entry.lifecycle_state.is_runtime_active() { + continue; + } + let runtime = runtime_plugin_from_entry(packages, validator, entry).await?; + if let Some(layout) = runtime + .contributes + .layouts + .iter() + .find(|layout| layout.layout_type == *layout_type) + { + return Ok(PluginRuntimeLayoutContribution { + plugin_id: runtime.id, + provider_plugin_display_name: runtime.display_name, + layout_type: layout.layout_type.as_str().to_owned(), + label: layout.label.clone(), + }); + } + return Err(AppError::Invalid(format!( + "plugin `{}` does not contribute layout `{}`", + plugin_id.as_str(), + layout_type.as_str() + ))); + } + Err(AppError::Invalid(format!( + "plugin `{}` is not active at runtime", + plugin_id.as_str() + ))) +} + fn checked_plugin_asset_url( packages: &dyn PluginPackageStore, plugin_id: &PluginId, diff --git a/crates/application/src/window/mod.rs b/crates/application/src/window/mod.rs index 9902118..d987188 100644 --- a/crates/application/src/window/mod.rs +++ b/crates/application/src/window/mod.rs @@ -4,6 +4,7 @@ mod usecases; pub use usecases::{ - MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, RestoreOpenWindows, + MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, OpenPluginLayoutWindow, + OpenPluginLayoutWindowInput, OpenPluginLayoutWindowOutput, RestoreOpenWindows, RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput, }; diff --git a/crates/application/src/window/usecases.rs b/crates/application/src/window/usecases.rs index 2988e29..c7e6d15 100644 --- a/crates/application/src/window/usecases.rs +++ b/crates/application/src/window/usecases.rs @@ -9,10 +9,18 @@ use std::sync::Arc; use domain::ids::{TabId, WindowId}; use std::collections::HashSet; -use domain::layout::{PersistedWindowKind, PersistedWindowState, WindowStateSnapshot, Workspace}; -use domain::ports::{IdGenerator, ProjectStore, WindowStateStore}; +use domain::layout::{ + PersistedPluginLayoutWindow, PersistedWindowKind, PersistedWindowState, WindowStateSnapshot, + Workspace, +}; +use domain::ports::{ + IdGenerator, PluginManifestValidator, PluginPackageStore, PluginRegistryStore, ProjectStore, + WindowStateStore, +}; +use domain::{PluginId, PluginLayoutType}; use crate::error::AppError; +use crate::plugin::{ensure_runtime_plugin_layout_contribution, PluginRuntimeLayoutContribution}; /// Input for [`MoveTabToNewWindow::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] @@ -116,15 +124,27 @@ pub struct RestoreOpenWindowsOutput { pub struct RestoreOpenWindows { windows: Arc, _projects: Arc, + packages: Arc, + registry: Arc, + validator: Arc, } impl RestoreOpenWindows { /// Builds the use case from its ports. #[must_use] - pub fn new(windows: Arc, projects: Arc) -> Self { + pub fn new( + windows: Arc, + projects: Arc, + packages: Arc, + registry: Arc, + validator: Arc, + ) -> Self { Self { windows, _projects: projects, + packages, + registry, + validator, } } @@ -154,9 +174,103 @@ impl RestoreOpenWindows { } windows.push(window); } + PersistedWindowKind::PluginLayout => { + let Some(surface) = &window.plugin_layout else { + continue; + }; + if self.validate_plugin_layout_surface(surface).await.is_ok() { + windows.push(window); + } + } } } Ok(RestoreOpenWindowsOutput { windows }) } + + async fn validate_plugin_layout_surface( + &self, + surface: &PersistedPluginLayoutWindow, + ) -> Result<(), AppError> { + ensure_runtime_plugin_layout_contribution( + self.packages.as_ref(), + self.registry.as_ref(), + self.validator.as_ref(), + &surface.plugin_id, + &surface.layout_type, + ) + .await + .map(|_| ()) + } +} + +/// Input for [`OpenPluginLayoutWindow::execute`]. +#[derive(Debug, Clone, PartialEq)] +pub struct OpenPluginLayoutWindowInput { + /// Provider plugin id. + pub plugin_id: PluginId, + /// Layout type declared by the provider plugin. + pub layout_type: PluginLayoutType, + /// Opaque plugin-owned initial/window state. + pub state: serde_json::Value, +} + +/// Output of [`OpenPluginLayoutWindow::execute`]. +#[derive(Debug, Clone, PartialEq)] +pub struct OpenPluginLayoutWindowOutput { + /// Runtime contribution that was validated. + pub contribution: PluginRuntimeLayoutContribution, + /// Persistable plugin layout surface. + pub surface: PersistedPluginLayoutWindow, +} + +/// Validates a plugin layout contribution before a detached OS window hosts it. +pub struct OpenPluginLayoutWindow { + packages: Arc, + registry: Arc, + validator: Arc, +} + +impl OpenPluginLayoutWindow { + /// Builds the use case from plugin runtime ports. + #[must_use] + pub fn new( + packages: Arc, + registry: Arc, + validator: Arc, + ) -> Self { + Self { + packages, + registry, + validator, + } + } + + /// Executes the validation. + /// + /// # Errors + /// [`AppError::Invalid`] when the plugin is inactive or does not declare the + /// requested layout contribution; other errors bubble from plugin loading. + pub async fn execute( + &self, + input: OpenPluginLayoutWindowInput, + ) -> Result { + let contribution = ensure_runtime_plugin_layout_contribution( + self.packages.as_ref(), + self.registry.as_ref(), + self.validator.as_ref(), + &input.plugin_id, + &input.layout_type, + ) + .await?; + let surface = PersistedPluginLayoutWindow { + plugin_id: input.plugin_id, + layout_type: input.layout_type, + state: input.state, + }; + Ok(OpenPluginLayoutWindowOutput { + contribution, + surface, + }) + } } diff --git a/crates/application/tests/window_usecases.rs b/crates/application/tests/window_usecases.rs index 57ff17a..631e7c0 100644 --- a/crates/application/tests/window_usecases.rs +++ b/crates/application/tests/window_usecases.rs @@ -6,17 +6,25 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::ids::{ProjectId, TabId, WindowId}; use domain::layout::{ - LayoutNode, LayoutTree, LeafCell, PersistedWindowKind, PersistedWindowState, Tab, Window, - WindowStateSnapshot, Workspace, + LayoutNode, LayoutTree, LeafCell, PersistedPluginLayoutWindow, PersistedWindowKind, + PersistedWindowState, Tab, Window, WindowStateSnapshot, Workspace, +}; +use domain::ports::{ + IdGenerator, PluginManifestBytes, PluginPackageStore, PluginRegistryError, PluginRegistryStore, + PluginStoreError, ProjectStore, StoreError, WindowStateStore, }; -use domain::ports::{IdGenerator, ProjectStore, StoreError, WindowStateStore}; use domain::project::{Project, ProjectPath}; -use domain::{NodeId, RemoteRef}; +use domain::{ + ContentHash, LocalPath, NodeId, PluginBundleUrl, PluginId, PluginInstallSource, + PluginLayoutType, PluginLifecycleState, PluginPackageRef, PluginRegistry, PluginRegistryEntry, + RelativePath, RemoteRef, RemovalOutcome, StagedPluginPackage, +}; use uuid::Uuid; +use application::plugin::JsonPluginManifestValidator; use application::{ - MoveTabToNewWindow, MoveTabToNewWindowInput, RestoreOpenWindows, SnapshotOpenWindows, - SnapshotOpenWindowsInput, + MoveTabToNewWindow, MoveTabToNewWindowInput, OpenPluginLayoutWindow, + OpenPluginLayoutWindowInput, RestoreOpenWindows, SnapshotOpenWindows, SnapshotOpenWindowsInput, }; /// A `ProjectStore` fake that only implements the workspace persistence the use @@ -68,6 +76,134 @@ impl WindowStateStore for FakeWindowStateStore { } } +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(()) + } +} + #[derive(Clone)] struct FakeProjectRegistry { existing: Arc>>, @@ -148,6 +284,7 @@ fn persisted_main() -> PersistedWindowState { kind: PersistedWindowKind::Main, panel: None, project_id: None, + plugin_layout: None, url: None, visible: true, maximized: false, @@ -165,6 +302,7 @@ fn persisted_view(label: &str, project_id: ProjectId) -> PersistedWindowState { kind: PersistedWindowKind::View, panel: Some("tickets".to_owned()), project_id: Some(project_id), + plugin_layout: None, url: Some(format!("index.html?panel=tickets&project={project_id}")), visible: true, maximized: false, @@ -176,6 +314,52 @@ fn persisted_view(label: &str, project_id: ProjectId) -> PersistedWindowState { } } +fn persisted_plugin_layout( + label: &str, + plugin_id: &str, + layout_type: &str, +) -> PersistedWindowState { + PersistedWindowState { + label: label.to_owned(), + kind: PersistedWindowKind::PluginLayout, + panel: None, + project_id: None, + plugin_layout: Some(PersistedPluginLayoutWindow { + plugin_id: PluginId::new(plugin_id).unwrap(), + layout_type: PluginLayoutType::new(layout_type).unwrap(), + state: serde_json::json!({ "from": "test" }), + }), + url: Some(format!( + "index.html?pluginLayout=1&pluginId={plugin_id}&layoutType={layout_type}" + )), + visible: true, + maximized: false, + fullscreen: false, + outer_position: None, + outer_size: None, + monitor: None, + last_focused_at: None, + } +} + +fn restore_uc(store: FakeWindowStateStore, registry: FakePluginRegistry) -> RestoreOpenWindows { + RestoreOpenWindows::new( + Arc::new(store), + Arc::new(FakeProjectRegistry::new(vec![])), + Arc::new(FakePluginPackages::new(plugin_manifest())), + Arc::new(registry), + Arc::new(JsonPluginManifestValidator::new("0.3.0")), + ) +} + +fn open_plugin_layout_uc(registry: FakePluginRegistry) -> OpenPluginLayoutWindow { + OpenPluginLayoutWindow::new( + Arc::new(FakePluginPackages::new(plugin_manifest())), + Arc::new(registry), + Arc::new(JsonPluginManifestValidator::new("0.3.0")), + ) +} + #[tokio::test] async fn detaches_tab_and_persists_workspace() { let store = seeded(); @@ -250,8 +434,10 @@ async fn restore_open_windows_keeps_panel_views_without_reopening_projects() { url_missing, no_panel, ])))); - let projects = FakeProjectRegistry::new(vec![]); - let uc = RestoreOpenWindows::new(Arc::new(store), Arc::new(projects)); + let uc = restore_uc( + store, + FakePluginRegistry::with_state(PluginLifecycleState::Enabled), + ); let out = uc.execute().await.unwrap(); @@ -268,3 +454,80 @@ async fn restore_open_windows_keeps_panel_views_without_reopening_projects() { ] ); } + +#[tokio::test] +async fn open_plugin_layout_window_validates_active_runtime_contribution() { + let uc = open_plugin_layout_uc(FakePluginRegistry::with_state( + PluginLifecycleState::Enabled, + )); + + let out = uc + .execute(OpenPluginLayoutWindowInput { + plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(), + layout_type: PluginLayoutType::new("idea-android.health").unwrap(), + state: serde_json::json!({ "deviceId": "pixel-8" }), + }) + .await + .unwrap(); + + assert_eq!(out.contribution.provider_plugin_display_name, "Android"); + assert_eq!(out.contribution.label, "Android Health"); + assert_eq!(out.surface.plugin_id.as_str(), "dev.idea.android-plugin"); + assert_eq!(out.surface.layout_type.as_str(), "idea-android.health"); + assert_eq!(out.surface.state["deviceId"], "pixel-8"); +} + +#[tokio::test] +async fn open_plugin_layout_window_rejects_inactive_plugin() { + let uc = open_plugin_layout_uc(FakePluginRegistry::with_state( + PluginLifecycleState::Disabled, + )); + + let err = uc + .execute(OpenPluginLayoutWindowInput { + plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(), + layout_type: PluginLayoutType::new("idea-android.health").unwrap(), + state: serde_json::Value::Null, + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID", "got {err:?}"); +} + +#[tokio::test] +async fn restore_open_windows_keeps_valid_plugin_layout_windows_only() { + let mut missing_surface = persisted_plugin_layout( + "view-plugin-layout-missing", + "dev.idea.android-plugin", + "idea-android.health", + ); + missing_surface.plugin_layout = None; + let store = FakeWindowStateStore(Arc::new(Mutex::new(WindowStateSnapshot::new(vec![ + persisted_plugin_layout( + "view-plugin-layout-valid", + "dev.idea.android-plugin", + "idea-android.health", + ), + persisted_plugin_layout( + "view-plugin-layout-unknown-layout", + "dev.idea.android-plugin", + "idea-android.missing", + ), + missing_surface, + ])))); + let uc = restore_uc( + store, + FakePluginRegistry::with_state(PluginLifecycleState::Enabled), + ); + + let out = uc.execute().await.unwrap(); + + assert_eq!( + out.windows + .iter() + .map(|w| w.label.as_str()) + .collect::>(), + vec!["view-plugin-layout-valid"] + ); +} diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 30e51e4..2d62081 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -34,28 +34,28 @@ use application::{ ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, - MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, - OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, - PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess, - PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure, - ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment, - ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, - ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, - ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider, - ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, - ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions, - ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, - RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, - SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout, - SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, - StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, - SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, - UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort, - UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, - UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, - UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions, - UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, - AGENT_MEMORY_RECALL_BUDGET, + MutateLayout, OnnxModelView, OpenPluginLayoutWindow, OpenProject, OpenTerminal, + OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, + PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments, + PluginEventSubscriptions, PluginStorageAccess, PluginToolchainDiagnostics, + PluginWorkspaceAccess, ProposeContext, QueryProjectStructure, ReadAgentContext, ReadContext, + ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, + ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, + ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, + RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, + ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions, + ResolveAgentSystemPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, + ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, + SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile, + SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, + SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, + StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, + UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, + UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, + UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, + UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, + UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, + WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -927,6 +927,8 @@ pub struct BackendCore { pub delete_layout: Arc, /// Set the active named layout (#4). pub set_active_layout: Arc, + /// Validate/open a plugin layout in a detached OS window. + pub open_plugin_layout_window: Arc, /// Freeze `agent_was_running` on every agent leaf before a PTY kill (T5). pub snapshot_running_agents: Arc, /// Dé-doublonne, à l'ouverture, les feuilles d'agent en double d'un même @@ -2892,6 +2894,14 @@ impl BackendCore { let restore_open_windows = Arc::new(RestoreOpenWindows::new( Arc::clone(&window_state_port), Arc::clone(&store_port), + Arc::clone(&plugin_package_store), + Arc::clone(&plugin_registry_store), + Arc::clone(&plugin_manifest_validator), + )); + let open_plugin_layout_window = Arc::new(OpenPluginLayoutWindow::new( + Arc::clone(&plugin_package_store), + Arc::clone(&plugin_registry_store), + Arc::clone(&plugin_manifest_validator), )); Self { @@ -2923,6 +2933,7 @@ impl BackendCore { rename_layout, delete_layout, set_active_layout, + open_plugin_layout_window, snapshot_running_agents, reconcile_layouts, reconcile_live_state, diff --git a/crates/domain/src/layout.rs b/crates/domain/src/layout.rs index 9e5b9f5..13bf8fe 100644 --- a/crates/domain/src/layout.rs +++ b/crates/domain/src/layout.rs @@ -1095,6 +1095,21 @@ pub enum PersistedWindowKind { Main, /// A detached project view window. View, + /// A detached window hosting a plugin-contributed layout. + PluginLayout, +} + +/// Persisted plugin layout surface hosted by a detached OS window. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PersistedPluginLayoutWindow { + /// Provider plugin id. + pub plugin_id: PluginId, + /// Layout type declared by the provider plugin. + pub layout_type: PluginLayoutType, + /// Opaque plugin-owned initial/window state. + #[serde(default)] + pub state: serde_json::Value, } /// Physical top-left position of a window or monitor. @@ -1149,6 +1164,9 @@ pub struct PersistedWindowState { /// Legacy detached view project id. Ignored by panel-only restore. #[serde(default, skip_serializing_if = "Option::is_none")] pub project_id: Option, + /// Plugin layout surface. Present only for [`PersistedWindowKind::PluginLayout`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_layout: Option, /// App URL loaded in the window. #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 0f22fff..84453ed 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -194,9 +194,9 @@ pub use git::GitRepository; pub use layout::{ CustomPluginLayoutCell, Direction, GridCell, GridContainer, LayoutError, LayoutNode, - LayoutTree, LeafCell, PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, - PersistedWindowSize, PersistedWindowState, SplitContainer, Tab, WeightedChild, Window, - WindowStateSnapshot, Workspace, WINDOW_STATE_SNAPSHOT_VERSION, + LayoutTree, LeafCell, PersistedMonitorState, PersistedPluginLayoutWindow, PersistedWindowKind, + PersistedWindowPosition, PersistedWindowSize, PersistedWindowState, SplitContainer, Tab, + WeightedChild, Window, WindowStateSnapshot, Workspace, WINDOW_STATE_SNAPSHOT_VERSION, }; pub use events::{DomainEvent, OrchestrationSource}; diff --git a/crates/domain/tests/window.rs b/crates/domain/tests/window.rs index 1ffc02b..0a408c8 100644 --- a/crates/domain/tests/window.rs +++ b/crates/domain/tests/window.rs @@ -103,6 +103,7 @@ fn window_state_snapshot_round_trips_with_camel_case_schema() { kind: PersistedWindowKind::View, panel: Some("tickets".to_owned()), project_id: Some(ProjectId::from_uuid(Uuid::from_u128(42))), + plugin_layout: None, url: Some( "index.html?panel=tickets&project=00000000-0000-0000-0000-00000000002a".to_owned(), ), diff --git a/crates/infrastructure/tests/window_state_store.rs b/crates/infrastructure/tests/window_state_store.rs index 807d3dc..7292d65 100644 --- a/crates/infrastructure/tests/window_state_store.rs +++ b/crates/infrastructure/tests/window_state_store.rs @@ -44,6 +44,7 @@ async fn window_state_save_then_load_roundtrips() { kind: PersistedWindowKind::Main, panel: None, project_id: None, + plugin_layout: None, url: None, visible: true, maximized: true, From d8df466fba4d390f69743af819ad7786df17b315 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 4 Aug 2026 00:39:50 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(frontend):=20host=20les=20fen=C3=AAtre?= =?UTF-8?q?s=20plugin=20et=20l'API=20publique=20windows.open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la vue window host qui charge le layout plugin dans une fenêtre OS détachée, expose react/react-dom/jsx-runtime hébergés sous frontend/public/plugin-host pour que le runtime SDK y résolve React, et câble services.windows.open() côté runtime plugin (loader/services/registry). npm test -- --run src/adapters/window.test.ts src/app/ViewWindow.test.tsx src/plugins/runtime/loader.test.ts src/plugins/runtime/services.test.ts : 39/39 npm run typecheck : OK npm run build : OK Co-Authored-By: Claude Opus 4.8 --- frontend/index.html | 11 ++ .../public/plugin-host/react-dom-client.js | 5 + frontend/public/plugin-host/react-dom.js | 16 ++ .../plugin-host/react-jsx-dev-runtime.js | 4 + .../public/plugin-host/react-jsx-runtime.js | 5 + frontend/public/plugin-host/react.js | 35 ++++ frontend/src/adapters/http/unsupported.ts | 3 + frontend/src/adapters/mock/index.ts | 24 +++ frontend/src/adapters/window.test.ts | 35 ++++ frontend/src/adapters/window.ts | 10 ++ frontend/src/app/ViewWindow.test.tsx | 111 ++++++++++++- frontend/src/app/ViewWindow.tsx | 153 +++++++++++++++++- frontend/src/app/main.tsx | 6 +- .../plugins/PluginRuntimeProvider.tsx | 1 + frontend/src/plugins/runtime/loader.test.ts | 90 +++++++++++ frontend/src/plugins/runtime/loader.ts | 36 ++++- frontend/src/plugins/runtime/registry.ts | 2 + frontend/src/plugins/runtime/services.test.ts | 53 ++++++ frontend/src/plugins/runtime/services.ts | 60 ++++++- frontend/src/ports/index.ts | 28 ++++ 20 files changed, 678 insertions(+), 10 deletions(-) create mode 100644 frontend/public/plugin-host/react-dom-client.js create mode 100644 frontend/public/plugin-host/react-dom.js create mode 100644 frontend/public/plugin-host/react-jsx-dev-runtime.js create mode 100644 frontend/public/plugin-host/react-jsx-runtime.js create mode 100644 frontend/public/plugin-host/react.js diff --git a/frontend/index.html b/frontend/index.html index 61688b2..a6c9af1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -11,6 +11,17 @@ content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> IdeA +
diff --git a/frontend/public/plugin-host/react-dom-client.js b/frontend/public/plugin-host/react-dom-client.js new file mode 100644 index 0000000..bb3b06a --- /dev/null +++ b/frontend/public/plugin-host/react-dom-client.js @@ -0,0 +1,5 @@ +const ReactDomClient = globalThis.__IDEA_PLUGIN_HOST_REACT_DOM_CLIENT__; + +export const createRoot = ReactDomClient.createRoot; +export const hydrateRoot = ReactDomClient.hydrateRoot; +export const version = ReactDomClient.version; diff --git a/frontend/public/plugin-host/react-dom.js b/frontend/public/plugin-host/react-dom.js new file mode 100644 index 0000000..137e55f --- /dev/null +++ b/frontend/public/plugin-host/react-dom.js @@ -0,0 +1,16 @@ +const ReactDom = globalThis.__IDEA_PLUGIN_HOST_REACT_DOM__; + +export default ReactDom; +export const createPortal = ReactDom.createPortal; +export const flushSync = ReactDom.flushSync; +export const preconnect = ReactDom.preconnect; +export const prefetchDNS = ReactDom.prefetchDNS; +export const preinit = ReactDom.preinit; +export const preinitModule = ReactDom.preinitModule; +export const preload = ReactDom.preload; +export const preloadModule = ReactDom.preloadModule; +export const requestFormReset = ReactDom.requestFormReset; +export const unstable_batchedUpdates = ReactDom.unstable_batchedUpdates; +export const useFormState = ReactDom.useFormState; +export const useFormStatus = ReactDom.useFormStatus; +export const version = ReactDom.version; diff --git a/frontend/public/plugin-host/react-jsx-dev-runtime.js b/frontend/public/plugin-host/react-jsx-dev-runtime.js new file mode 100644 index 0000000..9432770 --- /dev/null +++ b/frontend/public/plugin-host/react-jsx-dev-runtime.js @@ -0,0 +1,4 @@ +const Runtime = globalThis.__IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__; + +export const Fragment = Runtime.Fragment; +export const jsxDEV = Runtime.jsxDEV; diff --git a/frontend/public/plugin-host/react-jsx-runtime.js b/frontend/public/plugin-host/react-jsx-runtime.js new file mode 100644 index 0000000..4ee696b --- /dev/null +++ b/frontend/public/plugin-host/react-jsx-runtime.js @@ -0,0 +1,5 @@ +const Runtime = globalThis.__IDEA_PLUGIN_HOST_REACT_JSX_RUNTIME__; + +export const Fragment = Runtime.Fragment; +export const jsx = Runtime.jsx; +export const jsxs = Runtime.jsxs; diff --git a/frontend/public/plugin-host/react.js b/frontend/public/plugin-host/react.js new file mode 100644 index 0000000..ca49583 --- /dev/null +++ b/frontend/public/plugin-host/react.js @@ -0,0 +1,35 @@ +const React = globalThis.__IDEA_PLUGIN_HOST_REACT__; + +export default React; +export const Children = React.Children; +export const Component = React.Component; +export const Fragment = React.Fragment; +export const Profiler = React.Profiler; +export const PureComponent = React.PureComponent; +export const StrictMode = React.StrictMode; +export const Suspense = React.Suspense; +export const cloneElement = React.cloneElement; +export const createContext = React.createContext; +export const createElement = React.createElement; +export const createRef = React.createRef; +export const forwardRef = React.forwardRef; +export const isValidElement = React.isValidElement; +export const lazy = React.lazy; +export const memo = React.memo; +export const startTransition = React.startTransition; +export const useCallback = React.useCallback; +export const useContext = React.useContext; +export const useDebugValue = React.useDebugValue; +export const useDeferredValue = React.useDeferredValue; +export const useEffect = React.useEffect; +export const useId = React.useId; +export const useImperativeHandle = React.useImperativeHandle; +export const useInsertionEffect = React.useInsertionEffect; +export const useLayoutEffect = React.useLayoutEffect; +export const useMemo = React.useMemo; +export const useReducer = React.useReducer; +export const useRef = React.useRef; +export const useState = React.useState; +export const useSyncExternalStore = React.useSyncExternalStore; +export const useTransition = React.useTransition; +export const version = React.version; diff --git a/frontend/src/adapters/http/unsupported.ts b/frontend/src/adapters/http/unsupported.ts index 096944f..7142722 100644 --- a/frontend/src/adapters/http/unsupported.ts +++ b/frontend/src/adapters/http/unsupported.ts @@ -79,6 +79,9 @@ export class WebWindowGateway implements WindowGateway { openViewWindow(): Promise { return unsupportedOnWeb("Detaching a panel into an OS window"); } + openPluginLayoutWindow(): Promise { + return unsupportedOnWeb("Opening a plugin layout in an OS window"); + } closeViewWindow(): Promise { return unsupportedOnWeb("Closing a detached OS window"); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 9e9fe0a..f3e7058 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -162,6 +162,8 @@ import type { ViewWindowClosed, ViewWindowSnapshot, WindowGateway, + PluginLayoutWindowOpenInput, + PluginLayoutWindowOpenResult, FocusedProject, FocusedProjectGateway, WorkStateGateway, @@ -1284,12 +1286,34 @@ class MockRemoteGateway implements RemoteGateway { export class MockWindowGateway implements WindowGateway { /** Currently-open detached windows, keyed by `panel`. */ readonly open = new Set(); + readonly pluginLayoutWindows = new Map(); private readonly listeners = new Set<(e: ViewWindowClosed) => void>(); async openViewWindow(panel: string): Promise { this.open.add(panel); } + async openPluginLayoutWindow( + input: PluginLayoutWindowOpenInput, + ): Promise { + const label = `view-plugin-layout-${input.pluginId}.${input.layoutType}`; + const alreadyOpen = this.pluginLayoutWindows.has(label); + const result: PluginLayoutWindowOpenResult = { + label, + url: `index.html?pluginLayout=1&pluginId=${encodeURIComponent(input.pluginId)}&layoutType=${encodeURIComponent(input.layoutType)}`, + alreadyOpen, + providerPluginDisplayName: input.pluginId, + layoutLabel: input.layoutType, + surface: { + pluginId: input.pluginId, + layoutType: input.layoutType, + state: input.state ?? null, + }, + }; + this.pluginLayoutWindows.set(label, result); + return result; + } + async closeViewWindow(panel: string): Promise { if (this.open.delete(panel)) { this.emit({ panel }); diff --git a/frontend/src/adapters/window.test.ts b/frontend/src/adapters/window.test.ts index 46d9a76..61f65a7 100644 --- a/frontend/src/adapters/window.test.ts +++ b/frontend/src/adapters/window.test.ts @@ -29,6 +29,41 @@ describe("TauriWindowGateway (#23)", () => { }); }); + it("opens plugin layout windows through the backend command", async () => { + invoke.mockResolvedValueOnce({ + label: "view-plugin-layout", + url: "index.html?pluginLayout=1", + alreadyOpen: false, + providerPluginDisplayName: "Plugin", + layoutLabel: "Dashboard", + surface: { + pluginId: "dev.acme.plugin", + layoutType: "dev.acme.plugin.dashboard", + state: { tab: "overview" }, + }, + }); + const gw = new TauriWindowGateway(); + + await expect( + gw.openPluginLayoutWindow({ + pluginId: "dev.acme.plugin", + layoutType: "dev.acme.plugin.dashboard", + state: { tab: "overview" }, + }), + ).resolves.toMatchObject({ + label: "view-plugin-layout", + surface: { layoutType: "dev.acme.plugin.dashboard" }, + }); + + expect(invoke).toHaveBeenCalledWith("open_plugin_layout_window", { + input: { + pluginId: "dev.acme.plugin", + layoutType: "dev.acme.plugin.dashboard", + state: { tab: "overview" }, + }, + }); + }); + it("onViewWindowClosed listens on the lifecycle channel and forwards only closes", async () => { // Capture the raw Tauri listener so we can drive lifecycle events at will. let raw: ((e: { payload: unknown }) => void) | undefined; diff --git a/frontend/src/adapters/window.ts b/frontend/src/adapters/window.ts index fcada39..739d77e 100644 --- a/frontend/src/adapters/window.ts +++ b/frontend/src/adapters/window.ts @@ -15,6 +15,8 @@ import { listen } from "@tauri-apps/api/event"; import type { Unsubscribe } from "@/domain"; import type { + PluginLayoutWindowOpenInput, + PluginLayoutWindowOpenResult, ViewWindowClosed, ViewWindowSnapshot, WindowGateway, @@ -41,6 +43,14 @@ export class TauriWindowGateway implements WindowGateway { await invoke("open_view_window", { panel }); } + async openPluginLayoutWindow( + input: PluginLayoutWindowOpenInput, + ): Promise { + return invoke("open_plugin_layout_window", { + input, + }); + } + async closeViewWindow(panel: string): Promise { await invoke("close_view_window", { panel }); } diff --git a/frontend/src/app/ViewWindow.test.tsx b/frontend/src/app/ViewWindow.test.tsx index 41ab38e..95996a0 100644 --- a/frontend/src/app/ViewWindow.test.tsx +++ b/frontend/src/app/ViewWindow.test.tsx @@ -17,19 +17,50 @@ import { MockTicketGateway, } from "@/adapters/mock"; import type { Gateways } from "@/ports"; -import { ViewWindow, parseViewWindowParams } from "./ViewWindow"; +import { + PluginCommandRegistry, + PluginLayoutRegistry, + PluginMenuRegistry, + PluginRuntimeRegistry, + type LoadedPlugin, + type PluginLayoutProps, +} from "@/plugins/runtime"; +import { PluginLayoutWindow, ViewWindow, parseViewWindowParams } from "./ViewWindow"; + +function b64url(value: string): string { + return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} describe("parseViewWindowParams (#47)", () => { it("parses a valid panel-only query (ignores any legacy project param)", () => { expect(parseViewWindowParams("?panel=tickets")).toEqual({ + kind: "panel", panel: "tickets", }); // A restored legacy URL that still carries `project` parses to panel only. expect(parseViewWindowParams("?panel=tickets&project=p-1")).toEqual({ + kind: "panel", panel: "tickets", }); }); + it("parses a plugin layout window query from the backend base64url contract", () => { + expect( + parseViewWindowParams( + `?pluginLayout=1&pluginId=${b64url("dev.acme.plugin")}&layoutType=${b64url( + "dev.acme.plugin.dashboard", + )}&state=${b64url(JSON.stringify({ tab: "overview" }))}`, + ), + ).toEqual({ + kind: "pluginLayout", + plugin: { + pluginId: "dev.acme.plugin", + layoutType: "dev.acme.plugin.dashboard", + state: { tab: "overview" }, + }, + }); + }); + it("rejects a missing panel or the non-detachable projects panel", () => { expect(parseViewWindowParams("?project=p-1")).toBeNull(); expect(parseViewWindowParams("?panel=projects")).toBeNull(); @@ -38,6 +69,84 @@ describe("parseViewWindowParams (#47)", () => { }); }); +function StubPluginLayout(props: PluginLayoutProps) { + return
{JSON.stringify(props.state)}
; +} + +function pluginRegistry(): PluginRuntimeRegistry { + const registry = new PluginRuntimeRegistry(); + const layouts = new PluginLayoutRegistry( + "dev.acme.plugin", + new Set(["dev.acme.plugin.dashboard"]), + ); + layouts.register({ + type: "dev.acme.plugin.dashboard", + component: StubPluginLayout, + }); + const loaded: LoadedPlugin = { + pluginId: "dev.acme.plugin", + displayName: "Acme Plugin", + contributes: { + menus: [], + menuItems: [], + layouts: [ + { + type: "dev.acme.plugin.dashboard", + label: "Dashboard", + component: "Dashboard", + }, + ], + mcpServers: [], + }, + commands: new PluginCommandRegistry("dev.acme.plugin", new Set()), + layouts, + menu: new PluginMenuRegistry("dev.acme.plugin"), + dispose: async () => {}, + }; + registry.add(loaded); + return registry; +} + +describe("PluginLayoutWindow (#143)", () => { + it("mounts the plugin layout for the focused project with URL state", async () => { + const system = new MockSystemGateway(); + const project = new MockProjectGateway(); + const created = await project.createProject("gamma", "/p/g"); + const focusedProject = new MockFocusedProjectGateway(); + await focusedProject.setFocusedProject({ + id: created.id, + name: "gamma", + root: "/p/g", + }); + const gateways = { + system, + project, + agent: new MockAgentGateway(), + ticket: new MockTicketGateway(system), + focusedProject, + } as unknown as Gateways; + + render( + + + , + ); + + await waitFor(() => expect(screen.getByText("Dashboard")).toBeTruthy()); + await waitFor(() => expect(screen.getByText("· gamma")).toBeTruthy()); + expect(screen.getByTestId("plugin-window-state").textContent).toBe( + JSON.stringify({ tab: "overview" }), + ); + }); +}); + describe("ViewWindow (#47)", () => { it("shows the 'open a project' shell when no project is focused", async () => { const system = new MockSystemGateway(); diff --git a/frontend/src/app/ViewWindow.tsx b/frontend/src/app/ViewWindow.tsx index 4a96bee..e5b732d 100644 --- a/frontend/src/app/ViewWindow.tsx +++ b/frontend/src/app/ViewWindow.tsx @@ -19,12 +19,19 @@ * and crucially no `openProject()` — opening a project is the main window's job. */ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import type { CustomPluginLayoutCell } from "@/domain"; import type { FocusedProject } from "@/ports"; import { Panel } from "@/shared"; import { PANEL_TITLE, type PanelId } from "@/features/projects"; import { ViewPanelBody, type ViewPanelId } from "@/features/projects"; +import { + PluginLayoutCellView, + PluginRuntimeProvider, + usePluginRuntime, + type PluginRuntimeContextValue, +} from "@/features/plugins"; import { useGateways } from "./di"; /** Panels that may be shown in a detached window (everything but "projects"). */ @@ -37,14 +44,63 @@ export interface ViewWindowParams { panel: ViewPanelId; } +export interface PluginLayoutWindowParams { + pluginId: string; + layoutType: string; + state: unknown; +} + +export type DetachedWindowParams = + | { kind: "panel"; panel: ViewPanelId } + | { kind: "pluginLayout"; plugin: PluginLayoutWindowParams }; + +function decodeBase64UrlPart(value: string): string | null { + try { + const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd( + Math.ceil(value.length / 4) * 4, + "=", + ); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + return null; + } +} + +function decodeBase64UrlJson(value: string | null): unknown { + if (!value) return null; + const decoded = decodeBase64UrlPart(value); + if (!decoded) return null; + try { + return JSON.parse(decoded); + } catch { + return null; + } +} + /** Reads and validates the panel-only param from a query string (#47: no project). */ export function parseViewWindowParams( search: string, -): ViewWindowParams | null { +): DetachedWindowParams | null { const params = new URLSearchParams(search); + if (params.get("pluginLayout") === "1") { + const pluginId = decodeBase64UrlPart(params.get("pluginId") ?? ""); + const layoutType = decodeBase64UrlPart(params.get("layoutType") ?? ""); + if (!pluginId || !layoutType) return null; + return { + kind: "pluginLayout", + plugin: { + pluginId, + layoutType, + state: decodeBase64UrlJson(params.get("state")), + }, + }; + } + const panel = params.get("panel"); if (!panel || !DETACHABLE.has(panel)) return null; - return { panel: panel as ViewPanelId }; + return { kind: "panel", panel: panel as ViewPanelId }; } export interface ViewWindowProps { @@ -117,3 +173,94 @@ export function ViewWindow({ panel }: ViewWindowProps) { ); } + +export interface PluginLayoutWindowProps { + plugin: PluginLayoutWindowParams; + runtimeValue?: PluginRuntimeContextValue; +} + +export function PluginLayoutWindow({ plugin, runtimeValue }: PluginLayoutWindowProps) { + return ( + + + + ); +} + +function PluginLayoutWindowBody({ plugin }: PluginLayoutWindowProps) { + const { focusedProject } = useGateways(); + const { registry, loading } = usePluginRuntime(); + const [focus, setFocus] = useState(undefined); + const [state, setState] = useState(plugin.state); + const title = + registry + .get(plugin.pluginId) + ?.contributes.layouts.find((layout) => layout.type === plugin.layoutType)?.label ?? + plugin.layoutType; + const cell = useMemo( + () => ({ + id: `plugin-window:${plugin.pluginId}:${plugin.layoutType}`, + pluginId: plugin.pluginId, + layoutType: plugin.layoutType, + state, + }), + [plugin.layoutType, plugin.pluginId, state], + ); + + useEffect(() => { + let cancelled = false; + let unsubscribe: (() => void) | undefined; + focusedProject + .getFocusedProject() + .then((project) => { + if (!cancelled) setFocus(project); + }) + .catch(() => { + if (!cancelled) setFocus(null); + }); + void focusedProject + .onFocusedProjectChanged((project) => { + if (!cancelled) setFocus(project); + }) + .then((un) => { + if (cancelled) un(); + else unsubscribe = un; + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [focusedProject]); + + return ( +
+
+

{title}

+ + · {registry.get(plugin.pluginId)?.displayName ?? plugin.pluginId} + + {focus && · {focus.name}} +
+
+ {focus ? ( + {}} + onChooseAnotherLayout={() => {}} + /> + ) : ( + +

+ Ouvrez un projet dans la fenêtre principale pour afficher ce layout. +

+
+ )} + {loading && ( +

Chargement des plugins...

+ )} +
+
+ ); +} diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index ef62dd4..6b444dc 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -4,7 +4,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App"; -import { ViewWindow, parseViewWindowParams } from "./ViewWindow"; +import { PluginLayoutWindow, ViewWindow, parseViewWindowParams } from "./ViewWindow"; import { WebApp } from "@/features/web"; import { DIProvider, resolveTransport } from "./di"; import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary"; @@ -41,8 +41,10 @@ ReactDOM.createRoot(root).render( {isWeb ? ( - ) : viewParams ? ( + ) : viewParams?.kind === "panel" ? ( + ) : viewParams?.kind === "pluginLayout" ? ( + ) : ( )} diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.tsx index 1b23b6f..be35fc9 100644 --- a/frontend/src/features/plugins/PluginRuntimeProvider.tsx +++ b/frontend/src/features/plugins/PluginRuntimeProvider.tsx @@ -79,6 +79,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti terminal: gateways.terminal, agents: gateways.agent, system: gateways.system, + window: gateways.window, workState: gateways.workState, focusedProject: gateways.focusedProject, pluginWorkspace: gateways.pluginWorkspace, diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index 5a98158..ea956a1 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -219,6 +219,7 @@ describe("loadPlugins", () => { globalThis.__eventServiceKeys = Object.keys(ctx.services.events).sort(); globalThis.__configServiceKeys = Object.keys(ctx.services.config).sort(); globalThis.__terminalServiceKeys = Object.keys(ctx.services.terminal).sort(); + globalThis.__windowServiceKeys = Object.keys(ctx.services.windows).sort(); } `); const { failures } = await loadPlugins( @@ -253,6 +254,7 @@ describe("loadPlugins", () => { "tasks", "terminal", "tooling", + "windows", "workspace", ]); expect((globalThis as Record).__workspaceServiceKeys).toEqual([ @@ -294,6 +296,94 @@ describe("loadPlugins", () => { "open", "reattach", ]); + expect((globalThis as Record).__windowServiceKeys).toEqual([ + "open", + ]); + }); + + it("injects services for UI plugins and validates plugin layout windows against declared layouts", async () => { + const calls: unknown[] = []; + const windowGateways = { + ...gateways, + window: { + async openViewWindow() {}, + async openPluginLayoutWindow(input: unknown) { + calls.push(input); + return { + label: "view-plugin-layout", + url: "index.html?pluginLayout=1", + alreadyOpen: false, + providerPluginDisplayName: "React Plugin", + layoutLabel: "Dashboard", + surface: { + pluginId: "dev.acme.react", + layoutType: "dev.acme.react.dashboard", + state: { tab: "overview" }, + }, + }; + }, + async closeViewWindow() {}, + async listOpenViewWindows() { + return []; + }, + async onViewWindowClosed() { + return () => {}; + }, + }, + } as PluginGatewaySet; + const bundle = dataUrl(` + export async function activate(ctx) { + globalThis.__uiServicesAvailable = Boolean(ctx.services); + globalThis.__pluginWindowResult = await ctx.services.windows.open({ + layoutType: "dev.acme.react.dashboard", + state: { tab: "overview" }, + }); + try { + await ctx.services.windows.open({ layoutType: "dev.acme.react.missing" }); + } catch (e) { + globalThis.__pluginWindowValidationError = String(e); + } + } + `); + + const { failures } = await loadPlugins( + [ + entry({ + id: "dev.acme.react", + displayName: "React Plugin", + capabilities: ["ui"], + bundleUrl: bundle, + contributes: { + ...emptyContributes(), + layouts: [ + { + type: "dev.acme.react.dashboard", + label: "Dashboard", + component: "Dashboard", + }, + ], + }, + }), + ], + windowGateways, + ); + + expect(failures).toEqual([]); + expect((globalThis as Record).__uiServicesAvailable).toBe(true); + expect((globalThis as Record).__pluginWindowResult).toMatchObject({ + label: "view-plugin-layout", + surface: { layoutType: "dev.acme.react.dashboard" }, + }); + expect((globalThis as Record).__pluginWindowValidationError).toMatch( + /undeclared layout/, + ); + expect(calls).toEqual([ + { + pluginId: "dev.acme.react", + layoutType: "dev.acme.react.dashboard", + state: { tab: "overview" }, + }, + ]); }); it("injects plugin-owned storage scoped to the activating plugin id", async () => { diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 7bd3a38..a8ba952 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -15,6 +15,12 @@ * collected, never thrown past `loadPlugins`. */ +import * as React from "react"; +import * as ReactDom from "react-dom"; +import * as ReactDomClient from "react-dom/client"; +import * as ReactJsxRuntime from "react/jsx-runtime"; +import * as ReactJsxDevRuntime from "react/jsx-dev-runtime"; + import type { JsonValue, PluginContributionDto, PluginRuntimePlugin } from "@/domain"; import { PluginCommandRegistry, @@ -93,6 +99,7 @@ export interface PluginLoadOptions { } const DEFAULT_PLUGIN_LOAD_TIMEOUT_MS = 10_000; +let hostReactImportMapInstalled = false; async function withTimeout( promise: Promise, @@ -161,6 +168,27 @@ function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined; } +function installHostReactImportMap(): void { + if (hostReactImportMapInstalled) { + hostReactImportMapInstalled = true; + return; + } + + const globalHost = globalThis as unknown as { + __IDEA_PLUGIN_HOST_REACT__?: typeof React; + __IDEA_PLUGIN_HOST_REACT_DOM__?: typeof ReactDom; + __IDEA_PLUGIN_HOST_REACT_DOM_CLIENT__?: typeof ReactDomClient; + __IDEA_PLUGIN_HOST_REACT_JSX_RUNTIME__?: typeof ReactJsxRuntime; + __IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__?: typeof ReactJsxDevRuntime; + }; + globalHost.__IDEA_PLUGIN_HOST_REACT__ = React; + globalHost.__IDEA_PLUGIN_HOST_REACT_DOM__ = ReactDom; + globalHost.__IDEA_PLUGIN_HOST_REACT_DOM_CLIENT__ = ReactDomClient; + globalHost.__IDEA_PLUGIN_HOST_REACT_JSX_RUNTIME__ = ReactJsxRuntime; + globalHost.__IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__ = ReactJsxDevRuntime; + hostReactImportMapInstalled = true; +} + function safePluginId(entry: unknown): string { return nonEmptyString(objectOrEmpty(entry).id) ?? ""; } @@ -247,6 +275,7 @@ async function loadOne( throw new Error("missing plugin bundle URL"); } + installHostReactImportMap(); const mod = resolveIdeaPluginModule( await withTimeout( import(/* @vite-ignore */ bundleUrl), @@ -282,8 +311,11 @@ async function loadOne( menu, storage, }; - if (hasCapability(entry, "tooling")) { - ctx.services = createPluginServices(gateways); + if (hasCapability(entry, "tooling") || hasCapability(entry, "ui")) { + ctx.services = createPluginServices(gateways, { + pluginId, + declaredLayoutTypes, + }); } activation = await withTimeout( diff --git a/frontend/src/plugins/runtime/registry.ts b/frontend/src/plugins/runtime/registry.ts index 4a27566..fe996ac 100644 --- a/frontend/src/plugins/runtime/registry.ts +++ b/frontend/src/plugins/runtime/registry.ts @@ -32,6 +32,7 @@ import type { ProjectGateway, SystemGateway, TerminalGateway, + WindowGateway, WorkStateGateway, } from "@/ports"; @@ -42,6 +43,7 @@ export interface PluginGatewaySet { terminal: TerminalGateway; agents: AgentGateway; system: SystemGateway; + window: WindowGateway; workState: WorkStateGateway; focusedProject: FocusedProjectGateway; pluginWorkspace: PluginWorkspaceGateway; diff --git a/frontend/src/plugins/runtime/services.test.ts b/frontend/src/plugins/runtime/services.test.ts index 690b1df..63a4698 100644 --- a/frontend/src/plugins/runtime/services.test.ts +++ b/frontend/src/plugins/runtime/services.test.ts @@ -13,6 +13,7 @@ import type { ReattachResult, TerminalGateway, TerminalHandle, + WindowGateway, WorkStateGateway, } from "@/ports"; import { createPluginServices } from "./services"; @@ -32,6 +33,7 @@ function gateways(overrides: { project?: Partial; workState?: Partial; terminal?: Partial; + window?: Partial; pluginWorkspace?: Partial; pluginTask?: Partial; pluginToolchain?: Partial; @@ -89,6 +91,25 @@ function gateways(overrides: { closeTerminal: vi.fn(), ...overrides.terminal, }; + const window: WindowGateway = { + openViewWindow: vi.fn(), + openPluginLayoutWindow: vi.fn(async (input) => ({ + label: `view-plugin-layout-${input.pluginId}.${input.layoutType}`, + url: "index.html?pluginLayout=1", + alreadyOpen: false, + providerPluginDisplayName: input.pluginId, + layoutLabel: input.layoutType, + surface: { + pluginId: input.pluginId, + layoutType: input.layoutType, + state: input.state ?? null, + }, + })), + closeViewWindow: vi.fn(), + listOpenViewWindows: vi.fn(async () => []), + onViewWindowClosed: vi.fn(async () => () => {}), + ...overrides.window, + }; const pluginWorkspace: PluginWorkspaceGateway = { readText: vi.fn(async ({ path }) => ({ path, content: "file text" })), readBinary: vi.fn(async ({ path }) => ({ path, bytes: new Uint8Array([67]) })), @@ -235,6 +256,7 @@ function gateways(overrides: { project, workState, terminal, + window, pluginWorkspace, pluginTask, pluginToolchain, @@ -249,6 +271,37 @@ async function flushMicrotasks(): Promise { } describe("createPluginServices", () => { + it("opens only plugin-declared layout windows through the window gateway", async () => { + const g = gateways(); + const services = createPluginServices(g, { + pluginId: "dev.acme.plugin", + declaredLayoutTypes: new Set(["dev.acme.plugin.dashboard"]), + }); + + await expect( + services.windows.open({ + layoutType: "dev.acme.plugin.dashboard", + state: { tab: "overview" }, + }), + ).resolves.toMatchObject({ + label: "view-plugin-layout-dev.acme.plugin.dev.acme.plugin.dashboard", + surface: { + pluginId: "dev.acme.plugin", + layoutType: "dev.acme.plugin.dashboard", + state: { tab: "overview" }, + }, + }); + expect(g.window.openPluginLayoutWindow).toHaveBeenCalledWith({ + pluginId: "dev.acme.plugin", + layoutType: "dev.acme.plugin.dashboard", + state: { tab: "overview" }, + }); + + await expect( + services.windows.open({ layoutType: "dev.acme.plugin.undeclared" }), + ).rejects.toThrow(/undeclared layout/); + }); + it("exposes focused workspace project helpers without leaking project gateway DTOs", async () => { const g = gateways(); const services = createPluginServices(g); diff --git a/frontend/src/plugins/runtime/services.ts b/frontend/src/plugins/runtime/services.ts index f5eb837..a0dea16 100644 --- a/frontend/src/plugins/runtime/services.ts +++ b/frontend/src/plugins/runtime/services.ts @@ -18,6 +18,7 @@ import type { PluginWorkspaceGateway, ProjectGateway, TerminalGateway, + WindowGateway, WorkStateGateway, } from "@/ports"; @@ -28,6 +29,7 @@ export interface PluginServices { events: EventService; config: ConfigDocumentService; terminal: TerminalService; + windows: WindowService; } export interface WorkspaceProject { @@ -370,9 +372,32 @@ export interface TerminalService { close(sessionId: string): Promise; } +export interface OpenPluginWindowOptions { + layoutType: string; + state?: JsonValue; +} + +export interface PluginWindow { + label: string; + url: string; + alreadyOpen: boolean; + providerPluginDisplayName: string; + layoutLabel: string; + surface: { + pluginId: string; + layoutType: string; + state: JsonValue; + }; +} + +export interface WindowService { + open(options: OpenPluginWindowOptions): Promise; +} + interface PluginServiceGatewaySet { project: ProjectGateway; terminal: TerminalGateway; + window: WindowGateway; workState: WorkStateGateway; focusedProject: FocusedProjectGateway; pluginWorkspace: PluginWorkspaceGateway; @@ -382,6 +407,11 @@ interface PluginServiceGatewaySet { pluginConfig: PluginConfigGateway; } +export interface PluginServiceScope { + pluginId?: string; + declaredLayoutTypes?: ReadonlySet; +} + const DEFAULT_ROWS = 24; const DEFAULT_COLS = 80; const DEFAULT_EVENT_POLL_INTERVAL_MS = 1000; @@ -462,7 +492,10 @@ function workspacePathMatches(watchedPath: string, eventPath: string): boolean { ); } -export function createPluginServices(gateways: PluginServiceGatewaySet): PluginServices { +export function createPluginServices( + gateways: PluginServiceGatewaySet, + scope: PluginServiceScope = {}, +): PluginServices { async function currentProject(): Promise { const focused = await gateways.focusedProject.getFocusedProject(); return focused ? toWorkspaceProject(focused) : null; @@ -724,5 +757,28 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS }, }; - return { workspace, tasks, tooling, events, config, terminal }; + const windows: WindowService = { + async open(options) { + const layoutType = options.layoutType.trim(); + if (!layoutType) { + throw new Error("layoutType is required"); + } + if (scope.declaredLayoutTypes && !scope.declaredLayoutTypes.has(layoutType)) { + throw new Error( + `plugin "${scope.pluginId ?? ""}" cannot open undeclared layout "${layoutType}"`, + ); + } + const pluginId = scope.pluginId; + if (!pluginId) { + throw new Error("plugin id is required to open a plugin layout window"); + } + return gateways.window.openPluginLayoutWindow({ + pluginId, + layoutType, + state: options.state ?? null, + }); + }, + }; + + return { workspace, tasks, tooling, events, config, terminal, windows }; } diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 000cfc1..28c8e31 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -1271,6 +1271,25 @@ export interface ViewWindowSnapshot { visible: boolean; } +export interface PluginLayoutWindowOpenInput { + pluginId: string; + layoutType: string; + state?: JsonValue; +} + +export interface PluginLayoutWindowOpenResult { + label: string; + url: string; + alreadyOpen: boolean; + providerPluginDisplayName: string; + layoutLabel: string; + surface: { + pluginId: string; + layoutType: string; + state: JsonValue; + }; +} + /** * Detaching a View into its own OS window (ticket #23, reworked in #47). * @@ -1290,6 +1309,15 @@ export interface WindowGateway { * main window's focused project; no project id is passed. */ openViewWindow(panel: string): Promise; + /** + * Opens — or focuses, if already open — a separate OS window rendering one + * plugin layout contribution declared in the provider plugin's + * `contributes.layouts`. The backend validates the provider is runtime-active + * and the layout type is declared before creating the window. + */ + openPluginLayoutWindow( + input: PluginLayoutWindowOpenInput, + ): Promise; /** Closes the detached window for `panel` if one is open. */ closeViewWindow(panel: string): Promise; /** From 13bad558ba2808c1b40e01ee2f0824af00ec97d1 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 4 Aug 2026 00:39:57 +0200 Subject: [PATCH 3/4] chore(tickets): synchronise carnets/statuts #142-146 (QA verte) + bump IdeaSDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA verte sur feature/plugin-hosted-windows : cargo (domain/application/ infrastructure/app-tauri), frontend (vitest/typecheck/build), sdk npm check. Réserve non bloquante : pas d'e2e natif complet clic sous-menu -> fenêtre OS. Co-Authored-By: Claude Opus 4.8 --- .ideai/tickets/142/carnet.md | 4 ++-- .ideai/tickets/142/issue.md | 6 +++--- .ideai/tickets/143/carnet.md | 4 ++-- .ideai/tickets/143/issue.md | 6 +++--- .ideai/tickets/144/carnet.md | 4 ++-- .ideai/tickets/144/issue.md | 6 +++--- .ideai/tickets/145/carnet.md | 4 ++-- .ideai/tickets/145/issue.md | 6 +++--- .ideai/tickets/146/carnet.md | 4 ++-- .ideai/tickets/146/issue.md | 6 +++--- .ideai/tickets/index.json | 20 ++++++++++---------- sdk/IdeaSDK | 2 +- 12 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.ideai/tickets/142/carnet.md b/.ideai/tickets/142/carnet.md index df9d650..bbcfd3f 100644 --- a/.ideai/tickets/142/carnet.md +++ b/.ideai/tickets/142/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#142" -version: 1 +version: 3 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1785794926619 +updatedAt: 1785796721082 --- diff --git a/.ideai/tickets/142/issue.md b/.ideai/tickets/142/issue.md index 882c694..8cf6229 100644 --- a/.ideai/tickets/142/issue.md +++ b/.ideai/tickets/142/issue.md @@ -2,7 +2,7 @@ id: "73c2a22d-86bb-4bf5-912b-7766a528d5e7" number: 142 title: "Plugin SDK: backend support for plugin-hosted windows" -status: "open" +status: "qa" priority: "high" sprint: null links: [] @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1785794926619 -updatedAt: 1785794926619 -version: 1 +updatedAt: 1785796721082 +version: 3 --- Implement the backend/domain/application changes needed so plugin commands can open a new OS window that hosts a plugin-contributed layout, reusing the existing window pipeline and anti-duplication rules instead of inventing a parallel window system. Scope: extend the accepted view/window surface contract for plugin layout ids, preserve native panel behavior, and keep the window lifecycle compatible with the existing layout/window stores and commands. \ No newline at end of file diff --git a/.ideai/tickets/143/carnet.md b/.ideai/tickets/143/carnet.md index 5f7d8f4..837f836 100644 --- a/.ideai/tickets/143/carnet.md +++ b/.ideai/tickets/143/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#143" -version: 1 +version: 3 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1785794926635 +updatedAt: 1785796721101 --- diff --git a/.ideai/tickets/143/issue.md b/.ideai/tickets/143/issue.md index c52c7a0..2a58ac3 100644 --- a/.ideai/tickets/143/issue.md +++ b/.ideai/tickets/143/issue.md @@ -2,7 +2,7 @@ id: "9e684778-62df-491f-b824-d74960bd6b9d" number: 143 title: "Plugin SDK: frontend host for plugin windows and window-open API" -status: "open" +status: "qa" priority: "high" sprint: null links: [] @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1785794926635 -updatedAt: 1785794926635 -version: 1 +updatedAt: 1785796721101 +version: 3 --- Implement the frontend runtime and SDK service surface so a plugin menu command can open a new window rendering one of its declared layout contributions. Scope: route plugin window surfaces through the existing view-window host, reuse plugin layout rendering/fallback behavior, and expose a public `services.windows.open(...)` API validated against declared layout ids. \ No newline at end of file diff --git a/.ideai/tickets/144/carnet.md b/.ideai/tickets/144/carnet.md index e987616..750f2b6 100644 --- a/.ideai/tickets/144/carnet.md +++ b/.ideai/tickets/144/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#144" -version: 1 +version: 3 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1785794926654 +updatedAt: 1785796721119 --- diff --git a/.ideai/tickets/144/issue.md b/.ideai/tickets/144/issue.md index 92aa0ea..6482577 100644 --- a/.ideai/tickets/144/issue.md +++ b/.ideai/tickets/144/issue.md @@ -2,7 +2,7 @@ id: "7ed6eb7f-713e-41c6-a0b4-9783acf68268" number: 144 title: "Plugin SDK: shared React runtime for plugin layouts" -status: "open" +status: "qa" priority: "high" sprint: null links: [] @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1785794926654 -updatedAt: 1785794926654 -version: 1 +updatedAt: 1785796721119 +version: 3 --- Upgrade the plugin SDK/runtime so plugin-contributed layouts can be authored as real React components with JSX and hooks. Scope: resolve `react`/`react-dom` imports to the host instance, update SDK public types/tsconfig/package metadata accordingly, and refresh the hello-plugin example to demonstrate the supported React authoring model. \ No newline at end of file diff --git a/.ideai/tickets/145/carnet.md b/.ideai/tickets/145/carnet.md index 35289e0..7bf1085 100644 --- a/.ideai/tickets/145/carnet.md +++ b/.ideai/tickets/145/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#145" -version: 1 +version: 3 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1785794926667 +updatedAt: 1785796721137 --- diff --git a/.ideai/tickets/145/issue.md b/.ideai/tickets/145/issue.md index dacfb39..4675e6b 100644 --- a/.ideai/tickets/145/issue.md +++ b/.ideai/tickets/145/issue.md @@ -2,7 +2,7 @@ id: "00df9acb-29ec-498b-b0dd-56af00b3d630" number: 145 title: "Plugin SDK: expand and restructure SDK documentation" -status: "open" +status: "qa" priority: "high" sprint: null links: [] @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1785794926667 -updatedAt: 1785794926667 -version: 1 +updatedAt: 1785796721137 +version: 3 --- Produce a much more complete SDK documentation set under `sdk/IdeaSDK/docs/` with explicit file names and focused topics. Scope: turn the root README into a concise entrypoint/summary, add dedicated docs for manifest, activation/context, menus, layouts with React, windows, services, packaging/distribution, and keep the content aligned with the real runtime contracts and example plugin. \ No newline at end of file diff --git a/.ideai/tickets/146/carnet.md b/.ideai/tickets/146/carnet.md index cce3a96..9a8b7ca 100644 --- a/.ideai/tickets/146/carnet.md +++ b/.ideai/tickets/146/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#146" -version: 1 +version: 2 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1785794926679 +updatedAt: 1785796721013 --- diff --git a/.ideai/tickets/146/issue.md b/.ideai/tickets/146/issue.md index 0f845f8..3259f3a 100644 --- a/.ideai/tickets/146/issue.md +++ b/.ideai/tickets/146/issue.md @@ -2,7 +2,7 @@ id: "1b3f9aa1-c16a-4fb7-aab0-a3c5b56b200b" number: 146 title: "QA: validate plugin window opening, React layouts, and SDK docs/examples" -status: "open" +status: "qa" priority: "high" sprint: null links: [] @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1785794926679 -updatedAt: 1785794926679 -version: 1 +updatedAt: 1785796721013 +version: 2 --- Validate the plugin SDK feature set end to end after implementation. Scope: real test evidence that a plugin submenu click can open a new window, the opened window renders a React-based plugin layout correctly, layout state still round-trips, existing plugin layout cells still work, and the refreshed SDK docs/example match the shipped behavior. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index e3a2b74..baccbc2 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -1841,7 +1841,7 @@ "issueRef": "#142", "path": "142", "title": "Plugin SDK: backend support for plugin-hosted windows", - "status": "open", + "status": "qa", "priority": "high", "sprint": null, "assignedAgentIds": [ @@ -1851,13 +1851,13 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1785794926619 + "updatedAt": 1785796721082 }, { "issueRef": "#143", "path": "143", "title": "Plugin SDK: frontend host for plugin windows and window-open API", - "status": "open", + "status": "qa", "priority": "high", "sprint": null, "assignedAgentIds": [ @@ -1867,13 +1867,13 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1785794926635 + "updatedAt": 1785796721101 }, { "issueRef": "#144", "path": "144", "title": "Plugin SDK: shared React runtime for plugin layouts", - "status": "open", + "status": "qa", "priority": "high", "sprint": null, "assignedAgentIds": [ @@ -1883,13 +1883,13 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1785794926654 + "updatedAt": 1785796721119 }, { "issueRef": "#145", "path": "145", "title": "Plugin SDK: expand and restructure SDK documentation", - "status": "open", + "status": "qa", "priority": "high", "sprint": null, "assignedAgentIds": [ @@ -1899,13 +1899,13 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1785794926667 + "updatedAt": 1785796721137 }, { "issueRef": "#146", "path": "146", "title": "QA: validate plugin window opening, React layouts, and SDK docs/examples", - "status": "open", + "status": "qa", "priority": "high", "sprint": null, "assignedAgentIds": [ @@ -1915,7 +1915,7 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1785794926679 + "updatedAt": 1785796721013 } ] } \ No newline at end of file diff --git a/sdk/IdeaSDK b/sdk/IdeaSDK index 7e1bb5f..31925dc 160000 --- a/sdk/IdeaSDK +++ b/sdk/IdeaSDK @@ -1 +1 @@ -Subproject commit 7e1bb5fd38cbdccd3d2c168741799b6460c2faaa +Subproject commit 31925dc1ce0c4d8209d75dcc2a85ab8beb9ea26b From 5a64e7fd5b7bf6585356785da30a521bac29a5ad Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 4 Aug 2026 00:40:16 +0200 Subject: [PATCH 4/4] chore(sdk): pointe le submodule sur develop (post-merge feature/plugin-hosted-windows) Co-Authored-By: Claude Opus 4.8 --- sdk/IdeaSDK | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/IdeaSDK b/sdk/IdeaSDK index 31925dc..fe50219 160000 --- a/sdk/IdeaSDK +++ b/sdk/IdeaSDK @@ -1 +1 @@ -Subproject commit 31925dc1ce0c4d8209d75dcc2a85ab8beb9ea26b +Subproject commit fe50219493c2d37c488f35d825d11c5ba6929db2