Add macOS window tabs (#33334)

Gaauwe Rombouts and Conrad Irwin created

Closes https://github.com/zed-industries/zed/issues/14722
Closes https://github.com/zed-industries/zed/issues/4948
Closes https://github.com/zed-industries/zed/issues/7136

Follow up of https://github.com/zed-industries/zed/pull/20557 and
https://github.com/zed-industries/zed/pull/32238.

Based on the discussions in the previous PRs and the pairing session
with @ConradIrwin I've decided to rewrite it from scratch, to properly
incorporate all the requirements. The feature is opt-in, the settings is
set to false by default. Once enabled via the Zed settings, it will
behave according to the user’s system preference, without requiring a
restart β€” the next window opened will adopt the new behavior (similar to
Ghostty).

I’m not entirely sure if the changes to the Window class are the best
approach. I’ve tried to keep things flexible enough that other
applications built with GPUI won’t be affected (while giving them the
option to still use it), but I’d appreciate input on whether this
direction makes sense long-term.



https://github.com/user-attachments/assets/9573e094-4394-41ad-930c-5375a8204cbf

### Features
* System-aware tabbing behavior
* Respects the three system modes: Always, Never, and Fullscreen
(default on macOS)
* Changing the Zed setting does not require a restart β€” the next window
reflects the change
* Full theme support
    * Integrates with light and dark themes
* [One
Dark](https://github.com/user-attachments/assets/d1f55ff7-2339-4b09-9faf-d3d610ba7ca2)
* [One
Light](https://github.com/user-attachments/assets/7776e30c-2686-493e-9598-cdcd7e476ecf)
    * Supports opaque/blurred/transparent themes as best as possible
* [One Dark -
blurred](https://github.com/user-attachments/assets/c4521311-66cb-4cee-9e37-15146f6869aa)
* Dynamic layout adjustments
    * Only reserves tab bar space when tabs are actually visible
* [With
tabs](https://github.com/user-attachments/assets/3b6db943-58c5-4f55-bdf4-33d23ca7d820)
* [Without
tabs](https://github.com/user-attachments/assets/2d175959-5efc-4e4f-a15c-0108925c582e)
* VS Code compatibility
* Supports the `window.nativeTabs` setting in the VS Code settings
importer
* Command palette integration
    * Adds commands for managing tabs to the command palette
* These can be assigned to keyboard shortcuts as well, but didn't add
defaults as to not reserve precious default key combinations

Happy to pair again if things can be improved codewise, or if
explanations are necessary for certain choices!



Release Notes:
* Added support for native macOS window tabbing. When you set
`"use_system_window_tabs": true`, Zed will merge windows in the same was
as macOS: by default this happens only when full screened, but you can
adjust your macOS settings to have this happen on all windows.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

assets/settings/default.json                 |   2 
crates/agent_ui/src/ui/agent_notification.rs |   1 
crates/collab_ui/src/collab_ui.rs            |   1 
crates/gpui/examples/window_positioning.rs   |   1 
crates/gpui/src/app.rs                       | 305 +++++++++++++
crates/gpui/src/platform.rs                  |  27 +
crates/gpui/src/platform/mac/window.rs       | 337 +++++++++++++++
crates/gpui/src/window.rs                    | 118 +++++
crates/rules_library/src/rules_library.rs    |   2 
crates/title_bar/src/platform_title_bar.rs   |  22 
crates/title_bar/src/system_window_tabs.rs   | 477 ++++++++++++++++++++++
crates/title_bar/src/title_bar.rs            |   5 
crates/workspace/src/workspace.rs            |  67 +-
crates/workspace/src/workspace_settings.rs   |   7 
crates/zed/src/main.rs                       |  19 
crates/zed/src/zed.rs                        |   8 
docs/src/configuring-zed.md                  |  10 
17 files changed, 1,357 insertions(+), 52 deletions(-)

Detailed changes

assets/settings/default.json πŸ”—

@@ -363,6 +363,8 @@
     // Whether to show code action buttons in the editor toolbar.
     "code_actions": false
   },
+  // Whether to allow windows to tab together based on the user’s tabbing preference (macOS only).
+  "use_system_window_tabs": false,
   // Titlebar related settings
   "title_bar": {
     // Whether to show the branch icon beside branch switcher in the titlebar.

crates/agent_ui/src/ui/agent_notification.rs πŸ”—

@@ -62,6 +62,7 @@ impl AgentNotification {
             app_id: Some(app_id.to_owned()),
             window_min_size: None,
             window_decorations: Some(WindowDecorations::Client),
+            tabbing_identifier: None,
             ..Default::default()
         }
     }

crates/collab_ui/src/collab_ui.rs πŸ”—

@@ -66,6 +66,7 @@ fn notification_window_options(
         app_id: Some(app_id.to_owned()),
         window_min_size: None,
         window_decorations: Some(WindowDecorations::Client),
+        tabbing_identifier: None,
         ..Default::default()
     }
 }

crates/gpui/examples/window_positioning.rs πŸ”—

@@ -62,6 +62,7 @@ fn build_window_options(display_id: DisplayId, bounds: Bounds<Pixels>) -> Window
         app_id: None,
         window_min_size: None,
         window_decorations: None,
+        tabbing_identifier: None,
         ..Default::default()
     }
 }

crates/gpui/src/app.rs πŸ”—

@@ -7,7 +7,7 @@ use std::{
     path::{Path, PathBuf},
     rc::{Rc, Weak},
     sync::{Arc, atomic::Ordering::SeqCst},
-    time::Duration,
+    time::{Duration, Instant},
 };
 
 use anyhow::{Context as _, Result, anyhow};
@@ -17,6 +17,7 @@ use futures::{
     channel::oneshot,
     future::{LocalBoxFuture, Shared},
 };
+use itertools::Itertools;
 use parking_lot::RwLock;
 use slotmap::SlotMap;
 
@@ -39,8 +40,8 @@ use crate::{
     Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform,
     PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, PromptBuilder,
     PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle,
-    Reservation, ScreenCaptureSource, SubscriberSet, Subscription, SvgRenderer, Task, TextSystem,
-    Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator,
+    Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task,
+    TextSystem, Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator,
     colors::{Colors, GlobalColors},
     current_platform, hash, init_app_menus,
 };
@@ -237,6 +238,303 @@ type WindowClosedHandler = Box<dyn FnMut(&mut App)>;
 type ReleaseListener = Box<dyn FnOnce(&mut dyn Any, &mut App) + 'static>;
 type NewEntityListener = Box<dyn FnMut(AnyEntity, &mut Option<&mut Window>, &mut App) + 'static>;
 
+#[doc(hidden)]
+#[derive(Clone, PartialEq, Eq)]
+pub struct SystemWindowTab {
+    pub id: WindowId,
+    pub title: SharedString,
+    pub handle: AnyWindowHandle,
+    pub last_active_at: Instant,
+}
+
+impl SystemWindowTab {
+    /// Create a new instance of the window tab.
+    pub fn new(title: SharedString, handle: AnyWindowHandle) -> Self {
+        Self {
+            id: handle.id,
+            title,
+            handle,
+            last_active_at: Instant::now(),
+        }
+    }
+}
+
+/// A controller for managing window tabs.
+#[derive(Default)]
+pub struct SystemWindowTabController {
+    visible: Option<bool>,
+    tab_groups: FxHashMap<usize, Vec<SystemWindowTab>>,
+}
+
+impl Global for SystemWindowTabController {}
+
+impl SystemWindowTabController {
+    /// Create a new instance of the window tab controller.
+    pub fn new() -> Self {
+        Self {
+            visible: None,
+            tab_groups: FxHashMap::default(),
+        }
+    }
+
+    /// Initialize the global window tab controller.
+    pub fn init(cx: &mut App) {
+        cx.set_global(SystemWindowTabController::new());
+    }
+
+    /// Get all tab groups.
+    pub fn tab_groups(&self) -> &FxHashMap<usize, Vec<SystemWindowTab>> {
+        &self.tab_groups
+    }
+
+    /// Get the next tab group window handle.
+    pub fn get_next_tab_group_window(cx: &mut App, id: WindowId) -> Option<&AnyWindowHandle> {
+        let controller = cx.global::<SystemWindowTabController>();
+        let current_group = controller
+            .tab_groups
+            .iter()
+            .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group));
+
+        let current_group = current_group?;
+        let mut group_ids: Vec<_> = controller.tab_groups.keys().collect();
+        let idx = group_ids.iter().position(|g| *g == current_group)?;
+        let next_idx = (idx + 1) % group_ids.len();
+
+        controller
+            .tab_groups
+            .get(group_ids[next_idx])
+            .and_then(|tabs| {
+                tabs.iter()
+                    .max_by_key(|tab| tab.last_active_at)
+                    .or_else(|| tabs.first())
+                    .map(|tab| &tab.handle)
+            })
+    }
+
+    /// Get the previous tab group window handle.
+    pub fn get_prev_tab_group_window(cx: &mut App, id: WindowId) -> Option<&AnyWindowHandle> {
+        let controller = cx.global::<SystemWindowTabController>();
+        let current_group = controller
+            .tab_groups
+            .iter()
+            .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group));
+
+        let current_group = current_group?;
+        let mut group_ids: Vec<_> = controller.tab_groups.keys().collect();
+        let idx = group_ids.iter().position(|g| *g == current_group)?;
+        let prev_idx = if idx == 0 {
+            group_ids.len() - 1
+        } else {
+            idx - 1
+        };
+
+        controller
+            .tab_groups
+            .get(group_ids[prev_idx])
+            .and_then(|tabs| {
+                tabs.iter()
+                    .max_by_key(|tab| tab.last_active_at)
+                    .or_else(|| tabs.first())
+                    .map(|tab| &tab.handle)
+            })
+    }
+
+    /// Get all tabs in the same window.
+    pub fn tabs(&self, id: WindowId) -> Option<&Vec<SystemWindowTab>> {
+        let tab_group = self
+            .tab_groups
+            .iter()
+            .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group));
+
+        if let Some(tab_group) = tab_group {
+            self.tab_groups.get(&tab_group)
+        } else {
+            None
+        }
+    }
+
+    /// Initialize the visibility of the system window tab controller.
+    pub fn init_visible(cx: &mut App, visible: bool) {
+        let mut controller = cx.global_mut::<SystemWindowTabController>();
+        if controller.visible.is_none() {
+            controller.visible = Some(visible);
+        }
+    }
+
+    /// Get the visibility of the system window tab controller.
+    pub fn is_visible(&self) -> bool {
+        self.visible.unwrap_or(false)
+    }
+
+    /// Set the visibility of the system window tab controller.
+    pub fn set_visible(cx: &mut App, visible: bool) {
+        let mut controller = cx.global_mut::<SystemWindowTabController>();
+        controller.visible = Some(visible);
+    }
+
+    /// Update the last active of a window.
+    pub fn update_last_active(cx: &mut App, id: WindowId) {
+        let mut controller = cx.global_mut::<SystemWindowTabController>();
+        for windows in controller.tab_groups.values_mut() {
+            for tab in windows.iter_mut() {
+                if tab.id == id {
+                    tab.last_active_at = Instant::now();
+                }
+            }
+        }
+    }
+
+    /// Update the position of a tab within its group.
+    pub fn update_tab_position(cx: &mut App, id: WindowId, ix: usize) {
+        let mut controller = cx.global_mut::<SystemWindowTabController>();
+        for (_, windows) in controller.tab_groups.iter_mut() {
+            if let Some(current_pos) = windows.iter().position(|tab| tab.id == id) {
+                if ix < windows.len() && current_pos != ix {
+                    let window_tab = windows.remove(current_pos);
+                    windows.insert(ix, window_tab);
+                }
+                break;
+            }
+        }
+    }
+
+    /// Update the title of a tab.
+    pub fn update_tab_title(cx: &mut App, id: WindowId, title: SharedString) {
+        let controller = cx.global::<SystemWindowTabController>();
+        let tab = controller
+            .tab_groups
+            .values()
+            .flat_map(|windows| windows.iter())
+            .find(|tab| tab.id == id);
+
+        if tab.map_or(true, |t| t.title == title) {
+            return;
+        }
+
+        let mut controller = cx.global_mut::<SystemWindowTabController>();
+        for windows in controller.tab_groups.values_mut() {
+            for tab in windows.iter_mut() {
+                if tab.id == id {
+                    tab.title = title.clone();
+                }
+            }
+        }
+    }
+
+    /// Insert a tab into a tab group.
+    pub fn add_tab(cx: &mut App, id: WindowId, tabs: Vec<SystemWindowTab>) {
+        let mut controller = cx.global_mut::<SystemWindowTabController>();
+        let Some(tab) = tabs.clone().into_iter().find(|tab| tab.id == id) else {
+            return;
+        };
+
+        let mut expected_tab_ids: Vec<_> = tabs
+            .iter()
+            .filter(|tab| tab.id != id)
+            .map(|tab| tab.id)
+            .sorted()
+            .collect();
+
+        let mut tab_group_id = None;
+        for (group_id, group_tabs) in &controller.tab_groups {
+            let tab_ids: Vec<_> = group_tabs.iter().map(|tab| tab.id).sorted().collect();
+            if tab_ids == expected_tab_ids {
+                tab_group_id = Some(*group_id);
+                break;
+            }
+        }
+
+        if let Some(tab_group_id) = tab_group_id {
+            if let Some(tabs) = controller.tab_groups.get_mut(&tab_group_id) {
+                tabs.push(tab);
+            }
+        } else {
+            let new_group_id = controller.tab_groups.len();
+            controller.tab_groups.insert(new_group_id, tabs);
+        }
+    }
+
+    /// Remove a tab from a tab group.
+    pub fn remove_tab(cx: &mut App, id: WindowId) -> Option<SystemWindowTab> {
+        let mut controller = cx.global_mut::<SystemWindowTabController>();
+        let mut removed_tab = None;
+
+        controller.tab_groups.retain(|_, tabs| {
+            if let Some(pos) = tabs.iter().position(|tab| tab.id == id) {
+                removed_tab = Some(tabs.remove(pos));
+            }
+            !tabs.is_empty()
+        });
+
+        removed_tab
+    }
+
+    /// Move a tab to a new tab group.
+    pub fn move_tab_to_new_window(cx: &mut App, id: WindowId) {
+        let mut removed_tab = Self::remove_tab(cx, id);
+        let mut controller = cx.global_mut::<SystemWindowTabController>();
+
+        if let Some(tab) = removed_tab {
+            let new_group_id = controller.tab_groups.keys().max().map_or(0, |k| k + 1);
+            controller.tab_groups.insert(new_group_id, vec![tab]);
+        }
+    }
+
+    /// Merge all tab groups into a single group.
+    pub fn merge_all_windows(cx: &mut App, id: WindowId) {
+        let mut controller = cx.global_mut::<SystemWindowTabController>();
+        let Some(initial_tabs) = controller.tabs(id) else {
+            return;
+        };
+
+        let mut all_tabs = initial_tabs.clone();
+        for tabs in controller.tab_groups.values() {
+            all_tabs.extend(
+                tabs.iter()
+                    .filter(|tab| !initial_tabs.contains(tab))
+                    .cloned(),
+            );
+        }
+
+        controller.tab_groups.clear();
+        controller.tab_groups.insert(0, all_tabs);
+    }
+
+    /// Selects the next tab in the tab group in the trailing direction.
+    pub fn select_next_tab(cx: &mut App, id: WindowId) {
+        let mut controller = cx.global_mut::<SystemWindowTabController>();
+        let Some(tabs) = controller.tabs(id) else {
+            return;
+        };
+
+        let current_index = tabs.iter().position(|tab| tab.id == id).unwrap();
+        let next_index = (current_index + 1) % tabs.len();
+
+        let _ = &tabs[next_index].handle.update(cx, |_, window, _| {
+            window.activate_window();
+        });
+    }
+
+    /// Selects the previous tab in the tab group in the leading direction.
+    pub fn select_previous_tab(cx: &mut App, id: WindowId) {
+        let mut controller = cx.global_mut::<SystemWindowTabController>();
+        let Some(tabs) = controller.tabs(id) else {
+            return;
+        };
+
+        let current_index = tabs.iter().position(|tab| tab.id == id).unwrap();
+        let previous_index = if current_index == 0 {
+            tabs.len() - 1
+        } else {
+            current_index - 1
+        };
+
+        let _ = &tabs[previous_index].handle.update(cx, |_, window, _| {
+            window.activate_window();
+        });
+    }
+}
+
 /// Contains the state of the full application, and passed as a reference to a variety of callbacks.
 /// Other [Context] derefs to this type.
 /// You need a reference to an `App` to access the state of a [Entity].
