Skip to content

Add macOS window tabs#33334

Merged
ConradIrwin merged 45 commits intozed-industries:mainfrom
gaauwe:feature/macos-system-tabs
Aug 28, 2025
Merged

Add macOS window tabs#33334
ConradIrwin merged 45 commits intozed-industries:mainfrom
gaauwe:feature/macos-system-tabs

Conversation

@gaauwe
Copy link
Contributor

@gaauwe gaauwe commented Jun 24, 2025

Closes #14722
Closes #4948
Closes #7136

Follow up of #20557 and #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.

demo.mp4

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
  • Dynamic layout adjustments
  • 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.

@cla-bot cla-bot bot added the cla-signed The user has signed the Contributor License Agreement label Jun 24, 2025
@JosephTLyons
Copy link
Collaborator

I hopped onto this branch to try it out. I found a weird visual bug when rearranging the system tabs:

SCR-20250624-qqjm

@fmunteanu

This comment was marked as spam.

@0x2CA
Copy link
Contributor

0x2CA commented Jun 25, 2025

Thank you for your work.

In vscode there is a menu called Show Tab Bar and Hide Tab Bar which actively controls the display and hiding of tabs.

I think this can be added, it's very useful. My use case is when I want to manually merge some windows.

There is also an Show All Tabs menu that can view all window thumbnails.

Of course the above mentioned can be implemented as an enhancement later, the current implementation I think is very good!

@ConradIrwin
Copy link
Member

Thanks for this!

I also noticed when testing that the tab text is rendered above the rest of the UI:
Screenshot 2025-06-24 at 22 16 08

It also seems like the items in the titlebar are no longer clickable:
Screenshot 2025-06-24 at 22 18 46

Not sure if it's just a layering issue, or if we need to rethink the approach a bit more.

@gaauwe
Copy link
Contributor Author

gaauwe commented Jun 25, 2025

Well, funny how one can test all kinds of weird edge cases but somehow never check to actually click items in the titlebar.. 🙈

I'm guessing I'd be able to look into the mouse events not propagating properly, but the layering is probably out of my wheelhouse. Is there perhaps someone that I can help me with this? I'm more than happy to look into it, but I would need some pointers on what try / where to start.

@ConradIrwin
Copy link
Member

I think if we can fix one we can fix both; we'd need to make sure the Zed UI is rendered above the tab bar.

If not, maybe we go back to the idea of rendering them ourselves; and just copy macOS's design language for them.

@gaauwe
Copy link
Contributor Author

gaauwe commented Jun 25, 2025

Tried to hack around a bit more in the Swift UIs, but it seems like Apple really doesn't want you to do this. The best I could manage is to propagate the clicks to the Zed titlebar, but then you still have the overlay color problem (and the tabs still intercept clicks):

Screenshot 2025-06-26 at 00 11 38

Even if we could somehow magically render the Zed content over the tabs, we'd have the problem the other way around where we'd somehow need to propagate mouse events through the Zed titlebar to the native tabs. At this point it seems like this path isn't really going anywhere moving forward.

There was still of course the Ghostty way of doing it, where the tabs would be on top. While this would most likely solve some of the problems and make it less likely for elements to overlap, it would still mean a lot of hacking around the Swift UIs and less flexibility of what Apple allows:
Screenshot 2025-06-26 at 00 20 47

So I went back to the GPUI solution and I honestly don't know why I didn't spent a bit more time on this previously. I've setup a quick proof of concept and it actually seems quite promising. It's very much WIP, but with the foundation already laid in this PR and the flexibility of GPUI it seems promising:

zed-tabs-gpui.mp4

It's still quite glitchy, but this is also still very much WIP and put together in a few hours. However, it does seem promising and it would be way more flexible I guess (not really any limitations by Apple, except for dragging a tab/window out of the current window) and there would of course be more GPUI expertise already in the Zed theme. Probably goes without saying, but we of course don't have the click/overlay issues with the GPUI tabs 😉

What do you think? Do you agree that it would make sense to dive further into the GPUI solution?

@ConradIrwin
Copy link
Member

Nice! Yes, I think that makes a lot of sense. The main open question is making tab drag'n'drop work?

If we can do that, let's go for it.

@ConradIrwin ConradIrwin self-assigned this Jun 26, 2025
@gaauwe
Copy link
Contributor Author

gaauwe commented Jun 26, 2025

While it doesn't seem like macOS has API's for reordering the tabs inside a tab group, we can still override the selectNextTab and selectPreviousTab in our custom NSWindow.

image

So I figured we could make the tabs drag'n'drop similar to editor tabs and terminal tabs, where we keep track of the tab group state ourselves and switch tabs based on that internal order (which is kinda what I already did). Selecting / switching a tab is as simple as calling window.activate_window() on the GPUI window. By overriding the native functions in the custom NSWindow and hiding the original tabbar, we should be mostly good to go I think?

This is sort of a simplified version of what I did in my proof of concept:

fn render_window_tabs(&self, cx: &App) -> AnyElement {
    // TODO: Get the actual list of tabs in the current tabbed window group.
    let windows = cx.windows();
    let active_window = cx.active_window();

    h_flex()
        .bg(cx.theme().colors().title_bar_background)
        .children(windows.iter().enumerate().map(|(index, window)| {
            WindowTab::new(window)
                .on_click(move |_, _, cx| {
                    window.update(cx, |_, window, _| {
                        window.activate_window();
                    })
                })
        }))
        .into_any()
}

While there is still some things to figure out as to how exactly it would all work, I feel like this should all be doable. I'll continue with the proof of concept I made, to see if I can get all these basics working. Then we can see from there on how to make it more stable / less glitchy.

Edit: hmm then you can’t move tabs between windows as @0x2CA mentioned..

@ConradIrwin
Copy link
Member

ConradIrwin commented Jun 27, 2025 via email

@gaauwe gaauwe force-pushed the feature/macos-system-tabs branch from 23ac42d to e7353fd Compare July 12, 2025 20:34
@gaauwe gaauwe marked this pull request as draft July 12, 2025 20:35
@gaauwe
Copy link
Contributor Author

gaauwe commented Jul 12, 2025

@ConradIrwin Quick update, thanks to our pairing session I've managed to cleanup most of the issues. With a production build the force redraw is indeed way less necessary, but it stills seems to not always be in sync. I've removed all logic with regard to the force redrawing, so that it can be tackled separately from this PR.

