From 1ebcde8164933e6a729fc5f76a23b7d2e7b7f422 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Sun, 5 Apr 2026 11:12:02 -0700 Subject: [PATCH] Update more sidebar interactions to use the MultiWorkspace's explicit project groups (#53174) * Don't require a workspace to be loaded in order to render the group header menu. * When adding or removing root folders, do it to *every* workspace in the group. * When activating a thread, never open a different window, and never open it in a workspace that's part of a different groupw with a superset of the thread's worktrees. Find or create a workspace with the exact right group of root folders. Release Notes: - N/A --- crates/project/src/project.rs | 13 ++ crates/project/src/worktree_store.rs | 15 ++ crates/sidebar/src/sidebar.rs | 184 +++++++----------- crates/util/src/path_list.rs | 10 + crates/workspace/src/multi_workspace.rs | 247 ++++++++++++++++++++++-- 5 files changed, 342 insertions(+), 127 deletions(-) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index c5b1f982ceacc59a60ff1303faffc972a3ce505d..0ec3366ca8f9f6c6e4e3cbd411e1894de4d0f2b8 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -4763,6 +4763,19 @@ impl Project { }); } + pub fn remove_worktree_for_main_worktree_path( + &mut self, + path: impl AsRef, + cx: &mut Context, + ) { + let path = path.as_ref(); + self.worktree_store.update(cx, |worktree_store, cx| { + if let Some(worktree) = worktree_store.worktree_for_main_worktree_path(path, cx) { + worktree_store.remove_worktree(worktree.read(cx).id(), cx); + } + }); + } + fn add_worktree(&mut self, worktree: &Entity, cx: &mut Context) { self.worktree_store.update(cx, |worktree_store, cx| { worktree_store.add(worktree, cx); diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index ca448ce53118fd23fec0dfc920ee67f5d6d19c41..7ca721ddb50c3f216ed630665e547b60ce4d52bf 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -850,6 +850,21 @@ impl WorktreeStore { self.send_project_updates(cx); } + pub fn worktree_for_main_worktree_path( + &self, + path: &Path, + cx: &App, + ) -> Option> { + self.visible_worktrees(cx).find(|worktree| { + let worktree = worktree.read(cx); + if let Some(common_dir) = worktree.root_repo_common_dir() { + common_dir.parent() == Some(path) + } else { + worktree.abs_path().as_ref() == path + } + }) + } + pub fn set_worktrees_reordered(&mut self, worktrees_reordered: bool) { self.worktrees_reordered = worktrees_reordered; } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 25a2b7ecb75ae11a551caa221609e8c5bfa1751e..53ae57d1a7c55f66e40e1d704859d689d41045e4 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -689,12 +689,9 @@ impl Sidebar { return; }; - let paths: Vec = - path_list.paths().iter().map(|p| p.to_path_buf()).collect(); - multi_workspace - .update(cx, |mw, cx| { - mw.open_project(paths, workspace::OpenMode::Activate, window, cx) + .update(cx, |this, cx| { + this.find_or_create_local_workspace(path_list.clone(), window, cx) }) .detach_and_log_err(cx); } @@ -1439,10 +1436,7 @@ impl Sidebar { }) }), ) - .child({ - let workspace_for_new_thread = workspace.clone(); - let path_list_for_new_thread = path_list.clone(); - + .child( h_flex() .when(self.project_header_menu_ix != Some(ix), |this| { this.visible_on_hover(group_name) @@ -1450,13 +1444,7 @@ impl Sidebar { .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) - .when_some(workspace, |this, workspace| { - this.child( - self.render_project_header_menu( - ix, id_prefix, &workspace, &workspace, cx, - ), - ) - }) + .child(self.render_project_header_menu(ix, id_prefix, key, cx)) .when(view_more_expanded && !is_collapsed, |this| { this.child( IconButton::new( @@ -1478,12 +1466,10 @@ impl Sidebar { })), ) }) - .when( - show_new_thread_button && workspace_for_new_thread.is_some(), - |this| { - let workspace_for_new_thread = - workspace_for_new_thread.clone().unwrap(); - let path_list_for_new_thread = path_list_for_new_thread.clone(); + .when_some( + workspace.filter(|_| show_new_thread_button), + |this, workspace| { + let path_list = path_list.clone(); this.child( IconButton::new( SharedString::from(format!( @@ -1495,26 +1481,22 @@ impl Sidebar { .tooltip(Tooltip::text("New Thread")) .on_click(cx.listener( move |this, _, window, cx| { - this.collapsed_groups.remove(&path_list_for_new_thread); + this.collapsed_groups.remove(&path_list); this.selection = None; - this.create_new_thread( - &workspace_for_new_thread, - window, - cx, - ); + this.create_new_thread(&workspace, window, cx); }, )), ) }, - ) - }) + ), + ) .when(!is_active, |this| { - let path_list_for_open = path_list.clone(); + let path_list = path_list.clone(); this.cursor_pointer() .hover(|s| s.bg(hover_color)) .tooltip(Tooltip::text("Open Workspace")) .on_click(cx.listener(move |this, _, window, cx| { - if let Some(workspace) = this.workspace_for_group(&path_list_for_open, cx) { + if let Some(workspace) = this.workspace_for_group(&path_list, cx) { this.active_entry = Some(ActiveEntry::Draft(workspace.clone())); if let Some(multi_workspace) = this.multi_workspace.upgrade() { multi_workspace.update(cx, |multi_workspace, cx| { @@ -1527,7 +1509,7 @@ impl Sidebar { }); } } else { - this.open_workspace_for_group(&path_list_for_open, window, cx); + this.open_workspace_for_group(&path_list, window, cx); } })) }) @@ -1538,14 +1520,12 @@ impl Sidebar { &self, ix: usize, id_prefix: &str, - workspace: &Entity, - workspace_for_remove: &Entity, + project_group_key: &ProjectGroupKey, cx: &mut Context, ) -> impl IntoElement { - let workspace_for_menu = workspace.clone(); - let workspace_for_remove = workspace_for_remove.clone(); let multi_workspace = self.multi_workspace.clone(); let this = cx.weak_entity(); + let project_group_key = project_group_key.clone(); PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}")) .on_open(Rc::new({ @@ -1559,116 +1539,102 @@ impl Sidebar { } })) .menu(move |window, cx| { - let workspace = workspace_for_menu.clone(); - let workspace_for_remove = workspace_for_remove.clone(); let multi_workspace = multi_workspace.clone(); + let project_group_key = project_group_key.clone(); let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, cx| { - let worktrees: Vec<_> = workspace - .read(cx) - .visible_worktrees(cx) - .map(|worktree| { - let worktree_read = worktree.read(cx); - let id = worktree_read.id(); - let name: SharedString = - worktree_read.root_name().as_unix_str().to_string().into(); - (id, name) - }) - .collect(); - - let worktree_count = worktrees.len(); - let mut menu = menu .header("Project Folders") .end_slot_action(Box::new(menu::EndSlot)); - for (worktree_id, name) in &worktrees { - let worktree_id = *worktree_id; - let workspace_for_worktree = workspace.clone(); - let workspace_for_remove_worktree = workspace_for_remove.clone(); - let multi_workspace_for_worktree = multi_workspace.clone(); - - let remove_handler = move |window: &mut Window, cx: &mut App| { - if worktree_count <= 1 { - if let Some(mw) = multi_workspace_for_worktree.upgrade() { - let ws = workspace_for_remove_worktree.clone(); - mw.update(cx, |multi_workspace, cx| { - multi_workspace.remove(&ws, window, cx); - }); - } - } else { - workspace_for_worktree.update(cx, |workspace, cx| { - workspace.project().update(cx, |project, cx| { - project.remove_worktree(worktree_id, cx); - }); - }); - } + for path in project_group_key.path_list().paths() { + let Some(name) = path.file_name() else { + continue; }; - + let name: SharedString = name.to_string_lossy().into_owned().into(); + let path = path.clone(); + let project_group_key = project_group_key.clone(); + let multi_workspace = multi_workspace.clone(); menu = menu.entry_with_end_slot_on_hover( name.clone(), None, |_, _| {}, IconName::Close, "Remove Folder".into(), - remove_handler, + move |_window, cx| { + multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace.remove_folder_from_project_group( + &project_group_key, + &path, + cx, + ); + }) + .ok(); + }, ); } - let workspace_for_add = workspace.clone(); - let multi_workspace_for_add = multi_workspace.clone(); let menu = menu.separator().entry( "Add Folder to Project", Some(Box::new(AddFolderToProject)), - move |window, cx| { - if let Some(mw) = multi_workspace_for_add.upgrade() { - mw.update(cx, |mw, cx| { - mw.activate(workspace_for_add.clone(), window, cx); - }); + { + let project_group_key = project_group_key.clone(); + let multi_workspace = multi_workspace.clone(); + move |window, cx| { + multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace.prompt_to_add_folders_to_project_group( + &project_group_key, + window, + cx, + ); + }) + .ok(); } - workspace_for_add.update(cx, |workspace, cx| { - workspace.add_folder_to_project(&AddFolderToProject, window, cx); - }); }, ); - let workspace_count = multi_workspace + let group_count = multi_workspace .upgrade() - .map_or(0, |mw| mw.read(cx).workspaces().len()); - let menu = if workspace_count > 1 { - let workspace_for_move = workspace.clone(); - let multi_workspace_for_move = multi_workspace.clone(); + .map_or(0, |mw| mw.read(cx).project_group_keys().count()); + let menu = if group_count > 1 { + let project_group_key = project_group_key.clone(); + let multi_workspace = multi_workspace.clone(); menu.entry( "Move to New Window", Some(Box::new( zed_actions::agents_sidebar::MoveWorkspaceToNewWindow, )), move |window, cx| { - if let Some(mw) = multi_workspace_for_move.upgrade() { - mw.update(cx, |multi_workspace, cx| { - multi_workspace.move_workspace_to_new_window( - &workspace_for_move, + multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace.move_project_group_to_new_window( + &project_group_key, window, cx, ); - }); - } + }) + .ok(); }, ) } else { menu }; - let workspace_for_remove = workspace_for_remove.clone(); - let multi_workspace_for_remove = multi_workspace.clone(); + let project_group_key = project_group_key.clone(); + let multi_workspace = multi_workspace.clone(); menu.separator() .entry("Remove Project", None, move |window, cx| { - if let Some(mw) = multi_workspace_for_remove.upgrade() { - let ws = workspace_for_remove.clone(); - mw.update(cx, |multi_workspace, cx| { - multi_workspace.remove(&ws, window, cx); - }); - } + multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace.remove_project_group( + &project_group_key, + window, + cx, + ); + }) + .ok(); }) }); @@ -2176,16 +2142,12 @@ impl Sidebar { return; }; - let paths: Vec = - path_list.paths().iter().map(|p| p.to_path_buf()).collect(); - - let open_task = multi_workspace.update(cx, |mw, cx| { - mw.open_project(paths, workspace::OpenMode::Activate, window, cx) + let open_task = multi_workspace.update(cx, |this, cx| { + this.find_or_create_local_workspace(path_list, window, cx) }); cx.spawn_in(window, async move |this, cx| { let workspace = open_task.await?; - this.update_in(cx, |this, window, cx| { this.activate_thread(metadata, &workspace, window, cx); })?; diff --git a/crates/util/src/path_list.rs b/crates/util/src/path_list.rs index 47ade219c6bd4a2217f7ac00ecccfd92fe64c199..af99f4c6570b35b004179afb87b737d3a4356489 100644 --- a/crates/util/src/path_list.rs +++ b/crates/util/src/path_list.rs @@ -65,6 +65,16 @@ impl PathList { self.paths.is_empty() } + /// Returns a new `PathList` with the given path removed. + pub fn without_path(&self, path_to_remove: &Path) -> PathList { + let paths: Vec = self + .ordered_paths() + .filter(|p| p.as_path() != path_to_remove) + .cloned() + .collect(); + PathList::new(&paths) + } + /// Get the paths in lexicographic order. pub fn paths(&self) -> &[PathBuf] { self.paths.as_ref() diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index d1bfcf2652d4d7c77d1f83ca2bc9d9603e3a2eed..cb2640142442b458b60759547c783d87d9de8a10 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -1,5 +1,6 @@ use anyhow::Result; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; +use gpui::PathPromptOptions; use gpui::{ AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId, @@ -7,14 +8,16 @@ use gpui::{ }; #[cfg(any(test, feature = "test-support"))] use project::Project; -use project::{DisableAiSettings, ProjectGroupKey}; +use project::{DirectoryLister, DisableAiSettings, ProjectGroupKey}; use settings::Settings; pub use settings::SidebarSide; use std::future::Future; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use ui::prelude::*; use util::ResultExt; +use util::path_list::PathList; use zed_actions::agents_sidebar::{MoveWorkspaceToNewWindow, ToggleThreadSwitcher}; use agent_settings::AgentSettings; @@ -23,6 +26,7 @@ use ui::{ContextMenu, right_click_menu}; const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); +use crate::AppState; use crate::{ CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, OpenMode, Panel, Workspace, WorkspaceId, client_side_decorations, @@ -494,6 +498,176 @@ impl MultiWorkspace { groups.into_iter() } + pub fn workspaces_for_project_group( + &self, + project_group_key: &ProjectGroupKey, + cx: &App, + ) -> impl Iterator> { + self.workspaces + .iter() + .filter(move |ws| ws.read(cx).project_group_key(cx) == *project_group_key) + } + + pub fn remove_folder_from_project_group( + &mut self, + project_group_key: &ProjectGroupKey, + path: &Path, + cx: &mut Context, + ) { + let new_path_list = project_group_key.path_list().without_path(path); + if new_path_list.is_empty() { + return; + } + + let new_key = ProjectGroupKey::new(project_group_key.host(), new_path_list); + + let workspaces: Vec<_> = self + .workspaces_for_project_group(project_group_key, cx) + .cloned() + .collect(); + + self.add_project_group_key(new_key); + + for workspace in workspaces { + let project = workspace.read(cx).project().clone(); + project.update(cx, |project, cx| { + project.remove_worktree_for_main_worktree_path(path, cx); + }); + } + + self.serialize(cx); + cx.notify(); + } + + pub fn prompt_to_add_folders_to_project_group( + &mut self, + key: &ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + let paths = self.workspace().update(cx, |workspace, cx| { + workspace.prompt_for_open_path( + PathPromptOptions { + files: false, + directories: true, + multiple: true, + prompt: None, + }, + DirectoryLister::Project(workspace.project().clone()), + window, + cx, + ) + }); + + let key = key.clone(); + cx.spawn_in(window, async move |this, cx| { + if let Some(new_paths) = paths.await.ok().flatten() { + if !new_paths.is_empty() { + this.update(cx, |multi_workspace, cx| { + multi_workspace.add_folders_to_project_group(&key, new_paths, cx); + })?; + } + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + pub fn add_folders_to_project_group( + &mut self, + project_group_key: &ProjectGroupKey, + new_paths: Vec, + cx: &mut Context, + ) { + let mut all_paths: Vec = project_group_key.path_list().paths().to_vec(); + all_paths.extend(new_paths.iter().cloned()); + let new_path_list = PathList::new(&all_paths); + let new_key = ProjectGroupKey::new(project_group_key.host(), new_path_list); + + let workspaces: Vec<_> = self + .workspaces_for_project_group(project_group_key, cx) + .cloned() + .collect(); + + self.add_project_group_key(new_key); + + for workspace in workspaces { + let project = workspace.read(cx).project().clone(); + for path in &new_paths { + project + .update(cx, |project, cx| { + project.find_or_create_worktree(path, true, cx) + }) + .detach_and_log_err(cx); + } + } + + self.serialize(cx); + cx.notify(); + } + + pub fn remove_project_group( + &mut self, + key: &ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + self.project_group_keys.retain(|k| k != key); + + let workspaces: Vec<_> = self + .workspaces_for_project_group(key, cx) + .cloned() + .collect(); + for workspace in workspaces { + self.remove(&workspace, window, cx); + } + + self.serialize(cx); + cx.notify(); + } + + /// Finds an existing workspace in this multi-workspace whose paths match, + /// or creates a new one (deserializing its saved state from the database). + /// Never searches other windows or matches workspaces with a superset of + /// the requested paths. + pub fn find_or_create_local_workspace( + &mut self, + path_list: PathList, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + if let Some(workspace) = self + .workspaces + .iter() + .find(|ws| ws.read(cx).project_group_key(cx).path_list() == &path_list) + .cloned() + { + self.activate(workspace.clone(), window, cx); + return Task::ready(Ok(workspace)); + } + + let paths = path_list.paths().to_vec(); + let app_state = self.workspace().read(cx).app_state().clone(); + let requesting_window = window.window_handle().downcast::(); + + cx.spawn(async move |_this, cx| { + let result = cx + .update(|cx| { + Workspace::new_local( + paths, + app_state, + requesting_window, + None, + None, + OpenMode::Activate, + cx, + ) + }) + .await?; + Ok(result.workspace) + }) + } + pub fn workspace(&self) -> &Entity { &self.workspaces[self.active_workspace_index] } @@ -892,7 +1066,7 @@ impl MultiWorkspace { return; } - let app_state: Arc = workspace.read(cx).app_state().clone(); + let app_state: Arc = workspace.read(cx).app_state().clone(); cx.defer(move |cx| { let options = (app_state.build_window_options)(None, cx); @@ -909,7 +1083,58 @@ impl MultiWorkspace { }); } - // TODO: Move group to a new window? + pub fn move_project_group_to_new_window( + &mut self, + key: &ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + let workspaces: Vec<_> = self + .workspaces_for_project_group(key, cx) + .cloned() + .collect(); + if workspaces.is_empty() { + return; + } + + self.project_group_keys.retain(|k| k != key); + + let mut removed = Vec::new(); + for workspace in &workspaces { + if self.remove(workspace, window, cx) { + removed.push(workspace.clone()); + } + } + + if removed.is_empty() { + return; + } + + let app_state = removed[0].read(cx).app_state().clone(); + + cx.defer(move |cx| { + let options = (app_state.build_window_options)(None, cx); + + let first = removed[0].clone(); + let rest = removed[1..].to_vec(); + + let Ok(new_window) = cx.open_window(options, |window, cx| { + cx.new(|cx| MultiWorkspace::new(first, window, cx)) + }) else { + return; + }; + + new_window + .update(cx, |mw, window, cx| { + for workspace in rest { + mw.activate(workspace, window, cx); + } + window.activate_window(); + }) + .log_err(); + }); + } + fn move_active_workspace_to_new_window( &mut self, _: &MoveWorkspaceToNewWindow, @@ -927,16 +1152,10 @@ impl MultiWorkspace { window: &mut Window, cx: &mut Context, ) -> Task>> { - let workspace = self.workspace().clone(); - - let needs_close_prompt = !self.multi_workspace_enabled(cx); - let open_mode = if self.multi_workspace_enabled(cx) { - open_mode + if self.multi_workspace_enabled(cx) { + self.find_or_create_local_workspace(PathList::new(&paths), window, cx) } else { - OpenMode::Activate - }; - - if needs_close_prompt { + let workspace = self.workspace().clone(); cx.spawn_in(window, async move |_this, cx| { let should_continue = workspace .update_in(cx, |workspace, window, cx| { @@ -953,10 +1172,6 @@ impl MultiWorkspace { Ok(workspace) } }) - } else { - workspace.update(cx, |workspace, cx| { - workspace.open_workspace_for_paths(open_mode, paths, window, cx) - }) } } }