дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
On macOS the menu bar drew each IDE item's shortcut as text after its label, since only Windows renders that tab as a column. IDE items now carry the shortcut of their first keymap binding as a native key equivalent, so it appears in the shortcut column; chords and double modifiers show none. The JS keymap still dispatches every shortcut: a menu event fired by a key equivalent is dropped, because that key reaches the menu only after the keymap declined it. IDE items also do not run while a native sheet or modal is open. A forced enablement refresh is no longer lost when a context update reschedules it. Co-authored-by: lleviathe <[email protected]>
355 рядки
15 KiB
Rust
355 рядки
15 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 cli;
|
|
mod commands;
|
|
#[cfg(debug_assertions)]
|
|
mod dev;
|
|
mod events;
|
|
mod jump_list;
|
|
#[cfg(any(target_os = "linux", test))]
|
|
mod linux_webkit;
|
|
#[cfg(any(target_os = "linux", test))]
|
|
mod linux_window_state;
|
|
mod logging;
|
|
#[cfg(target_os = "macos")]
|
|
mod macos;
|
|
mod menu;
|
|
#[cfg(target_os = "macos")]
|
|
mod page_swipe;
|
|
mod plugin_web;
|
|
#[cfg(target_os = "macos")]
|
|
mod relaunch;
|
|
mod remote_container;
|
|
mod remote_dev;
|
|
mod remote_wsl;
|
|
|
|
mod state;
|
|
mod theme_frame;
|
|
#[cfg(target_os = "macos")]
|
|
mod titlebar;
|
|
mod window_groups;
|
|
#[cfg(any(windows, test))]
|
|
mod window_work_area;
|
|
mod windows;
|
|
|
|
use parking_lot::Mutex;
|
|
use state::AppState;
|
|
use std::path::{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>) {
|
|
let target = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../src/bindings.ts"));
|
|
// Rewritten only when it changes: every debug launch exports, also a second instance that just
|
|
// hands its path over, and a rewrite makes Vite reload the running app's windows.
|
|
let fresh = std::env::temp_dir().join(format!("openidea-bindings-{}.ts", std::process::id()));
|
|
builder
|
|
.export(
|
|
specta_typescript::Typescript::default().header("// @ts-nocheck\n/* eslint-disable */\n// Generated by tauri-specta. Do not edit; run `pnpm gen:bindings`."),
|
|
&fresh,
|
|
)
|
|
.expect("failed to export TypeScript bindings");
|
|
let text = std::fs::read(&fresh).expect("exported TypeScript bindings");
|
|
let _ = std::fs::remove_file(&fresh);
|
|
if std::fs::read(target).ok().as_deref() != Some(text.as_slice()) {
|
|
std::fs::write(target, text).expect("failed to write 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);
|
|
if let Some(project) = ide_mcp::stdio_request(&std::env::args().collect::<Vec<_>>()) {
|
|
let app_data = dirs::data_dir().unwrap_or_default().join(&context.config().identifier);
|
|
std::process::exit(ide_mcp::stdio::run(&ide_mcp::discovery_path(&app_data), project));
|
|
}
|
|
// The `openidea` command: answer and exit, no UI.
|
|
if let Some(code) = cli::run(&context.config().identifier) {
|
|
std::process::exit(code);
|
|
}
|
|
// Before the first window: one taskbar identity for every launch path.
|
|
jump_list::set_app_id(&context.config().identifier);
|
|
logging::init(&context.config().identifier);
|
|
#[cfg(target_os = "linux")]
|
|
linux_webkit::configure();
|
|
|
|
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_local_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));
|
|
// Install before window-state restores maximized geometry during window creation.
|
|
#[cfg(windows)]
|
|
let app = app.plugin(window_work_area::init());
|
|
let app = app.plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init());
|
|
#[cfg(target_os = "linux")]
|
|
let app = app.plugin(linux_window_state::plugin());
|
|
#[cfg(not(target_os = "linux"))]
|
|
let window_state = tauri_plugin_window_state::Builder::default()
|
|
.with_state_flags(window_state_flags())
|
|
// One remembered geometry per kind of auxiliary window (all projects' settings windows share one).
|
|
.map_label(windows::state_key);
|
|
#[cfg(target_os = "macos")]
|
|
let window_state = window_state.with_filter(windows::keeps_window_state);
|
|
#[cfg(not(target_os = "linux"))]
|
|
let app = app.plugin(window_state.build());
|
|
let app = app
|
|
// Inert until an endpoint is configured (see `commands::window::app_check_update`).
|
|
.plugin(tauri_plugin_updater::Builder::new().build());
|
|
// Files of plugins' web views, for their sandboxed frames.
|
|
let app = app.register_asynchronous_uri_scheme_protocol(plugin_web::SCHEME, plugin_web::handle);
|
|
// 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 typed_invoke = builder.invoke_handler();
|
|
// Raw Response is not a JSON/Specta type; keep the binary command out of generated bindings.
|
|
let raw_invoke: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![commands::fonts::user_font_data];
|
|
let invoke = Arc::new(move |request: tauri::ipc::Invoke| {
|
|
if request.message.command() == "user_font_data" { raw_invoke(request) } else { typed_invoke(request) }
|
|
});
|
|
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 args = std::env::args().collect::<Vec<_>>();
|
|
match windows::cli_launch(&args, &cwd) {
|
|
Some(windows::CliLaunch::Path(path)) => {
|
|
windows::remember_cli_position(&windows, &args, &path);
|
|
windows.set_launch("main", windows::path_launch(path));
|
|
}
|
|
// `openidea diff a.txt b.txt`: the main window opens the pair in the diff viewer.
|
|
Some(windows::CliLaunch::Diff(diff)) => {
|
|
windows.set_launch("main", windows::Launch { diff: Some(Box::new(diff)), ..Default::default() });
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
let menu_state = menu::MenuState::default();
|
|
menu::install(app.handle(), &menu_state, &state.recent.list());
|
|
app.manage(state);
|
|
commands::mcp::apply(&app.state::<AppState>());
|
|
app.manage(windows);
|
|
app.manage(application_focus::ApplicationFocus::default());
|
|
#[cfg(target_os = "macos")]
|
|
macos::intercept_system_quit(app.handle());
|
|
#[cfg(target_os = "macos")]
|
|
if let Some(mtm) = objc2::MainThreadMarker::new() {
|
|
macos::prepare_window_tabbing(mtm);
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
application_focus::observe(app.handle());
|
|
app.manage(menu_state);
|
|
cli::serve(app.handle(), &app.config().identifier);
|
|
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())?;
|
|
jump_list::refresh(app.handle(), app.state::<AppState>().recent.list());
|
|
Ok(())
|
|
})
|
|
.on_window_event(|window, event| match event {
|
|
tauri::WindowEvent::CloseRequested { .. } => {
|
|
#[cfg(target_os = "linux")]
|
|
linux_window_state::save_before_close(window.app_handle());
|
|
if let Some(w) = window.try_state::<WindowsState>() {
|
|
w.close_requested(window.label());
|
|
}
|
|
}
|
|
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::Moved(_) | tauri::WindowEvent::Resized(_) => {
|
|
window_groups::frame_changed(window.app_handle(), window.label());
|
|
}
|
|
tauri::WindowEvent::Destroyed => {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
page_swipe::forget(window.label());
|
|
titlebar::forget(window);
|
|
}
|
|
if windows::auxiliary_parent(window.label()).is_none() {
|
|
window_groups::destroyed(window.app_handle(), window.label());
|
|
windows::close_auxiliary_windows(window.app_handle(), window.label());
|
|
if let Some(state) = window.try_state::<AppState>() {
|
|
state.close_project(window.label());
|
|
state.plugins.forget_client(window.label());
|
|
}
|
|
}
|
|
if let Some(w) = window.try_state::<WindowsState>() {
|
|
w.forget(window.label());
|
|
}
|
|
if windows::auxiliary_parent(window.label()).is_none() {
|
|
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.stop_processes_on_exit();
|
|
commands::mcp::stop(&state);
|
|
state.host.clear_workspace_edit_history_on_exit();
|
|
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()) {
|
|
#[cfg(target_os = "macos")]
|
|
relaunch::restart(&app.env());
|
|
#[cfg(not(target_os = "macos"))]
|
|
tauri::process::restart(&app.env());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
fn window_state_flags() -> tauri_plugin_window_state::StateFlags {
|
|
use tauri_plugin_window_state::StateFlags;
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
// Start at the configured size, centered, instead of restoring geometry after the
|
|
// window is visible (Windows briefly showed the default position before that move).
|
|
StateFlags::MAXIMIZED | StateFlags::FULLSCREEN
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
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();
|
|
// A keymap accelerator only draws the shortcut: its key reached the menu because the keymap declined it.
|
|
#[cfg(target_os = "macos")]
|
|
if !menu::fires_natively(&menu::spec(menu::Os::Mac), id)
|
|
&& (macos::handling_key_press() || macos::native_modal_open())
|
|
{
|
|
return;
|
|
}
|
|
dispatch_menu_action(app, id);
|
|
}
|
|
|
|
/// Runs menu item `id` as if it were chosen: in the focused window's frontend, or here without one.
|
|
pub(crate) fn dispatch_menu_action(app: &tauri::AppHandle, id: &str) {
|
|
let Some(route) = menu::route(&menu::spec(menu::Os::current()), id) else { return };
|
|
match windows::target_window(app) {
|
|
Some(w) => {
|
|
// App-level items of an auxiliary window go to its project window. Settings is modal for it:
|
|
// nothing else gets through. Others handle their own items; Close Tab closes the window.
|
|
let label = match windows::auxiliary(w.label()) {
|
|
Some((_, parent))
|
|
if matches!(
|
|
route.action.as_str(),
|
|
"app.quit" | "file.settings" | "file.projectStructure" | "file.newProjectsSettings"
|
|
) =>
|
|
{
|
|
parent
|
|
}
|
|
Some((kind, _)) if kind.modal() => return,
|
|
Some(_) if route.action == "editor.closeTab" => {
|
|
let _ = w.close();
|
|
return;
|
|
}
|
|
_ => 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)),
|
|
_ => {}
|
|
},
|
|
}
|
|
}
|