@@ -372,6 +670,7 @@ impl App {
         });
 
         init_app_menus(platform.as_ref(), &app.borrow());
+        SystemWindowTabController::init(&mut app.borrow_mut());
 
         platform.on_keyboard_layout_change(Box::new({
             let app = Rc::downgrade(&app);

crates/gpui/src/platform.rs πŸ”—

@@ -40,8 +40,8 @@ use crate::{
     DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun,
     ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput,
     Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, ScaledPixels, Scene,
-    ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SvgSize, Task, TaskLabel, Window,
-    WindowControlArea, hash, point, px, size,
+    ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SvgSize, SystemWindowTab, Task,
+    TaskLabel, Window, WindowControlArea, hash, point, px, size,
 };
 use anyhow::Result;
 use async_task::Runnable;
@@ -502,9 +502,26 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
     fn sprite_atlas(&self) -> Arc<dyn PlatformAtlas>;
 
     // macOS specific methods
+    fn get_title(&self) -> String {
+        String::new()
+    }
+    fn tabbed_windows(&self) -> Option<Vec<SystemWindowTab>> {
+        None
+    }
+    fn tab_bar_visible(&self) -> bool {
+        false
+    }
     fn set_edited(&mut self, _edited: bool) {}
     fn show_character_palette(&self) {}
     fn titlebar_double_click(&self) {}
+    fn on_move_tab_to_new_window(&self, _callback: Box<dyn FnMut()>) {}
+    fn on_merge_all_windows(&self, _callback: Box<dyn FnMut()>) {}
+    fn on_select_previous_tab(&self, _callback: Box<dyn FnMut()>) {}
+    fn on_select_next_tab(&self, _callback: Box<dyn FnMut()>) {}
+    fn on_toggle_tab_bar(&self, _callback: Box<dyn FnMut()>) {}
+    fn merge_all_windows(&self) {}
+    fn move_tab_to_new_window(&self) {}
+    fn toggle_window_tab_overview(&self) {}
 
     #[cfg(target_os = "windows")]
     fn get_raw_handle(&self) -> windows::HWND;
@@ -1113,6 +1130,9 @@ pub struct WindowOptions {
     /// Whether to use client or server side decorations. Wayland only
     /// Note that this may be ignored.
     pub window_decorations: Option<WindowDecorations>,
+
+    /// Tab group name, allows opening the window as a native tab on macOS 10.12+. Windows with the same tabbing identifier will be grouped together.
+    pub tabbing_identifier: Option<String>,
 }
 
 /// The variables that can be configured when creating a new window
@@ -1160,6 +1180,8 @@ pub(crate) struct WindowParams {
     pub display_id: Option<DisplayId>,
 
     pub window_min_size: Option<Size<Pixels>>,
+    #[cfg(target_os = "macos")]
+    pub tabbing_identifier: Option<String>,
 }
 
 /// Represents the status of how a window should be opened.
@@ -1212,6 +1234,7 @@ impl Default for WindowOptions {
             app_id: None,
             window_min_size: None,
             window_decorations: None,
+            tabbing_identifier: None,
         }
     }
 }

crates/gpui/src/platform/mac/window.rs πŸ”—

@@ -4,8 +4,10 @@ use crate::{
     ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton,
     MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay,
     PlatformInput, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions,
-    ScaledPixels, Size, Timer, WindowAppearance, WindowBackgroundAppearance, WindowBounds,
-    WindowControlArea, WindowKind, WindowParams, platform::PlatformInputHandler, point, px, size,
+    ScaledPixels, SharedString, Size, SystemWindowTab, Timer, WindowAppearance,
+    WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowParams,
+    dispatch_get_main_queue, dispatch_sys::dispatch_async_f, platform::PlatformInputHandler, point,
+    px, size,
 };
 use block::ConcreteBlock;
 use cocoa::{
@@ -24,6 +26,7 @@ use cocoa::{
         NSUserDefaults,
     },
 };
+
 use core_graphics::display::{CGDirectDisplayID, CGPoint, CGRect};
 use ctor::ctor;
 use futures::channel::oneshot;
@@ -82,6 +85,12 @@ type NSDragOperation = NSUInteger;
 const NSDragOperationNone: NSDragOperation = 0;
 #[allow(non_upper_case_globals)]
 const NSDragOperationCopy: NSDragOperation = 1;
+#[derive(PartialEq)]
+pub enum UserTabbingPreference {
+    Never,
+    Always,
+    InFullScreen,
+}
 
 #[link(name = "CoreGraphics", kind = "framework")]
 unsafe extern "C" {
@@ -343,6 +352,36 @@ unsafe fn build_window_class(name: &'static str, superclass: &Class) -> *const C
             conclude_drag_operation as extern "C" fn(&Object, Sel, id),
         );
 
+        decl.add_method(
+            sel!(addTitlebarAccessoryViewController:),
+            add_titlebar_accessory_view_controller as extern "C" fn(&Object, Sel, id),
+        );
+
+        decl.add_method(
+            sel!(moveTabToNewWindow:),
+            move_tab_to_new_window as extern "C" fn(&Object, Sel, id),
+        );
+
+        decl.add_method(
+            sel!(mergeAllWindows:),
+            merge_all_windows as extern "C" fn(&Object, Sel, id),
+        );
+
+        decl.add_method(
+            sel!(selectNextTab:),
+            select_next_tab as extern "C" fn(&Object, Sel, id),
+        );
+
+        decl.add_method(
+            sel!(selectPreviousTab:),
+            select_previous_tab as extern "C" fn(&Object, Sel, id),
+        );
+
+        decl.add_method(
+            sel!(toggleTabBar:),
+            toggle_tab_bar as extern "C" fn(&Object, Sel, id),
+        );
+
         decl.register()
     }
 }
