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