Files
openidea/src-tauri/src/windows.rs
T
l0ng-ai 2d3b7ebf54 fix(window): keep a fullscreen project window group in one Space on macOS (#1218)
Native fullscreen gives every window its own Space, so switching projects in a fullscreen window group left one Space and entered another: the desktop showed in between, and quick switching could drop out of fullscreen.

A grouped window now uses simple fullscreen for every member at once; switching projects only shows one window and hides the others. The green button, the system Enter Full Screen item and Toggle Full Screen take this path for grouped windows, which keep their window buttons; ungrouped windows keep native fullscreen. Menu bar and Dock auto-hide while any window is in simple fullscreen, and detaching or closing a tab keeps the rest of the group fullscreen.
2026-11-04 16:35:43 +08:00

1595 lines
65 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Project windows: one project per window, labelled `main` or `project-<n>`.
//!
//! 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).
//!
//! Auxiliary windows ([`AuxKind`]: Settings, Compare with Local, Sync with Deployed) belong to a project window and share its project (see
//! [`AppState::project`]). Their labels name the kind and the parent
//! (`settings-main`, `comparison-project-2-3`); their [`Launch`] says what 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, AtomicU64, 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 CASCADE: f64 = 28.0;
const SETTINGS_SIZE: (f64, f64) = (980.0, 720.0);
const SETTINGS_MIN_SIZE: (f64, f64) = (640.0, 420.0);
const COMPARISON_SIZE: (f64, f64) = (1100.0, 720.0);
const COMPARISON_MIN_SIZE: (f64, f64) = (640.0, 360.0);
const DEPLOYMENT_SIZE: (f64, f64) = (1400.0, 900.0);
const DEPLOYMENT_MIN_SIZE: (f64, f64) = (800.0, 500.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<ide_core::IpcPath>,
/// 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<ide_core::IpcPath>,
/// Remote project to open (Remote Development).
pub remote: Option<RemoteLaunch>,
/// Settings windows: the page to show.
pub settings_page: Option<String>,
/// Settings windows: edit the global layer only ("Settings for New Projects").
pub new_projects: bool,
/// Where to place the caret in some of `files` (`openidea --line 12 --column 5 <file>`).
#[serde(default)]
pub positions: Vec<FilePosition>,
/// Comparison windows: the revisions to compare (boxed: most launches have none).
#[serde(default)]
pub comparison: Option<Box<ComparisonLaunch>>,
/// Deployment windows: the server and local selection to compare.
#[serde(default)]
pub deployment: Option<DeploymentLaunch>,
}
/// Local files to compare with a deployment server (Sync with Deployed).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct DeploymentLaunch {
/// Deployment server id.
pub server: String,
pub paths: Vec<ide_core::IpcPath>,
}
/// A Git revision and how the comparison window names it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct ComparisonRevision {
pub rev: String,
pub label: String,
}
/// Limits a comparison to some paths (Compare with Branch on a selection).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct ComparisonScope {
pub paths: Vec<ide_core::IpcPath>,
pub repositories: Option<Vec<ide_core::IpcPath>>,
}
/// Files that differ between `from` and `to` (the working tree when `None`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct ComparisonLaunch {
pub from: ComparisonRevision,
pub to: Option<ComparisonRevision>,
pub scope: Option<ComparisonScope>,
pub repo: Option<ide_core::IpcPath>,
}
/// A caret position for a file opened from the command line: 0-based line and column.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FilePosition {
pub path: ide_core::IpcPath,
pub line: u32,
pub column: u32,
}
/// 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<ide_workspace::window_groups::WindowGroups>,
pub groups_restored: AtomicBool,
pub groups_persist: tokio::sync::Mutex<()>,
launches: Mutex<HashMap<String, Launch>>,
/// Caret positions asked for on the command line, by canonical file path, until a window takes the file.
positions: Mutex<HashMap<PathBuf, (u32, u32)>>,
/// Most recently focused window label (menu events go there).
last_focused: Mutex<Option<String>>,
/// Set by Quit while windows close one by one.
quitting: AtomicBool,
/// Project tabs closing one by one (Close Other Tabs and friends).
pub tab_closes: Mutex<Option<crate::window_groups::TabCloses>>,
/// Relaunch once the quit completes (after installing an update).
restart: AtomicBool,
/// Numbers windows of kinds that can open several times per project window.
aux_serial: AtomicU64,
}
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<Launch> {
let mut launch = self.launches.lock().remove(label)?;
let mut positions = self.positions.lock();
for file in &launch.files {
let path = dunce::canonicalize(&file.0).unwrap_or_else(|_| file.0.clone());
if let Some((line, column)) = positions.remove(&path) {
launch.positions.push(FilePosition { path: file.clone(), line, column });
}
}
Some(launch)
}
/// The caret goes to `position` (0-based line, column) when a window opens `file`.
pub fn set_position(&self, file: &Path, position: (u32, u32)) {
let path = dunce::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
self.positions.lock().insert(path, position);
}
/// 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<PathBuf>) {
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<Launch> {
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<String> {
self.last_focused.lock().clone()
}
pub fn set_quitting(&self, on: bool) {
self.quitting.store(on, Ordering::SeqCst);
if on {
*self.tab_closes.lock() = None;
} else {
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)
}
}
/// A window that belongs to a project window (its parent) and shares its project.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuxKind {
Settings,
Comparison,
Deployment,
}
impl AuxKind {
const ALL: [AuxKind; 3] = [AuxKind::Settings, AuxKind::Comparison, AuxKind::Deployment];
/// Label prefix, and the key of the one remembered geometry shared by every window of the kind.
pub fn name(self) -> &'static str {
match self {
AuxKind::Settings => "settings",
AuxKind::Comparison => "comparison",
AuxKind::Deployment => "deployment",
}
}
/// Several per project window (`<kind>-<parent>-<n>`), else one (`<kind>-<parent>`).
fn multiple(self) -> bool {
self == AuxKind::Comparison
}
/// Shows the project's documents: gets its parent's events and edits made in sibling windows,
/// and asks about its own unsaved changes before the parent closes it.
pub fn shares_documents(self) -> bool {
self != AuxKind::Settings
}
/// Modal for its parent: menu items other than app-level ones are dropped.
pub fn modal(self) -> bool {
self == AuxKind::Settings
}
/// Needs its parent to have a project open.
fn needs_project(self) -> bool {
self != AuxKind::Settings
}
}
/// The kind and parent of an auxiliary window (`comparison-project-2-3` → `Comparison`, `project-2`).
pub fn auxiliary(label: &str) -> Option<(AuxKind, &str)> {
AuxKind::ALL.into_iter().find_map(|kind| {
let rest = label.strip_prefix(kind.name())?.strip_prefix('-')?;
let parent = if kind.multiple() {
let (parent, n) = rest.rsplit_once('-')?;
n.parse::<u64>().ok()?;
parent
} else {
rest
};
(!parent.is_empty()).then_some((kind, parent))
})
}
/// The project window an auxiliary window belongs to (`settings-main` → `main`).
pub fn auxiliary_parent(label: &str) -> Option<&str> {
auxiliary(label).map(|(_, parent)| parent)
}
pub fn settings_label(parent: &str) -> String {
format!("{}-{parent}", AuxKind::Settings.name())
}
/// The window state key: one geometry per auxiliary kind, else the label itself.
pub fn state_key(label: &str) -> &str {
auxiliary(label).map_or(label, |(kind, _)| kind.name())
}
/// `parent`'s auxiliary windows that show its project's documents.
pub fn document_windows(app: &AppHandle, parent: &str) -> Vec<String> {
app.webview_windows()
.into_keys()
.filter(|label| auxiliary(label).is_some_and(|(kind, p)| kind.shares_documents() && p == parent))
.collect()
}
/// Closes `parent`'s auxiliary windows: they show (and would apply to) the project it is leaving.
/// Windows with documents are destroyed: the parent's leave guard has already settled their
/// unsaved changes (`closeAuxiliaryWindows` in the UI).
pub fn close_auxiliary_windows(app: &AppHandle, parent: &str) {
for (label, window) in app.webview_windows() {
match auxiliary(&label) {
Some((kind, p)) if p == parent && kind.shares_documents() => drop(window.destroy()),
Some((_, p)) if p == parent => drop(window.close()),
_ => {}
}
}
}
/// Project windows, excluding auxiliary windows, for counting and cycling.
pub fn project_window_labels(app: &AppHandle) -> Vec<String> {
sorted_labels(app.webview_windows().keys().map(String::as_str).filter(|l| auxiliary_parent(l).is_none()))
}
/// Sort key: `main` first, then `project-<n>` 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<Item = &'a str>) -> Vec<String> {
let mut v: Vec<String> = labels.into_iter().map(str::to_owned).collect();
v.sort_by_key(|l| order_key(l));
v
}
/// Lowest free `project-<n>` label.
pub fn next_label<'a>(existing: impl IntoIterator<Item = &'a str>) -> 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<String> {
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())
}
/// Flags followed by a value (`--line 12`).
const VALUE_FLAGS: [&str; 2] = ["--line", "--column"];
/// The project path from command-line arguments (`openidea /path/to/project`):
/// the first argument after the binary that is not a flag or a flag's value, resolved against `cwd`.
pub fn cli_path(args: &[String], cwd: &Path) -> Option<PathBuf> {
let mut rest = args.iter().skip(1);
while let Some(a) = rest.next() {
if VALUE_FLAGS.contains(&a.as_str()) {
rest.next();
continue;
}
if a.starts_with('-') || a.is_empty() || a.starts_with("psn_") {
continue;
}
let p = PathBuf::from(a);
return Some(if p.is_absolute() { p } else { cwd.join(p) });
}
None
}
/// `--line <n>` and `--column <n>` (1-based, also as `--line=<n>`) as a 0-based position;
/// without `--line` there is none, without `--column` it is the line's start.
pub fn cli_position(args: &[String]) -> Option<(u32, u32)> {
let value = |flag: &str| {
let prefix = format!("{flag}=");
args.iter().enumerate().skip(1).find_map(|(i, a)| match a.strip_prefix(&prefix) {
Some(v) => Some(v.to_owned()),
None if a == flag => args.get(i + 1).cloned(),
None => None,
})
};
let number = |flag: &str| value(flag).and_then(|v| v.trim().parse::<u32>().ok());
let line = number("--line")?;
Some((line.saturating_sub(1), number("--column").unwrap_or(1).saturating_sub(1)))
}
/// Remembers the command line's `--line` / `--column` for `path` (a file) before it is opened.
pub fn remember_cli_position(windows: &WindowsState, args: &[String], path: &Path) {
if let Some(position) = cli_position(args)
&& !path.is_dir()
{
windows.set_position(path, position);
}
}
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::<WindowsState>().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::<WindowsState>().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<WebviewWindow> {
let state = app.try_state::<AppState>()?;
app.webview_windows().into_iter().filter(|(label, _)| auxiliary_parent(label).is_none()).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<PathBuf>) {
app.state::<WindowsState>().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<WebviewWindow> {
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::<WindowsState>().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<WindowConfig> {
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<Color> {
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<WebviewWindow> {
let visible = cfg!(target_os = "macos") && config.visible;
let focused = config.focus;
if visible {
config.visible = false;
}
let state = app.try_state::<AppState>();
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}")))?;
#[cfg(target_os = "linux")]
decorate_csd(&window);
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)
}
/// Wayland: tao already makes frameless windows client-side decorated (an empty GTK title bar, so
/// the compositor adds no frame), but undecorated GTK windows get no shadow or resize margin.
/// Decorating keeps that empty title bar, so GTK adds the theme's shadow and resize edges and
/// nothing else. X11 has no such title bar: decorating there would bring back the WM frame.
#[cfg(target_os = "linux")]
fn decorate_csd(window: &WebviewWindow) {
if crate::linux_window_state::is_wayland() {
let _ = window.set_decorations(true);
}
}
/// 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::<AppState>().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::<AppState>().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<WebviewWindow> {
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<PathBuf>) -> IdeResult<WebviewWindow> {
create_window_with(app, project, Vec::new(), false)
}
/// [`create_window`] that also opens `files` (without `project`: in LightEdit).
fn create_window_with(
app: &AppHandle,
project: Option<PathBuf>,
files: Vec<PathBuf>,
restoring: bool,
) -> IdeResult<WebviewWindow> {
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() }, restoring)
}
/// Restoring a saved group preserves its membership and selected tab.
pub fn create_restored_window(app: &AppHandle, project: PathBuf) -> IdeResult<WebviewWindow> {
create_window_with(app, Some(project), Vec::new(), true)
}
fn create_window_for(
app: &AppHandle,
title: Option<String>,
launch: Launch,
_restoring: bool,
) -> IdeResult<WebviewWindow> {
#[cfg(target_os = "macos")]
let tab_target = if _restoring { None } else { crate::macos::new_project_tab_target(app, &launch)? };
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::<f64>(scale);
config.x = Some(pos.x + CASCADE);
config.y = Some(pos.y + CASCADE);
config.center = false;
}
app.state::<WindowsState>().set_launch(&label, launch);
let window = build_project_window(app, config)?;
#[cfg(target_os = "macos")]
crate::macos::intercept_toggle_fullscreen(&window);
#[cfg(target_os = "macos")]
if let Some(target) = tab_target {
crate::window_groups::join(app, target.label(), window.label())?;
return Ok(window);
}
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<PathBuf>) {
spawn_window_with(app, project, Vec::new());
}
fn spawn_window_with(app: &AppHandle, project: Option<PathBuf>, files: Vec<PathBuf>) {
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = create_window_with(&app, project, files, false) {
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<PathBuf>,
) -> IdeResult<OpenOutcome> {
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, false)?;
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<PathBuf>, file: PathBuf) -> IdeResult<OpenOutcome> {
tracing::info!("open_file_in_new_window {} project={:?}", file.display(), project);
let window = create_window_with(app, project, vec![file], false)?;
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<PathBuf>) -> Vec<ide_core::IpcPath> {
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<OpenOutcome> {
tracing::info!("open_remote_window {ssh_config}:{path} new_window={new_window}");
let windows = app.webview_windows();
let open = app.try_state::<AppState>().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() }, false)?;
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::<AppState>();
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::<WindowsState>().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<PathBuf>),
/// A free window opens this.
Launch(String, Launch),
/// A new window opens the project folder or (without one) the files in LightEdit.
New(Option<PathBuf>, Vec<PathBuf>),
}
/// 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<OpenStep> {
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<PathBuf>)> = 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<PathBuf>, files: Vec<PathBuf>) -> 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<PathBuf>,
windows: &[(String, Holds)],
) -> (Vec<OpenStep>, Vec<PathBuf>) {
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<PathBuf>) -> 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<PathBuf>) {
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<PathBuf>) {
let main = pending_holds(app.state::<WindowsState>().pending(MAIN).as_ref());
run_open(app, plan_open(resolve(paths), &[(MAIN.to_owned(), main)]));
}
fn run_open(app: &AppHandle, steps: Vec<OpenStep>) {
let windows = app.state::<WindowsState>();
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<PathBuf> {
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<String>, cwd: String) {
match cli_path(&args, Path::new(&cwd)) {
Some(path) => {
remember_cli_position(&app.state::<WindowsState>(), &args, &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);
}
}
/// An auxiliary window to open over its parent.
pub struct AuxWindow {
pub kind: AuxKind,
pub title: &'static str,
pub size: (f64, f64),
pub min_size: (f64, f64),
pub launch: Launch,
/// `#rrggbb` painted until the page has loaded (the theme's frame colour).
pub background: Option<String>,
}
/// Opens an auxiliary window of `caller`'s project window, centred over it. A kind with one window
/// per parent reuses it: the new launch is handed over ([`LAUNCH_EVENT`]) and the window focused.
pub fn open_auxiliary_window(app: &AppHandle, caller: &WebviewWindow, aux: AuxWindow) -> IdeResult<()> {
// Asked from an auxiliary window itself: open it for that window's parent.
let parent_label = auxiliary_parent(caller.label()).unwrap_or(caller.label()).to_owned();
if aux.kind.needs_project() {
let state = app.state::<AppState>();
if state.project(&parent_label).is_err() && state.remote_dev.get(&parent_label).is_none() {
return Err(IdeError::not_found("no project is open"));
}
}
let windows = app.state::<WindowsState>();
let label = if aux.kind.multiple() {
format!("{}-{parent_label}-{}", aux.kind.name(), windows.aux_serial.fetch_add(1, Ordering::Relaxed))
} else {
format!("{}-{parent_label}", aux.kind.name())
};
if let Some(existing) = app.get_webview_window(&label) {
if aux.launch != Launch::default() {
windows.set_launch(&label, aux.launch);
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 = aux.title.into();
// Frameless on Windows and Linux like project windows (tauri.<os>.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) = aux.size;
(config.min_width, config.min_height) = (Some(aux.min_size.0), Some(aux.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) = aux.background.as_deref().and_then(hex_color) {
config.background_color = Some(color);
}
windows.set_launch(&label, aux.launch);
#[cfg(target_os = "linux")]
crate::linux_window_state::prepare(app, &mut config);
let builder = WebviewWindowBuilder::from_config(app, &config);
// Windows: an owned child (WS_POPUP) of a borderless parent gets no DWM
// shadow, leaving the frameless window flat against its parent.
// Ownership is tracked by the `<kind>-<parent>` label instead
// (parent-close cleanup, menu modality), so only Linux keeps the native
// parent link. AppKit child windows can disappear when dragged to another
// display's Space, so macOS keeps it too.
#[cfg(target_os = "linux")]
let builder = builder.and_then(|b| b.parent(&parent));
let window = builder.and_then(|b| dev_font_preferences(b).build()).map_err(|e| {
windows.forget(&label);
IdeError::internal(format!("cannot create {} window: {e}", aux.kind.name()))
})?;
#[cfg(target_os = "linux")]
decorate_csd(&window);
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_over_parent(&window, &parent, aux.min_size) {
tracing::warn!("cannot fit {} window: {e}", aux.kind.name());
}
#[cfg(not(target_os = "macos"))]
if let Err(e) = place_over_parent(&window, &parent) {
tracing::warn!("cannot place {} window: {e}", aux.kind.name());
}
focus(&window);
Ok(())
}
/// 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<String>,
new_projects: bool,
background: Option<String>,
) -> IdeResult<()> {
open_auxiliary_window(
app,
parent,
AuxWindow {
kind: AuxKind::Settings,
title: "Settings",
size: SETTINGS_SIZE,
min_size: SETTINGS_MIN_SIZE,
launch: Launch { settings_page: page, new_projects, ..Default::default() },
background,
},
)
}
/// Opens a window listing the files that differ between two revisions (Compare with Local and
/// friends), next to its project window like IDEA's diff windows.
pub fn open_comparison_window(
app: &AppHandle,
parent: &WebviewWindow,
comparison: ComparisonLaunch,
background: Option<String>,
) -> IdeResult<()> {
open_auxiliary_window(
app,
parent,
AuxWindow {
kind: AuxKind::Comparison,
title: "Compare",
size: COMPARISON_SIZE,
min_size: COMPARISON_MIN_SIZE,
launch: Launch { comparison: Some(Box::new(comparison)), ..Default::default() },
background,
},
)
}
/// Opens (or focuses and hands over to) the project window's Sync with Deployed window: local
/// files next to their copies on a deployment server, like IDEA's.
pub fn open_deployment_window(
app: &AppHandle,
parent: &WebviewWindow,
server: String,
paths: Vec<PathBuf>,
background: Option<String>,
) -> IdeResult<()> {
let paths = paths.into_iter().map(Into::into).collect();
open_auxiliary_window(
app,
parent,
AuxWindow {
kind: AuxKind::Deployment,
title: "Sync with Deployed",
size: DEPLOYMENT_SIZE,
min_size: DEPLOYMENT_MIN_SIZE,
launch: Launch { deployment: Some(DeploymentLaunch { server, paths }), ..Default::default() },
background,
},
)
}
/// Fit outer bounds, including native decorations, using one monitor's logical coordinate space.
#[cfg(target_os = "macos")]
fn fit_over_parent(window: &WebviewWindow, parent: &WebviewWindow, min_size: (f64, f64)) -> 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::<f64>(scale);
let available = area.size.to_logical::<f64>(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::<f64>(scale);
let outer = window.outer_size()?.to_logical::<f64>(scale);
let frame = (outer.width - inner.width, outer.height - inner.height);
let pos = parent.outer_position()?.to_logical::<f64>(scale);
let size = parent.outer_size()?.to_logical::<f64>(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(
min_size.0.min((available.width - frame.0).max(1.0)),
min_size.1.min((available.height - frame.1).max(1.0)),
)
.to_physical::<u32>(scale),
))?;
window.set_size(
tauri::LogicalSize::new((bounds.2 - frame.0).max(1.0), (bounds.3 - frame.1).max(1.0)).to_physical::<u32>(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_over_parent(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> {
// Placement setters leave maximized mode. Fit the restored frame, then reapply the saved state.
// A restored fullscreen window already covers its display and must not be fitted to the work area.
if window.is_fullscreen()? {
return Ok(());
}
let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else {
return Ok(());
};
let maximized = window.is_maximized()?;
if maximized {
window.unmaximize()?;
}
let scale = monitor.scale_factor();
let area = monitor.work_area();
let origin = area.position.to_logical::<f64>(scale);
let available = area.size.to_logical::<f64>(scale);
let pos = parent.outer_position()?.to_logical::<f64>(scale);
let size = parent.outer_size()?.to_logical::<f64>(scale);
let own_scale = window.scale_factor()?;
let own = window.outer_size()?.to_logical::<f64>(own_scale);
// On Wayland, the frame is GTK's CSD shadow margin, which tao's inner size includes.
#[cfg(target_os = "linux")]
let inner = crate::linux_window_state::content_size(&window.as_ref().window())?;
#[cfg(not(target_os = "linux"))]
let inner = window.inner_size()?;
let inner = inner.to_logical::<f64>(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))?;
}
if maximized {
window.maximize()?;
}
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<String> {
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);
// A flag's value is not the path.
let args = s(&["openidea", "--line", "120", "--column", "8", "src/Main.java"]);
assert_eq!(cli_path(&args, cwd), Some(PathBuf::from("/work/src/Main.java")));
assert_eq!(
cli_path(&s(&["openidea", "--line=3", "a b/文件.rs"]), cwd),
Some(PathBuf::from("/work/a b/文件.rs"))
);
}
#[test]
fn cli_position_is_one_based_on_the_command_line() {
assert_eq!(cli_position(&s(&["openidea", "--line", "120", "--column", "8", "f"])), Some((119, 7)));
assert_eq!(cli_position(&s(&["openidea", "--line=5", "f"])), Some((4, 0)));
assert_eq!(cli_position(&s(&["openidea", "--line", "0", "f"])), Some((0, 0)));
assert_eq!(cli_position(&s(&["openidea", "--column", "8", "f"])), None);
assert_eq!(cli_position(&s(&["openidea", "--line", "x", "f"])), None);
assert_eq!(cli_position(&s(&["openidea", "f"])), None);
}
#[test]
fn a_launch_carries_the_positions_of_its_files() {
let st = WindowsState::default();
let file = std::env::current_exe().unwrap();
let other = std::env::temp_dir().join("openidea-not-opened.txt");
st.set_position(&file, (11, 4));
st.set_position(&other, (1, 0));
st.add_files("main", vec![file.clone()]);
let launch = st.take_launch("main").unwrap();
assert_eq!(launch.positions, vec![FilePosition { path: file.into(), line: 11, column: 4 }]);
// Taken once: the next launch of that file opens it where it was.
st.add_files("main", vec![std::env::current_exe().unwrap()]);
assert!(st.take_launch("main").unwrap().positions.is_empty());
}
#[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 auxiliary_windows_map_to_their_parent() {
assert_eq!(settings_label("project-2"), "settings-project-2");
assert_eq!(auxiliary("settings-main"), Some((AuxKind::Settings, "main")));
assert_eq!(auxiliary("comparison-project-2-14"), Some((AuxKind::Comparison, "project-2")));
assert_eq!(auxiliary("deployment-project-2"), Some((AuxKind::Deployment, "project-2")));
for label in ["main", "project-1", "comparison-main", "comparison-main-x", "settings-", "settingsmain"] {
assert_eq!(auxiliary(label), None, "{label}");
}
assert_eq!(state_key("comparison-main-3"), "comparison");
assert_eq!(state_key("project-3"), "project-3");
let labels = ["comparison-main-0", "project-2", "main", "settings-main", "deployment-project-2"];
assert_eq!(sorted_labels(labels.into_iter().filter(|l| auxiliary_parent(l).is_none())), ["main", "project-2"]);
}
#[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));
}
}