@@ -375,6 +414,11 @@ struct MacWindowState {
     // Whether the next left-mouse click is also the focusing click.
     first_mouse: bool,
     fullscreen_restore_bounds: Bounds<Pixels>,
+    move_tab_to_new_window_callback: Option<Box<dyn FnMut()>>,
+    merge_all_windows_callback: Option<Box<dyn FnMut()>>,
+    select_next_tab_callback: Option<Box<dyn FnMut()>>,
+    select_previous_tab_callback: Option<Box<dyn FnMut()>>,
+    toggle_tab_bar_callback: Option<Box<dyn FnMut()>>,
 }
 
 impl MacWindowState {
@@ -536,6 +580,7 @@ impl MacWindow {
             show,
             display_id,
             window_min_size,
+            tabbing_identifier,
         }: WindowParams,
         executor: ForegroundExecutor,
         renderer_context: renderer::Context,
@@ -543,7 +588,12 @@ impl MacWindow {
         unsafe {
             let pool = NSAutoreleasePool::new(nil);
 
-            let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: NO];
+            let allows_automatic_window_tabbing = tabbing_identifier.is_some();
+            if allows_automatic_window_tabbing {
+                let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: YES];
+            } else {
+                let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: NO];
+            }
 
             let mut style_mask;
             if let Some(titlebar) = titlebar.as_ref() {
@@ -668,6 +718,11 @@ impl MacWindow {
                 external_files_dragged: false,
                 first_mouse: false,
                 fullscreen_restore_bounds: Bounds::default(),
+                move_tab_to_new_window_callback: None,
+                merge_all_windows_callback: None,
+                select_next_tab_callback: None,
+                select_previous_tab_callback: None,
+                toggle_tab_bar_callback: None,
             })));
 
             (*native_window).set_ivar(
@@ -722,6 +777,11 @@ impl MacWindow {
                 WindowKind::Normal => {
                     native_window.setLevel_(NSNormalWindowLevel);
                     native_window.setAcceptsMouseMovedEvents_(YES);
+
+                    if let Some(tabbing_identifier) = tabbing_identifier {
+                        let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str());
+                        let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id];
+                    }
                 }
                 WindowKind::PopUp => {
                     // Use a tracking area to allow receiving MouseMoved events even when
@@ -750,6 +810,38 @@ impl MacWindow {
                 }
             }
 
+            let app = NSApplication::sharedApplication(nil);
+            let main_window: id = msg_send![app, mainWindow];
+            if allows_automatic_window_tabbing
+                && !main_window.is_null()
+                && main_window != native_window
+            {
+                let main_window_is_fullscreen = main_window
+                    .styleMask()
+                    .contains(NSWindowStyleMask::NSFullScreenWindowMask);
+                let user_tabbing_preference = Self::get_user_tabbing_preference()
+                    .unwrap_or(UserTabbingPreference::InFullScreen);
+                let should_add_as_tab = user_tabbing_preference == UserTabbingPreference::Always
+                    || user_tabbing_preference == UserTabbingPreference::InFullScreen
+                        && main_window_is_fullscreen;
+
+                if should_add_as_tab {
+                    let main_window_can_tab: BOOL =
+                        msg_send![main_window, respondsToSelector: sel!(addTabbedWindow:ordered:)];
+                    let main_window_visible: BOOL = msg_send![main_window, isVisible];
+
+                    if main_window_can_tab == YES && main_window_visible == YES {
+                        let _: () = msg_send![main_window, addTabbedWindow: native_window ordered: NSWindowOrderingMode::NSWindowAbove];
+
+                        // Ensure the window is visible immediately after adding the tab, since the tab bar is updated with a new entry at this point.
+                        // Note: Calling orderFront here can break fullscreen mode (makes fullscreen windows exit fullscreen), so only do this if the main window is not fullscreen.
+                        if !main_window_is_fullscreen {
+                            let _: () = msg_send![native_window, orderFront: nil];
+                        }
+                    }
+                }
+            }
+
             if focus && show {
                 native_window.makeKeyAndOrderFront_(nil);
             } else if show {
@@ -804,6 +896,33 @@ impl MacWindow {
             window_handles
         }
     }
