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,