WIP

Max Brunsfeld and Eric Holk created

Co-authored-by: Eric Holk <eric@zed.dev>

Change summary

crates/recent_projects/src/recent_projects.rs |  3 
crates/sidebar/src/sidebar.rs                 | 37 ++++++---------
crates/sidebar/src/sidebar_tests.rs           |  4 
crates/workspace/src/multi_workspace.rs       | 47 ++++++++++++++++++--
crates/workspace/src/persistence.rs           |  8 +-
5 files changed, 65 insertions(+), 34 deletions(-)

Detailed changes

crates/recent_projects/src/recent_projects.rs 🔗

@@ -1931,7 +1931,8 @@ impl RecentProjectsDelegate {
                             .workspaces()
                             .find(|ws| ws.read(cx).database_id() == Some(workspace_id));
                         if let Some(workspace) = workspace {
-                            multi_workspace.remove_group(&workspace, window, cx);
+                            multi_workspace
+                                .remove_group_containing_workspace(&workspace, window, cx);
                         }
                     })
                     .log_err();

crates/sidebar/src/sidebar.rs 🔗

@@ -793,13 +793,6 @@ impl Sidebar {
             let should_load_threads = !is_collapsed || !query.is_empty();
             let is_active = group.workspaces.contains(&active_workspace);
 
-            // Pick a representative workspace for the group: prefer the active
-            // workspace if it belongs to this group, otherwise use the main
-            // repo workspace (not a linked worktree).
-            let representative_workspace = Some(&active_workspace)
-                .filter(|_| is_active)
-                .unwrap_or_else(|| group.main_workspace(cx));
-
             // Collect live thread infos from all workspaces in this group.
             let live_infos: Vec<_> = group
                 .workspaces
@@ -854,23 +847,21 @@ impl Sidebar {
 
                 // Load threads from linked git worktrees whose
                 // canonical paths belong to this group.
-                let linked_worktree_queries = group
+                let linked_worktree_paths = group
                     .workspaces
-                    .iter()
-                    .flat_map(|ws| root_repository_snapshots(ws, cx))
-                    .filter(|snapshot| !snapshot.is_linked_worktree())
-                    .flat_map(|snapshot| {
-                        snapshot
-                            .linked_worktrees()
-                            .iter()
-                            .filter(|wt| {
-                                project_groups.group_owns_worktree(group, &path_list, &wt.path)
+                    .first()
+                    .map(|workspace| {
+                        root_repository_snapshots(workspace, cx)
+                            .flat_map(|repo| {
+                                repo.linked_worktrees
+                                    .iter()
+                                    .map(|worktree| worktree.path.clone())
                             })
-                            .map(|wt| PathList::new(std::slice::from_ref(&wt.path)))
                             .collect::<Vec<_>>()
-                    });
+                    })
+                    .unwrap_or_default();
 
-                for worktree_path_list in linked_worktree_queries {
+                for worktree_path_list in linked_worktree_paths {
                     for row in thread_store
                         .read(cx)
                         .entries_for_path(&worktree_path_list)
@@ -1584,7 +1575,8 @@ impl Sidebar {
                                 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_group(&ws, window, cx);
+                                        multi_workspace
+                                            .remove_group_containing_workspace(&ws, window, cx);
                                     });
                                 }
                             } else {
@@ -1657,7 +1649,8 @@ impl Sidebar {
                             if let Some(mw) = multi_workspace_for_remove.upgrade() {
                                 let ws = workspace_for_remove.clone();
                                 mw.update(cx, |multi_workspace, cx| {
-                                    multi_workspace.remove_group(&ws, window, cx);
+                                    multi_workspace
+                                        .remove_group_containing_workspace(&ws, window, cx);
                                 });
                             }
                         })

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -516,7 +516,7 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
     // Remove the second workspace
     multi_workspace.update_in(cx, |mw, window, cx| {
         let workspace = mw.workspaces().nth(1).unwrap().clone();
-        mw.remove_group(&workspace, window, cx);
+        mw.remove_group_containing_workspace(&workspace, window, cx);
     });
     cx.run_until_parked();
 
@@ -5061,7 +5061,7 @@ mod property_test {
             Operation::RemoveWorkspace { index } => {
                 let removed = multi_workspace.update_in(cx, |mw, window, cx| {
                     let workspace = mw.workspaces().nth(index).unwrap().clone();
-                    mw.remove_group(&workspace, window, cx)
+                    mw.remove_group_containing_workspace(&workspace, window, cx)
                 });
                 if removed {
                     state.workspace_paths.remove(index);

crates/workspace/src/multi_workspace.rs 🔗

@@ -23,6 +23,7 @@ use ui::{ContextMenu, right_click_menu};
 
 const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
 
+use crate::SerializedWorkspaceLocation;
 use crate::{
     CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, OpenMode,
     Panel, Workspace, WorkspaceId, client_side_decorations,
@@ -255,6 +256,17 @@ impl ProjectGroupKey {
             host,
         }
     }
+
+    pub fn from_location_and_paths(location: SerializedWorkspaceLocation, paths: PathList) -> Self {
+        let host = match location {
+            SerializedWorkspaceLocation::Local => None,
+            SerializedWorkspaceLocation::Remote(options) => Some(options),
+        };
+        Self {
+            main_worktree_paths: paths,
+            host,
+        }
+    }
 }
 
 pub struct MultiWorkspace {
@@ -447,6 +459,10 @@ impl MultiWorkspace {
         &self.project_groups
     }
 
+    pub fn project_group_keys(&self) -> impl Iterator<Item = &ProjectGroupKey> {
+        self.project_groups.iter().map(|group| &group.key)
+    }
+
     pub fn open_sidebar(&mut self, cx: &mut Context<Self>) {
         self.sidebar_open = true;
         let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx));
@@ -908,7 +924,8 @@ impl MultiWorkspace {
     }
 
     /// Removes the group that contains this workspace.
-    pub fn remove_group(
+    #[deprecated = "use remove_group instead"]
+    pub fn remove_group_containing_workspace(
         &mut self,
         workspace: &Entity<Workspace>,
         window: &mut Window,
@@ -921,11 +938,33 @@ impl MultiWorkspace {
         else {
             return false;
         };
+        self.remove_group_at_index(group_ix, window, cx);
+        true
+    }
 
+    pub fn remove_group(
+        &mut self,
+        key: ProjectGroupKey,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> bool {
+        let Some(group_ix) = self.project_groups.iter_mut().position(|g| g.key == key) else {
+            return false;
+        };
+        self.remove_group_at_index(group_ix, window, cx);
+        true
+    }
+
+    fn remove_group_at_index(
+        &mut self,
+        group_ix: usize,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         let removed_group = self.project_groups.remove(group_ix);
 
         // If we removed the active workspace, pick a new one.
-        let app_state = workspace.read(cx).app_state().clone();
+        let app_state = self.active_workspace.read(cx).app_state().clone();
         if removed_group.workspaces.contains(&self.active_workspace) {
             let workspace = self.workspaces().next();
             if let Some(workspace) = workspace {
@@ -958,8 +997,6 @@ impl MultiWorkspace {
         cx.emit(MultiWorkspaceEvent::WorkspaceRemoved);
         cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
         cx.notify();
-
-        true
     }
 
     pub fn move_workspace_to_new_window(
@@ -969,7 +1006,7 @@ impl MultiWorkspace {
         cx: &mut Context<Self>,
     ) {
         let workspace = workspace.clone();
-        if !self.remove_group(&workspace, window, cx) {
+        if !self.remove_group_containing_workspace(&workspace, window, cx) {
             return;
         }
 

crates/workspace/src/persistence.rs 🔗

@@ -2561,7 +2561,7 @@ mod tests {
                 .nth(1)
                 .expect("no workspace at index 1")
                 .clone();
-            mw.remove_group(&ws, window, cx);
+            mw.remove_group_containing_workspace(&ws, window, cx);
         });
 
         cx.run_until_parked();
@@ -4248,7 +4248,7 @@ mod tests {
                 .nth(1)
                 .expect("no workspace at index 1")
                 .clone();
-            mw.remove_group(&ws, window, cx);
+            mw.remove_group_containing_workspace(&ws, window, cx);
         });
 
         cx.run_until_parked();
@@ -4363,7 +4363,7 @@ mod tests {
                 .nth(1)
                 .expect("no workspace at index 1")
                 .clone();
-            mw.remove_group(&ws, window, cx);
+            mw.remove_group_containing_workspace(&ws, window, cx);
         });
 
         cx.run_until_parked();
@@ -4451,7 +4451,7 @@ mod tests {
                 .nth(1)
                 .expect("no workspace at index 1")
                 .clone();
-            mw.remove_group(&ws, window, cx);
+            mw.remove_group_containing_workspace(&ws, window, cx);
         });
 
         // Simulate the quit handler pattern: collect flush tasks + pending