From c9003e1a12cb477da08e439725068ce2b0597486 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:48:51 -0300 Subject: [PATCH] workspace: Improve the multi-project UX (#46641) 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. --- 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(-) create mode 100644 crates/title_bar/src/project_dropdown.rs diff --git a/Cargo.lock b/Cargo.lock index 4d3673d6bc71cf04f09d17073bae86051dc31bcf..fb211271f995bcdff1121f9fef479c2cd3a1df8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16930,6 +16930,7 @@ dependencies = [ "git_ui", "gpui", "http_client", + "menu", "notifications", "pretty_assertions", "project", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 9e9647f2e88e57e44f6d8c78d09bc80e3a8e3f91..c319b7c6ae9f22fdc8d9ac266e01745115648214 100644 --- a/assets/keymaps/default-linux.json +++ b/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", + }, + }, ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index f8414539f389eda85987385152b20e4b3b197f50..8fe72ecbc6bb14bf3dcf800ca9e1c73ef60dff67 100644 --- a/assets/keymaps/default-macos.json +++ b/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", + }, + }, ] diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 34e9d9c631864e9e6737823e5f1aa7e0895fc24c..637bc66f0cf9dbcc357a30276bd3a015518eb4fb 100644 --- a/assets/keymaps/default-windows.json +++ b/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", + }, + }, ] diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 423431b2232ccd8cdd2ffadd921456d49623de4e..440dff997c7726b7e560ca33f4cc091785f524cb 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -61,7 +61,33 @@ pub fn open( cx: &mut Context, ) { 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( diff --git a/crates/git_ui/src/git_picker.rs b/crates/git_ui/src/git_picker.rs index 44fe62f5a02249005dc6518c7ec50b940faae972..ca899c239eb3befa893d0630ee56f11bbdaab3ca 100644 --- a/crates/git_ui/src/git_picker.rs +++ b/crates/git_ui/src/git_picker.rs @@ -549,7 +549,33 @@ fn open_with_tab( cx: &mut Context, ) { 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) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index b8ad24d4dfb50de56054726048f15f565b46ffe3..94d37e1aae3a11302431cd2435da2835ad2e9cfd 100644 --- a/crates/project/src/project.rs +++ b/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 => { diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index 101f804b086f891d00a23978fff35f3e49842232..a17395e33ff3b3e4794e2cf3cda48c9a104453bd 100644 --- a/crates/title_bar/Cargo.toml +++ b/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 diff --git a/crates/title_bar/src/project_dropdown.rs b/crates/title_bar/src/project_dropdown.rs new file mode 100644 index 0000000000000000000000000000000000000000..c946db957a07667f243ecd28c08868a400b6f38d --- /dev/null +++ b/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, + is_active: bool, +} + +pub struct ProjectDropdown { + menu: Entity, + workspace: WeakEntity, + worktree_ids: Rc>>, + menu_shell: Rc>>>, + _subscription: Subscription, +} + +impl ProjectDropdown { + pub fn new( + project: Entity, + workspace: WeakEntity, + initial_active_worktree_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let menu_shell: Rc>>> = Rc::new(RefCell::new(None)); + let worktree_ids: Rc>> = 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, + workspace: WeakEntity, + initial_active_worktree_id: Option, + menu_shell: Rc>>>, + worktree_ids: Rc>>, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + 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, + active_worktree_id: Option, + cx: &App, + ) -> Vec { + 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 = 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], + cx: &App, + ) -> Option { + 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, + 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, + 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, + ) { + 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) -> 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 for ProjectDropdown {} + +impl Focusable for ProjectDropdown { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.menu.focus_handle(cx) + } +} diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index ec70eff42114ed4ddf41b6ecd34dfb80abd21f9e..60edba4f3a182bf07f4b5f2235a2db8cbb536c76 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/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::().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, banner: Entity, screen_share_popover_handle: PopoverMenuHandle, + project_dropdown_handle: PopoverMenuHandle, } 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) -> Option { - 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> { + 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, + ) { + 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) { + 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, + cx: &App, + ) -> Option> { + 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) -> Option { @@ -545,10 +633,15 @@ impl TitleBar { pub fn render_project_name(&self, cx: &mut Context) -> 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, + ) -> 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) -> Option { - 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, + ) -> 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) -> Option { + 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) diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 7dae8c79a0f796a193b990b4107cd6eb4b43f91f..5e6b2222c54d39a08e9210d4ac196822a35f1d1c 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -833,6 +833,10 @@ impl ContextMenu { self } + pub fn selected_index(&self) -> Option { + self.selected_index + } + pub fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { let Some(ix) = self.selected_index else { return; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 492f0bf70bbf848da5d690d8a548b3dbf6eb62de..9bdb6403d48c2603ab71c6e47079928a6f8d29fe 100644 --- a/crates/workspace/src/workspace.rs +++ b/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, right_dock: Entity, panes: Vec>, + active_worktree_override: Option, panes_by_item: HashMap>, active_pane: Entity, last_active_center_pane: Option>, @@ -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 { + self.active_worktree_override + } + + pub fn set_active_worktree_override( + &mut self, + worktree_id: Option, + cx: &mut Context, + ) { + self.active_worktree_override = worktree_id; + cx.notify(); + } + + pub fn clear_active_worktree_override(&mut self, cx: &mut Context) { + 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 diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 7ebb2781ace473a4c3b212cdd3f55bf607f4cf9b..a7ec568894c3cb05187dd92436da615df2f25e74 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4759,6 +4759,7 @@ mod tests { "pane", "panel", "picker", + "project_dropdown", "project_panel", "project_search", "project_symbols",