diff --git a/crates/ide-workspace/src/window_groups.rs b/crates/ide-workspace/src/window_groups.rs index a2a088d40..5e948a790 100644 --- a/crates/ide-workspace/src/window_groups.rs +++ b/crates/ide-workspace/src/window_groups.rs @@ -2,6 +2,29 @@ use ide_core::{IdeError, IdeResult}; use serde::{Deserialize, Serialize}; +/// Platform adapters supply the system's tab preference and active project state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TabbingPreference { + Always, + InFullScreen, + Manually, +} + +pub fn should_group_new_project( + preference: TabbingPreference, + fullscreen: bool, + active_project: bool, + light_edit: bool, +) -> bool { + active_project + && !light_edit + && match preference { + TabbingPreference::Always => true, + TabbingPreference::InFullScreen => fullscreen, + TabbingPreference::Manually => false, + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WindowGroup { pub members: Vec, @@ -39,6 +62,13 @@ impl WindowGroups { Ok(()) } + /// Adds a window to the anchor's group without merging unrelated groups. + pub fn join(&mut self, anchor: &str, member: &str) -> IdeResult<()> { + let mut members = self.group(anchor).map(|g| g.members.clone()).unwrap_or_else(|| vec![anchor.to_owned()]); + members.push(member.to_owned()); + self.merge(members, anchor) + } + pub fn select(&mut self, caller: &str, target: &str) -> IdeResult { let group = self .groups @@ -94,6 +124,45 @@ mod tests { values.iter().map(|s| (*s).into()).collect() } + #[test] + fn new_project_grouping_follows_system_preference_and_excludes_light_edit() { + for preference in [TabbingPreference::Always, TabbingPreference::InFullScreen, TabbingPreference::Manually] { + for fullscreen in [false, true] { + for active_project in [false, true] { + for light_edit in [false, true] { + let expected = matches!( + (preference, fullscreen, active_project, light_edit), + (TabbingPreference::Always, _, true, false) + | (TabbingPreference::InFullScreen, true, true, false) + ); + assert_eq!( + should_group_new_project(preference, fullscreen, active_project, light_edit), + expected, + "{preference:?}, fullscreen={fullscreen}, active={active_project}, light={light_edit}" + ); + } + } + } + } + } + + #[test] + fn joining_new_windows_preserves_the_active_group_and_other_groups() { + let mut groups = WindowGroups::default(); + groups.merge(members(&["other-a", "other-b"]), "other-b").unwrap(); + groups.join("a", "b").unwrap(); + assert_eq!(groups.group("a").unwrap().members, members(&["a", "b"])); + let before = groups.select("a", "b").unwrap(); + assert_eq!(before.selected, "a"); + groups.join("b", "c").unwrap(); + let before = groups.select("b", "c").unwrap(); + assert_eq!(before.selected, "b"); + assert_eq!(groups.group("c").unwrap().members, members(&["a", "b", "c"])); + assert_eq!(groups.group("c").unwrap().selected, "c"); + assert_eq!(groups.group("other-a").unwrap().members, members(&["other-a", "other-b"])); + assert_eq!(groups.group("other-a").unwrap().selected, "other-b"); + } + #[test] fn selecting_and_detaching_preserve_the_other_sessions() { let mut groups = WindowGroups::default(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ed795e660..e75910a3d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,8 @@ mod events; #[cfg(any(target_os = "linux", test))] mod linux_window_state; mod logging; +#[cfg(target_os = "macos")] +mod macos; mod menu; mod remote_dev; @@ -172,7 +174,10 @@ pub fn run() { tauri::WindowEvent::Focused(false) => application_focus::changed(window.app_handle(), false), tauri::WindowEvent::Destroyed => { #[cfg(target_os = "macos")] - titlebar::forget(window); + { + titlebar::forget(window); + macos::forget_fullscreen(window.label()); + } window_groups::destroyed(window.app_handle(), window.label()); if let Some(settings) = window.app_handle().get_webview_window(&windows::settings_label(window.label())) { let _ = settings.destroy(); diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs new file mode 100644 index 000000000..783e0a993 --- /dev/null +++ b/src-tauri/src/macos.rs @@ -0,0 +1,98 @@ +//! macOS window tab preferences, applied to application-drawn project tabs. +use crate::windows::{Launch, WindowsState}; +use ide_core::{IdeError, IdeResult}; +use ide_workspace::window_groups::{TabbingPreference, should_group_new_project}; +use objc2::{MainThreadMarker, rc::Retained, runtime::ProtocolObject}; +use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference}; +use objc2_foundation::{NSNotificationCenter, NSObjectProtocol}; +use tauri::{AppHandle, Manager, WebviewWindow}; + +fn tabbing_preference(app: &AppHandle) -> IdeResult { + fn read(mtm: MainThreadMarker) -> TabbingPreference { + match NSWindow::userTabbingPreference(mtm) { + NSWindowUserTabbingPreference::Always => TabbingPreference::Always, + NSWindowUserTabbingPreference::InFullScreen => TabbingPreference::InFullScreen, + _ => TabbingPreference::Manually, + } + } + if let Some(mtm) = MainThreadMarker::new() { + return Ok(read(mtm)); + } + let (tx, rx) = std::sync::mpsc::sync_channel(1); + app.run_on_main_thread(move || { + let _ = tx.send(read(MainThreadMarker::new().expect("AppKit main thread"))); + }) + .map_err(|e| IdeError::internal(e.to_string()))?; + rx.recv().map_err(|e| IdeError::internal(e.to_string())) +} + +/// Capture before building the new window, which takes focus itself. +pub fn new_project_tab_target(app: &AppHandle, launch: &Launch) -> IdeResult> { + let windows = app.webview_windows(); + let active = windows + .values() + .find(|w| w.is_focused().unwrap_or(false)) + .cloned() + .or_else(|| app.state::().last_focused().and_then(|label| windows.get(&label).cloned())); + let active = active.and_then(|w| { + let label = crate::windows::settings_parent(w.label()).unwrap_or(w.label()); + windows.get(label).cloned() + }); + let active = active.filter(|w| { + let host = app.state::(); + host.project(w.label()).is_ok_and(|p| !p.light) || host.remote_dev.get(w.label()).is_some() + }); + let fullscreen = active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false)); + let light_edit = launch.project.is_none() && launch.remote.is_none(); + Ok(should_group_new_project(tabbing_preference(app)?, fullscreen, active.is_some(), light_edit) + .then_some(active) + .flatten()) +} + +struct FullscreenObserver(Retained>); + +impl Drop for FullscreenObserver { + fn drop(&mut self) { + let observer: &ProtocolObject = &self.0; + unsafe { NSNotificationCenter::defaultCenter().removeObserver(observer.as_ref()) }; + } +} + +thread_local! { + static FULLSCREEN_TRANSFERS: std::cell::RefCell> = std::cell::RefCell::default(); +} + +pub fn forget_fullscreen(label: &str) { + FULLSCREEN_TRANSFERS.with_borrow_mut(|observers| observers.remove(label)); +} + +/// AppKit cannot enter another window's fullscreen Space until the old one has exited. +pub fn transfer_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> { + let source = from.label().to_owned(); + let to = to.clone(); + from.with_webview(move |webview| unsafe { + let native = &*webview.ns_window().cast::(); + forget_fullscreen(&source); + let label = source.clone(); + let block = block2::RcBlock::new(move |_| { + forget_fullscreen(&label); + let app = to.app_handle(); + let selected = app.state::().groups.lock().group(to.label()).map(|g| g.selected.clone()); + if let Some(selected) = selected { + // Exiting fullscreen can show the old window again; reapply group visibility. + let _ = crate::window_groups::select(app, &selected, &selected); + if selected == to.label() { + let _ = to.set_fullscreen(true); + } + } + }); + let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( + Some(objc2_app_kit::NSWindowDidExitFullScreenNotification), + Some(native), + None, + &block, + ); + FULLSCREEN_TRANSFERS.with_borrow_mut(|observers| observers.insert(source, FullscreenObserver(observer))); + native.toggleFullScreen(None); + }) +} diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 66fe0c88f..fef00fa14 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -51,8 +51,13 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { let err = |e: tauri::Error| IdeError::internal(e.to_string()); let fullscreen = from.is_fullscreen().map_err(err)?; if fullscreen { - from.set_fullscreen(false).map_err(err)?; - to.set_fullscreen(true).map_err(err)?; + #[cfg(target_os = "macos")] + crate::macos::transfer_fullscreen(from, to).map_err(err)?; + #[cfg(not(target_os = "macos"))] + { + from.set_fullscreen(false).map_err(err)?; + to.set_fullscreen(true).map_err(err)?; + } return Ok(()); } to.set_fullscreen(false).map_err(err)?; @@ -109,6 +114,13 @@ pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> { result } +#[cfg(target_os = "macos")] +pub fn join(app: &AppHandle, anchor: &str, new_window: &str) -> IdeResult<()> { + let state = app.state::(); + 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() @@ -235,7 +247,7 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> { } else if path.is_dir() { let window = match windows::find_project_window(app, path) { Some(w) => w, - None => windows::create_window(app, Some(path.clone()))?, + None => windows::create_restored_window(app, path.clone())?, }; Some(window.label().to_owned()) } else { diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 1599e0604..b9d14c122 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -357,18 +357,35 @@ pub fn create_main_window(app: &AppHandle) -> IdeResult { /// Creates a new project window; `project` is opened by its frontend on load. pub fn create_window(app: &AppHandle, project: Option) -> IdeResult { - create_window_with(app, project, Vec::new()) + create_window_with(app, project, Vec::new(), false) } /// [`create_window`] that also opens `files` (without `project`: in LightEdit). -fn create_window_with(app: &AppHandle, project: Option, files: Vec) -> IdeResult { +fn create_window_with( + app: &AppHandle, + project: Option, + files: Vec, + restoring: bool, +) -> IdeResult { let title = project.as_deref().or(files.first().map(PathBuf::as_path)).and_then(Path::file_name); let title = title.map(|n| n.to_string_lossy().into_owned()); let files = files.into_iter().map(Into::into).collect(); - create_window_for(app, title, Launch { project: project.map(Into::into), files, ..Default::default() }) + create_window_for(app, title, Launch { project: project.map(Into::into), files, ..Default::default() }, restoring) } -fn create_window_for(app: &AppHandle, title: Option, launch: Launch) -> IdeResult { +/// Restoring a saved group preserves its membership and selected tab. +pub fn create_restored_window(app: &AppHandle, project: PathBuf) -> IdeResult { + create_window_with(app, Some(project), Vec::new(), true) +} + +fn create_window_for( + app: &AppHandle, + title: Option, + launch: Launch, + _restoring: bool, +) -> IdeResult { + #[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)?; @@ -388,6 +405,11 @@ fn create_window_for(app: &AppHandle, title: Option, launch: Launch) -> } app.state::().set_launch(&label, launch); let window = build_project_window(app, config)?; + #[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) } @@ -401,7 +423,7 @@ pub fn spawn_window(app: &AppHandle, project: Option) { fn spawn_window_with(app: &AppHandle, project: Option, files: Vec) { let app = app.clone(); tauri::async_runtime::spawn(async move { - if let Err(e) = create_window_with(&app, project, files) { + if let Err(e) = create_window_with(&app, project, files, false) { tracing::warn!("cannot open window: {}", e.message); } }); @@ -440,7 +462,7 @@ pub fn open_project_window( if !new_window { return Ok(OpenOutcome::Current); } - let window = create_window_with(app, Some(root), files)?; + let window = create_window_with(app, Some(root), files, false)?; Ok(OpenOutcome::Opened { label: window.label().to_owned() }) } @@ -450,7 +472,7 @@ pub fn open_project_window( /// detach an editor tab out of its window. pub fn open_file_in_new_window(app: &AppHandle, project: Option, file: PathBuf) -> IdeResult { tracing::info!("open_file_in_new_window {} project={:?}", file.display(), project); - let window = create_window_with(app, project, vec![file])?; + let window = create_window_with(app, project, vec![file], false)?; Ok(OpenOutcome::Opened { label: window.label().to_owned() }) } @@ -478,7 +500,7 @@ pub fn open_remote_window(app: &AppHandle, ssh_config: &str, path: &str, new_win } 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() })?; + let window = create_window_for(app, title, Launch { remote: Some(remote), ..Default::default() }, false)?; Ok(OpenOutcome::Opened { label: window.label().to_owned() }) } diff --git a/src/features/app/WorkspaceDialog.tsx b/src/features/app/WorkspaceDialog.tsx index df5529f46..3e0f4fcd2 100644 --- a/src/features/app/WorkspaceDialog.tsx +++ b/src/features/app/WorkspaceDialog.tsx @@ -5,7 +5,7 @@ import { basename, dirname, useProject } from "@/platform/project"; import { isRemoteWindow } from "@/platform/remote"; import { tx } from "@/platform/i18n"; import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui"; -import { openProjectInWindow } from "./windows"; +import { openProjectWithPolicy } from "./windows"; import styles from "./WorkspaceDialog.module.css"; const useWorkspaceDialog = create<{ mode: "create" | "manage" | null }>(() => ({ mode: null })); @@ -53,7 +53,7 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose const included = adding.trim() ? [...new Set([...projects, adding.trim()])] : projects; if (mode === "create") { const root = await call(commands.workspaceCreate(path.trim(), name.trim(), included)); - await openProjectInWindow(root, !!project); + await openProjectWithPolicy(root); } else { const updated = await call(commands.workspaceUpdate(name.trim(), included)); useProject.setState({ project: updated, settings: updated.settings }); diff --git a/src/features/app/__tests__/WorkspaceDialog.test.tsx b/src/features/app/__tests__/WorkspaceDialog.test.tsx index 262cfb27d..2095e3a44 100644 --- a/src/features/app/__tests__/WorkspaceDialog.test.tsx +++ b/src/features/app/__tests__/WorkspaceDialog.test.tsx @@ -3,13 +3,14 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { commands } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { WorkspaceDialogHost, showWorkspaceDialog } from "../WorkspaceDialog"; -import { openProjectInWindow } from "../windows"; +import { openProjectWithPolicy } from "../windows"; -vi.mock("../windows", () => ({ openProjectInWindow: vi.fn(async () => {}) })); +vi.mock("../windows", () => ({ openProjectWithPolicy: vi.fn(async () => {}) })); vi.mock("@/platform/remote", () => ({ isRemoteWindow: () => false })); beforeEach(() => { vi.restoreAllMocks(); + vi.mocked(openProjectWithPolicy).mockClear(); useProject.setState({ project: null, settings: null, lightEdit: false }); }); @@ -21,7 +22,21 @@ it("creates a workspace from the pending project field without an extra Add clic fireEvent.change(screen.getByLabelText("Project folder path"), { target: { value: "/work/frontend" } }); fireEvent.click(screen.getByRole("button", { name: /^Create$/ })); await waitFor(() => expect(save).toHaveBeenCalledWith("/work/full", "Workspace", ["/work/frontend"])); - expect(openProjectInWindow).toHaveBeenCalledWith("/work/full", false); + expect(openProjectWithPolicy).toHaveBeenCalledWith("/work/full"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); +}); + +it("uses the shared opening policy after creation from an already open project", async () => { + useProject.setState({ + project: { root: "/work/frontend", name: "frontend", trusted: true, isGit: false, settings: {} }, + settings: { general: { openProjectIn: "ask" } }, + } as never); + vi.spyOn(commands, "workspaceCreate").mockResolvedValue({ status: "ok", data: "/work/full" }); + showWorkspaceDialog("create"); + render(); + fireEvent.change(screen.getByLabelText("Workspace location"), { target: { value: "/work/full" } }); + fireEvent.click(screen.getByRole("button", { name: /^Create$/ })); + await waitFor(() => expect(openProjectWithPolicy).toHaveBeenCalledWith("/work/full")); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); });