+
+    pub fn get_user_tabbing_preference() -> Option<UserTabbingPreference> {
+        unsafe {
+            let defaults: id = NSUserDefaults::standardUserDefaults();
+            let domain = NSString::alloc(nil).init_str("NSGlobalDomain");
+            let key = NSString::alloc(nil).init_str("AppleWindowTabbingMode");
+
+            let dict: id = msg_send![defaults, persistentDomainForName: domain];
+            let value: id = if !dict.is_null() {
+                msg_send![dict, objectForKey: key]
+            } else {
+                nil
+            };
+
+            let value_str = if !value.is_null() {
+                CStr::from_ptr(NSString::UTF8String(value)).to_string_lossy()
+            } else {
+                "".into()
+            };
+
+            match value_str.as_ref() {
+                "manual" => Some(UserTabbingPreference::Never),
+                "always" => Some(UserTabbingPreference::Always),
+                _ => Some(UserTabbingPreference::InFullScreen),
+            }
+        }
+    }
 }
 
 impl Drop for MacWindow {
@@ -859,6 +978,46 @@ impl PlatformWindow for MacWindow {
             .detach();
     }
 
+    fn merge_all_windows(&self) {
+        let native_window = self.0.lock().native_window;
+        unsafe extern "C" fn merge_windows_async(context: *mut std::ffi::c_void) {
+            let native_window = context as id;
+            let _: () = msg_send![native_window, mergeAllWindows:nil];
+        }
+
+        unsafe {
+            dispatch_async_f(
+                dispatch_get_main_queue(),
+                native_window as *mut std::ffi::c_void,
+                Some(merge_windows_async),
+            );
+        }
+    }
+
+    fn move_tab_to_new_window(&self) {
+        let native_window = self.0.lock().native_window;
+        unsafe extern "C" fn move_tab_async(context: *mut std::ffi::c_void) {
+            let native_window = context as id;
+            let _: () = msg_send![native_window, moveTabToNewWindow:nil];
+            let _: () = msg_send![native_window, makeKeyAndOrderFront: nil];
+        }
+
+        unsafe {
+            dispatch_async_f(
+                dispatch_get_main_queue(),
+                native_window as *mut std::ffi::c_void,
+                Some(move_tab_async),
+            );
+        }
+    }
+
+    fn toggle_window_tab_overview(&self) {
+        let native_window = self.0.lock().native_window;
+        unsafe {
+            let _: () = msg_send![native_window, toggleTabOverview:nil];
+        }
+    }
+
     fn scale_factor(&self) -> f32 {
         self.0.as_ref().lock().scale_factor()
     }
@@ -1059,6 +1218,17 @@ impl PlatformWindow for MacWindow {
         }
     }
 
+    fn get_title(&self) -> String {
+        unsafe {
+            let title: id = msg_send![self.0.lock().native_window, title];
+            if title.is_null() {
+                "".to_string()
+            } else {
+                title.to_str().to_string()
+            }
+        }
+    }
+
     fn set_app_id(&mut self, _app_id: &str) {}
 
     fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance) {
@@ -1220,6 +1390,62 @@ impl PlatformWindow for MacWindow {
         self.0.lock().appearance_changed_callback = Some(callback);
     }
 
+    fn tabbed_windows(&self) -> Option<Vec<SystemWindowTab>> {
+        unsafe {
+            let windows: id = msg_send![self.0.lock().native_window, tabbedWindows];
+            if windows.is_null() {
+                return None;
+            }
+
+            let count: NSUInteger = msg_send![windows, count];
+            let mut result = Vec::new();
+            for i in 0..count {
+                let window: id = msg_send![windows, objectAtIndex:i];
+                if msg_send![window, isKindOfClass: WINDOW_CLASS] {
+                    let handle = get_window_state(&*window).lock().handle;
+                    let title: id = msg_send![window, title];
+                    let title = SharedString::from(title.to_str().to_string());
+
+                    result.push(SystemWindowTab::new(title, handle));
+                }
+            }
+
+            Some(result)
+        }
+    }
+
+    fn tab_bar_visible(&self) -> bool {
+        unsafe {
+            let tab_group: id = msg_send![self.0.lock().native_window, tabGroup];
+            if tab_group.is_null() {
+                false
+            } else {
+                let tab_bar_visible: BOOL = msg_send![tab_group, isTabBarVisible];
+                tab_bar_visible == YES
+            }
+        }
+    }
+
+    fn on_move_tab_to_new_window(&self, callback: Box<dyn FnMut()>) {
+        self.0.as_ref().lock().move_tab_to_new_window_callback = Some(callback);
+    }
+
+    fn on_merge_all_windows(&self, callback: Box<dyn FnMut()>) {
+        self.0.as_ref().lock().merge_all_windows_callback = Some(callback);
+    }
+
+    fn on_select_next_tab(&self, callback: Box<dyn FnMut()>) {
+        self.0.as_ref().lock().select_next_tab_callback = Some(callback);
+    }
+
+    fn on_select_previous_tab(&self, callback: Box<dyn FnMut()>) {
+        self.0.as_ref().lock().select_previous_tab_callback = Some(callback);
+    }
+
+    fn on_toggle_tab_bar(&self, callback: Box<dyn FnMut()>) {
+        self.0.as_ref().lock().toggle_tab_bar_callback = Some(callback);
+    }
+
     fn draw(&self, scene: &crate::Scene) {
         let mut this = self.0.lock();
         this.renderer.draw(scene);
@@ -1661,6 +1887,7 @@ extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) {
             .occlusionState()
             .contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible)
         {
+            lock.move_traffic_light();
             lock.start_display_link();
         } else {
             lock.stop_display_link();
@@ -1722,7 +1949,7 @@ extern "C" fn window_did_change_screen(this: &Object, _: Sel, _: id) {
 
 extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) {
     let window_state = unsafe { get_window_state(this) };
-    let lock = window_state.lock();
+    let mut lock = window_state.lock();
     let is_active = unsafe { lock.native_window.isKeyWindow() == YES };
 
     // When opening a pop-up while the application isn't active, Cocoa sends a spurious
@@ -1743,9 +1970,34 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id)
 
     let executor = lock.executor.clone();
     drop(lock);
+
+    // If window is becoming active, trigger immediate synchronous frame request.
+    if selector == sel!(windowDidBecomeKey:) && is_active {
+        let window_state = unsafe { get_window_state(this) };
+        let mut lock = window_state.lock();
+
+        if let Some(mut callback) = lock.request_frame_callback.take() {
+            #[cfg(not(feature = "macos-blade"))]
+            lock.renderer.set_presents_with_transaction(true);
+            lock.stop_display_link();
+            drop(lock);
+            callback(Default::default());
+
+            let mut lock = window_state.lock();
+            lock.request_frame_callback = Some(callback);
+            #[cfg(not(feature = "macos-blade"))]
+            lock.renderer.set_presents_with_transaction(false);
+            lock.start_display_link();
+        }
+    }
+
     executor
         .spawn(async move {
             let mut lock = window_state.as_ref().lock();
+            if is_active {
+                lock.move_traffic_light();
+            }
+
             if let Some(mut callback) = lock.activate_callback.take() {
                 drop(lock);
                 callback(is_active);
@@ -2281,3 +2533,80 @@ unsafe fn remove_layer_background(layer: id) {
         }
     }
 }
+
+extern "C" fn add_titlebar_accessory_view_controller(this: &Object, _: Sel, view_controller: id) {
+    unsafe {
+        let _: () = msg_send![super(this, class!(NSWindow)), addTitlebarAccessoryViewController: view_controller];
+
+        // Hide the native tab bar and set its height to 0, since we render our own.
+        let accessory_view: id = msg_send![view_controller, view];
+        let _: () = msg_send![accessory_view, setHidden: YES];
+        let mut frame: NSRect = msg_send![accessory_view, frame];
+        frame.size.height = 0.0;
+        let _: () = msg_send![accessory_view, setFrame: frame];
+    }
+}
+
+extern "C" fn move_tab_to_new_window(this: &Object, _: Sel, _: id) {
+    unsafe {
+        let _: () = msg_send![super(this, class!(NSWindow)), moveTabToNewWindow:nil];
+
+        let window_state = get_window_state(this);
+        let mut lock = window_state.as_ref().lock();
+        if let Some(mut callback) = lock.move_tab_to_new_window_callback.take() {
+            drop(lock);
+            callback();
+            window_state.lock().move_tab_to_new_window_callback = Some(callback);
+        }
+    }
+}
+
+extern "C" fn merge_all_windows(this: &Object, _: Sel, _: id) {
+    unsafe {
+        let _: () = msg_send![super(this, class!(NSWindow)), mergeAllWindows:nil];
+
+        let window_state = get_window_state(this);
+        let mut lock = window_state.as_ref().lock();
+        if let Some(mut callback) = lock.merge_all_windows_callback.take() {
+            drop(lock);
+            callback();
+            window_state.lock().merge_all_windows_callback = Some(callback);
+        }
+    }
+}
+
+extern "C" fn select_next_tab(this: &Object, _sel: Sel, _id: id) {
+    let window_state = unsafe { get_window_state(this) };
+    let mut lock = window_state.as_ref().lock();
+    if let Some(mut callback) = lock.select_next_tab_callback.take() {
+        drop(lock);
+        callback();
+        window_state.lock().select_next_tab_callback = Some(callback);
+    }
+}
+
+extern "C" fn select_previous_tab(this: &Object, _sel: Sel, _id: id) {
+    let window_state = unsafe { get_window_state(this) };
+    let mut lock = window_state.as_ref().lock();
+    if let Some(mut callback) = lock.select_previous_tab_callback.take() {
+        drop(lock);
+        callback();
+        window_state.lock().select_previous_tab_callback = Some(callback);
+    }
+}
+
+extern "C" fn toggle_tab_bar(this: &Object, _sel: Sel, _id: id) {
+    unsafe {
+        let _: () = msg_send![super(this, class!(NSWindow)), toggleTabBar:nil];
+
+        let window_state = get_window_state(this);
+        let mut lock = window_state.as_ref().lock();
+        lock.move_traffic_light();
+
+        if let Some(mut callback) = lock.toggle_tab_bar_callback.take() {
+            drop(lock);
+            callback();
+            window_state.lock().toggle_tab_bar_callback = Some(callback);
+        }
+    }
+}

crates/gpui/src/window.rs πŸ”—

@@ -12,11 +12,11 @@ use crate::{
     PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, PromptButton, PromptLevel, Quad,
     Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge,
     SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS, ScaledPixels, Scene, Shadow, SharedString, Size,
-    StrikethroughStyle, Style, SubscriberSet, Subscription, TabHandles, TaffyLayoutEngine, Task,
-    TextStyle, TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle,
-    WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations,
-    WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size,
-    transparent_black,
+    StrikethroughStyle, Style, SubscriberSet, Subscription, SystemWindowTab,
+    SystemWindowTabController, TabHandles, TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement,
+    TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance,
+    WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem,
+    point, prelude::*, px, rems, size, transparent_black,
 };
 use anyhow::{Context as _, Result, anyhow};
 use collections::{FxHashMap, FxHashSet};
@@ -946,6 +946,8 @@ impl Window {
             app_id,
             window_min_size,
             window_decorations,
+            #[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
+            tabbing_identifier,
         } = options;
 
         let bounds = window_bounds
@@ -964,8 +966,17 @@ impl Window {
                 show,
                 display_id,
                 window_min_size,
+                #[cfg(target_os = "macos")]
+                tabbing_identifier,
             },
         )?;
+
+        let tab_bar_visible = platform_window.tab_bar_visible();
+        SystemWindowTabController::init_visible(cx, tab_bar_visible);
+        if let Some(tabs) = platform_window.tabbed_windows() {
+            SystemWindowTabController::add_tab(cx, handle.window_id(), tabs);
+        }
+
         let display_id = platform_window.display().map(|display| display.id());
         let sprite_atlas = platform_window.sprite_atlas();
         let mouse_position = platform_window.mouse_position();
@@ -995,9 +1006,13 @@ impl Window {
         }
 
         platform_window.on_close(Box::new({
+            let window_id = handle.window_id();
             let mut cx = cx.to_async();
             move || {
                 let _ = handle.update(&mut cx, |_, window, _| window.remove_window());
+                let _ = cx.update(|cx| {
+                    SystemWindowTabController::remove_tab(cx, window_id);
+                });
             }
         }));
         platform_window.on_request_frame(Box::new({
@@ -1086,7 +1101,11 @@ impl Window {
                             .activation_observers
                             .clone()
                             .retain(&(), |callback| callback(window, cx));
+
+                        window.bounds_changed(cx);
                         window.refresh();
+
+                        SystemWindowTabController::update_last_active(cx, window.handle.id);
                     })
                     .log_err();
             }
@@ -1127,6 +1146,57 @@ impl Window {
                     .unwrap_or(None)
             })
         });