I've also managed to get the width (and truncation) working based on the bounds, so no more magic numbers/calculations necessary anymore (and it also works perfectly with bigger UI font sizes out of the box!): normal font size and big font size.

The incorrect window bounds black glitch only happened when I moved a tab to a new window through the command palette (probably because Apple does some extra magic when you do it through their menu?), but updating the window bounds on key status change indeed fixes it.

There are still some things I want to fix/add; close buttons based on settings (like other tabs have) and better inactive tab background colors (since it looks weird now in light themes). I'm also noticing some other weird bugs with regards to tab/window management, so I'll investigate those as well, but its getting closer and closer! (:

@fmunteanu
Copy link

Incredible work @gaauwe, looking forward to see this in production.

@ConradIrwin
Copy link
Member

@gaauwe nice! Happy to pair more, or let me know when I should take a closer look.

@gaauwe
Copy link
Contributor Author

gaauwe commented Jul 15, 2025

We discussed the inactive tab background color briefly I think, but just wanted to check/confirm if you have any strong opinion on this.

I looked into a few things:

  • Initially I used the editor background color, since that one was one of the darker colors
    • This didn't really work out for light themes, and also wasn't always the case for other dark themes
  • Tried a solution where I would programatically darken the titlebar color (or lighten it for light themes)
    • Didn't really work out either, some cases looked good, some cases didn't look great
    • This solution is also a bit harder for themes that already are quite dark, (since you can't really darken it more in that case)
      • Ghostty defaults back to macOS gray if the titlebar theme color is really dark
  • Thought about introducing a new theme key, but that might be a bit tricky for theme creators that are on linux (or even windows soon) and don't know about this feature
  • Just use the existing tab_bar_background

Extra busy screenshot, to demonstrate the tabbar color in its sort of ‘worst case scenario’:
image

To keep it simple I figure we either:

  • Just keep it at tab_bar_background and see how people react to it
  • Add a new variable, but default back to the tab_bar_background if the key isn't defined
    • Even if it then isn't included in all themes, users can still define it themselves by using experimental.theme_overrides

@gaauwe gaauwe force-pushed the feature/macos-system-tabs branch from bbfa1b8 to 0f74f3e Compare July 20, 2025 18:22
@gaauwe
Copy link
Contributor Author

gaauwe commented Jul 20, 2025

@ConradIrwin I believe everything is finally ready! 🥳

I’ve gone through all the items in my notes and implemented everything (and fixed the bugs that I noticed during testing). It should now work as expected, but feel free to take a look and let me know if anything seems off.

We had a pairing session planned for the coming week to look into a specific bug, but I managed to fix that already today myself. So we could either cancel the session, or use it to walk through the code together (since things can probably be improved), but I'll leave that up to you (:

@gaauwe gaauwe marked this pull request as ready for review July 20, 2025 20:43
@ConradIrwin
Copy link
Member

Nice! Let's skip the pairing session today, and I'll spend some time playing with this and report back this evening!

@gaauwe
Copy link
Contributor Author

gaauwe commented Jul 22, 2025

Looked further into the 'out of sync rendering' when switching between tabbed windows as well (yep.. hard to let that one go..) and found some new information.

The observer for each window does get triggered when a change is made to the global window tabs state, it just doesn't re-render. However, when you switch to another other tabbed window the first render does have the most up-to-date data, so it immediately renders the correct UI. This explains why it is only incorrect for a very short time (and probably only noticeable because both windows render the exact same UI in that location).

Based on that I looked into the rendering pipeline and found the DisplayLink, but more importantly this function:

extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) {
    let window_state = unsafe { get_window_state(this) };
    let lock = &mut *window_state.lock();
    unsafe {
        if lock
            .native_window
            .occlusionState()
            .contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible)
        {
            lock.start_display_link();
        } else {
            // lock.stop_display_link();
        }
    }
}

From what I understand the DisplayLink is responsible for the actual rendering of content to the macOS layer and when a window becomes invisible it stops rendering. This makes sense, because why would you keep rendering invisible windows, but with that line disabled it does fix the out of sync issue.

Disabling this line is of course not the solution, so I'm not yet sure what to do with this information. Will read a bit more into this, but if this triggers any ideas for you do let me know (:

@ConradIrwin
Copy link
Member

I wonder how long until the display link fires the first time, and whether we need to trigger a render immediately at this point.

@gaauwe
Copy link
Contributor Author

gaauwe commented Jul 22, 2025

Without forced step (original situation):

2025-07-22T22:40:24+02:00 INFO  [gpui::platform::mac::window] Start display link: 1753216824177
2025-07-22T22:40:24+02:00 INFO  [title_bar::system_window_tabs] Rendering tabs:   1753216824185

2025-07-22T22:40:26+02:00 INFO  [gpui::platform::mac::window] Start display link: 1753216826572
2025-07-22T22:40:26+02:00 INFO  [title_bar::system_window_tabs] Rendering tabs:   1753216826577

2025-07-22T22:40:27+02:00 INFO  [gpui::platform::mac::window] Start display link: 1753216827330
2025-07-22T22:40:27+02:00 INFO  [title_bar::system_window_tabs] Rendering tabs:   1753216827336

2025-07-22T22:40:29+02:00 INFO  [gpui::platform::mac::window] Start display link: 1753216829106
2025-07-22T22:40:29+02:00 INFO  [title_bar::system_window_tabs] Rendering tabs:   1753216829119

2025-07-22T22:40:30+02:00 INFO  [gpui::platform::mac::window] Start display link: 1753216830125
2025-07-22T22:40:30+02:00 INFO  [title_bar::system_window_tabs] Rendering tabs:   1753216830136

2025-07-22T22:40:32+02:00 INFO  [gpui::platform::mac::window] Start display link: 1753216832442
2025-07-22T22:40:32+02:00 INFO  [title_bar::system_window_tabs] Rendering tabs:   1753216832453

With a forced step:

2025-07-22T22:28:52+02:00 INFO  [gpui::platform::mac::window] Start display link: 1753216132761
2025-07-22T22:28:52+02:00 INFO  [title_bar::system_window_tabs] Rendering tabs:   1753216132761

