fix(macos): open project windows as tabs per the system tab preference (#693)

Project windows only joined a tab group when merged explicitly or restored at startup; the macOS "Prefer tabs when opening documents" setting was never read, so opening another project or a workspace always created a separate window.

New project windows now follow that preference: with Always they join the active project window's tab group (forming one if needed) and are selected; with In Full Screen they do so only while the active window is full screen; with Manually they stay separate. This covers every path that creates a project window — Open, recent projects, workspaces, drag and drop, and opening from the command line or Finder — while LightEdit windows and restored groups keep their behaviour. New Workspace now opens through the same Open project in policy as Open Workspace, and a race when toggling full screen during the join is fixed.

Fixes #678
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-01 20:14:08 +08:00
1 джерело b3abc8edd1
коміт 8d7ce81249
7 files changed
+238 -17

No files matched your search

+69
Переглянути файл
@@ -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<String>,
@@ -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<WindowGroup> {
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();
+6 -1
Переглянути файл
@@ -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();
+98
Переглянути файл
@@ -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<TabbingPreference> {
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<Option<WebviewWindow>> {
let windows = app.webview_windows();
let active = windows
.values()
.find(|w| w.is_focused().unwrap_or(false))
.cloned()
.or_else(|| app.state::<WindowsState>().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::<crate::state::AppState>();
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<ProtocolObject<dyn NSObjectProtocol>>);
impl Drop for FullscreenObserver {
fn drop(&mut self) {
let observer: &ProtocolObject<dyn NSObjectProtocol> = &self.0;
unsafe { NSNotificationCenter::defaultCenter().removeObserver(observer.as_ref()) };
}
}
thread_local! {
static FULLSCREEN_TRANSFERS: std::cell::RefCell<std::collections::HashMap<String, FullscreenObserver>> = 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::<NSWindow>();
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::<WindowsState>().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);
})
}
+15 -3
Переглянути файл
@@ -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::<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()
@@ -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 {
+30 -8
Переглянути файл
@@ -357,18 +357,35 @@ pub fn create_main_window(app: &AppHandle) -> IdeResult<WebviewWindow> {
/// 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())
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>) -> IdeResult<WebviewWindow> {
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() })
create_window_for(app, title, Launch { project: project.map(Into::into), files, ..Default::default() }, restoring)
}
fn create_window_for(app: &AppHandle, title: Option<String>, launch: Launch) -> IdeResult<WebviewWindow> {
/// 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)?;
@@ -388,6 +405,11 @@ fn create_window_for(app: &AppHandle, title: Option<String>, launch: Launch) ->
}
app.state::<WindowsState>().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<PathBuf>) {
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) {
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<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])?;
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() })
}
+2 -2
Переглянути файл
@@ -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 });
+18 -3
Переглянути файл
@@ -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(<WorkspaceDialogHost />);
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());
});