+        platform_window.on_move_tab_to_new_window({
+            let mut cx = cx.to_async();
+            Box::new(move || {
+                handle
+                    .update(&mut cx, |_, _window, cx| {
+                        SystemWindowTabController::move_tab_to_new_window(cx, handle.window_id());
+                    })
+                    .log_err();
+            })
+        });
+        platform_window.on_merge_all_windows({
+            let mut cx = cx.to_async();
+            Box::new(move || {
+                handle
+                    .update(&mut cx, |_, _window, cx| {
+                        SystemWindowTabController::merge_all_windows(cx, handle.window_id());
+                    })
+                    .log_err();
+            })
+        });
+        platform_window.on_select_next_tab({
+            let mut cx = cx.to_async();
+            Box::new(move || {
+                handle
+                    .update(&mut cx, |_, _window, cx| {
+                        SystemWindowTabController::select_next_tab(cx, handle.window_id());
+                    })
+                    .log_err();
+            })
+        });
+        platform_window.on_select_previous_tab({
+            let mut cx = cx.to_async();
+            Box::new(move || {
+                handle
+                    .update(&mut cx, |_, _window, cx| {
+                        SystemWindowTabController::select_previous_tab(cx, handle.window_id())
+                    })
+                    .log_err();
+            })
+        });
+        platform_window.on_toggle_tab_bar({
+            let mut cx = cx.to_async();
+            Box::new(move || {
+                handle
+                    .update(&mut cx, |_, window, cx| {
+                        let tab_bar_visible = window.platform_window.tab_bar_visible();
+                        SystemWindowTabController::set_visible(cx, tab_bar_visible);
+                    })
+                    .log_err();
+            })
+        });
 
         if let Some(app_id) = app_id {
             platform_window.set_app_id(&app_id);
@@ -4279,11 +4349,47 @@ impl Window {
     }
 
     /// Perform titlebar double-click action.
-    /// This is MacOS specific.
+    /// This is macOS specific.
     pub fn titlebar_double_click(&self) {
         self.platform_window.titlebar_double_click();
     }
 
+    /// Gets the window's title at the platform level.
+    /// This is macOS specific.
+    pub fn window_title(&self) -> String {
+        self.platform_window.get_title()
+    }
+
+    /// Returns a list of all tabbed windows and their titles.
+    /// This is macOS specific.
+    pub fn tabbed_windows(&self) -> Option<Vec<SystemWindowTab>> {
+        self.platform_window.tabbed_windows()
+    }
+
+    /// Returns the tab bar visibility.
+    /// This is macOS specific.
+    pub fn tab_bar_visible(&self) -> bool {
+        self.platform_window.tab_bar_visible()
+    }
+
+    /// Merges all open windows into a single tabbed window.
+    /// This is macOS specific.
+    pub fn merge_all_windows(&self) {
+        self.platform_window.merge_all_windows()
+    }
+
+    /// Moves the tab to a new containing window.
+    /// This is macOS specific.
+    pub fn move_tab_to_new_window(&self) {
+        self.platform_window.move_tab_to_new_window()
+    }
+
+    /// Shows or hides the window tab overview.
+    /// This is macOS specific.
+    pub fn toggle_window_tab_overview(&self) {
+        self.platform_window.toggle_window_tab_overview()
+    }
+
     /// Toggles the inspector mode on this window.
     #[cfg(any(feature = "inspector", debug_assertions))]
     pub fn toggle_inspector(&mut self, cx: &mut App) {

crates/rules_library/src/rules_library.rs πŸ”—

@@ -414,7 +414,7 @@ impl RulesLibrary {
         });
         Self {
             title_bar: if !cfg!(target_os = "macos") {
-                Some(cx.new(|_| PlatformTitleBar::new("rules-library-title-bar")))
+                Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx)))
             } else {
                 None
             },

