workspace: Improve the multi-project UX (#46641)

Danilo Leal created

This PR introduces a project dropdown when working with multiple
folders/projects in one workspace. Here are some interaction details
that I hope improves the UX of working on this scenario significantly:

- The dropdown shows the currently "active" project, which is determined
by:
  - Either the file you're currently editing
  - Or the file you have just recently switched to
  - Some example cases:
- If you are focused on file from project A but switch to project B in
the titlebar, nothing happens. However, as soon as you type on the file
from project A, the title bar will update and your active project will
return to being project A.
- If you're focused on file from project A and change tabs to a file
from project B, the title bar will update, showing project B as the
active one.
- The content you'll see in the branch picker will correspond to the
currently active project
- It's still possible to reach the "Recent Projects" picker through the
project dropdown
- It's possible to do all interactions (trigger dropdown, select active
project, and remove project from workspace) with the keyboard


https://github.com/user-attachments/assets/e2346757-74df-47c5-bf4d-6354623b6f47

Note that this entire UX is valid only for a multiple folder workspace
scenario; nothing changes for the single project case.

Release Notes:

- Workspace: Improved the UX of working with multiple projects in the
same workspace through introducing a project dropdown that more clearly
shows the currently active project as well as allowing you to change it.

Change summary

Cargo.lock                               |   1 
assets/keymaps/default-linux.json        |   7 
assets/keymaps/default-macos.json        |   7 
assets/keymaps/default-windows.json      |   7 
crates/git_ui/src/branch_picker.rs       |  28 +
crates/git_ui/src/git_picker.rs          |  28 +
crates/project/src/project.rs            |   5 
crates/title_bar/Cargo.toml              |   1 
crates/title_bar/src/project_dropdown.rs | 353 ++++++++++++++++++++++++++
crates/title_bar/src/title_bar.rs        | 260 ++++++++++++++----
crates/ui/src/components/context_menu.rs |   4 
crates/workspace/src/workspace.rs        |  25 +
crates/zed/src/zed.rs                    |   1 
13 files changed, 667 insertions(+), 60 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -16930,6 +16930,7 @@ dependencies = [
  "git_ui",
  "gpui",
  "http_client",
+ "menu",
  "notifications",
  "pretty_assertions",
  "project",

assets/keymaps/default-linux.json 🔗

@@ -561,6 +561,7 @@
       // "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]",
       "alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
       "alt-ctrl-shift-b": "branches::OpenRecent",
+      "ctrl-alt-p": "workspace::SwitchProject",
       "alt-shift-enter": "toast::RunAction",
       "ctrl-~": "workspace::NewTerminal",
       "save": "workspace::Save",
@@ -1357,4 +1358,10 @@
       "alt-3": "git_picker::ActivateStashTab",
     },
   },
+  {
+    "context": "MultiProjectDropdown",
+    "bindings": {
+      "shift-backspace": "project_dropdown::RemoveSelectedFolder",
+    },
+  },
 ]

assets/keymaps/default-macos.json 🔗

@@ -631,6 +631,7 @@
       "ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
       "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }],
       "cmd-ctrl-b": "branches::OpenRecent",
+      "cmd-alt-p": "workspace::SwitchProject",
       "ctrl-~": "workspace::NewTerminal",
       "cmd-s": "workspace::Save",
       "cmd-k s": "workspace::SaveWithoutFormat",
@@ -1459,4 +1460,10 @@
       "cmd-3": "git_picker::ActivateStashTab",
     },
   },
+  {
+    "context": "MultiProjectDropdown",
+    "bindings": {
+      "shift-backspace": "project_dropdown::RemoveSelectedFolder",
+    },
+  },
 ]

assets/keymaps/default-windows.json 🔗

@@ -560,6 +560,7 @@
       // "ctrl-shift-alt-o": "["projects::OpenRemote", { "from_existing_connection": true }]",
       "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
       "shift-alt-b": "branches::OpenRecent",
+      "ctrl-alt-p": "workspace::SwitchProject",
       "shift-alt-enter": "toast::RunAction",
       "ctrl-shift-`": "workspace::NewTerminal",
       "ctrl-s": "workspace::Save",
@@ -1378,4 +1379,10 @@
       "alt-3": "git_picker::ActivateStashTab",
     },
   },
+  {
+    "context": "MultiProjectDropdown",
+    "bindings": {
+      "shift-backspace": "project_dropdown::RemoveSelectedFolder",
+    },
+  },
 ]

