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/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, 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; /** diff --git a/sdk/IdeaSDK b/sdk/IdeaSDK index 7e1bb5f..fe50219 160000 --- a/sdk/IdeaSDK +++ b/sdk/IdeaSDK @@ -1 +1 @@ -Subproject commit 7e1bb5fd38cbdccd3d2c168741799b6460c2faaa +Subproject commit fe50219493c2d37c488f35d825d11c5ba6929db2