crates/title_bar/src/platform_title_bar.rs πŸ”—

@@ -1,28 +1,35 @@
 use gpui::{
-    AnyElement, Context, Decorations, Hsla, InteractiveElement, IntoElement, MouseButton,
+    AnyElement, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, MouseButton,
     ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, px,
 };
 use smallvec::SmallVec;
 use std::mem;
 use ui::prelude::*;
 
-use crate::platforms::{platform_linux, platform_mac, platform_windows};
+use crate::{
+    platforms::{platform_linux, platform_mac, platform_windows},
+    system_window_tabs::SystemWindowTabs,
+};
 
 pub struct PlatformTitleBar {
     id: ElementId,
     platform_style: PlatformStyle,
     children: SmallVec<[AnyElement; 2]>,
     should_move: bool,
+    system_window_tabs: Entity<SystemWindowTabs>,
 }
 
 impl PlatformTitleBar {
-    pub fn new(id: impl Into<ElementId>) -> Self {
+    pub fn new(id: impl Into<ElementId>, cx: &mut Context<Self>) -> Self {
         let platform_style = PlatformStyle::platform();
+        let system_window_tabs = cx.new(|_cx| SystemWindowTabs::new());
+
         Self {
             id: id.into(),
             platform_style,
             children: SmallVec::new(),
             should_move: false,
+            system_window_tabs,
         }
     }
 
@@ -66,7 +73,7 @@ impl Render for PlatformTitleBar {
         let close_action = Box::new(workspace::CloseWindow);
         let children = mem::take(&mut self.children);
 
-        h_flex()
+        let title_bar = h_flex()
             .window_control_area(WindowControlArea::Drag)
             .w_full()
             .h(height)
@@ -162,7 +169,12 @@ impl Render for PlatformTitleBar {
                         title_bar.child(platform_windows::WindowsWindowControls::new(height))
                     }
                 }
-            })
+            });
+
+        v_flex()
+            .w_full()
+            .child(title_bar)
+            .child(self.system_window_tabs.clone().into_any_element())
     }
 }
 

crates/title_bar/src/system_window_tabs.rs πŸ”—

