WIP

Max Brunsfeld created

Change summary

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(-)

Detailed changes

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<PathBuf, PathBuf>,
-    project_groups: VecMap<ProjectGroupName, ProjectGroup>,
+    project_groups: VecMap<ProjectGroupKey, ProjectGroup>,
 }
 
 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<Workspace>,
-        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<Item = (&ProjectGroupName, &ProjectGroup)> {
+    pub fn groups(&self) -> impl Iterator<Item = (&ProjectGroupKey, &ProjectGroup)> {
         self.project_groups.iter()
     }
+
+    pub fn ensure_group(&mut self, key: &ProjectGroupKey) {
+        self.project_groups.entry_ref(key).or_insert_default();
+    }
 }
 
 #[cfg(test)]

crates/sidebar/src/sidebar.rs 🔗

@@ -224,7 +224,6 @@ enum ListEntry {
     },
     NewThread {
         path_list: PathList,
-        workspace: Entity<Workspace>,
         worktrees: Vec<WorktreeInfo>,
     },
 }
@@ -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<Workspace>,
+        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);
                 }))
             });
 

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<Subscription>,
 }
 
+#[derive(Clone, PartialEq, Eq, Debug)]
+pub struct ProjectGroupKey {
+    pub host: Option<RemoteConnectionOptions>,
+    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<MultiWorkspaceEvent> for MultiWorkspace {}
 
 impl MultiWorkspace {
@@ -454,6 +477,15 @@ impl MultiWorkspace {
         &self.workspaces
     }
 
+    pub fn project_group_keys(&self, cx: &App) -> impl Iterator<Item = ProjectGroupKey> {
+        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
     }

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::<Vec<_>>()
     }
 
+    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::<Vec<_>>();
+        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<Workspace>) {
         match member {
             Member::Axis(PaneAxis { members, .. }) => {