дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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
Цей коміт міститься в:
1 джерело
b3abc8edd1
коміт
8d7ce81249
7 files changed
+238
-17
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Заблокувати користувача