mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
Running Spring Boot main-class configurations had no Update action, and incremental Java builds did not publish edited resources to the running JVM. They now share the Update action, toolbar and build runner with Tomcat: resources go through the module's Maven or Gradle build and are published to the resolved runtime classpath, including Gradle's separate jdt.ls output directory. Class updates use scoped HotSwap when available and explain the restart requirement otherwise. Both configuration types get "On 'Update' action" and "On frame deactivation" policies. A missing policy keeps the chooser and does no deactivation update. Focus tracking debounces external changes and prevents overlapping updates. Verified on macOS with Maven, Gradle, Tomcat 10.1 and HotSwap. Windows and Linux focus detection were not run. On Linux a native GTK dialog can count as deactivation when no webview window is focused. Fixes #580
236 lines
9.9 KiB
Rust
236 lines
9.9 KiB
Rust
//! OpenIDEA desktop shell: wires the backend crates into Tauri.
|
|
//!
|
|
//! This crate is glue only. Each `commands/<domain>.rs` is a thin, typed
|
|
//! adapter from IPC to a backend crate; no domain logic lives here.
|
|
|
|
mod application_focus;
|
|
mod commands;
|
|
#[cfg(debug_assertions)]
|
|
mod dev;
|
|
mod events;
|
|
mod logging;
|
|
mod menu;
|
|
mod remote_dev;
|
|
|
|
mod state;
|
|
mod theme_frame;
|
|
#[cfg(target_os = "macos")]
|
|
mod titlebar;
|
|
mod window_groups;
|
|
mod windows;
|
|
|
|
use parking_lot::Mutex;
|
|
use state::AppState;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use tauri::{Emitter, Manager};
|
|
use windows::WindowsState;
|
|
|
|
/// Builds the tauri-specta builder holding every command and exported type.
|
|
/// Also used by `export_bindings` (tests) to regenerate `src/bindings.ts`.
|
|
pub fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
|
commands::builder().typ::<ide_core::IdeEvent>().dangerously_cast_bigints_to_number()
|
|
}
|
|
|
|
pub fn export_bindings(builder: &tauri_specta::Builder<tauri::Wry>) {
|
|
builder
|
|
.export(
|
|
specta_typescript::Typescript::default().header("// @ts-nocheck\n/* eslint-disable */\n// Generated by tauri-specta. Do not edit; run `pnpm gen:bindings`."),
|
|
concat!(env!("CARGO_MANIFEST_DIR"), "/../src/bindings.ts"),
|
|
)
|
|
.expect("failed to export TypeScript bindings");
|
|
}
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
ide_workspace::process::mark_start();
|
|
let context = tauri::generate_context!();
|
|
#[cfg(debug_assertions)]
|
|
let context = dev::configure(context);
|
|
logging::init(&context.config().identifier);
|
|
|
|
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
|
|
// Managed LSP/DAP installs; must run before other threads start and before PATH is cached.
|
|
if let Some(data) = dirs::data_dir() {
|
|
ide_tools::extend_process_path(&data.join(&context.config().identifier));
|
|
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data.join(&context.config().identifier)));
|
|
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data.join(&context.config().identifier)));
|
|
}
|
|
if let Some(refresh) = refresh_shell_path {
|
|
refresh();
|
|
}
|
|
|
|
let builder = specta_builder();
|
|
#[cfg(debug_assertions)]
|
|
export_bindings(&builder);
|
|
|
|
let app = tauri::Builder::default();
|
|
// Single instance first: a second launch focuses this one and opens its path argument.
|
|
// Debug builds have a separate identifier per worktree.
|
|
let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance));
|
|
let app = app
|
|
.plugin(tauri_plugin_opener::init())
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(
|
|
tauri_plugin_window_state::Builder::default()
|
|
.with_state_flags(window_state_flags())
|
|
// One remembered geometry shared by every project's settings window.
|
|
.map_label(|l| if windows::is_settings_window(l) { "settings" } else { l })
|
|
.build(),
|
|
)
|
|
// Inert until an endpoint is configured (see `commands::window::app_check_update`).
|
|
.plugin(tauri_plugin_updater::Builder::new().build());
|
|
// E2E automation bridge (Tauri MCP), debug builds only, localhost only.
|
|
#[cfg(debug_assertions)]
|
|
let app = app.plugin(tauri_plugin_mcp_bridge::Builder::new().bind_address("127.0.0.1").build());
|
|
|
|
// Paths macOS asked to open (Finder's Open With, the Dock) before setup ran: the first window takes them.
|
|
let early_opens: Arc<Mutex<Option<Vec<PathBuf>>>> = Arc::default();
|
|
let setup_opens = early_opens.clone();
|
|
let invoke = Arc::new(builder.invoke_handler());
|
|
app.invoke_handler(move |request| {
|
|
let webview = request.message.webview_ref();
|
|
if let Some(state) = webview.try_state::<AppState>() {
|
|
let label = AppState::owner_window(webview.label());
|
|
if let Some(connection) = state.remote_dev.get(label) {
|
|
let label = label.to_owned();
|
|
let command = request.message.command().to_owned();
|
|
let invoke = invoke.clone();
|
|
let app = webview.app_handle().clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
match connection.authorize_command(&label, &command).await {
|
|
Ok(()) => {
|
|
let resolver = request.resolver.clone();
|
|
// Sync commands must run on the main thread, as they do in local windows.
|
|
if let Err(error) = app.run_on_main_thread(move || {
|
|
invoke(request);
|
|
}) {
|
|
resolver.reject(ide_core::IdeError::internal(format!(
|
|
"failed to dispatch command on the main thread: {error}"
|
|
)));
|
|
}
|
|
}
|
|
Err(error) => request.resolver.reject(error),
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
if let Err(error) = state.host.authorize_command(webview.label(), request.message.command()) {
|
|
request.resolver.reject(error);
|
|
return true;
|
|
}
|
|
}
|
|
invoke(request)
|
|
})
|
|
.setup(move |app| {
|
|
builder.mount_events(app);
|
|
let state = AppState::new(app.handle().clone())?;
|
|
let windows = WindowsState::default();
|
|
if let Ok(cwd) = std::env::current_dir()
|
|
&& let Some(path) = windows::cli_path(&std::env::args().collect::<Vec<_>>(), &cwd)
|
|
{
|
|
windows.set_launch("main", windows::path_launch(path));
|
|
}
|
|
let menu_state = menu::MenuState::default();
|
|
menu::install(app.handle(), &menu_state, &state.recent.list());
|
|
app.manage(state);
|
|
app.manage(windows);
|
|
app.manage(application_focus::ApplicationFocus::default());
|
|
#[cfg(target_os = "macos")]
|
|
application_focus::observe(app.handle());
|
|
app.manage(menu_state);
|
|
if let Some(paths) = setup_opens.lock().take() {
|
|
windows::open_paths_at_launch(app.handle(), paths);
|
|
}
|
|
app.on_menu_event(on_menu_event);
|
|
windows::create_main_window(app.handle())?;
|
|
Ok(())
|
|
})
|
|
.on_window_event(|window, event| match event {
|
|
tauri::WindowEvent::Focused(true) => {
|
|
application_focus::changed(window.app_handle(), true);
|
|
if let Some(w) = window.try_state::<WindowsState>() {
|
|
w.focused(window.label());
|
|
}
|
|
}
|
|
tauri::WindowEvent::Focused(false) => application_focus::changed(window.app_handle(), false),
|
|
tauri::WindowEvent::Destroyed => {
|
|
#[cfg(target_os = "macos")]
|
|
titlebar::forget(window);
|
|
window_groups::destroyed(window.app_handle(), window.label());
|
|
if let Some(settings) = window.app_handle().get_webview_window(&windows::settings_label(window.label())) {
|
|
let _ = settings.destroy();
|
|
}
|
|
if let Some(state) = window.try_state::<AppState>() {
|
|
state.close_project(window.label());
|
|
}
|
|
if let Some(w) = window.try_state::<WindowsState>() {
|
|
w.forget(window.label());
|
|
}
|
|
if !windows::is_settings_window(window.label()) {
|
|
windows::continue_quit(window.app_handle(), Some(window.label()));
|
|
}
|
|
}
|
|
_ => {}
|
|
})
|
|
.build(context)
|
|
.expect("error while running OpenIDEA")
|
|
.run(move |app, event| {
|
|
#[cfg(target_os = "macos")]
|
|
if let tauri::RunEvent::Opened { urls } = &event {
|
|
let paths = windows::opened_paths(urls);
|
|
if app.try_state::<WindowsState>().is_some() {
|
|
windows::open_paths(app, paths);
|
|
} else {
|
|
early_opens.lock().get_or_insert_default().extend(paths);
|
|
}
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
let _ = &early_opens;
|
|
if let tauri::RunEvent::Exit = event {
|
|
if let Some(state) = app.try_state::<AppState>() {
|
|
state.host.shutdown_notebooks_on_exit();
|
|
}
|
|
// Cleanup must finish before update/restart replaces this process.
|
|
if app.try_state::<WindowsState>().is_some_and(|w| w.restart_requested()) {
|
|
tauri::process::restart(&app.env());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn window_state_flags() -> tauri_plugin_window_state::StateFlags {
|
|
use tauri_plugin_window_state::StateFlags;
|
|
StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED | StateFlags::FULLSCREEN
|
|
}
|
|
|
|
/// Routes IDE menu items to the focused window's frontend (`executeAction`).
|
|
fn on_menu_event(app: &tauri::AppHandle, event: tauri::menu::MenuEvent) {
|
|
let id = event.id().as_ref();
|
|
let Some(route) = menu::route(&menu::spec(menu::Os::current()), id) else { return };
|
|
match windows::target_window(app) {
|
|
Some(w) => {
|
|
// Settings is modal for its project window: only app-level items get through, to the parent.
|
|
let label = match windows::settings_parent(w.label()) {
|
|
Some(parent)
|
|
if matches!(route.action.as_str(), "app.quit" | "file.settings" | "file.newProjectsSettings") =>
|
|
{
|
|
parent
|
|
}
|
|
Some(_) => return,
|
|
None => w.label(),
|
|
};
|
|
if let Err(e) = app.emit_to(label, menu::MENU_EVENT, &route) {
|
|
tracing::warn!("failed to deliver menu event {id}: {e}");
|
|
}
|
|
}
|
|
// No window left (macOS keeps the menu bar): handle the few that make sense.
|
|
None => match route.action.as_str() {
|
|
"app.quit" => app.exit(0),
|
|
"window.new" | "file.openProject" => windows::spawn_window(app, None),
|
|
menu::OPEN_RECENT_ACTION => windows::spawn_window(app, route.arg.map(Into::into)),
|
|
_ => {}
|
|
},
|
|
}
|
|
}
|