@@ -0,0 +1,477 @@
+use settings::Settings;
+
+use gpui::{
+    AnyWindowHandle, Context, Hsla, InteractiveElement, MouseButton, ParentElement, ScrollHandle,
+    Styled, SystemWindowTab, SystemWindowTabController, Window, WindowId, actions, canvas, div,
+};
+
+use theme::ThemeSettings;
+use ui::{
+    Color, ContextMenu, DynamicSpacing, IconButton, IconButtonShape, IconName, IconSize, Label,
+    LabelSize, Tab, h_flex, prelude::*, right_click_menu,
+};
+use workspace::{
+    CloseWindow, ItemSettings, Workspace,
+    item::{ClosePosition, ShowCloseButton},
+};
+
+actions!(
+    window,
+    [
+        ShowNextWindowTab,
+        ShowPreviousWindowTab,
+        MergeAllWindows,
+        MoveTabToNewWindow
+    ]
+);
+
+#[derive(Clone)]
+pub struct DraggedWindowTab {
+    pub id: WindowId,
+    pub ix: usize,
+    pub handle: AnyWindowHandle,
+    pub title: String,
+    pub width: Pixels,
+    pub is_active: bool,
+    pub active_background_color: Hsla,
+    pub inactive_background_color: Hsla,
+}
+
+pub struct SystemWindowTabs {
+    tab_bar_scroll_handle: ScrollHandle,
+    measured_tab_width: Pixels,
+    last_dragged_tab: Option<DraggedWindowTab>,
+}
+
+impl SystemWindowTabs {
+    pub fn new() -> Self {
+        Self {
+            tab_bar_scroll_handle: ScrollHandle::new(),
+            measured_tab_width: px(0.),
+            last_dragged_tab: None,
+        }
+    }
+
+    pub fn init(cx: &mut App) {
+        cx.observe_new(|workspace: &mut Workspace, _, _| {
+            workspace.register_action_renderer(|div, _, window, cx| {
+                let window_id = window.window_handle().window_id();
+                let controller = cx.global::<SystemWindowTabController>();
+
+                let tab_groups = controller.tab_groups();
+                let tabs = controller.tabs(window_id);
+                let Some(tabs) = tabs else {
+                    return div;
+                };
+
+                div.when(tabs.len() > 1, |div| {
+                    div.on_action(move |_: &ShowNextWindowTab, window, cx| {
+                        SystemWindowTabController::select_next_tab(
+                            cx,
+                            window.window_handle().window_id(),
+                        );
+                    })
+                    .on_action(move |_: &ShowPreviousWindowTab, window, cx| {
+                        SystemWindowTabController::select_previous_tab(
+                            cx,
+                            window.window_handle().window_id(),
+                        );
+                    })
+                    .on_action(move |_: &MoveTabToNewWindow, window, cx| {
+                        SystemWindowTabController::move_tab_to_new_window(
+                            cx,
+                            window.window_handle().window_id(),
+                        );
+                        window.move_tab_to_new_window();
+                    })
+                })
+                .when(tab_groups.len() > 1, |div| {
+                    div.on_action(move |_: &MergeAllWindows, window, cx| {
+                        SystemWindowTabController::merge_all_windows(
+                            cx,
+                            window.window_handle().window_id(),
+                        );
+                        window.merge_all_windows();
+                    })
+                })
+            });
+        })
+        .detach();
+    }
+
+    fn render_tab(
+        &self,
+        ix: usize,
+        item: SystemWindowTab,
+        tabs: Vec<SystemWindowTab>,
+        active_background_color: Hsla,
+        inactive_background_color: Hsla,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement + use<> {
+        let entity = cx.entity();
+        let settings = ItemSettings::get_global(cx);
+        let close_side = &settings.close_position;
+        let show_close_button = &settings.show_close_button;
+
+        let rem_size = window.rem_size();
+        let width = self.measured_tab_width.max(rem_size * 10);
+        let is_active = window.window_handle().window_id() == item.id;
+        let title = item.title.to_string();
+
+        let label = Label::new(&title)
+            .size(LabelSize::Small)
+            .truncate()
+            .color(if is_active {
+                Color::Default
+            } else {
+                Color::Muted
+            });
+
+        let tab = h_flex()
+            .id(ix)
+            .group("tab")
+            .w_full()
+            .overflow_hidden()
+            .h(Tab::content_height(cx))
+            .relative()
+            .px(DynamicSpacing::Base16.px(cx))
+            .justify_center()
+            .border_l_1()
+            .border_color(cx.theme().colors().border)
+            .cursor_pointer()
+            .on_drag(
+                DraggedWindowTab {
+                    id: item.id,
+                    ix,
+                    handle: item.handle,
+                    title: item.title.to_string(),
+                    width,
+                    is_active,
+                    active_background_color,
+                    inactive_background_color,
+                },
+                move |tab, _, _, cx| {
+                    entity.update(cx, |this, _cx| {
+                        this.last_dragged_tab = Some(tab.clone());
+                    });
+                    cx.new(|_| tab.clone())
+                },
+            )
+            .drag_over::<DraggedWindowTab>({
+                let tab_ix = ix;
+                move |element, dragged_tab: &DraggedWindowTab, _, cx| {
+                    let mut styled_tab = element
+                        .bg(cx.theme().colors().drop_target_background)
+                        .border_color(cx.theme().colors().drop_target_border)
+                        .border_0();
+
+                    if tab_ix < dragged_tab.ix {
+                        styled_tab = styled_tab.border_l_2();
+                    } else if tab_ix > dragged_tab.ix {
+                        styled_tab = styled_tab.border_r_2();
+                    }
+
+                    styled_tab
+                }
+            })
+            .on_drop({
+                let tab_ix = ix;
+                cx.listener(move |this, dragged_tab: &DraggedWindowTab, _window, cx| {
+                    this.last_dragged_tab = None;
+                    Self::handle_tab_drop(dragged_tab, tab_ix, cx);
+                })
+            })
+            .on_click(move |_, _, cx| {
+                let _ = item.handle.update(cx, |_, window, _| {
+                    window.activate_window();
+                });
+            })
+            .child(label)
+            .map(|this| match show_close_button {
+                ShowCloseButton::Hidden => this,
+                _ => this.child(
+                    div()
+                        .absolute()
+                        .top_2()
+                        .w_4()
+                        .h_4()
+                        .map(|this| match close_side {
+                            ClosePosition::Left => this.left_1(),
+                            ClosePosition::Right => this.right_1(),
+                        })
+                        .child(
+                            IconButton::new("close", IconName::Close)
+                                .shape(IconButtonShape::Square)
+                                .icon_color(Color::Muted)
+                                .icon_size(IconSize::XSmall)
+                                .on_click({
+                                    move |_, window, cx| {
+                                        if item.handle.window_id()
+                                            == window.window_handle().window_id()
+                                        {
+                                            window.dispatch_action(Box::new(CloseWindow), cx);
+                                        } else {
+                                            let _ = item.handle.update(cx, |_, window, cx| {
+                                                window.dispatch_action(Box::new(CloseWindow), cx);
+                                            });
+                                        }
+                                    }
+                                })
+                                .map(|this| match show_close_button {
+                                    ShowCloseButton::Hover => this.visible_on_hover("tab"),
+                                    _ => this,
+                                }),
+                        ),
+                ),
+            })
+            .into_any();
+
+        let menu = right_click_menu(ix)
+            .trigger(|_, _, _| tab)
+            .menu(move |window, cx| {
+                let focus_handle = cx.focus_handle();
+                let tabs = tabs.clone();
+                let other_tabs = tabs.clone();
+                let move_tabs = tabs.clone();
+                let merge_tabs = tabs.clone();
+
+                ContextMenu::build(window, cx, move |mut menu, _window_, _cx| {
+                    menu = menu.entry("Close Tab", None, move |window, cx| {
+                        Self::handle_right_click_action(
+                            cx,
+                            window,
+                            &tabs,
+                            |tab| tab.id == item.id,
+                            |window, cx| {
+                                window.dispatch_action(Box::new(CloseWindow), cx);
+                            },
+                        );
+                    });
+
+                    menu = menu.entry("Close Other Tabs", None, move |window, cx| {
+                        Self::handle_right_click_action(
+                            cx,
+                            window,
+                            &other_tabs,
+                            |tab| tab.id != item.id,
+                            |window, cx| {
+                                window.dispatch_action(Box::new(CloseWindow), cx);
+                            },
+                        );
+                    });
+
+                    menu = menu.entry("Move Tab to New Window", None, move |window, cx| {
+                        Self::handle_right_click_action(
+                            cx,
+                            window,
+                            &move_tabs,
+                            |tab| tab.id == item.id,
+                            |window, cx| {
+                                SystemWindowTabController::move_tab_to_new_window(
+                                    cx,
+                                    window.window_handle().window_id(),
+                                );
+                                window.move_tab_to_new_window();
+                            },
+                        );
+                    });
+
+                    menu = menu.entry("Show All Tabs", None, move |window, cx| {
+                        Self::handle_right_click_action(
+                            cx,
+                            window,
+                            &merge_tabs,
+                            |tab| tab.id == item.id,
+                            |window, _cx| {
+                                window.toggle_window_tab_overview();
+                            },
+                        );
+                    });
+
+                    menu.context(focus_handle)
+                })
+            });
+
+        div()
+            .flex_1()
+            .min_w(rem_size * 10)
+            .when(is_active, |this| this.bg(active_background_color))
+            .border_t_1()
+            .border_color(if is_active {
+                active_background_color
+            } else {
+                cx.theme().colors().border
+            })
+            .child(menu)
+    }
+
+    fn handle_tab_drop(dragged_tab: &DraggedWindowTab, ix: usize, cx: &mut Context<Self>) {
+        SystemWindowTabController::update_tab_position(cx, dragged_tab.id, ix);
+    }
+
+    fn handle_right_click_action<F, P>(
+        cx: &mut App,
+        window: &mut Window,
+        tabs: &Vec<SystemWindowTab>,
+        predicate: P,
+        mut action: F,
+    ) where
+        P: Fn(&SystemWindowTab) -> bool,
+        F: FnMut(&mut Window, &mut App),
+    {
+        for tab in tabs {
+            if predicate(tab) {
+                if tab.id == window.window_handle().window_id() {
+                    action(window, cx);
+                } else {
+                    let _ = tab.handle.update(cx, |_view, window, cx| {
+                        action(window, cx);
+                    });
+                }
+            }
+        }
+    }
+}
+
+impl Render for SystemWindowTabs {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let active_background_color = cx.theme().colors().title_bar_background;
+        let inactive_background_color = cx.theme().colors().tab_bar_background;
+        let entity = cx.entity();
+
+        let controller = cx.global::<SystemWindowTabController>();
+        let visible = controller.is_visible();
+        let current_window_tab = vec![SystemWindowTab::new(
+            SharedString::from(window.window_title()),
+            window.window_handle(),
+        )];
+        let tabs = controller
+            .tabs(window.window_handle().window_id())
+            .unwrap_or(&current_window_tab)
+            .clone();
+
+        let tab_items = tabs
+            .iter()
+            .enumerate()
+            .map(|(ix, item)| {
+                self.render_tab(
+                    ix,
+                    item.clone(),
+                    tabs.clone(),
+                    active_background_color,
+                    inactive_background_color,
+                    window,
+                    cx,
+                )
+            })
+            .collect::<Vec<_>>();
+
+        let number_of_tabs = tab_items.len().max(1);
+        if !window.tab_bar_visible() && !visible {
+            return h_flex().into_any_element();
+        }
+
+        h_flex()
+            .w_full()
+            .h(Tab::container_height(cx))
+            .bg(inactive_background_color)
+            .on_mouse_up_out(
+                MouseButton::Left,
+                cx.listener(|this, _event, window, cx| {
+                    if let Some(tab) = this.last_dragged_tab.take() {
+                        SystemWindowTabController::move_tab_to_new_window(cx, tab.id);
+                        if tab.id == window.window_handle().window_id() {
+                            window.move_tab_to_new_window();
+                        } else {
+                            let _ = tab.handle.update(cx, |_, window, _cx| {
+                                window.move_tab_to_new_window();
+                            });
+                        }
+                    }
+                }),
+            )
+            .child(
+                h_flex()
+                    .id("window tabs")
+                    .w_full()
+                    .h(Tab::container_height(cx))
+                    .bg(inactive_background_color)
+                    .overflow_x_scroll()
+                    .track_scroll(&self.tab_bar_scroll_handle)
+                    .children(tab_items)
+                    .child(
+                        canvas(
+                            |_, _, _| (),
+                            move |bounds, _, _, cx| {
+                                let entity = entity.clone();
+                                entity.update(cx, |this, cx| {
+                                    let width = bounds.size.width / number_of_tabs as f32;
+                                    if width != this.measured_tab_width {
+                                        this.measured_tab_width = width;
+                                        cx.notify();
+                                    }
+                                });
+                            },
+                        )
+                        .absolute()
+                        .size_full(),
+                    ),
+            )
+            .child(
+                h_flex()
+                    .h_full()
+                    .px(DynamicSpacing::Base06.rems(cx))
+                    .border_t_1()
+                    .border_l_1()
+                    .border_color(cx.theme().colors().border)
+                    .child(
+                        IconButton::new("plus", IconName::Plus)
+                            .icon_size(IconSize::Small)
+                            .icon_color(Color::Muted)
+                            .on_click(|_event, window, cx| {
+                                window.dispatch_action(
+                                    Box::new(zed_actions::OpenRecent {
+                                        create_new_window: true,
+                                    }),
+                                    cx,
+                                );
+                            }),
+                    ),
+            )
+            .into_any_element()
+    }
+}
+
+impl Render for DraggedWindowTab {
+    fn render(
+        &mut self,
+        _window: &mut gpui::Window,
+        cx: &mut gpui::Context<Self>,
+    ) -> impl gpui::IntoElement {
+        let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
+        let label = Label::new(self.title.clone())
+            .size(LabelSize::Small)
+            .truncate()
+            .color(if self.is_active {
+                Color::Default
+            } else {
+                Color::Muted
+            });
+
+        h_flex()
+            .h(Tab::container_height(cx))
+            .w(self.width)
+            .px(DynamicSpacing::Base16.px(cx))
+            .justify_center()
+            .bg(if self.is_active {
+                self.active_background_color
+            } else {
+                self.inactive_background_color
+            })
+            .border_1()
+            .border_color(cx.theme().colors().border)
+            .font(ui_font)
+            .child(label)
+    }
+}

crates/title_bar/src/title_bar.rs πŸ”—

@@ -3,6 +3,7 @@ mod collab;
 mod onboarding_banner;
 pub mod platform_title_bar;
 mod platforms;
+mod system_window_tabs;
 mod title_bar_settings;
 
 #[cfg(feature = "stories")]
