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:
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user