2025-07-22T22:28:53+02:00 INFO  [gpui::platform::mac::window] Start display link: 1753216133821
2025-07-22T22:28:53+02:00 INFO  [title_bar::system_window_tabs] Rendering tabs:   1753216133822

2025-07-22T22:28:54+02:00 INFO  [gpui::platform::mac::window] Start display link: 1753216134610
2025-07-22T22:28:54+02:00 INFO  [title_bar::system_window_tabs] Rendering tabs:   1753216134611

2025-07-22T22:28:55+02:00 INFO  [gpui::platform::mac::window] Start display link: 1753216135445
2025-07-22T22:28:55+02:00 INFO  [title_bar::system_window_tabs] Rendering tabs:   1753216135445

2025-07-22T22:28:59+02:00 INFO  [gpui::platform::mac::window] Start display link: 1753216139871
2025-07-22T22:28:59+02:00 INFO  [title_bar::system_window_tabs] Rendering tabs:   1753216139872

2025-07-22T22:29:00+02:00 INFO  [gpui::platform::mac::window] Start display link: 1753216140670
2025-07-22T22:29:00+02:00 INFO  [title_bar::system_window_tabs] Rendering tabs:   1753216140670

No idea if this is the correct way of getting the timings, but I logged the time in milliseconds since epoch (in a production build). Wasn't sure how to best force another step/re-render either, but for this test I did the following:

extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) {
    let window_state = unsafe { get_window_state(this) };
    let mut lock = window_state.as_ref().lock();
    unsafe {
        if lock
            .native_window
            .occlusionState()
            .contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible)
        {
            lock.start_display_link();

            let native_view = lock.native_view.as_ptr();
            drop(lock);
            step(native_view as *mut c_void);
        } else {
            lock.stop_display_link();
        }
    }
}

Can still see it (although very quickly) even with the forced step, so I'm guessing it probably still takes like a frame to get the new information drawn in the window? While in the other situation (not stopping the display link) it would've already re-rendered in the background in real time before you switch to the window.

Edit: feel free to ignore this sidetrack btw, if it isn't blocking for this PR, and can be done in a follow for example. Mainly just curiosity from my side, so I started looking into it again.

@ConradIrwin
Copy link
Member

@gaauwe I played with this branch a bit, and it is feeling pretty good!

But, I think we need to get to the bottom of the tab bar rendering slowness before we ship.

I particularly notice it when using the window actions (merge all windows and move tab to new window) where it seems like the window operations happen well before the tab bar updates, and in this case it went up to three tabs, then down to two tabs, and back to three again; which was very disorienting.

Untitled.mov

From looking at the code, one of the things I noticed is that it takes about 60ms for this block to be scheduled:

let mut lock = window_state.as_ref().lock();

That's not great, but in the video it feels much slower than that...

A few things I'd like to try:

  • What happens if we just call NSWindow.tabbedWindows from in the render function so we can be sure the state is up-to-date on each render cycle?
  • Is there any way to schedule that block faster? (it looks like just calling it doesn't work because the window is already locked; but maybe we could preemptively call the callback when opening a window?)

The other thing I noticed code-wise is that you're using the CommandPaletteFilter to enable/disable the actions. Instead you should use the (new-ish) workspace register_action_renderer so you can decide at render time whether or not to register the actions at all.

I am unfortunately going to be traveling the next two weeks, so not sure how available I can be for pairing, but please let me know if you want to jump in together more on this.

@gaauwe
Copy link
Contributor Author

gaauwe commented Jul 23, 2025

@ConradIrwin Thanks! And no worries, would be nice to get it all polished up to release it all together as a complete package.

When building this I found out that calling the .tabbingGroup to early would mess up the tabbing behaviour, but indeed maybe a good idea to look into the .tabbedWindows as an alternative. Great tip as well about the register_action_renderer , the current implementation didn't feel great but it was the only existing use case that I could find so far.

Do you have any tips with regard to debugging these timer issues? Will happily continue investigating these things, but it's a bit hard for me to actually figure out where the issues are. Meanwhile you found that the lock is slow, so would be interested to know how to find these things.

Will continue looking into the render issues/delays and keep you updated!

@ConradIrwin
Copy link
Member

Not really sure or I'd have pushed up fixes :D. To time the callback I created let start = Instant::now() before putting the block into the queue, and then dbg!(now.elapsed()) before acquiring the lock in the callback.

I also tried bumping up the queue priority, but it didn't seem to help, so I'm guessing that much or most of the time is that the main thread is already busy doing high priority stuff (like creating a new window).

@gaauwe
Copy link
Contributor Author

gaauwe commented Jul 24, 2025

@ConradIrwin Built a quick proof-of-concept based on using .tabbedWindows, while it works it still doesn't seem to be completely 'glitch free'. I'll play around a bit more with this possible path.

Another idea I had is to optimistically update the global state, we already know all the events that can trigger a state update;

  • New window is added to an existing window, we can already put the new window in the same tab group immediately
  • Tabbed window is moved to a new window, we can already remove it from the previous group immediately
  • Windows are merged together, we can already put all the windows in the current windows tab group immediately
  • Window gets closed, we can remove it from the current tab group immediately

That way we would be less dependent on the macOS timings. I'll have a look at both paths and see if it has potential.

@ConradIrwin
Copy link
Member

@MrSubidubi Would you be able to pair with @gaauwe to show him what's up on your machine and figure this out?

@MrSubidubi
Copy link
Member

👋

Good news: Thanks to your mention of the bundle-script, I can actually build something and test against that. I just spun up a debug Preview build and that works entirely fine. A debug Nightly build remains broken for me.

Bad news:

Just throwing it out there; but could there possibly somehow be a bug in the settings logic (unrelated to the system tabs) that could be causing this?

Checked in on this, and sadly no.

'm currently testing a version with those improvements

Tested that as well, and that does not change anything for my scenario.

Other information:

Did the windows actually group together in the same window? Or were the windows still separate with the setting turned off, but with a visible tabbar somehow?

The latter:

grafik

Probably equally important to know, but I was affected by what you most likely call the "original bug" (link seems broken, but I looked at the Git blame and seems to match what you were referring to) and many of the stuff there seems to be in place because I initially reported it broken with my setup. That might just also indicate thought that it is a somewhat specific bug with my Nightly which was broken previously and somehow never entirely left that state.

Also, there were like multiple fixes regarding what I ran into, so some of these might be equally relevant. #26534 was the original issue and I think most of the relevant fixes are tracked below there with various references. As said, all this might have just caused my Nightly to get somewhat specially broken. Considering all this, I don't currently think my issue should block this - not a single member of the team ran into the issue I am experiencing.


Down to pair and check in on it if you'd like to, any preferred channel? You can reach me at my mail address and we can discuss anything further there (don't have a Calendly set up).

