Track project groups in MultiWorkspace (#53032)

Eric Holk , Max Brunsfeld , and Mikayla Maki created

This PR adds tracking of project groups to the MultiWorkspace and
serialization/restoration of them. This will later be used by the
sidebar to provide reliable reloading of threads across Zed reloads.

Release Notes:

- N/A

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>

Change summary

crates/git_ui/src/worktree_picker.rs          |   4 
crates/project/src/project.rs                 |  63 +++++
crates/project_panel/src/project_panel.rs     |   2 
crates/recent_projects/src/recent_projects.rs |  16 
crates/recent_projects/src/wsl_picker.rs      |   2 
crates/remote/src/remote_client.rs            |   2 
crates/remote/src/transport/docker.rs         |  13 +
crates/remote/src/transport/mock.rs           |   2 
crates/remote/src/transport/ssh.rs            |   4 
crates/remote/src/transport/wsl.rs            |   4 
crates/sidebar/src/project_group_builder.rs   |  61 ----
crates/sidebar/src/sidebar.rs                 |   9 
crates/util/src/path_list.rs                  |   2 
crates/workspace/src/multi_workspace.rs       |  89 +++---
crates/workspace/src/multi_workspace_tests.rs | 247 ++++++++++++++++----
crates/workspace/src/persistence.rs           |   2 
crates/workspace/src/persistence/model.rs     |  25 +
crates/workspace/src/welcome.rs               |   2 
crates/workspace/src/workspace.rs             |  19 
19 files changed, 382 insertions(+), 186 deletions(-)

Detailed changes

crates/git_ui/src/worktree_picker.rs 🔗

@@ -364,7 +364,7 @@ impl WorktreeListDelegate {
                 workspace
                     .update_in(cx, |workspace, window, cx| {
                         workspace.open_workspace_for_paths(
-                            OpenMode::Replace,
+                            OpenMode::Activate,
                             vec![new_worktree_path],
                             window,
                             cx,
@@ -418,7 +418,7 @@ impl WorktreeListDelegate {
             return;
         };
         let open_mode = if replace_current_window {
-            OpenMode::Replace
+            OpenMode::Activate
         } else {
             OpenMode::NewWindow
         };

crates/project/src/project.rs 🔗

@@ -2349,6 +2349,22 @@ impl Project {
             .find(|tree| tree.read(cx).root_name() == root_name)
     }
 
+    pub fn project_group_key(&self, cx: &App) -> ProjectGroupKey {
+        let roots = self
+            .visible_worktrees(cx)
+            .map(|worktree| {
+                let snapshot = worktree.read(cx).snapshot();
+                snapshot
+                    .root_repo_common_dir()
+                    .and_then(|dir| Some(dir.parent()?.to_path_buf()))
+                    .unwrap_or(snapshot.abs_path().to_path_buf())
+            })
+            .collect::<Vec<_>>();
+        let host = self.remote_connection_options(cx);
+        let path_list = PathList::new(&roots);
+        ProjectGroupKey::new(host, path_list)
+    }
+
     #[inline]
     pub fn worktree_root_names<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a str> {
         self.visible_worktrees(cx)
@@ -6018,6 +6034,53 @@ impl Project {
     }
 }
 
+/// 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, Debug)]
+pub struct ProjectGroupKey {
+    paths: PathList,
+    host: Option<RemoteConnectionOptions>,
+}
+
+impl ProjectGroupKey {
+    /// Creates a new `ProjectGroupKey` with the given path list.
+    ///
+    /// The path list should point to the git main worktree paths for a project.
+    ///
+    /// This should be used only in a few places to make sure we can ensure the
+    /// main worktree path invariant. Namely, this should only be called from
+    /// [`Workspace`].
+    pub(crate) fn new(host: Option<RemoteConnectionOptions>, paths: PathList) -> Self {
+        Self { paths, host }
+    }
+
+    pub fn display_name(&self) -> SharedString {
+        let mut names = Vec::with_capacity(self.paths.paths().len());
+        for abs_path in self.paths.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.paths
+    }
+
+    pub fn host(&self) -> Option<RemoteConnectionOptions> {
+        self.host.clone()
+    }
+}
+
 pub struct PathMatchCandidateSet {
     pub snapshot: Snapshot,
     pub include_ignored: bool,

crates/project_panel/src/project_panel.rs 🔗

@@ -7126,7 +7126,7 @@ impl Render for ProjectPanel {
                                     .workspace
                                     .update(cx, |workspace, cx| {
                                         workspace.open_workspace_for_paths(
-                                            OpenMode::Replace,
+                                            OpenMode::Activate,
                                             external_paths.paths().to_owned(),
                                             window,
                                             cx,

crates/recent_projects/src/recent_projects.rs 🔗

@@ -1160,7 +1160,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                                             .update(cx, |multi_workspace, window, cx| {
                                                 multi_workspace.open_project(
                                                     paths,
-                                                    OpenMode::Replace,
+                                                    OpenMode::Activate,
                                                     window,
                                                     cx,
                                                 )
@@ -2122,14 +2122,12 @@ mod tests {
         cx.dispatch_action(*multi_workspace, menu::Confirm);
         cx.run_until_parked();
 
-        // prepare_to_close triggers a save prompt for the dirty buffer.
-        // Choose "Don't Save" (index 2) to discard and continue replacing.
+        // In multi-workspace mode, the dirty workspace is kept and a new one is
+        // opened alongside it — no save prompt needed.
         assert!(
-            cx.has_pending_prompt(),
-            "Should prompt to save dirty buffer before replacing workspace"
+            !cx.has_pending_prompt(),
+            "Should not prompt in multi-workspace mode — dirty workspace is kept"
         );
-        cx.simulate_prompt_answer("Don't Save");
-        cx.run_until_parked();
 
         multi_workspace
             .update(cx, |multi_workspace, _, cx| {
@@ -2143,8 +2141,8 @@ mod tests {
                 );
 
                 assert!(
-                    !multi_workspace.workspaces().contains(&dirty_workspace),
-                    "The original dirty workspace should have been replaced"
+                    multi_workspace.workspaces().contains(&dirty_workspace),
+                    "The dirty workspace should still be present in multi-workspace mode"
                 );
 
                 assert!(

crates/recent_projects/src/wsl_picker.rs 🔗

@@ -246,7 +246,7 @@ impl WslOpenModal {
             false => !secondary,
         };
         let open_mode = if replace_current_window {
-            workspace::OpenMode::Replace
+            workspace::OpenMode::Activate
         } else {
             workspace::OpenMode::NewWindow
         };

crates/remote/src/remote_client.rs 🔗

@@ -1273,7 +1273,7 @@ impl ConnectionPool {
     }
 }
 
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
 pub enum RemoteConnectionOptions {
     Ssh(SshConnectionOptions),
     Wsl(WslConnectionOptions),

crates/remote/src/transport/docker.rs 🔗

@@ -30,7 +30,18 @@ use crate::{
     transport::parse_platform,
 };
 
-#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(
+    Debug,
+    Default,
+    Clone,
+    PartialEq,
+    Eq,
+    Hash,
+    PartialOrd,
+    Ord,
+    serde::Serialize,
+    serde::Deserialize,
+)]
 pub struct DockerConnectionOptions {
     pub name: String,
     pub container_id: String,

crates/remote/src/transport/mock.rs 🔗

@@ -56,7 +56,7 @@ use std::{
 use util::paths::{PathStyle, RemotePathBuf};
 
 /// Unique identifier for a mock connection.
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]

+#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]

 pub struct MockConnectionOptions {
     pub id: u64,
 }

crates/remote/src/transport/ssh.rs 🔗

@@ -45,7 +45,7 @@ pub(crate) struct SshRemoteConnection {
     _temp_dir: TempDir,
 }
 
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
 pub enum SshConnectionHost {
     IpAddr(IpAddr),
     Hostname(String),
@@ -102,7 +102,7 @@ fn bracket_ipv6(host: &str) -> String {
     }
 }
 
-#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
+#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
 pub struct SshConnectionOptions {
     pub host: SshConnectionHost,
     pub username: Option<String>,

crates/remote/src/transport/wsl.rs 🔗

@@ -28,7 +28,9 @@ use util::{
     shell_builder::ShellBuilder,
 };
 
-#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, schemars::JsonSchema)]
+#[derive(
+    Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
+)]
 pub struct WslConnectionOptions {
     pub distro_name: String,
     pub user: Option<String>,

crates/sidebar/src/project_group_builder.rs 🔗

@@ -9,46 +9,14 @@
 //! lookup and mapping.
 
 use collections::{HashMap, HashSet, vecmap::VecMap};
+use gpui::{App, Entity};
+use project::ProjectGroupKey;
 use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-
-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
-    }
-}
-
 #[derive(Default)]
 pub struct ProjectGroup {
     pub workspaces: Vec<Entity<Workspace>>,
@@ -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,7 +79,7 @@ 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_name = workspace.read(cx).project_group_key(cx);
             builder
                 .project_group_entry(&group_name)
                 .add_workspace(workspace, cx);
@@ -119,7 +87,7 @@ impl ProjectGroupBuilder {
         builder
     }
 
-    fn project_group_entry(&mut self, name: &ProjectGroupName) -> &mut ProjectGroup {
+    fn project_group_entry(&mut self, name: &ProjectGroupKey) -> &mut ProjectGroup {
         self.project_groups.entry_ref(name).or_insert_default()
     }
 
@@ -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,7 +154,7 @@ 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()
     }
 }

crates/sidebar/src/sidebar.rs 🔗

@@ -3829,6 +3829,15 @@ pub fn dump_workspace_info(
         .map(|mw| mw.read(cx).active_workspace_index());
 
     writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok();
+
+    if let Some(mw) = &multi_workspace {
+        let keys: Vec<_> = mw.read(cx).project_group_keys().cloned().collect();
+        writeln!(output, "Project group keys ({}):", keys.len()).ok();
+        for key in keys {
+            writeln!(output, "  - {key:?}").ok();
+        }
+    }
+
     if let Some(index) = active_index {
         writeln!(output, "Active workspace index: {index}").ok();
     }

crates/util/src/path_list.rs 🔗

@@ -38,7 +38,7 @@ impl Hash for PathList {
     }
 }
 
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct SerializedPathList {
     pub paths: String,
     pub order: String,

crates/workspace/src/multi_workspace.rs 🔗

@@ -5,9 +5,9 @@ use gpui::{
     ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId,
     actions, deferred, px,
 };
-use project::DisableAiSettings;
 #[cfg(any(test, feature = "test-support"))]
 use project::Project;
+use project::{DisableAiSettings, ProjectGroupKey};
 use settings::Settings;
 pub use settings::SidebarSide;
 use std::future::Future;
@@ -26,6 +26,7 @@ const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
 use crate::{
     CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, OpenMode,
     Panel, Workspace, WorkspaceId, client_side_decorations,
+    persistence::model::MultiWorkspaceState,
 };
 
 actions!(
@@ -222,6 +223,7 @@ pub struct MultiWorkspace {
     window_id: WindowId,
     workspaces: Vec<Entity<Workspace>>,
     active_workspace_index: usize,
+    project_group_keys: Vec<ProjectGroupKey>,
     sidebar: Option<Box<dyn SidebarHandle>>,
     sidebar_open: bool,
     sidebar_overlay: Option<AnyView>,
@@ -269,6 +271,7 @@ impl MultiWorkspace {
         });
         Self {
             window_id: window.window_handle().window_id(),
+            project_group_keys: vec![workspace.read(cx).project_group_key(cx)],
             workspaces: vec![workspace],
             active_workspace_index: 0,
             sidebar: None,
@@ -438,6 +441,20 @@ impl MultiWorkspace {
         window: &Window,
         cx: &mut Context<Self>,
     ) {
+        let project = workspace.read(cx).project().clone();
+        cx.subscribe_in(&project, window, {
+            let workspace = workspace.downgrade();
+            move |this, _project, event, _window, cx| match event {
+                project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => {
+                    if let Some(workspace) = workspace.upgrade() {
+                        this.add_project_group_key(workspace.read(cx).project_group_key(cx));
+                    }
+                }
+                _ => {}
+            }
+        })
+        .detach();
+
         cx.subscribe_in(workspace, window, |this, workspace, event, window, cx| {
             if let WorkspaceEvent::Activate = event {
                 this.activate(workspace.clone(), window, cx);
@@ -446,6 +463,17 @@ impl MultiWorkspace {
         .detach();
     }
 
+    pub fn add_project_group_key(&mut self, project_group_key: ProjectGroupKey) {
+        if self.project_group_keys.contains(&project_group_key) {
+            return;
+        }
+        self.project_group_keys.push(project_group_key);
+    }
+
+    pub fn project_group_keys(&self) -> impl Iterator<Item = &ProjectGroupKey> {
+        self.project_group_keys.iter()
+    }
+
     pub fn workspace(&self) -> &Entity<Workspace> {
         &self.workspaces[self.active_workspace_index]
     }
@@ -492,48 +520,6 @@ impl MultiWorkspace {
         cx.notify();
     }
 
-    /// Replaces the currently active workspace with a new one. If the
-    /// workspace is already in the list, this just switches to it.
-    pub fn replace(
-        &mut self,
-        workspace: Entity<Workspace>,
-        window: &Window,
-        cx: &mut Context<Self>,
-    ) {
-        if !self.multi_workspace_enabled(cx) {
-            self.set_single_workspace(workspace, cx);
-            return;
-        }
-
-        if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
-            let changed = self.active_workspace_index != index;
-            self.active_workspace_index = index;
-            if changed {
-                cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
-                self.serialize(cx);
-            }
-            cx.notify();
-            return;
-        }
-
-        let old_workspace = std::mem::replace(
-            &mut self.workspaces[self.active_workspace_index],
-            workspace.clone(),
-        );
-
-        let old_entity_id = old_workspace.entity_id();
-        self.detach_workspace(&old_workspace, cx);
-
-        Self::subscribe_to_workspace(&workspace, window, cx);
-        self.sync_sidebar_to_workspace(&workspace, cx);
-
-        cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old_entity_id));
-        cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
-        cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
-        self.serialize(cx);
-        cx.notify();
-    }
-
     fn set_single_workspace(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
         self.workspaces[0] = workspace;
         self.active_workspace_index = 0;
@@ -553,12 +539,16 @@ impl MultiWorkspace {
         if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
             index
         } else {
+            let project_group_key = workspace.read(cx).project().read(cx).project_group_key(cx);
+
             Self::subscribe_to_workspace(&workspace, window, cx);
             self.sync_sidebar_to_workspace(&workspace, cx);
             let weak_self = cx.weak_entity();
             workspace.update(cx, |workspace, cx| {
                 workspace.set_multi_workspace(weak_self, cx);
             });
+
+            self.add_project_group_key(project_group_key);
             self.workspaces.push(workspace.clone());
             cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
             cx.notify();
@@ -625,8 +615,13 @@ impl MultiWorkspace {
         self._serialize_task = Some(cx.spawn(async move |this, cx| {
             let Some((window_id, state)) = this
                 .read_with(cx, |this, cx| {
-                    let state = crate::persistence::model::MultiWorkspaceState {
+                    let state = MultiWorkspaceState {
                         active_workspace_id: this.workspace().read(cx).database_id(),
+                        project_group_keys: this
+                            .project_group_keys()
+                            .cloned()
+                            .map(Into::into)
+                            .collect::<Vec<_>>(),
                         sidebar_open: this.sidebar_open,
                         sidebar_state: this.sidebar.as_ref().and_then(|s| s.serialized_state(cx)),
                     };
@@ -894,6 +889,7 @@ impl MultiWorkspace {
         });
     }
 
+    // TODO: Move group to a new window?
     fn move_active_workspace_to_new_window(
         &mut self,
         _: &MoveWorkspaceToNewWindow,
@@ -913,12 +909,11 @@ impl MultiWorkspace {
     ) -> Task<Result<Entity<Workspace>>> {
         let workspace = self.workspace().clone();
 
-        let needs_close_prompt =
-            open_mode == OpenMode::Replace || !self.multi_workspace_enabled(cx);
+        let needs_close_prompt = !self.multi_workspace_enabled(cx);
         let open_mode = if self.multi_workspace_enabled(cx) {
             open_mode
         } else {
-            OpenMode::Replace
+            OpenMode::Activate
         };
 
         if needs_close_prompt {

crates/workspace/src/multi_workspace_tests.rs 🔗

@@ -2,7 +2,8 @@ use super::*;
 use feature_flags::FeatureFlagAppExt;
 use fs::FakeFs;
 use gpui::TestAppContext;
-use project::DisableAiSettings;
+use project::{DisableAiSettings, ProjectGroupKey};
+use serde_json::json;
 use settings::SettingsStore;
 
 fn init_test(cx: &mut TestAppContext) {
@@ -87,86 +88,232 @@ async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContex
 }
 
 #[gpui::test]
-async fn test_replace(cx: &mut TestAppContext) {
+async fn test_project_group_keys_initial(cx: &mut TestAppContext) {
     init_test(cx);
     let fs = FakeFs::new(cx.executor());
-    let project_a = Project::test(fs.clone(), [], cx).await;
-    let project_b = Project::test(fs.clone(), [], cx).await;
-    let project_c = Project::test(fs.clone(), [], cx).await;
-    let project_d = Project::test(fs.clone(), [], cx).await;
+    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+    let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
+
+    let expected_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
 
     let (multi_workspace, cx) =
-        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+
+    multi_workspace.read_with(cx, |mw, _cx| {
+        let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
+        assert_eq!(keys.len(), 1, "should have exactly one key on creation");
+        assert_eq!(*keys[0], expected_key);
+    });
+}
 
-    let workspace_a_id = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].entity_id());
+#[gpui::test]
+async fn test_project_group_keys_add_workspace(cx: &mut TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+    fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
+    let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
+    let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await;
 
-    // Replace the only workspace (single-workspace case).
-    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
-        let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx));
-        mw.replace(workspace.clone(), &*window, cx);
-        workspace
+    let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
+    let key_b = project_b.read_with(cx, |p, cx| p.project_group_key(cx));
+    assert_ne!(
+        key_a, key_b,
+        "different roots should produce different keys"
+    );
+
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+
+    multi_workspace.read_with(cx, |mw, _cx| {
+        assert_eq!(mw.project_group_keys().count(), 1);
+    });
+
+    // Adding a workspace with a different project root adds a new key.
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(project_b, window, cx);
     });
 
     multi_workspace.read_with(cx, |mw, _cx| {
-        assert_eq!(mw.workspaces().len(), 1);
+        let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
         assert_eq!(
-            mw.workspaces()[0].entity_id(),
-            workspace_b.entity_id(),
-            "slot should now be project_b"
-        );
-        assert_ne!(
-            mw.workspaces()[0].entity_id(),
-            workspace_a_id,
-            "project_a should be gone"
+            keys.len(),
+            2,
+            "should have two keys after adding a second workspace"
         );
+        assert_eq!(*keys[0], key_a);
+        assert_eq!(*keys[1], key_b);
     });
+}
+
+#[gpui::test]
+async fn test_project_group_keys_duplicate_not_added(cx: &mut TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+    let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
+    // A second project entity pointing at the same path produces the same key.
+    let project_a2 = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
 
-    // Add project_c as a second workspace, then replace it with project_d.
-    let workspace_c = multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.test_add_workspace(project_c.clone(), window, cx)
+    let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
+    let key_a2 = project_a2.read_with(cx, |p, cx| p.project_group_key(cx));
+    assert_eq!(key_a, key_a2, "same root path should produce the same key");
+
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(project_a2, window, cx);
     });
 
     multi_workspace.read_with(cx, |mw, _cx| {
-        assert_eq!(mw.workspaces().len(), 2);
-        assert_eq!(mw.active_workspace_index(), 1);
+        let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
+        assert_eq!(
+            keys.len(),
+            1,
+            "duplicate key should not be added when a workspace with the same root is inserted"
+        );
     });
+}
 
-    let workspace_d = multi_workspace.update_in(cx, |mw, window, cx| {
-        let workspace = cx.new(|cx| Workspace::test_new(project_d.clone(), window, cx));
-        mw.replace(workspace.clone(), &*window, cx);
-        workspace
-    });
+#[gpui::test]
+async fn test_project_group_keys_on_worktree_added(cx: &mut TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+    fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
+    let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
+
+    let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
+
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+
+    // Add a second worktree to the same project.
+    let (worktree, _) = project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree("/root_b", true, cx)
+        })
+        .await
+        .unwrap();
+    worktree
+        .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete())
+        .await;
+    cx.run_until_parked();
+
+    let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
+    assert_ne!(
+        initial_key, updated_key,
+        "key should change after adding a worktree"
+    );
 
     multi_workspace.read_with(cx, |mw, _cx| {
-        assert_eq!(mw.workspaces().len(), 2, "should still have 2 workspaces");
-        assert_eq!(mw.active_workspace_index(), 1);
+        let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
         assert_eq!(
-            mw.workspaces()[1].entity_id(),
-            workspace_d.entity_id(),
-            "active slot should now be project_d"
-        );
-        assert_ne!(
-            mw.workspaces()[1].entity_id(),
-            workspace_c.entity_id(),
-            "project_c should be gone"
+            keys.len(),
+            2,
+            "should have both the original and updated key"
         );
+        assert_eq!(*keys[0], initial_key);
+        assert_eq!(*keys[1], updated_key);
     });
+}
 
-    // Replace with workspace_b which is already in the list — should just switch.
-    multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.replace(workspace_b.clone(), &*window, cx);
+#[gpui::test]
+async fn test_project_group_keys_on_worktree_removed(cx: &mut TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+    fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
+    let project = Project::test(fs, ["/root_a".as_ref(), "/root_b".as_ref()], cx).await;
+
+    let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
+
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+
+    // Remove one worktree.
+    let worktree_b_id = project.read_with(cx, |project, cx| {
+        project
+            .worktrees(cx)
+            .find(|wt| wt.read(cx).root_name().as_unix_str() == "root_b")
+            .unwrap()
+            .read(cx)
+            .id()
+    });
+    project.update(cx, |project, cx| {
+        project.remove_worktree(worktree_b_id, cx);
     });
+    cx.run_until_parked();
+
+    let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
+    assert_ne!(
+        initial_key, updated_key,
+        "key should change after removing a worktree"
+    );
 
     multi_workspace.read_with(cx, |mw, _cx| {
+        let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
         assert_eq!(
-            mw.workspaces().len(),
+            keys.len(),
             2,
-            "no workspace should be added or removed"
+            "should accumulate both the original and post-removal key"
         );
+        assert_eq!(*keys[0], initial_key);
+        assert_eq!(*keys[1], updated_key);
+    });
+}
+
+#[gpui::test]
+async fn test_project_group_keys_across_multiple_workspaces_and_worktree_changes(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+    fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
+    fs.insert_tree("/root_c", json!({ "file.txt": "" })).await;
+    let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
+    let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await;
+
+    let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
+    let key_b = project_b.read_with(cx, |p, cx| p.project_group_key(cx));
+
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(project_b, window, cx);
+    });
+
+    multi_workspace.read_with(cx, |mw, _cx| {
+        assert_eq!(mw.project_group_keys().count(), 2);
+    });
+
+    // Now add a worktree to project_a. This should produce a third key.
+    let (worktree, _) = project_a
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree("/root_c", true, cx)
+        })
+        .await
+        .unwrap();
+    worktree
+        .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete())
+        .await;
+    cx.run_until_parked();
+
+    let key_a_updated = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
+    assert_ne!(key_a, key_a_updated);
+
+    multi_workspace.read_with(cx, |mw, _cx| {
+        let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
         assert_eq!(
-            mw.active_workspace_index(),
-            0,
-            "should have switched to workspace_b"
+            keys.len(),
+            3,
+            "should have key_a, key_b, and the updated key_a with root_c"
         );
+        assert_eq!(*keys[0], key_a);
+        assert_eq!(*keys[1], key_b);
+        assert_eq!(*keys[2], key_a_updated);
     });
 }

crates/workspace/src/persistence.rs 🔗

@@ -3993,6 +3993,7 @@ mod tests {
             window_10,
             MultiWorkspaceState {
                 active_workspace_id: Some(WorkspaceId(2)),
+                project_group_keys: vec![],
                 sidebar_open: true,
                 sidebar_state: None,
             },
@@ -4004,6 +4005,7 @@ mod tests {
             window_20,
             MultiWorkspaceState {
                 active_workspace_id: Some(WorkspaceId(3)),
+                project_group_keys: vec![],
                 sidebar_open: false,
                 sidebar_state: None,
             },

crates/workspace/src/persistence/model.rs 🔗

@@ -13,7 +13,7 @@ use db::sqlez::{
 use gpui::{AsyncWindowContext, Entity, WeakEntity, WindowId};
 
 use language::{Toolchain, ToolchainScope};
-use project::{Project, debugger::breakpoint_store::SourceBreakpoint};
+use project::{Project, ProjectGroupKey, debugger::breakpoint_store::SourceBreakpoint};
 use remote::RemoteConnectionOptions;
 use serde::{Deserialize, Serialize};
 use std::{
@@ -21,7 +21,7 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-use util::ResultExt;
+use util::{ResultExt, path_list::SerializedPathList};
 use uuid::Uuid;
 
 #[derive(
@@ -36,7 +36,7 @@ pub(crate) enum RemoteConnectionKind {
     Docker,
 }
 
-#[derive(Debug, PartialEq, Clone)]
+#[derive(Debug, PartialEq, Clone, serde::Serialize, serde::Deserialize)]
 pub enum SerializedWorkspaceLocation {
     Local,
     Remote(RemoteConnectionOptions),
@@ -59,11 +59,30 @@ pub struct SessionWorkspace {
     pub window_id: Option<WindowId>,
 }
 
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct SerializedProjectGroupKey {
+    pub path_list: SerializedPathList,
+    pub(crate) location: SerializedWorkspaceLocation,
+}
+
+impl From<ProjectGroupKey> for SerializedProjectGroupKey {
+    fn from(value: ProjectGroupKey) -> Self {
+        SerializedProjectGroupKey {
+            path_list: value.path_list().serialize(),
+            location: match value.host() {
+                Some(host) => SerializedWorkspaceLocation::Remote(host),
+                None => SerializedWorkspaceLocation::Local,
+            },
+        }
+    }
+}
+
 /// Per-window state for a MultiWorkspace, persisted to KVP.
 #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
 pub struct MultiWorkspaceState {
     pub active_workspace_id: Option<WorkspaceId>,
     pub sidebar_open: bool,
+    pub project_group_keys: Vec<SerializedProjectGroupKey>,
     #[serde(default)]
     pub sidebar_state: Option<String>,
 }

crates/workspace/src/welcome.rs 🔗

@@ -326,7 +326,7 @@ impl WelcomePage {
                     self.workspace
                         .update(cx, |workspace, cx| {
                             workspace
-                                .open_workspace_for_paths(OpenMode::Replace, paths, window, cx)
+                                .open_workspace_for_paths(OpenMode::Activate, paths, window, cx)
                                 .detach_and_log_err(cx);
                         })
                         .log_err();

crates/workspace/src/workspace.rs 🔗

@@ -90,8 +90,8 @@ pub use persistence::{
 };
 use postage::stream::Stream;
 use project::{
-    DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
-    WorktreeSettings,
+    DirectoryLister, Project, ProjectEntryId, ProjectGroupKey, ProjectPath, ResolvedPath, Worktree,
+    WorktreeId, WorktreeSettings,
     debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
     project_settings::ProjectSettings,
     toolchain_store::ToolchainStoreEvent,
@@ -672,7 +672,7 @@ fn prompt_and_open_paths(app_state: Arc<AppState>, options: PathPromptOptions, c
             None,
             None,
             None,
-            OpenMode::Replace,
+            OpenMode::Activate,
             cx,
         );
         cx.spawn(async move |cx| {
@@ -713,7 +713,7 @@ pub fn prompt_for_open_path_and_open(
             if let Some(handle) = multi_workspace_handle {
                 if let Some(task) = handle
                     .update(cx, |multi_workspace, window, cx| {
-                        multi_workspace.open_project(paths, OpenMode::Replace, window, cx)
+                        multi_workspace.open_project(paths, OpenMode::Activate, window, cx)
                     })
                     .log_err()
                 {
@@ -1380,8 +1380,6 @@ pub enum OpenMode {
     /// Add to the window's multi workspace and activate it.
     #[default]
     Activate,
-    /// Replace the currently active workspace, and any of it's linked workspaces
-    Replace,
 }
 
 impl Workspace {
@@ -1921,9 +1919,6 @@ impl Workspace {
                             workspace
                         });
                         match open_mode {
-                            OpenMode::Replace => {
-                                multi_workspace.replace(workspace.clone(), &*window, cx);
-                            }
                             OpenMode::Activate => {
                                 multi_workspace.activate(workspace.clone(), window, cx);
                             }
@@ -2056,6 +2051,10 @@ impl Workspace {
         })
     }
 
+    pub fn project_group_key(&self, cx: &App) -> ProjectGroupKey {
+        self.project.read(cx).project_group_key(cx)
+    }
+
     pub fn weak_handle(&self) -> WeakEntity<Self> {
         self.weak_self.clone()
     }
@@ -3409,7 +3408,7 @@ impl Workspace {
 
         let workspace_is_empty = !is_remote && !has_worktree && !has_dirty_items;
         if workspace_is_empty {
-            open_mode = OpenMode::Replace;
+            open_mode = OpenMode::Activate;
         }
 
         let app_state = self.app_state.clone();