//! Project windows: one project per window, labelled `main` or `project-`. //! //! A new window is built from the `main` window config (overlay title bar, //! traffic lights, min size, background colour). What it should open is kept //! here as a pending [`Launch`] that its frontend takes on startup //! (`window_take_launch`), and re-taken whenever [`LAUNCH_EVENT`] fires (a //! second app instance asking an already-loaded window to open a path). //! //! Settings open in a separate window `settings-` (one per project //! window) that shares its parent's project (see [`AppState::project`]); its //! [`Launch::settings_page`] tells it which page to show. use crate::state::AppState; use crate::theme_frame::builtin_frame; use ide_core::settings::AppearanceSettings; use ide_core::{IdeError, IdeResult}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use tauri::utils::config::{Color, WindowConfig}; use tauri::{AppHandle, Emitter, Manager, Theme, WebviewWindow, WebviewWindowBuilder}; /// Tells a window that a pending launch is waiting for it. pub const LAUNCH_EVENT: &str = "openidea://launch"; const MAIN: &str = "main"; const PREFIX: &str = "project-"; const SETTINGS_PREFIX: &str = "settings-"; const CASCADE: f64 = 28.0; const SETTINGS_SIZE: (f64, f64) = (980.0, 720.0); const SETTINGS_MIN_SIZE: (f64, f64) = (640.0, 420.0); /// What a freshly opened window should do once its frontend is up. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct Launch { /// Project folder to open. pub project: Option, /// Files to open in the editor: in `project` once it is open, else in this /// window's project or (without one) in LightEdit. pub files: Vec, /// Remote project to open (Remote Development). pub remote: Option, /// Settings windows: the page to show. pub settings_page: Option, /// Settings windows: edit the global layer only ("Settings for New Projects"). pub new_projects: bool, } /// A project folder on the host of an SSH configuration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct RemoteLaunch { pub ssh_config: String, pub path: String, } #[derive(Default)] pub struct WindowsState { pub groups: Mutex, pub groups_restored: AtomicBool, pub groups_persist: tokio::sync::Mutex<()>, launches: Mutex>, /// Most recently focused window label (menu events go there). last_focused: Mutex>, /// Set by Quit while windows close one by one. quitting: AtomicBool, /// Relaunch once the quit completes (after installing an update). restart: AtomicBool, } impl WindowsState { pub fn set_launch(&self, label: &str, launch: Launch) { self.launches.lock().insert(label.to_owned(), launch); } pub fn take_launch(&self, label: &str) -> Option { self.launches.lock().remove(label) } /// Adds `files` to `label`'s pending launch, so files sent before its frontend took the last batch aren't lost. pub fn add_files(&self, label: &str, files: Vec) { let mut launches = self.launches.lock(); let launch = launches.entry(label.to_owned()).or_default(); launch.files.extend(files.into_iter().map(Into::into)); } pub(crate) fn pending(&self, label: &str) -> Option { self.launches.lock().get(label).cloned() } pub fn focused(&self, label: &str) { *self.last_focused.lock() = Some(label.to_owned()); } pub fn forget(&self, label: &str) { self.launches.lock().remove(label); let mut last = self.last_focused.lock(); if last.as_deref() == Some(label) { *last = None; } } pub fn last_focused(&self) -> Option { self.last_focused.lock().clone() } pub fn set_quitting(&self, on: bool) { self.quitting.store(on, Ordering::SeqCst); if !on { self.restart.store(false, Ordering::SeqCst); } } pub fn set_restart(&self) { self.restart.store(true, Ordering::SeqCst); } pub fn restart_requested(&self) -> bool { self.restart.load(Ordering::SeqCst) } pub fn quitting(&self) -> bool { self.quitting.load(Ordering::SeqCst) } } /// The project window a settings window belongs to (`settings-main` → `main`). pub fn settings_parent(label: &str) -> Option<&str> { label.strip_prefix(SETTINGS_PREFIX) } pub fn is_settings_window(label: &str) -> bool { settings_parent(label).is_some() } pub fn settings_label(parent: &str) -> String { format!("{SETTINGS_PREFIX}{parent}") } /// Closes `parent`'s settings window: it shows (and would apply to) the project the parent is leaving. pub fn close_settings_window(app: &AppHandle, parent: &str) { if let Some(w) = app.get_webview_window(&settings_label(parent)) { let _ = w.close(); } } /// Project windows (everything but settings windows), for counting and cycling. pub fn project_window_labels(app: &AppHandle) -> Vec { sorted_labels(app.webview_windows().keys().map(String::as_str).filter(|l| !is_settings_window(l))) } /// Sort key: `main` first, then `project-` by n, then anything else. fn order_key(label: &str) -> (u8, u64, String) { if label == MAIN { (0, 0, String::new()) } else if let Some(n) = label.strip_prefix(PREFIX).and_then(|n| n.parse().ok()) { (1, n, String::new()) } else { (2, 0, label.to_owned()) } } pub fn sorted_labels<'a>(labels: impl IntoIterator) -> Vec { let mut v: Vec = labels.into_iter().map(str::to_owned).collect(); v.sort_by_key(|l| order_key(l)); v } /// Lowest free `project-` label. pub fn next_label<'a>(existing: impl IntoIterator) -> String { let used: std::collections::HashSet<&str> = existing.into_iter().collect(); (1..).map(|n| format!("{PREFIX}{n}")).find(|l| !used.contains(l.as_str())).expect("unbounded") } /// The window after (or before) `current` in label order, wrapping around. pub fn cycle_target(sorted: &[String], current: &str, forward: bool) -> Option { if sorted.len() < 2 { return None; } let i = sorted.iter().position(|l| l == current).unwrap_or(0); let n = sorted.len(); let j = if forward { (i + 1) % n } else { (i + n - 1) % n }; Some(sorted[j].clone()) } /// The project path from command-line arguments (`openidea /path/to/project`): /// the first argument after the binary that is not a flag, resolved against `cwd`. pub fn cli_path(args: &[String], cwd: &Path) -> Option { args.iter().skip(1).find(|a| !a.starts_with('-') && !a.is_empty() && !a.starts_with("psn_")).map(|a| { let p = PathBuf::from(a); if p.is_absolute() { p } else { cwd.join(p) } }) } pub fn focus(window: &WebviewWindow) { if crate::window_groups::select(window.app_handle(), window.label(), window.label()).is_ok() { return; } let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); } /// Present one project's save prompt at a time, including hidden group members. pub fn continue_quit(app: &AppHandle, closed: Option<&str>) { if !app.state::().quitting() { return; } let next = project_window_labels(app) .into_iter() .filter(|label| Some(label.as_str()) != closed) .find_map(|label| app.get_webview_window(&label)); if let Some(window) = next { focus(&window); if window.close().is_err() { app.state::().set_quitting(false); } } else { app.exit(0); } } /// The window whose open project is `root` (already canonical). pub fn find_project_window(app: &AppHandle, root: &Path) -> Option { let state = app.try_state::()?; app.webview_windows().into_iter().filter(|(label, _)| !is_settings_window(label)).find_map(|(label, w)| { let project = state.project(&label).ok()?; (project.root == root && !project.light).then_some(w) }) } /// Hands `files` to the already loaded `window` (its frontend takes them on [`LAUNCH_EVENT`]) and focuses it. fn send_files(app: &AppHandle, window: &WebviewWindow, files: Vec) { app.state::().add_files(window.label(), files); let _ = app.emit_to(window.label(), LAUNCH_EVENT, ()); focus(window); } /// The focused window, else the last focused, else any. pub fn target_window(app: &AppHandle) -> Option { let windows = app.webview_windows(); if let Some(w) = windows.values().find(|w| w.is_focused().unwrap_or(false)) { return Some(w.clone()); } let last = app.state::().last_focused(); if let Some(w) = last.and_then(|l| windows.get(&l)) { return Some(w.clone()); } sorted_labels(windows.keys().map(String::as_str)).first().and_then(|l| windows.get(l)).cloned() } fn window_config(app: &AppHandle) -> IdeResult { app.config() .app .windows .iter() .find(|w| w.label == MAIN) .or_else(|| app.config().app.windows.first()) .cloned() .ok_or_else(|| IdeError::internal("no window configuration")) } /// The theme's `--bg-frame` (tokens.css), mirroring `resolveThemeId` in theme.ts. pub fn frame_color(appearance: &AppearanceSettings, os_dark: bool) -> Color { let sync = appearance.sync_with_os || appearance.theme == "system"; let theme = match (sync, os_dark) { (false, _) => &appearance.theme, (true, true) => &appearance.preferred_dark_theme, (true, false) => &appearance.preferred_light_theme, }; builtin_frame(theme) .or_else(|| builtin_frame(if sync && os_dark { "dark" } else { "light" })) .unwrap_or(Color(0xe9, 0xea, 0xee, 0xff)) } /// A `#rgb` / `#rrggbb` / `#rrggbbaa` colour sent by the UI (Tauri's own parser). fn hex_color(hex: &str) -> Option { serde_json::from_value(serde_json::Value::String(hex.to_owned())).ok() } /// Builds a project window painted in the saved theme's frame colour, so it /// doesn't flash the light default before its page (and splash) loads. fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult { let visible = cfg!(target_os = "macos") && config.visible; let focused = config.focus; if visible { config.visible = false; } let state = app.try_state::(); let appearance = state.as_ref().map(|s| s.global_settings.effective().appearance); // A plugin theme replaces the built-in one in either OS mode; the UI records its resolved frame. let plugin_frame = state.as_ref().and_then(|s| s.plugins.startup_frame()).and_then(|c| hex_color(&c)); if let Some(color) = plugin_frame { config.background_color = Some(color); } else if let Some(a) = &appearance { config.background_color = Some(frame_color(a, false)); } #[cfg(target_os = "linux")] let show_after_build = crate::linux_window_state::prepare(app, &mut config); let window = WebviewWindowBuilder::from_config(app, &config) .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?; apply_zoom(app, &window); #[cfg(target_os = "macos")] align_titlebar(app, &window); // "Sync with OS" needs the OS mode, known once the window exists. if let Some(a) = appearance.filter(|a| plugin_frame.is_none() && (a.sync_with_os || a.theme == "system")) && window.theme().is_ok_and(|t| t == Theme::Dark) { let _ = window.set_background_color(Some(frame_color(&a, true))); } #[cfg(target_os = "linux")] if show_after_build { window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?; } if visible { window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?; if focused { let _ = window.set_focus(); } } Ok(window) } /// Sets the saved IDE zoom before the page loads; applied later by the frontend, /// it would visibly scale up the splash. fn apply_zoom(app: &AppHandle, window: &WebviewWindow) { let Some(zoom) = app.try_state::().map(|s| s.global_settings.effective().appearance.zoom) else { return; }; if zoom != 100 { let _ = window.set_zoom(f64::from(zoom) / 100.0); } } /// Aligns the native window buttons before the page reports its title bar height, /// so they don't visibly move once it loads. #[cfg(target_os = "macos")] fn align_titlebar(app: &AppHandle, window: &WebviewWindow) { // `--h-titlebar` in tokens.css. const HEIGHT: f64 = 40.0; const COMPACT_HEIGHT: f64 = 32.0; let Some(a) = app.try_state::().map(|s| s.global_settings.effective().appearance) else { return; }; let height = if a.compact_mode { COMPACT_HEIGHT } else { HEIGHT }; let _ = crate::titlebar::set_height(window, height * f64::from(a.zoom.clamp(50, 300)) / 100.0); } /// The first window (`create: false` in tauri.conf.json so it can take the theme's colour). pub fn create_main_window(app: &AppHandle) -> IdeResult { build_project_window(app, window_config(app)?) } /// Creates a new project window; `project` is opened by its frontend on load. pub fn create_window(app: &AppHandle, project: Option) -> IdeResult { create_window_with(app, project, Vec::new()) } /// [`create_window`] that also opens `files` (without `project`: in LightEdit). fn create_window_with(app: &AppHandle, project: Option, files: Vec) -> IdeResult { let title = project.as_deref().or(files.first().map(PathBuf::as_path)).and_then(Path::file_name); let title = title.map(|n| n.to_string_lossy().into_owned()); let files = files.into_iter().map(Into::into).collect(); create_window_for(app, title, Launch { project: project.map(Into::into), files, ..Default::default() }) } fn create_window_for(app: &AppHandle, title: Option, launch: Launch) -> IdeResult { let windows = app.webview_windows(); let label = next_label(windows.keys().map(String::as_str)); let mut config = window_config(app)?; config.label = label.clone(); if let Some(title) = title { config.title = title; } // Cascade from the window the user is looking at; window-state overrides // this with the remembered geometry when it has one for the label. if let Some(anchor) = target_window(app) && let (Ok(pos), Ok(scale)) = (anchor.outer_position(), anchor.scale_factor()) { let pos = pos.to_logical::(scale); config.x = Some(pos.x + CASCADE); config.y = Some(pos.y + CASCADE); config.center = false; } app.state::().set_launch(&label, launch); let window = build_project_window(app, config)?; focus(&window); Ok(window) } /// `create_window` off the event-loop thread (building a window from a /// main-thread callback can deadlock on Windows). pub fn spawn_window(app: &AppHandle, project: Option) { spawn_window_with(app, project, Vec::new()); } fn spawn_window_with(app: &AppHandle, project: Option, files: Vec) { let app = app.clone(); tauri::async_runtime::spawn(async move { if let Err(e) = create_window_with(&app, project, files) { tracing::warn!("cannot open window: {}", e.message); } }); } /// How `open_project_window` satisfied the request. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum OpenOutcome { /// Already open in `label`; that window was focused. Focused { label: String }, /// A new window `label` was created and opens the project itself. Opened { label: String }, /// The caller should open the project in its own window. Current, } /// Opens folder `path` as a project and then `files` in its editor; see [`OpenOutcome`] /// (`Current`: the caller opens both itself). pub fn open_project_window( app: &AppHandle, path: &Path, new_window: bool, files: Vec, ) -> IdeResult { let root = dunce::canonicalize(path).map_err(|e| IdeError::not_found(format!("{}: {e}", path.display())))?; tracing::info!("open_project_window {} new_window={new_window}", root.display()); if let Some(w) = find_project_window(app, &root) { if files.is_empty() { focus(&w); } else { send_files(app, &w, files); } return Ok(OpenOutcome::Focused { label: w.label().to_owned() }); } if !new_window { return Ok(OpenOutcome::Current); } let window = create_window_with(app, Some(root), files)?; Ok(OpenOutcome::Opened { label: window.label().to_owned() }) } /// Opens `file` in a forced new window: `project` (None: LightEdit) plus the /// single file, always creating a window even when the project is already open /// in another one (unlike [`open_project_window`], which focuses it). Used to /// detach an editor tab out of its window. pub fn open_file_in_new_window(app: &AppHandle, project: Option, file: PathBuf) -> IdeResult { tracing::info!("open_file_in_new_window {} project={:?}", file.display(), project); let window = create_window_with(app, project, vec![file])?; Ok(OpenOutcome::Opened { label: window.label().to_owned() }) } /// Loose local `files` asked for by window `caller` (File | Open File, a drop on the Welcome /// screen), routed like [`open_paths`]; returns the files `caller` opens itself. pub fn open_files_window(app: &AppHandle, caller: &str, files: Vec) -> Vec { let files = files.into_iter().map(|f| dunce::canonicalize(&f).unwrap_or(f)).collect(); let (steps, here) = plan_open_files(caller, files, &window_holds(app, Some(caller))); run_open(app, steps); here.into_iter().map(Into::into).collect() } /// [`open_project_window`] for folder `path` on the host of SSH configuration /// `ssh_config` (Remote Development). pub fn open_remote_window(app: &AppHandle, ssh_config: &str, path: &str, new_window: bool) -> IdeResult { tracing::info!("open_remote_window {ssh_config}:{path} new_window={new_window}"); let windows = app.webview_windows(); let open = app.try_state::().and_then(|s| s.remote_dev.window_of(ssh_config, path)); if let Some(w) = open.and_then(|label| windows.get(&label)) { focus(w); return Ok(OpenOutcome::Focused { label: w.label().to_owned() }); } if !new_window { return Ok(OpenOutcome::Current); } let title = path.trim_end_matches('/').rsplit('/').next().filter(|n| !n.is_empty() && *n != "~").map(str::to_owned); let remote = RemoteLaunch { ssh_config: ssh_config.to_owned(), path: path.to_owned() }; let window = create_window_for(app, title, Launch { remote: Some(remote), ..Default::default() })?; Ok(OpenOutcome::Opened { label: window.label().to_owned() }) } /// What a window holds, for routing paths opened from outside (command line, Finder). #[derive(Debug, Clone, PartialEq, Eq)] pub enum Holds { /// Nothing yet (Welcome screen): free to take a launch. Nothing, LightEdit, Project(PathBuf), /// A remote project. Other, } /// What window `label` holds: its open project, else what its pending launch is about to open. fn holds(app: &AppHandle, label: &str) -> Holds { let state = app.try_state::(); if let Some(project) = state.as_ref().and_then(|s| s.project(label).ok()) { return if project.light { Holds::LightEdit } else { Holds::Project(project.root.clone()) }; } if state.is_some_and(|s| s.remote_dev.get(label).is_some()) { return Holds::Other; } pending_holds(app.state::().pending(label).as_ref()) } fn pending_holds(launch: Option<&Launch>) -> Holds { match launch { Some(Launch { project: Some(root), .. }) => Holds::Project(root.to_path_buf()), Some(Launch { remote: Some(_), .. }) => Holds::Other, Some(l) if !l.files.is_empty() => Holds::LightEdit, _ => Holds::Nothing, } } /// One step of opening paths from outside; see [`plan_open`]. #[derive(Debug, PartialEq, Eq)] pub enum OpenStep { /// The window already has the folder open. Focus(String), /// Files open in this window: in its project, or join its LightEdit session. Files(String, Vec), /// A free window opens this. Launch(String, Launch), /// A new window opens the project folder or (without one) the files in LightEdit. New(Option, Vec), } /// Where a file inside a project goes: a window with it open, or the step opening it. enum ProjectTarget { Window(String), Step(usize), } /// Routes canonical `paths` (with whether each is a folder) over `windows` (the preferred /// free one first). A folder focuses the window that has it open, else takes a free window, /// else a new one. A file inside a local project being opened (the deepest root if nested) /// opens in that project's window; the others all join the LightEdit window, else a free /// window or a new one switches to LightEdit for them. pub fn plan_open(paths: Vec<(PathBuf, bool)>, windows: &[(String, Holds)]) -> Vec { let mut free = windows.iter().filter(|(_, h)| *h == Holds::Nothing).map(|(l, _)| l.clone()); let (mut dirs, mut files) = (Vec::new(), Vec::new()); for (path, is_dir) in paths { let list = if is_dir { &mut dirs } else { &mut files }; if !list.contains(&path) { list.push(path); } } let mut steps = Vec::new(); let mut roots: Vec<(PathBuf, ProjectTarget)> = windows .iter() .filter_map(|(label, h)| match h { Holds::Project(root) => Some((root.clone(), ProjectTarget::Window(label.clone()))), _ => None, }) .collect(); for dir in dirs { if let Some((label, _)) = windows.iter().find(|(_, h)| matches!(h, Holds::Project(root) if *root == dir)) { steps.push(OpenStep::Focus(label.clone())); continue; } roots.push((dir.clone(), ProjectTarget::Step(steps.len()))); steps.push(match free.next() { Some(label) => OpenStep::Launch(label, path_launch_of(Some(dir), Vec::new())), None => OpenStep::New(Some(dir), Vec::new()), }); } let mut in_windows: Vec<(String, Vec)> = Vec::new(); let mut loose = Vec::new(); for file in files { let deepest = roots.iter().filter(|(root, _)| file.starts_with(root)).max_by_key(|(root, _)| root.components().count()); match deepest.map(|(_, target)| target) { Some(ProjectTarget::Window(label)) => match in_windows.iter_mut().find(|(l, _)| l == label) { Some((_, list)) => list.push(file), None => in_windows.push((label.clone(), vec![file])), }, Some(ProjectTarget::Step(i)) => match &mut steps[*i] { OpenStep::Launch(_, launch) => launch.files.push(file.into()), OpenStep::New(_, list) => list.push(file), OpenStep::Focus(_) | OpenStep::Files(..) => unreachable!("folders opened by the plan launch or open"), }, None => loose.push(file), } } steps.extend(in_windows.into_iter().map(|(label, files)| OpenStep::Files(label, files))); if !loose.is_empty() { let light = windows.iter().find(|(_, h)| *h == Holds::LightEdit).map(|(l, _)| l.clone()); steps.push(match (light, free.next()) { (Some(label), _) => OpenStep::Files(label, loose), (None, Some(label)) => OpenStep::Launch(label, path_launch_of(None, loose)), (None, None) => OpenStep::New(None, loose), }); } steps } fn path_launch_of(project: Option, files: Vec) -> Launch { Launch { project: project.map(Into::into), files: files.into_iter().map(Into::into).collect(), ..Default::default() } } /// [`plan_open`] for loose `files` asked for by window `caller` (listed first in `windows`): /// no other free window takes them. Returns the steps for other windows and the files /// `caller` opens itself (in its LightEdit session, or turning into one). pub fn plan_open_files( caller: &str, files: Vec, windows: &[(String, Holds)], ) -> (Vec, Vec) { let windows: Vec<_> = windows.iter().filter(|(l, h)| l == caller || *h != Holds::Nothing).cloned().collect(); let mut here = Vec::new(); let mut steps = Vec::new(); for step in plan_open(files.into_iter().map(|f| (f, false)).collect(), &windows) { match step { OpenStep::Files(label, files) if label == caller => here.extend(files), OpenStep::Launch(label, launch) if label == caller => here.extend(launch.files.into_iter().map(|f| f.0)), step => steps.push(step), } } (steps, here) } /// Canonical existing `paths` with whether each is a folder; the others are logged and dropped. fn resolve(paths: Vec) -> Vec<(PathBuf, bool)> { paths .into_iter() .filter_map(|path| match dunce::canonicalize(&path) { Ok(p) => { let is_dir = p.is_dir(); Some((p, is_dir)) } Err(e) => { tracing::warn!("cannot open {}: {e}", path.display()); None } }) .collect() } /// Opens `path` from the command line / a second instance; see [`open_paths`]. pub fn open_path(app: &AppHandle, path: &Path) { open_paths(app, vec![path.to_path_buf()]); } /// Opens paths from outside the app (a second instance, Finder's Open With, the Dock): /// see [`plan_open`]; the focused window is the preferred free one. pub fn open_paths(app: &AppHandle, paths: Vec) { let preferred = target_window(app).map(|w| w.label().to_owned()); run_open(app, plan_open(resolve(paths), &window_holds(app, preferred.as_deref()))); } /// What each window holds, `preferred` first. fn window_holds(app: &AppHandle, preferred: Option<&str>) -> Vec<(String, Holds)> { let mut labels = project_window_labels(app); if let Some(i) = preferred.and_then(|p| labels.iter().position(|l| l == p)) { let first = labels.remove(i); labels.insert(0, first); } labels.into_iter().map(|l| (l.clone(), holds(app, &l))).collect() } /// [`open_paths`] while the app is starting, before the `main` window exists: it takes them as its launch. pub fn open_paths_at_launch(app: &AppHandle, paths: Vec) { let main = pending_holds(app.state::().pending(MAIN).as_ref()); run_open(app, plan_open(resolve(paths), &[(MAIN.to_owned(), main)])); } fn run_open(app: &AppHandle, steps: Vec) { let windows = app.state::(); for step in steps { match step { OpenStep::Focus(label) => { if let Some(w) = app.get_webview_window(&label) { focus(&w); } } OpenStep::Files(label, files) => match app.get_webview_window(&label) { Some(w) => send_files(app, &w, files), None => windows.add_files(&label, files), }, OpenStep::Launch(label, launch) => { windows.set_launch(&label, launch); if let Some(w) = app.get_webview_window(&label) { let _ = app.emit_to(&label, LAUNCH_EVENT, ()); focus(&w); } } OpenStep::New(project, files) => spawn_window_with(app, project, files), } } } /// Local files and folders of the URLs macOS asks the app to open (Finder's Open With, the Dock). #[cfg(target_os = "macos")] pub fn opened_paths(urls: &[tauri::Url]) -> Vec { urls.iter().filter_map(|u| u.to_file_path().ok()).collect() } /// What a window opens for a command-line path: a folder as its project, a file in LightEdit. pub fn path_launch(path: PathBuf) -> Launch { if path.is_dir() { Launch { project: Some(path.into()), ..Default::default() } } else { Launch { files: vec![path.into()], ..Default::default() } } } /// Second instance launched: focus this one and open its path argument. pub fn on_second_instance(app: &AppHandle, args: Vec, cwd: String) { match cli_path(&args, Path::new(&cwd)) { Some(path) => open_path(app, &path), None => { if let Some(w) = target_window(app) { focus(&w); } else { spawn_window(app, None); } } } } pub fn cycle(app: &AppHandle, current: &str, forward: bool) { let windows = app.webview_windows(); let sorted = project_window_labels(app); if let Some(w) = cycle_target(&sorted, current, forward).and_then(|l| windows.get(&l)) { focus(w); } } /// Opens (or focuses) the settings window of `parent` showing `page`: a /// separate window centred over its parent, sized like IDEA's Settings. pub fn open_settings_window( app: &AppHandle, parent: &WebviewWindow, page: Option, new_projects: bool, background: Option, ) -> IdeResult<()> { // Asked from a settings window itself: navigate it. let parent_label = settings_parent(parent.label()).unwrap_or(parent.label()).to_owned(); let label = settings_label(&parent_label); let windows = app.state::(); if let Some(existing) = app.get_webview_window(&label) { if page.is_some() || new_projects { windows.set_launch(&label, Launch { settings_page: page, new_projects, ..Default::default() }); let _ = app.emit_to(label.as_str(), LAUNCH_EVENT, ()); } focus(&existing); return Ok(()); } let parent = app.get_webview_window(&parent_label).ok_or_else(|| IdeError::not_found("no parent window"))?; let mut config = window_config(app)?; config.label = label.clone(); config.title = "Settings".into(); // Frameless on Windows and Linux like project windows (tauri..conf.json): the page draws // the title bar in the app theme instead of a system caption that follows the OS theme. (config.width, config.height) = SETTINGS_SIZE; (config.min_width, config.min_height) = (Some(SETTINGS_MIN_SIZE.0), Some(SETTINGS_MIN_SIZE.1)); config.center = false; config.visible = false; config.maximized = false; config.fullscreen = false; config.drag_drop_enabled = false; // Paint the theme's frame colour before the page loads. if let Some(color) = background.as_deref().and_then(hex_color) { config.background_color = Some(color); } windows.set_launch(&label, Launch { settings_page: page, new_projects, ..Default::default() }); #[cfg(target_os = "linux")] crate::linux_window_state::prepare(app, &mut config); let builder = WebviewWindowBuilder::from_config(app, &config); // AppKit child windows can disappear when dragged to another display's Space. // The settings label already tracks ownership and parent-close cleanup. #[cfg(not(target_os = "macos"))] let builder = builder.and_then(|b| b.parent(&parent)); let window = builder .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?; apply_zoom(app, &window); #[cfg(target_os = "macos")] align_titlebar(app, &window); // Restore runs during build; fit its size to the parent's current display before showing. #[cfg(target_os = "macos")] if let Err(e) = fit_settings_window(&window, &parent) { tracing::warn!("cannot fit settings window: {e}"); } #[cfg(not(target_os = "macos"))] if let Err(e) = place_settings_window(&window, &parent) { tracing::warn!("cannot place settings window: {e}"); } focus(&window); Ok(()) } /// Fit outer bounds, including native decorations, using one monitor's logical coordinate space. #[cfg(target_os = "macos")] fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> { let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else { return Ok(()); }; let scale = monitor.scale_factor(); let area = monitor.work_area(); let origin = area.position.to_logical::(scale); let available = area.size.to_logical::(scale); // Move to the target display first so native frame metrics use its scale factor. window.set_position(area.position)?; let inner = window.inner_size()?.to_logical::(scale); let outer = window.outer_size()?.to_logical::(scale); let frame = (outer.width - inner.width, outer.height - inner.height); let pos = parent.outer_position()?.to_logical::(scale); let size = parent.outer_size()?.to_logical::(scale); let bounds = fit_bounds( (pos.x, pos.y, size.width, size.height), (outer.width, outer.height), (origin.x, origin.y, available.width, available.height), ); // A small work area takes precedence over the usual minimum window size. window.set_min_size(Some( tauri::LogicalSize::new( SETTINGS_MIN_SIZE.0.min((available.width - frame.0).max(1.0)), SETTINGS_MIN_SIZE.1.min((available.height - frame.1).max(1.0)), ) .to_physical::(scale), ))?; window.set_size( tauri::LogicalSize::new((bounds.2 - frame.0).max(1.0), (bounds.3 - frame.1).max(1.0)).to_physical::(scale), )?; window.set_position(tauri::PhysicalPosition::new( (bounds.0 * scale).round() as i32, (bounds.1 * scale).round() as i32, )) } /// Centres the settings window over its owner, inside the owner's monitor work area. The window /// may still be on another display with a different scale, so its size is converted with its own /// scale and its position is given in physical pixels. #[cfg(not(target_os = "macos"))] pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> { let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else { return Ok(()); }; let scale = monitor.scale_factor(); let area = monitor.work_area(); let origin = area.position.to_logical::(scale); let available = area.size.to_logical::(scale); let pos = parent.outer_position()?.to_logical::(scale); let size = parent.outer_size()?.to_logical::(scale); let own_scale = window.scale_factor()?; let own = window.outer_size()?.to_logical::(own_scale); let inner = window.inner_size()?.to_logical::(own_scale); let frame = (own.width - inner.width, own.height - inner.height); let bounds = fit_bounds( (pos.x, pos.y, size.width, size.height), (own.width, own.height), (origin.x, origin.y, available.width, available.height), ); window.set_position(tauri::PhysicalPosition::new( (bounds.0 * scale).round() as i32, (bounds.1 * scale).round() as i32, ))?; if (bounds.2, bounds.3) != (own.width, own.height) { // Sizes are set on the client area, and even an undecorated window has a resize frame. let (width, height) = ((bounds.2 - frame.0).max(1.0), (bounds.3 - frame.1).max(1.0)); window.set_min_size(Some(tauri::LogicalSize::new( SETTINGS_MIN_SIZE.0.min(width), SETTINGS_MIN_SIZE.1.min(height), )))?; window.set_size(tauri::LogicalSize::new(width, height))?; } Ok(()) } fn fit_bounds(parent: (f64, f64, f64, f64), size: (f64, f64), work: (f64, f64, f64, f64)) -> (f64, f64, f64, f64) { let width = size.0.min(work.2); let height = size.1.min(work.3); let at = centered(parent.0, parent.1, parent.2, parent.3, width, height); ( work.0 + (at.0 - work.0).clamp(0.0, work.2 - width), work.1 + (at.1 - work.1).clamp(0.0, work.3 - height), width, height, ) } /// Top-left corner that centres a `w`×`h` window over the parent rectangle /// (never above the parent's top edge, so the title bar stays reachable). fn centered(px: f64, py: f64, pw: f64, ph: f64, w: f64, h: f64) -> (f64, f64) { (px + (pw - w) / 2.0, py + ((ph - h) / 2.0).max(0.0)) } /// Reproduce WKWebView's user-font restriction before WKWebView creation. The /// private selector and environment override are absent from release builds. #[cfg(all(debug_assertions, target_os = "macos"))] fn dev_font_preferences( builder: WebviewWindowBuilder<'_, tauri::Wry, AppHandle>, ) -> WebviewWindowBuilder<'_, tauri::Wry, AppHandle> { if std::env::var("OPENIDEA_DEV_DISALLOW_USER_FONTS").as_deref() == Ok("1") { use objc2::{MainThreadMarker, msg_send, sel}; use objc2_foundation::NSObjectProtocol; let config = unsafe { objc2_web_kit::WKWebViewConfiguration::new(MainThreadMarker::new().expect("window creation on main thread")) }; let preferences = unsafe { config.preferences() }; if preferences.respondsToSelector(sel!(_setShouldAllowUserInstalledFonts:)) { unsafe { let _: () = msg_send![&preferences, _setShouldAllowUserInstalledFonts: false]; } let allowed: bool = unsafe { msg_send![&preferences, _shouldAllowUserInstalledFonts] }; tracing::info!(allowed, "Development user-installed fonts override"); return builder.with_webview_configuration(config); } tracing::warn!("WKPreferences user-font development selector unavailable"); } builder } #[cfg(not(all(debug_assertions, target_os = "macos")))] fn dev_font_preferences( builder: WebviewWindowBuilder<'_, tauri::Wry, AppHandle>, ) -> WebviewWindowBuilder<'_, tauri::Wry, AppHandle> { builder } #[cfg(test)] mod tests { use super::*; fn s(v: &[&str]) -> Vec { v.iter().map(|x| x.to_string()).collect() } #[test] fn frame_color_follows_theme_and_os_mode() { let mut a = AppearanceSettings::default(); assert_eq!(frame_color(&a, true), Color(0xe9, 0xea, 0xee, 0xff)); a.theme = "dark-hc".into(); assert_eq!(frame_color(&a, false), Color(0x1a, 0x1b, 0x1e, 0xff)); a.sync_with_os = true; assert_eq!(frame_color(&a, false), Color(0xe9, 0xea, 0xee, 0xff)); assert_eq!(frame_color(&a, true), Color(0x26, 0x28, 0x2c, 0xff)); } #[test] fn additional_palettes_keep_native_startup_and_os_preferences_in_sync() { for (id, color) in [ ("solarized-light", Color(0xee, 0xe8, 0xd5, 0xff)), ("solarized-dark", Color(0x07, 0x36, 0x42, 0xff)), ("one-light", Color(0xea, 0xea, 0xeb, 0xff)), ("one-dark", Color(0x21, 0x25, 0x2b, 0xff)), ] { let mut a = AppearanceSettings { theme: id.into(), ..Default::default() }; assert_eq!(frame_color(&a, false), color); assert_eq!(frame_color(&a, true), color); a.sync_with_os = true; a.preferred_light_theme = id.into(); a.preferred_dark_theme = id.into(); assert_eq!(frame_color(&a, false), color); assert_eq!(frame_color(&a, true), color); } } #[test] fn frame_colours_from_the_ui_parse_as_hex() { for (hex, expected) in [ ("#eaeaeb", Color(0xea, 0xea, 0xeb, 0xff)), ("#21252b", Color(0x21, 0x25, 0x2b, 0xff)), ("#8040bfcc", Color(0x80, 0x40, 0xbf, 0xcc)), ("#fff", Color(0xff, 0xff, 0xff, 0xff)), ] { assert_eq!(hex_color(hex), Some(expected), "{hex}"); } assert_eq!(hex_color("hsl(220, 13%, 15%)"), None); } #[test] fn labels_sort_main_first_then_numerically() { assert_eq!( sorted_labels(["project-10", "project-2", "main", "x"]), s(&["main", "project-2", "project-10", "x"]) ); } #[test] fn next_label_fills_gaps() { assert_eq!(next_label(["main"]), "project-1"); assert_eq!(next_label(["main", "project-1", "project-3"]), "project-2"); } #[test] fn cycling_wraps_both_ways() { let w = s(&["main", "project-1", "project-2"]); assert_eq!(cycle_target(&w, "main", true).as_deref(), Some("project-1")); assert_eq!(cycle_target(&w, "project-2", true).as_deref(), Some("main")); assert_eq!(cycle_target(&w, "main", false).as_deref(), Some("project-2")); assert_eq!(cycle_target(&s(&["main"]), "main", true), None); } #[test] fn cli_path_skips_flags_and_resolves_relative() { let cwd = Path::new("/work"); let args = s(&["openidea", "--verbose", "proj"]); assert_eq!(cli_path(&args, cwd), Some(PathBuf::from("/work/proj"))); assert_eq!(cli_path(&s(&["openidea", "/abs/p"]), cwd), Some(PathBuf::from("/abs/p"))); assert_eq!(cli_path(&s(&["openidea"]), cwd), None); assert_eq!(cli_path(&s(&["openidea", "-psn_0_123"]), cwd), None); } #[test] fn command_line_folders_open_as_projects_and_files_in_light_edit() { let dir = std::env::temp_dir(); assert_eq!(path_launch(dir.clone()).project, Some(dir.into())); let file = std::env::current_exe().unwrap(); let launch = path_launch(file.clone()); assert_eq!((launch.project, launch.files), (None, vec![file.into()])); } fn p(s: &str) -> PathBuf { PathBuf::from(s) } fn w(label: &str, holds: Holds) -> (String, Holds) { (label.to_owned(), holds) } #[test] fn opening_at_launch_gives_the_first_window_files_or_folder() { let main = [w("main", Holds::Nothing)]; let files = plan_open(vec![(p("/x/a.txt"), false), (p("/y/b.rs"), false)], &main); assert_eq!(files, [OpenStep::Launch("main".into(), path_launch_of(None, vec![p("/x/a.txt"), p("/y/b.rs")]))]); let dir = plan_open(vec![(p("/proj"), true)], &main); assert_eq!(dir, [OpenStep::Launch("main".into(), path_launch_of(Some(p("/proj")), Vec::new()))]); // Folder and files: the folder takes the first window, the files a new LightEdit one. let both = plan_open(vec![(p("/x/a.txt"), false), (p("/proj"), true)], &main); assert_eq!( both, [ OpenStep::Launch("main".into(), path_launch_of(Some(p("/proj")), Vec::new())), OpenStep::New(None, vec![p("/x/a.txt")]), ] ); } #[test] fn files_opened_at_launch_join_files_from_the_command_line() { let cli = path_launch_of(None, vec![p("/x/a.txt")]); let main = [w("main", pending_holds(Some(&cli)))]; assert_eq!( plan_open(vec![(p("/x/b.txt"), false)], &main), [OpenStep::Files("main".into(), vec![p("/x/b.txt")])] ); let st = WindowsState::default(); st.set_launch("main", cli); st.add_files("main", vec![p("/x/b.txt")]); assert_eq!(st.take_launch("main").unwrap().files, [p("/x/a.txt"), p("/x/b.txt")]); } #[test] fn files_inside_an_open_project_open_in_its_window_and_the_others_in_light_edit() { let windows = [ w("main", Holds::Project(p("/proj"))), w("project-1", Holds::LightEdit), w("project-2", Holds::Project(p("/proj/sub"))), ]; let steps = plan_open( vec![ (p("/proj/src/a.rs"), false), (p("/x/b.txt"), false), (p("/proj/sub/c.rs"), false), (p("/projects/d.rs"), false), (p("/proj/e.rs"), false), ], &windows, ); assert_eq!( steps, [ OpenStep::Files("main".into(), vec![p("/proj/src/a.rs"), p("/proj/e.rs")]), // The deepest root wins. OpenStep::Files("project-2".into(), vec![p("/proj/sub/c.rs")]), // `/projects` is not inside `/proj`. OpenStep::Files("project-1".into(), vec![p("/x/b.txt"), p("/projects/d.rs")]), ] ); } #[test] fn files_inside_a_folder_being_opened_open_in_its_project() { let opening = path_launch_of(Some(p("/a")), Vec::new()); let windows = [w("main", pending_holds(Some(&opening))), w("project-1", Holds::Nothing)]; let steps = plan_open(vec![(p("/a/x.rs"), false), (p("/b"), true), (p("/b/y.rs"), false)], &windows); assert_eq!( steps, [ OpenStep::Launch("project-1".into(), path_launch_of(Some(p("/b")), vec![p("/b/y.rs")])), OpenStep::Files("main".into(), vec![p("/a/x.rs")]), ] ); assert_eq!( plan_open(vec![(p("/c"), true), (p("/c/z.rs"), false)], &[]), [OpenStep::New(Some(p("/c")), vec![p("/c/z.rs")])] ); } #[test] fn remote_projects_do_not_take_local_files() { let windows = [w("main", Holds::Other), w("project-1", Holds::LightEdit)]; assert_eq!( plan_open(vec![(p("/home/me/a.rs"), false)], &windows), [OpenStep::Files("project-1".into(), vec![p("/home/me/a.rs")])] ); } #[test] fn files_opened_from_a_window_go_to_their_project_window_else_light_edit() { let windows = [ w("main", Holds::Nothing), w("project-1", Holds::Project(p("/proj"))), w("project-2", Holds::Nothing), w("project-3", Holds::Other), ]; let files = vec![p("/proj/src/a.rs"), p("/x/b.txt")]; // The Welcome window turns into LightEdit itself; another free window doesn't take the files. let (steps, here) = plan_open_files("main", files.clone(), &windows); assert_eq!(steps, [OpenStep::Files("project-1".into(), vec![p("/proj/src/a.rs")])]); assert_eq!(here, [p("/x/b.txt")]); // A LightEdit window keeps loose files, even with another one open. let light = [w("main", Holds::LightEdit), w("project-1", Holds::Project(p("/proj"))), w("project-2", Holds::LightEdit)]; let (steps, here) = plan_open_files("main", files.clone(), &light); assert_eq!(steps, [OpenStep::Files("project-1".into(), vec![p("/proj/src/a.rs")])]); assert_eq!(here, [p("/x/b.txt")]); // An existing LightEdit window takes them from a Welcome window. let others = [w("main", Holds::Nothing), w("project-2", Holds::LightEdit)]; let (steps, here) = plan_open_files("main", vec![p("/x/b.txt")], &others); assert_eq!((steps, here), (vec![OpenStep::Files("project-2".into(), vec![p("/x/b.txt")])], vec![])); // A remote project window gets a new LightEdit window, never a free one or its own. let (steps, here) = plan_open_files("project-3", files, &windows); assert_eq!( steps, [OpenStep::Files("project-1".into(), vec![p("/proj/src/a.rs")]), OpenStep::New(None, vec![p("/x/b.txt")])] ); assert!(here.is_empty()); } #[test] fn files_opened_while_running_take_a_free_window_else_a_new_one() { let free = [w("main", Holds::Project(p("/proj"))), w("project-1", Holds::Nothing)]; assert_eq!( plan_open(vec![(p("/x/a.txt"), false)], &free), [OpenStep::Launch("project-1".into(), path_launch_of(None, vec![p("/x/a.txt")]))] ); let busy = [w("main", Holds::Project(p("/proj"))), w("project-1", Holds::Other)]; assert_eq!(plan_open(vec![(p("/x/a.txt"), false)], &busy), [OpenStep::New(None, vec![p("/x/a.txt")])]); } #[test] fn folders_opened_while_running_focus_their_window_else_open_as_projects() { let windows = [w("main", Holds::LightEdit), w("project-1", Holds::Project(p("/a")))]; let steps = plan_open(vec![(p("/a"), true), (p("/b"), true), (p("/b"), true)], &windows); assert_eq!(steps, [OpenStep::Focus("project-1".into()), OpenStep::New(Some(p("/b")), Vec::new())]); // A window still opening the folder (pending launch) counts as having it. let opening = path_launch_of(Some(p("/a")), Vec::new()); let windows = [w("project-2", pending_holds(Some(&opening)))]; assert_eq!(plan_open(vec![(p("/a"), true)], &windows), [OpenStep::Focus("project-2".into())]); } #[test] fn launches_are_taken_once() { let st = WindowsState::default(); st.set_launch("project-1", Launch { project: Some("/p".into()), ..Default::default() }); assert_eq!(st.take_launch("project-1").and_then(|l| l.project), Some("/p".into())); assert_eq!(st.take_launch("project-1"), None); } #[test] fn settings_windows_map_to_their_parent() { assert_eq!(settings_label("project-2"), "settings-project-2"); assert_eq!(settings_parent("settings-main"), Some("main")); assert_eq!(settings_parent("main"), None); assert!(is_settings_window("settings-project-1")); assert!(!is_settings_window("project-1")); } #[test] fn settings_bounds_fit_current_work_area() { for (parent, size, work, expected) in [ // 1920×1080 at 150% scaling, with a 40-physical-pixel taskbar. ( (0.0, 0.0, 1280.0, 600.0), (980.0, 720.0), (0.0, 0.0, 1280.0, 1040.0 / 1.5), (150.0, 0.0, 980.0, 1040.0 / 1.5), ), // 3840×2160 at 250% scaling: an owner taller than the work area must not push it off screen. ((100.0, 60.0, 1440.0, 900.0), (980.0, 720.0), (0.0, 0.0, 1536.0, 816.0), (330.0, 96.0, 980.0, 720.0)), // Restored geometry from a larger display. ((100.0, 50.0, 1440.0, 900.0), (2400.0, 1600.0), (0.0, 24.0, 1920.0, 1016.0), (0.0, 24.0, 1920.0, 1016.0)), // Secondary display with a negative origin and reserved top/left space. ( (-1800.0, 100.0, 1400.0, 900.0), (980.0, 720.0), (-1870.0, 24.0, 1870.0, 1016.0), (-1590.0, 190.0, 980.0, 720.0), ), // Parent near the lower-right edge. ((1700.0, 900.0, 800.0, 600.0), (980.0, 720.0), (0.0, 0.0, 1920.0, 1040.0), (940.0, 320.0, 980.0, 720.0)), // Work area smaller than the normal minimum size. ((0.0, 0.0, 800.0, 600.0), (980.0, 720.0), (0.0, 0.0, 600.0, 360.0), (0.0, 0.0, 600.0, 360.0)), ] { assert_eq!(fit_bounds(parent, size, work), expected); } } #[test] fn settings_window_is_centred_over_parent() { assert_eq!(centered(100.0, 50.0, 1440.0, 900.0, 980.0, 720.0), (330.0, 140.0)); // Taller than the parent: aligned with its top edge. assert_eq!(centered(0.0, 50.0, 800.0, 500.0, 600.0, 720.0), (100.0, 50.0)); } }