Detailed changes
@@ -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)
@@ -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();
@@ -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?;
@@ -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,