Files
IdeA/crates/app-tauri/src/lib.rs
Blomios dcba76b871 feat(chat): livre la CLI custom de chat agent (#147) et corrige Cancel
Implémente la vue chat structurée par cellule agent (toggle TUI/CLI custom,
préférence persistée `preferred_view`, reattach live, composer + pièces
jointes) avec le socle backend AgentSession/ChatBridge (UserPrompt,
cancel_current_turn, routage interrupt_agent, commande cancel_agent_chat).

Corrige le bug bloquant relevé par QA : le bouton Cancel de
CustomAgentChatView interrompait tout le tour via closeAgentChat au lieu
de n'annuler que le tour courant via cancelAgentChat, ce qui tuait la
session contrairement au contrat produit validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 11:59:39 +02:00

1375 lines
50 KiB
Rust

//! # IdeA — `app-tauri` (presentation / driving adapter + composition root)
//!
//! This crate is the **only** place that knows every other crate. It:
//! - builds the concrete adapters and injects them into use cases
//! ([`state::AppState`], the composition root),
//! - exposes `#[tauri::command]` handlers ([`commands`]) mapping DTOs ↔ use cases,
//! - relays domain events to the frontend ([`events::TauriEventRelay`]),
//! - hosts the generic PTY↔Channel bridge ([`pty::PtyBridge`]) for L3 and its
//! structured-chat twin ([`chat::ChatBridge`]) for §17.
//!
//! The wiring lives in the library (testable) and `main.rs` is a thin shim.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod chat;
pub mod commands;
pub mod dto;
pub mod embedded_server;
pub mod events;
pub mod mcp_bridge;
pub mod mcp_endpoint;
pub mod openai_tools;
pub mod plugins;
pub mod pty;
pub mod server;
pub mod state;
pub mod stream;
pub mod templates;
pub mod tickets;
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use application::{AppError, GetAppExitWorkGuardStateInput, SnapshotOpenWindowsInput};
use domain::{
PersistedMonitorState, PersistedPluginLayoutWindow, PersistedWindowKind,
PersistedWindowPosition, PersistedWindowSize, PersistedWindowState, ProjectId,
};
use tauri::{
Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow,
WebviewWindowBuilder,
};
use uuid::Uuid;
use state::AppState;
static EXIT_GUARD_CONFIRMED: AtomicBool = AtomicBool::new(false);
static PANIC_HOOK_INSTALLED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MainCloseAction {
AllowShutdown,
PreventAndNotify,
}
fn decide_main_close_action(
has_work_in_progress: bool,
already_confirmed: bool,
) -> MainCloseAction {
if has_work_in_progress && !already_confirmed {
MainCloseAction::PreventAndNotify
} else {
MainCloseAction::AllowShutdown
}
}
fn should_install_exit_guard(window_label: &str) -> bool {
window_label == "main"
}
fn install_panic_diagnostics_hook() {
if PANIC_HOOK_INSTALLED.swap(true, Ordering::SeqCst) {
return;
}
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let message = info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
.unwrap_or("<non-string panic payload>");
let location = info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "<unknown>".to_owned());
let thread = std::thread::current();
let thread_name = thread.name().unwrap_or("<unnamed>");
application::diag!("[panic] thread={thread_name} location={location} message={message}");
application::diag!(
"[panic] backtrace:\n{}",
std::backtrace::Backtrace::force_capture()
);
previous(info);
}));
}
fn apply_main_close_decision(
guard: application::AppExitWorkGuardState,
already_confirmed: bool,
mut prevent_close: impl FnMut(),
mut emit_guard: impl FnMut(application::AppExitWorkGuardState),
mut shutdown: impl FnMut(),
) -> MainCloseAction {
let action = decide_main_close_action(guard.has_work_in_progress, already_confirmed);
match action {
MainCloseAction::PreventAndNotify => {
prevent_close();
emit_guard(guard);
}
MainCloseAction::AllowShutdown => shutdown(),
}
action
}
/// The `argv[1]` subcommand token that switches the binary into the headless
/// `mcp-server` **bridge** mode (cadrage v5 §1.3) instead of launching Tauri.
pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
/// The `argv[1]` subcommand token that starts the secure HTTP server adapter.
pub const SERVE_SUBCOMMAND: &str = "--serve";
/// Process entry point: routes `argv` **before** anything Tauri/WebKit is touched.
///
/// When invoked as `<exe> mcp-server …` (an MCP CLI spawned us from the injected
/// `.mcp.json` declaration), we run the **stdio↔loopback bridge** headless and
/// **never** initialise the webview — see [`mcp_bridge::run_mcp_bridge`]. When
/// invoked as `<exe> --serve …`, we run the secure HTTP adapter headless. Any
/// other invocation is the normal IDE launch: [`run`] (which blocks until the
/// window closes and then exits the process itself).
///
/// Returns the [`ExitCode`] for the bridge path; the normal path does not return.
#[must_use]
pub fn dispatch() -> ExitCode {
let mut args = std::env::args_os().skip(1);
if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) {
// Headless bridge: bypass Tauri entirely. Forward the remaining args
// (`--endpoint`, `--project`, `--requester`) to the bridge parser.
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
return mcp_bridge::run_mcp_bridge(rest);
}
let mut args = std::env::args_os().skip(1);
if args.next().is_some_and(|a| a == *SERVE_SUBCOMMAND) {
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
return server::run_from_args(rest);
}
run();
ExitCode::SUCCESS
}
/// Builds and runs the Tauri application.
///
/// Sets up the composition root (resolving the app-data directory via the Tauri
/// path API), registers commands, spawns the event relay, and starts the main
/// window.
///
/// # Panics
/// Panics if the Tauri application fails to build or run (no window/webview), or
/// if the app-data directory cannot be resolved.
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.register_uri_scheme_protocol("idea-plugin", |ctx, request| {
plugins::plugin_asset_protocol(ctx.app_handle(), request)
})
.setup(|app| {
// Resolve the machine-local IDE data directory (ARCHITECTURE §9.2)
// and build the composition root once the app handle exists, so the
// stores receive a concrete path without ever touching Tauri.
let app_data_dir = app
.path()
.app_data_dir()
.expect("failed to resolve the app data directory");
let resource_dir = app.path().resource_dir().ok();
// Point the orchestrator's best-effort diagnostics at a persistent file
// (`<app-data>/logs/idea.log`) so inter-agent rendezvous beacons survive a
// click-launched AppImage (whose stderr is otherwise discarded). Best-effort:
// if the file can't be opened the beacons simply stay on stderr.
application::diag::set_log_path(app_data_dir.join("logs").join("idea.log"));
install_panic_diagnostics_hook();
application::diag!("[startup] IdeA launched; diagnostics log armed");
let app_state = AppState::build_with_resource_dir(app_data_dir, resource_dir);
// Wire the domain event bus → Tauri events relay.
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
let embedded_server = Arc::clone(&app_state.embedded_server);
let plugin_mcp_reconcile = Arc::clone(&app_state.reconcile_plugin_mcp_servers);
let core = app_state.core();
app.manage(app_state);
tauri::async_runtime::spawn(async move {
if let Err(err) = embedded_server.auto_start_if_enabled(core).await {
application::diag!(
"[embedded-server] auto-start failed: {}: {}",
err.code,
err.message
);
}
});
tauri::async_runtime::spawn(async move {
if let Err(err) = plugin_mcp_reconcile.execute().await {
application::diag!("[plugins] MCP reconcile at boot failed: {err}");
}
});
// Kill all live PTYs cleanly when the main window is closing. This is
// independent of the per-view (navigation/layout) lifecycle — those
// must NEVER kill a PTY — and only fires on a genuine app shutdown.
// A brutal crash is best-effort and out of scope.
if should_install_exit_guard("main") && app.get_webview_window("main").is_some() {
let window = app
.get_webview_window("main")
.expect("main window existence checked above");
let handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
let already_confirmed = consume_exit_guard_confirmation();
let guard =
tauri::async_runtime::block_on(app_exit_work_guard_state(&handle));
if let Ok(guard) = guard {
let action = apply_main_close_decision(
guard,
already_confirmed,
|| api.prevent_close(),
|guard| {
let _ = handle.emit(
"app-exit-work-guard",
backend::dto::AppExitWorkGuardStateDto::from(guard),
);
},
|| shutdown_app_after_confirm(&handle),
);
if action == MainCloseAction::PreventAndNotify {
return;
}
} else {
shutdown_app_after_confirm(&handle);
}
}
});
}
restore_open_webview_windows(app.handle());
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::health,
commands::create_project,
commands::open_project,
commands::close_project,
commands::list_projects,
commands::read_project_context,
commands::update_project_context,
commands::get_project_permissions,
commands::update_project_permissions,
commands::update_agent_permissions,
commands::update_agent_effort,
commands::resolve_agent_permissions,
commands::get_project_system_permissions,
commands::update_project_system_permissions,
commands::update_agent_system_permissions,
commands::resolve_agent_system_permissions,
commands::get_mcp_tool_permissions,
commands::update_project_mcp_tool_permissions,
commands::update_agent_mcp_tool_permissions,
commands::open_terminal,
commands::write_terminal,
commands::resize_terminal,
commands::close_terminal,
commands::reattach_terminal,
commands::load_layout,
commands::mutate_layout,
commands::list_layouts,
commands::create_layout,
commands::rename_layout,
commands::delete_layout,
commands::set_active_layout,
commands::first_run_state,
commands::reference_profiles,
commands::detect_profiles,
commands::list_profiles,
commands::save_profile,
commands::save_opencode_provider_profile,
commands::list_opencode_providers,
commands::list_claude_models,
commands::list_codex_models,
commands::clone_profile_from_seed,
commands::clone_opencode_profile_from_seed,
commands::delete_profile,
commands::configure_profiles,
commands::list_model_servers,
commands::save_model_server,
commands::preview_model_server_command,
commands::delete_model_server,
commands::delete_model_artifact,
commands::list_embedder_profiles,
commands::save_embedder_profile,
commands::delete_embedder_profile,
commands::describe_embedder_engines,
commands::dismiss_embedder_suggestion,
commands::create_agent,
commands::list_agents,
commands::get_app_exit_work_guard_state,
commands::confirm_app_exit,
tickets::ticket_create,
tickets::ticket_read,
tickets::ticket_delete,
tickets::ticket_bulk_update_status,
tickets::ticket_bulk_update_priority,
tickets::ticket_bulk_delete,
tickets::open_ticket_chat,
tickets::close_ticket_chat,
tickets::ticket_list,
tickets::ticket_update,
tickets::ticket_attachment_add,
tickets::ticket_attachment_read,
tickets::ticket_attachment_mark_summarized,
tickets::ticket_read_carnet,
tickets::ticket_update_carnet,
tickets::ticket_link,
tickets::ticket_unlink,
tickets::ticket_assign,
tickets::sprint_create,
tickets::sprint_list,
tickets::sprint_rename,
tickets::sprint_reorder,
tickets::sprint_delete,
tickets::ticket_assign_sprint,
tickets::ticket_unassign_sprint,
commands::get_project_work_state,
commands::read_conversation_page,
commands::list_live_agents,
commands::attach_live_agent,
commands::stop_live_agent,
commands::read_agent_context,
commands::update_agent_context,
commands::delete_agent,
commands::launch_agent,
commands::change_agent_profile,
commands::agent_send,
commands::cancel_resume,
commands::set_resume_at,
commands::interrupt_agent,
commands::cancel_agent_chat,
commands::delegation_delivered,
commands::set_front_attached,
commands::reattach_agent_chat,
commands::close_agent_session,
commands::list_resumable_agents,
commands::inspect_conversation,
commands::create_template,
commands::update_template,
commands::list_templates,
commands::delete_template,
commands::create_agent_from_template,
commands::detect_agent_drift,
commands::sync_agent_with_template,
commands::git_status,
commands::git_stage,
commands::git_unstage,
commands::git_commit,
commands::git_branches,
commands::git_checkout,
commands::git_log,
commands::git_init,
commands::git_graph,
commands::create_skill,
commands::update_skill,
commands::list_skills,
commands::delete_skill,
commands::assign_skill_to_agent,
commands::unassign_skill_from_agent,
commands::create_memory,
commands::update_memory,
commands::list_memories,
commands::get_memory,
commands::delete_memory,
commands::read_memory_index,
commands::recall_memory,
commands::resolve_memory_links,
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,
commands::spawn_background_command,
commands::cancel_background_task,
commands::retry_background_task,
commands::attach_background_task,
commands::list_background_tasks,
plugins::plugin_list_plugins,
plugins::plugin_review_package,
plugins::plugin_install_from_archive,
plugins::plugin_install_from_directory,
plugins::plugin_set_enabled,
plugins::plugin_uninstall,
plugins::plugin_list_runtime_contributions,
plugins::plugin_workspace_read_text,
plugins::plugin_workspace_read_binary,
plugins::plugin_workspace_write_text,
plugins::plugin_workspace_write_binary,
plugins::plugin_storage_get,
plugins::plugin_storage_set,
plugins::plugin_storage_delete,
plugins::plugin_workspace_list_dir,
plugins::plugin_workspace_stat,
plugins::plugin_query_project_structure,
plugins::plugin_config_read_document,
plugins::plugin_config_update_document,
plugins::plugin_task_run_command,
plugins::plugin_task_get_status,
plugins::plugin_toolchain_diagnose,
plugins::plugin_events_subscribe,
plugins::plugin_events_poll,
plugins::plugin_events_unsubscribe,
plugins::plugin_open_plugins_folder,
commands::get_server_exposure_settings,
commands::save_server_exposure_settings,
commands::preview_server_exposure_settings,
commands::embedded_server_status,
commands::embedded_server_start,
commands::embedded_server_stop,
commands::embedded_server_generate_pairing_code,
commands::list_devices,
commands::create_pairing_code,
commands::rename_device,
commands::revoke_device,
commands::revoke_all_devices,
])
.run(tauri::generate_context!())
.expect("error while running IdeA Tauri application");
}
#[cfg(test)]
fn plugin_workspace_invoke_handler<R: tauri::Runtime>(
) -> impl Fn(tauri::ipc::Invoke<R>) -> bool + Send + Sync + 'static {
tauri::generate_handler![
plugins::plugin_workspace_read_text,
plugins::plugin_workspace_read_binary,
plugins::plugin_workspace_write_text,
plugins::plugin_workspace_write_binary,
plugins::plugin_storage_get,
plugins::plugin_storage_set,
plugins::plugin_storage_delete,
plugins::plugin_workspace_list_dir,
plugins::plugin_workspace_stat,
plugins::plugin_query_project_structure,
plugins::plugin_config_read_document,
plugins::plugin_config_update_document,
plugins::plugin_task_run_command,
plugins::plugin_task_get_status,
plugins::plugin_toolchain_diagnose,
plugins::plugin_events_subscribe,
plugins::plugin_events_poll,
plugins::plugin_events_unsubscribe,
]
}
async fn app_exit_work_guard_state(
handle: &tauri::AppHandle,
) -> Result<application::AppExitWorkGuardState, AppError> {
let Some(state) = handle.try_state::<AppState>() else {
return Ok(application::AppExitWorkGuardState {
has_work_in_progress: false,
busy_agent_count: 0,
active_background_task_count: 0,
details: Vec::new(),
});
};
let mut projects = Vec::new();
for project_id in state.open_project_ids() {
projects.push(state.project_store.load_project(project_id).await?);
}
state
.get_app_exit_work_guard_state
.execute(GetAppExitWorkGuardStateInput { projects })
.await
}
/// Executes the global shutdown teardown after the close guard has allowed exit.
///
/// The order intentionally mirrors the historical inline `CloseRequested` hook:
/// snapshot windows, snapshot `agent_was_running`, kill PTYs, stop model servers,
/// stop the embedded server, then close secondary webview windows.
pub fn shutdown_app_after_confirm(handle: &tauri::AppHandle) {
if let Some(state) = handle.try_state::<AppState>() {
let open_windows = snapshot_open_webview_windows(handle);
let window_snapshot = std::sync::Arc::clone(&state.snapshot_open_windows);
let pty = std::sync::Arc::clone(&state.pty_port);
let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents);
let model_servers = std::sync::Arc::clone(&state.ensure_local_model_server);
let embedded_server = std::sync::Arc::clone(&state.embedded_server);
let open_projects = state.open_project_ids();
let handles = state.terminal_sessions.handles();
tauri::async_runtime::block_on(async move {
let _ = window_snapshot
.execute(SnapshotOpenWindowsInput {
windows: open_windows,
})
.await;
for project_id in open_projects {
let _ = snapshot
.execute(application::SnapshotRunningAgentsInput { project_id })
.await;
}
for h in handles {
let _ = pty.kill(&h).await;
}
let _ = model_servers.stop_on_app_exit().await;
let _ = embedded_server.stop().await;
});
}
close_non_main_webview_windows(handle);
}
pub(crate) fn confirm_next_main_window_close() {
EXIT_GUARD_CONFIRMED.store(true, Ordering::SeqCst);
}
fn consume_exit_guard_confirmation() -> bool {
EXIT_GUARD_CONFIRMED.swap(false, Ordering::SeqCst)
}
pub(crate) async fn read_app_exit_work_guard_state(
handle: &tauri::AppHandle,
) -> Result<application::AppExitWorkGuardState, AppError> {
app_exit_work_guard_state(handle).await
}
fn close_non_main_webview_windows(handle: &tauri::AppHandle) {
for (label, window) in handle.webview_windows() {
if !should_close_with_main_window(&label) {
continue;
}
let _ = window.close();
}
}
fn should_close_with_main_window(label: &str) -> bool {
label != "main"
}
fn snapshot_open_webview_windows(handle: &tauri::AppHandle) -> Vec<PersistedWindowState> {
handle
.webview_windows()
.into_iter()
.filter_map(|(label, window)| snapshot_webview_window(handle, &label, &window))
.collect()
}
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()
.map(|p| PersistedWindowPosition { x: p.x, y: p.y });
let outer_size = window.outer_size().ok().map(|s| PersistedWindowSize {
width: s.width,
height: s.height,
});
let monitor = window.current_monitor().ok().flatten().map(|m| {
let position = m.position();
let size = m.size();
PersistedMonitorState {
name: m.name().cloned(),
scale_factor: Some(m.scale_factor()),
position: Some(PersistedWindowPosition {
x: position.x,
y: position.y,
}),
size: Some(PersistedWindowSize {
width: size.width,
height: size.height,
}),
}
});
Some(PersistedWindowState {
label: label.to_owned(),
kind,
panel,
project_id,
plugin_layout,
url,
visible: window.is_visible().unwrap_or(true),
maximized: window.is_maximized().unwrap_or(false),
fullscreen: window.is_fullscreen().unwrap_or(false),
outer_position,
outer_size,
monitor,
last_focused_at: None,
})
}
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, 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)?;
Some((
PersistedWindowKind::View,
Some(panel.as_str().to_owned()),
None,
None,
Some(commands::view_window_url(panel)),
))
}
fn persisted_view_identity_from_label(label: &str) -> Option<commands::ViewPanel> {
let rest = label.strip_prefix("view-")?;
if let Ok(panel) = commands::ViewPanel::parse(rest) {
return Some(panel);
}
let (panel, project_id) = rest.rsplit_once('-')?;
if project_id.len() != 32 || Uuid::parse_str(project_id).is_err() {
return None;
}
commands::ViewPanel::parse(panel).ok()
}
fn restore_open_webview_windows(handle: &tauri::AppHandle) {
let Some(state) = handle.try_state::<AppState>() else {
return;
};
let restore = std::sync::Arc::clone(&state.restore_open_windows);
let Ok(output) = tauri::async_runtime::block_on(async move { restore.execute().await }) else {
return;
};
for window_state in output.windows {
match window_state.kind {
PersistedWindowKind::Main => {
if let Some(window) = handle.get_webview_window("main") {
apply_persisted_window_state(handle, &window, &window_state);
}
}
PersistedWindowKind::View => {
restore_view_window(handle, &window_state);
}
PersistedWindowKind::PluginLayout => {
restore_plugin_layout_window(handle, &window_state);
}
}
}
}
fn restore_view_window(handle: &tauri::AppHandle, state: &PersistedWindowState) {
let Some(panel) = state
.panel
.as_deref()
.and_then(|raw| commands::ViewPanel::parse(raw).ok())
.or_else(|| persisted_view_identity_from_label(&state.label))
else {
return;
};
let label = commands::view_window_label(panel);
if handle.get_webview_window(&label).is_some() {
return;
}
let url = commands::view_window_url(panel);
let Ok(window) = WebviewWindowBuilder::new(handle, &label, WebviewUrl::App(url.into()))
.title(format!("IdeA - {}", panel.title()))
.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;
};
let event_app = handle.clone();
let event_label = label.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
commands::emit_view_window_lifecycle(&event_app, "closed", panel, &event_label);
}
});
apply_persisted_window_state(handle, &window, state);
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,
state: &PersistedWindowState,
) {
if persisted_monitor_is_available(handle, state.monitor.as_ref()) {
if let Some(size) = state.outer_size {
let _ = window.set_size(PhysicalSize::new(size.width, size.height));
}
if let Some(position) = state.outer_position {
let _ = window.set_position(PhysicalPosition::new(position.x, position.y));
}
} else {
let _ = window.center();
}
if state.fullscreen {
let _ = window.set_fullscreen(true);
} else if state.maximized {
let _ = window.maximize();
}
if state.visible {
let _ = window.show();
} else {
let _ = window.hide();
}
}
fn persisted_monitor_is_available(
handle: &tauri::AppHandle,
monitor: Option<&PersistedMonitorState>,
) -> bool {
let Some(saved) = monitor else {
return true;
};
let Ok(monitors) = handle.available_monitors() else {
return false;
};
monitors.into_iter().any(|current| {
if let Some(saved_name) = saved.name.as_deref() {
if current.name().is_some_and(|name| name == saved_name) {
return true;
}
}
let position_matches = saved.position.is_some_and(|p| {
let current_position = current.position();
p.x == current_position.x && p.y == current_position.y
});
let size_matches = saved.size.is_some_and(|s| {
let current_size = current.size();
s.width == current_size.width && s.height == current_size.height
});
position_matches && size_matches
})
}
#[cfg(test)]
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_from_label, should_install_exit_guard, MainCloseAction,
};
use super::{should_close_with_main_window, PersistedWindowKind};
use application::AppExitWorkGuardState;
use serde_json::json;
use std::cell::Cell;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY};
#[test]
fn main_close_without_work_allows_shutdown_without_preventing_close() {
assert_eq!(
decide_main_close_action(false, false),
MainCloseAction::AllowShutdown
);
}
#[test]
fn main_close_with_work_prevents_and_notifies_before_shutdown() {
assert_eq!(
decide_main_close_action(true, false),
MainCloseAction::PreventAndNotify
);
}
#[test]
fn main_close_with_work_emits_guard_payload_and_skips_shutdown() {
let prevented = Cell::new(false);
let shutdown = Cell::new(false);
let emitted = Cell::new(None);
let guard = AppExitWorkGuardState {
has_work_in_progress: true,
busy_agent_count: 2,
active_background_task_count: 1,
details: Vec::new(),
};
let action = apply_main_close_decision(
guard,
false,
|| prevented.set(true),
|payload| {
emitted.set(Some((
payload.busy_agent_count,
payload.active_background_task_count,
)))
},
|| shutdown.set(true),
);
assert_eq!(action, MainCloseAction::PreventAndNotify);
assert!(prevented.get());
assert_eq!(emitted.get(), Some((2, 1)));
assert!(!shutdown.get());
}
#[test]
fn main_close_without_work_runs_shutdown_without_prevent_or_emit() {
let prevented = Cell::new(false);
let shutdown = Cell::new(false);
let emitted = Cell::new(false);
let guard = AppExitWorkGuardState {
has_work_in_progress: false,
busy_agent_count: 0,
active_background_task_count: 0,
details: Vec::new(),
};
let action = apply_main_close_decision(
guard,
false,
|| prevented.set(true),
|_| emitted.set(true),
|| shutdown.set(true),
);
assert_eq!(action, MainCloseAction::AllowShutdown);
assert!(!prevented.get());
assert!(!emitted.get());
assert!(shutdown.get());
}
#[test]
fn confirmed_main_close_bypasses_guard_once_then_rearms() {
confirm_next_main_window_close();
let first_attempt_confirmed = consume_exit_guard_confirmation();
assert!(first_attempt_confirmed);
assert_eq!(
decide_main_close_action(true, first_attempt_confirmed),
MainCloseAction::AllowShutdown
);
let second_attempt_confirmed = consume_exit_guard_confirmation();
assert!(!second_attempt_confirmed);
assert_eq!(
decide_main_close_action(true, second_attempt_confirmed),
MainCloseAction::PreventAndNotify
);
}
#[test]
fn exit_guard_is_scoped_to_main_window_only() {
assert!(should_install_exit_guard("main"));
assert!(!should_install_exit_guard(
"view-work-00000000-0000-0000-0000-000000000001"
));
assert!(!should_install_exit_guard("settings"));
}
#[test]
fn main_window_close_does_not_target_main_again() {
assert!(!should_close_with_main_window("main"));
}
#[test]
fn main_window_close_targets_detached_view_windows() {
assert!(should_close_with_main_window(
"view-work-00000000-0000-0000-0000-000000000001"
));
}
#[test]
fn main_window_close_targets_other_auxiliary_windows() {
assert!(should_close_with_main_window("settings"));
}
#[test]
fn persisted_identity_accepts_main_and_stable_view_labels() {
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, 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")
.unwrap()
.as_str(),
"tickets"
);
}
#[test]
fn persisted_identity_accepts_new_panel_only_view_labels() {
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_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]
fn dto_plugins_workspace_commands_are_registered_in_tauri_invoke_handler() {
let app_data = test_app_data_dir("plugin-workspace-commands");
let app = mock_builder()
.manage(crate::state::AppState::build(app_data.clone()))
.invoke_handler(plugin_workspace_invoke_handler())
.build(mock_context(noop_assets()))
.expect("mock app builds");
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.expect("mock webview builds");
let missing_project = uuid::Uuid::from_u128(124).to_string();
for command in [
"plugin_workspace_read_text",
"plugin_workspace_read_binary",
"plugin_workspace_list_dir",
"plugin_workspace_stat",
"plugin_query_project_structure",
] {
let err = invoke_plugin_command(
&webview,
command,
json!({
"input": {
"projectId": missing_project.clone(),
"path": "src/main.rs",
"maxDepth": 2,
"maxEntries": 10
}
}),
)
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND", "{command}");
}
for (command, input) in [
(
"plugin_workspace_write_text",
json!({
"projectId": missing_project.clone(),
"path": "generated.txt",
"content": "hello\n"
}),
),
(
"plugin_workspace_write_binary",
json!({
"projectId": missing_project.clone(),
"path": "generated.bin",
"bytes": [1, 2, 3]
}),
),
] {
let err = invoke_plugin_command(&webview, command, json!({ "input": input }))
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND", "{command}");
}
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn dto_plugins_config_document_commands_are_registered_in_tauri_invoke_handler() {
let app_data = test_app_data_dir("plugin-config-document-commands");
let app = mock_builder()
.manage(crate::state::AppState::build(app_data.clone()))
.invoke_handler(plugin_workspace_invoke_handler())
.build(mock_context(noop_assets()))
.expect("mock app builds");
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.expect("mock webview builds");
let missing_project = uuid::Uuid::from_u128(130).to_string();
let err = invoke_plugin_command(
&webview,
"plugin_config_read_document",
json!({
"input": {
"projectId": missing_project.clone(),
"path": "config/settings.json",
"format": "json"
}
}),
)
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND");
let err = invoke_plugin_command(
&webview,
"plugin_config_update_document",
json!({
"input": {
"projectId": missing_project,
"path": "config/settings.json",
"format": "json",
"mode": "mergePatch",
"value": {"enabled": true}
}
}),
)
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND");
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn dto_plugins_storage_commands_are_registered_in_tauri_invoke_handler() {
let app_data = test_app_data_dir("plugin-storage-commands");
let app = mock_builder()
.manage(crate::state::AppState::build(app_data.clone()))
.invoke_handler(plugin_workspace_invoke_handler())
.build(mock_context(noop_assets()))
.expect("mock app builds");
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.expect("mock webview builds");
let get_err = invoke_plugin_command(
&webview,
"plugin_storage_get",
json!({
"input": {
"pluginId": "dev.acme.missing",
"key": "helloPlugin.launches"
}
}),
)
.expect_err("missing plugin must surface through the registered command");
assert_eq!(get_err["code"], "NOT_FOUND");
let set_err = invoke_plugin_command(
&webview,
"plugin_storage_set",
json!({
"input": {
"pluginId": "dev.acme.missing",
"key": "helloPlugin.launches",
"value": 1
}
}),
)
.expect_err("missing plugin must surface through the registered command");
assert_eq!(set_err["code"], "NOT_FOUND");
let delete_err = invoke_plugin_command(
&webview,
"plugin_storage_delete",
json!({
"input": {
"pluginId": "dev.acme.missing",
"key": "helloPlugin.launches"
}
}),
)
.expect_err("missing plugin must surface through the registered command");
assert_eq!(delete_err["code"], "NOT_FOUND");
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn dto_plugins_command_task_commands_are_registered_in_tauri_invoke_handler() {
let app_data = test_app_data_dir("plugin-task-commands");
let app = mock_builder()
.manage(crate::state::AppState::build(app_data.clone()))
.invoke_handler(plugin_workspace_invoke_handler())
.build(mock_context(noop_assets()))
.expect("mock app builds");
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.expect("mock webview builds");
let missing_project = uuid::Uuid::from_u128(125).to_string();
let owner = uuid::Uuid::from_u128(126).to_string();
let err = invoke_plugin_command(
&webview,
"plugin_task_run_command",
json!({
"input": {
"projectId": missing_project,
"ownerAgentId": owner,
"label": "cargo test",
"command": "cargo",
"args": ["test"],
"cwd": ".",
"env": [["RUST_LOG", "debug"]],
"recordOnly": true
}
}),
)
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND");
let task_id = uuid::Uuid::from_u128(127).to_string();
let value = invoke_plugin_command(
&webview,
"plugin_task_get_status",
json!({
"input": {
"taskId": task_id
}
}),
)
.expect("unknown task is a successful empty status");
assert_eq!(value, serde_json::Value::Null);
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn dto_plugins_toolchain_diagnostic_command_is_registered_in_tauri_invoke_handler() {
let app_data = test_app_data_dir("plugin-toolchain-diagnostic-command");
let app = mock_builder()
.manage(crate::state::AppState::build(app_data.clone()))
.invoke_handler(plugin_workspace_invoke_handler())
.build(mock_context(noop_assets()))
.expect("mock app builds");
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.expect("mock webview builds");
let missing_project = uuid::Uuid::from_u128(126).to_string();
let err = invoke_plugin_command(
&webview,
"plugin_toolchain_diagnose",
json!({
"input": {
"projectId": missing_project,
"cwd": ".",
"tools": [{
"id": "rust",
"executable": "cargo",
"versionArgs": ["--version"],
"required": true
}],
"env": [{
"name": "RUSTUP_HOME",
"required": false
}],
"files": [{
"path": "Cargo.toml",
"required": true,
"kind": "file"
}]
}
}),
)
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND");
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn dto_plugins_event_commands_are_registered_in_tauri_invoke_handler() {
let app_data = test_app_data_dir("plugin-event-commands");
let app = mock_builder()
.manage(crate::state::AppState::build(app_data.clone()))
.invoke_handler(plugin_workspace_invoke_handler())
.build(mock_context(noop_assets()))
.expect("mock app builds");
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.expect("mock webview builds");
let missing_project = uuid::Uuid::from_u128(127).to_string();
let err = invoke_plugin_command(
&webview,
"plugin_events_subscribe",
json!({
"input": {
"projectId": missing_project,
"eventTypes": ["workspaceFileChanged", "backgroundTaskChanged"],
"capacity": 10
}
}),
)
.expect_err("missing project must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND");
let subscription_id = uuid::Uuid::from_u128(128).to_string();
let err = invoke_plugin_command(
&webview,
"plugin_events_poll",
json!({
"input": {
"subscriptionId": subscription_id.clone(),
"maxEvents": 10
}
}),
)
.expect_err("unknown subscription must surface through the registered command");
assert_eq!(err["code"], "NOT_FOUND");
let disposed = invoke_plugin_command(
&webview,
"plugin_events_unsubscribe",
json!({
"input": {
"subscriptionId": subscription_id
}
}),
)
.expect("unsubscribe is idempotent");
assert_eq!(disposed["retention"], "disposed");
std::fs::remove_dir_all(app_data).ok();
}
fn invoke_plugin_command<W: AsRef<tauri::Webview<tauri::test::MockRuntime>>>(
webview: &W,
command: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, serde_json::Value> {
get_ipc_response(
webview,
tauri::webview::InvokeRequest {
cmd: command.to_owned(),
callback: tauri::ipc::CallbackFn(0),
error: tauri::ipc::CallbackFn(1),
url: "tauri://localhost".parse().unwrap(),
body: tauri::ipc::InvokeBody::Json(body),
headers: Default::default(),
invoke_key: INVOKE_KEY.to_owned(),
},
)
.map(|body| body.deserialize::<serde_json::Value>().unwrap())
}
fn test_app_data_dir(label: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("idea-{label}-{}-{nanos}", std::process::id()))
}
}