Add macOS window tabs#33334
Conversation
This comment was marked as spam.
This comment was marked as spam.
|
Thank you for your work. In vscode there is a menu called 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 Of course the above mentioned can be implemented as an enhancement later, the current implementation I think is very good! |
|
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. |
|
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. |
|
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):
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: 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.mp4It'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? |
|
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. |
|
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.
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 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.. |
|
Great! Happy to pair if you want https://cal.com/conradirwin/pairing
…On Thu, Jun 26, 2025 at 3:41 PM, Gaauwe Rombouts ***@***.***> wrote:
*gaauwe* left a comment (zed-industries/zed#33334)
<#33334 (comment)>
While it doesn't seem like macOS has API's for reordering the tabs inside
a tab group, we can still override the selectNextTab
<https://developer.apple.com/documentation/appkit/nswindow/selectnexttab(_:)>
and selectPreviousTab
<https://developer.apple.com/documentation/appkit/nswindow/selectprevioustab(_:)>
in our custom NSWindow.
image.png (view on web)
<https://github.com/user-attachments/assets/9dca1b2e-0fbd-4497-b95c-152b07ee4ada>
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.
—
Reply to this email directly, view it on GitHub
<#33334 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAAXAQBIZKGU5UDVHD73AEL3FRSIFAVCNFSM6AAAAACABDGS2KVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZTAMJQGIZDMOBWHA>
.
You are receiving this because you were assigned.Message ID:
***@***.***>
|
23ac42d to
e7353fd
Compare
|
@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! (: |
|
Incredible work @gaauwe, looking forward to see this in production. |
|
@gaauwe nice! Happy to pair more, or let me know when I should take a closer look. |
bbfa1b8 to
0f74f3e
Compare
|
@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 (: |
|
Nice! Let's skip the pairing session today, and I'll spend some time playing with this and report back this evening! |
|
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 (: |
|
I wonder how long until the display link fires the first time, and whether we need to trigger a render immediately at this point. |
|
Without forced step (original situation): With a forced step: 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. |
|
@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.movFrom looking at the code, one of the things I noticed is that it takes about 60ms for this block to be scheduled: zed/crates/gpui/src/platform/mac/window.rs Line 2531 in 4774ff2 That's not great, but in the video it feels much slower than that... A few things I'd like to try:
The other thing I noticed code-wise is that you're using the 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. |
|
@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 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! |
|
Not really sure or I'd have pushed up fixes :D. To time the callback I created 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). |
|
@ConradIrwin Built a quick proof-of-concept based on using Another idea I had is to optimistically update the global state, we already know all the events that can trigger a state update;
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. |
|
@MrSubidubi Would you be able to pair with @gaauwe to show him what's up on your machine and figure this out? |
|
👋 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:
Checked in on this, and sadly no.
Tested that as well, and that does not change anything for my scenario. Other information:
The latter:
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). |
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
|
How i can enable setting? Because i don't see Merge All Windows in Window tab in macOS |
|
@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 |
|
Thank you @gaauwe |
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>
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
|
@gaauwe thanks, was looking forward to this. |
|
@gaauwe is there way to bind shortcuts to activate specific window tabs like with normal tabs? |
|
Share my keyboard bindings to mimic the behavior of VS Code [
{
"context": "Editor",
"bindings": {
"cmd-}": "window::ShowNextWindowTab",
"cmd-{": "window::ShowPreviousWindowTab"
}
}
]
|
|
@phsd0 That is not supported yet, for now there are only some basic commands to interact with the tabs: 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 (: |
|
@gaauwe is there a way to prevent recent projects item to get merged to the fist tab?
Result: It gets added/merged to first tab Same if:
Edit: also, Zed restart merges tabs into one |
|
@gaauwe Thanks for your work on this feature!
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 |
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>
…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.
for settigs in Zed "use_system_window_tabs" zed-industries/zed#33334
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>
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>
…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>
…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>
…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>








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
window.nativeTabssetting in the VS Code settings importerHappy to pair again if things can be improved codewise, or if explanations are necessary for certain choices!
Release Notes:
"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.