ConradIrwin pushed a commit that referenced this pull request Sep 1, 2025
Follow up of #33334

After chatting with @MrSubidubi we found out that he had an old defaults
setting (most likely from when he encountered a previous window tabbing
bug):
```
❯ defaults read dev.zed.Zed-Nightly
{
    NSNavPanelExpandedSizeForOpenMode = "{800, 448}";
    NSNavPanelExpandedSizeForSaveMode = "{800, 448}";
    NSNavPanelExpandedStateForSaveMode = 1;
    NSOSPLastRootDirectory = {length = 828, bytes = 0x626f6f6b 3c030000 00000410 30000000 ... dc010000 00000000 };
    "NSWindow Frame NSNavPanelAutosaveName" = "557 1726 800 448 -323 982 2560 1440 ";
    "NSWindowTabbingShoudShowTabBarKey-GPUIWindow-GPUIWindow-(null)-HT-FS" = 1;
}
```

> That suffix is AppKit’s fallback autosave name when no tabbing
identifier is set. It encodes the NSWindow subclass (GPUIWindow), plus
traits like HT (hidden titlebar) and FS (fullscreen).

Which explains why it only happened on the Nightly build, since each
bundle has it's own defaults. It also explains why the tabbar would
disappear when he activated the `use_system_window_tabs` setting,
because with that setting activated, the tabbing identifier becomes
"zed" (instead of the default one when omitted) for which he didn't have
the `NSWindowTabbingShoudShowTabBarKey` default.

The original implementation was perhaps a bit naive and relied fully on
macOS to determine if the tabbar should be shown. I've updated the code
to always hide the tabbar, if the setting is turned off and there is
only 1 tab entry.

While testing, I also noticed that the menu's like 'merge all windows'
wouldn't become active when the setting was turned on, only after a full
workspace reload. So I added a setting observer as well, to immediately
set the correct window properties to enable all the features without a
reload.

Release Notes:

- N/A
@dalisoft
Copy link

dalisoft commented Sep 8, 2025

How i can enable setting? Because i don't see Merge All Windows in Window tab in macOS

@gaauwe
Copy link
Contributor Author

gaauwe commented Sep 8, 2025

@dalisoft it can be turned on by enabling this setting: https://zed.dev/docs/configuring-zed#use-system-tabs

Currently this is only available in the preview build of Zed, but it should be coming to the stable build this week afaik

@dalisoft
Copy link

dalisoft commented Sep 8, 2025

Thank you @gaauwe

tidely pushed a commit to tidely/zed that referenced this pull request Sep 10, 2025
Closes zed-industries#14722
Closes zed-industries#4948
Closes zed-industries#7136

Follow up of zed-industries#20557 and
zed-industries#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>
tidely pushed a commit to tidely/zed that referenced this pull request Sep 10, 2025
Follow up of zed-industries#33334

After chatting with @MrSubidubi we found out that he had an old defaults
setting (most likely from when he encountered a previous window tabbing
bug):
```
❯ defaults read dev.zed.Zed-Nightly
{
    NSNavPanelExpandedSizeForOpenMode = "{800, 448}";
    NSNavPanelExpandedSizeForSaveMode = "{800, 448}";
    NSNavPanelExpandedStateForSaveMode = 1;
    NSOSPLastRootDirectory = {length = 828, bytes = 0x626f6f6b 3c030000 00000410 30000000 ... dc010000 00000000 };
    "NSWindow Frame NSNavPanelAutosaveName" = "557 1726 800 448 -323 982 2560 1440 ";
    "NSWindowTabbingShoudShowTabBarKey-GPUIWindow-GPUIWindow-(null)-HT-FS" = 1;
}
```

> That suffix is AppKit’s fallback autosave name when no tabbing
identifier is set. It encodes the NSWindow subclass (GPUIWindow), plus
traits like HT (hidden titlebar) and FS (fullscreen).

Which explains why it only happened on the Nightly build, since each
bundle has it's own defaults. It also explains why the tabbar would
disappear when he activated the `use_system_window_tabs` setting,
because with that setting activated, the tabbing identifier becomes
"zed" (instead of the default one when omitted) for which he didn't have
the `NSWindowTabbingShoudShowTabBarKey` default.

The original implementation was perhaps a bit naive and relied fully on
macOS to determine if the tabbar should be shown. I've updated the code
to always hide the tabbar, if the setting is turned off and there is
only 1 tab entry.

While testing, I also noticed that the menu's like 'merge all windows'
wouldn't become active when the setting was turned on, only after a full
workspace reload. So I added a setting observer as well, to immediately
set the correct window properties to enable all the features without a
reload.

Release Notes:

- N/A
@YurySolovyov
Copy link

@gaauwe thanks, was looking forward to this.

@phsd0
Copy link

phsd0 commented Sep 10, 2025

@gaauwe is there way to bind shortcuts to activate specific window tabs like with normal tabs?

      "cmd-1": ["pane::ActivateItem", 0],
      "cmd-2": ["pane::ActivateItem", 1],
      "cmd-3": ["pane::ActivateItem", 2],
      "cmd-4": ["pane::ActivateItem", 3],
      "cmd-5": ["pane::ActivateItem", 4],
      "cmd-6": ["pane::ActivateItem", 5],
      "cmd-7": ["pane::ActivateItem", 6],
      "cmd-8": ["pane::ActivateItem", 7],
      "cmd-9": ["pane::ActivateItem", 8],

@simonla
Copy link

simonla commented Sep 11, 2025

Share my keyboard bindings to mimic the behavior of VS Code

[
  {
    "context": "Editor",
    "bindings": {
      "cmd-}": "window::ShowNextWindowTab",
      "cmd-{": "window::ShowPreviousWindowTab"
    }
  }
]

@gaauwe
Copy link
Contributor Author

