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,