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
This commit is contained in:
Max Brunsfeld authored and GitHub committed 2026-04-05 11:12:02 -07:00
1 parent d7fca945ac
commit bf28ac168f
5 files changed
+342 -127

No files matched your search

+13
View File
@@ -4763,6 +4763,19 @@ impl Project {
});
}
pub fn remove_worktree_for_main_worktree_path(
&mut self,
path: impl AsRef<Path>,
cx: &mut Context<Self>,
) {
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<Worktree>, cx: &mut Context<Self>) {
self.worktree_store.update(cx, |worktree_store, cx| {
worktree_store.add(worktree, cx);
+15
View File
@@ -850,6 +850,21 @@ impl WorktreeStore {
self.send_project_updates(cx);
}
pub fn worktree_for_main_worktree_path(
&self,
path: &Path,
cx: &App,
) -> Option<Entity<Worktree>> {
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;
}
+73 -111
View File
@@ -689,12 +689,9 @@ impl Sidebar {
return;
};
let paths: Vec<std::path::PathBuf> =
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>,
workspace_for_remove: &Entity<Workspace>,
project_group_key: &ProjectGroupKey,
cx: &mut Context<Self>,
) -> 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<std::path::PathBuf> =
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);
})?;
+10
View File
@@ -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<PathBuf> = 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()
+231 -16
View File
@@ -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<Item = &Entity<Workspace>> {
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<Self>,
) {
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<Self>,
) {
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<PathBuf>,
cx: &mut Context<Self>,
) {
let mut all_paths: Vec<PathBuf> = 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>,
) {
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<Self>,
) -> Task<Result<Entity<Workspace>>> {
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::<MultiWorkspace>();
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<Workspace> {
&self.workspaces[self.active_workspace_index]
}
@@ -892,7 +1066,7 @@ impl MultiWorkspace {
return;
}
let app_state: Arc<crate::AppState> = workspace.read(cx).app_state().clone();
let app_state: Arc<AppState> = 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<Self>,
) {
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<Self>,
) -> Task<Result<Entity<Workspace>>> {
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)
})
}
}
}