gaauwe commented Sep 11, 2025

@phsd0 That is not supported yet, for now there are only some basic commands to interact with the tabs:

ShowNextWindowTab,
ShowPreviousWindowTab,
MergeAllWindows,
MoveTabToNewWindow

There are no default shortcuts for these, but like @simonla said you can assign these yourself. I'll have a look at the normal tab actions to see if the keyboard navigation of window tabs can be improved a bit more (:

@YurySolovyov
Copy link

YurySolovyov commented Sep 11, 2025

@gaauwe is there a way to prevent recent projects item to get merged to the fist tab?

  1. I open first tab
  2. I select Project A from recents
  3. I open second tab
  4. I select Project B from recents

Result: It gets added/merged to first tab
Expected: it gets added to second tab

Same if:

  1. I open first tab
  2. I select Project A from recents
  3. I use "New Window/Tab" Button at the end of tabs row and select Project B

Edit: also, Zed restart merges tabs into one

@lionel-
Copy link
Contributor

lionel- commented Sep 15, 2025

@gaauwe Thanks for your work on this feature!

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.

Would you consider adding an internal setting to determine whether to use the global macOS setting or just force tabs independently of that setting?

Otherwise the only way to use project tabs in Zed is to have all applications start using tabs by default, such as the Finder, which many people don't want.

FWIW, in our VS Code fork, we no longer allow the global setting to have an effect at all, and just force tabs when the setting is true: posit-dev/positron#9159

FrGoIs pushed a commit to FrGoIs/zed that referenced this pull request Sep 29, 2025
Closes zed-industries#14722
Closes zed-industries#4948
Closes zed-industries#7136

