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

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