mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
Merged project windows are separate native windows that take turns being visible. Hidden members kept their own earlier frame and monitor, so the first switch after merging, and any switch after moving or resizing the window, moved or resized the window on screen; a maximized member could appear on another display. The group now copies the selected window's frame and display to the hidden members after a selection and after the visible window is moved or resized (debounced), skips copies when the frame already matches, and leaves macOS simple fullscreen to its own path, so switching projects only changes which window is visible. Co-authored-by: xtr-hub <242808782+xtr-hub@users.noreply.github.com>
531 lines
20 KiB
Rust
531 lines
20 KiB
Rust
//! Desktop adapter for application-drawn project tabs. Each member retains its WebView and services.
|
|
use crate::{
|
|
state::AppState,
|
|
windows::{self, WindowsState},
|
|
};
|
|
use ide_core::{IdeError, IdeEvent, IdeResult};
|
|
use serde::{Deserialize, Serialize};
|
|
use specta::Type;
|
|
use std::collections::VecDeque;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::Duration;
|
|
use tauri::{AppHandle, Manager, WebviewWindow};
|
|
|
|
/// Wait for the selection's asynchronous `hide` calls to take effect before moving the peers.
|
|
const FRAME_SETTLE_AFTER_SELECT: Duration = Duration::from_millis(50);
|
|
/// Wait for a drag/resize to stop before copying its frame to the hidden peers.
|
|
const FRAME_SETTLE_AFTER_MOVE: Duration = Duration::from_millis(100);
|
|
/// Coalesces the move/resize events a drag emits.
|
|
static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0);
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct WindowTab {
|
|
pub label: String,
|
|
pub name: String,
|
|
pub root: Option<ide_core::IpcPath>,
|
|
pub current: bool,
|
|
}
|
|
|
|
pub fn current(app: &AppHandle, caller: &str) -> Vec<WindowTab> {
|
|
let state = app.state::<WindowsState>();
|
|
let Some(group) = state.groups.lock().group(caller).cloned() else { return Vec::new() };
|
|
let host = app.state::<AppState>();
|
|
group
|
|
.members
|
|
.iter()
|
|
.filter_map(|label| {
|
|
let window = app.get_webview_window(label)?;
|
|
let project = host.project(label).ok();
|
|
let name = project
|
|
.as_ref()
|
|
.map(|p| ide_host::ops::project::info(p).name)
|
|
.unwrap_or_else(|| window.title().unwrap_or_default());
|
|
Some(WindowTab {
|
|
label: label.clone(),
|
|
name,
|
|
root: project.map(|p| p.root.clone().into()),
|
|
current: label == caller,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn changed(app: &AppHandle) {
|
|
app.state::<AppState>().sink.emit(IdeEvent::WindowGroupsChanged);
|
|
}
|
|
|
|
/// Whether `to` already presents `from`'s frame, so moving it would only make it flicker.
|
|
fn same_frame(from: &WebviewWindow, to: &WebviewWindow) -> bool {
|
|
let (Ok(from_max), Ok(to_max)) = (from.is_maximized(), to.is_maximized()) else { return false };
|
|
let (Ok(from_full), Ok(to_full)) = (from.is_fullscreen(), to.is_fullscreen()) else { return false };
|
|
if from_max != to_max || from_full != to_full {
|
|
return false;
|
|
}
|
|
let same_monitor = match (from.current_monitor(), to.current_monitor()) {
|
|
(Ok(Some(a)), Ok(Some(b))) => a.position() == b.position() && a.size() == b.size(),
|
|
// An unmapped window may not report a monitor; treat only equal states as matching.
|
|
(Ok(None), Ok(None)) | (Err(_), Err(_)) => true,
|
|
_ => false,
|
|
};
|
|
if !same_monitor {
|
|
return false;
|
|
}
|
|
from_full
|
|
|| from_max
|
|
|| (from.outer_position().ok() == to.outer_position().ok() && from.inner_size().ok() == to.inner_size().ok())
|
|
}
|
|
|
|
/// Moves a hidden window onto `from`'s monitor. A restored window keeps its normal position on
|
|
/// the monitor it last occupied; maximizing or going fullscreen there would make the window
|
|
/// visibly jump across displays when its tab is selected.
|
|
fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
|
// Wayland does not let clients place top-level windows; maximizing stays on that output.
|
|
#[cfg(target_os = "linux")]
|
|
if crate::linux_window_state::is_wayland() {
|
|
return Ok(());
|
|
}
|
|
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
|
if let Some(monitor) = from.current_monitor().map_err(err)? {
|
|
to.set_position(*monitor.position()).map_err(err)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
|
if same_frame(from, to) {
|
|
return Ok(());
|
|
}
|
|
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
|
let fullscreen = from.is_fullscreen().map_err(err)?;
|
|
if fullscreen {
|
|
#[cfg(target_os = "macos")]
|
|
crate::macos::transfer_fullscreen(from, to).map_err(err)?;
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
to.set_fullscreen(false).map_err(err)?;
|
|
place_on_monitor(from, to)?;
|
|
from.set_fullscreen(false).map_err(err)?;
|
|
to.set_fullscreen(true).map_err(err)?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
to.set_fullscreen(false).map_err(err)?;
|
|
let maximized = from.is_maximized().map_err(err)?;
|
|
if !maximized {
|
|
to.unmaximize().map_err(err)?;
|
|
to.set_position(from.outer_position().map_err(err)?).map_err(err)?;
|
|
// `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland.
|
|
#[cfg(target_os = "linux")]
|
|
let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?;
|
|
#[cfg(not(target_os = "linux"))]
|
|
let size = from.inner_size().map_err(err)?;
|
|
#[cfg(target_os = "linux")]
|
|
crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size);
|
|
to.set_size(size).map_err(err)?;
|
|
} else {
|
|
// Maximize the target on the source's monitor, not on the monitor it happened to
|
|
// occupy while hidden.
|
|
to.unmaximize().map_err(err)?;
|
|
place_on_monitor(from, to)?;
|
|
to.maximize().map_err(err)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Keeps hidden group members at the selected window's frame. Selecting a tab then only
|
|
/// changes which window is visible instead of moving or resizing one on screen.
|
|
fn sync_member_frames(app: &AppHandle, selected: &str) {
|
|
let Some(group) = app.state::<WindowsState>().groups.lock().group(selected).cloned() else { return };
|
|
if group.selected != selected {
|
|
return;
|
|
}
|
|
let Some(from) = app.get_webview_window(selected) else { return };
|
|
if from.is_fullscreen().unwrap_or(false) || from.is_minimized().unwrap_or(false) || simple_fullscreen(selected) {
|
|
return;
|
|
}
|
|
for member in group.members.iter().filter(|member| member.as_str() != selected) {
|
|
let Some(to) = app.get_webview_window(member) else { continue };
|
|
// A visible peer is the one being presented; never move it behind the user's back.
|
|
if to.is_visible().unwrap_or(false) {
|
|
continue;
|
|
}
|
|
if let Err(error) = copy_frame(&from, &to) {
|
|
tracing::warn!("cannot align window {member} with {selected}: {}", error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A presented group window moved or resized. Align its hidden peers after the user stops, so
|
|
/// the next tab selection is only a visibility change.
|
|
pub fn frame_changed(app: &AppHandle, label: &str) {
|
|
let Some(group) = app.state::<WindowsState>().groups.lock().group(label).cloned() else { return };
|
|
if group.selected != label {
|
|
return;
|
|
}
|
|
let generation = FRAME_SYNC_GENERATION.fetch_add(1, Ordering::Relaxed) + 1;
|
|
let app = app.clone();
|
|
let label = label.to_owned();
|
|
tauri::async_runtime::spawn(async move {
|
|
tokio::time::sleep(FRAME_SETTLE_AFTER_MOVE).await;
|
|
if FRAME_SYNC_GENERATION.load(Ordering::Relaxed) != generation {
|
|
return;
|
|
}
|
|
if !app.state::<WindowsState>().quitting() {
|
|
sync_member_frames(&app, &label);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn simple_fullscreen(label: &str) -> bool {
|
|
#[cfg(target_os = "macos")]
|
|
return crate::macos::is_simple_fullscreen(label);
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
let _ = label;
|
|
false
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn set_simple_fullscreen(window: &WebviewWindow, on: bool) -> IdeResult<()> {
|
|
crate::macos::set_simple_fullscreen(window, on).map_err(|e| IdeError::internal(e.to_string()))
|
|
}
|
|
|
|
/// Fullscreen for `window`. On macOS a grouped window uses simple fullscreen, for every member
|
|
/// at once: the group stays in one Space and each member keeps the screen's size, so switching
|
|
/// projects is only showing one window and hiding the others.
|
|
pub fn toggle_fullscreen(window: &WebviewWindow) -> IdeResult<bool> {
|
|
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let simple = simple_fullscreen(window.label());
|
|
let app = window.app_handle();
|
|
let members = app.state::<WindowsState>().groups.lock().group(window.label()).map(|g| g.members.clone());
|
|
if simple || (members.is_some() && !window.is_fullscreen().map_err(err)?) {
|
|
set_simple_fullscreen(window, !simple)?;
|
|
for member in members.iter().flatten().filter(|m| *m != window.label()) {
|
|
if let Some(other) = app.get_webview_window(member)
|
|
&& simple_fullscreen(member) == simple
|
|
{
|
|
set_simple_fullscreen(&other, !simple)?;
|
|
}
|
|
}
|
|
return Ok(!simple);
|
|
}
|
|
}
|
|
let on = !window.is_fullscreen().map_err(err)?;
|
|
window.set_fullscreen(on).map_err(err)?;
|
|
Ok(on)
|
|
}
|
|
|
|
pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> {
|
|
let to = app.get_webview_window(target).ok_or_else(|| IdeError::not_found("Window closed"))?;
|
|
let state = app.state::<WindowsState>();
|
|
let previous = state.groups.lock().select(caller, target)?;
|
|
// In a fullscreen group the target already covers the screen, or does so before it is shown.
|
|
let simple = previous.selected != target && simple_fullscreen(&previous.selected);
|
|
let result = (|| {
|
|
#[cfg(target_os = "macos")]
|
|
if simple && !simple_fullscreen(target) {
|
|
set_simple_fullscreen(&to, true)?;
|
|
}
|
|
if !simple
|
|
&& previous.selected != target
|
|
&& let Some(from) = app.get_webview_window(&previous.selected)
|
|
{
|
|
copy_frame(&from, &to)?;
|
|
}
|
|
to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?;
|
|
to.show().map_err(|e| IdeError::internal(e.to_string()))?;
|
|
to.set_focus().map_err(|e| IdeError::internal(e.to_string()))?;
|
|
for member in &previous.members {
|
|
if member != target
|
|
&& let Some(window) = app.get_webview_window(member)
|
|
{
|
|
window.hide().map_err(|e| IdeError::internal(e.to_string()))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
})();
|
|
if result.is_err() {
|
|
let _ = state.groups.lock().select(caller, &previous.selected);
|
|
for member in &previous.members {
|
|
if let Some(window) = app.get_webview_window(member) {
|
|
if member == &previous.selected {
|
|
let _ = window.show();
|
|
let _ = window.set_focus();
|
|
} else {
|
|
let _ = window.hide();
|
|
}
|
|
}
|
|
}
|
|
} else if !state.quitting() {
|
|
// Align the members that are now hidden while they stay invisible, so the next
|
|
// selection does not move or resize a window on screen. Hiding is asynchronous;
|
|
// wait for it before moving a peer that is still on screen.
|
|
let app = app.clone();
|
|
let target = target.to_owned();
|
|
tauri::async_runtime::spawn(async move {
|
|
tokio::time::sleep(FRAME_SETTLE_AFTER_SELECT).await;
|
|
if !app.state::<WindowsState>().quitting() {
|
|
sync_member_frames(&app, &target);
|
|
}
|
|
});
|
|
}
|
|
changed(app);
|
|
result
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
pub fn join(app: &AppHandle, anchor: &str, new_window: &str) -> IdeResult<()> {
|
|
let state = app.state::<WindowsState>();
|
|
state.groups.lock().join(anchor, new_window)?;
|
|
select(app, anchor, new_window)
|
|
}
|
|
|
|
pub fn merge(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
|
let members = windows::project_window_labels(app)
|
|
.into_iter()
|
|
.filter(|label| app.state::<AppState>().project(label).is_ok_and(|p| !p.light))
|
|
.collect::<Vec<_>>();
|
|
if members.len() < 2 || !members.iter().any(|label| label == caller) {
|
|
return Err(IdeError::invalid("Open at least two project windows to create a window group"));
|
|
}
|
|
// Native fullscreen puts each window in a separate Space; leave it before grouping.
|
|
for label in &members {
|
|
if let Some(window) = app.get_webview_window(label) {
|
|
#[cfg(target_os = "macos")]
|
|
if label != caller && simple_fullscreen(label) {
|
|
set_simple_fullscreen(&window, false)?;
|
|
}
|
|
window.set_fullscreen(false).map_err(|e| IdeError::internal(e.to_string()))?;
|
|
}
|
|
}
|
|
app.state::<WindowsState>().groups.lock().merge(members, caller)?;
|
|
select(app, caller, caller)
|
|
}
|
|
|
|
pub fn detach(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
|
let state = app.state::<WindowsState>();
|
|
let next = state.groups.lock().detach(caller);
|
|
if let Some(next) = next {
|
|
// The tab that stays keeps the fullscreen; the detached window opens beside it.
|
|
#[cfg(target_os = "macos")]
|
|
let simple = simple_fullscreen(caller);
|
|
if let Some(window) = app.get_webview_window(&next) {
|
|
#[cfg(target_os = "macos")]
|
|
if simple && !simple_fullscreen(&next) {
|
|
set_simple_fullscreen(&window, true)?;
|
|
}
|
|
window.show().map_err(|e| IdeError::internal(e.to_string()))?;
|
|
}
|
|
if let Some(window) = app.get_webview_window(caller) {
|
|
#[cfg(target_os = "macos")]
|
|
if simple {
|
|
set_simple_fullscreen(&window, false)?;
|
|
}
|
|
if let Ok(position) = window.outer_position() {
|
|
let _ = window.set_position(tauri::PhysicalPosition::new(position.x + 32, position.y + 32));
|
|
}
|
|
windows::focus(&window);
|
|
}
|
|
}
|
|
changed(app);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn destroyed(app: &AppHandle, caller: &str) {
|
|
let Some(state) = app.try_state::<WindowsState>() else { return };
|
|
#[cfg(target_os = "macos")]
|
|
let simple = crate::macos::forget_simple_fullscreen(app, caller);
|
|
let next = state.groups.lock().detach(caller);
|
|
if !state.quitting() {
|
|
if let Some(window) = next.and_then(|label| app.get_webview_window(&label)) {
|
|
#[cfg(target_os = "macos")]
|
|
if simple && !simple_fullscreen(window.label()) {
|
|
let _ = set_simple_fullscreen(&window, true);
|
|
}
|
|
windows::focus(&window);
|
|
}
|
|
let app = app.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
let _ = persist(&app).await;
|
|
});
|
|
}
|
|
changed(app);
|
|
let advance = state.tab_closes.lock().as_mut().is_some_and(|batch| {
|
|
let closing = batch.pending.front().is_some_and(|l| l == caller);
|
|
if closing {
|
|
batch.pending.pop_front();
|
|
}
|
|
closing
|
|
});
|
|
if advance {
|
|
close_next(app);
|
|
}
|
|
}
|
|
|
|
/// Tabs left to close; the first one is closing now.
|
|
pub struct TabCloses {
|
|
keep: String,
|
|
pending: VecDeque<String>,
|
|
}
|
|
|
|
/// Closes `labels` one at a time so each project's unsaved-changes prompt is shown in turn, then selects `keep`.
|
|
pub fn close_tabs(app: &AppHandle, caller: &str, keep: &str, labels: Vec<String>) -> IdeResult<()> {
|
|
let state = app.state::<WindowsState>();
|
|
let members = state
|
|
.groups
|
|
.lock()
|
|
.group(caller)
|
|
.map(|g| g.members.clone())
|
|
.ok_or_else(|| IdeError::not_found("This window is not grouped"))?;
|
|
if !members.iter().any(|m| m == keep) {
|
|
return Err(IdeError::invalid("The window is not in this group"));
|
|
}
|
|
let pending = members.into_iter().filter(|m| m != keep && labels.contains(m)).collect();
|
|
*state.tab_closes.lock() = Some(TabCloses { keep: keep.to_owned(), pending });
|
|
close_next(app);
|
|
Ok(())
|
|
}
|
|
|
|
/// The closing tab's project vetoed (its unsaved-changes prompt was cancelled): leave the rest open.
|
|
pub fn cancel_close(app: &AppHandle, caller: &str) {
|
|
let state = app.state::<WindowsState>();
|
|
let mut batch = state.tab_closes.lock();
|
|
if batch.as_ref().is_some_and(|b| b.pending.front().is_some_and(|l| l == caller)) {
|
|
*batch = None;
|
|
}
|
|
}
|
|
|
|
fn close_next(app: &AppHandle) {
|
|
let state = app.state::<WindowsState>();
|
|
loop {
|
|
let next = {
|
|
let mut batch = state.tab_closes.lock();
|
|
let Some(b) = batch.as_mut() else { return };
|
|
match b.pending.front() {
|
|
Some(label) => Ok(label.clone()),
|
|
None => Err(batch.take().unwrap().keep),
|
|
}
|
|
};
|
|
match next {
|
|
Ok(label) => {
|
|
if let Some(window) = app.get_webview_window(&label) {
|
|
windows::focus(&window);
|
|
if window.close().is_ok() {
|
|
return;
|
|
}
|
|
}
|
|
if let Some(b) = state.tab_closes.lock().as_mut() {
|
|
b.pending.pop_front();
|
|
}
|
|
}
|
|
Err(keep) => {
|
|
if let Some(window) = app.get_webview_window(&keep) {
|
|
windows::focus(&window);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct SavedGroup {
|
|
projects: Vec<PathBuf>,
|
|
selected: usize,
|
|
}
|
|
|
|
pub async fn persist(app: &AppHandle) -> IdeResult<()> {
|
|
let state = app.state::<WindowsState>();
|
|
let _write = state.groups_persist.lock().await;
|
|
if state.quitting() {
|
|
return Ok(());
|
|
}
|
|
let groups = state.groups.lock().groups().to_vec();
|
|
let host = app.state::<AppState>();
|
|
let saved: Vec<_> = groups
|
|
.iter()
|
|
.filter_map(|group| {
|
|
let projects: Vec<_> = group
|
|
.members
|
|
.iter()
|
|
.filter_map(|label| {
|
|
host.project(label)
|
|
.ok()
|
|
.map(|p| p.root.clone())
|
|
.or_else(|| state.pending(label).and_then(|l| l.project).map(Into::into))
|
|
})
|
|
.collect();
|
|
(projects.len() == group.members.len()).then(|| SavedGroup {
|
|
projects,
|
|
selected: group.members.iter().position(|l| l == &group.selected).unwrap_or(0),
|
|
})
|
|
})
|
|
.collect();
|
|
// A restored member may have taken its launch but not finished opening yet.
|
|
if saved.len() != groups.len() {
|
|
return Ok(());
|
|
}
|
|
let path = host.config_dir.join("window-groups.json");
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
let bytes = serde_json::to_vec(&saved).map_err(|e| IdeError::internal(e.to_string()))?;
|
|
ide_vfs::write_atomic(&path, &bytes).map(|_| ())
|
|
})
|
|
.await
|
|
.map_err(|e| IdeError::internal(e.to_string()))?
|
|
}
|
|
|
|
pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
|
if caller != "main" {
|
|
return Ok(());
|
|
}
|
|
let state = app.state::<WindowsState>();
|
|
let host = app.state::<AppState>();
|
|
let project = host.project(caller)?;
|
|
if !project.settings.effective().general.reopen_last_project || state.groups_restored.swap(true, Ordering::SeqCst) {
|
|
return Ok(());
|
|
}
|
|
let path = host.config_dir.join("window-groups.json");
|
|
let saved: Vec<SavedGroup> = tauri::async_runtime::spawn_blocking(move || -> IdeResult<_> {
|
|
match std::fs::read(path) {
|
|
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| IdeError::internal(e.to_string()))??;
|
|
if let Some(group) = saved.iter().find(|g| g.projects.contains(&project.root)) {
|
|
let mut labels = Vec::new();
|
|
let mut selected = caller.to_owned();
|
|
for path in &group.projects {
|
|
let label = if path == &project.root {
|
|
Some(caller.to_owned())
|
|
} else if path.is_dir() {
|
|
let window = match windows::find_project_window(app, path) {
|
|
Some(w) => w,
|
|
None => windows::create_restored_window(app, path.clone())?,
|
|
};
|
|
Some(window.label().to_owned())
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(label) = label {
|
|
if group.projects.get(group.selected) == Some(path) {
|
|
selected = label.clone();
|
|
}
|
|
labels.push(label);
|
|
}
|
|
}
|
|
if labels.len() > 1 {
|
|
state.groups.lock().merge(labels, caller)?;
|
|
select(app, caller, &selected)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|