From 011113ea3c22ccba2c8340ba3a7eb64a1967e24d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 12 Feb 2026 09:54:33 -0300 Subject: [PATCH] workspace: Improve recent projects picker for multi-project scenario (#48989) Follow-up to https://github.com/zed-industries/zed/pull/46641. In the PR linked above, I had introduced a dropdown that'd show up in the title bar when the workspace contained more than one project. Although that helped improve the multi-project use case, it created some quirky designs: - The project dropdown and the recent project pickers looked too different from one another - The transition between the 2 project case to the 1 project scenario, from the dropdown, was not great, because you'd be then seeing the bigger recent projects picker - The `workspace: switch project` action was still reachable in the command palette even if you had one project in the workspace So, what this PR does is essentially fixing all of this by consolidating it all in the Recent Projects picker. If you are in a multi-project scenario, the picker will display a section with all of the projects on the workspace allowing you to activate each one of them. The picker also looks simpler when you reach it by clicking on the project name in the title bar, as opposed to through the keybinding. I've then removed the project dropdown code as well as the action, given we don't need them anymore due to the consolidation. Lastly, I tackled the inconsistent wording used between "Folders", "Projects", and "Workspaces". Here's the result: https://github.com/user-attachments/assets/9d8ef3e3-e57b-4558-9bc0-dcc401dec469 - [x] Code Reviewed - [x] Manual QA Release Notes: - Workspace: Improved the recent projects picker by making it also display active projects in case of a multi-project workspace. --- Cargo.lock | 1 - assets/keymaps/default-linux.json | 14 +- assets/keymaps/default-macos.json | 15 +- assets/keymaps/default-windows.json | 15 +- crates/project_panel/src/project_panel.rs | 4 +- crates/recent_projects/src/recent_projects.rs | 1086 ++++++++++++----- crates/title_bar/Cargo.toml | 1 - crates/title_bar/src/project_dropdown.rs | 592 --------- crates/title_bar/src/title_bar.rs | 106 +- crates/workspace/src/workspace.rs | 2 - crates/zed/src/zed.rs | 2 +- 11 files changed, 830 insertions(+), 1008 deletions(-) delete mode 100644 crates/title_bar/src/project_dropdown.rs diff --git a/Cargo.lock b/Cargo.lock index 1d1712b6f288945a9006a918e9b27052e4ff3fae..1ec098e6025514cee7b90ee7961880f972e59046 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17269,7 +17269,6 @@ dependencies = [ "git_ui", "gpui", "http_client", - "menu", "notifications", "platform_title_bar", "pretty_assertions", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index bf38edfeb85e07280d7ae817ad56067337c0f149..1be1de0230a74c29e96f44d54a4405dfa4c0b29d 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -577,7 +577,6 @@ // "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", @@ -1094,6 +1093,13 @@ "ctrl-l": "pane::SplitRight", }, }, + { + "context": "RecentProjects || (RecentProjects > Picker > Editor)", + "bindings": { + "ctrl-k": "recent_projects::ToggleActionsMenu", + "ctrl-shift-a": "workspace::AddFolderToProject", + }, + }, { "context": "TabSwitcher", "bindings": { @@ -1425,10 +1431,4 @@ "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 bca1e42d9ceaf96a6da3f6ceaca77ef21cc40ef3..2d121bce142c109af36480e6d11a455ce7fb848a 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -643,7 +643,6 @@ "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", @@ -1157,6 +1156,14 @@ "cmd-l": "pane::SplitRight", }, }, + { + "context": "RecentProjects || (RecentProjects > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "cmd-k": "recent_projects::ToggleActionsMenu", + "cmd-shift-a": "workspace::AddFolderToProject", + }, + }, { "context": "TabSwitcher", "use_key_equivalents": true, @@ -1495,12 +1502,6 @@ "cmd-3": "git_picker::ActivateStashTab", }, }, - { - "context": "MultiProjectDropdown", - "bindings": { - "shift-backspace": "project_dropdown::RemoveSelectedFolder", - }, - }, { "context": "NotebookEditor", "bindings": { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 0f117a75688e441de7b4dc98c80bf63a05238c7c..273e733b0cdef263ae5d2ee5d4004ac312f49f4b 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -576,7 +576,6 @@ // "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", @@ -1108,6 +1107,14 @@ "ctrl-l": "pane::SplitRight", }, }, + { + "context": "RecentProjects || (RecentProjects > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-k": "recent_projects::ToggleActionsMenu", + "ctrl-shift-a": "workspace::AddFolderToProject", + }, + }, { "context": "TabSwitcher", "use_key_equivalents": true, @@ -1417,12 +1424,6 @@ "alt-3": "git_picker::ActivateStashTab", }, }, - { - "context": "MultiProjectDropdown", - "bindings": { - "shift-backspace": "project_dropdown::RemoveSelectedFolder", - }, - }, { "context": "NotebookEditor", "bindings": { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index b1c07a3f94f1317dd5169b68072cc701c3fde548..255b0b0e6abcb31755d9e30bb00549329596f0e2 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1212,10 +1212,10 @@ impl ProjectPanel { .when(!is_collab && is_root, |menu| { menu.separator() .action( - "Add Folder to Project…", + "Add Project to Workspace…", Box::new(workspace::AddFolderToProject), ) - .action("Remove from Project", Box::new(RemoveFromProject)) + .action("Remove from Workspace", Box::new(RemoveFromProject)) }) .when(is_dir && !is_root, |menu| { menu.separator().action( diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index c0a22b43e37a55ac5a3380b1d5e903ea5b06b80e..0a0b2c4b79f465ed4331410186e35965613d498b 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -4,7 +4,10 @@ mod remote_connections; mod remote_servers; mod ssh_config; -use std::{path::PathBuf, sync::Arc}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use fs::Fs; @@ -19,18 +22,22 @@ use disconnected_overlay::DisconnectedOverlay; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - Subscription, Task, WeakEntity, Window, + Subscription, Task, WeakEntity, Window, actions, px, }; -use ordered_float::OrderedFloat; + use picker::{ Picker, PickerDelegate, highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths}, }; +use project::{Worktree, git_store::Repository}; pub use remote_connections::RemoteSettings; pub use remote_servers::RemoteServerProjects; -use settings::Settings; -use std::path::Path; -use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container}; +use settings::{Settings, WorktreeId}; + +use ui::{ + ContextMenu, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, PopoverMenu, + PopoverMenuHandle, TintColor, Tooltip, prelude::*, +}; use util::{ResultExt, paths::PathExt}; use workspace::{ HistoryManager, ModalView, MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation, @@ -39,6 +46,8 @@ use workspace::{ }; use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote}; +actions!(recent_projects, [ToggleActionsMenu]); + #[derive(Clone, Debug)] pub struct RecentProjectEntry { pub name: SharedString, @@ -47,6 +56,28 @@ pub struct RecentProjectEntry { pub workspace_id: WorkspaceId, } +#[derive(Clone, Debug)] +struct OpenFolderEntry { + worktree_id: WorktreeId, + name: SharedString, + path: PathBuf, + branch: Option, + is_active: bool, +} + +#[derive(Clone, Debug)] +enum ProjectPickerEntry { + Header(SharedString), + OpenFolder { index: usize, positions: Vec }, + RecentProject(StringMatch), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProjectPickerStyle { + Modal, + Popover, +} + pub async fn get_recent_projects( current_workspace_id: Option, limit: Option, @@ -104,6 +135,76 @@ pub async fn delete_recent_project(workspace_id: WorkspaceId) { let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await; } +fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec { + let project = workspace.project().read(cx); + let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect(); + + if visible_worktrees.len() <= 1 { + return Vec::new(); + } + + let active_worktree_id = workspace.active_worktree_override().or_else(|| { + 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.read(cx).id()); + } + } + } + project + .visible_worktrees(cx) + .next() + .map(|wt| wt.read(cx).id()) + }); + + let git_store = project.git_store().read(cx); + let repositories: Vec<_> = git_store.repositories().values().cloned().collect(); + + let mut entries: Vec = visible_worktrees + .into_iter() + .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 path = worktree_ref.abs_path().to_path_buf(); + let branch = get_branch_for_worktree(worktree_ref, &repositories, cx); + let is_active = active_worktree_id == Some(worktree_id); + OpenFolderEntry { + worktree_id, + name, + path, + 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 +} + pub fn init(cx: &mut App) { #[cfg(target_os = "windows")] cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| { @@ -325,7 +426,18 @@ pub struct RecentProjects { _subscription: Subscription, } -impl ModalView for RecentProjects {} +impl ModalView for RecentProjects { + fn on_before_dismiss( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> workspace::DismissDecision { + let submenu_focused = self.picker.update(cx, |picker, cx| { + picker.delegate.actions_menu_handle.is_focused(window, cx) + }); + workspace::DismissDecision::Dismiss(!submenu_focused) + } +} impl RecentProjects { fn new( @@ -336,13 +448,16 @@ impl RecentProjects { cx: &mut Context, ) -> Self { let picker = cx.new(|cx| { - // We want to use a list when we render paths, because the items can have different heights (multiple paths). - if delegate.render_paths { - Picker::list(delegate, window, cx) - } else { - Picker::uniform_list(delegate, window, cx) - } + Picker::list(delegate, window, cx) + .list_measure_all() + .show_scrollbar(true) + }); + + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, _| { + picker.delegate.focus_handle = picker_focus_handle; }); + let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent)); // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap // out workspace locations once the future runs to completion. @@ -377,9 +492,18 @@ impl RecentProjects { cx: &mut Context, ) { let weak = cx.entity().downgrade(); + let open_folders = get_open_folders(workspace, cx); + let project_connection_options = workspace.project().read(cx).remote_connection_options(cx); let fs = Some(workspace.app_state().fs.clone()); workspace.toggle_modal(window, cx, |window, cx| { - let delegate = RecentProjectsDelegate::new(weak, create_new_window, true, focus_handle); + let delegate = RecentProjectsDelegate::new( + weak, + create_new_window, + focus_handle, + open_folders, + project_connection_options, + ProjectPickerStyle::Modal, + ); Self::new(delegate, fs, 34., window, cx) }) @@ -392,17 +516,48 @@ impl RecentProjects { window: &mut Window, cx: &mut App, ) -> Entity { - let fs = workspace + let (open_folders, project_connection_options, fs) = workspace .upgrade() - .map(|ws| ws.read(cx).app_state().fs.clone()); + .map(|workspace| { + let workspace = workspace.read(cx); + ( + get_open_folders(workspace, cx), + workspace.project().read(cx).remote_connection_options(cx), + Some(workspace.app_state().fs.clone()), + ) + }) + .unwrap_or_else(|| (Vec::new(), None, None)); + cx.new(|cx| { - let delegate = - RecentProjectsDelegate::new(workspace, create_new_window, true, focus_handle); - let list = Self::new(delegate, fs, 34., window, cx); + let delegate = RecentProjectsDelegate::new( + workspace, + create_new_window, + focus_handle, + open_folders, + project_connection_options, + ProjectPickerStyle::Popover, + ); + let list = Self::new(delegate, fs, 20., window, cx); list.picker.focus_handle(cx).focus(window, cx); list }) } + + fn handle_toggle_open_menu( + &mut self, + _: &ToggleActionsMenu, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + let menu_handle = &picker.delegate.actions_menu_handle; + if menu_handle.is_deployed() { + menu_handle.hide(cx); + } else { + menu_handle.show(window, cx); + } + }); + } } impl EventEmitter for RecentProjects {} @@ -417,46 +572,53 @@ impl Render for RecentProjects { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .key_context("RecentProjects") + .on_action(cx.listener(Self::handle_toggle_open_menu)) .w(rems(self.rem_width)) .child(self.picker.clone()) - .on_mouse_down_out(cx.listener(|this, _, window, cx| { - this.picker.update(cx, |this, cx| { - this.cancel(&Default::default(), window, cx); - }) - })) } } pub struct RecentProjectsDelegate { workspace: WeakEntity, + open_folders: Vec, workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>, - selected_match_index: usize, - matches: Vec, + filtered_entries: Vec, + selected_index: usize, render_paths: bool, create_new_window: bool, // Flag to reset index when there is a new query vs not reset index when user delete an item reset_selected_match_index: bool, has_any_non_local_projects: bool, + project_connection_options: Option, focus_handle: FocusHandle, + style: ProjectPickerStyle, + actions_menu_handle: PopoverMenuHandle, } impl RecentProjectsDelegate { fn new( workspace: WeakEntity, create_new_window: bool, - render_paths: bool, focus_handle: FocusHandle, + open_folders: Vec, + project_connection_options: Option, + style: ProjectPickerStyle, ) -> Self { + let render_paths = style == ProjectPickerStyle::Modal; Self { workspace, + open_folders, workspaces: Vec::new(), - selected_match_index: 0, - matches: Default::default(), + filtered_entries: Vec::new(), + selected_index: 0, create_new_window, render_paths, reset_selected_match_index: true, - has_any_non_local_projects: false, + has_any_non_local_projects: project_connection_options.is_some(), + project_connection_options, focus_handle, + style, + actions_menu_handle: PopoverMenuHandle::default(), } } @@ -465,39 +627,28 @@ impl RecentProjectsDelegate { workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>, ) { self.workspaces = workspaces; - self.has_any_non_local_projects = !self + let has_non_local_recent = !self .workspaces .iter() .all(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local)); + self.has_any_non_local_projects = + self.project_connection_options.is_some() || has_non_local_recent; } } impl EventEmitter for RecentProjectsDelegate {} impl PickerDelegate for RecentProjectsDelegate { - type ListItem = ListItem; + type ListItem = AnyElement; - fn placeholder_text(&self, window: &mut Window, _: &mut App) -> Arc { - let (create_window, reuse_window) = if self.create_new_window { - ( - window.keystroke_text_for(&menu::Confirm), - window.keystroke_text_for(&menu::SecondaryConfirm), - ) - } else { - ( - window.keystroke_text_for(&menu::SecondaryConfirm), - window.keystroke_text_for(&menu::Confirm), - ) - }; - Arc::from(format!( - "{reuse_window} reuses this window, {create_window} opens a new one", - )) + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Search projects…".into() } fn match_count(&self) -> usize { - self.matches.len() + self.filtered_entries.len() } fn selected_index(&self) -> usize { - self.selected_match_index + self.selected_index } fn set_selected_index( @@ -506,7 +657,19 @@ impl PickerDelegate for RecentProjectsDelegate { _window: &mut Window, _cx: &mut Context>, ) { - self.selected_match_index = ix; + self.selected_index = ix; + } + + fn can_select( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) -> bool { + matches!( + self.filtered_entries.get(ix), + Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::RecentProject(_)) + ) } fn update_matches( @@ -517,11 +680,34 @@ impl PickerDelegate for RecentProjectsDelegate { ) -> gpui::Task<()> { let query = query.trim_start(); let smart_case = query.chars().any(|c| c.is_uppercase()); - let candidates = self + let is_empty_query = query.is_empty(); + + let folder_matches = if self.open_folders.is_empty() { + Vec::new() + } else { + let candidates: Vec<_> = self + .open_folders + .iter() + .enumerate() + .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref())) + .collect(); + + smol::block_on(fuzzy::match_strings( + &candidates, + query, + smart_case, + true, + 100, + &Default::default(), + cx.background_executor().clone(), + )) + }; + + let recent_candidates: Vec<_> = self .workspaces .iter() .enumerate() - .filter(|(_, (id, _, _))| !self.is_current_workspace(*id, cx)) + .filter(|(_, (id, _, paths))| self.is_valid_recent_candidate(*id, paths, cx)) .map(|(id, (_, _, paths))| { let combined_string = paths .ordered_paths() @@ -530,9 +716,10 @@ impl PickerDelegate for RecentProjectsDelegate { .join(""); StringMatchCandidate::new(id, &combined_string) }) - .collect::>(); - self.matches = smol::block_on(fuzzy::match_strings( - candidates.as_slice(), + .collect(); + + let mut recent_matches = smol::block_on(fuzzy::match_strings( + &recent_candidates, query, smart_case, true, @@ -540,21 +727,66 @@ impl PickerDelegate for RecentProjectsDelegate { &Default::default(), cx.background_executor().clone(), )); - self.matches.sort_unstable_by(|a, b| { + recent_matches.sort_unstable_by(|a, b| { b.score - .partial_cmp(&a.score) // Descending score + .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.candidate_id.cmp(&b.candidate_id)) // Ascending candidate_id for ties + .then_with(|| a.candidate_id.cmp(&b.candidate_id)) }); + let mut entries = Vec::new(); + + if !self.open_folders.is_empty() { + let matched_folders: Vec<_> = if is_empty_query { + (0..self.open_folders.len()) + .map(|i| (i, Vec::new())) + .collect() + } else { + folder_matches + .iter() + .map(|m| (m.candidate_id, m.positions.clone())) + .collect() + }; + + for (index, positions) in matched_folders { + entries.push(ProjectPickerEntry::OpenFolder { index, positions }); + } + } + + let has_recent_to_show = if is_empty_query { + !recent_candidates.is_empty() + } else { + !recent_matches.is_empty() + }; + + if has_recent_to_show { + entries.push(ProjectPickerEntry::Header("Recent Projects".into())); + + if is_empty_query { + for (id, (workspace_id, _, paths)) in self.workspaces.iter().enumerate() { + if self.is_valid_recent_candidate(*workspace_id, paths, cx) { + entries.push(ProjectPickerEntry::RecentProject(StringMatch { + candidate_id: id, + score: 0.0, + positions: Vec::new(), + string: String::new(), + })); + } + } + } else { + for m in recent_matches { + entries.push(ProjectPickerEntry::RecentProject(m)); + } + } + } + + self.filtered_entries = entries; + if self.reset_selected_match_index { - self.selected_match_index = self - .matches + self.selected_index = self + .filtered_entries .iter() - .enumerate() - .rev() - .max_by_key(|(_, m)| OrderedFloat(m.score)) - .map(|(ix, _)| ix) + .position(|e| !matches!(e, ProjectPickerEntry::Header(_))) .unwrap_or(0); } self.reset_selected_match_index = true; @@ -562,93 +794,109 @@ impl PickerDelegate for RecentProjectsDelegate { } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { - if let Some((selected_match, workspace)) = self - .matches - .get(self.selected_index()) - .zip(self.workspace.upgrade()) - { - let (candidate_workspace_id, candidate_workspace_location, candidate_workspace_paths) = - &self.workspaces[selected_match.candidate_id]; - let replace_current_window = if self.create_new_window { - secondary - } else { - !secondary - }; - workspace.update(cx, |workspace, cx| { - if workspace.database_id() == Some(*candidate_workspace_id) { + match self.filtered_entries.get(self.selected_index) { + Some(ProjectPickerEntry::OpenFolder { index, .. }) => { + let Some(folder) = self.open_folders.get(*index) else { return; + }; + let worktree_id = folder.worktree_id; + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.set_active_worktree_override(Some(worktree_id), cx); + }); } - match candidate_workspace_location.clone() { - SerializedWorkspaceLocation::Local => { - let paths = candidate_workspace_paths.paths().to_vec(); - if replace_current_window { - if let Some(handle) = - window.window_handle().downcast::() - { - cx.defer(move |cx| { - if let Some(task) = handle - .update(cx, |multi_workspace, window, cx| { - multi_workspace.open_project(paths, window, cx) - }) - .log_err() - { - task.detach_and_log_err(cx); - } - }); + cx.emit(DismissEvent); + } + Some(ProjectPickerEntry::RecentProject(selected_match)) => { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let Some(( + candidate_workspace_id, + candidate_workspace_location, + candidate_workspace_paths, + )) = self.workspaces.get(selected_match.candidate_id) + else { + return; + }; + + let replace_current_window = self.create_new_window == secondary; + let candidate_workspace_id = *candidate_workspace_id; + let candidate_workspace_location = candidate_workspace_location.clone(); + let candidate_workspace_paths = candidate_workspace_paths.clone(); + + workspace.update(cx, |workspace, cx| { + if workspace.database_id() == Some(candidate_workspace_id) { + return; + } + match candidate_workspace_location { + SerializedWorkspaceLocation::Local => { + let paths = candidate_workspace_paths.paths().to_vec(); + if replace_current_window { + if let Some(handle) = + window.window_handle().downcast::() + { + cx.defer(move |cx| { + if let Some(task) = handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.open_project(paths, window, cx) + }) + .log_err() + { + task.detach_and_log_err(cx); + } + }); + } + return; + } else { + workspace.open_workspace_for_paths(false, paths, window, cx) } - return; - } else { - workspace.open_workspace_for_paths(false, paths, window, cx) + } + SerializedWorkspaceLocation::Remote(mut connection) => { + let app_state = workspace.app_state().clone(); + let replace_window = if replace_current_window { + window.window_handle().downcast::() + } else { + None + }; + let open_options = OpenOptions { + replace_window, + ..Default::default() + }; + if let RemoteConnectionOptions::Ssh(connection) = &mut connection { + RemoteSettings::get_global(cx) + .fill_connection_options_from_settings(connection); + }; + let paths = candidate_workspace_paths.paths().to_vec(); + cx.spawn_in(window, async move |_, cx| { + open_remote_project( + connection.clone(), + paths, + app_state, + open_options, + cx, + ) + .await + }) } } - SerializedWorkspaceLocation::Remote(mut connection) => { - let app_state = workspace.app_state().clone(); - - let replace_window = if replace_current_window { - window.window_handle().downcast::() - } else { - None - }; - - let open_options = OpenOptions { - replace_window, - ..Default::default() - }; - - if let RemoteConnectionOptions::Ssh(connection) = &mut connection { - RemoteSettings::get_global(cx) - .fill_connection_options_from_settings(connection); - }; - - let paths = candidate_workspace_paths.paths().to_vec(); - - cx.spawn_in(window, async move |_, cx| { - open_remote_project( - connection.clone(), - paths, - app_state, - open_options, - cx, - ) - .await - }) - } - } - .detach_and_prompt_err( - "Failed to open project", - window, - cx, - |_, _, _| None, - ); - }); - cx.emit(DismissEvent); + .detach_and_prompt_err( + "Failed to open project", + window, + cx, + |_, _, _| None, + ); + }); + cx.emit(DismissEvent); + } + _ => {} } } fn dismissed(&mut self, _window: &mut Window, _: &mut Context>) {} fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { - let text = if self.workspaces.is_empty() { + let text = if self.workspaces.is_empty() && self.open_folders.is_empty() { "Recently opened projects will show up here".into() } else { "No matches".into() @@ -663,160 +911,381 @@ impl PickerDelegate for RecentProjectsDelegate { window: &mut Window, cx: &mut Context>, ) -> Option { - let hit = self.matches.get(ix)?; - - let (_, location, paths) = self.workspaces.get(hit.candidate_id)?; - - let mut path_start_offset = 0; - - let (match_labels, paths): (Vec<_>, Vec<_>) = paths - .ordered_paths() - .map(|p| p.compact()) - .map(|path| { - let highlighted_text = - highlights_for_path(path.as_ref(), &hit.positions, path_start_offset); - path_start_offset += highlighted_text.1.text.len(); - highlighted_text - }) - .unzip(); - - let prefix = match &location { - SerializedWorkspaceLocation::Remote(options) => { - Some(SharedString::from(options.display_name())) + match self.filtered_entries.get(ix)? { + ProjectPickerEntry::Header(title) => Some( + v_flex() + .w_full() + .gap_1() + .when(ix > 0, |this| this.mt_1().child(Divider::horizontal())) + .child(ListSubHeader::new(title.clone()).inset(true)) + .into_any_element(), + ), + ProjectPickerEntry::OpenFolder { index, positions } => { + let folder = self.open_folders.get(*index)?; + let name = folder.name.clone(); + let path = folder.path.compact(); + let branch = folder.branch.clone(); + let is_active = folder.is_active; + let worktree_id = folder.worktree_id; + let positions = positions.clone(); + let show_path = self.style == ProjectPickerStyle::Modal; + + let secondary_actions = h_flex() + .gap_1() + .child( + IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Remove Folder from Workspace")) + .on_click(cx.listener(move |picker, _, window, cx| { + let Some(workspace) = picker.delegate.workspace.upgrade() else { + return; + }; + workspace.update(cx, |workspace, cx| { + let project = workspace.project().clone(); + project.update(cx, |project, cx| { + project.remove_worktree(worktree_id, cx); + }); + }); + picker.delegate.open_folders = + get_open_folders(workspace.read(cx), cx); + let query = picker.query(cx); + picker.update_matches(query, window, cx); + })), + ) + .into_any_element(); + + let icon = icon_for_remote_connection(self.project_connection_options.as_ref()); + + Some( + ListItem::new(ix) + .toggle_state(selected) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .child( + h_flex() + .id("open_folder_item") + .gap_3() + .flex_grow() + .when(self.has_any_non_local_projects, |this| { + this.child(Icon::new(icon).color(Color::Muted)) + }) + .child( + v_flex() + .child( + h_flex() + .gap_1() + .child({ + let highlighted = HighlightedMatch { + text: name.to_string(), + highlight_positions: positions, + color: Color::Default, + }; + highlighted.render(window, cx) + }) + .when_some(branch, |this, branch| { + this.child( + Label::new(branch).color(Color::Muted), + ) + }) + .when(is_active, |this| { + this.child( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Accent), + ) + }), + ) + .when(show_path, |this| { + this.child( + Label::new(path.to_string_lossy().to_string()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + .when(!show_path, |this| { + this.tooltip(Tooltip::text(path.to_string_lossy().to_string())) + }), + ) + .map(|el| { + if self.selected_index == ix { + el.end_slot(secondary_actions) + } else { + el.end_hover_slot(secondary_actions) + } + }) + .into_any_element(), + ) } - _ => None, - }; + ProjectPickerEntry::RecentProject(hit) => { + let popover_style = matches!(self.style, ProjectPickerStyle::Popover); + let (_, location, paths) = self.workspaces.get(hit.candidate_id)?; + let tooltip_path: SharedString = paths + .ordered_paths() + .map(|p| p.compact().to_string_lossy().to_string()) + .collect::>() + .join("\n") + .into(); - let highlighted_match = HighlightedMatchWithPaths { - prefix, - match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "), - paths, - }; + let mut path_start_offset = 0; + let (match_labels, paths): (Vec<_>, Vec<_>) = paths + .ordered_paths() + .map(|p| p.compact()) + .map(|path| { + let highlighted_text = + highlights_for_path(path.as_ref(), &hit.positions, path_start_offset); + path_start_offset += highlighted_text.1.text.len(); + highlighted_text + }) + .unzip(); - let focus_handle = self.focus_handle.clone(); + let prefix = match &location { + SerializedWorkspaceLocation::Remote(options) => { + Some(SharedString::from(options.display_name())) + } + _ => None, + }; - let secondary_actions = h_flex() - .gap_px() - .child( - IconButton::new("open_new_window", IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .tooltip({ - move |_, cx| { - Tooltip::for_action_in( - "Open Project in New Window", - &menu::SecondaryConfirm, - &focus_handle, - cx, - ) - } + let highlighted_match = HighlightedMatchWithPaths { + prefix, + match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "), + paths, + }; + + let focus_handle = self.focus_handle.clone(); + + let secondary_actions = h_flex() + .gap_px() + .when(popover_style, |this| { + this.child( + IconButton::new("open_new_window", IconName::ArrowUpRight) + .icon_size(IconSize::XSmall) + .tooltip({ + move |_, cx| { + Tooltip::for_action_in( + "Open Project in New Window", + &menu::SecondaryConfirm, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener(move |this, _event, window, cx| { + cx.stop_propagation(); + window.prevent_default(); + this.delegate.set_selected_index(ix, window, cx); + this.delegate.confirm(true, window, cx); + })), + ) }) - .on_click(cx.listener(move |this, _event, window, cx| { - cx.stop_propagation(); - window.prevent_default(); - this.delegate.set_selected_index(ix, window, cx); - this.delegate.confirm(true, window, cx); - })), - ) - .child( - IconButton::new("delete", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Delete from Recent Projects")) - .on_click(cx.listener(move |this, _event, window, cx| { - cx.stop_propagation(); - window.prevent_default(); - - this.delegate.delete_recent_project(ix, window, cx) - })), - ) - .into_any_element(); + .child( + IconButton::new("delete", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Delete from Recent Projects")) + .on_click(cx.listener(move |this, _event, window, cx| { + cx.stop_propagation(); + window.prevent_default(); + this.delegate.delete_recent_project(ix, window, cx) + })), + ) + .into_any_element(); - Some( - ListItem::new(ix) - .toggle_state(selected) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .child( - h_flex() - .id("projecy_info_container") - .gap_3() - .flex_grow() - .when(self.has_any_non_local_projects, |this| { - this.child(match location { - SerializedWorkspaceLocation::Local => Icon::new(IconName::Screen) - .color(Color::Muted) - .into_any_element(), - SerializedWorkspaceLocation::Remote(options) => { - Icon::new(match options { - RemoteConnectionOptions::Ssh { .. } => IconName::Server, - RemoteConnectionOptions::Wsl { .. } => IconName::Linux, - RemoteConnectionOptions::Docker(_) => IconName::Box, - #[cfg(any(test, feature = "test-support"))] - RemoteConnectionOptions::Mock(_) => IconName::Server, - }) - .color(Color::Muted) - .into_any_element() - } - }) - }) - .child({ - let mut highlighted = highlighted_match.clone(); - if !self.render_paths { - highlighted.paths.clear(); + let icon = icon_for_remote_connection(match location { + SerializedWorkspaceLocation::Local => None, + SerializedWorkspaceLocation::Remote(options) => Some(options), + }); + + Some( + ListItem::new(ix) + .toggle_state(selected) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .child( + h_flex() + .id("project_info_container") + .gap_3() + .flex_grow() + .when(self.has_any_non_local_projects, |this| { + this.child(Icon::new(icon).color(Color::Muted)) + }) + .child({ + let mut highlighted = highlighted_match; + if !self.render_paths { + highlighted.paths.clear(); + } + highlighted.render(window, cx) + }) + .tooltip(Tooltip::text(tooltip_path)), + ) + .map(|el| { + if self.selected_index == ix { + el.end_slot(secondary_actions) + } else { + el.end_hover_slot(secondary_actions) } - highlighted.render(window, cx) }) - .tooltip(move |_, cx| { - let tooltip_highlighted_location = highlighted_match.clone(); - cx.new(|_| MatchTooltip { - highlighted_location: tooltip_highlighted_location, - }) - .into() - }), + .into_any_element(), ) - .map(|el| { - if self.selected_index() == ix { - el.end_slot(secondary_actions) - } else { - el.end_hover_slot(secondary_actions) - } - }), - ) + } + } } fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + let focus_handle = self.focus_handle.clone(); + let popover_style = matches!(self.style, ProjectPickerStyle::Popover); + let open_folder_section = matches!( + self.filtered_entries.get(self.selected_index)?, + ProjectPickerEntry::OpenFolder { .. } + ); + + if popover_style { + return Some( + v_flex() + .flex_1() + .p_1p5() + .gap_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Button::new("add_folder", "Add Project to Workspace") + .key_binding(KeyBinding::for_action_in( + &workspace::AddFolderToProject, + &focus_handle, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action( + workspace::AddFolderToProject.boxed_clone(), + cx, + ) + }), + ) + .child( + Button::new("open_local_folder", "Open Local Project") + .key_binding(KeyBinding::for_action_in( + &workspace::Open, + &focus_handle, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action(workspace::Open.boxed_clone(), cx) + }), + ) + .child( + Button::new("open_remote_folder", "Open Remote Project") + .key_binding(KeyBinding::for_action( + &OpenRemote { + from_existing_connection: false, + create_new_window: false, + }, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action( + OpenRemote { + from_existing_connection: false, + create_new_window: false, + } + .boxed_clone(), + cx, + ) + }), + ) + .into_any(), + ); + } + Some( h_flex() - .w_full() - .p_2() - .gap_2() + .flex_1() + .p_1p5() + .gap_1() .justify_end() .border_t_1() .border_color(cx.theme().colors().border_variant) + .map(|this| { + if open_folder_section { + this.child( + Button::new("activate", "Activate") + .key_binding(KeyBinding::for_action_in( + &menu::Confirm, + &focus_handle, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), + ) + } else { + this.child( + Button::new("open_new_window", "New Window") + .key_binding(KeyBinding::for_action_in( + &menu::SecondaryConfirm, + &focus_handle, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) + }), + ) + .child( + Button::new("open_here", "Open") + .key_binding(KeyBinding::for_action_in( + &menu::Confirm, + &focus_handle, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), + ) + } + }) + .child(Divider::vertical()) .child( - Button::new("remote", "Open Remote Folder") - .key_binding(KeyBinding::for_action( - &OpenRemote { - from_existing_connection: false, - create_new_window: false, - }, - cx, - )) - .on_click(|_, window, cx| { - window.dispatch_action( - OpenRemote { - from_existing_connection: false, - create_new_window: false, - } - .boxed_clone(), - cx, - ) - }), - ) - .child( - Button::new("local", "Open Local Folder") - .key_binding(KeyBinding::for_action(&workspace::Open, cx)) - .on_click(|_, window, cx| { - window.dispatch_action(workspace::Open.boxed_clone(), cx) + PopoverMenu::new("actions-menu-popover") + .with_handle(self.actions_menu_handle.clone()) + .anchor(gpui::Corner::BottomRight) + .offset(gpui::Point { + x: px(0.0), + y: px(-2.0), + }) + .trigger( + Button::new("actions-trigger", "Actions…") + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .key_binding(KeyBinding::for_action_in( + &ToggleActionsMenu, + &focus_handle, + cx, + )), + ) + .menu({ + let focus_handle = focus_handle.clone(); + + move |window, cx| { + Some(ContextMenu::build(window, cx, { + let focus_handle = focus_handle.clone(); + move |menu, _, _| { + menu.context(focus_handle) + .action( + "Open Local Project", + workspace::Open.boxed_clone(), + ) + .action( + "Open Remote Project", + OpenRemote { + from_existing_connection: false, + create_new_window: false, + } + .boxed_clone(), + ) + .action( + "Add Project to Workspace", + workspace::AddFolderToProject.boxed_clone(), + ) + } + })) + } }), ) .into_any(), @@ -824,6 +1293,19 @@ impl PickerDelegate for RecentProjectsDelegate { } } +fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName { + match options { + None => IconName::Screen, + Some(options) => match options { + RemoteConnectionOptions::Ssh(_) => IconName::Server, + RemoteConnectionOptions::Wsl(_) => IconName::Linux, + RemoteConnectionOptions::Docker(_) => IconName::Box, + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(_) => IconName::Server, + }, + } +} + // Compute the highlighted text for the name and path fn highlights_for_path( path: &Path, @@ -878,8 +1360,11 @@ impl RecentProjectsDelegate { window: &mut Window, cx: &mut Context>, ) { - if let Some(selected_match) = self.matches.get(ix) { - let (workspace_id, _, _) = self.workspaces[selected_match.candidate_id]; + if let Some(ProjectPickerEntry::RecentProject(selected_match)) = + self.filtered_entries.get(ix) + { + let (workspace_id, _, _) = &self.workspaces[selected_match.candidate_id]; + let workspace_id = *workspace_id; let fs = self .workspace .upgrade() @@ -928,16 +1413,30 @@ impl RecentProjectsDelegate { false } -} -struct MatchTooltip { - highlighted_location: HighlightedMatchWithPaths, -} -impl Render for MatchTooltip { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(cx, |div, _| { - self.highlighted_location.render_paths_children(div) - }) + fn is_open_folder(&self, paths: &PathList) -> bool { + if self.open_folders.is_empty() { + return false; + } + + for workspace_path in paths.paths() { + for open_folder in &self.open_folders { + if workspace_path == &open_folder.path { + return true; + } + } + } + + false + } + + fn is_valid_recent_candidate( + &self, + workspace_id: WorkspaceId, + paths: &PathList, + cx: &mut Context>, + ) -> bool { + !self.is_current_workspace(workspace_id, cx) && !self.is_open_folder(paths) } } @@ -1035,17 +1534,18 @@ mod tests { recent_projects_picker.update(cx, |picker, cx| { assert_eq!(picker.query(cx), ""); let delegate = &mut picker.delegate; - delegate.matches = vec![StringMatch { - candidate_id: 0, - score: 1.0, - positions: Vec::new(), - string: "fake candidate".to_string(), - }]; delegate.set_workspaces(vec![( WorkspaceId::default(), SerializedWorkspaceLocation::Local, PathList::new(&[path!("/test/path")]), )]); + delegate.filtered_entries = + vec![ProjectPickerEntry::RecentProject(StringMatch { + candidate_id: 0, + score: 1.0, + positions: Vec::new(), + string: "fake candidate".to_string(), + })]; }); }) .unwrap(); diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index 0c93afd63c30c019a016d2215cc252b3776945ab..a9988d498e463edb463175ec19867fa6624479e5 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -41,7 +41,6 @@ db.workspace = true feature_flags.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 deleted file mode 100644 index 1f4c6376c4fb6d3f366fdf8c4008c347004a763f..0000000000000000000000000000000000000000 --- a/crates/title_bar/src/project_dropdown.rs +++ /dev/null @@ -1,592 +0,0 @@ -use std::cell::RefCell; -use std::path::PathBuf; -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 recent_projects::{RecentProjectEntry, delete_recent_project, get_recent_projects}; -use settings::WorktreeId; -use ui::{ContextMenu, DocumentationAside, DocumentationSide, Tooltip, prelude::*}; -use util::ResultExt as _; -use workspace::{MultiWorkspace, Workspace}; - -actions!(project_dropdown, [RemoveSelectedFolder]); - -const RECENT_PROJECTS_INLINE_LIMIT: usize = 5; - -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>>>, - _recent_projects: 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 recent_projects: 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(), - recent_projects.clone(), - window, - cx, - ); - - *menu_shell.borrow_mut() = Some(menu.clone()); - - let _subscription = cx.subscribe(&menu, |_, _, _: &DismissEvent, cx| { - cx.emit(DismissEvent); - }); - - let recent_projects_for_fetch = recent_projects.clone(); - let menu_shell_for_fetch = menu_shell.clone(); - let workspace_for_fetch = workspace.clone(); - let fs = workspace - .upgrade() - .map(|ws| ws.read(cx).app_state().fs.clone()); - - cx.spawn_in(window, async move |_this, cx| { - let Some(fs) = fs else { return }; - let current_workspace_id = cx - .update(|_, cx| { - workspace_for_fetch - .upgrade() - .and_then(|ws| ws.read(cx).database_id()) - }) - .ok() - .flatten(); - - let projects = get_recent_projects(current_workspace_id, None, fs).await; - - cx.update(|window, cx| { - *recent_projects_for_fetch.borrow_mut() = projects; - - if let Some(menu_entity) = menu_shell_for_fetch.borrow().clone() { - menu_entity.update(cx, |menu, cx| { - menu.rebuild(window, cx); - }); - } - }) - .ok(); - }) - .detach(); - - Self { - menu, - workspace, - worktree_ids, - menu_shell, - _recent_projects: recent_projects, - _subscription, - } - } - - fn build_menu( - project: Entity, - workspace: WeakEntity, - initial_active_worktree_id: Option, - menu_shell: Rc>>>, - worktree_ids: Rc>>, - recent_projects: 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(); - - 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(); - - 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({ - let menu_shell = menu_shell.clone(); - move |window, cx| { - if let Some(menu_entity) = menu_shell.borrow().as_ref() { - let focus_handle = menu_entity.focus_handle(cx); - Tooltip::for_action_in( - "Remove Folder", - &RemoveSelectedFolder, - &focus_handle, - cx, - ) - } else { - Tooltip::text("Remove Folder")(window, 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 = menu.separator(); - - let recent = recent_projects.borrow(); - - if !recent.is_empty() { - menu = menu.header("Recent Projects"); - - let enter_hint = window.keystroke_text_for(&menu::Confirm); - let cmd_enter_hint = window.keystroke_text_for(&menu::SecondaryConfirm); - - let inline_count = recent.len().min(RECENT_PROJECTS_INLINE_LIMIT); - for entry in recent.iter().take(inline_count) { - menu = Self::add_recent_project_entry( - menu, - entry.clone(), - workspace.clone(), - menu_shell.clone(), - recent_projects.clone(), - &enter_hint, - &cmd_enter_hint, - ); - } - - if recent.len() > RECENT_PROJECTS_INLINE_LIMIT { - let remaining_projects: Vec = recent - .iter() - .skip(RECENT_PROJECTS_INLINE_LIMIT) - .cloned() - .collect(); - let workspace_for_submenu = workspace.clone(); - let menu_shell_for_submenu = menu_shell.clone(); - let recent_projects_for_submenu = recent_projects.clone(); - - menu = menu.submenu("View More…", move |submenu, window, _cx| { - let enter_hint = window.keystroke_text_for(&menu::Confirm); - let cmd_enter_hint = window.keystroke_text_for(&menu::SecondaryConfirm); - - let mut submenu = submenu; - for entry in &remaining_projects { - submenu = Self::add_recent_project_entry( - submenu, - entry.clone(), - workspace_for_submenu.clone(), - menu_shell_for_submenu.clone(), - recent_projects_for_submenu.clone(), - &enter_hint, - &cmd_enter_hint, - ); - } - submenu - }); - } - - menu = menu.separator(); - } - drop(recent); - - menu.action( - "Add Folder to Workspace", - workspace::AddFolderToProject.boxed_clone(), - ) - }) - } - - fn add_recent_project_entry( - menu: ContextMenu, - entry: RecentProjectEntry, - workspace: WeakEntity, - menu_shell: Rc>>>, - recent_projects: Rc>>, - enter_hint: &str, - cmd_enter_hint: &str, - ) -> ContextMenu { - let name = entry.name.clone(); - let full_path = entry.full_path.clone(); - let paths = entry.paths.clone(); - let workspace_id = entry.workspace_id; - - let element_id = format!("remove-recent-{}", full_path); - - let enter_hint = enter_hint.to_string(); - let cmd_enter_hint = cmd_enter_hint.to_string(); - let full_path_for_docs = full_path; - let docs_aside = DocumentationAside { - side: DocumentationSide::Right, - render: Rc::new(move |cx| { - v_flex() - .gap_1() - .child(Label::new(full_path_for_docs.clone()).size(LabelSize::Small)) - .child( - h_flex() - .pt_1() - .gap_1() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new(format!("{} reuses this window", enter_hint)) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new(format!("{} opens a new one", cmd_enter_hint)) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .into_any_element() - }), - }; - - menu.custom_entry_with_docs( - { - let menu_shell_for_delete = menu_shell; - let recent_projects_for_delete = recent_projects; - - move |_window, _cx| { - let name = name.clone(); - let menu_shell = menu_shell_for_delete.clone(); - let recent_projects = recent_projects_for_delete.clone(); - - h_flex() - .group(name.clone()) - .w_full() - .justify_between() - .child(Label::new(name.clone())) - .child( - IconButton::new(element_id.clone(), IconName::Close) - .visible_on_hover(name) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Remove from Recent Projects")) - .on_click({ - move |_, window, cx| { - let menu_shell = menu_shell.clone(); - let recent_projects = recent_projects.clone(); - - recent_projects - .borrow_mut() - .retain(|p| p.workspace_id != workspace_id); - - if let Some(menu_entity) = menu_shell.borrow().clone() { - menu_entity.update(cx, |menu, cx| { - menu.rebuild(window, cx); - }); - } - - cx.background_spawn(async move { - delete_recent_project(workspace_id).await; - }) - .detach(); - } - }), - ) - .into_any_element() - } - }, - move |window, cx| { - let create_new_window = window.modifiers().platform; - Self::open_recent_project( - workspace.clone(), - paths.clone(), - create_new_window, - window, - cx, - ); - window.dispatch_action(menu::Cancel.boxed_clone(), cx); - }, - Some(docs_aside), - ) - } - - fn open_recent_project( - workspace: WeakEntity, - paths: Vec, - create_new_window: bool, - window: &mut Window, - cx: &mut App, - ) { - if create_new_window { - let Some(workspace) = workspace.upgrade() else { - return; - }; - workspace.update(cx, |workspace, cx| { - workspace - .open_workspace_for_paths(false, paths, window, cx) - .detach_and_log_err(cx); - }); - } else { - let Some(handle) = window.window_handle().downcast::() else { - return; - }; - - cx.defer(move |cx| { - if let Some(task) = handle - .update(cx, |multi_workspace, window, cx| { - multi_workspace.open_project(paths, window, cx) - }) - .log_err() - { - task.detach_and_log_err(cx); - } - }); - } - } - - /// 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 7d5cdd6f122040790004e3b31bb57903ec9e4a68..17925a51d5debf3ec194160d06e974c92dd0d32e 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -1,7 +1,6 @@ mod application_menu; pub mod collab; mod onboarding_banner; -mod project_dropdown; mod title_bar_settings; mod update_version; @@ -25,13 +24,12 @@ use client::{Client, UserStore, zed_urls}; use cloud_api_types::Plan; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ - Action, AnyElement, App, Context, Corner, Element, Entity, FocusHandle, Focusable, - InteractiveElement, IntoElement, MouseButton, ParentElement, Render, - StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div, + Action, AnyElement, App, Context, Corner, Element, Entity, Focusable, InteractiveElement, + IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, + Subscription, WeakEntity, Window, actions, div, }; use onboarding_banner::OnboardingBanner; use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees}; -use project_dropdown::ProjectDropdown; use remote::RemoteConnectionOptions; use settings::Settings; use settings::WorktreeId; @@ -45,7 +43,7 @@ use ui::{ use update_version::UpdateVersion; use util::ResultExt; use workspace::{ - MultiWorkspace, SwitchProject, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace, + MultiWorkspace, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt, }; use zed_actions::OpenRemote; @@ -94,19 +92,6 @@ pub fn init(cx: &mut App) { } }); - workspace.register_action(|workspace, _: &SwitchProject, window, cx| { - if let Some(titlebar) = workspace - .titlebar_item() - .and_then(|item| item.downcast::().ok()) - { - window.defer(cx, move |window, cx| { - 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 @@ -167,7 +152,6 @@ pub struct TitleBar { banner: Entity, update_version: Entity, screen_share_popover_handle: PopoverMenuHandle, - project_dropdown_handle: PopoverMenuHandle, } impl Render for TitleBar { @@ -418,7 +402,6 @@ impl TitleBar { banner, update_version, screen_share_popover_handle: PopoverMenuHandle::default(), - project_dropdown_handle: PopoverMenuHandle::default(), } } @@ -432,12 +415,6 @@ impl TitleBar { cx.notify(); } - 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 @@ -757,24 +734,6 @@ 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( @@ -786,8 +745,13 @@ impl TitleBar { )) }) .trigger_with_tooltip( - Button::new("project_name_trigger", name) + Button::new("project_name_trigger", display_name) .label_size(LabelSize::Small) + .when(self.worktree_count(cx) > 1, |this| { + this.icon(IconName::ChevronDown) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + }) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .when(!is_project_selected, |s| s.color(Color::Muted)), move |_window, cx| { @@ -801,55 +765,7 @@ impl TitleBar { }, ) .anchor(gpui::Corner::TopLeft) - } - - 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) + .into_any_element() } pub fn render_project_branch(&self, cx: &mut Context) -> Option { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 041e5fc96c320b9a169b0fbfe09fce80253d1aa3..ca79f6364a1f36475af115e5beefb18df7c394f0 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -222,8 +222,6 @@ 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**. diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 5327d5a8818bbcbe2290f5add9612d215cfc6890..6632959b9b84ab561e23aa5248776b0ca1521618 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4882,11 +4882,11 @@ mod tests { "pane", "panel", "picker", - "project_dropdown", "project_panel", "project_search", "project_symbols", "projects", + "recent_projects", "remote_debug", "repl", "rules_library",