feat(window): support des fenêtres plugin-hébergées (backend)

Étend le port/usecases window et layout.customPluginLayout pour ouvrir et
piloter des fenêtres OS hébergeant un layout plugin, en réutilisant
contributes.layouts plutôt qu'un système de fenêtres parallèle. Câble la
commande Tauri et l'état app-tauri correspondants.

Cargo test -p domain --test window : 5/5
Cargo test -p application --test window_usecases : 7/7
Cargo test -p infrastructure --test window_state_store : 1/1
Cargo test -p app-tauri view_window_tests : 8/8

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 00:39:42 +02:00
parent 3ea1d58b38
commit 551eb09ad2
14 changed files with 884 additions and 95 deletions

View File

@ -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.

View File

@ -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]

View File

@ -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 {

View File

@ -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(|_| ())
}
}

View File

@ -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::{

View File

@ -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,

View File

@ -4,6 +4,7 @@
mod usecases;
pub use usecases::{
MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, RestoreOpenWindows,
MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, OpenPluginLayoutWindow,
OpenPluginLayoutWindowInput, OpenPluginLayoutWindowOutput, RestoreOpenWindows,
RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput,
};

View File

@ -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,
})
}
}

View File

@ -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"]
);
}

View File

@ -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,

View File

@ -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>,

View File

@ -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};

View File

@ -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(),
),

View File

@ -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,