Add list of open workspaces to the project group menu in the sidebar (#54207)

Max Brunsfeld created

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:
<img width="229" height="170" alt="Screenshot 2026-04-17 at 4 13 39 PM"
src="https://github.com/user-attachments/assets/86fca86a-73f1-4295-97b0-e97951102139"
/>

Multi folder project:
<img width="381" height="193" alt="Screenshot 2026-04-17 at 2 26 14 PM"
src="https://github.com/user-attachments/assets/b3c0c299-adf9-45d1-a314-feee8a993ea8"
/>

Release Notes:


- N/A

Change summary

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(-)

Detailed changes

crates/sidebar/src/sidebar.rs 🔗

@@ -325,6 +325,72 @@ fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
     PathList::new(&workspace.read(cx).root_paths(cx))
 }
 
+#[derive(Clone)]
+struct WorkspaceMenuWorktreeLabel {
+    icon: Option<IconName>,
+    primary_name: SharedString,
+    secondary_name: Option<SharedString>,
+}
+
+fn workspace_menu_worktree_labels(
+    workspace: &Entity<Workspace>,
+    cx: &App,
+) -> Vec<WorkspaceMenuWorktreeLabel> {
+    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)

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();

crates/workspace/src/multi_workspace.rs 🔗

@@ -849,6 +849,105 @@ impl MultiWorkspace {
         })
     }
 
+    pub fn close_workspace(
+        &mut self,
+        workspace: &Entity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<bool>> {
+        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<Workspace>, cx: &mut Context<Self>) {
         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?;
 

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,