From 0eda7a2c2ef2502742f1274e36b868ae7571e8c2 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 1 Apr 2026 17:46:16 -0700 Subject: [PATCH] WIP --- crates/sidebar/src/project_group_builder.rs | 67 +++---------------- crates/sidebar/src/sidebar.rs | 36 ++++------ crates/workspace/src/multi_workspace.rs | 34 +++++++++- crates/workspace/src/multi_workspace_tests.rs | 5 ++ crates/workspace/src/workspace.rs | 29 +++++++- 5 files changed, 90 insertions(+), 81 deletions(-) diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs index 9d06c7d31f1e1b34676db84a4f8e50131897f94d..5bdfe8e6d971d311896ece7799b244353e56a7c9 100644 --- a/crates/sidebar/src/project_group_builder.rs +++ b/crates/sidebar/src/project_group_builder.rs @@ -15,39 +15,7 @@ use std::{ }; use gpui::{App, Entity}; -use ui::SharedString; -use workspace::{MultiWorkspace, PathList, Workspace}; - -/// Identifies a project group by a set of paths the workspaces in this group -/// have. -/// -/// Paths are mapped to their main worktree path first so we can group -/// workspaces by main repos. -#[derive(PartialEq, Eq, Hash, Clone)] -pub struct ProjectGroupName { - path_list: PathList, -} - -impl ProjectGroupName { - pub fn display_name(&self) -> SharedString { - let mut names = Vec::with_capacity(self.path_list.paths().len()); - for abs_path in self.path_list.paths() { - if let Some(name) = abs_path.file_name() { - names.push(name.to_string_lossy().to_string()); - } - } - if names.is_empty() { - // TODO: Can we do something better in this case? - "Empty Workspace".into() - } else { - names.join(", ").into() - } - } - - pub fn path_list(&self) -> &PathList { - &self.path_list - } -} +use workspace::{MultiWorkspace, PathList, ProjectGroupKey, Workspace}; #[derive(Default)] pub struct ProjectGroup { @@ -88,7 +56,7 @@ impl ProjectGroup { pub struct ProjectGroupBuilder { /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path directory_mappings: HashMap, - project_groups: VecMap, + project_groups: VecMap, } impl ProjectGroupBuilder { @@ -111,16 +79,16 @@ impl ProjectGroupBuilder { // Second pass: group each workspace using canonical paths derived // from the full set of mappings. for workspace in mw.workspaces() { - let group_name = builder.canonical_workspace_paths(workspace, cx); + let group_key = workspace.read(cx).project_group_key(cx); builder - .project_group_entry(&group_name) + .project_group_entry(&group_key) .add_workspace(workspace, cx); } builder } - fn project_group_entry(&mut self, name: &ProjectGroupName) -> &mut ProjectGroup { - self.project_groups.entry_ref(name).or_insert_default() + fn project_group_entry(&mut self, key: &ProjectGroupKey) -> &mut ProjectGroup { + self.project_groups.entry_ref(key).or_insert_default() } fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) { @@ -150,23 +118,6 @@ impl ProjectGroupBuilder { } } - /// Derives the canonical group name for a workspace by canonicalizing - /// each of its root paths using the builder's directory mappings. - fn canonical_workspace_paths( - &self, - workspace: &Entity, - cx: &App, - ) -> ProjectGroupName { - let root_paths = workspace.read(cx).root_paths(cx); - let paths: Vec<_> = root_paths - .iter() - .map(|p| self.canonicalize_path(p).to_path_buf()) - .collect(); - ProjectGroupName { - path_list: PathList::new(&paths), - } - } - pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path { self.directory_mappings .get(path) @@ -203,9 +154,13 @@ impl ProjectGroupBuilder { PathList::new(&paths) } - pub fn groups(&self) -> impl Iterator { + pub fn groups(&self) -> impl Iterator { self.project_groups.iter() } + + pub fn ensure_group(&mut self, key: &ProjectGroupKey) { + self.project_groups.entry_ref(key).or_insert_default(); + } } #[cfg(test)] diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index cd9bfb4d7ab5e66dafd088999e484513c5074411..cc26b5af9559ca146eb06504148f291d7ab5ec51 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -224,7 +224,6 @@ enum ListEntry { }, NewThread { path_list: PathList, - workspace: Entity, worktrees: Vec, }, } @@ -239,7 +238,7 @@ impl ListEntry { ThreadEntryWorkspace::Closed(_) => None, }, ListEntry::ViewMore { .. } => None, - ListEntry::NewThread { workspace, .. } => Some(workspace.clone()), + ListEntry::NewThread { .. } => None, } } @@ -767,7 +766,7 @@ impl Sidebar { // Use ProjectGroupBuilder to canonically group workspaces by their // main git repository. This replaces the manual absorbed-workspace // detection that was here before. - let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx); + let mut project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx); let has_open_projects = workspaces .iter() @@ -785,13 +784,18 @@ impl Sidebar { (icon, icon_from_external_svg) }; - for (group_name, group) in project_groups.groups() { - let path_list = group_name.path_list().clone(); + // Make sure all the groups the MultiWorkspace cares about are also present + for mw_groups in mw.project_group_keys(cx) { + project_groups.ensure_group(&mw_groups); + } + + for (group_key, group) in project_groups.groups() { + let path_list = group_key.main_worktree_paths.clone(); if path_list.paths().is_empty() { continue; } - let label = group_name.display_name(); + let label = group_key.display_name(); let is_collapsed = self.collapsed_groups.contains(&path_list); let should_load_threads = !is_collapsed || !query.is_empty(); @@ -807,6 +811,7 @@ impl Sidebar { .as_ref() .filter(|_| is_active) .unwrap_or_else(|| group.main_workspace(cx)); + // TODO: if we don't have a main workspace, create one for the main worktrees. // Collect live thread infos from all workspaces in this group. let live_infos: Vec<_> = group @@ -1044,7 +1049,6 @@ impl Sidebar { for (workspace, worktrees) in &threadless_workspaces { entries.push(ListEntry::NewThread { path_list: path_list.clone(), - workspace: workspace.clone(), worktrees: worktrees.clone(), }); } @@ -1057,7 +1061,6 @@ impl Sidebar { let worktrees = worktree_info_from_thread_paths(&ws_path_list, &project_groups); entries.push(ListEntry::NewThread { path_list: path_list.clone(), - workspace: representative_workspace.clone(), worktrees, }); } @@ -1218,17 +1221,8 @@ impl Sidebar { } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx), ListEntry::NewThread { path_list, - workspace, - worktrees, - } => self.render_new_thread( - ix, - path_list, - workspace, - is_active, worktrees, - is_selected, - cx, - ), + } => self.render_new_thread(ix, path_list, is_active, worktrees, is_selected, cx), }; if is_group_header_after_first { @@ -3058,8 +3052,7 @@ impl Sidebar { fn render_new_thread( &self, ix: usize, - _path_list: &PathList, - workspace: &Entity, + path_list: &PathList, is_active: bool, worktrees: &[WorktreeInfo], is_selected: bool, @@ -3072,7 +3065,6 @@ impl Sidebar { DEFAULT_THREAD_TITLE.into() }; - let workspace = workspace.clone(); let id = SharedString::from(format!("new-thread-btn-{}", ix)); let thread_item = ThreadItem::new(id, label) @@ -3093,7 +3085,7 @@ impl Sidebar { .when(!is_active, |this| { this.on_click(cx.listener(move |this, _, window, cx| { this.selection = None; - this.create_new_thread(&workspace, window, cx); + this.create_new_thread(&path_list, window, cx); })) }); diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 10a5ce70ead2d5aea7cc21a9af53ee9f216859c3..095584d1f805ba4195fb88d3f37f74727ca2dcfe 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -8,13 +8,14 @@ use gpui::{ use project::DisableAiSettings; #[cfg(any(test, feature = "test-support"))] use project::Project; +use remote::RemoteConnectionOptions; use settings::Settings; pub use settings::SidebarSide; use std::future::Future; use std::path::PathBuf; use std::sync::Arc; use ui::prelude::*; -use util::ResultExt; +use util::{ResultExt, path_list::PathList}; use zed_actions::agents_sidebar::{MoveWorkspaceToNewWindow, ToggleThreadSwitcher}; use agent_settings::AgentSettings; @@ -230,6 +231,28 @@ pub struct MultiWorkspace { _subscriptions: Vec, } +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct ProjectGroupKey { + pub host: Option, + pub main_worktree_paths: PathList, +} + +impl ProjectGroupKey { + pub fn display_name(&self) -> SharedString { + let mut names = Vec::with_capacity(self.main_worktree_paths.paths().len()); + for abs_path in self.main_worktree_paths.paths() { + if let Some(name) = abs_path.file_name() { + names.push(name.to_string_lossy().to_string()); + } + } + if names.is_empty() { + "Empty Workspace".into() + } else { + names.join(", ").into() + } + } +} + impl EventEmitter for MultiWorkspace {} impl MultiWorkspace { @@ -454,6 +477,15 @@ impl MultiWorkspace { &self.workspaces } + pub fn project_group_keys(&self, cx: &App) -> impl Iterator { + let mut keys = Vec::new(); + for workspace in &self.workspaces { + let key = workspace.read(cx).project_group_key(cx); + keys.push(key); + } + keys.into_iter() + } + pub fn active_workspace_index(&self) -> usize { self.active_workspace_index } diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index 50161121719ec7b2835fd11e389f24860e57d8f5..94e532e55c0720c70e482b11782f309d6bed5708 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -170,3 +170,8 @@ async fn test_replace(cx: &mut TestAppContext) { ); }); } + +#[gpui::test] +async fn test_worktrees(cx: &mut TestAppContext) { + // +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index aa692ab39a6084126c9b15b07856549364b13842..1c01d1bbddcbe9cda1b36a0b62985376d16fda6f 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -31,8 +31,9 @@ pub use crate::notifications::NotificationFrame; pub use dock::Panel; pub use multi_workspace::{ CloseWorkspaceSidebar, DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, - MultiWorkspaceEvent, NextWorkspace, PreviousWorkspace, Sidebar, SidebarEvent, SidebarHandle, - SidebarRenderState, SidebarSide, ToggleWorkspaceSidebar, sidebar_side_context_menu, + MultiWorkspaceEvent, NextWorkspace, PreviousWorkspace, ProjectGroupKey, Sidebar, SidebarEvent, + SidebarHandle, SidebarRenderState, SidebarSide, ToggleWorkspaceSidebar, + sidebar_side_context_menu, }; pub use path_list::{PathList, SerializedPathList}; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; @@ -6375,6 +6376,30 @@ impl Workspace { .collect::>() } + pub fn project_group_key(&self, cx: &App) -> ProjectGroupKey { + let project = self.project().read(cx); + let repositories = project.repositories(cx); + let paths = project + .visible_worktrees(cx) + .map(|worktree| { + let worktree = worktree.read(cx); + let main_worktree_path = repositories.values().find_map(|repo| { + let repo = repo.read(cx); + if repo.work_directory_abs_path == worktree.abs_path() { + Some(repo.original_repo_abs_path.clone()) + } else { + None + } + }); + main_worktree_path.unwrap_or_else(|| worktree.abs_path()) + }) + .collect::>(); + ProjectGroupKey { + host: project.remote_connection_options(cx), + main_worktree_paths: PathList::new(&paths), + } + } + fn remove_panes(&mut self, member: Member, window: &mut Window, cx: &mut Context) { match member { Member::Axis(PaneAxis { members, .. }) => {