From 34ae8bd8714cf89387bd2c3ef43545b508c78322 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 17 Apr 2026 17:19:06 -0700 Subject: [PATCH] Add list of open workspaces to the project group menu in the sidebar (#54207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a list of open workspaces to the project group dropdown menu, to make sure it's possible to understand what workspaces are open, might be running language servers, etc. It also allows you to close a specific workspace. Single folder project: Screenshot 2026-04-17 at 4 13 39 PM Multi folder project: Screenshot 2026-04-17 at 2 26 14 PM Release Notes: - N/A --- crates/sidebar/src/sidebar.rs | 218 +++++++++++++++++- crates/ui/src/components/context_menu.rs | 5 +- crates/workspace/src/multi_workspace.rs | 118 +++++++++- crates/workspace/src/multi_workspace_tests.rs | 92 ++++++++ 4 files changed, 425 insertions(+), 8 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 04a850941b87789723538869a2e5fa319de306bf..2597bd9546d5584bc6c10fe67a2adcbcbd7f9c63 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -325,6 +325,72 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } +#[derive(Clone)] +struct WorkspaceMenuWorktreeLabel { + icon: Option, + primary_name: SharedString, + secondary_name: Option, +} + +fn workspace_menu_worktree_labels( + workspace: &Entity, + cx: &App, +) -> Vec { + let root_paths = workspace.read(cx).root_paths(cx); + let show_folder_name = root_paths.len() > 1; + let project = workspace.read(cx).project().clone(); + let repository_snapshots: Vec<_> = project + .read(cx) + .repositories(cx) + .values() + .map(|repo| repo.read(cx).snapshot()) + .collect(); + + root_paths + .into_iter() + .map(|root_path| { + let root_path = root_path.as_ref(); + let folder_name = root_path + .file_name() + .map(|name| SharedString::from(name.to_string_lossy().to_string())) + .unwrap_or_default(); + let repository_snapshot = repository_snapshots + .iter() + .find(|snapshot| snapshot.work_directory_abs_path.as_ref() == root_path); + + if let Some(snapshot) = repository_snapshot + && snapshot.is_linked_worktree() + { + let worktree_name = project::linked_worktree_short_name( + snapshot.original_repo_abs_path.as_ref(), + root_path, + ) + .unwrap_or_else(|| folder_name.clone()); + + if show_folder_name { + WorkspaceMenuWorktreeLabel { + icon: Some(IconName::GitWorktree), + primary_name: folder_name, + secondary_name: Some(worktree_name), + } + } else { + WorkspaceMenuWorktreeLabel { + icon: Some(IconName::GitWorktree), + primary_name: worktree_name, + secondary_name: None, + } + } + } else { + WorkspaceMenuWorktreeLabel { + icon: None, + primary_name: folder_name, + secondary_name: None, + } + } + }) + .collect() +} + /// Shows a [`RemoteConnectionModal`] on the given workspace and establishes /// an SSH connection. Suitable for passing to /// [`MultiWorkspace::find_or_create_workspace`] as the `connect_remote` @@ -1658,8 +1724,31 @@ impl Sidebar { let project_group_key = project_group_key.clone(); let this_for_menu = this.clone(); + let open_workspaces = multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspaces_for_project_group(&project_group_key, cx) + .unwrap_or_default() + }) + .unwrap_or_default(); + + let active_workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .ok(); + let workspace_labels: Vec<_> = open_workspaces + .iter() + .map(|workspace| workspace_menu_worktree_labels(workspace, cx)) + .collect(); + let workspace_is_active: Vec<_> = open_workspaces + .iter() + .map(|workspace| active_workspace.as_ref() == Some(workspace)) + .collect(); + let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, menu_cx| { + let menu = menu.end_slot_action(Box::new(menu::SecondaryConfirm)); let weak_menu = menu_cx.weak_entity(); let menu = menu.when(show_multi_project_entries, |this| { @@ -1747,11 +1836,136 @@ impl Sidebar { ) .selectable(!is_active); + let menu = if open_workspaces.is_empty() { + menu + } else { + let mut menu = menu.separator().header("Open Workspaces"); + + for ( + workspace_index, + ((workspace, workspace_label), is_active_workspace), + ) in open_workspaces + .iter() + .cloned() + .zip(workspace_labels.iter().cloned()) + .zip(workspace_is_active.iter().copied()) + .enumerate() + { + let activate_multi_workspace = multi_workspace.clone(); + let close_multi_workspace = multi_workspace.clone(); + let activate_weak_menu = weak_menu.clone(); + let close_weak_menu = weak_menu.clone(); + let activate_workspace = workspace.clone(); + let close_workspace = workspace.clone(); + + menu = menu.custom_entry( + move |_window, _cx| { + let close_multi_workspace = close_multi_workspace.clone(); + let close_weak_menu = close_weak_menu.clone(); + let close_workspace = close_workspace.clone(); + let label_color = if is_active_workspace { + Color::Accent + } else { + Color::Muted + }; + let row_group_name = SharedString::from(format!( + "workspace-menu-row-{workspace_index}" + )); + + h_flex() + .w_full() + .group(&row_group_name) + .justify_between() + .gap_2() + .child(h_flex().min_w_0().gap_3().children( + workspace_label.iter().map(|label| { + h_flex() + .min_w_0() + .gap_0p5() + .when_some(label.icon, |this, icon| { + this.child( + Icon::new(icon) + .size(IconSize::XSmall) + .color(label_color), + ) + }) + .child( + Label::new(label.primary_name.clone()) + .color(label_color) + .truncate(), + ) + .when_some( + label.secondary_name.clone(), + |this, secondary_name| { + this.child( + Label::new(":") + .color(label_color), + ) + .child( + Label::new(secondary_name) + .color(label_color) + .truncate(), + ) + }, + ) + .into_any_element() + }), + )) + .child( + IconButton::new( + ("close-workspace", workspace_index), + IconName::Close, + ) + .shape(ui::IconButtonShape::Square) + .style(ButtonStyle::Subtle) + .visible_on_hover(&row_group_name) + .tooltip(Tooltip::text("Close Workspace")) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + window.prevent_default(); + close_multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace + .close_workspace( + &close_workspace, + window, + cx, + ) + .detach_and_log_err(cx); + }) + .ok(); + close_weak_menu + .update(cx, |_, cx| cx.emit(DismissEvent)) + .ok(); + }), + ) + .into_any_element() + }, + move |window, cx| { + activate_multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace.activate( + activate_workspace.clone(), + window, + cx, + ); + }) + .ok(); + activate_weak_menu + .update(cx, |_, cx| cx.emit(DismissEvent)) + .ok(); + }, + ); + } + + menu + }; + let project_group_key = project_group_key.clone(); - let multi_workspace = multi_workspace.clone(); + let remove_multi_workspace = multi_workspace.clone(); menu.separator() .entry("Remove Project", None, move |window, cx| { - multi_workspace + remove_multi_workspace .update(cx, |multi_workspace, cx| { multi_workspace .remove_project_group(&project_group_key, window, cx) diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index d9552552f4d948e9c25576410103d391de00043f..006892effc8676756a10988bfdbff9b60673810c 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -1,6 +1,6 @@ use crate::{ - IconButtonShape, KeyBinding, List, ListItem, ListSeparator, ListSubHeader, Tooltip, prelude::*, - utils::WithRemSize, + ButtonCommon, ButtonStyle, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, + ListSubHeader, Tooltip, prelude::*, utils::WithRemSize, }; use gpui::{ Action, AnyElement, App, Bounds, Corner, DismissEvent, Entity, EventEmitter, FocusHandle, @@ -1979,6 +1979,7 @@ impl ContextMenu { el.end_slot({ let icon_button = IconButton::new("end-slot-icon", *icon) .shape(IconButtonShape::Square) + .style(ButtonStyle::Subtle) .tooltip({ let action_context = self.action_context.clone(); let title = title.clone(); diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 204e2c45f7000264676d13758a7dff6cb88ec60e..508d8928ed52b4fc2582b95c2ed98af84e7dd620 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -849,6 +849,105 @@ impl MultiWorkspace { }) } + pub fn close_workspace( + &mut self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let group_key = workspace.read(cx).project_group_key(cx); + let excluded_workspace = workspace.clone(); + + self.remove( + [workspace.clone()], + move |this, window, cx| { + if let Some(workspace) = this + .workspaces_for_project_group(&group_key, cx) + .unwrap_or_default() + .into_iter() + .find(|candidate| candidate != &excluded_workspace) + { + return Task::ready(Ok(workspace)); + } + + let current_group_index = this + .project_groups + .iter() + .position(|group| group.key == group_key); + + if let Some(current_group_index) = current_group_index { + for distance in 1..this.project_groups.len() { + for neighboring_index in [ + current_group_index.checked_add(distance), + current_group_index.checked_sub(distance), + ] + .into_iter() + .flatten() + { + let Some(neighboring_group) = + this.project_groups.get(neighboring_index) + else { + continue; + }; + + if let Some(workspace) = this + .last_active_workspace_for_group(&neighboring_group.key, cx) + .or_else(|| { + this.workspaces_for_project_group(&neighboring_group.key, cx) + .unwrap_or_default() + .into_iter() + .find(|candidate| candidate != &excluded_workspace) + }) + { + return Task::ready(Ok(workspace)); + } + } + } + } + + let neighboring_group_key = current_group_index.and_then(|index| { + this.project_groups + .get(index + 1) + .or_else(|| { + index + .checked_sub(1) + .and_then(|previous| this.project_groups.get(previous)) + }) + .map(|group| group.key.clone()) + }); + + if let Some(neighboring_group_key) = neighboring_group_key { + return this.find_or_create_local_workspace( + neighboring_group_key.path_list().clone(), + Some(neighboring_group_key), + std::slice::from_ref(&excluded_workspace), + None, + OpenMode::Activate, + window, + cx, + ); + } + + let app_state = this.workspace().read(cx).app_state().clone(); + let project = Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + project::LocalProjectFlags::default(), + cx, + ); + let new_workspace = + cx.new(|cx| Workspace::new(None, project, app_state, window, cx)); + Task::ready(Ok(new_workspace)) + }, + window, + cx, + ) + } + pub fn remove_project_group( &mut self, group_key: &ProjectGroupKey, @@ -1286,6 +1385,17 @@ impl MultiWorkspace { fn detach_workspace(&mut self, workspace: &Entity, cx: &mut Context) { self.retained_workspaces .retain(|retained| retained != workspace); + for group in &mut self.project_groups { + if group + .last_active_workspace + .as_ref() + .and_then(WeakEntity::upgrade) + .as_ref() + == Some(workspace) + { + group.last_active_workspace = None; + } + } cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(workspace.entity_id())); workspace.update(cx, |workspace, _cx| { workspace.session_id.take(); @@ -1643,13 +1753,13 @@ impl MultiWorkspace { let fallback_task = removing_active.then(|| fallback_workspace(self, window, cx)); cx.spawn_in(window, async move |this, cx| { - // Prompt each workspace for unsaved changes. If any workspace - // has dirty buffers, save_all_internal will emit Activate to - // bring it into view before showing the save dialog. + // Run the standard workspace close lifecycle for every workspace + // being removed from this window. This handles save prompting and + // session cleanup consistently with other replace-in-window flows. for workspace in &workspaces { let should_continue = workspace .update_in(cx, |workspace, window, cx| { - workspace.save_all_internal(crate::SaveIntent::Close, window, cx) + workspace.prepare_to_close(CloseIntent::ReplaceWindow, window, cx) })? .await?; diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index dd86e210f9643a70acc360d8a0820c9964172f2a..89822383e6307eb23e370bbe9e0810af53584ba8 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -524,6 +524,98 @@ async fn test_find_or_create_local_workspace_reuses_active_workspace_after_sideb }); } +#[gpui::test] +async fn test_close_workspace_prefers_already_loaded_neighboring_workspace( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root_a", json!({ "file_a.txt": "" })).await; + fs.insert_tree("/root_b", json!({ "file_b.txt": "" })).await; + fs.insert_tree("/root_c", json!({ "file_c.txt": "" })).await; + let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await; + let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await; + let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx)); + let project_c = Project::test(fs, ["/root_c".as_ref()], cx).await; + let project_c_key = project_c.read_with(cx, |project, cx| project.project_group_key(cx)); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.open_sidebar(cx); + }); + cx.run_until_parked(); + + let workspace_a = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }); + let workspace_b = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b, window, cx) + }); + + multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.activate(workspace_a.clone(), window, cx); + multi_workspace.test_add_project_group(ProjectGroup { + key: project_c_key.clone(), + workspaces: Vec::new(), + expanded: true, + }); + }); + + multi_workspace.read_with(cx, |multi_workspace, _cx| { + let keys = multi_workspace.project_group_keys(); + assert_eq!( + keys.len(), + 3, + "expected three project groups in the test setup" + ); + assert_eq!(keys[0], project_b_key); + assert_eq!( + keys[1], + workspace_a.read_with(cx, |workspace, cx| { workspace.project_group_key(cx) }) + ); + assert_eq!(keys[2], project_c_key); + assert_eq!( + multi_workspace.workspace().entity_id(), + workspace_a.entity_id(), + "workspace A should be active before closing" + ); + }); + + let closed = multi_workspace + .update_in(cx, |multi_workspace, window, cx| { + multi_workspace.close_workspace(&workspace_a, window, cx) + }) + .await + .expect("closing the active workspace should succeed"); + + assert!( + closed, + "close_workspace should report that it removed a workspace" + ); + + multi_workspace.read_with(cx, |multi_workspace, cx| { + assert_eq!( + multi_workspace.workspace().entity_id(), + workspace_b.entity_id(), + "closing workspace A should activate the already-loaded workspace B instead of opening group C" + ); + assert_eq!( + multi_workspace.workspaces().count(), + 1, + "only workspace B should remain loaded after closing workspace A" + ); + assert!( + multi_workspace + .workspaces_for_project_group(&project_c_key, cx) + .unwrap_or_default() + .is_empty(), + "the unloaded neighboring group C should remain unopened" + ); + }); +} + #[gpui::test] async fn test_switching_projects_with_sidebar_closed_detaches_old_active_workspace( cx: &mut TestAppContext,