From c720024b42a41f1acb901c5da317a232bacba4a3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:56:38 +0800 Subject: [PATCH] fix(window): open the next tab's Space before a fullscreen group's host closes (#2002) Closing the tab that hosts a fullscreen project group ended its Space first, so macOS showed the desktop for about a second before the next tab's Space opened. Once the close is confirmed, the next tab now enters its own fullscreen Space first and the host closes after it, so the screen moves straight to it. --- crates/ide-workspace/src/window_groups.rs | 33 ++++++++++++++++---- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/window_group.rs | 14 +++++++++ src-tauri/src/macos.rs | 38 +++++++++++++++++++++++ src-tauri/src/window_groups.rs | 35 +++++++++++++++++++-- src/bindings.ts | 5 +++ src/features/app/closeGuard.ts | 1 + 7 files changed, 119 insertions(+), 8 deletions(-) diff --git a/crates/ide-workspace/src/window_groups.rs b/crates/ide-workspace/src/window_groups.rs index da095146b..53b61d1ae 100644 --- a/crates/ide-workspace/src/window_groups.rs +++ b/crates/ide-workspace/src/window_groups.rs @@ -84,16 +84,24 @@ impl WindowGroups { Ok(before) } + /// The member `detach(member)` will make active: the selection, or the selected member's neighbour. + pub fn successor(&self, member: &str) -> Option { + let group = self.group(member)?; + if group.selected != member { + return Some(group.selected.clone()); + } + let index = group.members.iter().position(|m| m == member)?; + let next = if index + 1 < group.members.len() { index + 1 } else { index.checked_sub(1)? }; + Some(group.members[next].clone()) + } + /// Returns the remaining active member, including when the last pair dissolves. pub fn detach(&mut self, member: &str) -> Option { + let selected = self.successor(member)?; let i = self.groups.iter().position(|g| g.members.iter().any(|m| m == member))?; let group = &mut self.groups[i]; - let index = group.members.iter().position(|m| m == member).unwrap(); - group.members.remove(index); - if group.selected == member { - group.selected = group.members[index.min(group.members.len() - 1)].clone(); - } - let selected = group.selected.clone(); + group.members.retain(|m| m != member); + group.selected = selected.clone(); if group.members.len() < 2 { self.groups.remove(i); } @@ -213,6 +221,19 @@ mod tests { assert!(groups.group("a").is_none()); } + #[test] + fn the_successor_is_the_member_detach_selects() { + let mut groups = WindowGroups::default(); + groups.merge(members(&["a", "b", "c"]), "b").unwrap(); + assert_eq!(groups.successor("a"), Some("b".into())); + assert_eq!(groups.successor("b"), Some("c".into())); + groups.select("a", "c").unwrap(); + assert_eq!(groups.successor("c"), Some("b".into())); + assert_eq!(groups.successor("unrelated"), None); + assert_eq!(groups.detach("c"), Some("b".into())); + assert_eq!(groups.group("a").unwrap().selected, "b"); + } + #[test] fn merging_groups_and_reordering_keep_unique_members() { let mut groups = WindowGroups::default(); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ecb5fd3a8..9b9173e6f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -87,6 +87,7 @@ pub fn builder() -> tauri_specta::Builder { window_group::window_group_close, window_group::window_group_close_tabs, window_group::window_group_cancel_close, + window_group::window_group_before_close, window_group::window_group_reorder, window_group::window_group_restore, app::light_edit_open, diff --git a/src-tauri/src/commands/window_group.rs b/src-tauri/src/commands/window_group.rs index f6d4d29ae..f016e9519 100644 --- a/src-tauri/src/commands/window_group.rs +++ b/src-tauri/src/commands/window_group.rs @@ -55,6 +55,20 @@ pub fn window_group_cancel_close(window: WebviewWindow) { groups::cancel_close(window.app_handle(), window.label()); } +/// This window closes next (its prompts passed): a fullscreen group's host first hands its Space +/// to the tab that takes over. +#[tauri::command] +#[specta::specta] +pub async fn window_group_before_close(window: WebviewWindow) -> IdeResult<()> { + #[cfg(target_os = "macos")] + return groups::before_close(window.app_handle(), window.label()).await; + #[cfg(not(target_os = "macos"))] + { + let _ = window; + Ok(()) + } +} + #[tauri::command] #[specta::specta] pub async fn window_group_reorder(window: WebviewWindow, labels: Vec) -> IdeResult<()> { diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 3d32a8de1..7b6a63add 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -173,6 +173,44 @@ pub fn watch_fullscreen_host(host: &WebviewWindow, left: impl Fn() + Send + 'sta }) } +thread_local! { + /// Windows being waited on to finish entering fullscreen. + static ENTERING_FULLSCREEN: std::cell::RefCell> = std::cell::RefCell::default(); +} + +/// Toggles `window` into native fullscreen and resolves once it is there, or after `timeout`. +pub async fn enter_fullscreen_and_wait(window: &WebviewWindow, timeout: std::time::Duration) -> tauri::Result<()> { + let (tx, rx) = tokio::sync::oneshot::channel(); + let label = window.label().to_owned(); + let target = window.clone(); + on_main(window, move || unsafe { + let Ok(pointer) = target.ns_window() else { return }; + let native = &*pointer.cast::(); + let tx = std::cell::Cell::new(Some(tx)); + let block = block2::RcBlock::new(move |_| { + if let Some(tx) = tx.take() { + let _ = tx.send(()); + } + }); + let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( + Some(objc2_app_kit::NSWindowDidEnterFullScreenNotification), + Some(native), + None, + &block, + ); + ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.insert(label, FullscreenObserver(observer))); + })?; + let toggled = native_toggle_fullscreen(window); + if toggled.is_ok() { + let _ = tokio::time::timeout(timeout, rx).await; + } + let label = window.label().to_owned(); + on_main(window, move || { + ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.remove(&label)); + })?; + toggled +} + /// An observer on the workspace's notification center, removed when dropped. struct WorkspaceObserver(Retained, Retained>); diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 0601fb32f..70ea2dd37 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -19,6 +19,9 @@ const FRAME_SETTLE_AFTER_MOVE: Duration = Duration::from_millis(100); /// Longest wait for a closed host's fullscreen Space to go before the next tab takes the screen. #[cfg(target_os = "macos")] const SPACE_TEARDOWN: Duration = Duration::from_millis(1500); +/// Longest wait for the tab taking over from a closing host to enter its own fullscreen Space. +#[cfg(target_os = "macos")] +const SPACE_HANDOVER: Duration = Duration::from_millis(2000); /// Coalesces the move/resize events a drag emits. static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0); @@ -327,6 +330,13 @@ pub fn toggle_fullscreen(window: &WebviewWindow) -> IdeResult { /// Makes `host` the fullscreen window of its group, or of its own Space when ungrouped. #[cfg(target_os = "macos")] fn host_fullscreen(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> { + prepare_host(app, host)?; + crate::macos::native_toggle_fullscreen(host).map_err(|e| IdeError::internal(e.to_string())) +} + +/// `host` and the other members of its group, ready for `host` to enter fullscreen. +#[cfg(target_os = "macos")] +fn prepare_host(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> { let err = |e: tauri::Error| IdeError::internal(e.to_string()); let members = app.state::().groups.lock().group(host.label()).map(|g| g.members.clone()); crate::macos::set_fullscreen_auxiliary(host, None).map_err(err)?; @@ -338,8 +348,29 @@ fn host_fullscreen(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> { if members.is_some() { watch_host(app, host)?; } - host.show().map_err(err)?; - crate::macos::native_toggle_fullscreen(host).map_err(err) + host.show().map_err(err) +} + +/// A fullscreen group's host is about to close, past its prompts. The tab that takes over leaves +/// for a Space of its own first, so the screen moves straight to it; were the host closed first, +/// its Space would end and show the desktop until the next one opened. +#[cfg(target_os = "macos")] +pub async fn before_close(app: &AppHandle, caller: &str) -> IdeResult<()> { + let state = app.state::(); + let Some(members) = state.groups.lock().group(caller).map(|g| g.members.clone()) else { return Ok(()) }; + if state.quitting() || fullscreen_host(app, &members).is_none_or(|host| host.label() != caller) { + return Ok(()); + } + // Detached now, the closing host leaves nothing for `destroyed` to hand over. + let next = state.groups.lock().detach(caller); + changed(app); + let Some(next) = next.and_then(|label| app.get_webview_window(&label)) else { return Ok(()) }; + prepare_host(app, &next)?; + crate::macos::enter_fullscreen_and_wait(&next, SPACE_HANDOVER) + .await + .map_err(|e| IdeError::internal(e.to_string()))?; + windows::focus(&next); + Ok(()) } #[cfg(target_os = "macos")] diff --git a/src/bindings.ts b/src/bindings.ts index 91103e03b..f185b0b2f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -42,6 +42,11 @@ export const commands = { windowGroupCloseTabs: (keep: string, labels: string[]) => typedError(__TAURI_INVOKE("window_group_close_tabs", { keep, labels })), /** This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. */ windowGroupCancelClose: () => __TAURI_INVOKE("window_group_cancel_close"), + /** + * This window closes next (its prompts passed): a fullscreen group's host first hands its Space + * to the tab that takes over. + */ + windowGroupBeforeClose: () => typedError(__TAURI_INVOKE("window_group_before_close")), windowGroupReorder: (labels: string[]) => typedError(__TAURI_INVOKE("window_group_reorder", { labels })), windowGroupRestore: () => typedError(__TAURI_INVOKE("window_group_restore")), /** diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index 084fb5244..448435502 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -205,6 +205,7 @@ async function onCloseRequested(deps: CloseDeps): Promise { deps, ); if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose()); + else if (!ctx.quitting) await commands.windowGroupBeforeClose(); return ok; } catch (e) { maybeEditorService()?.cancelClose?.();