merge(window): intègre feature/plugin-hosted-windows — fenêtres plugin-hébergées #142-146 (vert QA)
Backend (window usecases + commande Tauri), frontend (host de fenêtre + services.windows.open), runtime SDK (react/react-dom résolus vers l'host) et documentation SDK restructurée. QA verte (cargo + vitest + typecheck + build + sdk check) ; réserve non bloquante : pas d'e2e natif clic sous-menu -> fenêtre OS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#142"
|
||||
version: 1
|
||||
version: 3
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785794926619
|
||||
updatedAt: 1785796721082
|
||||
---
|
||||
|
||||
@ -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.
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#143"
|
||||
version: 1
|
||||
version: 3
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785794926635
|
||||
updatedAt: 1785796721101
|
||||
---
|
||||
|
||||
@ -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.
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#144"
|
||||
version: 1
|
||||
version: 3
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785794926654
|
||||
updatedAt: 1785796721119
|
||||
---
|
||||
|
||||
@ -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.
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#145"
|
||||
version: 1
|
||||
version: 3
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785794926667
|
||||
updatedAt: 1785796721137
|
||||
---
|
||||
|
||||
@ -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.
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#146"
|
||||
version: 1
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785794926679
|
||||
updatedAt: 1785796721013
|
||||
---
|
||||
|
||||
@ -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.
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -4,7 +4,9 @@
|
||||
//! [`AppState`], map `Result<Output, AppError>` to `Result<ResponseDto,
|
||||
//! ErrorDto>`. 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> {
|
||||
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<PersistedPluginLayoutWindow> {
|
||||
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<ViewPanel> {
|
||||
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<OpenPluginLayoutWindowResponseDto, ErrorDto> {
|
||||
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::<AppState>() {
|
||||
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.
|
||||
|
||||
@ -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<PersistedWind
|
||||
handle
|
||||
.webview_windows()
|
||||
.into_iter()
|
||||
.filter_map(|(label, window)| snapshot_webview_window(&label, &window))
|
||||
.filter_map(|(label, window)| snapshot_webview_window(handle, &label, &window))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn snapshot_webview_window(label: &str, window: &WebviewWindow) -> Option<PersistedWindowState> {
|
||||
let (kind, panel, project_id, url) = persisted_window_identity(label)?;
|
||||
fn snapshot_webview_window(
|
||||
handle: &tauri::AppHandle,
|
||||
label: &str,
|
||||
window: &WebviewWindow,
|
||||
) -> Option<PersistedWindowState> {
|
||||
let (kind, panel, project_id, mut plugin_layout, url) =
|
||||
persisted_window_identity_from_label(label)?;
|
||||
if kind == PersistedWindowKind::PluginLayout {
|
||||
plugin_layout = handle
|
||||
.try_state::<AppState>()
|
||||
.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<Persis
|
||||
kind,
|
||||
panel,
|
||||
project_id,
|
||||
plugin_layout,
|
||||
url,
|
||||
visible: window.is_visible().unwrap_or(true),
|
||||
maximized: window.is_maximized().unwrap_or(false),
|
||||
@ -597,16 +610,28 @@ fn snapshot_webview_window(label: &str, window: &WebviewWindow) -> Option<Persis
|
||||
})
|
||||
}
|
||||
|
||||
fn persisted_window_identity(
|
||||
fn persisted_window_identity_from_label(
|
||||
label: &str,
|
||||
) -> Option<(
|
||||
PersistedWindowKind,
|
||||
Option<String>,
|
||||
Option<ProjectId>,
|
||||
Option<PersistedPluginLayoutWindow>,
|
||||
Option<String>,
|
||||
)> {
|
||||
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::<AppState>() {
|
||||
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::<AppState>() {
|
||||
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]
|
||||
|
||||
@ -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<EmbeddedServerController>,
|
||||
/// Project currently focused by the main window; panel-only windows follow it.
|
||||
focused_project: Mutex<Option<FocusedProjectDto>>,
|
||||
/// Detached plugin-layout surfaces keyed by Tauri window label.
|
||||
plugin_window_surfaces: Mutex<HashMap<String, PersistedPluginLayoutWindow>>,
|
||||
}
|
||||
|
||||
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<PersistedPluginLayoutWindow> {
|
||||
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 {
|
||||
|
||||
@ -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(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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::{
|
||||
|
||||
@ -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<PluginRuntimeLayoutContribution, AppError> {
|
||||
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,
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
mod usecases;
|
||||
|
||||
pub use usecases::{
|
||||
MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, RestoreOpenWindows,
|
||||
MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, OpenPluginLayoutWindow,
|
||||
OpenPluginLayoutWindowInput, OpenPluginLayoutWindowOutput, RestoreOpenWindows,
|
||||
RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput,
|
||||
};
|
||||
|
||||
@ -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<dyn WindowStateStore>,
|
||||
_projects: Arc<dyn ProjectStore>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
}
|
||||
|
||||
impl RestoreOpenWindows {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(windows: Arc<dyn WindowStateStore>, projects: Arc<dyn ProjectStore>) -> Self {
|
||||
pub fn new(
|
||||
windows: Arc<dyn WindowStateStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
) -> 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<dyn PluginPackageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
}
|
||||
|
||||
impl OpenPluginLayoutWindow {
|
||||
/// Builds the use case from plugin runtime ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
validator: Arc<dyn PluginManifestValidator>,
|
||||
) -> 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<OpenPluginLayoutWindowOutput, AppError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<u8> {
|
||||
br#"{
|
||||
"ideaPluginManifestVersion": 1,
|
||||
"id": "dev.idea.android-plugin",
|
||||
"displayName": "Android",
|
||||
"version": "1.0.0",
|
||||
"engines": {"idea": ">=0.1.0 <1.0.0"},
|
||||
"main": "dist/index.js",
|
||||
"trustLevel": "full",
|
||||
"capabilities": ["ui"],
|
||||
"contributes": {
|
||||
"layouts": [{"type":"idea-android.health","label":"Android Health","component":"AndroidHealth"}]
|
||||
}
|
||||
}"#.to_vec()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakePluginPackages {
|
||||
manifest: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl FakePluginPackages {
|
||||
fn new(manifest: Vec<u8>) -> Self {
|
||||
Self {
|
||||
manifest: Arc::new(Mutex::new(manifest)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPackageStore for FakePluginPackages {
|
||||
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn read_manifest(
|
||||
&self,
|
||||
_package: &PluginPackageRef,
|
||||
) -> Result<PluginManifestBytes, PluginStoreError> {
|
||||
Ok(PluginManifestBytes {
|
||||
bytes: self.manifest.lock().unwrap().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn install_from_archive(
|
||||
&self,
|
||||
_archive: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("unused".to_owned()))
|
||||
}
|
||||
|
||||
async fn install_from_directory(
|
||||
&self,
|
||||
_dir: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("unused".to_owned()))
|
||||
}
|
||||
|
||||
async fn commit_install(
|
||||
&self,
|
||||
staged: StagedPluginPackage,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<PluginPackageRef, PluginStoreError> {
|
||||
Ok(PluginPackageRef {
|
||||
plugin_id: Some(plugin_id.clone()),
|
||||
root: staged.root,
|
||||
})
|
||||
}
|
||||
|
||||
async fn remove_package(
|
||||
&self,
|
||||
_plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStoreError> {
|
||||
Ok(RemovalOutcome::NotFound)
|
||||
}
|
||||
|
||||
fn bundle_url(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
entry: &RelativePath,
|
||||
hash: &ContentHash,
|
||||
) -> Result<PluginBundleUrl, PluginStoreError> {
|
||||
Ok(PluginBundleUrl::new(format!(
|
||||
"idea-plugin://{}/current/{}/{}",
|
||||
plugin_id.as_str(),
|
||||
hash.as_str(),
|
||||
entry.as_str()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakePluginRegistry {
|
||||
registry: Arc<Mutex<PluginRegistry>>,
|
||||
}
|
||||
|
||||
impl FakePluginRegistry {
|
||||
fn with_state(lifecycle_state: PluginLifecycleState) -> Self {
|
||||
Self {
|
||||
registry: Arc::new(Mutex::new(PluginRegistry {
|
||||
version: 1,
|
||||
plugins: vec![PluginRegistryEntry {
|
||||
id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
||||
lifecycle_state,
|
||||
source: PluginInstallSource::Directory {
|
||||
path_label: "/plugin".to_owned(),
|
||||
},
|
||||
content_hash: ContentHash::new("abc123").unwrap(),
|
||||
restart_required: false,
|
||||
error: None,
|
||||
}],
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginRegistryStore for FakePluginRegistry {
|
||||
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
|
||||
Ok(self.registry.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError> {
|
||||
*self.registry.lock().unwrap() = registry.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeProjectRegistry {
|
||||
existing: Arc<Mutex<Vec<ProjectId>>>,
|
||||
@ -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<_>>(),
|
||||
vec!["view-plugin-layout-valid"]
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<DeleteLayout>,
|
||||
/// Set the active named layout (#4).
|
||||
pub set_active_layout: Arc<SetActiveLayout>,
|
||||
/// Validate/open a plugin layout in a detached OS window.
|
||||
pub open_plugin_layout_window: Arc<OpenPluginLayoutWindow>,
|
||||
/// Freeze `agent_was_running` on every agent leaf before a PTY kill (T5).
|
||||
pub snapshot_running_agents: Arc<SnapshotRunningAgents>,
|
||||
/// 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,
|
||||
|
||||
@ -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<crate::ids::ProjectId>,
|
||||
/// Plugin layout surface. Present only for [`PersistedWindowKind::PluginLayout`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_layout: Option<PersistedPluginLayoutWindow>,
|
||||
/// App URL loaded in the window.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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(),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -11,6 +11,17 @@
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<title>IdeA</title>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react": "./plugin-host/react.js",
|
||||
"react-dom": "./plugin-host/react-dom.js",
|
||||
"react-dom/client": "./plugin-host/react-dom-client.js",
|
||||
"react/jsx-runtime": "./plugin-host/react-jsx-runtime.js",
|
||||
"react/jsx-dev-runtime": "./plugin-host/react-jsx-dev-runtime.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
5
frontend/public/plugin-host/react-dom-client.js
vendored
Normal file
5
frontend/public/plugin-host/react-dom-client.js
vendored
Normal file
@ -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;
|
||||
16
frontend/public/plugin-host/react-dom.js
vendored
Normal file
16
frontend/public/plugin-host/react-dom.js
vendored
Normal file
@ -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;
|
||||
4
frontend/public/plugin-host/react-jsx-dev-runtime.js
vendored
Normal file
4
frontend/public/plugin-host/react-jsx-dev-runtime.js
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
const Runtime = globalThis.__IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__;
|
||||
|
||||
export const Fragment = Runtime.Fragment;
|
||||
export const jsxDEV = Runtime.jsxDEV;
|
||||
5
frontend/public/plugin-host/react-jsx-runtime.js
vendored
Normal file
5
frontend/public/plugin-host/react-jsx-runtime.js
vendored
Normal file
@ -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;
|
||||
35
frontend/public/plugin-host/react.js
vendored
Normal file
35
frontend/public/plugin-host/react.js
vendored
Normal file
@ -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;
|
||||
@ -79,6 +79,9 @@ export class WebWindowGateway implements WindowGateway {
|
||||
openViewWindow(): Promise<void> {
|
||||
return unsupportedOnWeb("Detaching a panel into an OS window");
|
||||
}
|
||||
openPluginLayoutWindow(): Promise<never> {
|
||||
return unsupportedOnWeb("Opening a plugin layout in an OS window");
|
||||
}
|
||||
closeViewWindow(): Promise<void> {
|
||||
return unsupportedOnWeb("Closing a detached OS window");
|
||||
}
|
||||
|
||||
@ -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<string>();
|
||||
readonly pluginLayoutWindows = new Map<string, PluginLayoutWindowOpenResult>();
|
||||
private readonly listeners = new Set<(e: ViewWindowClosed) => void>();
|
||||
|
||||
async openViewWindow(panel: string): Promise<void> {
|
||||
this.open.add(panel);
|
||||
}
|
||||
|
||||
async openPluginLayoutWindow(
|
||||
input: PluginLayoutWindowOpenInput,
|
||||
): Promise<PluginLayoutWindowOpenResult> {
|
||||
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<void> {
|
||||
if (this.open.delete(panel)) {
|
||||
this.emit({ panel });
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<PluginLayoutWindowOpenResult> {
|
||||
return invoke<PluginLayoutWindowOpenResult>("open_plugin_layout_window", {
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
async closeViewWindow(panel: string): Promise<void> {
|
||||
await invoke("close_view_window", { panel });
|
||||
}
|
||||
|
||||
@ -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 <div data-testid="plugin-window-state">{JSON.stringify(props.state)}</div>;
|
||||
}
|
||||
|
||||
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(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginLayoutWindow
|
||||
plugin={{
|
||||
pluginId: "dev.acme.plugin",
|
||||
layoutType: "dev.acme.plugin.dashboard",
|
||||
state: { tab: "overview" },
|
||||
}}
|
||||
runtimeValue={{ registry: pluginRegistry(), failures: [], pending: [], loading: false }}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
@ -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) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PluginLayoutWindowProps {
|
||||
plugin: PluginLayoutWindowParams;
|
||||
runtimeValue?: PluginRuntimeContextValue;
|
||||
}
|
||||
|
||||
export function PluginLayoutWindow({ plugin, runtimeValue }: PluginLayoutWindowProps) {
|
||||
return (
|
||||
<PluginRuntimeProvider value={runtimeValue}>
|
||||
<PluginLayoutWindowBody plugin={plugin} />
|
||||
</PluginRuntimeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginLayoutWindowBody({ plugin }: PluginLayoutWindowProps) {
|
||||
const { focusedProject } = useGateways();
|
||||
const { registry, loading } = usePluginRuntime();
|
||||
const [focus, setFocus] = useState<FocusedProject | null | undefined>(undefined);
|
||||
const [state, setState] = useState<unknown>(plugin.state);
|
||||
const title =
|
||||
registry
|
||||
.get(plugin.pluginId)
|
||||
?.contributes.layouts.find((layout) => layout.type === plugin.layoutType)?.label ??
|
||||
plugin.layoutType;
|
||||
const cell = useMemo<CustomPluginLayoutCell>(
|
||||
() => ({
|
||||
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 (
|
||||
<div className="flex h-full flex-col bg-canvas text-content">
|
||||
<header className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<h1 className="text-sm font-semibold text-content">{title}</h1>
|
||||
<span className="truncate text-xs text-muted">
|
||||
· {registry.get(plugin.pluginId)?.displayName ?? plugin.pluginId}
|
||||
</span>
|
||||
{focus && <span className="truncate text-xs text-muted">· {focus.name}</span>}
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{focus ? (
|
||||
<PluginLayoutCellView
|
||||
projectId={focus.id}
|
||||
cell={cell}
|
||||
onStateChange={setState}
|
||||
onOpenPlugins={() => {}}
|
||||
onChooseAnotherLayout={() => {}}
|
||||
/>
|
||||
) : (
|
||||
<Panel>
|
||||
<p className="text-sm text-muted">
|
||||
Ouvrez un projet dans la fenêtre principale pour afficher ce layout.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
{loading && (
|
||||
<p className="mt-3 text-xs text-muted">Chargement des plugins...</p>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
<DIProvider>
|
||||
{isWeb ? (
|
||||
<WebApp />
|
||||
) : viewParams ? (
|
||||
) : viewParams?.kind === "panel" ? (
|
||||
<ViewWindow panel={viewParams.panel} />
|
||||
) : viewParams?.kind === "pluginLayout" ? (
|
||||
<PluginLayoutWindow plugin={viewParams.plugin} />
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string, unknown>).__workspaceServiceKeys).toEqual([
|
||||
@ -294,6 +296,94 @@ describe("loadPlugins", () => {
|
||||
"open",
|
||||
"reattach",
|
||||
]);
|
||||
expect((globalThis as Record<string, unknown>).__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<string, unknown>).__uiServicesAvailable).toBe(true);
|
||||
expect((globalThis as Record<string, unknown>).__pluginWindowResult).toMatchObject({
|
||||
label: "view-plugin-layout",
|
||||
surface: { layoutType: "dev.acme.react.dashboard" },
|
||||
});
|
||||
expect((globalThis as Record<string, unknown>).__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 () => {
|
||||
|
||||
@ -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<T>(
|
||||
promise: Promise<T>,
|
||||
@ -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) ?? "<unknown-plugin>";
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<ProjectGateway>;
|
||||
workState?: Partial<WorkStateGateway>;
|
||||
terminal?: Partial<TerminalGateway>;
|
||||
window?: Partial<WindowGateway>;
|
||||
pluginWorkspace?: Partial<PluginWorkspaceGateway>;
|
||||
pluginTask?: Partial<PluginTaskGateway>;
|
||||
pluginToolchain?: Partial<PluginToolchainGateway>;
|
||||
@ -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<void> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@ -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<void>;
|
||||
}
|
||||
|
||||
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<PluginWindow>;
|
||||
}
|
||||
|
||||
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<string>;
|
||||
}
|
||||
|
||||
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<WorkspaceProject | null> {
|
||||
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 ?? "<unknown>"}" 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 };
|
||||
}
|
||||
|
||||
@ -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<void>;
|
||||
/**
|
||||
* 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<PluginLayoutWindowOpenResult>;
|
||||
/** Closes the detached window for `panel` if one is open. */
|
||||
closeViewWindow(panel: string): Promise<void>;
|
||||
/**
|
||||
|
||||
Submodule sdk/IdeaSDK updated: 7e1bb5fd38...fe50219493
Reference in New Issue
Block a user