crates/git_ui/src/branch_picker.rs 🔗

@@ -61,7 +61,33 @@ pub fn open(
     cx: &mut Context<Workspace>,
 ) {
     let workspace_handle = workspace.weak_handle();
-    let repository = workspace.project().read(cx).active_repository(cx);
+    let project = workspace.project().clone();
+
+    // Check if there's a worktree override from the project dropdown.
+    // This ensures the branch picker shows branches for the project the user
+    // explicitly selected in the title bar, not just the focused file's project.
+    // This is only relevant if for multi-projects workspaces.
+    let repository = workspace
+        .active_worktree_override()
+        .and_then(|override_id| {
+            let project_ref = project.read(cx);
+            project_ref
+                .worktree_for_id(override_id, cx)
+                .and_then(|worktree| {
+                    let worktree_abs_path = worktree.read(cx).abs_path();
+                    let git_store = project_ref.git_store().read(cx);
+                    git_store
+                        .repositories()
+                        .values()
+                        .find(|repo| {
+                            let repo_path = &repo.read(cx).work_directory_abs_path;
+                            *repo_path == worktree_abs_path
+                                || worktree_abs_path.starts_with(repo_path.as_ref())
+                        })
+                        .cloned()
+                })
+        })
+        .or_else(|| project.read(cx).active_repository(cx));
 
     workspace.toggle_modal(window, cx, |window, cx| {
         BranchList::new(

crates/git_ui/src/git_picker.rs 🔗

@@ -549,7 +549,33 @@ fn open_with_tab(
     cx: &mut Context<Workspace>,
 ) {
     let workspace_handle = workspace.weak_handle();
-    let repository = workspace.project().read(cx).active_repository(cx);
+    let project = workspace.project().clone();
+
+    // Check if there's a worktree override from the project dropdown.
+    // This ensures the git picker shows info for the project the user
+    // explicitly selected in the title bar, not just the focused file's project.
+    // This is only relevant if for multi-projects workspaces.
+    let repository = workspace
+        .active_worktree_override()
+        .and_then(|override_id| {
+            let project_ref = project.read(cx);
+            project_ref
+                .worktree_for_id(override_id, cx)
+                .and_then(|worktree| {
+                    let worktree_abs_path = worktree.read(cx).abs_path();
+                    let git_store = project_ref.git_store().read(cx);
+                    git_store
+                        .repositories()
+                        .values()
+                        .find(|repo| {
+                            let repo_path = &repo.read(cx).work_directory_abs_path;
+                            *repo_path == worktree_abs_path
+                                || worktree_abs_path.starts_with(repo_path.as_ref())
+                        })
+                        .cloned()
+                })
+        })
+        .or_else(|| project.read(cx).active_repository(cx));
 
     workspace.toggle_modal(window, cx, |window, cx| {
         GitPicker::new(workspace_handle, repository, tab, rems(34.), window, cx)

crates/project/src/project.rs 🔗

@@ -354,6 +354,7 @@ pub enum Event {
     EntryRenamed(ProjectTransaction, ProjectPath, PathBuf),
     WorkspaceEditApplied(ProjectTransaction),
     AgentLocationChanged,
+    BufferEdited,
 }
 
 pub struct AgentLocationChanged;
@@ -3413,6 +3414,10 @@ impl Project {
             self.request_buffer_diff_recalculation(&buffer, cx);
         }
 
+        if matches!(event, BufferEvent::Edited) {
+            cx.emit(Event::BufferEdited);
+        }
+
         let buffer_id = buffer.read(cx).remote_id();
         match event {
             BufferEvent::ReloadNeeded => {

crates/title_bar/Cargo.toml 🔗

@@ -39,6 +39,7 @@ cloud_llm_client.workspace = true
 db.workspace = true
 git_ui.workspace = true
 gpui = { workspace = true, features = ["screen-capture"] }
+menu.workspace = true
 notifications.workspace = true
 project.workspace = true
 recent_projects.workspace = true

crates/title_bar/src/project_dropdown.rs 🔗

@@ -0,0 +1,353 @@
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use gpui::{
+    Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription,
+    WeakEntity, actions,
+};
+use menu;
+use project::{Project, Worktree, git_store::Repository};
+use settings::WorktreeId;
+use ui::{ContextMenu, Tooltip, prelude::*};
+use workspace::Workspace;
+
+actions!(project_dropdown, [RemoveSelectedFolder]);
+
+struct ProjectEntry {
+    worktree_id: WorktreeId,
+    name: SharedString,
+    branch: Option<SharedString>,
+    is_active: bool,
+}
+
+pub struct ProjectDropdown {
+    menu: Entity<ContextMenu>,
+    workspace: WeakEntity<Workspace>,
+    worktree_ids: Rc<RefCell<Vec<WorktreeId>>>,
+    menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
+    _subscription: Subscription,
+}
+
+impl ProjectDropdown {
+    pub fn new(
+        project: Entity<Project>,
+        workspace: WeakEntity<Workspace>,
+        initial_active_worktree_id: Option<WorktreeId>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>> = Rc::new(RefCell::new(None));
+        let worktree_ids: Rc<RefCell<Vec<WorktreeId>>> = Rc::new(RefCell::new(Vec::new()));
+
+        let menu = Self::build_menu(
+            project,
+            workspace.clone(),
+            initial_active_worktree_id,
+            menu_shell.clone(),
+            worktree_ids.clone(),
+            window,
+            cx,
+        );
+
+        *menu_shell.borrow_mut() = Some(menu.clone());
+
+        let _subscription = cx.subscribe(&menu, |_, _, _: &DismissEvent, cx| {
+            cx.emit(DismissEvent);
+        });
+
+        Self {
+            menu,
+            workspace,
+            worktree_ids,
+            menu_shell,
+            _subscription,
+        }
+    }
+
+    fn build_menu(
+        project: Entity<Project>,
+        workspace: WeakEntity<Workspace>,
+        initial_active_worktree_id: Option<WorktreeId>,
+        menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
+        worktree_ids: Rc<RefCell<Vec<WorktreeId>>>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Entity<ContextMenu> {
+        ContextMenu::build_persistent(window, cx, move |menu, _window, cx| {
+            let active_worktree_id = if menu_shell.borrow().is_some() {
+                workspace
+                    .upgrade()
+                    .and_then(|ws| ws.read(cx).active_worktree_override())
+                    .or(initial_active_worktree_id)
+            } else {
+                initial_active_worktree_id
+            };
+
+            let entries = Self::get_project_entries(&project, active_worktree_id, cx);
+
+            // Update the worktree_ids list so we can map selected_index -> worktree_id.
+            {
+                let mut ids = worktree_ids.borrow_mut();
+                ids.clear();
+                for entry in &entries {
+                    ids.push(entry.worktree_id);
+                }
+            }
+
+            let mut menu = menu.header("Open Folders");
+
+            for entry in entries {
+                let worktree_id = entry.worktree_id;
+                let name = entry.name.clone();
+                let branch = entry.branch.clone();
+                let is_active = entry.is_active;
+
+                let workspace_for_select = workspace.clone();
+                let workspace_for_remove = workspace.clone();
+                let menu_shell_for_remove = menu_shell.clone();
+
+                let menu_focus_handle = menu.focus_handle(cx);
+
+                menu = menu.custom_entry(
+                    move |_window, _cx| {
+                        let name = name.clone();
+                        let branch = branch.clone();
+                        let workspace_for_remove = workspace_for_remove.clone();
+                        let menu_shell = menu_shell_for_remove.clone();
+                        let menu_focus_handle = menu_focus_handle.clone();
+
+                        h_flex()
+                            .group(name.clone())
+                            .w_full()
+                            .justify_between()
+                            .child(
+                                h_flex()
+                                    .gap_1()
+                                    .child(
+                                        Label::new(name.clone())
+                                            .when(is_active, |label| label.color(Color::Accent)),
+                                    )
+                                    .when_some(branch, |this, branch| {
+                                        this.child(Label::new(branch).color(Color::Muted))
+                                    }),
+                            )
+                            .child(
+                                IconButton::new(
+                                    ("remove", worktree_id.to_usize()),
+                                    IconName::Close,
+                                )
+                                .visible_on_hover(name)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Muted)
+                                .tooltip(move |_, cx| {
+                                    Tooltip::for_action_in(
+                                        "Remove Folder",
+                                        &RemoveSelectedFolder,
+                                        &menu_focus_handle,
+                                        cx,
+                                    )
+                                })
+                                .on_click({
+                                    let workspace = workspace_for_remove;
+                                    move |_, window, cx| {
+                                        Self::handle_remove(
+                                            workspace.clone(),
+                                            worktree_id,
+                                            window,
+                                            cx,
+                                        );
+
+                                        if let Some(menu_entity) = menu_shell.borrow().clone() {
+                                            menu_entity.update(cx, |menu, cx| {
+                                                menu.rebuild(window, cx);
+                                            });
+                                        }
+                                    }
+                                }),
+                            )
+                            .into_any_element()
+                    },
+                    move |window, cx| {
+                        Self::handle_select(workspace_for_select.clone(), worktree_id, window, cx);
+                        window.dispatch_action(menu::Cancel.boxed_clone(), cx);
+                    },
+                );
+            }
+
+            menu.separator()
+                .action(
+                    "Add Folder to Workspace",
+                    workspace::AddFolderToProject.boxed_clone(),
+                )
+                .action(
+                    "Open Recent Projects",
+                    zed_actions::OpenRecent {
+                        create_new_window: false,
+                    }
+                    .boxed_clone(),
+                )
+        })
+    }
+
+    /// Get all projects sorted alphabetically with their branch info.
+    fn get_project_entries(
+        project: &Entity<Project>,
+        active_worktree_id: Option<WorktreeId>,
+        cx: &App,
+    ) -> Vec<ProjectEntry> {
+        let project = project.read(cx);
+        let git_store = project.git_store().read(cx);
+        let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
+
+        let mut entries: Vec<ProjectEntry> = project
+            .visible_worktrees(cx)
+            .map(|worktree| {
+                let worktree_ref = worktree.read(cx);
+                let worktree_id = worktree_ref.id();
+                let name = SharedString::from(worktree_ref.root_name().as_unix_str().to_string());
+
+                let branch = Self::get_branch_for_worktree(worktree_ref, &repositories, cx);
+
+                let is_active = active_worktree_id == Some(worktree_id);
+
+                ProjectEntry {
+                    worktree_id,
+                    name,
+                    branch,
+                    is_active,
+                }
+            })
+            .collect();
+
+        entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
+        entries
+    }
+
+    fn get_branch_for_worktree(
+        worktree: &Worktree,
+        repositories: &[Entity<Repository>],
+        cx: &App,
+    ) -> Option<SharedString> {
+        let worktree_abs_path = worktree.abs_path();
+
+        for repo in repositories {
+            let repo = repo.read(cx);
+            if repo.work_directory_abs_path == worktree_abs_path
+                || worktree_abs_path.starts_with(&*repo.work_directory_abs_path)
+            {
+                if let Some(branch) = &repo.branch {
+                    return Some(SharedString::from(branch.name().to_string()));
+                }
+            }
+        }
+        None
+    }
+
+    fn handle_select(
+        workspace: WeakEntity<Workspace>,
+        worktree_id: WorktreeId,
+        _window: &mut Window,
+        cx: &mut App,
+    ) {
+        if let Some(workspace) = workspace.upgrade() {
+            workspace.update(cx, |workspace, cx| {
+                workspace.set_active_worktree_override(Some(worktree_id), cx);
+            });
+        }
+    }
+
+    fn handle_remove(
+        workspace: WeakEntity<Workspace>,
+        worktree_id: WorktreeId,
+        _window: &mut Window,
+        cx: &mut App,
+    ) {
+        if let Some(workspace) = workspace.upgrade() {
+            workspace.update(cx, |workspace, cx| {
+                let project = workspace.project().clone();
+
+                let current_active_id = workspace.active_worktree_override();
+                let is_removing_active = current_active_id == Some(worktree_id);
+
+                if is_removing_active {
+                    let worktrees: Vec<_> = project.read(cx).visible_worktrees(cx).collect();
+
+                    let mut sorted: Vec<_> = worktrees
+                        .iter()
+                        .map(|wt| {
+                            let wt = wt.read(cx);
+                            (wt.root_name().as_unix_str().to_string(), wt.id())
+                        })
+                        .collect();
+                    sorted.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
+
+                    if let Some(idx) = sorted.iter().position(|(_, id)| *id == worktree_id) {
+                        let new_active_id = if idx > 0 {
+                            Some(sorted[idx - 1].1)
+                        } else if sorted.len() > 1 {
+                            Some(sorted[1].1)
+                        } else {
+                            None
+                        };
+
+                        workspace.set_active_worktree_override(new_active_id, cx);
+                    }
+                }
+
+                project.update(cx, |project, cx| {
+                    project.remove_worktree(worktree_id, cx);
+                });
+            });
+        }
+    }
+
+    fn remove_selected_folder(
+        &mut self,
+        _: &RemoveSelectedFolder,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let selected_index = self.menu.read(cx).selected_index();
+
+        if let Some(menu_index) = selected_index {
+            // Early return because the "Open Folders" header is index 0.
+            if menu_index == 0 {
+                return;
+            }
+
+            let entry_index = menu_index - 1;
+            let worktree_ids = self.worktree_ids.borrow();
+
+            if entry_index < worktree_ids.len() {
+                let worktree_id = worktree_ids[entry_index];
+                drop(worktree_ids);
+
+                Self::handle_remove(self.workspace.clone(), worktree_id, window, cx);
+
+                if let Some(menu_entity) = self.menu_shell.borrow().clone() {
+                    menu_entity.update(cx, |menu, cx| {
+                        menu.rebuild(window, cx);
+                    });
+                }
+            }
+        }
+    }
+}
+
+impl Render for ProjectDropdown {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        div()
+            .key_context("MultiProjectDropdown")
+            .track_focus(&self.focus_handle(cx))
+            .on_action(cx.listener(Self::remove_selected_folder))
+            .child(self.menu.clone())
+    }
+}
+
+impl EventEmitter<DismissEvent> for ProjectDropdown {}
+
+impl Focusable for ProjectDropdown {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.menu.focus_handle(cx)
+    }
+}

crates/title_bar/src/title_bar.rs 🔗

@@ -3,6 +3,7 @@ pub mod collab;
 mod onboarding_banner;
 pub mod platform_title_bar;
 mod platforms;
+mod project_dropdown;
 mod system_window_tabs;
 mod title_bar_settings;
 
@@ -25,16 +26,16 @@ use call::ActiveCall;
 use client::{Client, UserStore, zed_urls};
 use cloud_llm_client::{Plan, PlanV1, PlanV2};
 use gpui::{
-    Action, AnyElement, App, Context, Corner, Element, Entity, Focusable, InteractiveElement,
-    IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled,
-    Subscription, WeakEntity, Window, actions, div,
+    Action, AnyElement, App, Context, Corner, Element, Entity, FocusHandle, Focusable,
+    InteractiveElement, IntoElement, MouseButton, ParentElement, Render,
+    StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div,
 };
 use onboarding_banner::OnboardingBanner;
-use project::{
-    Project, WorktreeSettings, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees,
-};
+use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees};
+use project_dropdown::ProjectDropdown;
 use remote::RemoteConnectionOptions;
-use settings::{Settings, SettingsLocation};
+use settings::Settings;
+use settings::WorktreeId;
 use std::sync::Arc;
 use theme::ActiveTheme;
 use title_bar_settings::TitleBarSettings;
@@ -42,8 +43,8 @@ use ui::{
     Avatar, ButtonLike, Chip, ContextMenu, IconWithIndicator, Indicator, PopoverMenu,
     PopoverMenuHandle, TintColor, Tooltip, prelude::*,
 };
-use util::{ResultExt, rel_path::RelPath};
-use workspace::{ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt};
+use util::ResultExt;
+use workspace::{SwitchProject, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt};
 use zed_actions::OpenRemote;
 
 pub use onboarding_banner::restore_banner;
@@ -77,6 +78,17 @@ pub fn init(cx: &mut App) {
         let item = cx.new(|cx| TitleBar::new("title-bar", workspace, window, cx));
         workspace.set_titlebar_item(item.into(), window, cx);
 
+        workspace.register_action(|workspace, _: &SwitchProject, window, cx| {
+            if let Some(titlebar) = workspace
+                .titlebar_item()
+                .and_then(|item| item.downcast::<TitleBar>().ok())
+            {
+                titlebar.update(cx, |titlebar, cx| {
+                    titlebar.show_project_dropdown(window, cx);
+                });
+            }
+        });
+
         #[cfg(not(target_os = "macos"))]
         workspace.register_action(|workspace, action: &OpenApplicationMenu, window, cx| {
             if let Some(titlebar) = workspace
@@ -136,6 +148,7 @@ pub struct TitleBar {
     _subscriptions: Vec<Subscription>,
     banner: Entity<OnboardingBanner>,
     screen_share_popover_handle: PopoverMenuHandle<ContextMenu>,
+    project_dropdown_handle: PopoverMenuHandle<ProjectDropdown>,
 }
 
 impl Render for TitleBar {
@@ -170,7 +183,7 @@ impl Render for TitleBar {
                                         .child(self.render_project_name(cx))
                                 })
                                 .when(title_bar_settings.show_branch_name, |title_bar| {
-                                    title_bar.children(self.render_project_repo(cx))
+                                    title_bar.children(self.render_project_branch(cx))
                                 })
                         })
                 })
@@ -282,13 +295,27 @@ impl TitleBar {
                 cx.notify()
             }),
         );
-        subscriptions.push(cx.subscribe(&project, |_, _, _: &project::Event, cx| cx.notify()));
+        subscriptions.push(
+            cx.subscribe(&project, |this, _, event: &project::Event, cx| {
+                if let project::Event::BufferEdited = event {
+                    // Clear override when user types in any editor,
+                    // so the title bar reflects the project they're actually working in
+                    this.clear_active_worktree_override(cx);
+                    cx.notify();
+                }
+            }),
+        );
         subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx)));
         subscriptions.push(cx.observe_window_activation(window, Self::window_activation_changed));
         subscriptions.push(
-            cx.subscribe(&git_store, move |_, _, event, cx| match event {
-                GitStoreEvent::ActiveRepositoryChanged(_)
-                | GitStoreEvent::RepositoryUpdated(_, _, true) => {
+            cx.subscribe(&git_store, move |this, _, event, cx| match event {
+                GitStoreEvent::ActiveRepositoryChanged(_) => {
+                    // Clear override when focus-derived active repo changes
+                    // (meaning the user focused a file from a different project)
+                    this.clear_active_worktree_override(cx);
+                    cx.notify();
+                }
+                GitStoreEvent::RepositoryUpdated(_, _, true) => {
                     cx.notify();
                 }
                 _ => {}
@@ -326,28 +353,89 @@ impl TitleBar {
             _subscriptions: subscriptions,
             banner,
             screen_share_popover_handle: PopoverMenuHandle::default(),
+            project_dropdown_handle: PopoverMenuHandle::default(),
         }
     }
 
-    fn project_name(&self, cx: &Context<Self>) -> Option<SharedString> {
-        self.project
-            .read(cx)
-            .visible_worktrees(cx)
-            .map(|worktree| {
-                let worktree = worktree.read(cx);
-                let settings_location = SettingsLocation {
-                    worktree_id: worktree.id(),
-                    path: RelPath::empty(),
-                };
+    fn worktree_count(&self, cx: &App) -> usize {
+        self.project.read(cx).visible_worktrees(cx).count()
+    }
 
-                let settings = WorktreeSettings::get(Some(settings_location), cx);
-                let name = match &settings.project_name {
-                    Some(name) => name.as_str(),
-                    None => worktree.root_name_str(),
-                };
-                SharedString::new(name)
-            })
-            .next()
+    pub fn show_project_dropdown(&self, window: &mut Window, cx: &mut App) {
+        if self.worktree_count(cx) > 1 {
+            self.project_dropdown_handle.show(window, cx);
+        }
+    }
+
+    /// Returns the worktree to display in the title bar.
+    /// - If there's an override set on the workspace, use that (if still valid)
+    /// - Otherwise, derive from the active repository
+    /// - Fall back to the first visible worktree
+    pub fn effective_active_worktree(&self, cx: &App) -> Option<Entity<project::Worktree>> {
+        let project = self.project.read(cx);
+
+        if let Some(workspace) = self.workspace.upgrade() {
+            if let Some(override_id) = workspace.read(cx).active_worktree_override() {
+                if let Some(worktree) = project.worktree_for_id(override_id, cx) {
+                    return Some(worktree);
+                }
+            }
+        }
+
+        if let Some(repo) = project.active_repository(cx) {
+            let repo = repo.read(cx);
+            let repo_path = &repo.work_directory_abs_path;
+
+            for worktree in project.visible_worktrees(cx) {
+                let worktree_path = worktree.read(cx).abs_path();
+                if worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref()) {
+                    return Some(worktree);
+                }
+            }
+        }
+
+        project.visible_worktrees(cx).next()
+    }
+
+    pub fn set_active_worktree_override(
+        &mut self,
+        worktree_id: WorktreeId,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(workspace) = self.workspace.upgrade() {
+            workspace.update(cx, |workspace, cx| {
+                workspace.set_active_worktree_override(Some(worktree_id), cx);
+            });
+        }
+        cx.notify();
+    }
+
+    fn clear_active_worktree_override(&mut self, cx: &mut Context<Self>) {
+        if let Some(workspace) = self.workspace.upgrade() {
+            workspace.update(cx, |workspace, cx| {
+                workspace.clear_active_worktree_override(cx);
+            });
+        }
+        cx.notify();
+    }
+
+    fn get_repository_for_worktree(
+        &self,
+        worktree: &Entity<project::Worktree>,
+        cx: &App,
+    ) -> Option<Entity<project::git_store::Repository>> {
+        let project = self.project.read(cx);
+        let git_store = project.git_store().read(cx);
+        let worktree_path = worktree.read(cx).abs_path();
+
+        for repo in git_store.repositories().values() {
+            let repo_path = &repo.read(cx).work_directory_abs_path;
+            if worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref()) {
+                return Some(repo.clone());
+            }
+        }
+
+        None
     }
 
     fn render_remote_project_connection(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
@@ -545,10 +633,15 @@ impl TitleBar {
     pub fn render_project_name(&self, cx: &mut Context<Self>) -> impl IntoElement {
         let workspace = self.workspace.clone();
 
-        let name = self.project_name(cx);
+        let name = self.effective_active_worktree(cx).map(|worktree| {
+            let worktree = worktree.read(cx);
+            SharedString::from(worktree.root_name().as_unix_str().to_string())
+        });
+
         let is_project_selected = name.is_some();
-        let name = if let Some(name) = name {
-            util::truncate_and_trailoff(&name, MAX_PROJECT_NAME_LENGTH)
+
+        let display_name = if let Some(ref name) = name {
+            util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH)
         } else {
             "Open Recent Project".to_string()
         };
@@ -558,6 +651,24 @@ impl TitleBar {
             .map(|w| w.read(cx).focus_handle(cx))
             .unwrap_or_else(|| cx.focus_handle());
 
+        if self.worktree_count(cx) > 1 {
+            self.render_multi_project_menu(display_name, is_project_selected, cx)
+                .into_any_element()
+        } else {
+            self.render_single_project_menu(display_name, is_project_selected, focus_handle, cx)
+                .into_any_element()
+        }
+    }
+
+    fn render_single_project_menu(
+        &self,
+        name: String,
+        is_project_selected: bool,
+        focus_handle: FocusHandle,
+        _cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let workspace = self.workspace.clone();
+
         PopoverMenu::new("recent-projects-menu")
             .menu(move |window, cx| {
                 Some(recent_projects::RecentProjects::popover(
@@ -586,9 +697,58 @@ impl TitleBar {
             .anchor(gpui::Corner::TopLeft)
     }
 
-    pub fn render_project_repo(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
-        let repository = self.project.read(cx).active_repository(cx)?;
-        let repository_count = self.project.read(cx).repositories(cx).len();
+    fn render_multi_project_menu(
+        &self,
+        name: String,
+        is_project_selected: bool,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let project = self.project.clone();
+        let workspace = self.workspace.clone();
+        let initial_active_worktree_id = self
+            .effective_active_worktree(cx)
+            .map(|wt| wt.read(cx).id());
+
+        let focus_handle = workspace
+            .upgrade()
+            .map(|w| w.read(cx).focus_handle(cx))
+            .unwrap_or_else(|| cx.focus_handle());
+
+        PopoverMenu::new("project-dropdown-menu")
+            .with_handle(self.project_dropdown_handle.clone())
+            .menu(move |window, cx| {
+                let project = project.clone();
+                let workspace = workspace.clone();
+
+                Some(cx.new(|cx| {
+                    ProjectDropdown::new(
+                        project.clone(),
+                        workspace.clone(),
+                        initial_active_worktree_id,
+                        window,
+                        cx,
+                    )
+                }))
+            })
+            .trigger_with_tooltip(
+                Button::new("project_name_trigger", name)
+                    .label_size(LabelSize::Small)
+                    .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                    .icon(IconName::ChevronDown)
+                    .icon_position(IconPosition::End)
+                    .icon_size(IconSize::XSmall)
+                    .icon_color(Color::Muted)
+                    .when(!is_project_selected, |s| s.color(Color::Muted)),
+                move |_, cx| {
+                    Tooltip::for_action_in("Switch Project", &SwitchProject, &focus_handle, cx)
+                },
+            )
+            .anchor(gpui::Corner::TopLeft)
+    }
+
+    pub fn render_project_branch(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
+        let effective_worktree = self.effective_active_worktree(cx)?;
+        let repository = self.get_repository_for_worktree(&effective_worktree, cx)?;
         let workspace = self.workspace.upgrade()?;
 
         let (branch_name, icon_info) = {
@@ -608,22 +768,6 @@ impl TitleBar {
                     })
                 });
 
-            let branch_name = branch_name?;
-
-            let project_name = self.project_name(cx);
-            let repo_name = repo
-                .work_directory_abs_path
-                .file_name()
-                .and_then(|name| name.to_str())
-                .map(SharedString::new);
-            let show_repo_name =
-                repository_count > 1 && repo.branch.is_some() && repo_name != project_name;
-            let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) {
-                format!("{repo_name}/{branch_name}")
-            } else {
-                branch_name
-            };
-
             let status = repo.status_summary();
             let tracked = status.index + status.worktree;
             let icon_info = if status.conflict > 0 {
@@ -642,22 +786,22 @@ impl TitleBar {
         };
 
         let settings = TitleBarSettings::get_global(cx);
-        let project = self.project.clone();
+
+        let effective_repository = Some(repository);
 
         Some(
             PopoverMenu::new("branch-menu")
                 .menu(move |window, cx| {
-                    let repository = project.read(cx).active_repository(cx);
                     Some(git_ui::branch_picker::popover(
                         workspace.downgrade(),
                         true,
-                        repository,
+                        effective_repository.clone(),
                         window,
                         cx,
                     ))
                 })
                 .trigger_with_tooltip(
-                    Button::new("project_branch_trigger", branch_name)
+                    Button::new("project_branch_trigger", branch_name?)
                         .selected_style(ButtonStyle::Tinted(TintColor::Accent))
                         .label_size(LabelSize::Small)
                         .color(Color::Muted)

crates/ui/src/components/context_menu.rs 🔗

@@ -833,6 +833,10 @@ impl ContextMenu {
         self
     }
 
+    pub fn selected_index(&self) -> Option<usize> {
+        self.selected_index
+    }
+
     pub fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
         let Some(ix) = self.selected_index else {
             return;

crates/workspace/src/workspace.rs 🔗

@@ -213,6 +213,8 @@ actions!(
         ActivatePreviousWindow,
         /// Adds a folder to the current project.
         AddFolderToProject,
+        /// Opens the project switcher dropdown (only visible when multiple folders are open).
+        SwitchProject,
         /// Clears all notifications.
         ClearAllNotifications,
         /// Clears all navigation history, including forward/backward navigation, recently opened files, and recently closed tabs. **This action is irreversible**.
@@ -1181,6 +1183,7 @@ pub struct Workspace {
     bottom_dock: Entity<Dock>,
     right_dock: Entity<Dock>,
     panes: Vec<Entity<Pane>>,
+    active_worktree_override: Option<WorktreeId>,
     panes_by_item: HashMap<EntityId, WeakEntity<Pane>>,
     active_pane: Entity<Pane>,
     last_active_center_pane: Option<WeakEntity<Pane>>,
@@ -1588,6 +1591,7 @@ impl Workspace {
             modal_layer,
             toast_layer,
             titlebar_item: None,
+            active_worktree_override: None,
             notifications: Notifications::default(),
             suppressed_notifications: HashSet::default(),
             left_dock,
@@ -2364,6 +2368,27 @@ impl Workspace {
         self.titlebar_item.clone()
     }
 
+    /// Returns the worktree override set by the user (e.g., via the project dropdown).
+    /// When set, git-related operations should use this worktree instead of deriving
+    /// the active worktree from the focused file.
+    pub fn active_worktree_override(&self) -> Option<WorktreeId> {
+        self.active_worktree_override
+    }
+
+    pub fn set_active_worktree_override(
+        &mut self,
+        worktree_id: Option<WorktreeId>,
+        cx: &mut Context<Self>,
+    ) {
+        self.active_worktree_override = worktree_id;
+        cx.notify();
+    }
+
+    pub fn clear_active_worktree_override(&mut self, cx: &mut Context<Self>) {
+        self.active_worktree_override = None;
+        cx.notify();
+    }
+
     /// Call the given callback with a workspace whose project is local.
     ///
     /// If the given workspace has a local project, then it will be passed

crates/zed/src/zed.rs 🔗

@@ -4759,6 +4759,7 @@ mod tests {
                 "pane",
                 "panel",
                 "picker",
+                "project_dropdown",
                 "project_panel",
                 "project_search",
                 "project_symbols",