@@ -11,6 +12,7 @@ mod stories;
 use crate::{
     application_menu::{ApplicationMenu, show_menus},
     platform_title_bar::PlatformTitleBar,
+    system_window_tabs::SystemWindowTabs,
 };
 
 #[cfg(not(target_os = "macos"))]
@@ -65,6 +67,7 @@ actions!(
 
 pub fn init(cx: &mut App) {
     TitleBarSettings::register(cx);
+    SystemWindowTabs::init(cx);
 
     cx.observe_new(|workspace: &mut Workspace, window, cx| {
         let Some(window) = window else {
@@ -284,7 +287,7 @@ impl TitleBar {
             )
         });
 
-        let platform_titlebar = cx.new(|_| PlatformTitleBar::new(id));
+        let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx));
 
         Self {
             platform_titlebar,

crates/workspace/src/workspace.rs πŸ”—

@@ -42,9 +42,9 @@ use gpui::{
     Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context,
     CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle,
     Focusable, Global, HitboxBehavior, Hsla, KeyContext, Keystroke, ManagedView, MouseButton,
-    PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription, Task,
-    Tiling, WeakEntity, WindowBounds, WindowHandle, WindowId, WindowOptions, actions, canvas,
-    point, relative, size, transparent_black,
+    PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription,
+    SystemWindowTabController, Task, Tiling, WeakEntity, WindowBounds, WindowHandle, WindowId,
+    WindowOptions, actions, canvas, point, relative, size, transparent_black,
 };
 pub use history_manager::*;
 pub use item::{
@@ -4375,6 +4375,11 @@ impl Workspace {
             return;
         }
         window.set_window_title(&title);
+        SystemWindowTabController::update_tab_title(
+            cx,
+            window.window_handle().window_id(),
+            SharedString::from(&title),
+        );
         self.last_window_title = Some(title);
     }
 
@@ -5797,17 +5802,22 @@ impl Workspace {
             return;
         };
         let windows = cx.windows();
-        let Some(next_window) = windows
-            .iter()
-            .cycle()
-            .skip_while(|window| window.window_id() != current_window_id)
-            .nth(1)
-        else {
-            return;
-        };
-        next_window
-            .update(cx, |_, window, _| window.activate_window())
-            .ok();
+        let next_window =
+            SystemWindowTabController::get_next_tab_group_window(cx, current_window_id).or_else(
+                || {
+                    windows
+                        .iter()
+                        .cycle()
+                        .skip_while(|window| window.window_id() != current_window_id)
+                        .nth(1)
+                },
+            );
+
+        if let Some(window) = next_window {
+            window
+                .update(cx, |_, window, _| window.activate_window())
+                .ok();
+        }
     }
 
     pub fn activate_previous_window(&mut self, cx: &mut Context<Self>) {
@@ -5815,18 +5825,23 @@ impl Workspace {
             return;
         };
         let windows = cx.windows();
-        let Some(prev_window) = windows
-            .iter()
-            .rev()
-            .cycle()
-            .skip_while(|window| window.window_id() != current_window_id)
-            .nth(1)
-        else {
-            return;
-        };
-        prev_window
-            .update(cx, |_, window, _| window.activate_window())
-            .ok();
+        let prev_window =
+            SystemWindowTabController::get_prev_tab_group_window(cx, current_window_id).or_else(
+                || {
+                    windows
+                        .iter()
+                        .rev()
+                        .cycle()
+                        .skip_while(|window| window.window_id() != current_window_id)
+                        .nth(1)
+                },
+            );
+
+        if let Some(window) = prev_window {
+            window
+                .update(cx, |_, window, _| window.activate_window())
+                .ok();
+        }
     }
 
     pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {

crates/workspace/src/workspace_settings.rs πŸ”—

@@ -29,6 +29,7 @@ pub struct WorkspaceSettings {
     pub on_last_window_closed: OnLastWindowClosed,
     pub resize_all_panels_in_dock: Vec<DockPosition>,
     pub close_on_file_delete: bool,
+    pub use_system_window_tabs: bool,
     pub zoomed_padding: bool,
 }
 
@@ -203,6 +204,10 @@ pub struct WorkspaceSettingsContent {
     ///
     /// Default: false
     pub close_on_file_delete: Option<bool>,
+    /// Whether to allow windows to tab together based on the user’s tabbing preference (macOS only).
+    ///
+    /// Default: false
+    pub use_system_window_tabs: Option<bool>,
     /// Whether to show padding for zoomed panels.
     /// When enabled, zoomed bottom panels will have some top padding,
     /// while zoomed left/right panels will have padding to the right/left (respectively).
@@ -357,6 +362,8 @@ impl Settings for WorkspaceSettings {
             current.max_tabs = Some(n)
         }
 
+        vscode.bool_setting("window.nativeTabs", &mut current.use_system_window_tabs);
+
         // some combination of "window.restoreWindows" and "workbench.startupEditor" might
         // map to our "restore_on_startup"
 

crates/zed/src/main.rs πŸ”—

@@ -2,7 +2,7 @@ mod reliability;
 mod zed;
 
 use agent_ui::AgentPanel;
-use anyhow::{Context as _, Result};
+use anyhow::{Context as _, Error, Result};
 use clap::{Parser, command};
 use cli::FORCE_CLI_MODE_ENV_VAR_NAME;
 use client::{Client, ProxySettings, UserStore, parse_zed_link};
@@ -947,9 +947,13 @@ async fn installation_id() -> Result<IdType> {
 
 async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp) -> Result<()> {
     if let Some(locations) = restorable_workspace_locations(cx, &app_state).await {
+        let use_system_window_tabs = cx
+            .update(|cx| WorkspaceSettings::get(None, cx).use_system_window_tabs)
+            .unwrap_or(false);
+        let mut results: Vec<Result<(), Error>> = Vec::new();
         let mut tasks = Vec::new();
 
-        for (location, paths) in locations {
+        for (index, (location, paths)) in locations.into_iter().enumerate() {
             match location {
                 SerializedWorkspaceLocation::Local => {
                     let app_state = app_state.clone();
@@ -964,7 +968,14 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
                         })?;
                         open_task.await.map(|_| ())
                     });
-                    tasks.push(task);
+
+                    // If we're using system window tabs and this is the first workspace,
+                    // wait for it to finish so that the other windows can be added as tabs.
+                    if use_system_window_tabs && index == 0 {
+                        results.push(task.await);
+                    } else {
+                        tasks.push(task);
+                    }
                 }
                 SerializedWorkspaceLocation::Ssh(ssh) => {
                     let app_state = app_state.clone();
@@ -998,7 +1009,7 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
         }
 
         // Wait for all workspaces to open concurrently
-        let results = future::join_all(tasks).await;
+        results.extend(future::join_all(tasks).await);
 
         // Show notifications for any errors that occurred
         let mut error_count = 0;

crates/zed/src/zed.rs πŸ”—

@@ -282,6 +282,8 @@ pub fn build_window_options(display_uuid: Option<Uuid>, cx: &mut App) -> WindowO
         _ => gpui::WindowDecorations::Client,
     };
 
+    let use_system_window_tabs = WorkspaceSettings::get_global(cx).use_system_window_tabs;
+
     WindowOptions {
         titlebar: Some(TitlebarOptions {
             title: None,
@@ -301,6 +303,11 @@ pub fn build_window_options(display_uuid: Option<Uuid>, cx: &mut App) -> WindowO
             width: px(360.0),
             height: px(240.0),
         }),
+        tabbing_identifier: if use_system_window_tabs {
+            Some(String::from("zed"))
+        } else {
+            None
+        },
         ..Default::default()
     }
 }
@@ -4507,6 +4514,7 @@ mod tests {
                 "zed",
                 "zed_predict_onboarding",
                 "zeta",
+                "window",
             ];
             assert_eq!(
                 all_namespaces,

docs/src/configuring-zed.md πŸ”—

@@ -1421,6 +1421,16 @@ or
 
 Each option controls displaying of a particular toolbar element. If all elements are hidden, the editor toolbar is not displayed.
 
+## Use System Tabs
+
+- Description: Whether to allow windows to tab together based on the user’s tabbing preference (macOS only).
+- Setting: `use_system_window_tabs`
+- Default: `false`
+
+**Options**
+
+This setting enables integration with macOS’s native window tabbing feature. When set to `true`, Zed windows can be grouped together as tabs in a single macOS window, following the system-wide tabbing preferences set by the user (such as "Always", "In Full Screen", or "Never"). This setting is only available on macOS.
+
 ## Enable Language Server
 
 - Description: Whether or not to use language servers to provide code intelligence.