Follow up of zed-industries#20557 and
zed-industries#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>
smitbarmase added a commit that referenced this pull request Oct 3, 2025
…de Zed (#39467)

Closes #38258

Regressed in #33334
 
Release Notes:

- Fixed an issue on macOS where keyboard shortcuts wouldn’t work until
you clicked inside Zed.
LivioGama pushed a commit to LivioGama/zed that referenced this pull request Oct 11, 2025
…de Zed (zed-industries#39467)

Closes zed-industries#38258

Regressed in zed-industries#33334
 
Release Notes:

- Fixed an issue on macOS where keyboard shortcuts wouldn’t work until
you clicked inside Zed.
handlename added a commit to handlename/dotfiles that referenced this pull request Oct 12, 2025
for settigs in Zed "use_system_window_tabs"
zed-industries/zed#33334
ConradIrwin added a commit that referenced this pull request Dec 17, 2025
Closes #20613

Release Notes:

- Fixed: New windows no longer flicker between "Open a file or project
to get started" and an empty editor.

---

When opening a new window (`cmd-shift-n`), the window rendered showing
the empty state message before the editor was created, causing a visible
flicker.

**Changes:**

- Modified `Workspace::new_local` to accept an optional `init` callback
that executes inside the window build closure
- The init callback runs within `cx.new` (the `build_root_view`
closure), before `window.draw()` is called for the first render
- Changed the NewWindow action handler to use
`Project::create_local_buffer()` (synchronous) instead of
`Editor::new_file()` (asynchronous)
- Updated `open_new` to pass the editor creation callback to `new_local`
- All other `new_local` call sites pass `None` to maintain existing
behavior

**Key Technical Detail:**

The window creation sequence in `cx.open_window()` is:
1. `build_root_view` closure is called (creates workspace via `cx.new`)
2. `window.draw(cx)` is called (first render)
3. `open_window` returns

The fix uses `Project::create_local_buffer()` which creates a buffer
**synchronously** (returns `Entity<Buffer>` directly), rather than
`Editor::new_file()` which is asynchronous (calls
`project.create_buffer()` which returns a `Task`). The editor is created
from this buffer inside the `cx.new` closure (step 1), ensuring it
exists before step 2 renders the first frame.

**Before:**
```rust
let task = Workspace::new_local(Vec::new(), app_state, None, env, cx);
cx.spawn(async move |cx| {
    let (workspace, _) = task.await?;  // Window already drawn
    workspace.update(cx, |workspace, window, cx| {
        Editor::new_file(workspace, ...)  // Async - editor not present for first render
    })?;
})
```

**After:**
```rust
cx.open_window(options, {
    move |window, cx| {
        cx.new(|cx| {
            let mut workspace = Workspace::new(...);
            // Create buffer synchronously, then create editor
            if let Some(init) = init {
                init(&mut workspace, window, cx);  // Uses create_local_buffer (sync)
            }
            workspace
        })
    }
})?
```

The editor is now part of the workspace before the window's first frame
is rendered, eliminating the flicker.

<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> 
> ----
> 
> *This section details on the original issue you should resolve*
> 
> <issue_title>Opening a new window flickers before opening an empty
buffer</issue_title>
> <issue_description>### Check for existing issues
> 
> - [x] Completed
> 
> ### Describe the bug / provide steps to reproduce it
> 
> Opening a new window, with e.g. `cmd-shift-n`, flickers for a fraction
of a second. The new window first shows the startup page, "Open a file
or project to get started.". Then, a frame or two later, a new empty
buffer opens.
> 
> Not sure if I'm sensitive or something but these kinds of flashes can
knock me out of focus/flow pretty easily.
> 
> It'd be great to either have the empty buffer open from the first
frame, or to have an option to simply not open that empty buffer when a
new window is opened.
> 
> ### Zed Version and System Specs
> 
> Zed: v0.170.4 (Zed)
> OS: macOS 14.6.1
> Memory: 36 GiB
> Architecture: aarch64
> 
> ### If applicable, add screenshots or screencasts of the incorrect
state / behavior
> 
>
https://github.com/user-attachments/assets/6d9ba791-8a02-4e13-857c-66a33eb0905b
> 
> ### If applicable, attach your Zed.log file to this issue.
> 
> N/A</issue_description>
> 
> <agent_instructions>We should make sure that the window is created in
the correct state, and not have an intermediate render before the editor
opens.</agent_instructions>
> 
> ## Comments on the Issue (you are @copilot in this section)
> 
> <comments>
> <comment_new><author>@ConradIrwin</author><body>
> Ugh, no. I don't believe I never noticed this before, but now I can't
unsee it :s
> 
> If you'd like to pair on this: https://cal.com/conradirwin/pairing,
otherwise I'll see if I get around to it.</body></comment_new>
> <comment_new><author>@ConradIrwin</author><body>
> Yeah... I wonder if that can be a preview tab or something. It's nice
when you want it, but not so nice when you don't.
> 
> Fixing this will also make #33334 feel much
smoother.</body></comment_new>
> <comment_new><author>@zelenenka</author><body>
> @robinplace do you maybe have an opportunity to test it with the
latest stable version, 0.213.3?</body></comment_new>
> </comments>
> 


</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes #23742

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ConradIrwin <94272+ConradIrwin@users.noreply.github.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
HactarCE pushed a commit that referenced this pull request Dec 17, 2025
Closes #20613

Release Notes:

- Fixed: New windows no longer flicker between "Open a file or project
to get started" and an empty editor.

---

When opening a new window (`cmd-shift-n`), the window rendered showing
the empty state message before the editor was created, causing a visible
flicker.

**Changes:**

- Modified `Workspace::new_local` to accept an optional `init` callback
that executes inside the window build closure
- The init callback runs within `cx.new` (the `build_root_view`
closure), before `window.draw()` is called for the first render
- Changed the NewWindow action handler to use
`Project::create_local_buffer()` (synchronous) instead of
`Editor::new_file()` (asynchronous)
- Updated `open_new` to pass the editor creation callback to `new_local`
- All other `new_local` call sites pass `None` to maintain existing
behavior

**Key Technical Detail:**

The window creation sequence in `cx.open_window()` is:
1. `build_root_view` closure is called (creates workspace via `cx.new`)
2. `window.draw(cx)` is called (first render)
3. `open_window` returns

The fix uses `Project::create_local_buffer()` which creates a buffer
**synchronously** (returns `Entity<Buffer>` directly), rather than
`Editor::new_file()` which is asynchronous (calls
`project.create_buffer()` which returns a `Task`). The editor is created
from this buffer inside the `cx.new` closure (step 1), ensuring it
exists before step 2 renders the first frame.

**Before:**
```rust
let task = Workspace::new_local(Vec::new(), app_state, None, env, cx);
cx.spawn(async move |cx| {
    let (workspace, _) = task.await?;  // Window already drawn
    workspace.update(cx, |workspace, window, cx| {
        Editor::new_file(workspace, ...)  // Async - editor not present for first render
    })?;
})
```

**After:**
```rust
cx.open_window(options, {
    move |window, cx| {
        cx.new(|cx| {
            let mut workspace = Workspace::new(...);
            // Create buffer synchronously, then create editor
            if let Some(init) = init {
                init(&mut workspace, window, cx);  // Uses create_local_buffer (sync)
            }
            workspace
        })
    }
})?
```

The editor is now part of the workspace before the window's first frame
is rendered, eliminating the flicker.

<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> 
> ----
> 
> *This section details on the original issue you should resolve*
> 
> <issue_title>Opening a new window flickers before opening an empty
buffer</issue_title>
> <issue_description>### Check for existing issues
> 
> - [x] Completed
> 
> ### Describe the bug / provide steps to reproduce it
> 
> Opening a new window, with e.g. `cmd-shift-n`, flickers for a fraction
of a second. The new window first shows the startup page, "Open a file
or project to get started.". Then, a frame or two later, a new empty
buffer opens.
> 
> Not sure if I'm sensitive or something but these kinds of flashes can
knock me out of focus/flow pretty easily.
> 
> It'd be great to either have the empty buffer open from the first
frame, or to have an option to simply not open that empty buffer when a
new window is opened.
> 
> ### Zed Version and System Specs
> 
> Zed: v0.170.4 (Zed)
> OS: macOS 14.6.1
> Memory: 36 GiB
> Architecture: aarch64
> 
> ### If applicable, add screenshots or screencasts of the incorrect
state / behavior
> 
>
https://github.com/user-attachments/assets/6d9ba791-8a02-4e13-857c-66a33eb0905b
> 
> ### If applicable, attach your Zed.log file to this issue.
> 
> N/A</issue_description>
> 
> <agent_instructions>We should make sure that the window is created in
the correct state, and not have an intermediate render before the editor
opens.</agent_instructions>
> 
> ## Comments on the Issue (you are @copilot in this section)
> 
> <comments>
> <comment_new><author>@ConradIrwin</author><body>
> Ugh, no. I don't believe I never noticed this before, but now I can't
unsee it :s
> 
> If you'd like to pair on this: https://cal.com/conradirwin/pairing,
otherwise I'll see if I get around to it.</body></comment_new>
> <comment_new><author>@ConradIrwin</author><body>
> Yeah... I wonder if that can be a preview tab or something. It's nice
when you want it, but not so nice when you don't.
> 
> Fixing this will also make #33334 feel much
smoother.</body></comment_new>
> <comment_new><author>@zelenenka</author><body>
> @robinplace do you maybe have an opportunity to test it with the
latest stable version, 0.213.3?</body></comment_new>
> </comments>
> 


</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes #23742

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ConradIrwin <94272+ConradIrwin@users.noreply.github.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
LivioGama pushed a commit to LivioGama/zed that referenced this pull request Jan 20, 2026
…s#44915)

Closes zed-industries#20613

Release Notes:

- Fixed: New windows no longer flicker between "Open a file or project
to get started" and an empty editor.

---

When opening a new window (`cmd-shift-n`), the window rendered showing
the empty state message before the editor was created, causing a visible
flicker.

**Changes:**

- Modified `Workspace::new_local` to accept an optional `init` callback
that executes inside the window build closure
- The init callback runs within `cx.new` (the `build_root_view`
closure), before `window.draw()` is called for the first render
- Changed the NewWindow action handler to use
`Project::create_local_buffer()` (synchronous) instead of
`Editor::new_file()` (asynchronous)
- Updated `open_new` to pass the editor creation callback to `new_local`
- All other `new_local` call sites pass `None` to maintain existing
behavior

**Key Technical Detail:**

The window creation sequence in `cx.open_window()` is:
1. `build_root_view` closure is called (creates workspace via `cx.new`)
2. `window.draw(cx)` is called (first render)
3. `open_window` returns

The fix uses `Project::create_local_buffer()` which creates a buffer
**synchronously** (returns `Entity<Buffer>` directly), rather than
`Editor::new_file()` which is asynchronous (calls
`project.create_buffer()` which returns a `Task`). The editor is created
from this buffer inside the `cx.new` closure (step 1), ensuring it
exists before step 2 renders the first frame.

**Before:**
```rust
let task = Workspace::new_local(Vec::new(), app_state, None, env, cx);
cx.spawn(async move |cx| {
    let (workspace, _) = task.await?;  // Window already drawn
    workspace.update(cx, |workspace, window, cx| {
        Editor::new_file(workspace, ...)  // Async - editor not present for first render
    })?;
})
```

**After:**
```rust
cx.open_window(options, {
    move |window, cx| {
        cx.new(|cx| {
            let mut workspace = Workspace::new(...);
            // Create buffer synchronously, then create editor
            if let Some(init) = init {
                init(&mut workspace, window, cx);  // Uses create_local_buffer (sync)
            }
            workspace
        })
    }
})?
```

The editor is now part of the workspace before the window's first frame
is rendered, eliminating the flicker.

<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> 
> ----
> 
> *This section details on the original issue you should resolve*
> 
> <issue_title>Opening a new window flickers before opening an empty
buffer</issue_title>
> <issue_description>### Check for existing issues
> 
> - [x] Completed
> 
> ### Describe the bug / provide steps to reproduce it
> 
> Opening a new window, with e.g. `cmd-shift-n`, flickers for a fraction
of a second. The new window first shows the startup page, "Open a file
or project to get started.". Then, a frame or two later, a new empty
buffer opens.
> 
> Not sure if I'm sensitive or something but these kinds of flashes can
knock me out of focus/flow pretty easily.
> 
> It'd be great to either have the empty buffer open from the first
frame, or to have an option to simply not open that empty buffer when a
new window is opened.
> 
> ### Zed Version and System Specs
> 
> Zed: v0.170.4 (Zed)
> OS: macOS 14.6.1
> Memory: 36 GiB
> Architecture: aarch64
> 
> ### If applicable, add screenshots or screencasts of the incorrect
state / behavior
> 
>
https://github.com/user-attachments/assets/6d9ba791-8a02-4e13-857c-66a33eb0905b
> 
> ### If applicable, attach your Zed.log file to this issue.
> 
> N/A</issue_description>
> 
> <agent_instructions>We should make sure that the window is created in
the correct state, and not have an intermediate render before the editor
opens.</agent_instructions>
> 
> ## Comments on the Issue (you are @copilot in this section)
> 
> <comments>
> <comment_new><author>@ConradIrwin</author><body>
> Ugh, no. I don't believe I never noticed this before, but now I can't
unsee it :s
> 
> If you'd like to pair on this: https://cal.com/conradirwin/pairing,
otherwise I'll see if I get around to it.</body></comment_new>
> <comment_new><author>@ConradIrwin</author><body>
> Yeah... I wonder if that can be a preview tab or something. It's nice
when you want it, but not so nice when you don't.
> 
> Fixing this will also make zed-industries#33334 feel much
smoother.</body></comment_new>
> <comment_new><author>@zelenenka</author><body>
> @robinplace do you maybe have an opportunity to test it with the
latest stable version, 0.213.3?</body></comment_new>
> </comments>
> 


</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes zed-industries#23742

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ConradIrwin <94272+ConradIrwin@users.noreply.github.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
LivioGama pushed a commit to LivioGama/zed that referenced this pull request Jan 20, 2026
…s#44915)

Closes zed-industries#20613

Release Notes:

- Fixed: New windows no longer flicker between "Open a file or project
to get started" and an empty editor.

---

When opening a new window (`cmd-shift-n`), the window rendered showing
the empty state message before the editor was created, causing a visible
flicker.

**Changes:**

- Modified `Workspace::new_local` to accept an optional `init` callback
that executes inside the window build closure
- The init callback runs within `cx.new` (the `build_root_view`
closure), before `window.draw()` is called for the first render
- Changed the NewWindow action handler to use
`Project::create_local_buffer()` (synchronous) instead of
`Editor::new_file()` (asynchronous)
- Updated `open_new` to pass the editor creation callback to `new_local`
- All other `new_local` call sites pass `None` to maintain existing
behavior

**Key Technical Detail:**

The window creation sequence in `cx.open_window()` is:
1. `build_root_view` closure is called (creates workspace via `cx.new`)
2. `window.draw(cx)` is called (first render)
3. `open_window` returns

The fix uses `Project::create_local_buffer()` which creates a buffer
**synchronously** (returns `Entity<Buffer>` directly), rather than
`Editor::new_file()` which is asynchronous (calls
`project.create_buffer()` which returns a `Task`). The editor is created
from this buffer inside the `cx.new` closure (step 1), ensuring it
exists before step 2 renders the first frame.

**Before:**
```rust
let task = Workspace::new_local(Vec::new(), app_state, None, env, cx);
cx.spawn(async move |cx| {
    let (workspace, _) = task.await?;  // Window already drawn
    workspace.update(cx, |workspace, window, cx| {
        Editor::new_file(workspace, ...)  // Async - editor not present for first render
    })?;
})
```

**After:**
```rust
cx.open_window(options, {
    move |window, cx| {
        cx.new(|cx| {
            let mut workspace = Workspace::new(...);
            // Create buffer synchronously, then create editor
            if let Some(init) = init {
                init(&mut workspace, window, cx);  // Uses create_local_buffer (sync)
            }
            workspace
        })
    }
})?
```

The editor is now part of the workspace before the window's first frame
is rendered, eliminating the flicker.

<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> 
> ----
> 
> *This section details on the original issue you should resolve*
> 
> <issue_title>Opening a new window flickers before opening an empty
buffer</issue_title>
> <issue_description>### Check for existing issues
> 
> - [x] Completed
> 
> ### Describe the bug / provide steps to reproduce it
> 
> Opening a new window, with e.g. `cmd-shift-n`, flickers for a fraction
of a second. The new window first shows the startup page, "Open a file
or project to get started.". Then, a frame or two later, a new empty
buffer opens.
> 
> Not sure if I'm sensitive or something but these kinds of flashes can
knock me out of focus/flow pretty easily.
> 
> It'd be great to either have the empty buffer open from the first
frame, or to have an option to simply not open that empty buffer when a
new window is opened.
> 
> ### Zed Version and System Specs
> 
> Zed: v0.170.4 (Zed)
> OS: macOS 14.6.1
> Memory: 36 GiB
> Architecture: aarch64
> 
> ### If applicable, add screenshots or screencasts of the incorrect
state / behavior
> 
>
https://github.com/user-attachments/assets/6d9ba791-8a02-4e13-857c-66a33eb0905b
> 
> ### If applicable, attach your Zed.log file to this issue.
> 
> N/A</issue_description>
> 
> <agent_instructions>We should make sure that the window is created in
the correct state, and not have an intermediate render before the editor
opens.</agent_instructions>
> 
> ## Comments on the Issue (you are @copilot in this section)
> 
> <comments>
> <comment_new><author>@ConradIrwin</author><body>
> Ugh, no. I don't believe I never noticed this before, but now I can't
unsee it :s
> 
> If you'd like to pair on this: https://cal.com/conradirwin/pairing,
otherwise I'll see if I get around to it.</body></comment_new>
> <comment_new><author>@ConradIrwin</author><body>
> Yeah... I wonder if that can be a preview tab or something. It's nice
when you want it, but not so nice when you don't.
> 
> Fixing this will also make zed-industries#33334 feel much
smoother.</body></comment_new>
> <comment_new><author>@zelenenka</author><body>
> @robinplace do you maybe have an opportunity to test it with the
latest stable version, 0.213.3?</body></comment_new>
> </comments>
> 


</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes zed-industries#23742

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ConradIrwin <94272+ConradIrwin@users.noreply.github.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
LivioGama pushed a commit to LivioGama/zed that referenced this pull request Feb 15, 2026
…s#44915)

Closes zed-industries#20613

Release Notes:

- Fixed: New windows no longer flicker between "Open a file or project
to get started" and an empty editor.

---

When opening a new window (`cmd-shift-n`), the window rendered showing
the empty state message before the editor was created, causing a visible
flicker.

**Changes:**

- Modified `Workspace::new_local` to accept an optional `init` callback
that executes inside the window build closure
- The init callback runs within `cx.new` (the `build_root_view`
closure), before `window.draw()` is called for the first render
- Changed the NewWindow action handler to use
`Project::create_local_buffer()` (synchronous) instead of
`Editor::new_file()` (asynchronous)
- Updated `open_new` to pass the editor creation callback to `new_local`
- All other `new_local` call sites pass `None` to maintain existing
behavior

**Key Technical Detail:**

The window creation sequence in `cx.open_window()` is:
1. `build_root_view` closure is called (creates workspace via `cx.new`)
2. `window.draw(cx)` is called (first render)
3. `open_window` returns

The fix uses `Project::create_local_buffer()` which creates a buffer
**synchronously** (returns `Entity<Buffer>` directly), rather than
`Editor::new_file()` which is asynchronous (calls
`project.create_buffer()` which returns a `Task`). The editor is created
from this buffer inside the `cx.new` closure (step 1), ensuring it
exists before step 2 renders the first frame.

**Before:**
```rust
let task = Workspace::new_local(Vec::new(), app_state, None, env, cx);
cx.spawn(async move |cx| {
    let (workspace, _) = task.await?;  // Window already drawn
    workspace.update(cx, |workspace, window, cx| {
        Editor::new_file(workspace, ...)  // Async - editor not present for first render
    })?;
})
```

**After:**
```rust
cx.open_window(options, {
    move |window, cx| {
        cx.new(|cx| {
            let mut workspace = Workspace::new(...);
            // Create buffer synchronously, then create editor
            if let Some(init) = init {
                init(&mut workspace, window, cx);  // Uses create_local_buffer (sync)
            }
            workspace
        })
    }
})?
```

The editor is now part of the workspace before the window's first frame
is rendered, eliminating the flicker.

<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> 
> ----
> 
> *This section details on the original issue you should resolve*
> 
> <issue_title>Opening a new window flickers before opening an empty
buffer</issue_title>
> <issue_description>### Check for existing issues
> 
> - [x] Completed
> 
> ### Describe the bug / provide steps to reproduce it
> 
> Opening a new window, with e.g. `cmd-shift-n`, flickers for a fraction
of a second. The new window first shows the startup page, "Open a file
or project to get started.". Then, a frame or two later, a new empty
buffer opens.
> 
> Not sure if I'm sensitive or something but these kinds of flashes can
knock me out of focus/flow pretty easily.
> 
> It'd be great to either have the empty buffer open from the first
frame, or to have an option to simply not open that empty buffer when a
new window is opened.
> 
> ### Zed Version and System Specs
> 
> Zed: v0.170.4 (Zed)
> OS: macOS 14.6.1
> Memory: 36 GiB
> Architecture: aarch64
> 
> ### If applicable, add screenshots or screencasts of the incorrect
state / behavior
> 
>
https://github.com/user-attachments/assets/6d9ba791-8a02-4e13-857c-66a33eb0905b
> 
> ### If applicable, attach your Zed.log file to this issue.
> 
> N/A</issue_description>
> 
> <agent_instructions>We should make sure that the window is created in
the correct state, and not have an intermediate render before the editor
opens.</agent_instructions>
> 
> ## Comments on the Issue (you are @copilot in this section)
> 
> <comments>
> <comment_new><author>@ConradIrwin</author><body>
> Ugh, no. I don't believe I never noticed this before, but now I can't
unsee it :s
> 
> If you'd like to pair on this: https://cal.com/conradirwin/pairing,
otherwise I'll see if I get around to it.</body></comment_new>
> <comment_new><author>@ConradIrwin</author><body>
> Yeah... I wonder if that can be a preview tab or something. It's nice
when you want it, but not so nice when you don't.
> 
> Fixing this will also make zed-industries#33334 feel much
smoother.</body></comment_new>
> <comment_new><author>@zelenenka</author><body>
> @robinplace do you maybe have an opportunity to test it with the
latest stable version, 0.213.3?</body></comment_new>
> </comments>
> 


</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes zed-industries#23742

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ConradIrwin <94272+ConradIrwin@users.noreply.github.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed The user has signed the Contributor License Agreement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Merge all Windows Merge All Window Feature (workspace tabs) Multiple projects in one window