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",