fix(window): stop project tab switches from moving the group window (#1287)

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 <[email protected]>
Цей коміт міститься в:
xtr-hubтаxtr-hub authored and GitHub committed 2026-11-04 23:25:01 +08:00
1 джерело 057877a39e
коміт 7cdf690b4b
3 files changed
+115 -2

No files matched your search

+1 -1
Переглянути файл
@@ -30,7 +30,7 @@ Open two project windows, then use **Window → Merge All Project Windows**. The
Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Right-click a tab for **Close Other Tabs**, **Close Tabs to the Left** and **Close Tabs to the Right**, which close the projects one at a time and keep the clicked tab; cancelling one project's unsaved-changes prompt leaves it and the rest open. The same commands are in the Command Palette and can be assigned shortcuts. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled.
The grouping model lives in `ide-workspace`, independently of project ownership. The desktop adapter keeps each member's WebView and backend services alive, presents the selected window at the group's position/size, and hides its peers. This is an application-drawn tab strip, not the macOS native tab strip. Its persistence is application-local in `window-groups.json`. Language servers, terminals, editor buffers and settings remain independent between group members.
The grouping model lives in `ide-workspace`, independently of project ownership. The desktop adapter keeps each member's WebView and backend services alive, presents the selected window at the group's position/size, and hides its peers. Hidden members are kept at the selected window's frame and display (and follow it after a move or resize), so switching projects only changes which window is visible. This is an application-drawn tab strip, not the macOS native tab strip. Its persistence is application-local in `window-groups.json`. Language servers, terminals, editor buffers and settings remain independent between group members.
## Validation
+3
Переглянути файл
@@ -176,6 +176,9 @@ pub fn run() {
}
}
tauri::WindowEvent::Focused(false) => application_focus::changed(window.app_handle(), false),
tauri::WindowEvent::Moved(_) | tauri::WindowEvent::Resized(_) => {
window_groups::frame_changed(window.app_handle(), window.label());
}
tauri::WindowEvent::Destroyed => {
#[cfg(target_os = "macos")]
{
+111 -1
Переглянути файл
@@ -8,9 +8,17 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::Ordering;
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 {
@@ -48,7 +56,47 @@ 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 {
@@ -56,6 +104,8 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
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)?;
}
@@ -75,11 +125,59 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
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);
@@ -163,6 +261,18 @@ pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> {
}
}
}
} 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