Cargo.lock 🔗
@@ -17269,7 +17269,6 @@ dependencies = [
"git_ui",
"gpui",
"http_client",
- "menu",
"notifications",
"platform_title_bar",
"pretty_assertions",
Danilo Leal created
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 | 1021 +++++++++++++++-----
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, 782 insertions(+), 991 deletions(-)
@@ -17269,7 +17269,6 @@ dependencies = [
"git_ui",
"gpui",
"http_client",
- "menu",
"notifications",
"platform_title_bar",
"pretty_assertions",
@@ -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",
- },
- },
]
@@ -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": {
@@ -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": {
@@ -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(
@@ -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<SharedString>,
+ is_active: bool,
+}
+
+#[derive(Clone, Debug)]
+enum ProjectPickerEntry {
+ Header(SharedString),
+ OpenFolder { index: usize, positions: Vec<usize> },
+ RecentProject(StringMatch),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ProjectPickerStyle {
+ Modal,
+ Popover,
+}
+
pub async fn get_recent_projects(
current_workspace_id: Option<WorkspaceId>,
limit: Option<usize>,
@@ -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<OpenFolderEntry> {
+ 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<OpenFolderEntry> = 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<Repository>],
+ cx: &App,
+) -> Option<SharedString> {
+ let worktree_abs_path = worktree.abs_path();
+ for repo in repositories {
+ let repo = repo.read(cx);
+ if repo.work_directory_abs_path == worktree_abs_path
+ || worktree_abs_path.starts_with(&*repo.work_directory_abs_path)
+ {
+ if let Some(branch) = &repo.branch {
+ return Some(SharedString::from(branch.name().to_string()));
+ }
+ }
+ }
+ None
+}
+
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<Self>,
+ ) -> 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>,
) -> 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<Workspace>,
) {
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<Self> {
- 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>,
+ ) {
+ 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<DismissEvent> for RecentProjects {}
@@ -417,46 +572,53 @@ impl Render for RecentProjects {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Workspace>,
+ open_folders: Vec<OpenFolderEntry>,
workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
- selected_match_index: usize,
- matches: Vec<StringMatch>,
+ filtered_entries: Vec<ProjectPickerEntry>,
+ 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<RemoteConnectionOptions>,
focus_handle: FocusHandle,
+ style: ProjectPickerStyle,
+ actions_menu_handle: PopoverMenuHandle<ContextMenu>,
}
impl RecentProjectsDelegate {
fn new(
workspace: WeakEntity<Workspace>,
create_new_window: bool,
- render_paths: bool,
focus_handle: FocusHandle,
+ open_folders: Vec<OpenFolderEntry>,
+ project_connection_options: Option<RemoteConnectionOptions>,
+ 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<DismissEvent> for RecentProjectsDelegate {}
impl PickerDelegate for RecentProjectsDelegate {
- type ListItem = ListItem;
+ type ListItem = AnyElement;
- fn placeholder_text(&self, window: &mut Window, _: &mut App) -> Arc<str> {
- 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<str> {
+ "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<Picker<Self>>,
) {
- self.selected_match_index = ix;
+ self.selected_index = ix;
+ }
+
+ fn can_select(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context<Picker<Self>>,
+ ) -> 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::<Vec<_>>();
- 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<Picker<Self>>) {
- 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::<MultiWorkspace>()
- {
- 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::<MultiWorkspace>()
+ {
+ 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::<MultiWorkspace>()
+ } 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::<MultiWorkspace>()
- } 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<Picker<Self>>) {}
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
- 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<Picker<Self>>,
) -> Option<Self::ListItem> {
- 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::<Vec<_>>()
+ .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<Picker<Self>>) -> Option<AnyElement> {
+ 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(),
@@ -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
@@ -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<SharedString>,
- is_active: bool,
-}
-
-pub struct ProjectDropdown {
- menu: Entity<ContextMenu>,
- workspace: WeakEntity<Workspace>,
- worktree_ids: Rc<RefCell<Vec<WorktreeId>>>,
- menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
- _recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>>,
- _subscription: Subscription,
-}
-
-impl ProjectDropdown {
- pub fn new(
- project: Entity<Project>,
- workspace: WeakEntity<Workspace>,
- initial_active_worktree_id: Option<WorktreeId>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Self {
- let menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>> = Rc::new(RefCell::new(None));
- let worktree_ids: Rc<RefCell<Vec<WorktreeId>>> = Rc::new(RefCell::new(Vec::new()));
- let recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>> =
- 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<Project>,
- workspace: WeakEntity<Workspace>,
- initial_active_worktree_id: Option<WorktreeId>,
- menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
- worktree_ids: Rc<RefCell<Vec<WorktreeId>>>,
- recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Entity<ContextMenu> {
- ContextMenu::build_persistent(window, cx, move |menu, window, cx| {
- let active_worktree_id = if menu_shell.borrow().is_some() {
- workspace
- .upgrade()
- .and_then(|ws| ws.read(cx).active_worktree_override())
- .or(initial_active_worktree_id)
- } else {
- initial_active_worktree_id
- };
-
- let entries = Self::get_project_entries(&project, active_worktree_id, cx);
-
- // Update the worktree_ids list so we can map selected_index -> worktree_id.
- {
- let mut ids = worktree_ids.borrow_mut();
- ids.clear();
- for entry in &entries {
- ids.push(entry.worktree_id);
- }
- }
-
- let mut menu = menu.header("Open Folders");
-
- for entry in entries {
- let worktree_id = entry.worktree_id;
- let name = entry.name.clone();
- let branch = entry.branch.clone();
- let is_active = entry.is_active;
-
- let workspace_for_select = workspace.clone();
- let workspace_for_remove = workspace.clone();
- let menu_shell_for_remove = menu_shell.clone();
-
- 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<RecentProjectEntry> = 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<Workspace>,
- menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
- recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>>,
- 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<Workspace>,
- paths: Vec<PathBuf>,
- 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::<MultiWorkspace>() 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<Project>,
- active_worktree_id: Option<WorktreeId>,
- cx: &App,
- ) -> Vec<ProjectEntry> {
- let project = project.read(cx);
- let git_store = project.git_store().read(cx);
- let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
-
- let mut entries: Vec<ProjectEntry> = project
- .visible_worktrees(cx)
- .map(|worktree| {
- let worktree_ref = worktree.read(cx);
- let worktree_id = worktree_ref.id();
- let name = SharedString::from(worktree_ref.root_name().as_unix_str().to_string());
-
- let branch = Self::get_branch_for_worktree(worktree_ref, &repositories, cx);
-
- let is_active = active_worktree_id == Some(worktree_id);
-
- ProjectEntry {
- worktree_id,
- name,
- branch,
- is_active,
- }
- })
- .collect();
-
- entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
- entries
- }
-
- fn get_branch_for_worktree(
- worktree: &Worktree,
- repositories: &[Entity<Repository>],
- cx: &App,
- ) -> Option<SharedString> {
- let worktree_abs_path = worktree.abs_path();
-
- for repo in repositories {
- let repo = repo.read(cx);
- if repo.work_directory_abs_path == worktree_abs_path
- || worktree_abs_path.starts_with(&*repo.work_directory_abs_path)
- {
- if let Some(branch) = &repo.branch {
- return Some(SharedString::from(branch.name().to_string()));
- }
- }
- }
- None
- }
-
- fn handle_select(
- workspace: WeakEntity<Workspace>,
- worktree_id: WorktreeId,
- _window: &mut Window,
- cx: &mut App,
- ) {
- if let Some(workspace) = workspace.upgrade() {
- workspace.update(cx, |workspace, cx| {
- workspace.set_active_worktree_override(Some(worktree_id), cx);
- });
- }
- }
-
- fn handle_remove(
- workspace: WeakEntity<Workspace>,
- worktree_id: WorktreeId,
- _window: &mut Window,
- cx: &mut App,
- ) {
- if let Some(workspace) = workspace.upgrade() {
- workspace.update(cx, |workspace, cx| {
- let project = workspace.project().clone();
-
- let current_active_id = workspace.active_worktree_override();
- let is_removing_active = current_active_id == Some(worktree_id);
-
- if is_removing_active {
- let worktrees: Vec<_> = project.read(cx).visible_worktrees(cx).collect();
-
- let mut sorted: Vec<_> = worktrees
- .iter()
- .map(|wt| {
- let wt = wt.read(cx);
- (wt.root_name().as_unix_str().to_string(), wt.id())
- })
- .collect();
- sorted.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
-
- if let Some(idx) = sorted.iter().position(|(_, id)| *id == worktree_id) {
- let new_active_id = if idx > 0 {
- Some(sorted[idx - 1].1)
- } else if sorted.len() > 1 {
- Some(sorted[1].1)
- } else {
- None
- };
-
- workspace.set_active_worktree_override(new_active_id, cx);
- }
- }
-
- project.update(cx, |project, cx| {
- project.remove_worktree(worktree_id, cx);
- });
- });
- }
- }
-
- fn remove_selected_folder(
- &mut self,
- _: &RemoveSelectedFolder,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- let selected_index = self.menu.read(cx).selected_index();
-
- if let Some(menu_index) = selected_index {
- // Early return because the "Open Folders" header is index 0.
- if menu_index == 0 {
- return;
- }
-
- let entry_index = menu_index - 1;
- let worktree_ids = self.worktree_ids.borrow();
-
- if entry_index < worktree_ids.len() {
- let worktree_id = worktree_ids[entry_index];
- drop(worktree_ids);
-
- Self::handle_remove(self.workspace.clone(), worktree_id, window, cx);
-
- if let Some(menu_entity) = self.menu_shell.borrow().clone() {
- menu_entity.update(cx, |menu, cx| {
- menu.rebuild(window, cx);
- });
- }
- }
- }
- }
-}
-
-impl Render for ProjectDropdown {
- fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- div()
- .key_context("MultiProjectDropdown")
- .track_focus(&self.focus_handle(cx))
- .on_action(cx.listener(Self::remove_selected_folder))
- .child(self.menu.clone())
- }
-}
-
-impl EventEmitter<DismissEvent> for ProjectDropdown {}
-
-impl Focusable for ProjectDropdown {
- fn focus_handle(&self, cx: &App) -> FocusHandle {
- self.menu.focus_handle(cx)
- }
-}
@@ -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::<TitleBar>().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<OnboardingBanner>,
update_version: Entity<UpdateVersion>,
screen_share_popover_handle: PopoverMenuHandle<ContextMenu>,
- project_dropdown_handle: PopoverMenuHandle<ProjectDropdown>,
}
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<Self>,
- ) -> 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<Self>,
- ) -> impl IntoElement {
- let project = self.project.clone();
- let workspace = self.workspace.clone();
- let initial_active_worktree_id = self
- .effective_active_worktree(cx)
- .map(|wt| wt.read(cx).id());
-
- let focus_handle = workspace
- .upgrade()
- .map(|w| w.read(cx).focus_handle(cx))
- .unwrap_or_else(|| cx.focus_handle());
-
- PopoverMenu::new("project-dropdown-menu")
- .with_handle(self.project_dropdown_handle.clone())
- .menu(move |window, cx| {
- let project = project.clone();
- let workspace = workspace.clone();
-
- Some(cx.new(|cx| {
- ProjectDropdown::new(
- project.clone(),
- workspace.clone(),
- initial_active_worktree_id,
- window,
- cx,
- )
- }))
- })
- .trigger_with_tooltip(
- Button::new("project_name_trigger", name)
- .label_size(LabelSize::Small)
- .selected_style(ButtonStyle::Tinted(TintColor::Accent))
- .icon(IconName::ChevronDown)
- .icon_position(IconPosition::End)
- .icon_size(IconSize::XSmall)
- .icon_color(Color::Muted)
- .when(!is_project_selected, |s| s.color(Color::Muted)),
- move |_, cx| {
- Tooltip::for_action_in("Switch Project", &SwitchProject, &focus_handle, cx)
- },
- )
- .anchor(gpui::Corner::TopLeft)
+ .into_any_element()
}
pub fn render_project_branch(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
@@ -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**.
@@ -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",